diff --git a/.changeset/combined-native-partial-evidence.md b/.changeset/combined-native-partial-evidence.md new file mode 100644 index 0000000000..1fb892d0de --- /dev/null +++ b/.changeset/combined-native-partial-evidence.md @@ -0,0 +1,7 @@ +--- +'@fluentui-react-native/desktop-test': patch +'@fluentui-react-native/desktop-driver': patch +'@fluentui-react-native/desktop-driver-mcp': patch +--- + +Record partial combined-candidate macOS native qualification evidence. diff --git a/.changeset/desktop-test-resource-ownership.md b/.changeset/desktop-test-resource-ownership.md new file mode 100644 index 0000000000..cc7e95156c --- /dev/null +++ b/.changeset/desktop-test-resource-ownership.md @@ -0,0 +1,6 @@ +--- +'@fluentui-react-native/desktop-test': patch +'@fluentui-react-native/storybook-desktop': patch +--- + +Add typed resource contributions, settled shutdown, and exact fenced workload ownership records. Keep Storybook service waiters abortable so shutdown can drain them. diff --git a/.changeset/desktop-test-stop-reset.md b/.changeset/desktop-test-stop-reset.md new file mode 100644 index 0000000000..04daa8cc7f --- /dev/null +++ b/.changeset/desktop-test-stop-reset.md @@ -0,0 +1,5 @@ +--- +'@fluentui-react-native/desktop-test': patch +--- + +Treat process-control socket resets after an intentional stop as expected shutdown while preserving unexpected transport failures. diff --git a/.changeset/f15-macos-native-qualification.md b/.changeset/f15-macos-native-qualification.md new file mode 100644 index 0000000000..2a2bfff476 --- /dev/null +++ b/.changeset/f15-macos-native-qualification.md @@ -0,0 +1,8 @@ +--- +'@fluentui-react-native/desktop-test': patch +'@fluentui-react-native/desktop-driver': patch +'@fluentui-react-native/desktop-driver-mcp': patch +--- + +Document the provisional macOS native qualification evidence and integrated +descriptor-path requalification procedure. diff --git a/.changeset/f3-process-lifelines.md b/.changeset/f3-process-lifelines.md new file mode 100644 index 0000000000..7ea6aa2e4a --- /dev/null +++ b/.changeset/f3-process-lifelines.md @@ -0,0 +1,10 @@ +--- +'@fluentui-react-native/desktop-test': patch +'@fluentui-react-native/storybook-desktop': patch +--- + +Add cooperative owned Node service parent lifelines, explicit settled disposal, +and per-command correlated typed service readiness. Storybook agent startup no +longer treats a generated ready file as authoritative. Incomplete withdrawal or +stalled disposal stays failed and fenced; native survivor qualification remains +separate. diff --git a/.changeset/f4-story-provider-lifetimes.md b/.changeset/f4-story-provider-lifetimes.md new file mode 100644 index 0000000000..1c3efddb7e --- /dev/null +++ b/.changeset/f4-story-provider-lifetimes.md @@ -0,0 +1,11 @@ +--- +'@fluentui-react-native/desktop-driver': patch +'@fluentui-react-native/storybook-desktop': patch +'@fluentui-react-native/storybook-desktop-runtime': patch +--- + +Add local story-provider lifetime/cancellation context, authenticated connection +epochs, versioned native marker conformance, prompt disconnect failure, and stale +preview mutation rejection. Fence interrupted navigation conservatively and +report renderer-specific bridge/relaunch failures without claiming transparent +upstream reconnect or clearing unknown-effect quarantine. diff --git a/.changeset/f5-exclusive-storybook-ingress.md b/.changeset/f5-exclusive-storybook-ingress.md new file mode 100644 index 0000000000..3a717bd5a5 --- /dev/null +++ b/.changeset/f5-exclusive-storybook-ingress.md @@ -0,0 +1,9 @@ +--- +'@fluentui-react-native/storybook-desktop': patch +'@fluentui-react-native/desktop-driver': patch +--- + +Disable alternate HTTP and WebSocket mutation ingress on agent-served Storybook +channels, dispatch authorized selections only to the authenticated runtime, and +retain explicit protected args refusal. Route manifest-backed smoke traversal +through driver sessions and cover foreign-authority selection and args refusal. diff --git a/.changeset/f5-retained-preview-reference.md b/.changeset/f5-retained-preview-reference.md new file mode 100644 index 0000000000..a825810bcd --- /dev/null +++ b/.changeset/f5-retained-preview-reference.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/desktop-driver": patch +--- + +Prevent reobserving a surviving native preview element from reviving a reference issued before Storybook selection or reset. diff --git a/.changeset/storybook-durable-bridge-epochs.md b/.changeset/storybook-durable-bridge-epochs.md new file mode 100644 index 0000000000..3cacef2318 --- /dev/null +++ b/.changeset/storybook-durable-bridge-epochs.md @@ -0,0 +1,7 @@ +--- +'@fluentui-react-native/storybook-desktop': patch +'@fluentui-react-native/desktop-test': patch +'@fluentui-react-native/desktop-driver': patch +--- + +Persist authenticated Storybook epochs beside settlement records in stable private target storage. Verify the exact live application incarnation before returning renderer-specific relaunch-required after a service restart; initial absence, replaced applications, and indeterminate probes remain bridge-unavailable. diff --git a/.changeset/storybook-reload-replaces-bridge.md b/.changeset/storybook-reload-replaces-bridge.md new file mode 100644 index 0000000000..5e80e41a2e --- /dev/null +++ b/.changeset/storybook-reload-replaces-bridge.md @@ -0,0 +1,8 @@ +--- +'@fluentui-react-native/storybook-desktop': patch +--- + +Retire the old authenticated bridge when a reloaded runtime authenticates its +new socket before the old socket closes. Pending work on the old socket is +rejected with effect `unknown`, and the old socket cannot reclaim the bridge, +instead of the new hello being dropped and selections reaching the stale socket. diff --git a/.changeset/windows-lifeline-sigterm.md b/.changeset/windows-lifeline-sigterm.md new file mode 100644 index 0000000000..d04a25323a --- /dev/null +++ b/.changeset/windows-lifeline-sigterm.md @@ -0,0 +1,11 @@ +--- +'@fluentui-react-native/desktop-test': patch +'@fluentui-react-native/storybook-desktop': patch +--- + +Run installed SIGTERM handlers when a parent lifeline stops a child without +disposers, so Windows children flush their report terminal before exiting. +Give the first manifest-backed smoke story a cold-bundle render budget, wait +for authenticated runtime readiness before authored tests run in a relaunched +smoke `tests` phase, and stop every owned smoke process before reporting a +service cleanup failure. diff --git a/.changeset/windows-native-qualification-repair.md b/.changeset/windows-native-qualification-repair.md new file mode 100644 index 0000000000..03763d5dbb --- /dev/null +++ b/.changeset/windows-native-qualification-repair.md @@ -0,0 +1,10 @@ +--- +'@fluentui-react-native/desktop-test': patch +'@fluentui-react-native/desktop-driver': patch +'@fluentui-react-native/desktop-driver-mcp': patch +--- + +Repair Windows package-boundary validation, tolerate bounded atomic-report +sharing conflicts without hiding persistent failures, and record descriptor-path cleanup, +correlated render faults, recovery, and separate legacy smoke evidence, retaining +earlier failures and partial native coverage. diff --git a/.changeset/windows-relaunch-awaiting-render.md b/.changeset/windows-relaunch-awaiting-render.md new file mode 100644 index 0000000000..fbfab4f932 --- /dev/null +++ b/.changeset/windows-relaunch-awaiting-render.md @@ -0,0 +1,7 @@ +--- +'@fluentui-react-native/storybook-desktop': patch +--- + +Report `bridge-unavailable`, not `relaunch-required`, while a relaunched +runtime that has said hello awaits its initial `storyRendered`, so manifest +smoke readiness waits keep retrying instead of failing before any dispatch. diff --git a/.changeset/windows-smoke-initial-lease.md b/.changeset/windows-smoke-initial-lease.md new file mode 100644 index 0000000000..f9db295338 --- /dev/null +++ b/.changeset/windows-smoke-initial-lease.md @@ -0,0 +1,5 @@ +--- +'@fluentui-react-native/storybook-desktop': patch +--- + +Write the Windows Storybook application lease before the manifest-backed stories phase, and release the traversal session before authored tests create their own session for the target. diff --git a/.changeset/windows-win32-native-qualification.md b/.changeset/windows-win32-native-qualification.md new file mode 100644 index 0000000000..b561b2fce0 --- /dev/null +++ b/.changeset/windows-win32-native-qualification.md @@ -0,0 +1,8 @@ +--- +'@fluentui-react-native/desktop-test': patch +'@fluentui-react-native/desktop-driver': patch +'@fluentui-react-native/desktop-driver-mcp': patch +--- + +Record Windows Fabric and Win32 Paper native qualification results in the +status ledgers and add Windows parameters to the native qualification runbook. diff --git a/packages/agentic/design/CHANGELOG.md b/packages/agentic/design/CHANGELOG.md index 9d50816a47..8bddd18ab6 100644 --- a/packages/agentic/design/CHANGELOG.md +++ b/packages/agentic/design/CHANGELOG.md @@ -25,9 +25,9 @@ color scheme. Elevated appearances resolve by color scheme for now. - 06b458f: Rework Flex tokens around the current x3 defaults. Legacy `ThemeReference` themes, `ThemeProvider`, `useTheme`, and Flex tokens projected from legacy themes keep their values. - + Breaking changes for Flex consumers and theme authors: - + - Every Flex semantic color is a resolved `InteractiveColor` with `rest`, `hover`, and `pressed`. Read `tokens.color.x.rest` instead of `tokens.color.x`, and `.hover` or `.pressed` instead of the removed `color.hover` and `color.pressed` maps. Omitted interactive states derive lazily from rest with no configuration. @@ -45,7 +45,7 @@ - The `color-lib` interaction functions no longer take a token; their `variant` option is replaced by `colorTone`, `direction`, and `compositing`. `isInverseInteractionToken` moves from `color-lib` to the package root, alongside the new `INTERACTIVE_COLOR_TOKENS` and `isInteractiveColorToken`. - + Root `useThemeState` consumers now bundle the interaction algorithm and generated defaults, about 23 KB raw and 8 KB gzip in the measured scenarios. Package sources move under `src/flex`, `src/fluent`, `src/types`, and `src/test`; export keys and conditions are unchanged. diff --git a/packages/agentic/desktop-driver-mcp/references/current-status.md b/packages/agentic/desktop-driver-mcp/references/current-status.md index 6a63eb39cd..93a9a68c27 100644 --- a/packages/agentic/desktop-driver-mcp/references/current-status.md +++ b/packages/agentic/desktop-driver-mcp/references/current-status.md @@ -84,3 +84,185 @@ physical Ctrl+C and interactive input/capture cases where supported. Current fake-host tests validate portable controller semantics, not native ownership, signing, TCC, Windows ACLs, DPI, or process containment. This requalification is tracked in [#4441](https://github.com/microsoft/fluentui-react-native/issues/4441). + +## Provisional F15 native endpoint records — 2026-11-06 + +**Exact source revision:** `388a60cc803ab78202a6f64f92e3f53b779c5c39`. +**App:** Agentic Components Storybook / ReactTestApp (configured bundle ID +`com.microsoft.fluentui.agenticstorybook`). +**OS:** macOS 27.2, arm64. +The reusable procedure is +`packages/agentic/desktop-test/references/native-qualification.md`. + +These rows document the native smoke environment only. The smoke used the +non-agent WDIO path; no MCP descriptor was consumed, so these rows are not +descriptor-path passes. + +| Renderer | Command and selection | Passed / failed / skipped / unsupported | Cleanup | Artifact | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| macOS Fabric | `STORYBOOK_SMOKE_STORY=components-button--default yarn storybook smoke --macos --mode stories-and-tests`; 211 selected/executed | 211 / 0 / 0 / 0 | No owned processes or listeners on 21338/30029/45382; `interactive.lock` absent | `apps/storybook/artifacts/desktop/0f99740f-631b-444a-a705-76a9b4a761b7/run.json` | +| macOS Paper | `STORYBOOK_SMOKE_STORY=components-button--default yarn storybook smoke --macos --paper --mode stories-and-tests`; 211 selected/executed | 211 / 0 / 0 / 0 | No owned processes or listeners on 21338/30029/45382; `interactive.lock` absent | `apps/storybook/artifacts/desktop/e82a83c2-a01d-41c0-b308-2551630e8b1e/run.json` | +| Windows Fabric descriptor path | Not run — Windows is unavailable on this macOS-only machine | Not run; 0 selected | Not applicable | None | +| Win32 Paper descriptor path | Not run — Win32 is unavailable on this macOS-only machine | Not run; 0 selected | Not applicable | None | + +| macOS descriptor-path renderer | Command / selection | Passed / failed / skipped / unsupported | Cleanup | Artifact | +| ------------------------------ | ---------------------------------------------- | --------------------------------------- | -------------------------------------------- | -------- | +| Fabric | Not run; no descriptor consumed; selection N/A | 0 / 0 / 0 / 0; Not run | N/A for descriptor path; smoke cleanup above | None | +| Paper | Not run; no descriptor consumed; selection N/A | 0 / 0 / 0 / 0; Not run | N/A for descriptor path; smoke cleanup above | None | + +Real-descriptor lifecycle, grant revocation, ambiguous create, resource +invalidation, protected args, quarantine/recovery, F2/F3/F4/F5 cases, and MCP +discovery/text/latency measurements remain **Not run** on this revision. Do not +attribute the WDIO callback counts to MCP. The initial Fabric smoke attempt +failed before selection because the generated CocoaPods sandbox was out of +sync; supported `prep` resolved it before the passing run. The story +environment selector did not constrain callback selection; the artifacts show +211 selected. + +## 2026-11-06 partial descriptor-path evidence at `3c780cfd1bccaf510ece080e4be2b516c645989a` + +App: Agentic Components Storybook / ReactTestApp, +`com.microsoft.fluentui.agenticstorybook`; macOS 27.2 arm64. The MCP SDK stdio +client used the default `agent-connection.json` descriptor against the real +native helper. The exact inline client launch command and connection/session +identifiers were not retained. No descriptor grants or story-args values are +recorded here. Manual MCP operations are not suite selections; the companion +driver `run.json` files each report 0 selected/0 executed tests. + +| Renderer | Tool/operation evidence | Result | Artifact / limitation | +| -------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Fabric | `desktop_session.status/open/close`; `desktop_story.story` select/reset; `desktop_observe` find/observe; `desktop_capture.capture` | Discovery showed attach-only. Select and reset were applied with marker verification (regions 1 then 2). A separate preview-child reference was rejected with `stale element reference`, reason `ref-region-changed`. Protected args returned `unsupported operation` / `story-args-unsupported`; an intermediate unchanged-story assertion was false, the final assertion was true, and no probe value appeared in error or journal. | Private `fabric-launch-mode-discovery.json`, `fabric-mcp-markers-resources.json`, `fabric-mcp-observe-resource-invalidation.json`, `fabric-protected-args-retry.json`, `fabric-protected-args-final.json`. | +| Fabric | Anonymous/foreign-authority HTTP select, prepare, args; foreign WebSocket; idle channel | HTTP returned 403 for all three mutation classes and native preview stayed unchanged. The first two foreign-WS assertions failed; the final attempt passed with no observer-message delta. The channel remained open after 21,003 ms idle and marker verification succeeded. | Private `fabric-f5-ingress-bypass.json`, `fabric-f5-ingress-bypass-retry.json`, `fabric-f5-ingress-bypass-final.json`, `fabric-f5-channel-idle.json`, `fabric-mcp-live-selection.json`. | +| Paper | `desktop_session.status/open/close`; `desktop_story.story` select/reset; protected args; bypass and idle-channel checks | Attach-only discovery, marker-settled selection/reset, protected args refusal, revocation/drain, bypass checks, and 21-second channel lifetime were reported. A protected preview reference was reported rejected after reset, but handle kind/generation and exact operation-level fields were not retained; classify that invalidation as Partial. | Private Paper driver/MCP logs in `f15-native-qualification-3c780cfd-20261106T0200/`; Paper run metadata: `apps/storybook/artifacts/desktop/a58d2fdd-5584-47f4-8747-5307607b4c0d/run.json`. | + +The initial Fabric observation that a pre-reset reference was accepted after a +later observation remains **Inconclusive**: its handle kind (preview element, +MCP PNG resource, or chrome/window reference), stable identity relationship, +and explicit session continuity were not retained. Do not count an immutable +PNG resource or chrome/window reference surviving reset as a failure. The +separate retained preview-child stale-element rejection is expected behavior, +but does not prove the F5 same-native-ID-after-fresh-find seam. No additional +probe of the inconclusive sequence was run. The later F5 fix at +`2d8e8db001b64ebea85f0d6dce141f040fb5182d` is not part of this candidate; the +original observation is not attributed to that defect. + +No aggregate MCP call count was retained. Dispose-during-open, failed delete, +ambiguous create, story quarantine/recovery, failed-cleanup claim retention, +F4 cancellation/readiness/render/lost-context/secondary-window cases, F3 +failure-survivor injection, F5 legacy separation, and exact manifest-smoke +single-session/no-HTTP-fallback evidence remain Not run. Windows Fabric and +Win32 Paper are Not run on this macOS-only host. At handoff, the default +descriptor and workload lock were absent and the Storybook, Metro, and driver +ports had no listeners. The Fabric cleanup record reports owner exit, +descriptor removal, grant revocation, and attached-app preservation. Paper +cleanup was reported, but its process IDs were not retained for independent +PID-level confirmation. + +## 2026-11-06 combined-candidate descriptor evidence at `a19fd3e1e7b3f1f443a241b1571cb029162f4ef2` + +App: Agentic Components Storybook / ReactTestApp, +`com.microsoft.fluentui.agenticstorybook`; macOS 27.2 arm64. The helper was +rebuilt for this exact source and the fresh nonprompting permission preflight +reported Accessibility, PostEvent, and ScreenCapture granted. The MCP SDK +stdio client consumed the default private `agent-connection.json`; no +descriptor grants, private descriptor data, or story-args values are retained. +The descriptor-path commands were `yarn storybook driver --macos --agent` +and `yarn storybook driver --macos --paper --agent`, paired with +`yarn storybook run --macos` and `yarn storybook run --macos --paper`. +Manual MCP operation sequences are not suite selections; companion driver +run.json counts were 0 selected/0 executed. + +| Renderer | Sanitized operation sequence and application outcomes | Result and locator | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Fabric | `desktop_session.status` → `desktop_session.open(launch:false)` → `desktop_story.story(select)` → `desktop_observe.find` → authorized `desktop_story.story(select)` → `desktop_observe.observe(within)` → authorized `desktop_story.story(reset)` → old `observe(within)` → fresh `find` → fresh `observe(within)` → old `observe(within)` → `desktop_session.close`. | Passed. Same driver session/incarnation 1 throughout; preview-element aliases `element-ref-1` and `element-ref-2` were distinct. Select marker settled at region 2 and reset at region 3. Old ref returned `stale element reference` / `ref-region-changed` immediately after select and reset and after fresh find; fresh ref worked. Private sanitized record: `fabric-reference-sequence-retry.json`. | +| Paper | Same operation sequence as Fabric. | Passed with the same identity, marker, region, stale-old-ref, and fresh-ref outcomes. Private sanitized record: `paper-reference-sequence.json`. | +| Fabric idle channel | `desktop_session.status` → `open` → idle 21,002 ms → `status` → `close`. | Passed; status and close succeeded after idle. Private record: `fabric-idle-channel-21s.json`. No equivalent Paper interval on this SHA. | +| Fabric same-port replacement | Old client: `desktop_session.status` → `open` → `desktop_observe.find` (0 matches) → service stopped/replaced on the same ports → old client `desktop_session.status` → `desktop_session.close`. | Partial (recorded Failed at qualification; reclassified on contract review): the first post-replacement status returned `mcpIsError:true`, `error:"adapter"`, `reason:"service-unavailable"`, the designed fail-closed rejection that the same-port contract asserts before the next call re-verifies discovery; close then succeeded. Close is a no-op once invalid authority forgets the session, so native rediscovery was not demonstrated. No old-reference operation was possible because find returned 0 matches, and no mutation was sent. Sanitized record: `fabric-same-port-replacement-final.json`. | +| Fabric physical input | `desktop_act` physical click was sent once after one corrected request shape. | Partial/inconclusive: MCP returned success and sequence completed, but the client parser did not retain `result.steps[0].act` mechanism/disposition. Not a physical-input pass and not repeated. Private record: `fabric-physical-input-retry-corrected.json`. | + +Separate non-agent manifest-backed smoke passed 211/211 callbacks in both +renderers, with 0 failed, skipped, unsupported, incomplete, retries, or flaky +results. These runs are the legacy lane, not protected-ingress evidence, and +the recorded artifacts do not establish the requested one-session/no-HTTP- +fallback invariant. Fabric artifact: +`apps/storybook/artifacts/desktop/a2475e8d-7ea9-465f-8461-b8f115cfcee5/run.json`; +Paper artifact: +`apps/storybook/artifacts/desktop/87f0452f-5fd7-48eb-8494-a63202842521/run.json`. + +The initial Fabric service replacement attempt encountered a retained +`interactive.lock`; it was not removed until its exact recorded owner exited +and the lock showed no resources or unresolved effects. A subsequent launch +attempt was correctly refused while that claim remained; after verified +cleanup, the replacement service started normally. Final cleanup verified +owned app and service PIDs exited, ports 21338/30029/45382 had no listeners, +the descriptor and lock were absent, and no external app was stopped. +Anonymous/foreign HTTP and WS bypass, protected args refusal, grant revocation, +dispose-during-open, failed delete, ambiguous create, resource invalidation +beyond preview elements, quarantine/recovery, F3/F4 injections, and Paper +same-port/idle/physical-input cases are Not run on this exact SHA. + +## 2026-11-06 Windows Fabric and Win32 Paper descriptor evidence + +Source: the PR branch at `1f5333e25` plus `8458bde48` and `a6b89e924`; host +Windows 11 Enterprise build 26300, AMD64. Commands: +`yarn storybook driver --windows|--win32 --agent`, then +`yarn storybook run --windows|--win32`; the MCP SDK stdio client consumed the +default private `agent-connection.json`. No grants, descriptor data, or story +args values are retained. Manual MCP calls are not suite selections. The full +matrix (Windows Fabric 15 Pass, 1 Partial, 1 Failed, 7 Not run; Win32 Paper +15 Pass, 2 Partial, 7 Not run), run records, and cleanup are in the "Windows +Fabric and Win32 Paper qualification — 2026-11-06" section of +`packages/agentic/desktop-test/references/current-status.md`. +These rows supersede the macOS-host "Not run" Windows rows above for the +Windows host only. + +| Area | Windows Fabric and Win32 Paper outcome | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Same-port replacement | Pass on both. The old client's status returned `service-unavailable` and its refs returned `no-session`; a later call rediscovered the new service, and close and a new open succeeded. The first select after replacement returned `relaunch-required` (rejected, effect none). No old mutation replayed. | +| Authority faults | Pass on both for grant revocation, dispose during open, failed delete, and ambiguous create (`session-uncertain` until the grant was revoked). Windows grant revocation and failed delete each passed on a rerun; the first attempts are retained in the desktop-test ledger. | +| Resources and refs | Pass on both. Preview element refs became stale after reset and after the next select; chrome/window refs kept their identity; the immutable MCP PNG resource stayed readable after reset with a matching digest. One session and incarnation 1 throughout each sequence. | +| Quarantine and recovery | Pass on both. An interrupted select returned `story-not-ready` (indeterminate, quarantined); later mutations in the same session returned `target-quarantined` while reads worked. A new session's exit probe lifted the quarantine, and select and reset applied with marker verification. | +| Protected args | Pass on both: `story-args-unsupported`, with no value in the response. | +| F4 cancellation | Pass on both. After a client abort at 150 or 40 ms, replaying the request ID returned the recorded applied result, and the next mutation applied in the same session. | +| F5 ingress and lifetime | Pass on both. Unauthenticated HTTP returned 403, an anonymous WebSocket received only the hello request, and the session stayed usable after about 21 s idle. | +| Not run | Failed-cleanup claim retention, live cleanup and restart, F4 readiness/render faults, F4 secondary windows, F3 survivors, F5 manifest smoke, and F5 legacy separation. No MCP discovery-size or latency measurements were recorded on Windows. | + +## 2026-11-07 UTC Windows follow-up + +Source `386f0585edae2450ab22e6d359e2fe4db3399555`, plus validation, +atomic-report repairs, and guidance. The seven Not run rows above were exercised; the +additive desktop-test ledger now records Windows 21 Pass, 2 Partial, 1 Fail +and Win32 21 Pass, 3 Partial. Full run IDs, ref kinds/identities/generations, +failure history, and operator cleanup proof are in +`packages/agentic/desktop-test/references/current-status.md`. + +The real SDK stdio client continued to use only the default private +descriptor and native-verified service. Delayed readiness rejected a stale +correlation; correlated render faults quarantined the same session while +chrome reads remained usable. Old preview refs expired. No fresh preview +root was exposed after those errors, so this does not fill Win32's +fresh-preview fence gap. Secondary Callout probes were Partial: no separately +registered secondary target was selected. + +Exact failed-cleanup and startup-survivor claims blocked replacement until +the original stop callbacks settled. After exact operator reconciliation, new +descriptor-backed services selected/reset with marker verification in +Windows session `fbbdadc4-aa0e-45a9-9862-124052bc5e4e` and Win32 session +`0245de0c-c004-4c76-865d-7458b62f999f`. Normal shutdown removed discovery, +revoked authority, freed ports/resources, and preserved attached apps. + +Manifest smoke/legacy traces were separate **unprotected** workloads. They +used no descriptor or grant and provide no additional protected-ingress +evidence. The initial local probe's cached `bridge-unavailable` loop was a +request-ID reuse error: the corrected client issued a new ID only after +rejected/effect-none proof, never after an indeterminate mutation. Earlier +failed attempts and the local control-file crash remain recorded. + +The final separate non-agent smoke runs passed: Windows +`1825d9aa-2c25-4a2c-8524-ceadcf52ad68` (234 passed, 78 skipped) and +Win32 `8c6f58e1-3591-4f1b-b008-c52a718d3ca2` (214 passed, 52 skipped). +Windows recovered one atomic-report sharing conflict with a journal warning; +Win32 needed none. All sessions closed and cleanup removed owned processes, +listeners, and the workload claim; no default descriptor remained. These +runs issued no descriptor grant and do not change the descriptor matrix's +Partial or Fail outcomes. diff --git a/packages/agentic/desktop-driver-mcp/src/server.test.ts b/packages/agentic/desktop-driver-mcp/src/server.test.ts index 10c4e5b3fd..f5eb7fd538 100644 --- a/packages/agentic/desktop-driver-mcp/src/server.test.ts +++ b/packages/agentic/desktop-driver-mcp/src/server.test.ts @@ -345,6 +345,8 @@ describe('desktop-driver-mcp', () => { schemaVersion: 1, }; const harness = await createDesktopDriverStoryHarness(manifest, { features }); + // Model a preview container whose native identity survives both selection and reset. + jest.spyOn(harness.host, 'resetPreview').mockImplementation(() => {}); const client = await connectClient(async () => testConnection(harness)); try { await open(client, harness); @@ -372,6 +374,33 @@ describe('desktop-driver-mcp', () => { error: 'stale element reference', reason: 'ref-region-changed', }); + const current = json(await call(client, 'desktop_observe', { find: { automationId: 'button-primary' } })) as { + result: { matches: { ref: string }[] }; + }; + expect(json(await call(client, 'desktop_observe', { observe: { within: found.result.matches[0].ref } }))).toMatchObject({ + error: 'stale element reference', + }); + expect( + json( + await call(client, 'desktop_story', { + requestId: 'reset-1', + story: { action: 'reset', storyId: 'components-checkbox--default' }, + }), + ), + ).toMatchObject({ + operation: 'story', + result: { disposition: 'applied', verification: 'marker' }, + }); + expect(json(await call(client, 'desktop_observe', { observe: { within: current.result.matches[0].ref } }))).toMatchObject({ + error: 'stale element reference', + reason: 'ref-region-changed', + }); + await call(client, 'desktop_observe', { find: { automationId: 'button-primary' } }); + for (const ref of [found.result.matches[0].ref, current.result.matches[0].ref]) { + expect(json(await call(client, 'desktop_observe', { observe: { within: ref } }))).toMatchObject({ + error: 'stale element reference', + }); + } for (const args of [ { list: {}, story: { storyId: 'components-button--default' } }, { list: {}, requestId: 'r' }, diff --git a/packages/agentic/desktop-driver/README.md b/packages/agentic/desktop-driver/README.md index 484c27638b..b4c44be114 100644 --- a/packages/agentic/desktop-driver/README.md +++ b/packages/agentic/desktop-driver/README.md @@ -16,6 +16,15 @@ Native. implemented capabilities, incomplete lifecycle guarantees, endpoint qualification, and measured token/latency history. +Story providers use the existing `/storybook` subpath for `StoryProviderContext`, +`StoryBridgeUnavailableError`, and the versioned marker verifier. Built-in routes +pass the local lifetime ID and abort signal separately from serialized inputs. +Preview-scoped mutations recheck provider/native context; chrome and live +secondary-window references do not expire with the preview. Unknown navigation +effects still quarantine: a replacement bridge epoch is not recovery evidence. +After losing a verified context, preview mutations remain unavailable until +a new verified selection; finding fresh elements alone cannot restore readiness. + ## Supported targets | Endpoint | Operating system | Architecture | Native implementation | diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 1e7f7b0ad6..a08eb0a78e 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -7,7 +7,7 @@ owner_paths: # Current capability and qualification status -**Work in progress, updated 2026-11-05.** "Done" below means the stated +**Work in progress, updated 2026-11-06.** "Done" below means the stated implementation exists, not that every native endpoint is qualified or that the complete orchestration product is ready for submission. Historical measurements remain tied to their original revision; they were not rerun by this update. @@ -19,26 +19,26 @@ and its sub-issues, which record dependencies and parallel waves. ## Capabilities -| Capability | State | Boundary or remaining work | -| ---------------------------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| W3C service and typed/WDIO clients | Done | Registered targets only; native browser-inapplicable commands explicitly unsupported. | -| Application-neutral driver | Done | No Storybook, React, or React Native dependency; package-boundary tests reject forbidden imports/dependencies. Optional story provider is injected. | -| Native sessions and command/input queues | Done | One reservation per registered target in one service. Cross-service exact-application claims remain work in progress. | -| Verified private discovery and grants | Done | Native identity verified before credential transmission; all protected driver routes authenticate. Does not protect the separate Storybook channel. | -| Attenuated connection principals and permission scopes | Done in deterministic contracts | Driver/workload/recovery scopes are distinct; fixed driver-only HTTP delegation, target/scope subsets, synchronous subtree fencing and awaitable/retryable drain. Native descriptor-path requalification not run for this change. | -| Mutation effects, settlement, quarantine | Done | Native mutations settle before advancing. Uncertain story changes now quarantine instead of assuming a later render is safe. | -| Durable settlement storage and trusted recovery API | Done | Generic/Storybook native paths share the harness factory's private storage and exact-incarnation probes. Direct library embedders must pass `recovery`; restart is not proof. | -| Bounded observation, references, paging, deltas | Done | Provider capability/budget checks, explicit completeness and truncation. Reference generation is session-local; reopen after application replacement. | -| Find, action, check, sequence | Done | Up to eight sequence steps; request-id outcomes prevent replay within their retained session history. No replay across service replacement. | -| Capture and artifact pages | Done | Session-bound bounded artifacts and hashes; pixels are opt-in. Screenshots are evidence, not visual comparison. | -| Named accessibility actions | Done in agent operations | WDIO can list actions but has no corresponding named-action invocation command. | -| Story catalog/select/reset | Done, experimental integration | Catalog-only IDs, correlated readiness and marker verification. Delayed/unknown failures fence mutations. | -| Story args on protected driver | Intentionally unsupported | `story-args-unsupported`; legacy unprotected args remain separate. | -| Exclusive Storybook control | Work in progress | Channel selection/prepare ingress can bypass driver authority; out-of-band args/context invalidation is incomplete. | -| Supervisor crash containment and automatic reclaim | Work in progress outside driver | Exact ownership ledger, surviving-resource reconciliation, and lifelines belong to desktop-test. | -| Application-exit/reconnect continuity | Work in progress | No transparent app/session or runtime bridge restoration. Events are advisory, not proof of recovery. | -| Recording/replay export, event-tail API, visual comparison | Work in progress/deferred | No agent-session-to-WDIO export; window-events still returns its full bounded ring. | -| Intel macOS and distributed/remote control | Unsupported | Current macOS helper is arm64; service is loopback-only. | +| Capability | State | Boundary or remaining work | +| ---------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| W3C service and typed/WDIO clients | Done | Registered targets only; native browser-inapplicable commands explicitly unsupported. | +| Application-neutral driver | Done | No Storybook, React, or React Native dependency; package-boundary tests reject forbidden imports/dependencies. Optional story provider is injected. | +| Native sessions and command/input queues | Done | One reservation per registered target in one service. Cross-service exact-application claims remain work in progress. | +| Verified private discovery and grants | Done | Native identity verified before credential transmission; all protected driver routes authenticate. Storybook agent manifests additionally disable alternate channel mutations in the integration adapter. | +| Attenuated connection principals and permission scopes | Done in deterministic contracts | Driver/workload/recovery scopes are distinct; fixed driver-only HTTP delegation, target/scope subsets, synchronous subtree fencing and awaitable/retryable drain. Native descriptor-path requalification not run for this change. | +| Mutation effects, settlement, quarantine | Done | Native mutations settle before advancing. Uncertain story changes now quarantine instead of assuming a later render is safe. | +| Durable settlement storage and trusted recovery API | Done | Generic/Storybook native paths share the harness factory's private storage and exact-incarnation probes. Direct library embedders must pass `recovery`; restart is not proof. | +| Bounded observation, references, paging, deltas | Done | Provider capability/budget checks, explicit completeness and truncation. Reference generation is session-local; reopen after application replacement. | +| Find, action, check, sequence | Done | Up to eight sequence steps; request-id outcomes prevent replay within their retained session history. No replay across service replacement. | +| Capture and artifact pages | Done | Session-bound bounded artifacts and hashes; pixels are opt-in. Screenshots are evidence, not visual comparison. | +| Named accessibility actions | Done in agent operations | WDIO can list actions but has no corresponding named-action invocation command. | +| Story catalog/select/reset | Done, experimental integration | Local lifetime/signal context, authenticated epochs persisted per native-verified app incarnation, versioned marker conformance, prompt disconnect failure, and stale preview rejection. Bounded macOS Paper/Fabric marker and stale-reference checks passed at `6fca14788`; native cancellation/fault injection remain unqualified. Unknown navigation effects still quarantine. | +| Story args on protected driver | Intentionally unsupported | `story-args-unsupported`, including the session-owning grant and controlled orchestrator. HTTP and foreign WebSocket args are refused/not relayed without logging values; legacy unprotected args remain separate. | +| Exclusive Storybook control | Work in progress: native qualification | Deterministic endpoint evidence: controlled HTTP mutations return 403; foreign WS selection/prepare/args never reach the runtime; authenticated driver prepare/select uses F4 settlement and fencing. Non-agent channel remains legacy unprotected. Native endpoint evidence not yet run: macOS Paper/Fabric, Windows Fabric/Win32 Paper. Preview context checks preserve chrome/secondary references. | +| Supervisor crash containment and automatic reclaim | Work in progress outside driver | Exact ownership ledger, surviving-resource reconciliation, and lifelines belong to desktop-test. | +| Application-exit/reconnect continuity | Work in progress | Bounded macOS Paper/Fabric full-service restart checks passed at `6fca14788`: private authenticated-epoch record plus exact live-incarnation proof yields renderer-specific relaunch-required, effect none. Initial absence and exited/replaced apps stay bridge-unavailable; failed/unknown probes are contract-tested only. No transparent restoration or quarantine recovery; inspected RN Storybook 10.5.4 / Storybook 10.6.3 transport does not reconnect. | +| Recording/replay export, event-tail API, visual comparison | Work in progress/deferred | No agent-session-to-WDIO export; window-events still returns its full bounded ring. | +| Intel macOS and distributed/remote control | Unsupported | Current macOS helper is arm64; service is loopback-only. | The approved direction is a stronger `desktop-test` workload host, with native authority here and MCP as a client. No coordinator package or daemon is being @@ -46,14 +46,14 @@ added. Management/run schemas stay in the harness, not this package. ## Native evidence and full-submission gates -| Lane | Recorded evidence through 2026-11-05 | Still required before full submission | -| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| macOS Fabric | M3 real-app observation and MCP story path at `41fc7473f`; helper self-tests and historical Calculator operation evidence | Requalify changed story settlement/lifetime behavior live; runtime reconnect, supervisor crash/cleanup, generic AppKit fixture, complete reliability window. | -| macOS Paper | Hosted build/smoke evidence exists | Separate live agent/lifetime qualification; do not infer it from Fabric or hosted smoke. | -| Windows Fabric | M3 real-app observation and story MCP path recorded at `600235c35` | Same-branch lifetime/restart and failure gates; 200%/mixed DPI, minimized/negative-origin windows, live lock/RDP/elevation, popups, capture cancellation. | -| Win32 Paper | M3/agent path recorded; native fixture physical input/capture at 100% and 150% | Interactive rerun of current native contracts; physical Ctrl+C, mixed DPI and failure paths. Hosted input/capture skips are not passes. | -| Generic native applications | macOS Calculator and plain Win32 fixture evidence recorded | AppKit fixture and integrated ownership/recovery parity; a probe is not a real-app lifecycle qualification. | -| This update's portable changes | Deterministic fake-host MCP, authority, and story regression coverage; the Windows oversized-tree fallback passed hosted CI | CI and matching-OS live evidence are distinct. The oversized-tree fallback is compiled and contract-tested only on hosted CI; no live Windows qualification. | +| Lane | Recorded evidence through 2026-11-06 | Still required before full submission | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| macOS Fabric | M3 evidence at `41fc7473f`; bounded F4 marker, stale-preview/chrome and authenticated-incarnation restart checks at `6fca14788` | Native cancellation/fault injection, fresh preview after lost context, secondary windows, physical input, supervisor crash, generic AppKit fixture and reliability window. | +| macOS Paper | Hosted build/smoke evidence; separate bounded F4 marker, stale-preview/chrome and authenticated-incarnation restart checks at `6fca14788` | Native cancellation/fault injection, fresh preview after lost context, secondary windows, physical input and complete lifecycle/reliability qualification. | +| Windows Fabric | M3 real-app observation and story MCP path recorded at `600235c35` | Same-branch lifetime/restart and failure gates; 200%/mixed DPI, minimized/negative-origin windows, live lock/RDP/elevation, popups, capture cancellation. | +| Win32 Paper | M3/agent path recorded; native fixture physical input/capture at 100% and 150% | Interactive rerun of current native contracts; physical Ctrl+C, mixed DPI and failure paths. Hosted input/capture skips are not passes. | +| Generic native applications | macOS Calculator and plain Win32 fixture evidence recorded | AppKit fixture and integrated ownership/recovery parity; a probe is not a real-app lifecycle qualification. | +| This update's portable changes | Deterministic fake-host MCP, authority, and story regression coverage; the Windows oversized-tree fallback passed hosted CI | CI and matching-OS live evidence are distinct. The oversized-tree fallback is compiled and contract-tested only on hosted CI; no live Windows qualification. | Hosted Windows policy disables physical input and can reject capture with `0x80070057`. Capability-gated skips must stay explicit. `activate` succeeding @@ -63,6 +63,98 @@ Live requalification of these lanes is tracked in [#4441](https://github.com/microsoft/fluentui-react-native/issues/4441), and the repeated-run support matrix in [#4451](https://github.com/microsoft/fluentui-react-native/issues/4451). +The parameterized procedure is +`packages/agentic/desktop-test/references/native-qualification.md`. + +### Provisional F15 macOS evidence — 2026-11-06 + +**Provisional; exact source revision `388a60cc803ab78202a6f64f92e3f53b779c5c39`.** +App: Agentic Components Storybook / ReactTestApp (configured bundle ID +`com.microsoft.fluentui.agenticstorybook`). OS: macOS 27.2, arm64. +The helper was explicitly built and the noninteractive doctor reported +Accessibility, PostEvent, and ScreenCapture preflight `true`, console unlocked, +ad-hoc signing, and designated requirement +`cdhash H"0d2e7a81dfd4695b7fb84aa1b3242f5e1773e8b4"`. + +| Renderer | Command and actual selection | Result | Cleanup | Artifact | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| macOS Fabric | `STORYBOOK_SMOKE_STORY=components-button--default yarn storybook smoke --macos --mode stories-and-tests`; 211 selected/executed | Passed 211, failed 0, skipped 0, unsupported 0; retries 0 | No owned app/driver/channel/Metro process; ports 21338/30029/45382 had no listeners; `interactive.lock` absent | `apps/storybook/artifacts/desktop/0f99740f-631b-444a-a705-76a9b4a761b7/run.json` | +| macOS Paper | `STORYBOOK_SMOKE_STORY=components-button--default yarn storybook smoke --macos --paper --mode stories-and-tests`; 211 selected/executed | Passed 211, failed 0, skipped 0, unsupported 0; retries 0 | No owned app/driver/channel/Metro process; ports 21338/30029/45382 had no listeners; `interactive.lock` absent | `apps/storybook/artifacts/desktop/e82a83c2-a01d-41c0-b308-2551630e8b1e/run.json` | +| Windows Fabric | Not run — this machine is macOS-only | Not run; 0 selected | Not applicable | None | +| Win32 Paper | Not run — this machine is macOS-only | Not run; 0 selected | Not applicable | None | + +| Descriptor-path renderer | Command / selection | Passed / failed / skipped / unsupported | Cleanup | Artifact | +| ------------------------ | --------------------------------------------------- | --------------------------------------- | -------------------------------------------- | -------- | +| macOS Fabric | Not run; no real descriptor consumed; selection N/A | 0 / 0 / 0 / 0; Not run | N/A for descriptor path; smoke cleanup above | None | +| macOS Paper | Not run; no real descriptor consumed; selection N/A | 0 / 0 / 0 / 0; Not run | N/A for descriptor path; smoke cleanup above | None | + +The `STORYBOOK_SMOKE_STORY` value did not constrain the callbacks in these +runs; `run.json` recorded 211 selected. An initial Fabric attempt failed before +test selection because CocoaPods was out of sync. `yarn storybook prep --macos` +resolved that environment prerequisite; the failed attempt is retained at +`apps/storybook/artifacts/desktop/2e42a0aa-506d-4edb-a3ca-32e5b7c26b04/run.json`. +These smoke runs used the non-agent WDIO path. They do **not** qualify the MCP +descriptor lifecycle, native story quarantine/recovery, F2/F3/F4/F5 integration, +or physical-input/capture requirements. The descriptor rows are not run on +this baseline revision; update them on the combined integration SHA after the +real descriptor-path pass. + +### 2026-11-06: bounded F4 restart qualification + +Source revision `6fca1478871f41ec884fb4bd2cff66cff8197833` includes mainline +`cd0ede21489c9ab8cb76fea07c4dec2e9699389f` through merge +`35057966aeb20b70756af4e83c58c3242675aa72`. macOS arm64 Paper and Fabric +were run separately. These are bounded acceptance checks, not full smoke or +reliability passes. + +**Passed:** the nonprompting preflight +`FURN_DESKTOP_DRIVER_BUILD_POLICY=never yarn desktop-driver doctor --platform macos --permissions` +reported AX, PostEvent and screenCapture all `true`. The cached helper retained +cdhash `0d2e7a81dfd4695b7fb84aa1b3242f5e1773e8b4`; no helper rebuild, permission +prompt, TCC change or unrestricted capture was used. + +**Passed separately for Paper and Fabric:** authenticated button navigation, +actual native marker conformance, rejection of an old preview reference after +another selection, and continued usability of an existing chrome reference. +The bounded authored command was +`FURN_DESKTOP_DRIVER_BUILD_POLICY=never yarn storybook test --macos --test '*exposes enabled button semantics*'` +(append `--paper` for Paper). Passing authored run IDs, beneath +`apps/storybook/artifacts/desktop/`, were +`c462e891-a755-4a27-9b63-9eb07f64934b` (Paper) and +`cd1ef4ac-8f38-4d3e-ac06-0d957ade0207` (Fabric). + +**Passed separately for Paper and Fabric:** stop the entire driver supervisor +while preserving the authenticated app; start a fresh driver; verify the +unchanged authenticated epoch and exact live PID/start time. The story route +returned `relaunch-required`, the matching renderer, and mutation effect +`none`. The same bounded authored command intentionally failed navigation +with that typed error; it is not an authored-test pass. Independent native +route assertions passed. The private endpoint/target storage namespace and +epoch survived the service restart without relying on the rotated manifest. + +**Passed separately for Paper and Fabric:** quit the recorded app and driver, +launch a replacement app before its channel exists, then restart the driver. +The route returned `bridge-unavailable`, effect `none`, and removed the exited +incarnation's record. A subsequent no-record check remained unavailable. +Initial absence before authentication also returned unavailable, effect `none`. +Early Paper startup attempts failed navigation while the runtime was not ready; +they remain failures, not passes hidden by the later settled run. The earlier +Fabric full-catalog traversal with app-stop AppleEvent `-1712` remains a failed +smoke command; bounded reruns used verified-process cleanup instead. + +**Passed:** focused and owning-package checks, clean-cache `yarn lage buildci` +(178 tasks), and `yarn lage test-links --verbose --grouped` (90 tasks). +Private session evidence is retained under `files/f4-validation/`: +`restart-{paper,fabric}-{markers,record,assert,replaced-assert}.log`, +`restart-permissions.log`, `restart-buildci.log` and `restart-test-links.log`. +After both lanes, owned apps, supervisors, Metro, channels and helpers were +stopped; ports 23489, 39627 and 30542 and the interactive lock were verified +clear, and the exclusive validation slot was released. + +**Not run natively:** post-dispatch cancellation, delayed ready/error/render +fault injection, fresh-preview actions after lost context, secondary windows +and physical input. **Not run:** Windows Fabric and Win32 Paper qualification. +Portable contract coverage is not native evidence for these gaps. ## Measurement ledger @@ -169,3 +261,154 @@ Report p50/p90/max with sample count, retries, failures, skipped capabilities, and cleanup status. Compare equivalent assertions and complete scopes. Windows and Paper measurements need their own rows; do not extrapolate macOS Fabric numbers. + +### 2026-11-06: partial native evidence at `3c780cfd1bccaf510ece080e4be2b516c645989a` + +App: Agentic Components Storybook / ReactTestApp +(`com.microsoft.fluentui.agenticstorybook`), macOS 27.2 arm64. The exact +candidate helper was built with `yarn storybook build-driver --macos`; fresh +nonprompting preflight was +`yarn desktop-driver doctor --platform macos --permissions`. Accessibility, +PostEvent, and ScreenCapture were all `true`; no prompt or permission change +was made. The console was on and unlocked. The helper was arm64/release, +ad-hoc signed, with designated requirement +`cdhash H"0d2e7a81dfd4695b7fb84aa1b3242f5e1773e8b4"`. Host load was 3.25 / +4.41 / 3.81 at 1/5/15 minutes. Detailed preflight output is kept privately as +`f15-native-qualification-3c780cfd-20261106T0200/doctor.json`. + +| Renderer | Command and selection | Outcome | Cleanup and artifact | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| macOS Paper | `yarn storybook driver --macos --paper --agent`; app via `yarn storybook run --macos --paper`; MCP calls used the default descriptor. Driver run selected/executed 0 tests. | Attach-only discovery, capture, normal selection/reset, protected-args refusal, revocation, and 21-second channel check were reported. Same-port restart/rediscovery was partial because explicit old-session close was not retained. | Cleanup was reported; ports 21338/30029/45382 had no listeners and the lock/descriptor were absent at handoff. Paper process IDs were not retained for PID-level confirmation. Driver run: `apps/storybook/artifacts/desktop/a58d2fdd-5584-47f4-8747-5307607b4c0d/run.json`; private `paper-native-capture.png`. | +| macOS Fabric | `yarn storybook driver --macos --agent`; app via `yarn storybook run --macos`; MCP calls used the default descriptor. Driver run selected/executed 0 tests. | Attach-only discovery and capture passed. Selection/reset markers settled; F5 HTTP/WS bypass checks and 21,003 ms idle-channel check passed. The old-reference acceptance remains inconclusive; the physical-input attempt returned `physicalInputApplied:false`. | Fabric cleanup record reports the service owner exited, descriptor removed/grant revoked, and attached app preserved through service shutdown; ports had no listeners and the lock/descriptor were absent at handoff. Driver run: `apps/storybook/artifacts/desktop/c15a35b9-7b8d-4edb-adba-84e3cd5a3023/run.json`; app launch: `apps/storybook/artifacts/desktop/8fb9a576-cde8-470c-a8bb-a76b6d8fabc0/run.json`; private `fabric-native-capture.png`. | + +The capture operation succeeded for both renderers. Fabric's first capture +caller attempt used the wrong argument shape and failed; the corrected +documented `image` field succeeded. This caller error is retained and is not +counted as a product failure. The separate Paper non-agent manifest smoke +selected 319, executed/passed 308, failed 0, skipped 11, unsupported 0, +incomplete 0, retries 0, flaky 0; it is not descriptor-path evidence. The +three 319-count run artifacts do not all preserve renderer metadata, so only +the Paper-attributed artifact is credited to Paper. + +Fabric's first two foreign-WebSocket bypass assertions failed, followed by a +final attempt that passed with no observer-message delta; the cause is +unknown. An intermediate protected-args record had a false unchanged-story +assertion, while the final record was unchanged and value-free. The overall +bypass/args results are therefore recorded as Partial in the harness matrix. +The physical-input attempt returned `physicalInputApplied:false`; it was not +retried. + +This is not full native qualification. Dispose-during-open, failed delete, +ambiguous create, quarantine/recovery, failed-cleanup claim retention, faulted +F3 survivor reconciliation, F4 cancellation/readiness/render/lost-context/ +secondary-window cases, and legacy-lane separation were not run. Physical +input did not pass. Windows Fabric and Win32 Paper are Not run on this host. +The F5 reference-generation fix at +`2d8e8db001b64ebea85f0d6dce141f040fb5182d` is outside the tested SHA and needs +a new native run; do not attribute the original inconclusive probe to it. + +## 2026-11-06 combined-candidate macOS evidence at `a19fd3e1e7b3f1f443a241b1571cb029162f4ef2` + +App: Agentic Components Storybook / ReactTestApp +(`com.microsoft.fluentui.agenticstorybook`), macOS 27.2 arm64. The exact +arm64/release helper was rebuilt with +`yarn storybook build-driver --macos`; fresh nonprompting +`yarn desktop-driver doctor --platform macos --permissions` reported +Accessibility, PostEvent, and ScreenCapture granted. No permission changes +were made. Helper fingerprint: +`b58e4d8692702fd1dfa16b73ac987d1738e6e17d16757cbc3c6007722b8e3869`; +ad-hoc signing designated requirement: +`cdhash H"0d2e7a81dfd4695b7fb84aa1b3242f5e1773e8b4"`. +Observed quiet host load was 2.49 / 5.27 / 5.47 (1/5/15-minute averages). + +| Renderer | Command and selection | Result | Cleanup and artifact | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| macOS Fabric | `yarn storybook driver --macos --agent`; app `yarn storybook run --macos`; manual MCP descriptor session, 0 automated tests selected/executed. Separate `yarn storybook smoke --macos --mode stories-and-tests`: 211 selected/executed. | Retained preview-element sequence passed: same session/incarnation 1; select settled at region 2, reset at region 3; old element alias rejected after both transitions; fresh find produced a distinct alias that worked; old alias remained rejected. Fabric idle session passed at 21,002 ms. Same-port replacement partial (recorded Failed, reclassified on contract review): old client's first post-replacement `desktop_session.status` returned the designed fail-closed `adapter` / `service-unavailable`; close succeeded, but rediscovery on a later call was not demonstrated. One physical click was issued but its nested mechanism/disposition was not retained, so partial only. | App PID 26792 survived service shutdown/restart and was stopped afterward. Owned PIDs gone; ports 21338/30029/45382 free; descriptor and lock absent. Smoke artifact `apps/storybook/artifacts/desktop/a2475e8d-7ea9-465f-8461-b8f115cfcee5/run.json`; service runs `apps/storybook/artifacts/desktop/414cf449-bfd4-4b9f-b630-63e86ebdd5c4/run.json`, `apps/storybook/artifacts/desktop/d732ae38-df56-4b81-a56c-2791bdbac5e8/run.json`; blocked-claim attempt `apps/storybook/artifacts/desktop/73563809-2d77-49cb-a297-c74fd57ecd36/run.json`. Sanitized operation records are in the private `f15-native-qualification-a19fd3e1-20261106T0406/` log directory. | +| macOS Paper | `yarn storybook driver --macos --paper --agent`; app `yarn storybook run --macos --paper`; manual MCP descriptor session, 0 automated tests selected/executed. Separate `yarn storybook smoke --macos --paper --mode stories-and-tests`: 211 selected/executed. | Retained preview-element sequence passed with the same-session region transition and stale/fresh alias assertions. No same-port, idle-channel, or physical-input case was run for Paper on this SHA. | App, service, and Metro processes exited; ports free, descriptor absent, stale lock removed only after verifying its recorded owner had exited with empty resources/unresolved effects. Smoke artifact `apps/storybook/artifacts/desktop/87f0452f-5fd7-48eb-8494-a63202842521/run.json`. Sanitized record is in the private log directory above. | + +The Fabric replacement case was not retried. It was recorded as Failed and +reclassified as Partial on contract review: the replacement service started on +the same ports, and the old MCP client's first status call was rejected with +`service-unavailable`, which is the designed fail-closed response before the +next call re-verifies discovery. The following close cannot prove rediscovery, +so a native status call after the rejection remains required. No mutation was +issued. Manual MCP calls are +not counted as selected tests. Manifest smoke is the separate non-agent legacy +lane and does not prove protected-ingress behavior or the no-HTTP-fallback +instrumentation requirement. Windows Fabric and Win32 Paper measurements are +Not run on this host. Fault injection, args refusal, ingress bypass, and F3/F4 +survivor/fault cases were not run. + +## 2026-11-06 Windows Fabric and Win32 Paper descriptor evidence + +Source: the PR branch at `1f5333e25` plus `8458bde48` and `a6b89e924`; host +Windows 11 Enterprise build 26300, AMD64. The full 24-case matrix, counts, +retry history, run records, and cleanup are in the "Windows Fabric and Win32 +Paper qualification — 2026-11-06" section of +`packages/agentic/desktop-test/references/current-status.md`. +`yarn storybook build-driver --windows|--win32` succeeded, and +`yarn desktop-driver doctor --platform windows|win32 --permissions` reports +`--permissions` as **Unsupported** on Windows. No text-size or operation +latency measurements were recorded on Windows, so none are added here. + +Reference kinds, each from one driver session at service incarnation 1: + +| Endpoint | Target | Preview element refs | Chrome/window ref | Fresh ref after reset | +| -------------- | -------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------- | ---------------------------------------- | +| Windows Fabric | `windows-f33cce69c1a7d0ae` | `9z4z23` (story root) and `9z4z66` became `ref-region-changed` after reset | `9z4z1` survived on `window-df4a4ab9-b2` (allowed) | `9z4z102` became stale after next select | +| Win32 Paper | `win32-5e01f2ae23282f34` | `okdw21` (story root) and `okdw38` became stale after reset | `okdw1` survived on `window-9951db02-b3` (allowed) | `okdw54` became stale after next select | + +After a Fabric Metro reload, the old story root `7fqk23` returned `ref-stale` +and a new story root `7fqk101` was observable in the same session. After a Win32 app exit, observing the old preview returned +`window-closed`. Physical input: the Windows helper rejected a physical click +because the target window did not become the foreground window (effect none); +the Win32 helper applied one with mechanism `physical-pointer`. + +## 2026-11-07 UTC Windows follow-up + +Source `386f0585edae2450ab22e6d359e2fe4db3399555`; validation tests, +tooling, atomic reporting, and guidance changed during this follow-up. The additive native +matrix in `packages/agentic/desktop-test/references/current-status.md` +records 21 Pass, 2 Partial, 1 Fail for Windows and 21 Pass, 3 Partial for +Win32, with no remaining Not run rows. This does not resolve Windows +physical input or the partial launch/secondary-window coverage. + +Real descriptor/MCP probes held correlated readiness for 1,500 ms after +native render and delivered a stale correlation first. Windows settled after +1,637 ms and Win32 after 1,618 ms. Within one uninterrupted session at +incarnation 1, regions changed 1 -> 2 -> 3: + +| Endpoint | Session | Preview element / chrome | Exact window | Runtime delayed/error generation | +| -------- | -------------------------------------- | ------------------------ | --------------------- | -------------------------------- | +| Windows | `46a48bed-5f9c-40c6-aac7-c2929461d222` | `0yoo23` / `0yoo1` | `window-4124f4f1-1be` | 2 / 3 | +| Win32 | `a29dc7af-c96f-4b0a-a98f-2eae04d7f8c1` | `oyno25` / `oyno1` | `window-5d019501-11b` | 5 / 6 | + +The preview refs returned `ref-region-changed`; chrome checks passed. +Correlated render errors produced unknown-effect quarantine and rejected +later story mutations. A fresh preview root was not exposed after that error, +so that subcase is Partial, not a bypass/fencing proof. Win32's earlier +fresh-preview fencing gap remains Partial. Native Callout probes confirmed a +shown native window and cleanup by subsequent selection, but addressed only +the registered primary identity: separately registered secondary-window +selection remains Partial on both endpoints. + +Startup and shutdown failure probes retained exact owned-resource claims and +refused replacement. Original stop callbacks reconciled the recorded +generation-2 channel/helper; the retained owner and claim were removed only +after resource cleanup proof. Fresh service incarnations then passed +descriptor discovery, marker-verified selection/reset, normal shutdown, +authority revocation, and attached-app preservation. The desktop-test ledger +retains the injected failures, one local control-reader crash, operator +reconciliation details, every supervisor run ID, and the separate unprotected +smoke history. No new physical-input or capture capability is inferred. + +Final non-agent smoke with bounded atomic-report recovery passed on both +endpoints: Windows run `1825d9aa-2c25-4a2c-8524-ceadcf52ad68` +(234 passed, 78 skipped) and Win32 run +`8c6f58e1-3591-4f1b-b008-c52a718d3ca2` (214 passed, 52 skipped). +Every session had a successful delete; readiness traversal ended before +authored tests. Windows journaled one recovered sharing conflict, Win32 none. +Processes, listeners, per-user claim, and default descriptor were absent +after cleanup. Artifacts remain under +`apps/storybook/artifacts/desktop//run.json`; earlier reporting and +physical-input failures are not erased. diff --git a/packages/agentic/desktop-driver/references/operations.md b/packages/agentic/desktop-driver/references/operations.md index 1499a2c896..3c04c94a23 100644 --- a/packages/agentic/desktop-driver/references/operations.md +++ b/packages/agentic/desktop-driver/references/operations.md @@ -409,7 +409,7 @@ region, so references into the old render expire. or `reset`, which renders it again from its initial state. An id the catalog lacks fails with `story-unknown` before anything changes. Rendering replaces the preview region first, so every earlier preview reference fails with -`ref-region-changed`, then waits up to the target's story render deadline for +`ref-region-changed` (or `ref-expired` once its table entry is dropped), then waits up to the target's story render deadline for the story root to confirm the story. The result gives the new `region` and the `verification`: `marker`, or `none` on a target without a story root. A render that does not confirm fails with `story-not-ready`, coded `timeout` when the @@ -426,8 +426,14 @@ render may take far longer than an input. A reference is an opaque `` string. The tag is random per session and per incarnation and never reused in the process, so a reference from another session or server cannot resolve here. Each element keeps one -reference while the table retains it, so repeated observations report unchanged -elements under unchanged references. A reference pins its window and keeps +reference while the table retains it in the same region, so repeated observations report unchanged +elements under unchanged references. After a preview region changes, observing a +surviving native element issues a new reference; it never revives the old one. +Application, chrome, and secondary-window references survive preview changes. +A whole-window observation without `within` reads the current window rather than +asserting an earlier preview's lifetime. Immutable captures remain readable until +artifact eviction or session end; they are not live preview references. +A reference pins its window and keeps working while another window is current. The table holds at most 2,000 references per session. Beyond that it drops references into a replaced preview region first, then the least recently returned; the references an observation diff --git a/packages/agentic/desktop-driver/src/operations/act.ts b/packages/agentic/desktop-driver/src/operations/act.ts index f9e2f3e8f0..0214525a58 100644 --- a/packages/agentic/desktop-driver/src/operations/act.ts +++ b/packages/agentic/desktop-driver/src/operations/act.ts @@ -208,6 +208,16 @@ export async function proveSelector( /** Reads the element again, immediately before acting: the provider's liveness check and the fingerprint precondition. */ export async function revalidate(runtime: OperationRuntime, entry: ReferenceEntry, proof: UniquenessProof): Promise { + if (entry.scope === 'preview' && runtime.stories) { + const current = await runtime.stories.current(runtime.signal); + if (current.changed || current.unavailable || current.story?.verification === 'unconfirmed') { + throw rejection(runtime, 'stale element reference', 'story-context-stale', 'The preview context is stale. Select the story again.'); + } + const resolution = runtime.state.refs.resolve(entry.reference, runtime.previewGeneration()); + if (resolution.ok === false) { + throw asRejection(runtime, referenceFailureError(resolution.failure, entry.reference)); + } + } let snapshot: NativeElementSnapshot; try { snapshot = await runtime.command('Reading the element before acting', (signal) => runtime.host.snapshot(entry.nativeId, signal)); diff --git a/packages/agentic/desktop-driver/src/operations/refs.test.ts b/packages/agentic/desktop-driver/src/operations/refs.test.ts index 57e43c47c7..6b63f5a42b 100644 --- a/packages/agentic/desktop-driver/src/operations/refs.test.ts +++ b/packages/agentic/desktop-driver/src/operations/refs.test.ts @@ -87,6 +87,29 @@ describe('ReferenceTable', () => { expect(table.resolve('k3xa3', 1)).toEqual({ failure: 'region-changed', ok: false }); }); + test('reobserving a retained preview native id cannot revive a reference from an earlier region', () => { + const table = new ReferenceTable('k3xa'); + const before = table.issue(target('preview-root', { scope: 'preview' }), 1); + expect(table.resolve(before, 1)).toEqual({ failure: 'region-changed', ok: false }); + + const current = table.issue(target('preview-root', { scope: 'preview', previewGeneration: 1 }), 2); + + expect(table.resolve(before, 1)).toMatchObject({ ok: false }); + expect(current).not.toBe(before); + expect(table.resolve(current, 1)).toMatchObject({ entry: { nativeId: 'preview-root', previewGeneration: 1 }, ok: true }); + expect(table.issue(target('preview-root', { scope: 'preview', previewGeneration: 1 }), 3)).toBe(current); + }); + + test.each(['application', 'chrome', 'secondary-window'] as const)( + 'reobserving %s retains its reference across preview generations', + (scope) => { + const table = new ReferenceTable('k3xa'); + const before = table.issue(target('retained', { scope }), 1); + expect(table.issue(target('retained', { scope, previewGeneration: 1 }), 2)).toBe(before); + expect(table.resolve(before, 1)).toMatchObject({ ok: true }); + }, + ); + test('an earlier incarnation stops resolving and is reported as earlier, not foreign', () => { const table = new ReferenceTable('k3xa'); const before = table.issue(target('e1'), 1); diff --git a/packages/agentic/desktop-driver/src/operations/refs.ts b/packages/agentic/desktop-driver/src/operations/refs.ts index 67d15c7ad2..8c903faea9 100644 --- a/packages/agentic/desktop-driver/src/operations/refs.ts +++ b/packages/agentic/desktop-driver/src/operations/refs.ts @@ -102,7 +102,8 @@ function isDigit(code: number): boolean { /** * The element references one session has issued. A native id keeps one reference while the table retains it, so a - * repeated observation reports unchanged elements under unchanged references, and a reference is never reissued. + * repeated observation reports unchanged elements under unchanged references within their region, and a reference + * is never reissued. */ export class ReferenceTable { private readonly capacity: number; @@ -140,11 +141,16 @@ export class ReferenceTable { const existing = this.referencesByNativeId.get(target.nativeId); if (existing !== undefined) { const entry = this.entries.get(existing)!; - entry.lastSeen = sequence; - entry.previewGeneration = target.previewGeneration; - entry.scope = target.scope; - entry.windowId = target.windowId; - return existing; + if (entry.scope === 'preview' && entry.previewGeneration !== target.previewGeneration) { + // A surviving native container does not make its expired public preview reference current again. + this.drop(existing); + } else { + entry.lastSeen = sequence; + entry.previewGeneration = target.previewGeneration; + entry.scope = target.scope; + entry.windowId = target.windowId; + return existing; + } } this.issued += 1; const reference = `${this.tag}${this.issued}`; diff --git a/packages/agentic/desktop-driver/src/operations/runtime.ts b/packages/agentic/desktop-driver/src/operations/runtime.ts index 1ad67a6759..d8e0baa0db 100644 --- a/packages/agentic/desktop-driver/src/operations/runtime.ts +++ b/packages/agentic/desktop-driver/src/operations/runtime.ts @@ -8,7 +8,6 @@ import type { NativeObservation, NativeObserveRequest, } from '../host/types.js'; -import type { DesktopStoryManifestEntry } from '../storybook.js'; import type { JournalScalar } from './journal.js'; import type { OperationSessionState } from './sessionState.js'; @@ -62,6 +61,8 @@ export type StoryVerification = 'marker' | 'none' | 'unconfirmed'; /** The story this session selected, as revalidated when it was read. */ export type OperationCurrentStory = { + /** A previously verified context was lost; fresh references cannot establish story readiness. */ + unavailable?: true; /** The rendered story is not the one this session selected, so the session forgot it and expired the preview. */ changed?: true; /** Absent when no story is selected. */ @@ -71,7 +72,7 @@ export type OperationCurrentStory = { /** The session's Storybook catalog and selection. Present only when `capabilities.stories` is true. */ export type OperationStories = { /** The target's story catalog. */ - catalog(signal: AbortSignal): Promise; + catalog(signal: AbortSignal): Promise; /** The selected story, checked against the rendered story root so a change made by another client is noticed. */ current(signal: AbortSignal): Promise; /** @@ -81,6 +82,13 @@ export type OperationStories = { change(action: 'reset' | 'select', storyId: string): Promise<{ storyId: string; verification: 'marker' | 'none' }>; }; +export type OperationStoryEntry = { + id: string; + name: string; + tags: readonly string[]; + title: string; +}; + /** * Everything an operation may touch for one request. Operations depend on this interface rather than on the server, so * they run unchanged under HTTP today and under an MCP adapter later, and a test can supply a plain object. diff --git a/packages/agentic/desktop-driver/src/operations/story.ts b/packages/agentic/desktop-driver/src/operations/story.ts index 3303059ce3..720cec95d4 100644 --- a/packages/agentic/desktop-driver/src/operations/story.ts +++ b/packages/agentic/desktop-driver/src/operations/story.ts @@ -3,11 +3,10 @@ import { z } from 'zod'; import { toWebDriverError } from '../protocol/errors.js'; import type { WebDriverError } from '../protocol/errors.js'; import { attachFailureEffect, attachMutationReport, createMutationReport } from '../protocol/settlement.js'; -import type { DesktopStoryManifestEntry } from '../storybook.js'; import { asRejection, assertNotQuarantined, rejection } from './act.js'; import { operationError } from './envelope.js'; import { storyPage, storyTextLimits } from './limits.js'; -import type { OperationAvailability, OperationCapabilities, OperationRuntime, OperationStories } from './runtime.js'; +import type { OperationAvailability, OperationCapabilities, OperationRuntime, OperationStories, OperationStoryEntry } from './runtime.js'; import { registerOperation } from './runtime.js'; import { budgetField } from './shapes.js'; import { clipText, neutralizeText, safeToken } from './text.js'; @@ -65,7 +64,7 @@ function catalogText(text: string, maxLength: number): string { return clipped ? `${kept}…` : kept; } -function describeEntry(entry: DesktopStoryManifestEntry): StoriesResult['entries'][number] { +function describeEntry(entry: OperationStoryEntry): StoriesResult['entries'][number] { const tags = entry.tags.slice(0, storyTextLimits.tags).map((tag) => catalogText(tag, storyTextLimits.tag)); return { id: catalogText(entry.id, storyTextLimits.id), @@ -75,7 +74,7 @@ function describeEntry(entry: DesktopStoryManifestEntry): StoriesResult['entries }; } -function matchesFilter(entry: DesktopStoryManifestEntry, needle: string): boolean { +function matchesFilter(entry: OperationStoryEntry, needle: string): boolean { return [entry.id, entry.title, entry.name].some((text) => text.toLowerCase().includes(needle)); } @@ -138,6 +137,12 @@ export type StoryResult = z.infer; * The words are fixed: the cause can quote a story's render error, and an agent reads errors as instructions. */ function storyNotReady(runtime: OperationRuntime, error: unknown): WebDriverError { + const converted = toWebDriverError(error); + if (converted.data?.reason === 'bridge-unavailable' || converted.data?.reason === 'relaunch-required') { + const report = createMutationReport(error, runtime.quarantined()); + attachFailureEffect(converted, report.effect); + return attachMutationReport(converted, report); + } const failure = operationError( toWebDriverError(error).code === 'timeout' ? 'timeout' : 'unknown error', 'story-not-ready', diff --git a/packages/agentic/desktop-driver/src/server/SessionManager.ts b/packages/agentic/desktop-driver/src/server/SessionManager.ts index 5e59c13995..8b430c3741 100644 --- a/packages/agentic/desktop-driver/src/server/SessionManager.ts +++ b/packages/agentic/desktop-driver/src/server/SessionManager.ts @@ -22,6 +22,7 @@ import { import type { MutationEffect } from '../protocol/settlement.js'; import { withCommandTimeout } from '../protocol/timeouts.js'; import type { DesktopClickMode, DesktopTimeouts, WebDriverTimeouts } from '../protocol/types.js'; +import type { StoryReadyResult } from '../storybook.js'; import { applicationOf } from './settlementJournal.js'; import type { ProbeApplication, QuarantineRecord, SettlementJournal } from './settlementJournal.js'; @@ -105,7 +106,7 @@ export type DesktopSession = { /** Set on a protected service; absent when the service does not authenticate callers. */ owner?: SessionOwner; previewGeneration: number; - story: { previewGeneration: number; runId: string; storyId: string } | null; + story: StoryReadyResult | null; target: DesktopTarget; timeouts: WebDriverTimeouts; windows: DesktopWindow[]; diff --git a/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts b/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts index 1fea7d25d6..b2dac9e849 100644 --- a/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts +++ b/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts @@ -18,7 +18,7 @@ import type { DesktopSession, ElementRecord, MutationOptions, RecoverTargetReque import { SettlementJournal } from './settlementJournal.js'; import type { ProbeApplication } from './settlementJournal.js'; import { createOperationRuntime, handleOperationRoute, requestBodyLimit } from './sessionOperations.js'; -import { changeStory } from './sessionStories.js'; +import { assertStoryContext, changeStory, readCurrentStory } from './sessionStories.js'; import { TargetRegistry } from './TargetRegistry.js'; export type DesktopDriverServerOptions = { @@ -500,6 +500,10 @@ async function route(context: RouteContext, sessions: SessionManager, targets: T throw invalidArgument('Action element origin is missing a valid WebDriver element reference.'); } const record = sessions.resolveElement(session, elementId); + if (record.scope === 'preview') { + await assertStoryContext(session, sessions); + sessions.resolveElement(session, elementId); + } await hostCommand(session, `Resolving action origin element "${elementId}"`, (signal) => session.target.host.snapshot(record.nativeId, signal), ); @@ -529,10 +533,16 @@ async function route(context: RouteContext, sessions: SessionManager, targets: T throw new WebDriverError('unsupported operation', `Target "${session.target.id}" does not provide Storybook orchestration.`); } if (method === 'GET' && command[0] === 'manifest' && command.length === 1) { - return withCommandTimeout(() => orchestrator.getManifest(), session.desktopTimeouts.nativeCommand, 'Reading the Storybook manifest'); + return withCommandTimeout( + (signal) => orchestrator.getManifest({ lifetimeId: session.id, signal }), + session.desktopTimeouts.nativeCommand, + 'Reading the Storybook manifest', + { signal: session.commandAbortController.signal }, + ); } if (method === 'GET' && command[0] === 'story' && command.length === 1) { - return session.story; + const current = await readCurrentStory(session, sessions, session.commandAbortController.signal); + return current.story?.verification === 'unconfirmed' ? null : session.story; } if (method === 'POST' && command[0] === 'story' && command.length === 1) { sessions.assertMutable(session); @@ -560,9 +570,10 @@ async function route(context: RouteContext, sessions: SessionManager, targets: T sessions.invalidatePreview(session); session.story = null; await withCommandTimeout( - () => orchestrator.updateArgs!(request.storyId as string, args), + (signal) => orchestrator.updateArgs!(request.storyId as string, args, { lifetimeId: session.id, signal }), session.desktopTimeouts.storyRender, `Updating args for Storybook story "${request.storyId}"`, + { signal: session.commandAbortController.signal }, ); return null; } @@ -666,6 +677,10 @@ async function handleElementCommand( sessions: SessionManager, ): Promise { const record = sessions.resolveElement(session, command[0]); + if (record.scope === 'preview' && method === 'POST' && ['click', 'clear', 'value'].includes(command[1])) { + await assertStoryContext(session, sessions); + sessions.resolveElement(session, command[0]); + } const snapshot = await hostCommand(session, `Reading element "${record.id}"`, (signal) => session.target.host.snapshot(record.nativeId, signal), ); diff --git a/packages/agentic/desktop-driver/src/server/sessionStories.ts b/packages/agentic/desktop-driver/src/server/sessionStories.ts index 12322cc8f1..26996fce2c 100644 --- a/packages/agentic/desktop-driver/src/server/sessionStories.ts +++ b/packages/agentic/desktop-driver/src/server/sessionStories.ts @@ -1,15 +1,18 @@ import { randomUUID } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; import type { OperationCurrentStory, OperationStories } from '../operations/runtime.js'; import { WebDriverError } from '../protocol/errors.js'; import { attachFailureEffect, readFailureEffect } from '../protocol/settlement.js'; import { withCommandTimeout } from '../protocol/timeouts.js'; import type { StoryOrchestrator, StoryReadyResult, StorySelectionRequest } from '../storybook.js'; +import { matchesStoryMarker, parseStoryMarker, StoryBridgeUnavailableError } from '../storybook.js'; import type { DesktopSession, SessionManager } from './SessionManager.js'; export type StoryChange = 'reset' | 'select'; const markerPollMs = 25; +const unavailableContexts = new WeakSet(); /** * Selects or resets a story and waits until the native story root shows it. The preview region expires first and the @@ -24,23 +27,40 @@ export async function changeStory( ): Promise<{ result: StoryReadyResult; verified: boolean }> { const orchestrator = requireOrchestrator(session); return sessions.runMutation(session, 'Changing Storybook context', async () => { - const deadline = Date.now() + session.desktopTimeouts.storyRender; sessions.invalidatePreview(session); session.story = null; try { - const result = await withCommandTimeout( - () => (change === 'reset' ? orchestrator.resetStory(request) : orchestrator.selectStory(request)), - remainingTime(deadline), + return await withCommandTimeout( + async (signal) => { + const context = { lifetimeId: session.id, signal }; + const result = await (change === 'reset' + ? orchestrator.resetStory(request, context) + : orchestrator.selectStory(request, context)); + if ( + result.storyId !== request.storyId || + result.runId !== request.runId || + (result.requestId !== undefined && result.requestId !== request.requestId) + ) { + throw new WebDriverError('unknown error', 'Storybook returned readiness for a different selection.'); + } + const verified = await verifyStoryMarker(session, result, signal); + signal.throwIfAborted(); + unavailableContexts.delete(session); + session.story = result; + return { result, verified }; + }, + session.desktopTimeouts.storyRender, 'Changing Storybook context', { signal: session.commandAbortController.signal }, ); - const verified = await verifyStoryMarker(session, result, deadline); - session.story = result; - return { result, verified }; } catch (error) { // Runtime failures can contain application text; settlement records must contain only a fixed description. throw attachFailureEffect( - new WebDriverError(error instanceof WebDriverError ? error.code : 'unknown error', 'Storybook context did not settle.'), + new WebDriverError( + error instanceof WebDriverError ? error.code : 'unknown error', + 'Storybook context did not settle.', + error instanceof StoryBridgeUnavailableError ? error.data : undefined, + ), readFailureEffect(error), ); } @@ -61,12 +81,12 @@ export function createOperationStories( return { async catalog(callSignal) { const manifest = await withCommandTimeout( - () => orchestrator.getManifest(), + (signal) => orchestrator.getManifest({ lifetimeId: session.id, signal }), session.desktopTimeouts.nativeCommand, 'Reading the Storybook manifest', { signal: AbortSignal.any([callSignal, requestSignal()]) }, ); - return manifest.entries; + return manifest.entries.map(({ id, name, tags, title }) => ({ id, name, tags, title })); }, current: (callSignal) => readCurrentStory(session, sessions, AbortSignal.any([callSignal, requestSignal()])), async change(change, storyId) { @@ -81,10 +101,26 @@ export function createOperationStories( * can change the story without this session: a root that shows a different story proves it, so the session forgets * its story and expires the preview. A root it cannot find proves nothing, because the current window may be another. */ -async function readCurrentStory(session: DesktopSession, sessions: SessionManager, signal: AbortSignal): Promise { +export async function readCurrentStory( + session: DesktopSession, + sessions: SessionManager, + signal: AbortSignal, +): Promise { const story = session.story; if (!story) { - return {}; + return unavailableContexts.has(session) ? { unavailable: true } : {}; + } + const current = await withCommandTimeout( + (signal) => requireOrchestrator(session).getCurrentStory({ lifetimeId: session.id, signal }), + session.desktopTimeouts.nativeCommand, + 'Checking the Storybook context epoch', + { signal }, + ); + if (!current || !matchesStoryMarker(JSON.stringify(current), story)) { + unavailableContexts.add(session); + sessions.invalidatePreview(session); + session.story = null; + return { changed: true }; } const testId = session.target.storyRootTestId; if (!testId) { @@ -95,6 +131,7 @@ async function readCurrentStory(session: DesktopSession, sessions: SessionManage return { story: { storyId: story.storyId, verification: 'marker' } }; } if (markers.some((marker) => parseStoryMarker(marker) !== undefined) && session.story === story) { + unavailableContexts.add(session); sessions.invalidatePreview(session); session.story = null; return { changed: true }; @@ -102,20 +139,35 @@ async function readCurrentStory(session: DesktopSession, sessions: SessionManage return { story: { storyId: story.storyId, verification: 'unconfirmed' } }; } +export async function assertStoryContext(session: DesktopSession, sessions: SessionManager): Promise { + const current = await readCurrentStory(session, sessions, session.commandAbortController.signal); + if (current.changed || current.unavailable || current.story?.verification === 'unconfirmed') { + throw attachFailureEffect( + new WebDriverError('stale element reference', 'The Storybook preview context is stale.', { + reason: 'story-context-stale', + }), + 'none', + ); + } +} + /** Polls the story root until it shows `expected`. Returns false when the target has no native story root. */ -async function verifyStoryMarker(session: DesktopSession, expected: StoryReadyResult, deadline: number): Promise { +async function verifyStoryMarker(session: DesktopSession, expected: StoryReadyResult, signal: AbortSignal): Promise { const testId = session.target.storyRootTestId; if (!testId) { + if (expected.markerVersion === 1) { + throw new WebDriverError('unknown error', 'Versioned Storybook readiness requires a native story-root marker.'); + } return false; } - do { - const markers = await readMarkers(session, testId, session.commandAbortController.signal); + for (;;) { + signal.throwIfAborted(); + const markers = await readMarkers(session, testId, signal); if (markers.some((marker) => matchesStoryMarker(marker, expected))) { return true; } - await new Promise((resolve) => setTimeout(resolve, markerPollMs)); - } while (Date.now() <= deadline); - throw new WebDriverError('timeout', `The native Storybook marker did not confirm story "${expected.storyId}" run "${expected.runId}".`); + await delay(markerPollMs, undefined, { signal }); + } } async function readMarkers(session: DesktopSession, testId: string, signal: AbortSignal): Promise { @@ -132,27 +184,6 @@ async function readMarkers(session: DesktopSession, testId: string, signal: Abor }); } -function parseStoryMarker(value: string): Record | undefined { - try { - const marker: unknown = JSON.parse(value); - return typeof marker === 'object' && marker !== null && typeof (marker as { storyId?: unknown }).storyId === 'string' - ? (marker as Record) - : undefined; - } catch { - return undefined; - } -} - -function matchesStoryMarker(value: string, expected: StoryReadyResult): boolean { - const marker = parseStoryMarker(value); - return ( - marker !== undefined && - marker.storyId === expected.storyId && - marker.runId === expected.runId && - marker.previewGeneration === expected.previewGeneration - ); -} - function requireOrchestrator(session: DesktopSession): StoryOrchestrator { const orchestrator = session.target.storyOrchestrator; if (!orchestrator) { @@ -160,7 +191,3 @@ function requireOrchestrator(session: DesktopSession): StoryOrchestrator { } return orchestrator; } - -function remainingTime(deadline: number): number { - return Math.max(1, deadline - Date.now()); -} diff --git a/packages/agentic/desktop-driver/src/server/storyOperations.test.ts b/packages/agentic/desktop-driver/src/server/storyOperations.test.ts index 61aadbccdd..52cf413941 100644 --- a/packages/agentic/desktop-driver/src/server/storyOperations.test.ts +++ b/packages/agentic/desktop-driver/src/server/storyOperations.test.ts @@ -1,8 +1,11 @@ import { DesktopAuthority } from '../authority/grants.js'; import { createDesktopDriverClient } from '../client/DesktopDriverClient.js'; +import type { FakeDesktopHostOptions } from '../hosts/fake/FakeDesktopHost.js'; import type { DesktopSessionClient } from '../client/DesktopDriverClient.js'; import { attachFailureEffect } from '../protocol/settlement.js'; -import type { DesktopStoryManifest } from '../storybook.js'; +import type { DesktopStoryManifest, StoryProviderContext } from '../storybook.js'; +import { StoryBridgeUnavailableError, StoryProviderError } from '../storybook.js'; +import { createOperationStories } from './sessionStories.js'; import { createDesktopDriverStoryHarness } from '../testing/protocolHarness.js'; import type { DesktopDriverTestHarness } from '../testing/protocolHarness.js'; import { createDesktopDriverTestHarness } from '../testing/protocolHarness.js'; @@ -34,8 +37,11 @@ const manifest: DesktopStoryManifest = { type Fixture = { harness: DesktopDriverTestHarness; session: DesktopSessionClient }; -async function withStories(body: (fixture: Fixture) => Promise): Promise { - const harness = await createDesktopDriverStoryHarness(manifest, { features: { accessibilityActions: true, boundedObservation: true } }); +async function withStories(body: (fixture: Fixture) => Promise, options: FakeDesktopHostOptions = {}): Promise { + const harness = await createDesktopDriverStoryHarness(manifest, { + ...options, + features: { accessibilityActions: true, boundedObservation: true }, + }); try { const session = await createDesktopDriverClient({ url: harness.server.url }).newSession({ alwaysMatch: { 'furn:clickMode': 'accessibility', 'furn:target': harness.target.id, platformName: 'windows' }, @@ -47,6 +53,198 @@ async function withStories(body: (fixture: Fixture) => Promise): Promise { + test('built-in manifest, current, reset and args routes all propagate local provider context', async () => { + await withStories(async ({ harness, session }) => { + const orchestrator = harness.storyOrchestrator!; + const seen: StoryProviderContext[] = []; + const save = (context?: StoryProviderContext) => { + expect(context?.lifetimeId).toBe(session.id); + expect(context?.signal).toBeInstanceOf(AbortSignal); + seen.push(context!); + }; + const getManifest = orchestrator.getManifest.bind(orchestrator); + orchestrator.getManifest = (context) => { + save(context); + return getManifest(context); + }; + const getCurrent = orchestrator.getCurrentStory.bind(orchestrator); + orchestrator.getCurrentStory = (context) => { + save(context); + return getCurrent(context); + }; + const reset = orchestrator.resetStory.bind(orchestrator); + orchestrator.resetStory = (request, context) => { + save(context); + return reset(request, context); + }; + const update = orchestrator.updateArgs.bind(orchestrator); + orchestrator.updateArgs = (storyId, args, context) => { + save(context); + return update(storyId, args, context); + }; + const client = createDesktopDriverClient({ url: harness.server.url }); + await client.request('GET', `/session/${session.id}/furn/manifest`); + await session.story({ storyId: manifest.entries[0].id, action: 'reset' }); + await session.getCurrentStory(); + await client.request('POST', `/session/${session.id}/furn/story/args`, { storyId: manifest.entries[0].id, args: {} }); + expect(seen).toHaveLength(5); + }); + }); + + test('a missing native marker consumes the shared deadline without committing readiness or releasing quarantine', async () => { + await withStories(async ({ harness, session }) => { + const state = harness.server.sessions.get(session.id); + state.desktopTimeouts.storyRender = 20; + harness.storyOrchestrator!.selectStory = async (request) => ({ + ...request, + markerVersion: 1, + connectionEpoch: 'epoch', + previewGeneration: 1, + }); + await expect(session.story({ storyId: manifest.entries[0].id })).rejects.toMatchObject({ + data: { mutation: { disposition: 'indeterminate', effect: 'unknown', quarantined: true } }, + }); + expect(state.story).toBeNull(); + }); + }); + + test('W3C preview mutations reject stale context while chrome and secondary-window references remain live', async () => { + const rect = { x: 0, y: 0, width: 100, height: 100 }; + await withStories( + async ({ harness, session }) => { + await session.story({ storyId: manifest.entries[0].id }); + const preview = await session.findElement('accessibility id', 'preview'); + const chrome = await session.findElement('accessibility id', 'chrome'); + await session.switchToWindow('secondary'); + const secondary = await session.findElement('accessibility id', 'secondary-button'); + await session.switchToWindow('main'); + harness.storyOrchestrator!.getCurrentStory = async () => null; + await expect(preview.click()).rejects.toMatchObject({ data: { reason: 'story-context-stale' } }); + await expect(chrome.click()).resolves.toBeNull(); + const freshPreview = await session.findElement('accessibility id', 'preview'); + await expect(freshPreview.click()).rejects.toMatchObject({ data: { reason: 'story-context-stale' } }); + await session.switchToWindow('secondary'); + await expect(secondary.click()).resolves.toBeNull(); + }, + { + windows: [ + { + id: 'main', + title: 'Main', + rect, + elements: [ + { id: 'story-root', automationId: 'story-root', name: '{}', role: 'group', scope: 'preview', windowId: 'main', rect }, + { id: 'preview', automationId: 'preview', role: 'button', scope: 'preview', windowId: 'main', rect }, + { id: 'chrome', automationId: 'chrome', role: 'button', scope: 'chrome', windowId: 'main', rect }, + ], + }, + { + id: 'secondary', + title: 'Secondary', + rect, + elements: [ + { + id: 'secondary-button', + automationId: 'secondary-button', + role: 'button', + scope: 'secondary-window', + windowId: 'secondary', + rect, + }, + ], + }, + ], + }, + ); + }); + + test('W3C element-origin actions cannot bypass preview epoch validation', async () => { + await withStories(async ({ harness, session }) => { + await session.story({ storyId: manifest.entries[0].id }); + const preview = await session.findElement('accessibility id', 'button-primary'); + harness.storyOrchestrator!.getCurrentStory = async () => null; + await expect( + session.performActions([ + { + type: 'pointer', + id: 'mouse', + parameters: { pointerType: 'mouse' }, + actions: [{ type: 'pointerMove', duration: 0, x: 0, y: 0, origin: { 'element-6066-11e4-a52e-4f735466cecf': preview.id } }], + }, + ]), + ).rejects.toMatchObject({ data: { reason: 'story-context-stale' } }); + }); + }); + + test('passes local session lifetime and deadline signal separately from serialized selection inputs', async () => { + await withStories(async ({ harness, session }) => { + const state = harness.server.sessions.get(session.id); + state.desktopTimeouts.storyRender = 20; + harness.storyOrchestrator!.selectStory = async (input, context) => { + expect(Object.keys(input).sort()).toEqual(['requestId', 'runId', 'storyId']); + expect(context?.lifetimeId).toBe(session.id); + return new Promise((_resolve, reject) => { + context!.signal.addEventListener('abort', () => reject(new StoryProviderError('Cancelled after dispatch.', 'unknown')), { + once: true, + }); + }); + }; + await expect(session.story({ storyId: manifest.entries[0].id })).rejects.toMatchObject({ + data: { mutation: { disposition: 'indeterminate', effect: 'unknown', quarantined: true } }, + }); + }); + }); + + test('an unavailable bridge rejects without quarantining and the operation catalog excludes source/test metadata', async () => { + await withStories(async ({ harness, session }) => { + const state = harness.server.sessions.get(session.id); + const stories = createOperationStories(state, harness.server.sessions, new AbortController().signal)!; + expect(await stories.catalog(new AbortController().signal)).toEqual( + manifest.entries.map(({ id, name, tags, title }) => ({ id, name, tags, title })), + ); + harness.storyOrchestrator!.selectStory = async () => { + throw new StoryBridgeUnavailableError('windows', 'fabric', 'bridge-unavailable'); + }; + await expect(session.story({ storyId: manifest.entries[0].id })).rejects.toMatchObject({ + data: { mutation: { effect: 'none', quarantined: false } }, + }); + }); + }); + + test('context-sensitive input rejects stale previews when the provider disconnects or changes epoch', async () => { + await withStories(async ({ harness, session }) => { + await session.story({ storyId: manifest.entries[0].id }); + const found = (await session.find({ automationId: 'button-primary' })).result; + const getCurrent = harness.storyOrchestrator!.getCurrentStory.bind(harness.storyOrchestrator); + harness.storyOrchestrator!.getCurrentStory = async () => null; + await expect(session.act({ action: 'activate', ref: found.matches[0].ref, sample: found.sample.id })).rejects.toMatchObject({ + code: 'stale element reference', + data: { reason: 'story-context-stale', mutation: { effect: 'none' } }, + }); + expect(harness.server.sessions.get(session.id).story).toBeNull(); + const fresh = (await session.find({ automationId: 'button-primary' })).result; + await expect(session.act({ action: 'activate', ref: fresh.matches[0].ref, sample: fresh.sample.id })).rejects.toMatchObject({ + data: { reason: 'story-context-stale', mutation: { effect: 'none' } }, + }); + harness.storyOrchestrator!.getCurrentStory = getCurrent; + await session.story({ storyId: manifest.entries[0].id }); + const selected = (await session.find({ automationId: 'button-primary' })).result; + await expect(session.act({ action: 'activate', ref: selected.matches[0].ref, sample: selected.sample.id })).resolves.toBeDefined(); + }); + }); + + test('context-sensitive input rejects mismatched native markers without needing an intervening stories read', async () => { + await withStories(async ({ harness, session }) => { + await session.story({ storyId: manifest.entries[0].id }); + const found = (await session.find({ automationId: 'button-primary' })).result; + harness.host.setElementName('story-root', JSON.stringify({ storyId: manifest.entries[1].id })); + await expect(session.act({ action: 'activate', ref: found.matches[0].ref, sample: found.sample.id })).rejects.toMatchObject({ + code: 'stale element reference', + data: { reason: 'story-context-stale' }, + }); + }); + }); + test('select a story the story root confirms, and expire references into the preview it replaced', async () => { await withStories(async ({ session }) => { expect((await session.stories()).result).toEqual({ @@ -91,6 +289,35 @@ describe('story operations', () => { }); }); + test('same-session select/reset never revives old preview references when native records survive and are observed again', async () => { + await withStories(async ({ harness, session }) => { + // A native preview container can survive a React render; keep its fake native records alive too. + jest.spyOn(harness.host, 'resetPreview').mockImplementation(() => {}); + const old = (await session.find({ automationId: 'button-primary' })).result; + const oldRef = old.matches[0].ref; + const native = harness.server.sessions.get(session.id).operations.refs.resolve(oldRef, old.sample.epoch.region); + if (!native.ok) throw new Error('The initial preview reference must resolve.'); + for (const action of ['select', 'reset'] as const) { + const before = (await session.find({ automationId: 'button-primary' })).result; + const beforeRef = before.matches[0].ref; + const changed = (await session.story({ action, storyId: manifest.entries[0].id })).result; + expect(changed).toMatchObject({ disposition: 'applied', verification: 'marker' }); + expect(changed.region).toBeGreaterThan(before.sample.epoch.region); + await expect(session.observe({ within: beforeRef })).rejects.toMatchObject({ + code: 'stale element reference', + data: { reason: 'ref-region-changed' }, + }); + const current = (await session.find({ automationId: 'button-primary' })).result; + await expect(session.observe({ within: beforeRef })).rejects.toMatchObject({ code: 'stale element reference' }); + expect(current.matches[0].ref).not.toBe(beforeRef); + const resolved = harness.server.sessions.get(session.id).operations.refs.resolve(current.matches[0].ref, changed.region); + expect(resolved).toMatchObject({ ok: true, entry: { nativeId: native.entry.nativeId } }); + await expect(session.observe({ within: current.matches[0].ref })).resolves.toBeDefined(); + } + await expect(session.observe({ within: oldRef })).rejects.toMatchObject({ code: 'stale element reference' }); + }); + }); + test('fence further mutations after a story whose completion is unknown, without recording runtime text', async () => { await withStories(async ({ harness, session }) => { const orchestrator = harness.storyOrchestrator!; @@ -121,6 +348,25 @@ describe('story operations', () => { try { const client = createDesktopDriverClient({ grant: grant.token, url: harness.server.url }); const session = await client.newSession({ alwaysMatch: { 'furn:target': harness.target.id } }); + const owner = harness.server.sessions.get(session.id); + const foreign = authority.issue({ label: 'foreign', targets: '*' }); + const foreignClient = createDesktopDriverClient({ grant: foreign.token, url: harness.server.url }); + const anonymous = createDesktopDriverClient({ url: harness.server.url }); + const select = jest.spyOn(storyHarness.storyOrchestrator!, 'selectStory'); + const before = owner.story; + for (const rejected of [foreignClient, anonymous]) { + for (const route of ['/furn/story', '/furn/story/reset', '/furn/story/args']) { + await expect( + rejected.request('POST', `/session/${session.id}${route}`, { + requestId: 'unauthorized', + runId: 'unauthorized', + storyId: 'components-button--default', + args: { label: 'private-value-never-recorded' }, + }), + ).rejects.toBeDefined(); + } + } + expect(select).not.toHaveBeenCalled(); await expect( client.request('POST', `/session/${session.id}/furn/story/args`, { storyId: 'components-button--default', @@ -128,6 +374,9 @@ describe('story operations', () => { }), ).rejects.toMatchObject({ code: 'unsupported operation', data: { reason: 'story-args-unsupported' } }); expect(storyHarness.storyOrchestrator!.argUpdates).toEqual([]); + expect(owner.story).toEqual(before); + expect(JSON.stringify(await session.journal())).not.toContain('private-value-never-recorded'); + expect(JSON.stringify(await session.journal())).not.toContain('not dispatched'); } finally { await harness.close(); await storyHarness.close(); diff --git a/packages/agentic/desktop-driver/src/storybook.ts b/packages/agentic/desktop-driver/src/storybook.ts index 9aa50d9d49..7467788996 100644 --- a/packages/agentic/desktop-driver/src/storybook.ts +++ b/packages/agentic/desktop-driver/src/storybook.ts @@ -1,6 +1,8 @@ import type { DesktopStoryTests } from './authoring/storyTests.js'; import type { ResolvedDesktopCaseOptions } from './authoring/input.js'; import type { DesktopEndpoint } from './protocol/types.js'; +import { WebDriverError } from './protocol/errors.js'; +import { attachFailureEffect } from './protocol/settlement.js'; export type DesktopStoryManifestEntry = { id: string; @@ -33,15 +35,74 @@ export type StorySelectionRequest = { }; export type StoryReadyResult = { + connectionEpoch?: string; + markerVersion?: 1; previewGeneration: number; + requestId?: string; runId: string; storyId: string; }; +/** Local provider lifetime; never included in channel messages or serialized operation inputs. */ +export type StoryProviderContext = { + lifetimeId: string; + signal: AbortSignal; +}; + +export class StoryProviderError extends Error { + readonly effect: 'none' | 'unknown'; + + constructor(message: string, effect: 'none' | 'unknown') { + super(message); + this.effect = effect; + } +} + +export class StoryBridgeUnavailableError extends WebDriverError { + constructor(endpoint: DesktopEndpoint, renderer: 'fabric' | 'paper', reason: 'bridge-unavailable' | 'relaunch-required') { + super('unknown error', 'The authenticated Storybook bridge is unavailable. Relaunch the application before selecting a story.', { + endpoint, + reason, + renderer, + }); + attachFailureEffect(this, 'none'); + } +} + +/** Versioned markers bind readiness to the exact connection and request, not just the story name. */ +export function matchesStoryMarker(value: string, expected: StoryReadyResult): boolean { + const marker = parseStoryMarker(value); + if (!Number.isSafeInteger(expected.previewGeneration) || expected.previewGeneration < 1 || !expected.runId || !expected.storyId) + return false; + if (expected.markerVersion === 1 && (!expected.connectionEpoch || !expected.requestId)) return false; + if (expected.markerVersion === undefined && (expected.connectionEpoch !== undefined || expected.requestId !== undefined)) return false; + return ( + marker !== undefined && + marker.storyId === expected.storyId && + marker.runId === expected.runId && + marker.previewGeneration === expected.previewGeneration && + (expected.markerVersion === undefined || + (marker.markerVersion === 1 && marker.connectionEpoch === expected.connectionEpoch && marker.requestId === expected.requestId)) + ); +} + +export function parseStoryMarker(value: string): Record | undefined { + try { + const marker: unknown = JSON.parse(value); + if (typeof marker !== 'object' || marker === null) { + return undefined; + } + const fields = marker as Record; + return typeof fields.storyId === 'string' ? fields : undefined; + } catch { + return undefined; + } +} + export interface StoryOrchestrator { - getManifest(): Promise; - getCurrentStory(): Promise; - selectStory(request: StorySelectionRequest): Promise; - resetStory(request: StorySelectionRequest): Promise; - updateArgs?(storyId: string, args: Readonly>): Promise; + getManifest(context?: StoryProviderContext): Promise; + getCurrentStory(context?: StoryProviderContext): Promise; + selectStory(request: StorySelectionRequest, context?: StoryProviderContext): Promise; + resetStory(request: StorySelectionRequest, context?: StoryProviderContext): Promise; + updateArgs?(storyId: string, args: Readonly>, context?: StoryProviderContext): Promise; } diff --git a/packages/agentic/desktop-driver/src/testing/FakeStoryOrchestrator.ts b/packages/agentic/desktop-driver/src/testing/FakeStoryOrchestrator.ts index dc14b9bc04..3e89af57ed 100644 --- a/packages/agentic/desktop-driver/src/testing/FakeStoryOrchestrator.ts +++ b/packages/agentic/desktop-driver/src/testing/FakeStoryOrchestrator.ts @@ -1,5 +1,11 @@ import type { FakeDesktopHost } from '../hosts/fake/FakeDesktopHost.js'; -import type { DesktopStoryManifest, StoryOrchestrator, StoryReadyResult, StorySelectionRequest } from '../storybook.js'; +import type { + DesktopStoryManifest, + StoryOrchestrator, + StoryProviderContext, + StoryReadyResult, + StorySelectionRequest, +} from '../storybook.js'; export class FakeStoryOrchestrator implements StoryOrchestrator { readonly argUpdates: { args: Readonly>; storyId: string }[] = []; @@ -14,23 +20,28 @@ export class FakeStoryOrchestrator implements StoryOrchestrator { this.host = host; } - async getCurrentStory(): Promise { + async getCurrentStory(context?: StoryProviderContext): Promise { + context?.signal.throwIfAborted(); return this.current; } - async getManifest(): Promise { + async getManifest(context?: StoryProviderContext): Promise { + context?.signal.throwIfAborted(); return this.manifest; } - selectStory(request: StorySelectionRequest): Promise { + selectStory(request: StorySelectionRequest, context?: StoryProviderContext): Promise { + context?.signal.throwIfAborted(); return this.select(request); } - resetStory(request: StorySelectionRequest): Promise { + resetStory(request: StorySelectionRequest, context?: StoryProviderContext): Promise { + context?.signal.throwIfAborted(); return this.select(request); } - async updateArgs(storyId: string, args: Readonly>): Promise { + async updateArgs(storyId: string, args: Readonly>, context?: StoryProviderContext): Promise { + context?.signal.throwIfAborted(); this.requireStory(storyId); this.argUpdates.push({ args, storyId }); } diff --git a/packages/agentic/desktop-test/README.md b/packages/agentic/desktop-test/README.md index fda75cc0e0..1e1a15a591 100644 --- a/packages/agentic/desktop-test/README.md +++ b/packages/agentic/desktop-test/README.md @@ -71,6 +71,14 @@ when working on the package. - Generic and Storybook native services share one factory with durable quarantine storage and exact application-exit probes. Restart does not clear uncertain effects; see [lifecycle and recovery](references/lifecycle.md). +- Owned Node services inherit a per-command parent lifeline. Cooperative + disposers await initialization and settlement before withdrawing descriptors; + Metro and command wrappers use their own SIGTERM handlers. Without a + disposer, the lifeline dispatches installed SIGTERM handlers directly, because + a Windows self-signal terminates without running them. A readiness receipt + is correlated to that command's incarnation and producer process, not a file + or a successful HTTP response. This proves only service readiness, not target, + application, or context readiness. Unsettled cleanup stays fenced. macOS RN Test App users must bundle before `prep`, then build: CocoaPods embeds only resources that exist during `pod install`. Renderer changes require a new diff --git a/packages/agentic/desktop-test/config/process-lifeline.mjs b/packages/agentic/desktop-test/config/process-lifeline.mjs new file mode 100644 index 0000000000..e2544c0cee --- /dev/null +++ b/packages/agentic/desktop-test/config/process-lifeline.mjs @@ -0,0 +1,109 @@ +import net from 'node:net'; + +// --import waits for this handshake before executing the entry point, including in late-spawned Node descendants. +const encoded = process.env.FURN_DESKTOP_PROCESS_CONTROL; +if (encoded) { + const context = JSON.parse(encoded); + if (context.schemaVersion !== 1 || typeof context.address !== 'string' || typeof context.nonce !== 'string') + throw new TypeError('Invalid owned process lifeline.'); + const socket = net.createConnection(context.address); + socket.setEncoding('utf8'); + const disposers = new Set(); + const pending = new Map(); + let stopping; + let finished = false; + let buffer = ''; + let resolveHandshake; + let rejectHandshake; + const handshake = new Promise((resolve, reject) => { + resolveHandshake = resolve; + rejectHandshake = reject; + }); + const timer = setTimeout(() => lost(new Error('Owned process lifeline handshake timed out.')), 5000); + function lost(error) { + clearTimeout(timer); + rejectHandshake(error); + for (const { reject, timer } of pending.values()) { + clearTimeout(timer); + reject(error); + } + pending.clear(); + if (finished || stopping) return; + stopping = (async () => { + if (disposers.size) { + const results = await Promise.allSettled([...disposers].map((dispose) => Promise.resolve().then(dispose))); + const failures = results.filter((result) => result.status === 'rejected').map((result) => result.reason); + if (failures.length) throw new AggregateError(failures, 'Owned process disposal is incomplete; recovery remains fenced.'); + } else { + // Metro and command wrappers keep their own signal handlers. Never kill a parent process group from a child. + // Windows terminates on a self-SIGTERM without running handlers, so dispatch installed handlers directly. + if (process.listenerCount('SIGTERM')) process.emit('SIGTERM', 'SIGTERM'); + else process.kill(process.pid, 'SIGTERM'); + } + })(); + void stopping.catch((failure) => { + process.stderr.write(`${failure.stack ?? failure}\n`); + process.exitCode = 1; + if (!socket.destroyed) socket.write(`${JSON.stringify({ type: 'disposal-failed', message: String(failure.message ?? failure) })}\n`); + }); + } + socket.on('error', lost); + socket.on('close', () => lost(new Error('Owned process parent lifeline closed.'))); + socket.on('connect', () => socket.write(`${JSON.stringify({ type: 'hello', nonce: context.nonce, processId: process.pid })}\n`)); + socket.on('data', (text) => { + buffer += text; + if (Buffer.byteLength(buffer) > 65536) { + lost(new Error('Oversized process control frame.')); + socket.destroy(); + return; + } + let newline; + while ((newline = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + try { + const frame = JSON.parse(line); + if (frame.type === 'accepted') { + clearTimeout(timer); + resolveHandshake(); + } else if (frame.type === 'stop') { + lost(new Error('Owned process stop requested.')); + } else if (frame.type === 'acknowledged' && pending.has(frame.requestId)) { + const item = pending.get(frame.requestId); + clearTimeout(item.timer); + pending.delete(frame.requestId); + item.resolve(); + } else throw new Error('Invalid owned process control response.'); + } catch (error) { + lost(error); + socket.destroy(); + } + } + }); + await handshake; + if (stopping) throw new Error('Owned process stopped before its entry point.'); + socket.unref(); + globalThis[Symbol.for('furn.desktop.process-control')] = { + register(dispose) { + if (finished || stopping) throw new Error('Owned process is already stopping.'); + disposers.add(dispose); + return () => disposers.delete(dispose); + }, + async ready(readiness) { + if (finished || stopping) throw new Error('A stopping process cannot publish readiness.'); + const requestId = `${process.pid}-${pending.size}-${Date.now()}`; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(requestId); + reject(new Error('Owned service readiness was not acknowledged.')); + }, 5000); + pending.set(requestId, { resolve, reject, timer }); + socket.write(`${JSON.stringify({ type: 'ready', requestId, readiness })}\n`); + }); + }, + finish() { + finished = true; + socket.end('{"type":"disposed"}\n'); + }, + }; +} diff --git a/packages/agentic/desktop-test/config/process-path.cjs b/packages/agentic/desktop-test/config/process-path.cjs new file mode 100644 index 0000000000..7a8c0dff83 --- /dev/null +++ b/packages/agentic/desktop-test/config/process-path.cjs @@ -0,0 +1,4 @@ +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +exports.processLifelineUrl = pathToFileURL(path.join(__dirname, 'process-lifeline.mjs')).href; diff --git a/packages/agentic/desktop-test/config/process-path.d.cts b/packages/agentic/desktop-test/config/process-path.d.cts new file mode 100644 index 0000000000..33c3961b0f --- /dev/null +++ b/packages/agentic/desktop-test/config/process-path.d.cts @@ -0,0 +1 @@ +export const processLifelineUrl: string; diff --git a/packages/agentic/desktop-test/fixtures/process-control/child.mjs b/packages/agentic/desktop-test/fixtures/process-control/child.mjs new file mode 100644 index 0000000000..d4690b55fe --- /dev/null +++ b/packages/agentic/desktop-test/fixtures/process-control/child.mjs @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import { spawn } from 'node:child_process'; + +const [mode, marker, preload] = process.argv.slice(2); +const control = globalThis[Symbol.for('furn.desktop.process-control')]; +if (mode === 'late') { + fs.writeFileSync(marker, 'entry-must-not-run'); +} else if (mode === 'wrapper') { + fs.writeFileSync(`${marker}.wrapper`, String(process.pid)); + const child = spawn(process.execPath, ['--import', preload, import.meta.filename, 'service', marker], { + env: process.env, + stdio: ['ignore', 'inherit', 'inherit'], + }); + child.on('error', (error) => { + throw error; + }); + setInterval(() => undefined, 60_000); +} else if (mode === 'signal') { + const keepAlive = setInterval(() => undefined, 60_000); + process.once('SIGTERM', () => { + setTimeout(() => { + fs.writeFileSync(marker, 'graceful'); + clearInterval(keepAlive); + }, 30); + }); + fs.writeFileSync(marker, 'started'); +} else if (mode === 'borrowed') { + fs.writeFileSync(marker, String(process.pid)); + setInterval(() => undefined, 60_000); +} else { + const keepAlive = setInterval(() => undefined, 60_000); + fs.writeFileSync(`${marker}.socket`, JSON.parse(process.env.FURN_DESKTOP_PROCESS_CONTROL).address); + fs.writeFileSync(marker, String(process.pid)); + control.register(async () => { + if (mode === 'stalled') await new Promise(() => undefined); + if (mode === 'failed') throw new Error('Injected descriptor withdrawal failure.'); + await new Promise((resolve) => setTimeout(resolve, 30)); + fs.rmSync(marker); + clearInterval(keepAlive); + control.finish(); + }); + await control.ready({ tier: 'service-ready', service: 'fixture', descriptor: 'fixture.json' }); +} diff --git a/packages/agentic/desktop-test/fixtures/process-control/parent.mjs b/packages/agentic/desktop-test/fixtures/process-control/parent.mjs new file mode 100644 index 0000000000..4945138afb --- /dev/null +++ b/packages/agentic/desktop-test/fixtures/process-control/parent.mjs @@ -0,0 +1,14 @@ +import { NodeDesktopCommandRunner } from '../../lib/commands.js'; + +const [fixture, mode, marker, artifactsRoot, preload] = process.argv.slice(2); +const runner = new NodeDesktopCommandRunner({ logDirectory: artifactsRoot, shutdownTimeoutMs: 2000, output: { write: () => true } }); +const running = runner.start({ + command: process.execPath, + args: [fixture, mode, marker, preload], + cwd: process.cwd(), + env: {}, +}); +const ready = await running.waitForReadiness({ tier: 'service-ready', service: 'fixture', descriptor: 'fixture.json' }, 15_000); +process.send(ready); +await new Promise((resolve) => process.once('message', resolve)); +await runner.close(); diff --git a/packages/agentic/desktop-test/package.json b/packages/agentic/desktop-test/package.json index 4cdbc84aa1..6fa9a882f5 100644 --- a/packages/agentic/desktop-test/package.json +++ b/packages/agentic/desktop-test/package.json @@ -55,6 +55,21 @@ "import": "./lib/driverService.js", "default": "./src/driverService.ts" }, + "./process-control": { + "types": "./lib/processControl.d.ts", + "import": "./lib/processControl.js", + "default": "./src/processControl.ts" + }, + "./resources": { + "types": "./lib/resources.d.ts", + "import": "./lib/resources.js", + "default": "./src/resources.ts" + }, + "./ownership": { + "types": "./lib/ownership.d.ts", + "import": "./lib/ownership.js", + "default": "./src/ownership.ts" + }, "./jasmine": { "types": "./lib/jasmine/index.d.ts", "import": "./lib/jasmine/index.js", diff --git a/packages/agentic/desktop-test/references/README.md b/packages/agentic/desktop-test/references/README.md index 849d8ecf30..5de1507f3f 100644 --- a/packages/agentic/desktop-test/references/README.md +++ b/packages/agentic/desktop-test/references/README.md @@ -7,13 +7,14 @@ owner_paths: # Desktop Test references -| ID | Document | Read when | -| ----------------------------- | ----------------------------------- | -------------------------------------------------------------------------------- | -| `desktop-test.configuration` | [Configuration](configuration.md) | Adding an application target or suite | -| `desktop-test.commands` | [Commands](commands.md) | Running stages, suites, or service mode | -| `desktop-test.platforms` | [Platform setup](platforms.md) | Preparing or building a platform-specific app | -| `desktop-test.wdio-suites` | [WDIO suites](wdio-suites.md) | Configuring suites or native page adapters | -| `desktop-test.lifecycle` | [Lifecycle](lifecycle.md) | Changing ownership, supervision, or cancellation | -| `desktop-test.reporting` | [Reporting](reporting.md) | Reading run results, artifacts, or CI output | -| `desktop-test.contributing` | [Contributing](contributing.md) | Editing the harness or its tests | -| `desktop-test.current-status` | [Current status](current-status.md) | Assessing lifecycle readiness, remaining gates, and historical consumer evidence | +| ID | Document | Read when | +| ----------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `desktop-test.configuration` | [Configuration](configuration.md) | Adding an application target or suite | +| `desktop-test.commands` | [Commands](commands.md) | Running stages, suites, or service mode | +| `desktop-test.platforms` | [Platform setup](platforms.md) | Preparing or building a platform-specific app | +| `desktop-test.wdio-suites` | [WDIO suites](wdio-suites.md) | Configuring suites or native page adapters | +| `desktop-test.lifecycle` | [Lifecycle](lifecycle.md) | Changing ownership, supervision, or cancellation | +| `desktop-test.reporting` | [Reporting](reporting.md) | Reading run results, artifacts, or CI output | +| `desktop-test.contributing` | [Contributing](contributing.md) | Editing the harness or its tests | +| `desktop-test.current-status` | [Current status](current-status.md) | Assessing lifecycle readiness, remaining gates, and historical consumer evidence | +| `desktop-test.native-qualification` | [Native descriptor-path qualification](native-qualification.md) | Running renderer-specific native and descriptor-path qualification | diff --git a/packages/agentic/desktop-test/references/contributing.md b/packages/agentic/desktop-test/references/contributing.md index ee9a698cc6..ab381c6c40 100644 --- a/packages/agentic/desktop-test/references/contributing.md +++ b/packages/agentic/desktop-test/references/contributing.md @@ -25,12 +25,20 @@ modules and the leaf `@fluentui-react-native/desktop-driver/authoring/input`; im everything else from desktop-driver as types only. The storybook-desktop `sourceConfig` test guards this. +The service factory resolves recovery before calling `createTarget(host, recovery)`. +The second argument exposes the same owner-only storage and exact-incarnation +probe used by settlement. Integration-specific sibling records share the stable +`settlement--` prefix; listener ports, rotating nonces, +and report/service incarnations must not affect it. + Add contract coverage for schema, lifecycle, reporting protocol, and failure paths. Do not move Storybook-specific story extraction into this package. The package-boundary contract exercises the built public entry points with a loader that rejects React, React Native, and Storybook imports; keep its standalone consumer and negative fixtures representative of package manifests -and runtime imports. +and runtime imports. Pass the loader as a `pathToFileURL(...).href` URL to +Node's `--experimental-loader`; a native Windows drive path is not a valid ESM +loader specifier. Run `yarn format`, `yarn lint`, `yarn build`, and `yarn test` from the package. Run consumer suites when changing public configuration or lifecycle behavior. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 0e6bd49ec4..610a03a16f 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -7,7 +7,7 @@ owner_paths: # Current lifecycle and qualification status -**Work in progress, updated 2026-11-05.** Done means the listed implementation, +**Work in progress, updated 2026-11-06.** Done means the listed implementation, not full cross-platform qualification. This package is the intended neutral workload host; the existing Storybook lifecycle takeover is transitional. No new coordinator package, daemon, dynamic plugin loader, or package rename @@ -17,24 +17,25 @@ and its sub-issues. ## Capability matrix -| Capability | State | Limit or next gate | -| -------------------------------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| App/suite configuration and standalone installed-app use | Done | Storybook is optional; runtime boundary and standalone-consumer contracts reject React, React Native, and Storybook imports/dependencies. Executable config is trusted operator code, not a sandbox. | -| Native service and helper composition | Done | Generic supervisor and supervised Storybook child use the same real `driverService` factory, helper policy, authority and settled close. | -| Standalone WDIO/Jasmine execution | Done | WDIO owns its session. Storybook CSF callbacks use a runner-owned session borrowed by a Node worker. | -| Required services, reporting, structured results | Done | One collector; bounded JSON/NDJSON summaries. Service-ready is not app/context-ready. | -| `serve --agent` descriptor publication | Done | Protects driver routes; no automatic build/launch from MCP. Normal WDIO cannot use the agent-served path yet. | -| Cleanup failure fence | Done, conservative | Failed cleanup retains `interactive.lock`; manual exact-owner/resource inspection is required. No automatic reclaim. | -| Complete lifecycle settlement | Work in progress | Guarded abandoned work, late acquisitions, exact resource ledger, and report/workload delegation separation need the shared resource model. | -| Durable quarantine wiring | Done | Both native service paths use owner-only storage and exact-incarnation probes; restart is not recovery proof. Live endpoint qualification remains separate. | -| Supervisor crash containment | Work in progress | Child lifelines, survivor reconciliation, stale-claim protocol, status/stop/recover tooling. | -| Storybook resource contributions | Work in progress | Current integration substitutes its CLI lifecycle; retain the channel/driver child until relocation is justified and qualified. | -| MCP/WDIO handoff | Work in progress | Must await drain/deletion; attached-app handoff is independent of future supervisor-owned launch. | -| Supervisor-owned app lifetime | Work in progress | Current session-owned launch/externally owned attach semantics remain; never silently retain or terminate another owner's app. | -| Cross-service/multi-target claims | Work in progress | Target aliases and input mutexes are not exact-application exclusion; resolve registered-ID collisions before expanding hosts. | -| Bounded persisted run/status/artifact queries | Done | Harness-local read service/client; atomic snapshots, terminal evidence, scoped expiring grants, bounded pages and allowlisted opaque artifact IDs. No management transport. | -| Workload management/control API | Work in progress | Future management selects only declared workload IDs, not commands/paths/environment; lifecycle/handoff gates still apply. | -| Automatic service reuse and per-user daemon | Deferred | Requires exact identity/config matching and evidence of need. Never adopt an unknown port occupant. | +| Capability | State | Limit or next gate | +| -------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| App/suite configuration and standalone installed-app use | Done | Storybook is optional; runtime boundary and standalone-consumer contracts reject React, React Native, and Storybook imports/dependencies. Executable config is trusted operator code, not a sandbox. | +| Native service and helper composition | Done | Generic supervisor and supervised Storybook child use the same real `driverService` factory, helper policy, authority and settled close. | +| Standalone WDIO/Jasmine execution | Done | WDIO owns its session. Storybook CSF callbacks use a runner-owned session borrowed by a Node worker. | +| Required services, reporting, structured results | Done | One collector; bounded JSON/NDJSON summaries. Service-ready is not app/context-ready. | +| `serve --agent` descriptor publication | Done | Protects driver routes; no automatic build/launch from MCP. Normal WDIO cannot use the agent-served path yet. | +| Cleanup failure fence | Done, conservative | Failed or timed-out disposal retains the claim with an exact resource generation and unresolved effect; manual inspection is required. No automatic reclaim. | +| Complete lifecycle settlement | Done, conservative | Guarded work settles before successful close; late acquisitions are disposed; required-service failures stop dependants; owned resources dispose in reverse order. | +| Typed generic resource contributions | Done | Static contribution plans validate IDs/dependencies and record readiness, generation, identity, ownership, and settled disposal through public `resources` and `ownership` subpaths. | +| Durable quarantine wiring | Done | Both native service paths use owner-only storage and exact-incarnation probes; restart is not recovery proof. Live endpoint qualification remains separate. | +| Supervisor crash containment | Work in progress | Cooperative Node service lifelines and correlated service readiness implemented; portable parent/wrapper crash, late-spawn, failed-withdrawal and stalled-child contracts. Live macOS survivors and Windows containment remain unqualified; no automatic reclaim. Windows: non-detached children are hard-terminated with a crashed owner (observed A/B), so the three crash-disposal contracts are skipped there. | +| Storybook resource contributions | Work in progress | Current integration substitutes its CLI lifecycle; retain the channel/driver child until relocation is justified and qualified. | +| MCP/WDIO handoff | Work in progress | Must await drain/deletion; attached-app handoff is independent of future supervisor-owned launch. | +| Supervisor-owned app lifetime | Work in progress | Current session-owned launch/externally owned attach semantics remain; never silently retain or terminate another owner's app. | +| Cross-service/multi-target claims | Work in progress | Configured aliases now have distinct registered IDs while retaining physical identity; exact-application exclusion and input-mutex qualification remain open. | +| Bounded persisted run/status/artifact queries | Done | Harness-local read service/client; atomic snapshots, terminal evidence, scoped expiring grants, bounded pages and allowlisted opaque artifact IDs. No management transport. | +| Workload management/control API | Work in progress | Future management selects only declared workload IDs, not commands/paths/environment; lifecycle/handoff gates still apply. | +| Automatic service reuse and per-user daemon | Deferred | Requires exact identity/config matching and evidence of need. Never adopt an unknown port occupant. | See [lifecycle](lifecycle.md), [commands](commands.md), and `@fluentui-react-native/desktop-driver/references/current-status.md` for the @@ -89,3 +90,543 @@ selection, passed/failed/skipped counts, cleanup outcome, and approved artifact locator. Distinguish tests that actually ran from build/bundle or fake-host checks. Keep measured text/operation costs in the driver status reference and MCP discovery overhead in the adapter's status reference. + +### Provisional F15 macOS evidence — 2026-11-06 + +**Provisional; exact source revision `388a60cc803ab78202a6f64f92e3f53b779c5c39`.** +App: Agentic Components Storybook / ReactTestApp (configured bundle ID +`com.microsoft.fluentui.agenticstorybook`). OS: macOS 27.2, arm64. + +| Renderer | Command and actual selection | Result | Cleanup | Artifact | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| macOS Fabric | `STORYBOOK_SMOKE_STORY=components-button--default yarn storybook smoke --macos --mode stories-and-tests`; 211 selected/executed | Passed 211, failed 0, skipped 0, unsupported 0; retries 0 | No owned app/driver/channel/Metro process; ports 21338/30029/45382 had no listeners; `interactive.lock` absent | `apps/storybook/artifacts/desktop/0f99740f-631b-444a-a705-76a9b4a761b7/run.json` | +| macOS Paper | `STORYBOOK_SMOKE_STORY=components-button--default yarn storybook smoke --macos --paper --mode stories-and-tests`; 211 selected/executed | Passed 211, failed 0, skipped 0, unsupported 0; retries 0 | No owned app/driver/channel/Metro process; ports 21338/30029/45382 had no listeners; `interactive.lock` absent | `apps/storybook/artifacts/desktop/e82a83c2-a01d-41c0-b308-2551630e8b1e/run.json` | +| Windows Fabric | Not run — this machine is macOS-only | Not run; 0 selected | Not applicable | None | +| Win32 Paper | Not run — this machine is macOS-only | Not run; 0 selected | Not applicable | None | + +| Descriptor-path renderer | Command / selection | Passed / failed / skipped / unsupported | Cleanup | Artifact | +| ------------------------ | --------------------------------------------------- | --------------------------------------- | -------------------------------------------- | -------- | +| macOS Fabric | Not run; no real descriptor consumed; selection N/A | 0 / 0 / 0 / 0; Not run | N/A for descriptor path; smoke cleanup above | None | +| macOS Paper | Not run; no real descriptor consumed; selection N/A | 0 / 0 / 0 / 0; Not run | N/A for descriptor path; smoke cleanup above | None | + +The Windows Fabric and Win32 Paper "Not run" rows above describe that macOS +host only. They are superseded, not erased, by the Windows-host section +"Windows Fabric and Win32 Paper qualification — 2026-11-06" at the end of this +file. + +These are real native Storybook smoke runs through the non-agent WDIO path. +They do not demonstrate descriptor publication, service restart, failed +cleanup, or exact attached-app preservation. The `STORYBOOK_SMOKE_STORY` +environment value did not constrain the selection; the artifact reports 211 +callbacks. The initial Fabric attempt failed before selection due to an +unsynchronized CocoaPods sandbox; `yarn storybook prep --macos` repaired the +generated project before the passing retry. See the runbook in +[`native-qualification.md`](native-qualification.md). The authoritative +descriptor-path and F2/F3/F4/F5 cases remain **Not run** until the combined +integration commit is available and the native lock is granted again. + +## F15 combined-candidate macOS qualification — partial, 2026-11-06 + +**Exact tested source:** `3c780cfd1bccaf510ece080e4be2b516c645989a`. +App: Agentic Components Storybook / ReactTestApp, bundle identifier +`com.microsoft.fluentui.agenticstorybook`. OS: macOS 27.2, arm64. +This is a partial ledger for this exact candidate, not final acceptance. The +F5 reference-generation fix at +`2d8e8db001b64ebea85f0d6dce141f040fb5182d` is not in this tested source and +requires a new native qualification. The original accepted-reference probe +remains inconclusive and is not attributed to that fix. + +**Commands and selection:** helper build and permission preflight were +`yarn storybook build-driver --macos` and +`yarn desktop-driver doctor --platform macos --permissions` (no `--prompt`). +The Paper descriptor pass used +`yarn storybook driver --macos --paper --agent` and +`yarn storybook run --macos --paper`; Fabric used +`yarn storybook driver --macos --agent` and +`yarn storybook run --macos`. Both used the MCP SDK stdio client with the +default private `agent-connection.json`; its exact inline client launch command +was not retained. Those app/driver `run.json` records each report zero selected +and zero executed WDIO tests; MCP operations are manual evidence, not suite +counts. The separate Paper legacy lane used +`yarn storybook smoke --macos --paper --mode stories-and-tests`: 319 selected, +308 executed/passed, 0 failed, 11 skipped, 0 unsupported/incomplete/retries/ +flaky. Its artifact is +`apps/storybook/artifacts/desktop/a569510a-ac99-4ec9-8c78-4d8607bdaadd/run.json`. +Two additional 319/308/11 reports +(`8c6d031d-32e3-49c4-9e80-049202b74626` and +`e5117371-764c-45ff-8b14-97c5b7ec5036`) do not retain renderer metadata and +are not credited to Fabric or Paper. + +The 24-case matrix is recorded without promoting partial evidence to passes. +`Not run` means no case-specific native evidence was retained; it is not a +skip or pass. + +| Case | macOS Fabric | macOS Paper | Retained evidence / limitation | +| ------------------------------ | ------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Same-port replacement | Not run | Partial | Paper restart and rediscovery succeeded; explicit confirmation of old-session close was not retained. | +| Grant revocation | Not run | Pass | Paper delegated-grant revocation and child drain were reported complete; private run record only. | +| Dispose during open | Not run | Not run | No acquisition hold was injected. | +| Failed delete | Not run | Not run | No DELETE dispatch-loss injection was run. | +| Ambiguous create | Not run | Not run | No session-create response-loss injection was run. | +| Resource invalidation | Inconclusive | Partial | Fabric had a separate preview-child stale-element rejection (`stale element reference`, `ref-region-changed`), but the earlier accepted handle's kind/identity was not retained. Paper's old-preview rejection lacks retained handle kind/generation. | +| Story quarantine and recovery | Not run | Not run | No unknown-effect transition/recovery injection was run. | +| Protected args refusal | Partial | Pass | Fabric final attempt returned `story-args-unsupported`, unchanged story, and no probe value in error/journal; an earlier recorded post-attempt unchanged-state assertion was false. Paper refusal was reported with no effect. | +| Failed-cleanup claim retention | Not run | Not run | No owned-resource cleanup failure was injected. | +| Launch-mode discovery | Partial | Partial | Only attach mode was discovered; no launch-capable target was exercised. | +| Live cleanup and restart | Not run | Partial | Paper service restart/rediscovery ran; old-session close confirmation was not retained. | +| Attached-app preservation | Pass | Pass | The same ReactTestApp process survived agent-service shutdown; the qualification-launched app was stopped only after that check. | +| F4 post-dispatch cancellation | Not run | Not run | No cancellation-after-native-dispatch injection was run. | +| F4 readiness/render faults | Not run | Not run | No delayed-ready, correlated error, or render-fault injection was run. | +| F4 lost-context fencing | Not run | Not run | No lost-context mutation-fencing case was run. | +| F4 lost-context recovery | Not run | Not run | No new authenticated epoch recovery case was run. | +| F4 secondary windows | Not run | Not run | No secondary-window case was run. | +| F4 physical input | Failed | Not run | Fabric returned `physicalInputApplied:false`; no retry. This is not a pass. | +| F3 startup/shutdown/survivors | Partial | Partial | Normal bounded shutdown and cleanup completed; injected startup/shutdown survivor reconciliation was not run. | +| F5 ingress bypass | Partial | Partial | Fabric anonymous/foreign HTTP select, prepare, and args returned 403 and preview stayed unchanged. The first two foreign-WS checks failed; a later attempt passed with no observer-message delta. Paper bypass checks were reported passed, but detailed status fields were not retained. | +| F5 owned selection/reset | Partial | Partial | Selection/reset markers settled. Stale preview-reference identity and generation evidence is incomplete; see the MCP ledger. | +| F5 channel lifetime | Pass | Pass | Fabric stayed open after 21,003 ms idle; Paper's 21-second check was reported connected. | +| F5 manifest smoke | Not run | Partial | Paper smoke passed 308/319 with 11 skipped; one-session/no-HTTP-fallback evidence was not retained. It is not protected-ingress evidence. | +| F5 legacy separation | Not run | Not run | No separate legacy-channel behavior case was retained. | + +Fabric outcome totals: 2 Pass, 5 Partial, 1 Inconclusive, 1 Failed, 15 Not +run. Paper totals: 4 Pass, 8 Partial, 12 Not run. These are matrix-case +statuses, not WDIO counts. Windows Fabric and Win32 Paper are Not run because +this host is macOS-only. + +The fresh doctor reported Accessibility, PostEvent, and ScreenCapture +preflight `true`, no permission prompt, console on/unlocked, and the arm64 +release helper ad-hoc signed with designated requirement +`cdhash H"0d2e7a81dfd4695b7fb84aa1b3242f5e1773e8b4"`. Host load was 3.25 / +4.41 / 3.81 (1/5/15-minute averages). Per-renderer driver metadata is +`apps/storybook/artifacts/desktop/a58d2fdd-5584-47f4-8747-5307607b4c0d/run.json` +(Paper) and +`apps/storybook/artifacts/desktop/c15a35b9-7b8d-4edb-adba-84e3cd5a3023/run.json` +(Fabric); Fabric app launch is +`apps/storybook/artifacts/desktop/8fb9a576-cde8-470c-a8bb-a76b6d8fabc0/run.json`. +All report zero selected/executed automated work, so they do not increase the +matrix totals. Sanitized operation records and private captures are under +`f15-native-qualification-3c780cfd-20261106T0200/` in the machine-local log +root; they are not committed. + +Retained retries/failures are not erased by later successes: Fabric's first +two foreign-WebSocket assertions failed before the final no-message-delta +attempt passed; an intermediate protected-args attempt recorded +`currentStoryUnchanged:false` before the final attempt recorded `true`; and the +first capture caller shape failed before the corrected call passed. No cause +is inferred from those retries. The Fabric physical-input attempt remained +failed and was not repeated. + +At handoff, ports 21338/30029/45382 had no listeners, the workload lock and +default descriptor were absent, and the Fabric cleanup record showed its +service owner exited, grant revoked, descriptor removed, and attached app +preserved through service shutdown. Paper cleanup was reported, but its +process IDs were not retained, so PID-level Paper process exit is not +independently asserted here. No further native workload was started. No +product implementation was changed in this ledger update. + +## F15 combined-candidate macOS requalification — partial, 2026-11-06 + +**Exact tested source:** `a19fd3e1e7b3f1f443a241b1571cb029162f4ef2`. +App: Agentic Components Storybook / ReactTestApp, +`com.microsoft.fluentui.agenticstorybook`; macOS 27.2 arm64. The exact helper +was rebuilt with `yarn storybook build-driver --macos`; fresh, nonprompting +`yarn desktop-driver doctor --platform macos --permissions` reported +Accessibility, PostEvent, and ScreenCapture as granted. No permission changes +were made. The arm64/release helper was ad-hoc signed with designated +requirement `cdhash H"0d2e7a81dfd4695b7fb84aa1b3242f5e1773e8b4"` and build +fingerprint +`b58e4d8692702fd1dfa16b73ac987d1738e6e17d16757cbc3c6007722b8e3869`. +Observed quiet host load was 2.49 / 5.27 / 5.47 (1/5/15-minute averages). + +The descriptor pass used the default private `agent-connection.json` through +an MCP SDK stdio client. Manual MCP calls have no suite selection; each +companion Storybook driver `run.json` recorded 0 selected and 0 executed tests. +The separate manifest-backed, non-agent smoke command selected and executed +211 callbacks in each renderer. These are legacy-lane results, not +protected-ingress or no-HTTP-fallback evidence. + +| Case | macOS Fabric | macOS Paper | Evidence and artifact | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Same-port replacement | Partial (recorded Failed at qualification; reclassified on contract review): the old client's first post-replacement `desktop_session.status` returned `adapter` / `service-unavailable`, the designed fail-closed rejection of the old incarnation; old session close then succeeded with no mutation replayed. Rediscovery on a later call was not demonstrated, because close is a no-op once invalid authority forgets the session. `desktop_observe.find` returned 0 matches, so no old-reference assertion was possible. | Not run | Fabric private sanitized record: `fabric-same-port-replacement-final.json`; service runs: `apps/storybook/artifacts/desktop/414cf449-bfd4-4b9f-b630-63e86ebdd5c4/run.json` and `apps/storybook/artifacts/desktop/d732ae38-df56-4b81-a56c-2791bdbac5e8/run.json`. | +| Grant revocation | Not run | Not run | No live revocation case on this SHA. | +| Dispose during open | Not run | Not run | No acquisition hold was injected. | +| Failed delete | Not run | Not run | No DELETE dispatch-loss injection was run. | +| Ambiguous create | Not run | Not run | No session-create response-loss injection was run. | +| Resource invalidation | Passed | Passed | Same-session preview-element refs were rejected after marker-settled select/reset; fresh find returned a distinct working alias and the old alias remained stale. Private records: `fabric-reference-sequence-retry.json`, `paper-reference-sequence.json`. | +| Story quarantine and recovery | Not run | Not run | No unknown-effect transition/recovery injection. | +| Protected args refusal | Not run | Not run | Not attempted on this SHA. | +| Failed-cleanup claim retention | Not run | Not run | No forced owned-resource cleanup failure. A normal runner retry was blocked by the live prior claim, retained until its owner exited and empty resources were verified. | +| Launch-mode discovery | Not run | Not run | No launch-capable versus attach-only target comparison on this SHA. | +| Live cleanup and restart | Partial | Not run | Fabric old service stopped and replacement started on the same ports; the old adapter's next status failed with `service-unavailable`. App PID 26792 remained alive through service stop/restart. | +| Attached-app preservation | Passed | Not run | Fabric app PID 26792 remained running after agent-service shutdown; it was stopped only after the check. Paper exact survival through service shutdown was not retained. | +| F4 post-dispatch cancellation | Not run | Not run | No dispatch-confirmed cancellation injection. | +| F4 readiness/render faults | Not run | Not run | No delayed-ready, correlated error, or render-fault injection. | +| F4 lost-context fencing | Not run | Not run | No lost-context mutation-fencing case. | +| F4 lost-context recovery | Not run | Not run | No new authenticated-epoch recovery case. | +| F4 secondary windows | Not run | Not run | No secondary-window case. | +| F4 physical input | Partial | Not run | One corrected Fabric `desktop_act` physical-click request was issued. MCP returned success and sequence completion, but the client did not retain the nested action mechanism/disposition; no physical-input pass is claimed and it was not repeated. Paper physical input was not attempted. | +| F3 startup/shutdown/survivors | Not run | Not run | No injected startup/shutdown survivor reconciliation. | +| F5 ingress bypass | Not run | Not run | No anonymous/foreign HTTP or WS mutation attempts on this SHA. | +| F5 owned selection/reset | Passed | Passed | Same-session authorized select and reset both applied with marker verification; preview-element old/new identity checks passed. | +| F5 channel lifetime | Passed | Not run | Fabric descriptor session remained open for 21,002 ms idle; subsequent status and close succeeded. No Paper idle interval on this SHA. | +| F5 manifest smoke | Partial | Partial | Non-agent `yarn storybook smoke --macos [--paper] --mode stories-and-tests` passed 211/211 with no skips or retries, but one-session/no-HTTP-fallback instrumentation was not retained. Separate from protected descriptor evidence. | +| F5 legacy separation | Partial | Partial | Non-agent smoke ran and was labeled separately as the legacy lane. No additional legacy-channel mutation trace was retained. | + +Fabric and Paper each passed the retained-preview element-reference sequence +and 211/211 separate legacy smoke callbacks. The same-port Fabric case was +recorded as Failed because its first post-replacement call returned +`service-unavailable`. Contract review reclassified it as Partial: that +response is the designed fail-closed rejection that +`packages/agentic/desktop-driver-mcp/src/server.test.ts` asserts before the next +call re-verifies discovery, but the native sequence ended with a close that does +not prove rediscovery. A native status call after the rejection remains +required. The +stale workload claim encountered on the first replacement attempt was not +removed until its exact owner was verified exited with no resources or +unresolved effects; the retry then started normally. On cleanup, owned app and +service PIDs were gone, ports 21338/30029/45382 had no listeners, the default +descriptor and workload lock were absent, and no external app was stopped. +The legacy run.json locators are +`apps/storybook/artifacts/desktop/a2475e8d-7ea9-465f-8461-b8f115cfcee5/run.json` +(Fabric) and +`apps/storybook/artifacts/desktop/87f0452f-5fd7-48eb-8494-a63202842521/run.json` +(Paper). Driver run records include the same-port attempt +`apps/storybook/artifacts/desktop/73563809-2d77-49cb-a297-c74fd57ecd36/run.json`, +blocked by the retained claim with 0 selected/executed tests. + +Windows Fabric and Win32 Paper remain **Not run** on this macOS-only host. +Fault-injection cases remain Not run because no existing documented native +hook was exercised. The older `3c780cfd1bccaf510ece080e4be2b516c645989a` +ledger and its inconclusive probe remain unchanged and separate. The Windows +results that follow were recorded later on a Windows host; they do not change +these macOS results. + +## Windows Fabric and Win32 Paper qualification — 2026-11-06 + +**Tested source:** the PR branch at `1f5333e25` plus two follow-up fixes from +this pass: `8458bde48` (a relaunched runtime reports `bridge-unavailable` +until its initial render) and `a6b89e924` (a reloaded runtime that +authenticates first retires the old bridge). Windows records started before +21:31Z predate both; `d558ed5d` includes only `8458bde48`; every later Windows +record and every Win32 record includes both. Host: Windows 11 Enterprise build +26300, AMD64, Node 26. Windows uses the React Native Test App host (window +title `Agentic Components Storybook`); Win32 uses the `rex` host, component +`AgenticStorybook` (window title `Agentic Components Storybook (Win32)`). +Windows App Runtime 1.8 was installed. The runbook is the Windows section of +[`native-qualification.md`](native-qualification.md). + +Preflight: `yarn storybook build-driver --windows` and `--win32` succeeded. +`yarn desktop-driver doctor --platform windows --permissions` and +`--platform win32 --permissions` report that `--permissions` is +**Unsupported** on Windows; doctor otherwise passed. No OS privacy or input +setting was changed. + +Each descriptor pass ran `yarn storybook driver -- --agent`, then +`yarn storybook run --`, then connected the MCP SDK stdio client to +`desktop-driver-mcp` through the default private `agent-connection.json`. +Only one interactive workload ran at a time. Manual MCP calls have no suite +selection, so each driver and app `run.json` reports 0 selected and 0 +executed tests; the cases are manual evidence in machine-local sanitized +records that are not committed. + +### Smoke runs (legacy lane, not descriptor evidence) + +Counts are selected / executed / passed / failed / skipped. + +| Endpoint | Command | Result | Artifact | +| -------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | +| Windows Fabric | `yarn storybook smoke --windows --mode stories-and-tests` | Failed: 202 / 202 / 202 / 0 / 0; the relaunch phase reported `relaunch-required`. This led to `8458bde48`. | `apps/storybook/artifacts/desktop/2a80a82b-febc-40f8-85de-50c36f92f842/run.json` | +| Windows Fabric | Same | Failed: 206 / 206 / 205 / 1 / 0; the one failure is the physical-input test (foreground not acquired). | `apps/storybook/artifacts/desktop/9506772a-d3f9-4f53-966e-b6c75799b463/run.json` | +| Win32 Paper | `yarn storybook smoke --win32 --mode stories-and-tests` | Failed: 180 / 180 / 179 / 1 / 0; the one failure is a physical-pointer test, and 43 groups were not run. | `apps/storybook/artifacts/desktop/7a737861-c31b-4eeb-ac6d-b21b8a96739d/run.json` | +| Win32 Paper | Same, with physical input disabled as on hosted runners | Passed: 266 / 214 / 214 / 0 / 52. | `apps/storybook/artifacts/desktop/4ba5a00d-40d8-4a34-86f9-e564448623e2/run.json` | +| CI A/B | The `win32-storybook` and `windows-storybook` job steps | Win32 baseline failed at owned-service shutdown with 214 / 214 passed; the lifeline fix passed 266 / 214 / 214 / 0 / 52. Windows baseline failed 204 / 204 / 202 / 2; the readiness fix passed 312 / 234 / 234 / 0 / 78. | Baselines `47471c35-…` and `b7c67987-…`; fixes `047f5c3a-…` and `6cbfe8c5-…` | + +### Descriptor matrix + +| Case | Windows Fabric | Win32 Paper | Evidence | +| ------------------------------ | -------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Same-port replacement | Pass | Pass | The old client's next status returned `service-unavailable`; old refs returned `no-session`; rediscovery, close, and a new session succeeded. No old mutation replayed. The new session's first select returned `relaunch-required` (rejected, effect none); after an app relaunch, select and reset applied with marker verification. | +| Grant revocation | Pass | Pass | After an external revoke (200), the next call and status returned `service-unavailable` ("authority is no longer valid"), and reopening on the same client was refused; a fresh connection opened and closed. Windows: the first attempt's post-revoke call was rejected by MCP input validation (the harness passed an unknown `budgets` key); the rerun passed. | +| Dispose during open | Pass | Pass | The client was disposed while `POST /session` was held; the traffic shows the late session deleted and the grant revoked, and a fresh connection opened. Incarnation unchanged; no target quarantined. | +| Failed delete | Pass | Pass | After the injected DELETE failure (`service-unavailable`), the session was retained and an observe succeeded; the retried close matched. Windows: the first attempt recorded `sessionUsableAfterFailure: false`, and its traffic has no observe request after the failure; the rerun recorded `true`. | +| Ambiguous create | Pass | Pass | With the create response dropped, open returned `service-unavailable`, status reported the session `uncertain`, and reopen and close returned `session-uncertain`; after the grant was revoked, a fresh connection opened. Incarnation unchanged. | +| Resource invalidation | Pass | Pass | See the reference evidence below. The immutable PNG resource stayed readable after reset with a matching capture digest. | +| Story quarantine and recovery | Pass | Pass | Windows: a Metro reload 15 ms into a select returned `story-not-ready` (indeterminate, quarantined), and later mutations returned `target-quarantined` while reads worked. Win32: an app kill 30 ms into a select did the same; after relaunch the same session stayed quarantined. On both, a new session ran the exit probe, lifted the quarantine, and select and reset applied. | +| Protected args refusal | Pass | Pass | `story-args-unsupported`; no value appeared in the response. | +| Failed-cleanup claim retention | Not run | Not run | No forced owned-resource cleanup failure was injected. | +| Launch-mode discovery | Partial | Partial | Attach-only targets were observed and never launched or terminated the app; no launch-capable comparison was run. | +| Live cleanup and restart | Not run | Not run | Cleanup was not forced to fail. Same-port service restart and reconnection are recorded above but are not this case. | +| Attached-app preservation | Pass | Pass | The externally started app process stayed running through service shutdown and was stopped only afterward. Win32 was observed by process presence. | +| F4 post-dispatch cancellation | Pass | Pass | Aborts at 150 ms and 40 ms landed at 160/47 ms (Windows) and 156/49 ms (Win32). The journal shows the dispatch completed; replaying the request ID returned the recorded applied result, and the next mutation applied in the same session. | +| F4 readiness/render faults | Not run | Not run | No readiness or render fault was injected through the descriptor path. | +| F4 lost-context fencing | Pass | Partial | Windows: in the in-flight run, a fresh preview root was observed while quarantined, preview mutations returned `target-quarantined`, and a chrome check passed. A quiet reload run on `a6b89e924` let the new runtime authenticate before the mutation, so it shows recovery rather than fencing. Win32 (production bundle): Metro reload has no effect (Unsupported); after app exit, select returned `bridge-unavailable` (rejected, effect none), but no fresh preview could be observed. | +| F4 lost-context recovery | Pass | Pass | After a relaunch or reload, the old preview ref was `ref-stale`, and select and reset applied with marker verification; the old context was not resumed. | +| F4 secondary windows | Not run | Not run | Not attempted. | +| F4 physical input | Failed | Pass | Windows: the descriptor `act` physical click was rejected ("The target window did not become the foreground window"; effect none), and the smoke physical test failed. Win32: the descriptor click applied with mechanism `physical-pointer`; separately, smoke 1's physical-pointer test failed and is retained. A semantic activation is not counted. | +| F3 startup/shutdown/survivors | Not run | Not run | Survivor reconciliation was not exercised. | +| F5 ingress bypass | Pass | Pass | HTTP `/select-story-sync`, `/send-event`, `/mcp`, and an unknown path returned 403; an anonymous WebSocket received only `furn:desktop:request-hello`; the leaf ref and region were unchanged. | +| F5 owned selection/reset | Pass | Pass | Select and reset applied with marker verification in one session. | +| F5 channel lifetime | Pass | Pass | The session stayed usable after 21,010 ms (Windows) and 21,006 ms (Win32) idle. | +| F5 manifest smoke | Not run | Not run | The one-session instrumentation was not run; the smokes above are legacy-lane evidence. | +| F5 legacy separation | Not run | Not run | The non-agent smokes ran in separate workloads, but no legacy-channel trace labeled unprotected was retained. | + +**Counts.** Windows Fabric: 15 Pass, 1 Partial, 1 Failed, 7 Not run. +Win32 Paper: 15 Pass, 2 Partial, 7 Not run. `--permissions` is Unsupported +on both. Win32 Metro-reload fencing is Unsupported (production bundle). + +**Reference evidence.** Each reference-sequence run used one session and +service incarnation 1 throughout. + +- Windows (target `windows-f33cce69c1a7d0ae`): preview story-root `9z4z23` + and child `9z4z66` became `ref-region-changed` after reset; window ref + `9z4z1` (chrome) survived on `window-df4a4ab9-b2`; the fresh root `9z4z102` + became stale after the second select (region 3). +- Win32 (target `win32-5e01f2ae23282f34`): preview refs `okdw21` and `okdw38` + became stale after reset; window ref `okdw1` survived on + `window-9951db02-b3`; the fresh root `okdw54` became stale after the second + select. +- The matrix scripts' ref labeled "preview element" (`wbwm18`, `1roq21`) was + actually sidebar chrome. It survived select and reset, which chrome may do; + it is not staleness evidence. + +**Run records** (`apps/storybook/artifacts/desktop//run.json`): + +- Windows driver supervisors: `d558ed5d-4117-4afa-b69b-5fe2b7777e26`, + `dc41f8c6-a7d0-4741-9a94-da88e3ab8619`, and the same-port replacement + `102c54a5-417d-4266-acdd-eb83372292c4` (all passed). The lifecycle records + `6424cc6f-6ffd-4ca1-83c0-8020f7817818` (passed), + `a5e9d797-1bd9-4ea4-a106-2f23f75dbff0` (failed, `rnx-cli` ENOENT), and + `e30e6500-105c-4055-a51d-a1f88d4f7f33` (failed) are retained attempts. +- Win32 driver supervisors: `80b2b8da-8428-4b90-a70a-d0ad9b6b5eae`, + `9044e1fe-d81e-4c14-b078-5542d1ad62be`, and + `2207ae33-ea4d-4b92-a4d8-5c3339a3faa0` (all passed). The app records + `3a6ee6cd-…`, `e805a6b8-…`, `380063db-…`, `acc6cd02-…`, and `1dd7cdfe-…` + report failed because the cases deliberately killed or externally stopped + the app. + +**Retained failures and deviations.** + +- Windows, before `a6b89e924`: after a quiet Metro reload, select returned + `story-not-ready` (indeterminate, quarantined) and later mutations returned + `target-quarantined`. This led to the bridge-retirement fix; the same case + on `a6b89e924` recovered. +- Windows: one matrix retry returned `bridge-unavailable` (rejected, effect + none) for a select, and the harness then failed on its own argument error. + The cause of that `bridge-unavailable` is not determined. +- Both matrix scripts requested a story list limit above 100, which schema + validation rejected; the reference-sequence runs used a limit of 100. + +**Cleanup.** After each pass, no owned app, driver, channel, or Metro process +remained; ports 8081, 8082, 6006, 7007, 4723, 17202, 29419, and 39225 had no +listeners; no `interactive.lock` or default `agent-connection.json` remained; +and the grant was revoked. Pre-existing unrelated processes were left +running. + +**Still Not run on Windows and Win32:** failed-cleanup claim retention, live +cleanup and restart, F4 readiness/render faults, F4 secondary windows, F3 +survivors, F5 manifest smoke, and F5 legacy separation. Hosted CI disables +physical input, so physical input is not qualified there. + +## Windows follow-up qualification - 2026-11-07 UTC + +Source: `386f0585edae2450ab22e6d359e2fe4db3399555` with repairs to the +package-boundary loader, link-check worker, and atomic reporting. Native/runtime +implementation was unchanged. Same Windows 11 Enterprise build 26300, AMD64 +host; Node 26.13.0. The earlier matrix and every failed attempt above remain +historical evidence, not erased by this follow-up. + +The seven previously Not run rows were exercised. All other rows retain their +earlier outcomes: **Windows Fabric 21 Pass, 2 Partial, 1 Fail; Win32 Paper +21 Pass, 3 Partial; neither has a Not run row.** This is not all-pass native +qualification: launch-capable discovery and separately registered secondary +windows remain Partial on both; fresh-preview lost-context fencing remains +Partial on Win32; Windows physical input remains Failed. Permission preflight +and Win32 Metro reload remain Unsupported. Hosted physical-input skips do not +change those results. + +| Follow-up case | Windows Fabric | Win32 Paper | Evidence | +| ------------------------------ | -------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Failed-cleanup claim retention | Pass | Pass | Injected failure before the exact owned channel stop dispatched. The claim stayed fenced with channel generation 2 unresolved; replacement was refused before acquiring resources. | +| Live cleanup and restart | Pass | Pass | Original recorded stop callbacks settled, exact resources exited, descriptors disappeared, then retained owners/claims were reconciled. Fresh descriptor discovery in a new service incarnation selected and reset with marker verification. Restart alone was not used as cleanup proof. | +| F4 readiness/render faults | Pass | Pass | Held a real correlated ready for 1,500 ms after native render and delivered a stale correlation first. Selection settled only after the real message; old preview refs expired. A correlated render error returned unknown-effect quarantine and fenced later mutations. | +| F4 secondary windows | Partial | Partial | `native-callout--default` reported `Native window: Shown`; selection back to Button cleaned up in the same session. Only the registered primary window was addressed; this does not prove selection of a separately registered secondary window. | +| F3 startup/shutdown/survivors | Pass | Pass | Startup failed after a real typed service-ready receipt, and shutdown failed before the owned stop. Exact channel/helper survivors were reconciled through the recorded stop; Metro generation 1 settled, channel generation 2 retained the failed claim until cleanup was proved. | +| F5 manifest smoke | Pass | Pass | Instrumented manifest-backed smoke used one traversal session per phase and confirmed its DELETE before the next phase/authored session. All driver sessions were paired with successful deletes, without overlapping sessions or controller HTTP fallback. Overall smoke/report failures are retained below separately. | +| F5 legacy separation | Pass | Pass | Non-agent smoke traced unprotected `/select-story-sync` requests nested inside driver-owned story calls. This is the explicitly configured legacy transport, not fallback from the controller and not protected-ingress evidence. No descriptor or grant was used by this lane. | + +### Ownership failures and reconciliation + +`yarn desktop-test --target windows|win32 --reporter json serve --agent` +acquired real Metro, channel, native helper, and private descriptor resources. +Local instrumentation failed `running.stop` **before** dispatch for only the +recorded `storybook-channel` resource; the startup variant also threw after +the actual correlated `service-ready` receipt. The lock recorded +`service:storybook-channel:2`, generation 2, `disposal: failed`, and an +unresolved disposal effect. A second identical `serve --agent` command failed +with the workload-held diagnostic. It did not replace or steal the service. + +Calling the saved original stop callback then settled the exact channel and +helper. On the corrected startup runs and Win32 shutdown run, instrumentation +also recorded `authority.revoke` returning true; no grant identifier or token +was logged. The descriptor disappeared and ports 8081, 17202, and 39225 were +free. The supervisor retained its failed claim and remained alive; only after +that resource proof was its exact process stopped and its exact stale claim +removed. This operator reconciliation is not an automatic recovery claim. +External app incarnations survived throughout and were stopped separately +only after preservation was checked. + +| Endpoint / phase | Run ID | Terminal result | +| ------------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Windows first secondary/fault harness | `9adb8cc6-b2de-4e12-b833-31314c03f111` | Passed supervisor; initial harness failures retained. | +| Windows Metro/native-launch attempt | `d2c2d92a-c561-4f57-8d71-9109c53a04b6` | Failed; Metro watch ENOENT during native build activity, followed by exact settled-resource/owner reconciliation. | +| Windows failed shutdown | `ed3d74cb-769f-4e9f-9ca6-7f00772e98c5` | Failed as injected; exact original stop subsequently settled. | +| Windows correlated readiness/render faults | `949dde01-8fa9-46b3-b6ee-9e2082eb0b56` | Passed supervisor and corrected MCP fault probe. | +| Windows first startup failure | `d729b429-89bc-44c4-9b06-13d3a00e0de6` | Failed; the local control-file reader crashed on a partial JSON write before reconciliation. | +| Windows corrected startup failure | `ea10eb32-ad0f-462d-8384-7d824357e5b2` | Failed as injected; original stop, grant revocation, and resource exit were observed. | +| Win32 failed shutdown and MCP faults | `509c46ef-d6ee-47d6-9fc5-4f9c475faa8a` | Failed as injected; original stop, grant revocation, and resource exit were observed. | +| Win32 startup failure | `f831f9d8-7335-4b98-8dad-5e8b6dade5e1` | Failed as injected; exact survivor reconciliation completed. | +| Windows fresh-service recovery | `5cad63bb-2e67-4033-a3ac-84ec44f5553d` | Passed; new MCP session selected and reset with verified markers. | +| Win32 fresh-service recovery | `dd134838-ca66-415c-afdb-828510739ef3` | Passed; new MCP session selected and reset with verified markers. | + +Every artifact is `apps/storybook/artifacts/desktop//run.json`. +Manual descriptor operations are not suite selections: these lifecycle runs +have zero selected/executed tests, not passing test counts. The new Windows +service-ready incarnation was `dd5b8e7c-cca3-4fb6-9bf5-b6838b270f97`, MCP +session `fbbdadc4-aa0e-45a9-9862-124052bc5e4e`; Win32 used incarnation +`28c0beaf-ffb2-4161-a230-3b9edabe1c1e`, session +`0245de0c-c004-4c76-865d-7458b62f999f`. Their normal shutdowns removed the +lock and descriptor, revoked authority, released all owned native resources +and ports, and preserved the attached app. + +The crashed first Windows startup run was **not** graceful cleanup: its +service and owner exited, leaving a stale descriptor and claim. Exact +process absence, no helper/listeners, and matching dead descriptor ownership +were established before removing those two stale files. Service-local +authority was invalidated by process exit; explicit grant revocation was +not observed on that attempt. The corrected probe retained the last valid +control during a partial write and used atomic updates. + +### Correlated render evidence and retained harness failures + +Both corrected probes used a real SDK stdio MCP client, default descriptor, +verified native service, and one uninterrupted driver session at incarnation + +1. Session regions were 1 -> 2 -> 3; runtime preview generation is a separate + identity, reported below. + +| Endpoint | MCP session | Preview / chrome refs | Window identity | Runtime epoch; delayed/error generations | Measured held-selection duration | +| -------- | -------------------------------------- | --------------------- | --------------------- | --------------------------------------------- | -------------------------------- | +| Windows | `46a48bed-5f9c-40c6-aac7-c2929461d222` | `0yoo23` / `0yoo1` | `window-4124f4f1-1be` | `7a4241eb-dc2c-4c9e-b6c5-bd7e7142ec74`; 2 / 3 | 1,637 ms | +| Win32 | `a29dc7af-c96f-4b0a-a98f-2eae04d7f8c1` | `oyno25` / `oyno1` | `window-5d019501-11b` | `a8a10ef6-7935-43c5-b198-79f4ced043bf`; 5 / 6 | 1,618 ms | + +The old preview returned `ref-region-changed` after the delayed transition. +The injected correlated error returned `story-not-ready`, indeterminate, +effect unknown, quarantined. Chrome remained present and a later reset was +rejected `target-quarantined`, effect none. Neither faulted tree exposed a +fresh preview root, so this stimulus's fresh-preview mutation subcase is +Partial, not additional fencing proof. Windows retains its earlier distinct +successful fresh-preview fence probe; Win32 remains Partial. + +Secondary-window probes used Windows session +`0ad62612-8bc5-4f9f-98f3-f987f9d0b4fb`, window `window-42ae1e0b-4`, +status ref `1kos1`, and Win32 session +`2d5302b1-e602-40b3-8536-66fad0f7d4a2`, window `window-5d019501-2`, +status ref `k0kf1`. Each changed region 2 -> 3 when selecting Button again. + +Retained harness failures are not product diagnoses: initially matching the +public request ID against the runtime's different internal ID did not inject +the fault; repeatedly using the same public request ID replayed a cached +effect-none `bridge-unavailable`; a stale-ref `check` returns a failed check +envelope rather than necessarily an MCP error; finding a fresh preview after +render failure did not succeed; opening a new session before quarantined-app +exit correctly failed. The corrected probe retried with a new ID only after +proven rejected/effect-none responses and never retried an ambiguous mutation. + +Windows app run `1885a5a3-0ba3-4206-8f8d-7894e77dc68a` failed at rnx-cli +Developer Mode enabling (2406); no OS setting was changed. Later Windows +launches used the exact already registered AUMID. The earlier app run +`cc9cebaf-9029-4a05-b234-5aaa96121886` passed. Win32 app runs +`177d3c9d-cbee-4387-a5bf-7ed135cc6811` and +`727752b3-427a-40e7-8cec-0e33cbef32d9` report failed because their externally +started apps were deliberately stopped after preservation/cleanup proof. + +### Instrumented smoke and atomic-report follow-up + +These are separate, explicitly **unprotected** non-agent workloads, with +`FURN_DESKTOP_DRIVER_DISABLED_INPUT_FEATURES=keyboard,physicalClick,wheel` +matching hosted restrictions. They do not replace the physical-input result +or descriptor-path evidence. Counts are selected / executed / passed / +failed / skipped; all rows have zero unsupported, incomplete, retries, flaky, +and groups not run. + +| Endpoint / attempt | Command | Result | Run ID | +| --------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------- | +| Win32 instrumented | `yarn storybook smoke --win32 --mode stories-and-tests` | Pass: 266 / 214 / 214 / 0 / 52. | `670a4dea-adad-4082-b597-56160040fcf5` | +| Windows first instrumented | `yarn storybook smoke --windows --mode stories-and-tests` | Fail overall: 312 / 234 / 234 / 0 / 78; nine `EPERM` atomic-report rename failures. | `5ac696da-3ea3-47dc-ad90-1d4bf1110377` | +| Windows retry without live report reads | Same Windows command | Fail overall: 312 / 234 / 234 / 0 / 78; fifteen `EPERM` atomic-report rename failures. | `a67d9961-40c9-4118-95bc-0bc988599ab7` | +| Windows with bounded reporting recovery | Same Windows command | Pass: 312 / 234 / 234 / 0 / 78; one replacement recovered after one sharing retry, journaled as a warning. | `1825d9aa-2c25-4a2c-8524-ceadcf52ad68` | +| Win32 with bounded reporting recovery | Same Win32 command | Pass: 266 / 214 / 214 / 0 / 52; no sharing retry needed. | `8c6f58e1-3591-4f1b-b008-c52a718d3ca2` | + +Artifacts are `apps/storybook/artifacts/desktop//run.json`. +The first Windows attempt overlapped live PowerShell reads of the projection; +the retry did not, so those reads are not an established explanation. The +exact holder of the conflicting Windows file handle was not identified. +Atomic replacement failed transiently while later replacements succeeded; +the collector correctly retained those failures, making otherwise successful +test runs fail. + +A deterministic real Windows reader opened `run.json` without delete sharing +for 100 ms. The pre-fix collector failed the regression; the corrected +collector passed. Only Windows `EPERM`/`EACCES` renames are retried, at most +ten times with 25 ms spacing, retaining the old complete snapshot. Recovery +is journaled, not silently discarded; persistent conflicts, other errors, +and non-Windows failures still fail reporting. Neither native mutation nor +producer-handshake retries were added. Tests cover both recovery and +permanent-failure retention. + +Win32 paired all 91 session creates with successful deletes. Traversal +`797fabc1-6291-4f06-9a10-87486c48d2dd` made 176 story calls; readiness +traversal `ce2d8587-3357-4765-8174-75d5e7527868` made one, then was +deleted before the first authored session. Both Windows attempts paired 112 +creates/deletes; their story-phase traversals were +`3b691f62-dc41-4aef-96ab-a05e3115a47e` (233 calls) and +`e423ef0d-4c3b-4357-bbae-8eeb7dbcfd09` (232 calls), respectively. +Their post-relaunch readiness traversals were +`b64ede17-91b3-40c1-9554-88bb38857359` and +`e1b4b77a-28de-4bb4-8c2d-331d0e0cc20f` (eight calls each). +Each was deleted before authored-test session acquisition, without +overlapping sessions. Readiness retries are not test retries. + +All 266 Win32 and 313 Windows internal legacy selections per run were nested +inside exactly one driver-owned story call. There was no controller HTTP +fallback. The non-agent orchestrator intentionally uses the legacy transport; +the presence of those nested requests is not evidence of protected-ingress +bypass. After every attempt, no owned app/helper/channel/Metro or listener +remained, the workload lock and default descriptor were absent, and no +external app was terminated. + +The fixed Windows run again paired 112 creates/deletes: traversal +`beb044e6-d9c5-4ee4-bf77-57febd117123` made 233 calls, and readiness +traversal `690c6ae8-1bfd-49bb-8f7e-fa6253249a4e` made eight. The fixed +Win32 run paired 91 creates/deletes: traversal +`dccddd56-608c-4ad5-bdda-3aa4a0e2e753` made 176 calls, and readiness +traversal `c7233695-703d-4f70-ba6b-83d9d53bf1ba` made one. Both again +deleted the readiness traversal before acquiring an authored-test session. +Terminal reports have no failures. The Windows journal preserves its one +recovered write; this is reporting recovery, not a test or mutation retry. +After both fixed runs the exact owned processes were absent, all relevant +ports were free, and the per-user workload claim and default private +descriptor were absent. These non-agent lanes issued no descriptor grant. + +A full desktop-test package test attempt during the Windows smoke failed +the `agent-cli` serve fixture (96 passed, four skipped, one failed). +It also acquires a workload; overlapping it was a qualification scheduling +error. Contention is a plausible explanation, but the retained child output +does not establish the cause. The attempt remains recorded separately from +the subsequent serial validation. + +After both native workloads and their cleanup finished, `yarn test` passed +serially in desktop-test (97 passed, four skipped), desktop-driver +(813 passed, 72 skipped), desktop-driver-mcp (26 passed), and scripts +(76 passed). The desktop-test `agent-cli` fixture passed without a live +smoke workload; no product behavior was changed to suppress that failure. diff --git a/packages/agentic/desktop-test/references/lifecycle.md b/packages/agentic/desktop-test/references/lifecycle.md index 29789f3cf9..109a7a7ed7 100644 --- a/packages/agentic/desktop-test/references/lifecycle.md +++ b/packages/agentic/desktop-test/references/lifecycle.md @@ -7,17 +7,36 @@ owner_paths: # Lifecycle and ownership -The generic supervisor owns required services, WDIO workers, and the WebDriver -listener. A launched application's lifetime is currently session-owned; attached +The generic supervisor owns required services, WDIO workers, the WebDriver +listener, and static typed resource contributions. A contribution has a stable +ID, dependencies, ownership mode, and a side-effect-free plan whose acquisition +and disposal are tracked as one resource generation. Dependencies are validated +and topologically ordered before acquisition. Owned resources are disposed in +reverse order; borrowed resources are recorded but never disposed. + +A launched application's lifetime is currently session-owned; attached applications have an external owner. Storybook still takes over the CLI lifecycle, with channel and embedded driver listeners in a child process. This exception is -transitional, not a unified resource graph. The intended boundary is one neutral -`desktop-test` workload host with optional Storybook contributions, not a driver -or MCP orchestrator. +transitional, not a fully migrated resource graph. The intended boundary is one +neutral `desktop-test` workload host with optional Storybook contributions, not +a driver or MCP orchestrator. Services report readiness and failures through the shared run lifecycle. Required-service failure aborts dependent work. Listener/descriptor-ready does not prove app-ready, runtime-connected, or story-context-ready. +Process-backed services may use `readyProcess(id, expected)` with an explicit +`service-ready` service and optional descriptor expectation. The runner must +provide correlated readiness; a missing waiter or mismatched receipt fails +closed. The recorded receipt retains its service-ready tier, incarnation, and +producer PID. HTTP readiness remains available for services without process +readiness publication. + +Process-backed contributions return the authenticated +`DesktopServiceReadyRecord` from `/process-control`, not a readiness expectation +or a ready-file observation. Resource records retain its service, descriptor, +command incarnation, and producer PID alongside the resource generation. +Only `service-ready` is accepted; target acquisition, application readiness, +and context readiness require separate verification. Cleanup drains worker reporting and stdio, closes sessions, releases owned input, and stops exact owned process trees in reverse service order. An @@ -32,11 +51,19 @@ fail closed. If a crash leaves a lock, inspect its recorded owner and verify that process and its resources have stopped before removing that exact lock. There is no automatic stale-lock recovery. -Cleanup failure now retains the claim, including a failure to dispose a host -after startup failed. A timeout is not proof of settlement and must not release -ownership. This conservative fence is not a resource ledger: late work abandoned -by service guards, process-tree survivors, exact resource accounting, and crash -reconciliation remain work in progress. +The supervisor registers each owned resource before acquisition starts. If +cancellation wins while acquisition is in flight, shutdown waits for that work; +a resource that arrives late is disposed before a successful shutdown settles. +Guarded work is also drained before close completes. A failed or timed-out +disposal remains unsettled and retains the claim. + +The versioned workload record stores a random owner ID, process ID and recorded +start timestamp, run ID, registered target ID, physical target instance ID, and each +resource's identity, generation, ownership, readiness, and disposal state. +Unresolved effects fence the claim. Claim inspection distinguishes a live owner, +an exited owner, an unknown probe, and a reused PID; inspection never reclaims a +claim, and uncertain or unsettled resources prevent release. Reporting-session +inheritance is separate from explicit workload-claim delegation. Both native service paths use the real `driverService.ts` composition root (`@fluentui-react-native/desktop-test/driver-service`). It resolves prebuilt @@ -67,11 +94,16 @@ never clears uncertain native or Storybook mutations. Keep reporting runs distinct from physical workload claims, resource ownership, application ownership, grants, native sessions, input locks, and context epochs. -Future integrations should contribute typed resources/workloads rather than -take over CLI output and supervision. Management/run API schemas belong here. +Integrations contribute through the public +`@fluentui-react-native/desktop-test/resources` and `/ownership` subpaths rather +than taking over CLI output and supervision. Management/run API schemas belong here. Attached-app MCP/WDIO handoff can be developed independently of supervisor-owned native launch, but both must wait for session drain and preserve external apps. +Configured target aliases receive distinct registered IDs while retaining the +same physical `instanceId` when they resolve to the same application identity. +Do not use an alias as proof that two targets are physically distinct. + See [current status](current-status.md) for remaining gates and recorded evidence. - **R-DTEST-001:** Unmatched patterns, empty groups, all-skipped required diff --git a/packages/agentic/desktop-test/references/native-qualification.md b/packages/agentic/desktop-test/references/native-qualification.md new file mode 100644 index 0000000000..46ae480346 --- /dev/null +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -0,0 +1,240 @@ +--- +id: desktop-test.native-qualification +kind: instruction-reference +owner_paths: + - packages/agentic/desktop-test + - packages/agentic/desktop-driver + - packages/agentic/desktop-driver-mcp + - packages/agentic/storybook-desktop +--- + +# Native descriptor-path qualification + +Use this runbook to qualify the desktop foundation against the combined +integration commit, not an individual track branch. The macOS procedure is +parameterized by renderer; Windows Fabric and Win32 Paper require a Windows +machine and must remain **Not run** on macOS. The +[Windows parameters](#windows-fabric-and-win32-paper) substitute the endpoint +and identity values. Deterministic fake-host results +and Storybook callback smoke results are supporting evidence, not proof of the +real descriptor path. + +Run the `yarn storybook`, `yarn desktop-driver`, and `yarn desktop-test` +commands below from `apps/storybook`, where their scripts are declared. + +## Parameters and prerequisites + +Record these for every renderer run: + +| Parameter | Value | +| ----------- | ------------------------------------------------------------------------------------------------------------------------- | +| `COMMIT` | Exact `git rev-parse HEAD`; final acceptance uses the coordinator's combined F2/F3/F4/F5 SHA. | +| `APP` | Agentic Components Storybook / `ReactTestApp`; record the exact bundle identifier from the run. | +| `OS` | `sw_vers -productVersion` and `uname -m`. | +| `ENDPOINT` | `macos`; the renderer is `fabric` or `paper`. | +| `SELECTION` | Exact story/test selector and actual selected count from `run.json`; do not infer selection from an environment variable. | +| `ARTIFACT` | Workspace-relative `apps/storybook/artifacts/desktop//run.json`; retain only approved, redacted summaries. | + +1. Obtain the exclusive `native-desktop-validation` lock. Run one interactive + desktop workload at a time; do not overlap physical-input runs. +2. Explicitly build the native helper and run its noninteractive preflight: + + ```sh + yarn storybook build-driver --macos + yarn desktop-driver doctor --platform macos --permissions + ``` + + Record the exact helper artifact, signing mode, designated requirement, and + Accessibility, PostEvent, and ScreenCapture preflight values. Do not use + `--prompt`, change OS permissions, or treat a semantic `activate` as proof + of physical input. If a capability is unavailable, mark its case skipped + or not run. + +3. Check static discovery without launching the native app: + + ```sh + yarn storybook manifest --macos + yarn storybook test --macos --list + ``` + + Use the generated manifest's exact story IDs and test names; do not guess + slugging or test names. + +## Per-renderer app pass + +Run Fabric and Paper as separate passes. Bundle before `prep` so CocoaPods +embeds the current JavaScript resources; switching renderers always requires a +new `prep`. + +```sh +# Fabric +yarn storybook bundle --macos +yarn storybook prep --macos +yarn storybook smoke --macos --mode stories-and-tests + +# Paper; complete the Fabric run and cleanup before switching +yarn storybook bundle --macos --paper +yarn storybook prep --macos --paper +yarn storybook smoke --macos --paper --mode stories-and-tests +``` + +`smoke` traverses the declared stories and, in `stories-and-tests` mode, runs +the selected static and authored callbacks. It does not expose a `--test` +filter. `STORYBOOK_SMOKE_STORY` and `STORYBOOK_SMOKE_TAG` can request focused +coverage, but always read the terminal `run.json` counts and work selection: +the provisional run recorded below supplied a story environment selector yet +still selected 211 callbacks. For a strict callback subset, keep the app and a +non-agent driver running and use the supported filtered command: + +```sh +yarn storybook test --macos --story '' --test '' +``` + +Do not use `storybook test` against an agent-served instance; it has no grant. +Manifest-backed `smoke` also uses a non-agent driver session; it has no +`--agent` option or descriptor-grant input. Qualify its driver-backed +traversal separately from the protected descriptor path below. +For every run record selected, executed, passed, failed, skipped, unsupported, +incomplete, retries, flaky count, and the cleanup result. Empty work is not a +pass. + +## Real descriptor-path pass + +Perform this after the app pass, independently for Fabric and Paper. Use one +supervisor path at a time; do not start `storybook driver --agent` and +`desktop-test serve --agent` concurrently. + +1. Start either the Storybook driver or the Storybook target via + `yarn desktop-test --target macos serve --agent`. For the Storybook driver, + use `yarn storybook driver --macos --agent` for Fabric or + `yarn storybook driver --macos --paper --agent` for Paper. The desktop-test + target must likewise be configured for the selected renderer. This owns + Metro and the Storybook channel; wait for its structured ready record, + including the private descriptor name. Do not print, copy to artifacts, or + expose the descriptor grant. +2. In a separate shell, launch the prepared app with + `yarn storybook run --macos` for Fabric or + `yarn storybook run --macos --paper` for Paper. Use the same renderer + selection as the driver. Keep the driver service running. Confirm the app + connects to the instance from the driver's readiness record before opening + an MCP session. +3. Connect `desktop-driver-mcp` using its default + `agent-connection.json` descriptor. Confirm the adapter verified the native + service identity before any grant is used. Use a real MCP client and the + public tools; do not substitute a direct unprotected driver URL. +4. Run the following cases against the real descriptor and native service. + Fault-injection cases must intercept the adapter-to-service transport while + retaining the real descriptor, verified service, and native helper. Record + the injected dispatch point and whether a mutation was applied; never retry + an ambiguous mutation. + + Keep the public operation request ID separate from the runtime's internal + selection request ID. Reusing the public ID replays its recorded outcome, + including a rejected `bridge-unavailable` result; it is not a new readiness + probe. A new ID is safe only after a rejected, effect-none result. Match + render-fault injections to the actual correlated runtime message and retain + its epoch, preview generation, and internal request ID. Write live + fault-control files atomically; a partially written control file is a harness + failure, not evidence about the service. + +| Case | Stimulus | Required evidence | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| Same-port replacement | Replace the protected service on the same port after opening a session. | Old incarnation/session and references are rejected; the next call re-verifies discovery; no old mutation is replayed. | +| Grant revocation | Revoke the active parent or delegated grant during a connection. | Protected access fails closed, authority is rediscovered, and there is no fallback to an unprotected service. | +| Dispose during open | Hold acquisition after dispatch, begin MCP disposal, then release acquisition. | Late session is deleted; no new open is admitted; drain completes or remains explicitly uncertain. | +| Failed delete | Inject transport loss before and after DELETE dispatch. | Session cleanup responsibility remains; replacement opens are blocked until deletion is confirmed or authoritative invalidation occurs. | +| Ambiguous create | Drop the session-create response after the service may have created the session. | The connection becomes `session-uncertain`; no repeated create or guessed session ID; disposal revokes and drains the child grant. | +| Resource invalidation | Replace the service or preview context after reading a resource. | Old resource identifiers cannot be used; fresh discovery and observation are required. | +| Story quarantine and recovery | Cause an unknown-effect story transition, then perform the trusted recovery path. | Later story mutations are quarantined until authoritative recovery and a verified current marker. | +| Protected args refusal | Attempt story args through the protected service. | `story-args-unsupported`, no native effect, and no argument values in output, diagnostics, or logs. | +| Failed-cleanup claim retention | Force owned-resource cleanup failure in `desktop-test serve --agent`, then attempt replacement. | `interactive.lock` and workload ownership remain; replacement is refused until exact-owner cleanup is proved. | +| Launch-mode discovery | Connect to launch-capable and attach-only targets. | Advertised modes match target policy; attach-only targets never launch or terminate the external app. | +| Live cleanup and restart | Fail cleanup, restart the service, and reconnect through its descriptor. | Exact cleanup state and new service incarnation are reported; restart alone is not recovery proof. | +| Attached-app preservation | Start the app externally, connect without launch ownership, then stop the agent service. | The same app process incarnation and bundle remain running after service/session cleanup. | +| F4 post-dispatch cancellation | Cancel only after native dispatch is observed. | Result is applied, rejected, or indeterminate from settlement evidence; timeout is not treated as no effect. | +| F4 readiness/render faults | Delay readiness and inject correlated error/render faults. | No stale readiness is accepted; old preview resources are invalidated. | +| F4 lost-context fencing | Lose the bridge context, observe a fresh preview reference, and attempt a preview mutation before recovery. | The fresh preview cannot bypass lost-context fencing; chrome remains usable. A newly observed reference is not readiness proof. | +| F4 lost-context recovery | Relaunch or otherwise establish a new authenticated bridge epoch after context loss. | Recovery requires correlated readiness and a verified marker; old context is not resumed. A same-incarnation restart alone remains relaunch-required. | +| F4 secondary windows | Exercise a secondary window through its registered identity. | Window identity and cleanup remain exact; no first-match behavior. | +| F4 physical input | Perform the native pointer/keyboard case only with fresh helper permissions. | Record actual physical result separately from semantic activation; otherwise mark skipped/not run. | +| F3 startup/shutdown/survivors | Fail service startup and shutdown with Metro/channel/helper resources alive. | Readiness is typed; survivors are reconciled by exact resource identity; failed cleanup retains the workload claim. | +| F5 ingress bypass | Try anonymous and foreign-authority HTTP/WS select, prepare, and args mutations. | Native preview and marker are unchanged; protected args neither mutate nor leak values. | +| F5 owned selection/reset | Use authorized driver selection and reset through the authenticated epoch client. | Correlated readiness and markers settle; stale preview resources are invalidated. | +| F5 channel lifetime | Leave agent mode idle beyond upstream's 20-second timeout. | Owned 10-second JSON heartbeat keeps the channel connected. | +| F5 manifest smoke | Run manifest-backed non-agent smoke separately from the protected descriptor pass. | One driver session is used and released before authored tests; there is no fallback to HTTP mutation routes. This is not protected-ingress evidence. | +| F5 legacy separation | Exercise the non-agent legacy channel separately. | Results are labeled unprotected/legacy and are not counted as protected-ingress evidence. | + +The F3/F4/F5 cases are integration gates, not claims about the F15 baseline. +Run them only on the combined source that contains those tracks. Until then, +record them as **Not run**. + +## Windows Fabric and Win32 Paper + +The procedure is the same with these substitutions. `--paper` is invalid on +Windows: `--windows` is always Fabric and `--win32` is always Paper. + +| Parameter | Windows Fabric (`--windows`) | Win32 Paper (`--win32`) | +| --------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `OS` | `(Get-CimInstance Win32_OperatingSystem).Caption`, `.BuildNumber`, and `$env:PROCESSOR_ARCHITECTURE`. | Same. | +| `APP` | React Native Test App host, window title `Agentic Components Storybook`; record the resolved AUMID or executable identity. | `rex` host component `AgenticStorybook`, window title `Agentic Components Storybook (Win32)`; record the resolved identity. | +| Bundle | Development Metro bundle; a Metro reload replaces the bridge. | Production bundle (`bundle --dev false`); there is no Metro reload path. | +| Prereqs | Windows App Runtime 1.8 registered for the user (CI installs it). | None beyond the build. | +| App pass | `yarn storybook prep --windows`, then `yarn storybook smoke --windows --mode stories-and-tests`. | `yarn storybook smoke --win32 --mode stories-and-tests`. | + +- Run `yarn storybook build-driver --windows` (or `--win32`) and + `yarn desktop-driver doctor --platform windows --permissions` (or + `--platform win32`). The Windows permission preflight reports **Unsupported**; + record that, not a pass. +- Physical pointer input needs the target window to become foreground. When the + machine cannot grant foreground, record the physical case as **Failed** or + **Skipped** and rerun the smoke with + `FURN_DESKTOP_DRIVER_DISABLED_INPUT_FEATURES=keyboard,physicalClick,wheel`, + reporting both runs. +- Win32 cannot lose context through a Metro reload. Use external app + termination instead: kill the app between calls for the fence case and + during an in-flight `select` for quarantine. Record the Metro-reload variant + as **Unsupported** on Win32. +- Quarantine of an attached app lifts through the application-exit probe, + which runs when a new session opens for the unheld target. A relaunch + observed inside the original session stays `target-quarantined`. +- After a same-port service replacement, story mutations against the existing + app return `relaunch-required`; recovery needs an app relaunch. +- An externally stopped `storybook run` exits nonzero and writes a failed + `run.json`. Record it as expected for that case; it is not a driver failure. +- If `storybook run --windows` fails while enabling Developer Mode, do not + change that OS setting for qualification. An already installed, verified + app can instead be launched by its exact AUMID resolved from the built + AppxManifest and the matching registered package. Record both the failed + launch command and this narrower launch path. +- Windows smoke uses separate story-traversal and post-relaunch test phases. + Trace session creation/deletion for each phase: one traversal session per + phase, its confirmed deletion before the next phase or authored session, + and no controller fallback to HTTP selection. The deliberately unprotected + orchestrator itself uses the legacy HTTP selection route; distinguish + those nested driver-owned calls from controller fallback. Do not mistake + authored-test sessions for traversal sessions. + +## Cleanup and endpoint limits + +After each bounded pass, stop only resources owned by that run and record: + +- owned app, driver, channel, and Metro process IDs are gone; +- reported service ports have no listeners; +- the exact per-user `interactive.lock` is absent, or retained with its + verified owner and unresolved cleanup reason; +- the private descriptor was removed and its grant revoked; +- no externally owned app was stopped. + +If cleanup is uncertain, do not remove the lock or start another workload. +Report a blocked question to the coordinator. Release the native lock only +after cleanup evidence is recorded. + +On a macOS-only machine, record **Windows Fabric** and **Win32 Paper** as +**Not run**, with no measurements inferred from macOS. On Windows, also confirm +that no `%TEMP%\furn-desktop-workload-*\interactive.lock` and no +`%LOCALAPPDATA%\com.microsoft.fluentui-react-native.desktop-driver\agent-connection.json` +remain, that the channel, Metro, and WebDriver ports reported by the driver +have no listeners, and that no app process from the run survives. Hosted physical-input +or capture skips are skips, not passes. Keep descriptor-path results separate +from smoke/WDIO and deterministic fake-host results in all three package +status ledgers. diff --git a/packages/agentic/desktop-test/references/reporting.md b/packages/agentic/desktop-test/references/reporting.md index 4e91dc7319..ad7b0176ae 100644 --- a/packages/agentic/desktop-test/references/reporting.md +++ b/packages/agentic/desktop-test/references/reporting.md @@ -137,6 +137,14 @@ marker remain incomplete in queries, even if they say `passed`. Existing Persisted tests, work, failures, retries, and skips remain the authoritative source; page size and worker exit codes do not decide coverage. +On Windows, atomic replacement retries only `EPERM`/`EACCES` rename failures, +up to ten times at 25 ms intervals, without deleting the previous snapshot or +rewriting its temporary replacement. A recovered replacement records a warning +in the event journal. Exhaustion, other errors, and errors on other platforms +remain reporting failures even if later snapshots succeed. The same rule +applies to JUnit replacement; it does not retry native mutations or producer +handshakes. + Trusted host code registers exact collector `{ runId, root, expiresAt }` records and an optional `textArtifacts` relative-path allowlist, then issues a read grant with exact `runIds`, an expiry, and optional `artifacts` permission: diff --git a/packages/agentic/desktop-test/src/cli.ts b/packages/agentic/desktop-test/src/cli.ts index 3258f8a45e..83e746f174 100644 --- a/packages/agentic/desktop-test/src/cli.ts +++ b/packages/agentic/desktop-test/src/cli.ts @@ -209,6 +209,7 @@ export async function runDesktopTestCli(argv = process.argv): Promise { }); } else if (config.config.integration?.run && suite?.kind !== 'wdio') { await owner.open(['test', 'serve', 'run'].includes(action)); + await owner.startResources(); if (action === 'test' || action === 'serve') await owner.startConfiguredServices(); await owner.services.guard( config.config.integration.run({ @@ -240,7 +241,7 @@ export async function runDesktopTestCli(argv = process.argv): Promise { }), ); markReady(); - await owner.services.guard(new Promise(() => undefined)); + await owner.services.waitForStop(); } else if (suite?.kind === 'wdio') await runDesktopWdioSuite(owner, config, test!.suite, input); else throw new Error('The imported integration does not implement this suite.'); } diff --git a/packages/agentic/desktop-test/src/commands.ts b/packages/agentic/desktop-test/src/commands.ts index 46b8265a79..d8341be369 100644 --- a/packages/agentic/desktop-test/src/commands.ts +++ b/packages/agentic/desktop-test/src/commands.ts @@ -10,6 +10,8 @@ import { DesktopRunReporter } from './reporting/collector.js'; import type { DesktopReportWriter, ReportOutput } from './reporting/collector.js'; import type { EventContext } from './reporting/protocol.js'; import { DESKTOP_REPORT_CONTEXT, DESKTOP_REPORT_SCOPE, requestReportStop } from './reporting/transport.js'; +import { createDesktopProcessControl } from './processControl.js'; +import type { DesktopServiceReady, DesktopServiceReadyRecord } from './processControl.js'; export type PreparedDesktopCommand = DesktopCommand & { args: readonly string[]; @@ -23,6 +25,7 @@ export type RunningDesktopCommand = { completed: Promise; logPath?: string; readonly failure?: unknown; + waitForReadiness?(expected: DesktopServiceReady, timeoutMs?: number, signal?: AbortSignal): Promise; stop(options?: { failed?: boolean }): Promise; }; export interface DesktopCommandRunner { @@ -107,17 +110,27 @@ export class NodeDesktopCommandRunner implements DesktopCommandRunner { command.context, ); emit({ kind: 'artifact', payload: { path: relativeLog, description: command.label ?? command.command } }, id, command.context); + let failed: unknown; + const processControl = createDesktopProcessControl((error) => { + failed ??= error; + reporter.fail(error, 'infrastructure', id); + }); + reporter.addSecret(processControl.nonce); + const environment = { ...process.env, ...command.env, ...this.options.environment }; const spawnCommand = needsWindowsCommandShim(command.command) ? spawn : spawnNative; const child = spawnCommand(command.command, [...command.args], { cwd: command.cwd, - env: { ...process.env, ...command.env, ...this.options.environment, [DESKTOP_REPORT_SCOPE]: id }, + env: { + ...environment, + ...(command.category === undefined || command.category === 'service' ? processControl.environment(environment.NODE_OPTIONS) : {}), + [DESKTOP_REPORT_SCOPE]: id, + }, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); let stopped = false; let closed = false; - let failed: unknown; let exitFailure: Error | undefined; let stopPromise: Promise | undefined; let closeDeadline: ReturnType | undefined; @@ -175,6 +188,7 @@ export class NodeDesktopCommandRunner implements DesktopCommandRunner { failed ??= error; }); child.once('exit', () => { + processControl.stop(); closeDeadline = setTimeout(() => { failed ??= new Error( `Child exited but inherited output streams did not close within the drain deadline: ${command.label ?? command.command}`, @@ -202,6 +216,13 @@ export class NodeDesktopCommandRunner implements DesktopCommandRunner { const running: RunningDesktopCommand = { completed: completion, logPath, + waitForReadiness: (expected, timeoutMs, signal) => + Promise.race([ + processControl.waitForReadiness(expected, timeoutMs, signal), + completion.then(() => { + throw failed ?? exitFailure ?? new Error('Owned command exited before service readiness.'); + }), + ]), get failure() { return failed ?? exitFailure; }, @@ -210,13 +231,15 @@ export class NodeDesktopCommandRunner implements DesktopCommandRunner { stopped = true; let cleanupError: unknown; const wasRunning = !closed; + const cooperative = processControl.stop(); try { const encoded = this.options.environment?.[DESKTOP_REPORT_CONTEXT] ?? command.env[DESKTOP_REPORT_CONTEXT] ?? process.env[DESKTOP_REPORT_CONTEXT]; const controlled = await requestReportStop(id, encoded, this.options.shutdownTimeoutMs ?? 10_000); - if (controlled && !closed) + await processControl.settled(this.options.shutdownTimeoutMs ?? 10_000); + if ((controlled || cooperative) && !closed) await deadline(completion, this.options.shutdownTimeoutMs ?? 10_000, 'Gracefully stopping owned Node services'); } catch (error) { cleanupError = error; @@ -237,12 +260,14 @@ export class NodeDesktopCommandRunner implements DesktopCommandRunner { code = await deadline(completion, this.options.shutdownTimeoutMs ?? 10_000, 'Draining command output'); } catch (error) { failed ??= error; + cleanupError ??= error; } const status = options.failed || failed !== undefined || cleanupError !== undefined || (!wasRunning && code !== 0) ? 'failed' : 'passed'; emit({ kind: 'scope-end', payload: { status, logPath: relativeLog } }, id, command.context); emit({ kind: 'producer-finished', payload: { status } }, id, command.context); this.active.delete(running); + await processControl.close(); if (cleanupError !== undefined) throw cleanupError; })()), }; diff --git a/packages/agentic/desktop-test/src/config.ts b/packages/agentic/desktop-test/src/config.ts index 79fcd0ee85..6665817721 100644 --- a/packages/agentic/desktop-test/src/config.ts +++ b/packages/agentic/desktop-test/src/config.ts @@ -10,6 +10,7 @@ import { resolveDesktopScopeDomains, resolveDesktopScopeValues } from '@fluentui import type { DesktopScopeDomains, DesktopScopeValues } from '@fluentui-react-native/desktop-driver/authoring/input'; import type { DesktopReporterMode } from '@fluentui-react-native/desktop-driver/cli/reporter'; import type { DesktopIntegration } from './integration.ts'; +import { orderDesktopResourceContributions } from './resources.ts'; export type DesktopCommand = { command: string; @@ -36,7 +37,7 @@ export type DesktopTargetConfig = { host: DesktopHostConfig; readiness?: DesktopReadiness; commands?: Partial>; - services?: readonly { id: string; command: DesktopCommand; readyUrl?: string }[]; + services?: readonly { id: string; command: DesktopCommand; readyUrl?: string; dependsOn?: readonly string[] }[]; launchTimeoutMs?: number; nativeDriver?: { cacheRoot?: string; helperPath?: string; installRoot?: string }; /** @@ -87,6 +88,7 @@ export type DesktopConfig = { export type ResolvedDesktopTarget = DesktopTargetConfig & { endpoint: DesktopEndpoint; id: string; + registrationId: string; instanceId: string; projectRoot: string; configPath: string; @@ -112,6 +114,10 @@ export function defineDesktopConfig(config: T): T { return config; } +function registeredTargetId(configPath: string, targetName: string, endpoint: DesktopEndpoint): string { + return `${endpoint}-${createHash('sha256').update(`${configPath}\0${targetName}`).digest('hex').slice(0, 16)}`; +} + export function validateDesktopConfig(value: unknown): asserts value is DesktopConfig { const config = object(value, 'desktop config'); keys(config, ['schemaVersion', 'projectRoot', 'targets', 'suites', 'integration', 'reporting', 'testScopes'], 'desktop config'); @@ -168,7 +174,7 @@ export function validateDesktopConfig(value: unknown): asserts value is DesktopC const ids = new Set(); for (const entry of target.services) { const service = object(entry, `${id}.service`); - keys(service, ['id', 'command', 'readyUrl'], `${id}.service`); + keys(service, ['id', 'command', 'readyUrl', 'dependsOn'], `${id}.service`); text(service.id, 'service id'); if (ids.has(service.id)) throw new TypeError(`Duplicate service ${service.id}.`); ids.add(service.id); @@ -180,7 +186,39 @@ export function validateDesktopConfig(value: unknown): asserts value is DesktopC throw new TypeError('Service readiness URLs must be loopback HTTP URLs.'); } } + if ( + service.dependsOn !== undefined && + (!Array.isArray(service.dependsOn) || !service.dependsOn.every((dependency) => typeof dependency === 'string')) + ) { + throw new TypeError(`${id}.service ${service.id} dependencies must be an array of IDs.`); + } + } + const services = new Map( + (target.services as { id: string; readyUrl?: string; dependsOn?: readonly string[] }[]).map((service) => [service.id, service]), + ); + for (const service of services.values()) { + const dependencies = service.dependsOn ?? []; + if (new Set(dependencies).size !== dependencies.length) { + throw new TypeError(`${id}.service ${service.id} has duplicate dependencies.`); + } + for (const dependency of dependencies) { + const required = services.get(dependency); + if (!required) throw new TypeError(`${id}.service ${service.id} has unknown dependency "${dependency}".`); + if (dependency === service.id) throw new TypeError(`${id}.service ${service.id} cannot depend on itself.`); + if (!required.readyUrl) throw new TypeError(`${id}.service ${service.id} depends on "${dependency}" without a readiness URL.`); + } } + const visiting = new Set(); + const visited = new Set(); + const visit = (serviceId: string, ancestry: readonly string[]) => { + if (visited.has(serviceId)) return; + if (visiting.has(serviceId)) throw new TypeError(`${id}.service dependency cycle: ${[...ancestry, serviceId].join(' -> ')}.`); + visiting.add(serviceId); + for (const dependency of services.get(serviceId)?.dependsOn ?? []) visit(dependency, [...ancestry, serviceId]); + visiting.delete(serviceId); + visited.add(serviceId); + }; + for (const serviceId of services.keys()) visit(serviceId, []); } } if (config.suites !== undefined) { @@ -226,10 +264,17 @@ export function validateDesktopConfig(value: unknown): asserts value is DesktopC } if (config.integration !== undefined) { const integration = object(config.integration, 'integration'); + keys(integration, ['name', 'validate', 'supportsAgent', 'createTarget', 'resources', 'run', 'configure'], 'integration'); text(integration.name, 'integration.name'); if (typeof integration.validate !== 'function') throw new TypeError('An integration must expose validate().'); if (integration.supportsAgent !== undefined && typeof integration.supportsAgent !== 'boolean') throw new TypeError('integration.supportsAgent must be a boolean.'); + if (integration.createTarget !== undefined && typeof integration.createTarget !== 'function') + throw new TypeError('integration.createTarget must be a function.'); + if (integration.configure !== undefined && typeof integration.configure !== 'function') + throw new TypeError('integration.configure must be a function.'); + if (integration.run !== undefined && typeof integration.run !== 'function') throw new TypeError('integration.run must be a function.'); + if (integration.resources !== undefined) orderDesktopResourceContributions(integration.resources); integration.validate(); } } @@ -285,11 +330,13 @@ export async function loadDesktopConfig(configPath?: string, cwd = process.cwd() : {}), }; const instanceId = targetInstanceId(root, endpoint, host.renderer); + const registrationId = registeredTargetId(file, id, endpoint); return Object.freeze({ ...target, endpoint, host: Object.freeze(host), - id: `${endpoint}-${instanceId}`, + id: registrationId, + registrationId, instanceId, projectRoot: root, configPath: file, @@ -299,7 +346,13 @@ export async function loadDesktopConfig(configPath?: string, cwd = process.cwd() }); }; const targets = Object.fromEntries( - await Promise.all(Object.keys(entry.config.targets).map(async (id) => [id, await resolve(entry.file, id, [])])), + await Promise.all( + Object.keys(entry.config.targets).map(async (id) => { + const target = await resolve(entry.file, id, []); + const registrationId = registeredTargetId(entry.file, id, target.endpoint); + return [id, Object.freeze({ ...target, id: registrationId, registrationId })]; + }), + ), ); return { config: entry.config, @@ -334,11 +387,12 @@ export function selectDesktopRenderer(target: ResolvedDesktopTarget, renderer: D if (target.endpoint !== 'macos' || target.host.kind !== 'react-native-test-app') throw new Error('Renderer switching requires a macOS RN Test App target; regenerate with prep after switching.'); const instanceId = targetInstanceId(target.projectRoot, target.endpoint, renderer); + const id = `${target.endpoint}-${createHash('sha256').update(`${target.registrationId}\0${renderer}`).digest('hex').slice(0, 16)}`; return Object.freeze({ ...target, host: Object.freeze({ ...target.host, renderer }), + id, instanceId, - id: `${target.endpoint}-${instanceId}`, }); } diff --git a/packages/agentic/desktop-test/src/contracts.contract.cjs b/packages/agentic/desktop-test/src/contracts.contract.cjs index 7182e5f8f1..e6203aa2e7 100644 --- a/packages/agentic/desktop-test/src/contracts.contract.cjs +++ b/packages/agentic/desktop-test/src/contracts.contract.cjs @@ -69,6 +69,20 @@ async function main() { targets: { win32: { host: { kind: 'installed', application: { executablePath: 'app.exe', windowTitle: 'Fixture' } } } }, }; assert.throws(() => defineDesktopConfig({ ...app, unrecognized: true }), /Unknown/); + assert.throws( + () => + defineDesktopConfig({ + ...app, + integration: { + name: 'fixture', + validate() { + throw new Error('Unexpected config validation.'); + }, + unrecognized: true, + }, + }), + /Unknown integration field/, + ); assert.throws( () => defineDesktopConfig({ ...app, targets: { win32: { use: { config: 'app.mjs', target: 'win32' }, host: {} } } }), /ambiguous/, @@ -78,13 +92,19 @@ async function main() { fs.writeFileSync(path.join(root, 'app', 'desktop.config.mjs'), `export default ${JSON.stringify(app)};`); const suite = { schemaVersion: 1, - targets: { alias: { use: { config: '../app/desktop.config.mjs', target: 'win32' } } }, + targets: { + alias: { use: { config: '../app/desktop.config.mjs', target: 'win32' } }, + aliasTwo: { use: { config: '../app/desktop.config.mjs', target: 'win32' } }, + }, reporting: { artifactsRoot: 'reports' }, }; fs.writeFileSync(path.join(root, 'suite', 'desktop.config.mjs'), `export default ${JSON.stringify(suite)};`); const direct = await loadDesktopConfig(path.join(root, 'app', 'desktop.config.mjs')); const referred = await loadDesktopConfig(path.join(root, 'suite', 'desktop.config.mjs')); - assert.equal(direct.targets.win32.id, referred.targets.alias.id); + assert.notEqual(direct.targets.win32.id, referred.targets.alias.id); + assert.notEqual(referred.targets.alias.id, referred.targets.aliasTwo.id); + assert.equal(direct.targets.win32.instanceId, referred.targets.alias.instanceId); + assert.equal(referred.targets.alias.instanceId, referred.targets.aliasTwo.instanceId); assert.equal(referred.targets.alias.host.application.executablePath, path.join(root, 'app', 'app.exe')); assert.equal(referred.reporting.artifactsRoot, path.join(root, 'suite', 'reports')); fs.writeFileSync( @@ -811,14 +831,432 @@ async function main() { const services = new DesktopServiceScope(runner, run); services.start('outer', command('setInterval(()=>{},1000);')); services.start('metro', command("process.stderr.write('original metro failure\\n'); setTimeout(()=>process.exitCode=7,20);")); + let settleGuardedWork; + const guardedWork = new Promise((resolve) => { + settleGuardedWork = resolve; + }); try { - await assert.rejects(services.guard(new Promise(() => undefined)), /exited.*code 7/); + await assert.rejects(services.guard(guardedWork), /exited.*code 7/); + let closeSettled = false; + const closing = services.close().then(() => { + closeSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(closeSettled, false, 'shutdown must await guarded work after required-service failure'); + settleGuardedWork(); + await closing; } finally { + settleGuardedWork(); await services.close(); } const result = await run.finish(); assert.equal(result.status, 'failed'); assert(err.text().includes('original metro failure')); + } else if (mode === 'resources') { + const { DesktopServiceScope } = await import(lib('services.js')); + const { createDesktopWorkloadClaim, inspectDesktopWorkloadClaim } = await import(lib('ownership.js')); + const { orderDesktopResourceContributions } = await import(lib('resources.js')); + const { DesktopSupervisor } = await import(lib('supervisor.js')); + assert.throws( + () => + orderDesktopResourceContributions([ + { + id: 'duplicate', + ownership: 'owned', + prepare() { + throw new Error('Duplicate resources must be rejected before preparation.'); + }, + }, + { + id: 'duplicate', + ownership: 'owned', + prepare() { + throw new Error('Duplicate resources must be rejected before preparation.'); + }, + }, + ]), + /Duplicate resource contribution/, + ); + assert.throws( + () => + orderDesktopResourceContributions([ + { + id: 'first', + dependsOn: ['second'], + ownership: 'owned', + prepare() { + throw new Error('Cycles must be rejected before preparation.'); + }, + }, + { + id: 'second', + dependsOn: ['first'], + ownership: 'owned', + prepare() { + throw new Error('Cycles must be rejected before preparation.'); + }, + }, + ]), + /dependency cycle/, + ); + const ready = { + tier: 'service-ready', + service: 'fixture-service', + descriptor: 'fixture.json', + incarnation: 'inc_fixture', + processId: process.pid, + }; + const events = []; + const runner = { + start() { + throw new Error('No process should be started by this fixture.'); + }, + run() { + throw new Error('No process should be run by this fixture.'); + }, + }; + const claimDirectory = path.join(root, 'resource-lock'); + const claim = createDesktopWorkloadClaim({ + runId: 'resource-run', + targetId: 'registered-target', + targetInstanceId: 'physical-target', + directory: claimDirectory, + }); + const scope = new DesktopServiceScope(runner, undefined, 'resource-run'); + scope.setResourceObserver((resource) => claim.updateResource(resource)); + await scope.contribute( + [ + { + id: 'dependent', + dependsOn: ['foundation'], + ownership: 'owned', + prepare: () => ({ + identity: 'dependent-instance', + async acquire() { + events.push('acquire-dependent'); + }, + async dispose() { + events.push('dispose-dependent'); + }, + }), + }, + { + id: 'foundation', + ownership: 'owned', + prepare: () => ({ + identity: 'foundation-instance', + async acquire() { + events.push('acquire-foundation'); + return ready; + }, + async dispose() { + events.push('dispose-foundation'); + }, + }), + }, + ], + { targetId: 'registered-target', targetInstanceId: 'physical-target' }, + ); + assert.deepEqual(events, ['acquire-foundation', 'acquire-dependent']); + assert.equal(claim.record.resources[0].serviceReady.incarnation, ready.incarnation); + assert.equal(claim.record.resources[0].serviceReady.processId, process.pid); + await scope.close(); + assert.deepEqual(events, ['acquire-foundation', 'acquire-dependent', 'dispose-dependent', 'dispose-foundation']); + claim.release(); + assert.throws(() => fs.readFileSync(path.join(claimDirectory, 'interactive.lock')), /ENOENT/); + + const processReady = { + tier: 'service-ready', + service: 'storybook-channel', + descriptor: 'agent', + incarnation: 'inc_process', + processId: process.pid, + }; + const processExpectation = { tier: 'service-ready', service: 'storybook-channel', descriptor: 'agent' }; + let receivedExpectation; + let receivedSignal; + let processResource; + const neverCompletes = new Promise(() => undefined); + const processScope = new DesktopServiceScope( + { + start() { + return { + completed: neverCompletes, + waitForReadiness(expected, _timeoutMs, signal) { + receivedExpectation = expected; + receivedSignal = signal; + return Promise.resolve(processReady); + }, + async stop() { + return undefined; + }, + }; + }, + async run() { + return undefined; + }, + }, + undefined, + 'process-run', + ); + processScope.setResourceObserver((resource) => { + if (resource.id === 'process-run:storybook-channel') processResource = resource; + }); + processScope.start('storybook-channel', command('')); + assert.deepEqual(await processScope.readyProcess('storybook-channel', processExpectation), processReady); + assert.deepEqual(receivedExpectation, processExpectation); + assert.equal(receivedSignal, processScope.controller.signal); + assert.equal(processResource.readiness, 'ready'); + assert.equal(processResource.serviceReady.tier, 'service-ready'); + assert.equal(processResource.serviceReady.incarnation, processReady.incarnation); + await processScope.close(); + + let mismatchedResource; + const mismatchedScope = new DesktopServiceScope( + { + start() { + return { + completed: neverCompletes, + async waitForReadiness() { + return { ...processReady, descriptor: 'unexpected' }; + }, + async stop() { + return undefined; + }, + }; + }, + async run() { + return undefined; + }, + }, + undefined, + 'mismatched-run', + ); + mismatchedScope.setResourceObserver((resource) => { + if (resource.id === 'mismatched-run:storybook-channel') mismatchedResource = resource; + }); + mismatchedScope.start('storybook-channel', command('')); + await assert.rejects( + mismatchedScope.readyProcess('storybook-channel', processExpectation), + /does not match the configured expectation/, + ); + assert.equal(mismatchedResource.readiness, 'failed'); + await mismatchedScope.close(); + + let unsupportedResource; + const unsupportedScope = new DesktopServiceScope( + { + start() { + return { + completed: neverCompletes, + async stop() { + return undefined; + }, + }; + }, + async run() { + return undefined; + }, + }, + undefined, + 'unsupported-run', + ); + unsupportedScope.setResourceObserver((resource) => { + if (resource.id === 'unsupported-run:storybook-channel') unsupportedResource = resource; + }); + unsupportedScope.start('storybook-channel', command('')); + await assert.rejects( + unsupportedScope.readyProcess('storybook-channel', processExpectation), + /does not support correlated process readiness/, + ); + assert.equal(unsupportedResource.readiness, 'failed'); + await unsupportedScope.close(); + + const lateDirectory = path.join(root, 'late-lock'); + const lateClaim = createDesktopWorkloadClaim({ + runId: 'late-run', + targetId: 'registered-target', + targetInstanceId: 'physical-target', + directory: lateDirectory, + }); + const lateScope = new DesktopServiceScope(runner, undefined, 'late-run'); + lateScope.setResourceObserver((resource) => lateClaim.updateResource(resource)); + let finishAcquire; + let lateDisposed = false; + const lateAcquisition = lateScope.contribute( + [ + { + id: 'late', + ownership: 'owned', + prepare: () => ({ + identity: 'late-instance', + acquire: () => + new Promise((resolve) => { + finishAcquire = resolve; + }), + async dispose() { + lateDisposed = true; + }, + }), + }, + ], + { targetId: 'registered-target', targetInstanceId: 'physical-target' }, + ); + lateScope.fail(new Error('cancel during acquisition')); + await assert.rejects(lateAcquisition, /cancel during acquisition/); + const lateClose = lateScope.close(); + await Promise.resolve(); + assert.equal(lateDisposed, false, 'shutdown must wait for the in-flight acquisition'); + finishAcquire(ready); + await lateClose; + assert.equal(lateDisposed, true, 'late acquisition must be disposed before shutdown settles'); + assert.equal(lateClaim.record.resources[0].disposal, 'settled'); + lateClaim.release(); + + const failedDirectory = path.join(root, 'failed-lock'); + const failedClaim = createDesktopWorkloadClaim({ + runId: 'failed-run', + targetId: 'registered-target', + targetInstanceId: 'physical-target', + directory: failedDirectory, + }); + const failedScope = new DesktopServiceScope(runner, undefined, 'failed-run'); + failedScope.setResourceObserver((resource) => failedClaim.updateResource(resource)); + await failedScope.contribute( + [ + { + id: 'unstoppable', + ownership: 'owned', + prepare: () => ({ + identity: 'exact-resource-instance', + acquire: () => Promise.resolve(), + async dispose() { + throw new Error('dispose failed'); + }, + }), + }, + ], + { targetId: 'registered-target', targetInstanceId: 'physical-target' }, + ); + await assert.rejects(failedScope.close(), /did not shut down cleanly/); + assert.equal(failedClaim.record.state, 'fenced'); + assert.deepEqual( + failedClaim.record.resources.map(({ id, identity, generation, ownership, disposal }) => ({ + id, + identity, + generation, + ownership, + disposal, + })), + [{ id: 'failed-run:unstoppable', identity: 'exact-resource-instance', generation: 1, ownership: 'owned', disposal: 'failed' }], + ); + assert.equal(failedClaim.record.unresolvedEffects[0].resourceIdentity, 'exact-resource-instance'); + assert.throws(() => failedClaim.release(), /unresolved effects/); + const ownerFile = path.join(failedDirectory, 'interactive.lock'); + const live = await inspectDesktopWorkloadClaim(ownerFile, async (owner) => ({ + state: 'alive', + processStartedAt: owner.processStartedAt, + })); + const unknown = await inspectDesktopWorkloadClaim(ownerFile, async () => ({ state: 'unknown' })); + const reused = await inspectDesktopWorkloadClaim(ownerFile, async (owner) => ({ + state: 'alive', + processStartedAt: new Date(Date.parse(owner.processStartedAt) + 1000).toISOString(), + })); + assert.equal(live.state, 'live'); + assert.equal(unknown.state, 'unknown'); + assert.equal(reused.state, 'pid-reused'); + assert.equal(fs.existsSync(ownerFile), true, 'inspection must never reclaim a claim'); + assert.throws( + () => + createDesktopWorkloadClaim({ + runId: 'concurrent-run', + targetId: 'registered-target', + targetInstanceId: 'physical-target', + directory: failedDirectory, + }), + /Another desktop workload holds/, + ); + + const marker = path.join(root, 'dependent-started'); + const serviceRun = reporter({ kind: 'lifecycle' }); + const serviceSupervisor = new DesktopSupervisor({ + target: { + endpoint: 'win32', + host: { kind: 'installed', application: { windowTitle: 'fixture' } }, + id: 'service-target', + instanceId: 'physical-target', + projectRoot: root, + configPath: path.join(root, 'desktop.config.mjs'), + launchTimeoutMs: 2000, + services: [ + { + id: 'required', + command: { command: process.execPath, args: ['-e', 'process.exitCode = 7'] }, + readyUrl: 'https://127.0.0.1:1', + }, + { + id: 'dependent', + command: { command: process.execPath, args: ['-e', "require('node:fs').writeFileSync(process.argv[1], 'started')", marker] }, + readyUrl: 'https://127.0.0.1:1', + dependsOn: ['required'], + }, + ], + }, + reporter: serviceRun, + lockDirectory: path.join(root, 'service-lock'), + }); + await serviceSupervisor.open(); + await assert.rejects(serviceSupervisor.startConfiguredServices(), /exited.*code 7/); + await serviceSupervisor.close(); + await serviceRun.finish(); + assert.equal(fs.existsSync(marker), false, 'a dependant must not start after its required service fails'); + + const reportLockDirectory = path.join(root, 'report-lock'); + const reportSupervisor = new DesktopSupervisor({ + target: { + endpoint: 'win32', + host: { kind: 'installed', application: { windowTitle: 'fixture' } }, + id: 'reported-target', + instanceId: 'physical-target', + projectRoot: root, + configPath: path.join(root, 'desktop.config.mjs'), + }, + reporter: { runId: 'report-delegated-run' }, + reportSession: { environment: {} }, + lockDirectory: reportLockDirectory, + }); + await reportSupervisor.open(); + assert.equal( + JSON.parse(fs.readFileSync(path.join(reportLockDirectory, 'interactive.lock'), 'utf8')).owner.targetId, + 'reported-target', + 'reporting delegation must not suppress workload ownership', + ); + await reportSupervisor.close(); + const workloadDirectory = path.join(root, 'delegated-workload-lock'); + const delegatedClaim = createDesktopWorkloadClaim({ + runId: 'parent-run', + targetId: 'registered-target', + targetInstanceId: 'physical-target', + directory: workloadDirectory, + }); + const workloadSupervisor = new DesktopSupervisor({ + target: { + endpoint: 'win32', + host: { kind: 'installed', application: { windowTitle: 'fixture' } }, + id: 'delegated-target', + instanceId: 'physical-target', + projectRoot: root, + configPath: path.join(root, 'desktop.config.mjs'), + }, + reporter: { runId: 'child-run' }, + reportSession: { environment: {} }, + workload: { kind: 'delegated', claim: delegatedClaim }, + lockDirectory: path.join(root, 'unused-child-lock'), + }); + await workloadSupervisor.open(); + await workloadSupervisor.close(); + assert.equal(fs.existsSync(path.join(workloadDirectory, 'interactive.lock')), true); + delegatedClaim.release(); } else if (mode === 'agent') { const { DesktopSupervisor } = await import(lib('supervisor.js')); const { FakeDesktopHost } = await import('@fluentui-react-native/desktop-driver/testing'); @@ -1011,6 +1449,32 @@ async function main() { const { startDesktopService } = await import(lib('driverService.js')); const { FakeDesktopHost } = await import('@fluentui-react-native/desktop-driver/testing'); const { createDesktopDriverClient } = await import('@fluentui-react-native/desktop-driver/client'); + const siblingRecovery = memoryRecovery(); + const siblingTarget = { endpoint: 'win32', id: 'bridge-target', platformName: 'windows' }; + for (const [index, port] of [0, undefined].entries()) { + const service = await startDesktopService(new FakeDesktopHost({ endpoint: 'win32' }), { + target: siblingTarget, + port, + recovery: siblingRecovery, + createTarget: async (_host, recovery) => { + if (index === 0) await recovery.storage.publish('bridge-epoch.json', 'authenticated-epoch'); + else assert.equal(await recovery.storage.read('bridge-epoch.json'), 'authenticated-epoch'); + return undefined; + }, + }); + await service.close(); + } + assert.equal(siblingRecovery.records.size, 1); + assert.match([...siblingRecovery.records.keys()][0], /^settlement-[a-f0-9]{24}-bridge-epoch\.json$/); + const isolated = await startDesktopService(new FakeDesktopHost({ endpoint: 'win32' }), { + target: { ...siblingTarget, id: 'another-target' }, + recovery: siblingRecovery, + createTarget: async (_host, recovery) => { + assert.equal(await recovery.storage.read('bridge-epoch.json'), undefined); + return undefined; + }, + }); + await isolated.close(); const target = { endpoint: 'win32', host: { kind: 'installed', ownership: 'external', application: { windowTitle: 'fake' } }, diff --git a/packages/agentic/desktop-test/src/contracts.test.ts b/packages/agentic/desktop-test/src/contracts.test.ts index d666a3dcd5..64e24bb49c 100644 --- a/packages/agentic/desktop-test/src/contracts.test.ts +++ b/packages/agentic/desktop-test/src/contracts.test.ts @@ -13,6 +13,7 @@ describe('neutral desktop harness contracts', () => { 'transport', 'delegated-cli', 'services', + 'resources', 'owned-service', 'agent', 'recovery', diff --git a/packages/agentic/desktop-test/src/driverService.ts b/packages/agentic/desktop-test/src/driverService.ts index 5584b68156..6aadce96d5 100644 --- a/packages/agentic/desktop-test/src/driverService.ts +++ b/packages/agentic/desktop-test/src/driverService.ts @@ -32,7 +32,10 @@ export type DesktopServiceOptions = { signal?: AbortSignal; guard?: (work: Promise) => Promise; /** Optional application-specific target context; native authority remains in the driver. */ - createTarget?: (host: DesktopHost) => Promise | undefined>; + createTarget?: ( + host: DesktopHost, + recovery: { storage: PrivateStorage; probeApplication?: ProbeApplication }, + ) => Promise | undefined>; }; export type DesktopService = DesktopDriverServer & { readonly agentDescriptor?: string }; @@ -66,8 +69,8 @@ export async function startDesktopService( let server: DesktopDriverServer | undefined; let publication: DesktopAgentPublication | undefined; try { - const target = { ...options.target, ...(await options.createTarget?.(desktopHost)), host: desktopHost }; const recovery = await resolveRecovery(options, artifact); + const target = { ...options.target, ...(await options.createTarget?.(desktopHost, recovery)), host: desktopHost }; options.signal?.throwIfAborted(); server = await createDesktopDriverServer({ ...(authority ? { authority } : {}), diff --git a/packages/agentic/desktop-test/src/index.ts b/packages/agentic/desktop-test/src/index.ts index 1b61b3ad75..44f242d5ce 100644 --- a/packages/agentic/desktop-test/src/index.ts +++ b/packages/agentic/desktop-test/src/index.ts @@ -1,5 +1,25 @@ export { claimDesktopWorkload, DesktopSupervisor } from './supervisor.js'; export type { DesktopSupervisorOptions } from './supervisor.js'; +export { createDesktopWorkloadClaim, inspectDesktopWorkloadClaim } from './ownership.js'; +export type { + DesktopWorkloadClaim, + DesktopWorkloadClaimInspection, + DesktopWorkloadOwnerIdentity, + DesktopWorkloadOwnerProbe, + DesktopWorkloadOwnershipRecord, + DesktopUnresolvedEffect, +} from './ownership.js'; +export { orderDesktopResourceContributions, validateDesktopResourcePlan } from './resources.js'; +export type { + DesktopResourceContext, + DesktopResourceContribution, + DesktopResourceDisposal, + DesktopResourceOwnership, + DesktopResourcePlan, + DesktopResourceReadiness, + DesktopResourceRecord, + DesktopResourceServiceReadyRecord, +} from './resources.js'; export { startNativeDesktopService } from './driverService.js'; export type { DesktopServiceOptions } from './driverService.js'; export { createNativeAgentPublisher, defaultAgentDescriptor } from './agentConnection.js'; diff --git a/packages/agentic/desktop-test/src/integration.ts b/packages/agentic/desktop-test/src/integration.ts index 35fc5021a6..dc7c7b0e6c 100644 --- a/packages/agentic/desktop-test/src/integration.ts +++ b/packages/agentic/desktop-test/src/integration.ts @@ -5,6 +5,7 @@ import type { DesktopAgentConnectionOptions } from './agentConnection.js'; import type { DesktopStage, ResolvedDesktopConfig, ResolvedDesktopTarget } from './config.js'; import type { DesktopSupervisor } from './supervisor.js'; import type { DesktopReportWriter } from './reporting/collector.js'; +import type { DesktopResourceContribution } from './resources.js'; /** * Trusted, explicitly imported local code; never included in capabilities or persisted manifests. @@ -13,6 +14,7 @@ import type { DesktopReportWriter } from './reporting/collector.js'; export type DesktopIntegration = { name: string; validate(): void; + resources?: readonly DesktopResourceContribution[]; /** * Whether `run` serves `serve --agent`: it protects its driver with a grant authority, publishes one agent connection * from the process that listens, and reports the descriptor in its readiness record. `serve --agent` requires it. diff --git a/packages/agentic/desktop-test/src/ownedService.ts b/packages/agentic/desktop-test/src/ownedService.ts index e5f1d39587..5f0ce59a94 100644 --- a/packages/agentic/desktop-test/src/ownedService.ts +++ b/packages/agentic/desktop-test/src/ownedService.ts @@ -3,9 +3,25 @@ import path from 'node:path'; import { validateCommand } from './config.js'; import { NodeDesktopCommandRunner } from './commands.js'; import { createDesktopReportSession } from './reporting/session.js'; +import { finishDesktopProcess, registerDesktopProcessDisposal } from './processControl.js'; /** Bridges a generated PowerShell service descriptor into the parent's resource/reporting scope. */ export async function runOwnedDesktopService(descriptorPath: string): Promise { + const controller = new AbortController(); + const unregister = registerDesktopProcessDisposal(async () => { + controller.abort(); + await execution; + }); + const execution = runService(descriptorPath, controller.signal); + try { + await execution; + finishDesktopProcess(); + } finally { + unregister(); + } +} + +async function runService(descriptorPath: string, signal: AbortSignal): Promise { const value: unknown = JSON.parse(fs.readFileSync(descriptorPath, 'utf8')); if ( !value || @@ -30,6 +46,8 @@ export async function runOwnedDesktopService(descriptorPath: string): Promise((resolve) => { stop = resolve; }); + signal.addEventListener('abort', stop, { once: true }); + if (signal.aborted) stop(); const interval = setInterval(() => { if (fs.existsSync(stopPath)) stop(); }, 100); @@ -38,6 +56,7 @@ export async function runOwnedDesktopService(descriptorPath: string): Promise; + +export type DesktopUnresolvedEffect = Readonly<{ + id: string; + resourceId: string; + resourceIdentity: string; +}>; + +export type DesktopWorkloadOwnershipRecord = Readonly<{ + schemaVersion: 2; + state: 'active' | 'fenced'; + owner: DesktopWorkloadOwnerIdentity; + resources: readonly DesktopResourceRecord[]; + unresolvedEffects: readonly DesktopUnresolvedEffect[]; +}>; + +export type DesktopWorkloadClaim = { + readonly record: DesktopWorkloadOwnershipRecord; + updateResource(resource: DesktopResourceRecord): void; + recordUnresolvedEffect(effect: DesktopUnresolvedEffect): void; + settleUnresolvedEffect(id: string): void; + release(): void; +}; + +export type DesktopWorkloadOwnerProbe = { state: 'alive'; processStartedAt: string } | { state: 'exited' } | { state: 'unknown' }; + +export type DesktopWorkloadClaimInspection = + | { state: 'live'; record: DesktopWorkloadOwnershipRecord } + | { state: 'exited'; record: DesktopWorkloadOwnershipRecord } + | { state: 'pid-reused'; record: DesktopWorkloadOwnershipRecord } + | { state: 'unknown'; record: DesktopWorkloadOwnershipRecord }; + +type DesktopWorkloadClaimOptions = { + runId: string; + targetId: string; + targetInstanceId: string; + directory?: string; +}; + +export function createDesktopWorkloadClaim(options: DesktopWorkloadClaimOptions): DesktopWorkloadClaim { + const user = createHash('sha256').update(os.homedir()).digest('hex').slice(0, 16); + const root = options.directory ?? path.join(os.tmpdir(), `furn-desktop-workload-${user}`); + fs.mkdirSync(root, { recursive: true, mode: 0o700 }); + const filename = path.join(root, 'interactive.lock'); + const owner: DesktopWorkloadOwnerIdentity = Object.freeze({ + id: randomUUID(), + processId: process.pid, + processStartedAt: new Date(Date.now() - process.uptime() * 1000).toISOString(), + runId: options.runId, + targetId: options.targetId, + targetInstanceId: options.targetInstanceId, + }); + let current: DesktopWorkloadOwnershipRecord = Object.freeze({ + schemaVersion: 2, + state: 'active', + owner, + resources: Object.freeze([]), + unresolvedEffects: Object.freeze([]), + }); + let contents = JSON.stringify(current); + try { + fs.writeFileSync(filename, contents, { flag: 'wx', mode: 0o600 }); + } catch (error) { + throw new Error( + `Another desktop workload holds ${filename}. Do not run parallel physical-input suites. After a crash or failed cleanup, verify the recorded owner and its resources have stopped before removing its claim.`, + { cause: error }, + ); + } + + const update = (next: DesktopWorkloadOwnershipRecord) => { + if (fs.readFileSync(filename, 'utf8') !== contents) + throw new Error('Desktop claim ownership changed; refusing to update another owner.'); + const nextContents = JSON.stringify(next); + fs.writeFileSync(filename, nextContents, { mode: 0o600 }); + contents = nextContents; + current = Object.freeze(next); + }; + + return { + get record() { + return current; + }, + updateResource(resource) { + const resources = current.resources.filter((entry) => entry.id !== resource.id); + resources.push(Object.freeze({ ...resource })); + const unresolvedEffects = current.unresolvedEffects.filter((effect) => effect.resourceId !== resource.id); + if (resource.ownership === 'owned' && resource.disposal === 'failed') { + unresolvedEffects.push({ + id: `dispose:${resource.id}:${resource.generation}`, + resourceId: resource.id, + resourceIdentity: resource.identity, + }); + } else if (resource.disposal === 'pending') { + unresolvedEffects.push({ + id: `dispose:${resource.id}:${resource.generation}`, + resourceId: resource.id, + resourceIdentity: resource.identity, + }); + } + update({ + ...current, + state: unresolvedEffects.length ? 'fenced' : 'active', + resources: Object.freeze(resources), + unresolvedEffects: Object.freeze(unresolvedEffects), + }); + }, + recordUnresolvedEffect(effect) { + if (current.unresolvedEffects.some((entry) => entry.id === effect.id)) return; + update({ + ...current, + state: 'fenced', + unresolvedEffects: Object.freeze([...current.unresolvedEffects, Object.freeze({ ...effect })]), + }); + }, + settleUnresolvedEffect(id) { + const unresolvedEffects = current.unresolvedEffects.filter((effect) => effect.id !== id); + update({ + ...current, + state: unresolvedEffects.length ? 'fenced' : 'active', + unresolvedEffects: Object.freeze(unresolvedEffects), + }); + }, + release() { + if (current.unresolvedEffects.length > 0) + throw new Error('Desktop claim has unresolved effects; refusing to release fenced ownership.'); + if (current.resources.some((resource) => resource.ownership === 'owned' && resource.disposal !== 'settled')) { + throw new Error('Desktop claim has unsettled owned resources; refusing to release ownership.'); + } + if (fs.readFileSync(filename, 'utf8') !== contents) + throw new Error('Desktop claim ownership changed; refusing to remove another owner.'); + fs.unlinkSync(filename); + }, + }; +} + +export async function inspectDesktopWorkloadClaim( + filename: string, + probe: (owner: DesktopWorkloadOwnerIdentity) => Promise, +): Promise { + const record = parseOwnershipRecord(fs.readFileSync(filename, 'utf8')); + const result = await probe(record.owner); + if (result.state === 'unknown') return { state: 'unknown', record }; + if (result.state === 'exited') return { state: 'exited', record }; + if (result.processStartedAt !== record.owner.processStartedAt) return { state: 'pid-reused', record }; + return { state: 'live', record }; +} + +function parseOwnershipRecord(contents: string): DesktopWorkloadOwnershipRecord { + const record = JSON.parse(contents) as DesktopWorkloadOwnershipRecord; + if ( + record.schemaVersion !== 2 || + (record.state !== 'active' && record.state !== 'fenced') || + !record.owner || + typeof record.owner.id !== 'string' || + !Number.isSafeInteger(record.owner.processId) || + typeof record.owner.processStartedAt !== 'string' || + typeof record.owner.runId !== 'string' || + typeof record.owner.targetId !== 'string' || + typeof record.owner.targetInstanceId !== 'string' || + !Array.isArray(record.resources) || + !Array.isArray(record.unresolvedEffects) + ) { + throw new TypeError('Desktop workload claim is invalid; refusing recovery.'); + } + return record; +} diff --git a/packages/agentic/desktop-test/src/packageBoundary.test.ts b/packages/agentic/desktop-test/src/packageBoundary.test.ts index 5f8710dd5d..965ffb63b7 100644 --- a/packages/agentic/desktop-test/src/packageBoundary.test.ts +++ b/packages/agentic/desktop-test/src/packageBoundary.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import path from 'node:path'; +import { pathToFileURL } from 'node:url'; const packageRoot = path.resolve(__dirname, '..'); const repoRoot = path.resolve(packageRoot, '../../..'); @@ -41,7 +42,7 @@ function declaredForbiddenPackages(manifest: PackageManifest): string[] { } function runNode(entry: string): ReturnType { - return spawnSync(process.execPath, ['--experimental-loader', loaderPath, entry], { + return spawnSync(process.execPath, ['--experimental-loader', pathToFileURL(loaderPath).href, entry], { cwd: fixtureRoot, encoding: 'utf8', }); diff --git a/packages/agentic/desktop-test/src/processControl.test.ts b/packages/agentic/desktop-test/src/processControl.test.ts new file mode 100644 index 0000000000..61ee818c27 --- /dev/null +++ b/packages/agentic/desktop-test/src/processControl.test.ts @@ -0,0 +1,339 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import type { ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import net from 'node:net'; +import { pathToFileURL } from 'node:url'; +import { createDesktopProcessControl } from './processControl.js'; +import { deadline, NodeDesktopCommandRunner } from './commands.js'; +import type { RunningDesktopCommand } from './commands.js'; +import { DesktopServiceScope } from './services.js'; +import type { DesktopResourceRecord } from './resources.js'; + +const fixture = path.resolve(__dirname, '../fixtures/process-control/child.mjs'); +const parentFixture = path.resolve(__dirname, '../fixtures/process-control/parent.mjs'); +// --import requires a file URL for absolute Windows paths. +const preload = pathToFileURL(path.resolve(__dirname, '../config/process-lifeline.mjs')).href; +const quiet = { write: () => true }; +jest.setTimeout(45_000); + +describe('owned process lifelines', () => { + let root: string; + const children: ChildProcess[] = []; + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'furn-process-contract-')); + }); + afterEach(async () => { + const exits: Promise[] = []; + for (const child of children.splice(0)) { + if (child.exitCode === null && child.signalCode === null) { + exits.push(once(child, 'exit')); + child.kill('SIGKILL'); + } + } + await Promise.all(exits); + // Windows can briefly hold files of just-terminated processes. + fs.rmSync(root, { force: true, recursive: true, maxRetries: 20, retryDelay: 100 }); + }); + + test('correlates service readiness and waits for explicit descriptor withdrawal', async () => { + const marker = path.join(root, 'descriptor'); + const runner = new NodeDesktopCommandRunner({ logDirectory: root, output: quiet }); + const running = runner.start({ command: process.execPath, args: [fixture, 'service', marker], cwd: root, env: {} }); + try { + const record = await running.waitForReadiness!({ tier: 'service-ready', service: 'fixture', descriptor: 'fixture.json' }, 5000); + expect(record).toEqual({ + tier: 'service-ready', + service: 'fixture', + descriptor: 'fixture.json', + incarnation: expect.any(String), + processId: Number(fs.readFileSync(marker, 'utf8')), + }); + await expect( + running.waitForReadiness!({ tier: 'service-ready', service: 'fixture', descriptor: 'stale.json' }, 5000), + ).rejects.toThrow('different descriptor'); + await running.stop(); + expect(fs.existsSync(marker)).toBe(false); + expect(await running.completed).toBe(0); + } finally { + await runner.close(); + } + }); + + test('records an authenticated process receipt in an owned resource generation', async () => { + const marker = path.join(root, 'descriptor'); + const runner = new NodeDesktopCommandRunner({ logDirectory: root, output: quiet }); + const scope = new DesktopServiceScope(runner); + const records: DesktopResourceRecord[] = []; + scope.setResourceObserver((record) => records.push(record)); + let running: RunningDesktopCommand | undefined; + try { + await scope.contribute( + [ + { + id: 'fixture', + ownership: 'owned', + prepare: () => ({ + identity: 'fixture-service', + acquire: async (signal) => { + running = runner.start({ command: process.execPath, args: [fixture, 'service', marker], cwd: root, env: {} }); + if (!running.waitForReadiness) throw new Error('Process readiness is required.'); + return running.waitForReadiness({ tier: 'service-ready', service: 'fixture', descriptor: 'fixture.json' }, 5000, signal); + }, + dispose: async () => running?.stop(), + }), + }, + ], + { targetId: 'fixture-target', targetInstanceId: 'fixture-instance' }, + ); + expect(records.at(-1)).toMatchObject({ + id: 'desktop:fixture', + identity: 'fixture-service', + generation: 1, + ownership: 'owned', + readiness: 'ready', + serviceReady: { + tier: 'service-ready', + service: 'fixture', + descriptor: 'fixture.json', + incarnation: expect.any(String), + processId: Number(fs.readFileSync(marker, 'utf8')), + }, + }); + await scope.close(); + expect(records.at(-1)?.disposal).toBe('settled'); + expect(fs.existsSync(marker)).toBe(false); + } finally { + await scope.close(); + await runner.close(); + } + }); + + // libuv places non-detached Windows children in a kill-on-close job, so a crashed owner hard-terminates them before + // cooperative disposal can run. See references/current-status.md. + (process.platform === 'win32' ? test.skip : test).each([ + { mode: 'service', crash: 'parent' }, + { mode: 'wrapper', crash: 'parent' }, + { mode: 'wrapper', crash: 'wrapper' }, + ])('$crash crash disposes surviving $mode and leaves borrowed resources running', async ({ mode, crash }) => { + const marker = path.join(root, 'descriptor'); + const borrowedMarker = path.join(root, 'borrowed'); + const borrowed = spawn(process.execPath, [fixture, 'borrowed', borrowedMarker], { stdio: 'ignore' }); + children.push(borrowed); + const parent = spawn(process.execPath, [parentFixture, fixture, mode, marker, root, preload], { + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }); + children.push(parent); + let diagnostic = ''; + parent.stderr!.on('data', (text: Buffer) => (diagnostic += text.toString())); + const ready = await deadline( + Promise.race([ + once(parent, 'message'), + once(parent, 'exit').then(() => { + throw new Error(`Parent fixture failed: ${diagnostic}`); + }), + ]), + 20_000, + 'Starting parent fixture', + ); + const processId = (ready[0] as { processId: number }).processId; + if (crash === 'parent') parent.kill('SIGKILL'); + else process.kill(Number(fs.readFileSync(`${marker}.wrapper`, 'utf8')), 'SIGKILL'); + await waitUntil(() => !fs.existsSync(marker)); + await waitUntil(() => !alive(processId)); + expect(alive(borrowed.pid!)).toBe(true); + expect(fs.existsSync(borrowedMarker)).toBe(true); + if (crash === 'wrapper') { + expect(alive(parent.pid!)).toBe(true); + parent.send('close'); + await deadline(once(parent, 'exit'), 5000, 'Closing surviving owner'); + } + if (process.platform !== 'win32') fs.rmSync(fs.readFileSync(`${marker}.socket`, 'utf8'), { force: true }); + }); + + test('a stop without disposers runs the child signal handler on every platform', async () => { + const marker = path.join(root, 'signal'); + const runner = new NodeDesktopCommandRunner({ logDirectory: root, output: quiet, errorOutput: quiet }); + const running = runner.start({ command: process.execPath, args: [fixture, 'signal', marker], cwd: root, env: {} }); + try { + await waitUntil(() => fs.existsSync(marker)); + await running.stop(); + expect(await running.completed).toBe(0); + expect(fs.readFileSync(marker, 'utf8')).toBe('graceful'); + } finally { + await runner.close(); + } + }); + + test('late Node spawn cannot execute its entry after parent disappearance', async () => { + const failures: Error[] = []; + const control = createDesktopProcessControl((error) => failures.push(error)); + const environment = control.environment(); + await control.close(); + const marker = path.join(root, 'late'); + const child = spawn(process.execPath, [fixture, 'late', marker], { + env: { ...process.env, ...environment }, + stdio: 'ignore', + }); + children.push(child); + const [code, signal] = await deadline(once(child, 'exit'), 15_000, 'Rejecting late child'); + expect(code !== 0 || signal !== null).toBe(true); + expect(fs.existsSync(marker)).toBe(false); + expect(failures).toEqual([]); + }); + + test.each(['failed', 'stalled'])('%s disposal is incomplete, never a successful cleanup', async (mode) => { + const marker = path.join(root, 'descriptor'); + const runner = new NodeDesktopCommandRunner({ logDirectory: root, output: quiet, errorOutput: quiet, shutdownTimeoutMs: 200 }); + const running = runner.start({ command: process.execPath, args: [fixture, mode, marker], cwd: root, env: {} }); + await running.waitForReadiness!({ tier: 'service-ready', service: 'fixture', descriptor: 'fixture.json' }, 5000); + await expect(running.stop()).rejects.toThrow(/disposal|settle/); + expect(fs.existsSync(marker)).toBe(true); + await runner.close(); + }); + + test('owned service wrappers cannot certify a failed child disposal', async () => { + const marker = path.join(root, 'descriptor'); + const descriptor = path.join(root, 'service.json'); + fs.writeFileSync( + descriptor, + JSON.stringify({ + label: 'fixture-wrapper', + stopPath: path.join(root, 'stop'), + command: { command: process.execPath, args: [fixture, 'failed', marker], cwd: root }, + }), + ); + const runner = new NodeDesktopCommandRunner({ logDirectory: root, output: quiet, errorOutput: quiet, shutdownTimeoutMs: 15_000 }); + const running = runner.start({ + command: process.execPath, + args: [path.resolve(__dirname, '../config/service.cjs'), descriptor], + cwd: root, + env: {}, + }); + try { + await waitUntil(() => fs.existsSync(marker)); + await expect(running.stop()).rejects.toThrow(); + expect(fs.existsSync(marker)).toBe(true); + } finally { + await runner.close(); + } + }); + + test('producer loss invalidates readiness and cannot certify descriptor withdrawal', async () => { + const marker = path.join(root, 'descriptor'); + const runner = new NodeDesktopCommandRunner({ logDirectory: root, output: quiet, errorOutput: quiet }); + const running = runner.start({ command: process.execPath, args: [fixture, 'service', marker], cwd: root, env: {} }); + const expected = { tier: 'service-ready', service: 'fixture', descriptor: 'fixture.json' } as const; + try { + const record = await running.waitForReadiness!(expected, 5000); + process.kill(record.processId, 'SIGKILL'); + await running.completed.catch(() => undefined); + await expect(running.waitForReadiness!(expected, 5000)).rejects.toThrow(); + await expect(running.stop()).rejects.toThrow('without settled disposal'); + expect(fs.existsSync(marker)).toBe(true); + } finally { + await runner.close(); + } + }); + + test('a connection reset reports readiness producer loss instead of the transport error', async () => { + const accepted: net.Socket[] = []; + const createServer = net.createServer.bind(net); + const spy = jest.spyOn(net, 'createServer').mockImplementation(((listener: (socket: net.Socket) => void) => + createServer((socket) => { + accepted.push(socket); + listener(socket); + })) as typeof net.createServer); + const failures: Error[] = []; + const control = createDesktopProcessControl((error) => failures.push(error)); + spy.mockRestore(); + const { address, nonce } = JSON.parse(control.environment().FURN_DESKTOP_PROCESS_CONTROL!) as { address: string; nonce: string }; + const expected = { tier: 'service-ready', service: 'fixture', descriptor: 'fixture.json' } as const; + const readiness = control.waitForReadiness(expected, 5000); + const client = net.connect(address); + try { + await once(client, 'connect'); + client.write(`${JSON.stringify({ type: 'hello', nonce, processId: process.pid })}\n`); + client.write(`${JSON.stringify({ type: 'ready', requestId: 'r1', readiness: expected })}\n`); + await readiness; + // Linux can surface a peer SIGKILL as ECONNRESET before 'close'; emulate that ordering on any platform. + const closed = new Promise((resolve) => accepted[0]!.once('close', () => resolve())); + accepted[0]!.destroy(Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' })); + await closed; + expect(failures.map((failure) => failure.message)).toEqual([expect.stringContaining('without settled disposal')]); + } finally { + client.destroy(); + await control.close(); + } + }); + + test('a connection reset after an intentional stop does not fail cleanup', async () => { + const accepted: net.Socket[] = []; + const createServer = net.createServer.bind(net); + const spy = jest.spyOn(net, 'createServer').mockImplementation(((listener: (socket: net.Socket) => void) => + createServer((socket) => { + accepted.push(socket); + listener(socket); + })) as typeof net.createServer); + const failures: Error[] = []; + const control = createDesktopProcessControl((error) => failures.push(error)); + spy.mockRestore(); + const { address, nonce } = JSON.parse(control.environment().FURN_DESKTOP_PROCESS_CONTROL!) as { address: string; nonce: string }; + const client = net.connect(address); + try { + const acceptedMessage = once(client, 'data'); + await once(client, 'connect'); + client.write(`${JSON.stringify({ type: 'hello', nonce, processId: process.pid })}\n`); + await acceptedMessage; + control.stop(); + const closed = new Promise((resolve) => accepted[0]!.once('close', () => resolve())); + accepted[0]!.destroy(Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' })); + await closed; + await expect(control.settled(1000)).resolves.toBeUndefined(); + expect(failures).toEqual([]); + } finally { + client.destroy(); + await control.close(); + } + }); + + test('stalled readiness and cancellation do not promote another readiness tier', async () => { + const control = createDesktopProcessControl(() => undefined); + try { + await expect(control.waitForReadiness({ tier: 'service-ready', service: 'missing' }, 30)).rejects.toThrow('correlated readiness'); + const controller = new AbortController(); + controller.abort(new Error('Cancelled acquisition')); + await expect(control.waitForReadiness({ tier: 'service-ready', service: 'missing' }, 1000, controller.signal)).rejects.toThrow( + 'Cancelled acquisition', + ); + await expect( + // @ts-expect-error Higher tiers are not process service readiness. + control.waitForReadiness({ tier: 'application-ready', service: 'missing' }, 1000), + ).rejects.toThrow('separate verification'); + } finally { + await control.close(); + } + }); +}); + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false; + throw error; + } +} + +async function waitUntil(check: () => boolean): Promise { + await deadline( + (async () => { + while (!check()) await new Promise((resolve) => setTimeout(resolve, 20)); + })(), + 15_000, + 'Verifying surviving cooperative process cleanup', + ); +} diff --git a/packages/agentic/desktop-test/src/processControl.ts b/packages/agentic/desktop-test/src/processControl.ts new file mode 100644 index 0000000000..fe8c19a4da --- /dev/null +++ b/packages/agentic/desktop-test/src/processControl.ts @@ -0,0 +1,231 @@ +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { randomUUID, timingSafeEqual } from 'node:crypto'; +import { once } from 'node:events'; +import { processLifelineUrl } from '../config/process-path.cjs'; + +export type DesktopServiceReady = { + tier: 'service-ready'; + service: string; + descriptor?: string; +}; +export type DesktopServiceReadyRecord = DesktopServiceReady & { incarnation: string; processId: number }; +type ChildControl = { + register(dispose: () => Promise): () => void; + ready(readiness: DesktopServiceReady): Promise; + finish(): void; +}; +const controlKey = Symbol.for('furn.desktop.process-control'); +export const DESKTOP_PROCESS_CONTROL = 'FURN_DESKTOP_PROCESS_CONTROL'; + +function childControl(): ChildControl | undefined { + return (globalThis as typeof globalThis & { [controlKey]?: ChildControl })[controlKey]; +} + +/** Register before the first asynchronous acquisition; disposal must await startup and durable settlement. */ +export function registerDesktopProcessDisposal(dispose: () => Promise): () => void { + if (process.env[DESKTOP_PROCESS_CONTROL] && !childControl()) throw new Error('Owned process lifeline preload is missing.'); + return childControl()?.register(dispose) ?? (() => undefined); +} + +export async function publishDesktopServiceReady(readiness: DesktopServiceReady): Promise { + validateReadiness(readiness); + if (process.env[DESKTOP_PROCESS_CONTROL] && !childControl()) throw new Error('Owned process lifeline preload is missing.'); + await childControl()?.ready(readiness); +} + +export function finishDesktopProcess(): void { + childControl()?.finish(); +} + +export function createDesktopProcessControl(onFailure: (error: Error) => void) { + const incarnation = randomUUID(); + const nonce = randomUUID(); + const address = + process.platform === 'win32' ? `\\\\.\\pipe\\furn-process-${incarnation}` : path.join(os.tmpdir(), `furn-process-${incarnation}.sock`); + const sockets = new Set(); + const records = new Map(); + const changed = new Set<() => void>(); + let stopping = false; + let terminal: Error | undefined; + let closePromise: Promise | undefined; + const fail = (error: Error) => { + terminal ??= error; + for (const notify of changed) notify(); + onFailure(error); + }; + const server = net.createServer((socket) => { + sockets.add(socket); + socket.setEncoding('utf8'); + let buffer = ''; + let processId: number | undefined; + let disposed = false; + let socketError: Error | undefined; + let socketErrorWasUnexpected = false; + // 'close' always follows 'error'; defer so producer loss (e.g. ECONNRESET on SIGKILL) keeps its specific diagnosis. + socket.on('error', (error) => { + if (!socketError) { + socketError = error; + socketErrorWasUnexpected = !stopping; + } + }); + socket.on('close', () => { + sockets.delete(socket); + let producerLost = false; + for (const [service, record] of records) { + if (record.processId !== processId) continue; + records.delete(service); + if (disposed && stopping) continue; + producerLost = true; + fail(new Error(`Owned service "${service}" lost its readiness producer without settled disposal.`)); + } + if (socketError && socketErrorWasUnexpected && !producerLost) fail(socketError); + for (const notify of changed) notify(); + }); + socket.on('data', (text: string) => { + buffer += text; + try { + if (Buffer.byteLength(buffer) > 65536) throw new Error('Oversized owned process control frame.'); + let newline: number; + while ((newline = buffer.indexOf('\n')) >= 0) { + const frame: unknown = JSON.parse(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + if (!isRecord(frame)) throw new TypeError('Invalid owned process control frame.'); + if (!processId) { + if ( + frame.type !== 'hello' || + typeof frame.nonce !== 'string' || + !sameSecret(frame.nonce, nonce) || + typeof frame.processId !== 'number' || + !Number.isSafeInteger(frame.processId) || + frame.processId <= 0 + ) + throw new Error('Invalid owned process lifeline handshake.'); + processId = frame.processId; + socket.write(`${JSON.stringify({ type: stopping ? 'stop' : 'accepted' })}\n`); + } else { + if (frame.type === 'disposed') { + disposed = true; + continue; + } + if (frame.type === 'disposal-failed') throw new Error(`Owned process disposal failed: ${String(frame.message)}`); + if (stopping) throw new Error('A stopping command cannot publish readiness.'); + if (frame.type !== 'ready' || typeof frame.requestId !== 'string') throw new Error('Invalid service readiness frame.'); + validateReadiness(frame.readiness); + if (records.has(frame.readiness.service)) throw new Error('Duplicate owned service readiness.'); + records.set(frame.readiness.service, { ...frame.readiness, incarnation, processId }); + socket.write(`${JSON.stringify({ type: 'acknowledged', requestId: frame.requestId })}\n`); + for (const notify of changed) notify(); + } + } + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + socket.destroy(); + } + }); + }); + server.on('error', fail); + server.listen(address); + const listening = once(server, 'listening').then(() => undefined); + void listening.catch(() => undefined); + return { + nonce, + environment(nodeOptions = '') { + return { + [DESKTOP_PROCESS_CONTROL]: JSON.stringify({ schemaVersion: 1, address, nonce, incarnation }), + NODE_OPTIONS: `${nodeOptions} --import=${processLifelineUrl}`.trim(), + }; + }, + async waitForReadiness(expected: DesktopServiceReady, timeoutMs = 120_000, signal?: AbortSignal): Promise { + validateReadiness(expected); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new TypeError('Invalid service readiness deadline.'); + await listening; + return new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); + changed.delete(check); + signal?.removeEventListener('abort', check); + }; + const check = () => { + const record = records.get(expected.service); + const error = + terminal ?? + (signal?.aborted ? signal.reason : undefined) ?? + (stopping ? new Error('Owned command stopped before readiness.') : undefined); + if (error !== undefined) { + cleanup(); + reject(error); + } else if (record) { + cleanup(); + if (record.descriptor !== expected.descriptor) reject(new Error('Owned service published a different descriptor.')); + else resolve(record); + } + }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Owned service "${expected.service}" did not publish correlated readiness within ${timeoutMs}ms.`)); + }, timeoutMs); + changed.add(check); + signal?.addEventListener('abort', check, { once: true }); + check(); + }); + }, + stop() { + stopping = true; + for (const notify of changed) notify(); + for (const socket of sockets) + if (!socket.destroyed && !socket.readableEnded && !socket.writableEnded) socket.write('{"type":"stop"}\n'); + return sockets.size; + }, + async settled(timeoutMs: number) { + await new Promise((resolve, reject) => { + const check = () => { + if (terminal || !sockets.size) { + clearTimeout(timer); + changed.delete(check); + if (terminal) reject(terminal); + else resolve(); + } + }; + const timer = setTimeout(() => { + changed.delete(check); + reject(new Error('Owned process disposal is incomplete; cooperative children did not settle.')); + }, timeoutMs); + changed.add(check); + check(); + }); + }, + close() { + return (closePromise ??= (async () => { + stopping = true; + for (const notify of changed) notify(); + await listening; + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + })()); + }, + }; +} + +function validateReadiness(value: unknown): asserts value is DesktopServiceReady { + if ( + !isRecord(value) || + value.tier !== 'service-ready' || + typeof value.service !== 'string' || + !value.service || + (value.descriptor !== undefined && (typeof value.descriptor !== 'string' || !value.descriptor)) || + Object.keys(value).some((key) => !['tier', 'service', 'descriptor'].includes(key)) + ) + throw new TypeError('Invalid owned service readiness; application, target and context readiness require separate verification.'); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function sameSecret(actual: string, expected: string): boolean { + const a = Buffer.from(actual); + const b = Buffer.from(expected); + return a.length === b.length && timingSafeEqual(a, b); +} diff --git a/packages/agentic/desktop-test/src/reporting/collector.test.ts b/packages/agentic/desktop-test/src/reporting/collector.test.ts index 3d2d3f3f39..3e811e66e6 100644 --- a/packages/agentic/desktop-test/src/reporting/collector.test.ts +++ b/packages/agentic/desktop-test/src/reporting/collector.test.ts @@ -1,6 +1,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; import { DesktopRunReporter } from './collector.js'; import type { DesktopInputLaneReport } from './results.js'; @@ -126,6 +128,7 @@ describe('desktop run reporter input lanes', () => { }); describe('desktop run reporter final output', () => { + const platform = process.platform; let artifactsRoot: string; beforeEach(() => { @@ -133,6 +136,8 @@ describe('desktop run reporter final output', () => { }); afterEach(() => { + jest.restoreAllMocks(); + Object.defineProperty(process, 'platform', { value: platform }); fs.rmSync(artifactsRoot, { force: true, recursive: true }); }); @@ -167,4 +172,93 @@ describe('desktop run reporter final output', () => { test('a ready long-lived command keeps its readiness document as the only output', async () => { expect(await finishFailedRun(true)).toBe(''); }); + + function createLifecycleReporter(): DesktopRunReporter { + return new DesktopRunReporter({ + artifactsRoot, + kind: 'lifecycle', + output: { write: () => true }, + errorOutput: { write: () => true }, + reporterMode: 'json', + summary: false, + }); + } + + (process.platform === 'win32' ? test : test.skip)( + 'atomic report replacement survives a short Windows reader without delete sharing', + async () => { + const reporter = createLifecycleReporter(); + const reader = spawn( + 'pwsh', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + '$file = [IO.File]::Open($env:DESKTOP_REPORT_LOCK_PATH, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read); try { [Console]::WriteLine("locked"); Start-Sleep -Milliseconds 100 } finally { $file.Dispose() }', + ], + { env: { ...process.env, DESKTOP_REPORT_LOCK_PATH: path.join(reporter.root, 'run.json') } }, + ); + const closed = once(reader, 'close'); + try { + const [ready] = await Promise.race([ + once(reader.stdout, 'data'), + closed.then(() => { + throw new Error('Report reader exited before acquiring its file lock.'); + }), + ]); + expect(String(ready).trim()).toBe('locked'); + await expect(reporter.finish()).resolves.toMatchObject({ terminal: true, status: 'passed', failures: [] }); + } finally { + expect(await closed).toEqual([0, null]); + } + }, + ); + + test.each(['EPERM', 'EACCES'])('retries transient Windows %s without dropping atomic replacement or diagnostics', async (code) => { + const reporter = createLifecycleReporter(); + Object.defineProperty(process, 'platform', { value: 'win32' }); + const wait = jest.spyOn(Atomics, 'wait').mockReturnValue('timed-out'); + const rename = fs.renameSync; + let attempts = 0; + const file = path.join(reporter.root, 'run.json'); + const previous = fs.readFileSync(file, 'utf8'); + jest.spyOn(fs, 'renameSync').mockImplementation((source, destination) => { + if (destination === file && attempts++ < 2) { + expect(fs.readFileSync(file, 'utf8')).toBe(previous); + throw Object.assign(new Error('Transient sharing conflict'), { code }); + } + return rename(source, destination); + }); + + await expect(reporter.finish()).resolves.toMatchObject({ terminal: true, status: 'passed', failures: [] }); + expect(wait).toHaveBeenCalledTimes(2); + expect(fs.readFileSync(path.join(reporter.root, 'events.jsonl'), 'utf8')).toContain('recovered after 2 Windows sharing retries'); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toMatchObject({ terminal: true, status: 'passed' }); + }); + + test.each([ + ['win32', 'EPERM', 10], + ['win32', 'EACCES', 10], + ['win32', 'EIO', 0], + ['linux', 'EPERM', 0], + ])('retains failed replacement on %s/%s after %i retries even if later writes recover', async (platform, code, retries) => { + const reporter = createLifecycleReporter(); + Object.defineProperty(process, 'platform', { value: platform }); + const wait = jest.spyOn(Atomics, 'wait').mockReturnValue('timed-out'); + const rename = fs.renameSync; + const injected = jest.spyOn(fs, 'renameSync').mockImplementation((source, destination) => { + if (destination === path.join(reporter.root, 'run.json')) throw Object.assign(new Error('Persistent rename failure'), { code }); + return rename(source, destination); + }); + reporter.emit({ kind: 'diagnostic', payload: { level: 'info', message: 'Queue an active snapshot.' } }); + await reporter.flush(); + expect(wait).toHaveBeenCalledTimes(retries); + expect(JSON.parse(fs.readFileSync(path.join(reporter.root, 'run.json'), 'utf8'))).toMatchObject({ terminal: false }); + injected.mockRestore(); + await expect(reporter.finish()).resolves.toMatchObject({ + terminal: true, + status: 'failed', + failures: [expect.objectContaining({ kind: 'reporting', error: expect.objectContaining({ message: 'Persistent rename failure' }) })], + }); + }); }); diff --git a/packages/agentic/desktop-test/src/reporting/collector.ts b/packages/agentic/desktop-test/src/reporting/collector.ts index c2d81db4cf..d8ea65525e 100644 --- a/packages/agentic/desktop-test/src/reporting/collector.ts +++ b/packages/agentic/desktop-test/src/reporting/collector.ts @@ -531,24 +531,49 @@ export class DesktopRunReporter { } private persist(result: DesktopRunResult): void { - const write = (name: string, data: string) => { - const temporary = this.artifactPath(`${name}.tmp`); - fs.writeFileSync(temporary, data, { mode: 0o600 }); - fs.renameSync(temporary, this.artifactPath(name)); - }; - if (this.options.junit !== false) write('junit.xml', renderJUnit(result)); + if (this.options.junit !== false) this.writeReport('junit.xml', renderJUnit(result)); this.persistSnapshot(result); } private persistSnapshot(result: DesktopRunResult): void { - const temporary = this.artifactPath('run.json.tmp'); const snapshot = this.sanitize({ ...result, terminal: result.terminal === true, status: result.terminal === true || result.status === 'failed' ? result.status : 'incomplete', }); - fs.writeFileSync(temporary, JSON.stringify(snapshot) + '\n', { mode: 0o600 }); - fs.renameSync(temporary, this.artifactPath('run.json')); + this.writeReport('run.json', JSON.stringify(snapshot) + '\n'); + } + + private writeReport(name: string, data: string): void { + const temporary = this.artifactPath(`${name}.tmp`); + const destination = this.artifactPath(name); + fs.writeFileSync(temporary, data, { mode: 0o600 }); + let retries = 0; + for (;;) { + try { + fs.renameSync(temporary, destination); + break; + } catch (error) { + if ( + process.platform !== 'win32' || + retries === 10 || + typeof error !== 'object' || + error === null || + !('code' in error) || + (error.code !== 'EPERM' && error.code !== 'EACCES') + ) + throw error; + // Windows readers can briefly deny delete sharing. Keep the old snapshot intact while retrying the same rename. + retries++; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); + } + } + if (retries > 0) { + this.emit({ + kind: 'diagnostic', + payload: { level: 'warning', message: `Atomic report replacement for ${name} recovered after ${retries} Windows sharing retries.` }, + }); + } } private async finalize(): Promise { diff --git a/packages/agentic/desktop-test/src/resources.ts b/packages/agentic/desktop-test/src/resources.ts new file mode 100644 index 0000000000..c2281bb920 --- /dev/null +++ b/packages/agentic/desktop-test/src/resources.ts @@ -0,0 +1,120 @@ +import type { DesktopServiceReadyRecord } from './processControl.js'; + +export type DesktopResourceServiceReadyRecord = Readonly; + +export type DesktopResourceOwnership = 'owned' | 'borrowed'; + +export type DesktopResourceReadiness = 'pending' | 'ready' | 'failed'; + +export type DesktopResourceDisposal = 'pending' | 'settled' | 'failed' | 'not-owned'; + +export type DesktopResourceRecord = Readonly<{ + id: string; + identity: string; + generation: number; + ownership: DesktopResourceOwnership; + readiness: DesktopResourceReadiness; + disposal: DesktopResourceDisposal; + serviceReady?: DesktopResourceServiceReadyRecord; +}>; + +export type DesktopResourcePlan = { + identity: string; + acquire(signal: AbortSignal): Promise; + /** Resolves only after every effect of this resource has settled. */ + dispose(): Promise; +}; + +export type DesktopResourceContext = { + signal: AbortSignal; + targetId: string; + targetInstanceId: string; +}; + +export type DesktopResourceContribution = { + id: string; + dependsOn?: readonly string[]; + ownership: DesktopResourceOwnership; + /** Must be side-effect-free; the returned cleanup is registered before acquire() runs. */ + prepare(context: DesktopResourceContext): DesktopResourcePlan; +}; + +export function orderDesktopResourceContributions(value: unknown): readonly DesktopResourceContribution[] { + if (!Array.isArray(value)) throw new TypeError('Desktop resource contributions must be an array.'); + const contributions = new Map(); + for (const [index, raw] of value.entries()) { + validateContribution(raw, index); + if (contributions.has(raw.id)) throw new TypeError(`Duplicate resource contribution "${raw.id}".`); + const dependencies = raw.dependsOn ?? []; + if (new Set(dependencies).size !== dependencies.length) throw new TypeError(`Resource "${raw.id}" has duplicate dependencies.`); + contributions.set(raw.id, raw); + } + + const ordered: DesktopResourceContribution[] = []; + const visiting = new Set(); + const visited = new Set(); + const visit = (id: string, ancestry: readonly string[]) => { + if (visited.has(id)) return; + if (visiting.has(id)) throw new TypeError(`Desktop resource dependency cycle: ${[...ancestry, id].join(' -> ')}.`); + const contribution = contributions.get(id); + if (!contribution) throw new TypeError(`Unknown desktop resource dependency "${id}".`); + visiting.add(id); + for (const dependency of contribution.dependsOn ?? []) visit(dependency, [...ancestry, id]); + visiting.delete(id); + visited.add(id); + ordered.push(contribution); + }; + for (const id of contributions.keys()) visit(id, []); + return ordered; +} + +export function validateDesktopResourcePlan(value: unknown, id: string): asserts value is DesktopResourcePlan { + const plan = object(value, `resource "${id}" plan`); + keys(plan, ['identity', 'acquire', 'dispose'], `resource "${id}" plan`); + text(plan.identity, `resource "${id}" identity`); + if (typeof plan.acquire !== 'function') throw new TypeError(`Resource "${id}" plan must expose acquire().`); + if (typeof plan.dispose !== 'function') throw new TypeError(`Resource "${id}" plan must expose settled dispose().`); +} + +export function validateDesktopResourceServiceReady(value: unknown): asserts value is DesktopResourceServiceReadyRecord { + const ready = object(value, 'service readiness'); + keys(ready, ['tier', 'service', 'descriptor', 'incarnation', 'processId'], 'service readiness'); + if (ready.tier !== 'service-ready') throw new TypeError('Only service-ready readiness is supported.'); + text(ready.service, 'service readiness service'); + if (ready.descriptor !== undefined) text(ready.descriptor, 'service readiness descriptor'); + text(ready.incarnation, 'service readiness incarnation'); + if (typeof ready.processId !== 'number' || !Number.isSafeInteger(ready.processId) || ready.processId < 1) { + throw new TypeError('Service readiness processId must be a positive safe integer.'); + } +} + +function validateContribution(value: unknown, index: number): asserts value is DesktopResourceContribution { + const contribution = object(value, `resource contribution ${index}`); + keys(contribution, ['id', 'dependsOn', 'ownership', 'prepare'], `resource contribution ${index}`); + text(contribution.id, `resource contribution ${index}.id`); + if (contribution.ownership !== 'owned' && contribution.ownership !== 'borrowed') { + throw new TypeError(`Resource "${contribution.id}" ownership must be owned or borrowed.`); + } + if (typeof contribution.prepare !== 'function') throw new TypeError(`Resource "${contribution.id}" must expose prepare().`); + if ( + contribution.dependsOn !== undefined && + (!Array.isArray(contribution.dependsOn) || !contribution.dependsOn.every((dependency) => typeof dependency === 'string')) + ) { + throw new TypeError(`Resource "${contribution.id}" dependencies must be an array of IDs.`); + } +} + +function object(value: unknown, context: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${context} must be an object.`); + return value as Record; +} + +function keys(value: Record, allowed: readonly string[], context: string): void { + for (const key of Object.keys(value)) if (!allowed.includes(key)) throw new TypeError(`Unknown ${context} field "${key}".`); +} + +function text(value: unknown, context: string): asserts value is string { + if (typeof value !== 'string' || !value.trim() || value.includes('\0')) { + throw new TypeError(`${context} must be a nonempty string without NUL.`); + } +} diff --git a/packages/agentic/desktop-test/src/services.ts b/packages/agentic/desktop-test/src/services.ts index b452912f34..5569bcf267 100644 --- a/packages/agentic/desktop-test/src/services.ts +++ b/packages/agentic/desktop-test/src/services.ts @@ -1,6 +1,29 @@ import type { DesktopCommandRunner, PreparedDesktopCommand, RunningDesktopCommand } from './commands.js'; import { deadline } from './commands.js'; +import type { DesktopServiceReady } from './processControl.js'; import type { DesktopReportWriter } from './reporting/collector.js'; +import { orderDesktopResourceContributions, validateDesktopResourcePlan, validateDesktopResourceServiceReady } from './resources.js'; +import type { + DesktopResourceContext, + DesktopResourceContribution, + DesktopResourceRecord, + DesktopResourceServiceReadyRecord, +} from './resources.js'; + +type ResourceEntry = { + key: string; + record: { + id: string; + identity: string; + generation: number; + ownership: 'owned' | 'borrowed'; + readiness: 'pending' | 'ready' | 'failed'; + disposal: 'pending' | 'settled' | 'failed' | 'not-owned'; + serviceReady?: DesktopResourceServiceReadyRecord; + }; + dispose?: () => Promise; + disposalPromise?: Promise; +}; export class DesktopStopRequested extends Error { constructor() { @@ -13,16 +36,22 @@ export class DesktopStopRequested extends Error { export class DesktopServiceScope { readonly controller = new AbortController(); private readonly running: { id: string; command: RunningDesktopCommand }[] = []; + private readonly resources: ResourceEntry[] = []; + private readonly work = new Set>(); private readonly failed: Promise; private rejectFailure!: (error: Error) => void; private closing = false; private closePromise?: Promise; + private generation = 0; + private onResource?: (resource: DesktopResourceRecord) => void; private readonly runner: DesktopCommandRunner; private readonly reporter?: DesktopReportWriter; + private readonly resourcePrefix: string; - constructor(runner: DesktopCommandRunner, reporter?: DesktopReportWriter) { + constructor(runner: DesktopCommandRunner, reporter?: DesktopReportWriter, resourcePrefix = 'desktop') { this.runner = runner; this.reporter = reporter; + this.resourcePrefix = resourcePrefix; this.failed = new Promise((_resolve, reject) => { this.rejectFailure = reject; }); @@ -30,6 +59,11 @@ export class DesktopServiceScope { void this.failed.catch(() => undefined); } + setResourceObserver(observer?: (resource: DesktopResourceRecord) => void): void { + this.onResource = observer; + if (observer) for (const resource of this.resources) observer(this.snapshot(resource)); + } + fail(error: Error): void { if (this.closing || this.controller.signal.aborted) return; this.reporter?.fail(error); @@ -48,7 +82,23 @@ export class DesktopServiceScope { this.controller.signal.throwIfAborted(); if (this.closing || this.running.some((service) => service.id === id)) throw new Error(`Cannot start duplicate or closing service ${id}.`); - const running = this.runner.start({ ...command, category: 'service', context: { ...command.context, service: id } }); + const resource = this.createResource({ + id, + identity: `service:${id}:${this.generation + 1}`, + ownership: 'owned', + readiness: 'pending', + disposal: 'pending', + }); + let running: RunningDesktopCommand; + try { + running = this.runner.start({ ...command, category: 'service', context: { ...command.context, service: id } }); + resource.dispose = () => running.stop({ failed: true }); + } catch (error) { + resource.record.readiness = 'failed'; + resource.record.disposal = 'settled'; + this.publish(resource); + throw error; + } this.running.push({ id, command: running }); void running.completed.then( (code) => { @@ -64,10 +114,70 @@ export class DesktopServiceScope { return running; } + async contribute(resources: readonly DesktopResourceContribution[], context: Omit): Promise { + const ordered = orderDesktopResourceContributions(resources); + for (const contribution of ordered) { + if (this.resources.some((resource) => resource.key === contribution.id)) { + throw new TypeError(`Duplicate desktop resource ID "${contribution.id}".`); + } + } + for (const contribution of ordered) { + this.controller.signal.throwIfAborted(); + if (this.closing) throw new Error('Cannot acquire desktop resources while the scope is closing.'); + const plan = contribution.prepare({ ...context, signal: this.controller.signal }); + validateDesktopResourcePlan(plan, contribution.id); + const entry = this.createResource({ + id: contribution.id, + identity: plan.identity, + ownership: contribution.ownership, + readiness: 'pending', + disposal: contribution.ownership === 'owned' ? 'pending' : 'not-owned', + }); + entry.dispose = contribution.ownership === 'owned' ? () => plan.dispose() : undefined; + + const acquisition = this.track( + (async () => { + try { + const ready = await plan.acquire(this.controller.signal); + if (ready !== undefined) { + validateDesktopResourceServiceReady(ready); + entry.record.serviceReady = Object.freeze({ ...ready }); + } + entry.record.readiness = 'ready'; + this.publish(entry); + if (this.controller.signal.aborted && entry.record.ownership === 'owned') await this.dispose(entry); + } catch (error) { + entry.record.readiness = 'failed'; + this.publish(entry); + if (entry.record.ownership === 'owned') { + try { + await this.dispose(entry); + } catch (cleanup) { + throw new AggregateError([error, cleanup], `Resource "${contribution.id}" acquisition and cleanup failed.`); + } + } + throw error; + } + })(), + ); + try { + await this.guard(acquisition); + } catch (error) { + this.fail(error instanceof Error ? error : new Error(String(error))); + throw error; + } + } + } + + waitForStop(): Promise { + return this.failed; + } + async guard(work: Promise): Promise { - if (this.controller.signal.aborted) void work.catch(() => undefined); + const tracked = this.track(work); + if (this.controller.signal.aborted) void tracked.catch(() => undefined); this.controller.signal.throwIfAborted(); - return Promise.race([work, this.failed]); + return Promise.race([tracked, this.failed]); } async ready(id: string, url: string, timeoutMs = 120_000, fetcher = globalThis.fetch): Promise { @@ -81,6 +191,11 @@ export class DesktopServiceScope { const response = await fetcher(url, { signal: AbortSignal.any([signal, AbortSignal.timeout(2000)]), redirect: 'error' }); await response.body?.cancel(); if (response.ok) { + const resource = this.resources.find((entry) => entry.key === id); + if (resource) { + resource.record.readiness = 'ready'; + this.publish(resource); + } this.reporter?.emit({ kind: 'service-ready', payload: { message: id } }, id, { service: id }); return; } @@ -90,11 +205,51 @@ export class DesktopServiceScope { } await new Promise((resolve) => setTimeout(resolve, 100)); } + if (this.controller.signal.aborted) throw this.controller.signal.reason; throw new Error(`Service "${id}" did not become ready within ${timeoutMs}ms.`, { cause: lastError ?? signal.reason }); })(), ); } + async readyProcess(id: string, expected: DesktopServiceReady, timeoutMs = 120_000): Promise { + validateServiceReadyExpectation(expected); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new TypeError('Invalid service readiness deadline.'); + const service = this.running.find((entry) => entry.id === id); + if (!service) throw new Error(`Cannot wait for process readiness from unregistered service "${id}".`); + const resource = this.resources.find((entry) => entry.key === id); + if (!resource) throw new Error(`Service "${id}" has no registered resource record.`); + const waitForReadiness = service.command.waitForReadiness; + if (typeof waitForReadiness !== 'function') { + resource.record.readiness = 'failed'; + this.publish(resource); + const error = new Error(`Service "${id}" does not support correlated process readiness.`); + this.fail(error); + throw error; + } + + return this.guard( + (async () => { + try { + const ready = await waitForReadiness.call(service.command, expected, timeoutMs, this.controller.signal); + validateDesktopResourceServiceReady(ready); + if (ready.service !== expected.service || ready.descriptor !== expected.descriptor) { + throw new Error(`Service "${id}" published readiness that does not match the configured expectation.`); + } + const record = Object.freeze({ ...ready }); + resource.record.serviceReady = record; + resource.record.readiness = 'ready'; + this.publish(resource); + return record; + } catch (error) { + resource.record.readiness = 'failed'; + this.publish(resource); + this.fail(error instanceof Error ? error : new Error(String(error))); + throw error; + } + })(), + ); + } + close(): Promise { return (this.closePromise ??= (async () => { this.closing = true; @@ -104,12 +259,109 @@ export class DesktopServiceScope { for (const { id, command } of [...this.running].reverse()) { try { await deadline(command.stop({ failed }), 45_000, `Stopping ${id}`); + const resource = this.resources.find((entry) => entry.key === id); + if (resource) { + resource.record.disposal = 'settled'; + this.publish(resource); + } } catch (error) { failures.push(error); this.reporter?.fail(error, 'cleanup', id); + const resource = this.resources.find((entry) => entry.key === id); + if (resource) { + resource.record.disposal = 'failed'; + this.publish(resource); + } } } + await this.awaitWork(failures); + for (const resource of [...this.resources].reverse()) { + if (resource.record.ownership !== 'owned' || resource.record.disposal !== 'pending') continue; + try { + await this.dispose(resource); + } catch (error) { + failures.push(error); + this.reporter?.fail(error, 'cleanup', resource.record.id); + } + } + + await this.awaitWork(failures); if (failures.length) throw new AggregateError(failures, 'Owned desktop services did not shut down cleanly.'); })()); } + + private createResource(record: Omit): ResourceEntry { + const entry: ResourceEntry = { + key: record.id, + record: { ...record, id: `${this.resourcePrefix}:${record.id}`, generation: ++this.generation }, + }; + this.resources.push(entry); + this.publish(entry); + return entry; + } + + private snapshot(entry: ResourceEntry): DesktopResourceRecord { + return Object.freeze({ ...entry.record }); + } + + private publish(entry: ResourceEntry): void { + this.onResource?.(this.snapshot(entry)); + } + + private track(work: Promise): Promise { + this.work.add(work); + void work.then( + () => this.work.delete(work), + () => this.work.delete(work), + ); + return work; + } + + private async awaitWork(failures: unknown[]): Promise { + while (this.work.size > 0) { + const results = await Promise.allSettled(this.work); + for (const result of results) { + if (result.status === 'rejected' && result.reason !== this.controller.signal.reason) failures.push(result.reason); + } + } + } + + private dispose(entry: ResourceEntry): Promise { + if (entry.disposalPromise) return entry.disposalPromise; + if (!entry.dispose) return Promise.resolve(); + entry.record.disposal = 'pending'; + this.publish(entry); + entry.disposalPromise = (async () => { + try { + await deadline(entry.dispose!(), 45_000, `Disposing resource ${entry.record.id}`); + entry.record.disposal = 'settled'; + this.publish(entry); + } catch (error) { + entry.record.disposal = 'failed'; + this.publish(entry); + throw error; + } + })(); + return entry.disposalPromise; + } +} + +function validateServiceReadyExpectation(value: unknown): asserts value is DesktopServiceReady { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError('Service readiness expectation must be an object.'); + } + const expected = value as Record; + if (Object.keys(expected).some((key) => !['tier', 'service', 'descriptor'].includes(key))) { + throw new TypeError('Service readiness expectation contains an unknown field.'); + } + if ( + expected.tier !== 'service-ready' || + typeof expected.service !== 'string' || + !expected.service.trim() || + expected.service.includes('\0') || + (expected.descriptor !== undefined && + (typeof expected.descriptor !== 'string' || !expected.descriptor.trim() || expected.descriptor.includes('\0'))) + ) { + throw new TypeError('Invalid service readiness expectation.'); + } } diff --git a/packages/agentic/desktop-test/src/supervisor.test.ts b/packages/agentic/desktop-test/src/supervisor.test.ts index 35ced5e4cf..1489fc48c7 100644 --- a/packages/agentic/desktop-test/src/supervisor.test.ts +++ b/packages/agentic/desktop-test/src/supervisor.test.ts @@ -39,6 +39,7 @@ describe('desktop supervisor input lane', () => { const target: ResolvedDesktopTarget = { endpoint: 'macos', id: 'fixture', + registrationId: 'fixture', instanceId: 'fixture-1', projectRoot: artifactsRoot, configPath: path.join(artifactsRoot, 'desktop.config.mts'), diff --git a/packages/agentic/desktop-test/src/supervisor.ts b/packages/agentic/desktop-test/src/supervisor.ts index eeb3766491..6ac998ce8e 100644 --- a/packages/agentic/desktop-test/src/supervisor.ts +++ b/packages/agentic/desktop-test/src/supervisor.ts @@ -1,7 +1,4 @@ -import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; -import { createHash } from 'node:crypto'; import type { DesktopDriverServer, DesktopHost } from '@fluentui-react-native/desktop-driver'; import { accessibleLaneMaskedFeatures } from '@fluentui-react-native/desktop-driver/authoring'; @@ -14,6 +11,8 @@ import type { DesktopIntegration } from './integration.js'; import { NodeDesktopCommandRunner, deadline } from './commands.js'; import type { PreparedDesktopCommand, RunningDesktopCommand } from './commands.js'; import { DesktopServiceScope } from './services.js'; +import { createDesktopWorkloadClaim } from './ownership.js'; +import type { DesktopWorkloadClaim } from './ownership.js'; import { resolveDesktopApplication } from './adapters.js'; import { DesktopRunReporter } from './reporting/collector.js'; import type { DesktopReportWriter } from './reporting/collector.js'; @@ -26,6 +25,7 @@ export type DesktopSupervisorOptions = { reporter: DesktopReportWriter; reportSession?: DesktopReportSession; integration?: DesktopIntegration; + workload?: { kind: 'delegated'; claim: DesktopWorkloadClaim }; hostFactory?: (target: ResolvedDesktopTarget) => Promise; lockDirectory?: string; /** @@ -49,7 +49,9 @@ export class DesktopSupervisor { private serverValue?: DesktopService; private transport?: ReportTransport; private opened = false; - private releaseClaim?: () => void; + private workloadClaim?: DesktopWorkloadClaim; + private ownsWorkloadClaim = false; + private resourceStartPromise?: Promise; private closePromise?: Promise; private ownershipUncertain = false; private readonly environment: Record = {}; @@ -62,7 +64,7 @@ export class DesktopSupervisor { this.reporter = options.reporter; Object.assign(this.environment, options.reportSession?.environment); this.runner = new NodeDesktopCommandRunner({ reporter: this.reporter, environment: this.environment }); - this.services = new DesktopServiceScope(this.runner, this.reporter); + this.services = new DesktopServiceScope(this.runner, this.reporter, this.reporter.runId); } get server(): DesktopDriverServer { @@ -78,8 +80,20 @@ export class DesktopSupervisor { async open(interactive = true): Promise { if (this.closePromise) throw new Error('Desktop supervisor is closing.'); if (this.opened) return; - if (interactive && !this.options.reportSession?.delegated) - this.releaseClaim = claimDesktopWorkload(this.reporter.runId, this.options.lockDirectory); + if (interactive) { + if (this.options.workload?.kind === 'delegated') { + this.workloadClaim = this.options.workload.claim; + } else { + this.workloadClaim = createDesktopWorkloadClaim({ + runId: this.reporter.runId, + targetId: this.target.id, + targetInstanceId: this.target.instanceId, + directory: this.options.lockDirectory, + }); + this.ownsWorkloadClaim = true; + } + this.services.setResourceObserver((resource) => this.workloadClaim?.updateResource(resource)); + } try { if (!this.options.reportSession) { if (!(this.reporter instanceof DesktopRunReporter)) throw new Error('A delegated supervisor requires its inherited reportSession.'); @@ -88,12 +102,33 @@ export class DesktopSupervisor { } this.opened = true; } catch (error) { - this.releaseClaim?.(); - this.releaseClaim = undefined; + try { + if (this.ownsWorkloadClaim) this.workloadClaim?.release(); + } catch (cleanup) { + throw new AggregateError([error, cleanup], 'Desktop supervisor opening and claim cleanup failed.'); + } + this.workloadClaim = undefined; + this.ownsWorkloadClaim = false; throw error; } } + startResources(): Promise { + return (this.resourceStartPromise ??= (() => { + const contributions = this.options.integration?.resources ?? []; + const serviceIds = new Set(['webdriver', ...(this.target.services ?? []).map(({ id }) => id)]); + for (const contribution of contributions) { + if (serviceIds.has(contribution.id)) { + throw new TypeError(`Resource contribution ID "${contribution.id}" conflicts with a configured service ID.`); + } + } + return this.services.contribute(contributions, { + targetId: this.target.id, + targetInstanceId: this.target.instanceId, + }); + })()); + } + async start(): Promise { if (this.serverValue || this.closePromise) throw new Error('Desktop supervisor may only be started once.'); await this.open(); @@ -116,12 +151,32 @@ export class DesktopSupervisor { createTarget: (host) => this.options.integration?.createTarget?.({ host, target: this.target, reporter: this.reporter }) ?? Promise.resolve(undefined), }; - if (this.options.hostFactory) { - const host = await this.options.hostFactory(this.target); - this.serverValue = await startDesktopService(host, options); - } else { - this.serverValue = await this.createService(options); - } + let service: DesktopService | undefined; + await this.services.contribute( + [ + { + id: 'webdriver', + ownership: 'owned', + prepare: () => ({ + identity: `driver-service:${this.target.id}:${this.target.instanceId}`, + acquire: async () => { + if (this.options.hostFactory) { + const host = await this.options.hostFactory(this.target); + service = await startDesktopService(host, options); + } else { + service = await this.createService(options); + } + }, + dispose: async () => { + await service?.close(); + }, + }), + }, + ], + { targetId: this.target.id, targetInstanceId: this.target.instanceId }, + ); + if (!service) throw new Error('WebDriver resource acquisition completed without a service.'); + this.serverValue = service; this.reporter.emit( { kind: 'service-ready', payload: { message: `WebDriver ${this.server.url}; target ${this.target.id}` } }, 'webdriver', @@ -140,7 +195,8 @@ export class DesktopSupervisor { } async startConfiguredServices(): Promise { - for (const service of this.target.services ?? []) { + await this.startResources(); + for (const service of orderServices(this.target.services ?? [])) { this.services.start(service.id, this.prepare(service.command, service.id)); if (service.readyUrl) await this.services.ready(service.id, service.readyUrl, this.target.launchTimeoutMs); } @@ -218,13 +274,14 @@ export class DesktopSupervisor { }; for (const worker of this.workers) await close('worker', () => worker.stop({ failed: true })); this.workers.clear(); - if (this.serverValue) await close('webdriver', () => deadline(this.serverValue!.close(), 30_000, 'Closing desktop sessions')); await close('services', () => this.services.close()); await close('commands', () => this.runner.close()); if (this.transport) await close('reporting', () => this.transport!.close()); - if (this.releaseClaim && failures.length === 0 && !this.ownershipUncertain) { + if (this.ownsWorkloadClaim && this.workloadClaim && failures.length === 0 && !this.ownershipUncertain) { try { - this.releaseClaim(); + this.workloadClaim.release(); + this.workloadClaim = undefined; + this.ownsWorkloadClaim = false; } catch (error) { failures.push(error); this.reporter.fail(error, 'cleanup', 'desktop-claim'); @@ -236,21 +293,31 @@ export class DesktopSupervisor { } export function claimDesktopWorkload(runId: string, directory?: string): () => void { - const user = createHash('sha256').update(os.homedir()).digest('hex').slice(0, 16); - const root = directory ?? path.join(os.tmpdir(), `furn-desktop-workload-${user}`); - fs.mkdirSync(root, { recursive: true, mode: 0o700 }); - const file = path.join(root, 'interactive.lock'); - const contents = JSON.stringify({ schemaVersion: 1, processId: process.pid, runId }); - try { - fs.writeFileSync(file, contents, { flag: 'wx', mode: 0o600 }); - } catch (error) { - throw new Error( - `Another desktop workload holds ${file}. Do not run parallel physical-input suites. After a crash or failed cleanup, verify the recorded owner and its resources have stopped before removing its claim.`, - { cause: error }, - ); - } - return () => { - if (fs.readFileSync(file, 'utf8') !== contents) throw new Error('Desktop claim ownership changed; refusing to remove another owner.'); - fs.unlinkSync(file); + const claim = createDesktopWorkloadClaim({ + runId, + targetId: 'unregistered', + targetInstanceId: 'unregistered', + directory, + }); + return () => claim.release(); +} + +function orderServices(services: readonly T[]): readonly T[] { + const byId = new Map(services.map((service) => [service.id, service])); + const ordered: T[] = []; + const visiting = new Set(); + const visited = new Set(); + const visit = (id: string, ancestry: readonly string[]) => { + if (visited.has(id)) return; + if (visiting.has(id)) throw new TypeError(`Desktop service dependency cycle: ${[...ancestry, id].join(' -> ')}.`); + const service = byId.get(id); + if (!service) throw new TypeError(`Unknown desktop service "${id}".`); + visiting.add(id); + for (const dependency of service.dependsOn ?? []) visit(dependency, [...ancestry, id]); + visiting.delete(id); + visited.add(id); + ordered.push(service); }; + for (const service of services) visit(service.id, []); + return ordered; } diff --git a/packages/agentic/storybook-desktop-runtime/references/runtime-contract.md b/packages/agentic/storybook-desktop-runtime/references/runtime-contract.md index 3987305bb3..a48dc6e929 100644 --- a/packages/agentic/storybook-desktop-runtime/references/runtime-contract.md +++ b/packages/agentic/storybook-desktop-runtime/references/runtime-contract.md @@ -15,6 +15,23 @@ ready/error events correlate instance, target, request/run IDs, and preview generation. Each named test gets a keyed preview remount. Preserve these contracts and fail closed on stale or uncorrelated messages. +The version-2 challenge/hello binds a fresh connection epoch. Prepare messages +carry that epoch and a strictly increasing selection sequence. A synchronous +selection ref fences callbacks from superseded React trees before effects can +commit. Version-1 ready events and native marker JSON share the data-only +`storyMarker.ts` producer: `connectionEpoch`, `markerVersion`, +`previewGeneration`, `requestId`, `runId`, and `storyId`. The driver tests +the actual producer against its verifier without Node globals. + +Cancellation retains a canceled selection, hides its story content, and +suppresses ready/error callbacks; old prepares cannot revive it. Channel +disconnect cancels the current selection. A replacement epoch rejects +previous-epoch messages, but this runtime does not add transport reconnect. +Different-story late trees are hidden; upstream same-story navigation has no +request identity and cannot be proven fenced. The driver therefore retains +unknown-effect quarantine after interrupted navigation, even after a new +hello. Keep this boundary explicit rather than claiming transparent recovery. + ## Theme and chrome One Flex `ThemedRoot` covers the app, toolbar, and chrome, so the Storybook diff --git a/packages/agentic/storybook-desktop-runtime/src/DesktopDriverBridge.test.tsx b/packages/agentic/storybook-desktop-runtime/src/DesktopDriverBridge.test.tsx new file mode 100644 index 0000000000..495d5d2be6 --- /dev/null +++ b/packages/agentic/storybook-desktop-runtime/src/DesktopDriverBridge.test.tsx @@ -0,0 +1,130 @@ +import { Text } from 'react-native'; +import { act, render } from '@testing-library/react-native'; +import { addons } from 'storybook/preview-api'; + +import { DesktopDriverBridge } from './DesktopDriverBridge'; +import { DesktopStoryRoot } from './DesktopStoryRoot'; +import { DesktopStorybookConfigProvider, useDesktopStorybookConfig } from './DesktopStorybookConfig'; + +jest.mock('storybook/preview-api', () => ({ addons: { getChannel: jest.fn() } })); +jest.mock('storybook/internal/core-events', () => ({ CHANNEL_WS_DISCONNECT: 'channelWSDisconnect' })); + +type Listener = (payload: unknown) => void; +const listeners = new Map>(); +const channel = { + emit: jest.fn(), + off: jest.fn((type: string, listener: Listener) => listeners.get(type)?.delete(listener)), + on: jest.fn((type: string, listener: Listener) => { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(listener); + }), +}; + +const runtimeInstance = { + bridgeNonce: 'nonce', + endpoint: 'macos' as const, + instanceId: 'instance', + platformManifestDigest: 'platform', + portablePlanDigest: 'portable', + targetId: 'target', +}; +const request = { connectionEpoch: 'epoch', requestId: 'request', runId: 'run', selectionSequence: 1, storyId: 'a' }; + +async function receive(type: string, payload?: unknown) { + await act(() => { + for (const listener of listeners.get(type) ?? []) listener(payload); + }); +} + +function App({ storyId = 'a' }: { storyId?: string }) { + return ( + + + + {storyId} + + + ); +} + +beforeEach(() => { + listeners.clear(); + jest.clearAllMocks(); + jest.mocked(addons.getChannel).mockReturnValue(channel as unknown as ReturnType); +}); + +test('authenticates a challenge and produces versioned ready and native markers from the same selection', async () => { + const scene = await render(); + expect(channel.emit).toHaveBeenCalledWith('furn:desktop:request-challenge'); + await receive('furn:desktop:request-hello', { version: 2, connectionEpoch: 'epoch' }); + expect(channel.emit).toHaveBeenCalledWith('furn:desktop:hello', { + connectionEpoch: 'epoch', + endpoint: 'macos', + instanceId: 'instance', + nonce: 'nonce', + platformManifestDigest: 'platform', + targetId: 'target', + version: 2, + }); + await receive('furn:desktop:prepare-story', request); + const marker = { + connectionEpoch: 'epoch', + markerVersion: 1, + previewGeneration: 1, + requestId: 'request', + runId: 'run', + storyId: 'a', + }; + expect(JSON.parse(scene.getByTestId('test-story-root').props.accessibilityLabel)).toEqual(marker); + expect(channel.emit).toHaveBeenCalledWith('furn:desktop:story-ready', { ...marker, portablePlanDigest: 'portable' }); + await scene.unmount(); + expect([...listeners.values()].every((set) => set.size === 0)).toBe(true); +}); + +test('old prepares and old render trees cannot replace a newer selection; cancellation hides late content', async () => { + const scene = await render(); + await receive('furn:desktop:request-hello', { version: 2, connectionEpoch: 'epoch' }); + await receive('furn:desktop:prepare-story', request); + const next = { ...request, requestId: 'next', runId: 'next', storyId: 'b', selectionSequence: 2 }; + await receive('furn:desktop:prepare-story', next); + expect(scene.queryByTestId('content')).toBeNull(); + await receive('furn:desktop:prepare-story', request); + expect(scene.queryByTestId('content')).toBeNull(); + await scene.rerender(); + expect(scene.getByTestId('content').props.children).toBe('b'); + await receive('furn:desktop:cancel-story', request); + expect(scene.getByTestId('content')).toBeDefined(); + await receive('furn:desktop:cancel-story', next); + expect(scene.queryByTestId('content')).toBeNull(); + channel.emit.mockClear(); + await scene.rerender(); + await scene.rerender(); + expect(scene.queryByTestId('content')).toBeNull(); + expect(channel.emit).not.toHaveBeenCalled(); +}); + +test('disconnect invalidates captured lifetimes and a new connection rejects previous-epoch messages', async () => { + let latest!: ReturnType; + function Capture() { + latest = useDesktopStorybookConfig(); + return null; + } + await render( + + + + , + ); + await receive('furn:desktop:request-hello', { version: 2, connectionEpoch: 'epoch' }); + await receive('furn:desktop:prepare-story', request); + const old = latest.selection!; + expect(latest.isCurrentSelection(old)).toBe(true); + await receive('channelWSDisconnect'); + expect(latest.isCurrentSelection(old)).toBe(false); + await receive('furn:desktop:request-hello', { version: 2, connectionEpoch: 'new-epoch' }); + await receive('furn:desktop:prepare-story', { ...request, selectionSequence: 10 }); + expect(latest.selection).toBeUndefined(); + await receive('furn:desktop:prepare-story', { ...request, connectionEpoch: 'new-epoch' }); + expect(latest.selection?.connectionEpoch).toBe('new-epoch'); + expect(latest.isCurrentSelection(old)).toBe(false); +}); diff --git a/packages/agentic/storybook-desktop-runtime/src/DesktopDriverBridge.tsx b/packages/agentic/storybook-desktop-runtime/src/DesktopDriverBridge.tsx index ad39652321..ee127a3e48 100644 --- a/packages/agentic/storybook-desktop-runtime/src/DesktopDriverBridge.tsx +++ b/packages/agentic/storybook-desktop-runtime/src/DesktopDriverBridge.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { addons } from 'storybook/preview-api'; +import { CHANNEL_WS_DISCONNECT } from 'storybook/internal/core-events'; import { useDesktopStorybookConfig } from './DesktopStorybookConfig'; @@ -10,13 +11,15 @@ export const desktopStoryErrorEvent = 'furn:desktop:story-error'; export const desktopStoryReadyEvent = 'furn:desktop:story-ready'; type PrepareStoryPayload = { + connectionEpoch: string; requestId: string; runId: string; + selectionSequence: number; storyId: string; }; export function DesktopDriverBridge(): null { - const { prepareStory, runtimeInstance } = useDesktopStorybookConfig(); + const { cancelStory, connectBridge, prepareStory, runtimeInstance } = useDesktopStorybookConfig(); React.useEffect(() => { if ( @@ -29,30 +32,51 @@ export function DesktopDriverBridge(): null { return undefined; } const channel = addons.getChannel(); - const emitHello = () => { + let epoch: string | undefined; + const emitHello = (challenge: unknown) => { + if (!challenge || typeof challenge !== 'object') return; + const fields = challenge as Record; + if (fields.version !== 2 || typeof fields.connectionEpoch !== 'string' || fields.connectionEpoch.length === 0) return; + epoch = fields.connectionEpoch; + connectBridge(epoch); channel.emit(desktopRuntimeHelloEvent, { + connectionEpoch: epoch, endpoint: runtimeInstance.endpoint, instanceId: runtimeInstance.instanceId, nonce: runtimeInstance.bridgeNonce, platformManifestDigest: runtimeInstance.platformManifestDigest, targetId: runtimeInstance.targetId, - version: 1, + version: 2, }); }; const onPrepare = (payload: PrepareStoryPayload) => { - if (isPrepareStoryPayload(payload)) { + if (isPrepareStoryPayload(payload) && payload.connectionEpoch === epoch) { prepareStory(payload); } }; + const onCancel = (payload: unknown) => { + if (!payload || typeof payload !== 'object') return; + const fields = payload as Record; + if (fields.connectionEpoch === epoch && typeof fields.requestId === 'string') cancelStory(fields.requestId); + }; + const onDisconnect = () => { + epoch = undefined; + cancelStory(); + }; channel.on(desktopPrepareStoryEvent, onPrepare); + channel.on('furn:desktop:cancel-story', onCancel); + channel.on(CHANNEL_WS_DISCONNECT, onDisconnect); channel.on(desktopRequestHelloEvent, emitHello); - emitHello(); + // The first challenge may precede the React effect installing its listener. + channel.emit('furn:desktop:request-challenge'); return () => { channel.off(desktopPrepareStoryEvent, onPrepare); + channel.off('furn:desktop:cancel-story', onCancel); + channel.off(CHANNEL_WS_DISCONNECT, onDisconnect); channel.off(desktopRequestHelloEvent, emitHello); }; - }, [prepareStory, runtimeInstance]); + }, [cancelStory, connectBridge, prepareStory, runtimeInstance]); return null; } @@ -62,5 +86,12 @@ function isPrepareStoryPayload(value: unknown): value is PrepareStoryPayload { return false; } const payload = value as Record; - return typeof payload.requestId === 'string' && typeof payload.runId === 'string' && typeof payload.storyId === 'string'; + return ( + typeof payload.connectionEpoch === 'string' && + Number.isSafeInteger(payload.selectionSequence) && + (payload.selectionSequence as number) > 0 && + typeof payload.requestId === 'string' && + typeof payload.runId === 'string' && + typeof payload.storyId === 'string' + ); } diff --git a/packages/agentic/storybook-desktop-runtime/src/DesktopStoryRoot.tsx b/packages/agentic/storybook-desktop-runtime/src/DesktopStoryRoot.tsx index 2cd1acc8fa..709218f9c8 100644 --- a/packages/agentic/storybook-desktop-runtime/src/DesktopStoryRoot.tsx +++ b/packages/agentic/storybook-desktop-runtime/src/DesktopStoryRoot.tsx @@ -4,6 +4,7 @@ import { addons } from 'storybook/preview-api'; import { desktopStoryErrorEvent, desktopStoryReadyEvent } from './DesktopDriverBridge'; import { useDesktopStorybookConfig, useDesktopStorybookTestID } from './DesktopStorybookConfig'; +import { createStoryMarker } from './storyMarker'; type DesktopStoryRootProps = { children: React.ReactNode; @@ -11,41 +12,44 @@ type DesktopStoryRootProps = { }; export function DesktopStoryRoot({ children, storyId }: DesktopStoryRootProps) { - const { runtimeInstance, selection } = useDesktopStorybookConfig(); + const { isCurrentSelection, runtimeInstance, selection } = useDesktopStorybookConfig(); const testID = useDesktopStorybookTestID('story-root'); const exposeNativeMarker = Platform.OS === 'macos' || Platform.OS === 'windows' || Platform.OS === ('win32' as any); - const activeSelection = selection?.storyId === storyId ? selection : undefined; + const activeSelection = selection?.storyId === storyId && !selection.cancelled ? selection : undefined; React.useEffect(() => { - if (!activeSelection || !runtimeInstance?.portablePlanDigest) { + if (!activeSelection || !isCurrentSelection(activeSelection) || !runtimeInstance?.portablePlanDigest) { return; } addons.getChannel().emit(desktopStoryReadyEvent, { portablePlanDigest: runtimeInstance.portablePlanDigest, - previewGeneration: activeSelection.previewGeneration, - requestId: activeSelection.requestId, - runId: activeSelection.runId, - storyId, + ...createStoryMarker(activeSelection), }); - }, [activeSelection, runtimeInstance?.portablePlanDigest, storyId]); + }, [activeSelection, isCurrentSelection, runtimeInstance?.portablePlanDigest]); - const marker = JSON.stringify({ - previewGeneration: activeSelection?.previewGeneration ?? 0, - runId: activeSelection?.runId, - storyId, - }); + const marker = JSON.stringify( + activeSelection + ? createStoryMarker(activeSelection) + : { + previewGeneration: activeSelection?.previewGeneration ?? 0, + runId: activeSelection?.runId, + storyId, + }, + ); const key = activeSelection ? `${activeSelection.runId}:${activeSelection.previewGeneration}` : storyId; + if (selection && !activeSelection) { + return null; + } + return ( { - if (activeSelection) { + if (activeSelection && isCurrentSelection(activeSelection)) { addons.getChannel().emit(desktopStoryErrorEvent, { message, - requestId: activeSelection.requestId, - runId: activeSelection.runId, - storyId, + ...createStoryMarker(activeSelection), }); } }} diff --git a/packages/agentic/storybook-desktop-runtime/src/DesktopStorybookConfig.tsx b/packages/agentic/storybook-desktop-runtime/src/DesktopStorybookConfig.tsx index 75c612775b..05f5ae78f4 100644 --- a/packages/agentic/storybook-desktop-runtime/src/DesktopStorybookConfig.tsx +++ b/packages/agentic/storybook-desktop-runtime/src/DesktopStorybookConfig.tsx @@ -1,9 +1,12 @@ import * as React from 'react'; export type DesktopRuntimeSelection = { + cancelled?: boolean; + connectionEpoch: string; previewGeneration: number; requestId: string; runId: string; + selectionSequence: number; storyId: string; }; @@ -19,6 +22,9 @@ export type DesktopStorybookRuntimeInstance = { }; type DesktopStorybookConfig = { + cancelStory(requestId?: string): void; + connectBridge(epoch: string): void; + isCurrentSelection(selection: DesktopRuntimeSelection): boolean; prepareStory(selection: Omit): void; runtimeInstance?: DesktopStorybookRuntimeInstance; selection?: DesktopRuntimeSelection; @@ -26,6 +32,9 @@ type DesktopStorybookConfig = { }; const defaultConfig: DesktopStorybookConfig = { + cancelStory: () => undefined, + connectBridge: () => undefined, + isCurrentSelection: () => false, prepareStory: () => undefined, testIDPrefix: 'storybook-desktop', }; @@ -38,12 +47,37 @@ export function DesktopStorybookConfigProvider({ testIDPrefix, }: React.PropsWithChildren>) { const [selection, setSelection] = React.useState(); + const currentSelection = React.useRef(undefined); + const connectionEpoch = React.useRef(undefined); + const connectBridge = React.useCallback((epoch: string) => { + if (connectionEpoch.current !== epoch) { + connectionEpoch.current = epoch; + currentSelection.current = undefined; + setSelection(undefined); + } + }, []); const prepareStory = React.useCallback((next: Omit) => { - setSelection((current) => ({ ...next, previewGeneration: (current?.previewGeneration ?? 0) + 1 })); + if (next.connectionEpoch !== connectionEpoch.current || next.selectionSequence <= (currentSelection.current?.selectionSequence ?? 0)) { + return; + } + const current = { ...next, previewGeneration: (currentSelection.current?.previewGeneration ?? 0) + 1 }; + currentSelection.current = current; + setSelection(current); + }, []); + const cancelStory = React.useCallback((requestId?: string) => { + const current = currentSelection.current; + if (current && (requestId === undefined || current.requestId === requestId)) { + currentSelection.current = { ...current, cancelled: true }; + setSelection(currentSelection.current); + } }, []); + const isCurrentSelection = React.useCallback( + (candidate: DesktopRuntimeSelection) => currentSelection.current === candidate && !candidate.cancelled, + [], + ); const value = React.useMemo( - () => ({ prepareStory, runtimeInstance, selection, testIDPrefix }), - [prepareStory, runtimeInstance, selection, testIDPrefix], + () => ({ cancelStory, connectBridge, isCurrentSelection, prepareStory, runtimeInstance, selection, testIDPrefix }), + [cancelStory, connectBridge, isCurrentSelection, prepareStory, runtimeInstance, selection, testIDPrefix], ); return {children}; } diff --git a/packages/agentic/storybook-desktop-runtime/src/storyMarker.ts b/packages/agentic/storybook-desktop-runtime/src/storyMarker.ts new file mode 100644 index 0000000000..2be6755006 --- /dev/null +++ b/packages/agentic/storybook-desktop-runtime/src/storyMarker.ts @@ -0,0 +1,20 @@ +/** Data-only version 1 contract, verified by desktop-driver without an on-device driver dependency. */ +export type DesktopStoryMarker = { + connectionEpoch: string; + markerVersion: 1; + previewGeneration: number; + requestId: string; + runId: string; + storyId: string; +}; + +export function createStoryMarker(selection: Omit): DesktopStoryMarker { + return { + connectionEpoch: selection.connectionEpoch, + markerVersion: 1, + previewGeneration: selection.previewGeneration, + requestId: selection.requestId, + runId: selection.runId, + storyId: selection.storyId, + }; +} diff --git a/packages/agentic/storybook-desktop/AGENTS.md b/packages/agentic/storybook-desktop/AGENTS.md index 59ac20446a..1e327ec2c6 100644 --- a/packages/agentic/storybook-desktop/AGENTS.md +++ b/packages/agentic/storybook-desktop/AGENTS.md @@ -20,6 +20,8 @@ source ownership and validation. native story-root verification. Never accept uncorrelated channel events. - Treat generated manifests, runtime state, native workspaces, and build products as disposable; fix their owning source. +- Agent manifests use the controlled transport, never upstream channel relays. + Keep args refused and manifest-backed smoke selection on driver sessions. - Keep global output formatting on `--reporter`; the child Node test reporter is `--test-reporter`. Long-lived command readiness is emitted only after its owned services pass readiness checks. diff --git a/packages/agentic/storybook-desktop/README.md b/packages/agentic/storybook-desktop/README.md index a511ed1ffc..58166b772b 100644 --- a/packages/agentic/storybook-desktop/README.md +++ b/packages/agentic/storybook-desktop/README.md @@ -23,6 +23,10 @@ Read [integration](references/integration.md) for app wiring and [story-tests](references/story-tests.md) to author inline `wdio` callbacks. Use `--reporter json` for bounded machine-readable summaries or NDJSON for live progress. +Agent-served channels reject alternate HTTP mutations and never relay client +WebSocket frames; selection/reset go through the session-owning driver grant, +and args remain unsupported. Non-agent channels are explicitly legacy +unprotected mode; see [integration](references/integration.md). The [reference index](references/README.md) routes platform, readiness, and troubleshooting tasks. Package agents should start from [AGENTS.md](AGENTS.md). diff --git a/packages/agentic/storybook-desktop/config/controlled-channel.cjs b/packages/agentic/storybook-desktop/config/controlled-channel.cjs new file mode 100644 index 0000000000..1d5b91dd59 --- /dev/null +++ b/packages/agentic/storybook-desktop/config/controlled-channel.cjs @@ -0,0 +1,59 @@ +const { createServer } = require('node:http'); +const { WebSocketServer } = require('ws'); + +// Upstream createChannelServer has no authorization hook and relays arbitrary client events. +// The controlled transport deliberately has no client-to-client relay or HTTP mutation handler. +async function createControlledChannelServer({ host, port, configPath, buildIndex, onFailure }) { + const http = createServer((request, response) => { + if (request.method !== 'GET' || request.url !== '/index.json') { + request.resume(); + response.writeHead(403, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: 'Controlled Storybook mutations require a session-owning driver grant.' })); + return; + } + void buildIndex({ configPath }).then( + (index) => { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(index)); + }, + () => { + response.writeHead(500, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: 'Failed to build the Storybook index.' })); + onFailure(new Error('Failed to build the controlled Storybook index.')); + }, + ); + }); + const channel = new WebSocketServer({ server: http, maxPayload: 1024 * 1024 }); + channel.on('connection', (client) => { + // Socket failures are connection-local. Do not log frames, which can contain argument values. + client.on('error', () => client.terminate()); + }); + await new Promise((resolve, reject) => { + http.once('error', reject); + http.listen(port, host, resolve); + }).catch(async (error) => { + await new Promise((resolve) => channel.close(resolve)); + throw error; + }); + http.on('error', onFailure); + // Storybook's WebsocketTransport closes an idle connection after 20 seconds. + const heartbeat = setInterval(() => { + for (const client of channel.clients) { + if (client.readyState === 1) client.send(JSON.stringify({ type: 'ping' })); + } + }, 10_000); + heartbeat.unref(); + return { + channel, + close: async () => { + clearInterval(heartbeat); + for (const client of channel.clients) client.terminate(); + await Promise.all([ + new Promise((resolve, reject) => channel.close((error) => (error ? reject(error) : resolve()))), + new Promise((resolve, reject) => http.close((error) => (error ? reject(error) : resolve()))), + ]); + }, + }; +} + +module.exports = { createControlledChannelServer }; diff --git a/packages/agentic/storybook-desktop/config/controlled-channel.d.cts b/packages/agentic/storybook-desktop/config/controlled-channel.d.cts new file mode 100644 index 0000000000..3852f9f3ae --- /dev/null +++ b/packages/agentic/storybook-desktop/config/controlled-channel.d.cts @@ -0,0 +1,12 @@ +import type { StorybookChannelServer } from '../src/driver/StorybookChannelOrchestrator.js'; + +export function createControlledChannelServer(options: { + host: string; + port: number; + configPath: string; + buildIndex(options: { configPath: string }): Promise; + onFailure(error: Error): void; +}): Promise<{ + channel: StorybookChannelServer & { address(): { port: number } }; + close(): Promise; +}>; diff --git a/packages/agentic/storybook-desktop/config/server.cjs b/packages/agentic/storybook-desktop/config/server.cjs index f4c461e76b..8410c0ed97 100644 --- a/packages/agentic/storybook-desktop/config/server.cjs +++ b/packages/agentic/storybook-desktop/config/server.cjs @@ -1,6 +1,7 @@ const { createRequire } = require('node:module'); const fs = require('node:fs'); const path = require('node:path'); +const { createControlledChannelServer } = require('./controlled-channel.cjs'); async function startDesktopStorybookServer({ configPath, @@ -14,13 +15,23 @@ async function startDesktopStorybookServer({ } const requireFromProject = createRequire(path.join(projectRoot, 'package.json')); - const { createChannelServer } = requireFromProject('@storybook/react-native/node'); - const [{ startNativeDesktopService }, { createDesktopReportSession }] = await Promise.all([ + const { buildIndex, createChannelServer } = requireFromProject('@storybook/react-native/node'); + const { finishDesktopProcess, publishDesktopServiceReady, registerDesktopProcessDisposal } = + await import('@fluentui-react-native/desktop-test/process-control'); + let initialized; + const initialization = new Promise((resolve) => { + initialized = resolve; + }); + let stopping = false; + const controller = new AbortController(); + const [{ startNativeDesktopService }, { createDesktopReportSession }, { NativeDesktopHost }] = await Promise.all([ import('@fluentui-react-native/desktop-test/driver-service'), import('@fluentui-react-native/desktop-test/reporting'), + import('@fluentui-react-native/desktop-driver'), ]); const report = await createDesktopReportSession({ artifactsRoot: path.join(projectRoot, 'artifacts', 'desktop'), kind: 'lifecycle' }); let server; + let controlledChannel; let driver; let agentReadyPath; let keepAlive; @@ -31,7 +42,7 @@ async function startDesktopStorybookServer({ process.off('SIGTERM', stop); const failures = error === undefined ? [] : [error]; try { - // The parent reads readiness from this record, so it must not outlive the published descriptor. + // Compatibility breadcrumb only; correlated process readiness is authoritative. if (agentReadyPath) fs.rmSync(agentReadyPath, { force: true }); } catch (failure) { report.reporter.fail(failure, 'cleanup'); @@ -44,7 +55,9 @@ async function startDesktopStorybookServer({ failures.push(failure); } try { - if (server) { + if (controlledChannel) { + await controlledChannel.close(); + } else if (server) { for (const client of server.clients) client.terminate(); await new Promise((resolve, reject) => server.close((failure) => (failure ? reject(failure) : resolve()))); } @@ -56,9 +69,18 @@ async function startDesktopStorybookServer({ process.exitCode = await report.close( failures.length ? new AggregateError(failures, 'Storybook channel service failed.') : undefined, ); + unregisterDisposal(); + if (failures.length) throw new AggregateError(failures, 'Storybook channel disposal is incomplete.'); + finishDesktopProcess(); })()); + const settle = async (error) => { + stopping = true; + controller.abort(error ?? new Error('Storybook channel stop requested.')); + await initialization; + await close(error); + }; const stop = () => { - void close().catch((error) => { + void settle().catch((error) => { process.stderr.write(`${error.stack ?? error}\n`); process.exitCode = 1; }); @@ -66,12 +88,17 @@ async function startDesktopStorybookServer({ const fatal = (error) => { if (closing) return; report.reporter.emit({ kind: 'service-failed', payload: { failure: report.reporter.failure(error) } }, 'storybook-channel'); - void close(error).catch((failure) => { + void settle(error).catch((failure) => { process.stderr.write(`${failure.stack ?? failure}\n`); process.exitCode = 1; }); }; + const unregisterDisposal = registerDesktopProcessDisposal(() => settle()); + report.onStop?.(stop); + process.once('SIGINT', stop); + process.once('SIGTERM', stop); try { + controller.signal.throwIfAborted(); const driverManifest = driverManifestPath ? JSON.parse(fs.readFileSync(driverManifestPath, 'utf8')) : undefined; if ( driverManifest && @@ -103,9 +130,13 @@ async function startDesktopStorybookServer({ report.reporter.addSecret(driverManifest.application.leaseNonce); report.reporter.addSecret(driverManifest.bridgeNonce); } - // The upstream HTTP server is unref'd and closes on beforeExit; own only our clearable keepalive. Its MCP endpoint - // selects stories without a grant, so an agent-served driver disables it and agents select through the driver. - server = createChannelServer({ host, port, configPath, websockets: true, experimental_mcp: !agent, keepNodeProcessAlive: false }); + if (agent) { + controlledChannel = await createControlledChannelServer({ host, port, configPath, buildIndex, onFailure: fatal }); + server = controlledChannel.channel; + } else { + // Deliberately unprotected legacy mode, including upstream HTTP, WebSocket relay and MCP. + server = createChannelServer({ host, port, configPath, websockets: true, experimental_mcp: true, keepNodeProcessAlive: false }); + } if (!server) { throw new Error('Desktop Driver Storybook orchestration requires WebSockets.'); } @@ -146,6 +177,7 @@ async function startDesktopStorybookServer({ }); } else { driver = await startNativeDesktopService({ + signal: controller.signal, // Windows agents verify the app's AUMID or executable; nothing maintains a lease there. application: agent?.application ?? driverManifest.application, artifact: driverManifest.nativeDriver, @@ -162,41 +194,68 @@ async function startDesktopStorybookServer({ addSecret: (secret) => report.reporter.addSecret(secret), } : {}), - target: { - ...target, + target, + createTarget: async (_targetHost, recovery) => ({ storyOrchestrator: createChannelOrchestrator({ channelServer: server, driverManifest, serverUrl: loopbackUrl(host, port), StorybookChannelOrchestrator, + recovery: { + ...recovery, + resolveApplication: async () => { + // A separate helper cannot steal window/element records from an active session's lease. + const identityHost = new NativeDesktopHost({ + application: agent?.application ?? driverManifest.application, + artifact: driverManifest.nativeDriver, + endpoint: driverManifest.endpoint, + }); + try { + const lease = await identityHost.attach({ ...target, host: identityHost }, AbortSignal.timeout(10_000)); + if (lease.processId === undefined || lease.processStartedAt === undefined) + throw new Error('The Storybook application has no verified process incarnation.'); + return { processId: lease.processId, processStartedAt: lease.processStartedAt }; + } finally { + await identityHost.dispose(); + } + }, + }, }), - }, + }), }); } + controller.signal.throwIfAborted(); if (agent) { agentReadyPath = agent.readyPath; writeAgentReady(agentReadyPath, agent.descriptor); } + if (stopping) controller.signal.throwIfAborted(); + await publishDesktopServiceReady({ + tier: 'service-ready', + service: 'storybook-channel', + ...(driverManifest?.agent ? { descriptor: driverManifest.agent.descriptor } : {}), + }); } report.reporter.emit( { kind: 'service-ready', payload: { message: `Storybook ${loopbackUrl(host, port)}${driver ? `; WebDriver ${driver.url}` : ''}` } }, 'storybook-channel', ); - report.onStop?.(stop); - process.once('SIGINT', stop); - process.once('SIGTERM', stop); + initialized(); return { channelServer: server, driver, close }; } catch (error) { + initialized(); await close(error); throw error; } } -function createChannelOrchestrator({ channelServer, driverManifest, serverUrl, StorybookChannelOrchestrator }) { +function createChannelOrchestrator({ channelServer, driverManifest, serverUrl, StorybookChannelOrchestrator, recovery }) { return new StorybookChannelOrchestrator({ channelServer, driverManifest, serverUrl, + recovery, + controlled: driverManifest.agent !== undefined, }); } diff --git a/packages/agentic/storybook-desktop/config/smoke-win32.ps1 b/packages/agentic/storybook-desktop/config/smoke-win32.ps1 index f147424403..4361b014af 100644 --- a/packages/agentic/storybook-desktop/config/smoke-win32.ps1 +++ b/packages/agentic/storybook-desktop/config/smoke-win32.ps1 @@ -229,9 +229,9 @@ try { Stop-Process -Id $ownedProcessId -ErrorAction Continue } } - if ($cleanupFailure) { - if ($smokeFailure) { throw [System.AggregateException]::new('Smoke execution and service cleanup failed.', [System.Exception[]]@($smokeFailure, $cleanupFailure)) } - throw $cleanupFailure - } + } + if ($cleanupFailure) { + if ($smokeFailure) { throw [System.AggregateException]::new('Smoke execution and service cleanup failed.', [System.Exception[]]@($smokeFailure, $cleanupFailure)) } + throw $cleanupFailure } } diff --git a/packages/agentic/storybook-desktop/config/smoke-windows.ps1 b/packages/agentic/storybook-desktop/config/smoke-windows.ps1 index a936a51469..ba3c61e729 100644 --- a/packages/agentic/storybook-desktop/config/smoke-windows.ps1 +++ b/packages/agentic/storybook-desktop/config/smoke-windows.ps1 @@ -265,6 +265,9 @@ try { $env:STORYBOOK_SMOKE_FAIL_FAST = '1' $appProcess = Start-StorybookApp + if ($SmokeMode -eq 'stories-and-tests') { + $applicationLeasePath = Write-ApplicationLease -Process $appProcess -LaunchTarget $launchTarget + } Invoke-StorybookControl -Phase 'stories' -Process $appProcess if ($SmokeMode -eq 'stories-and-tests') { Stop-StorybookApp -Process $appProcess @@ -289,9 +292,9 @@ try { Stop-Process -Id $ownedProcessId -ErrorAction Continue } } - if ($cleanupFailure) { - if ($smokeFailure) { throw [System.AggregateException]::new('Smoke execution and service cleanup failed.', [System.Exception[]]@($smokeFailure, $cleanupFailure)) } - throw $cleanupFailure - } + } + if ($cleanupFailure) { + if ($smokeFailure) { throw [System.AggregateException]::new('Smoke execution and service cleanup failed.', [System.Exception[]]@($smokeFailure, $cleanupFailure)) } + throw $cleanupFailure } } diff --git a/packages/agentic/storybook-desktop/config/storybook-control.cjs b/packages/agentic/storybook-desktop/config/storybook-control.cjs index 2e1f1aa09c..88531b9463 100644 --- a/packages/agentic/storybook-desktop/config/storybook-control.cjs +++ b/packages/agentic/storybook-desktop/config/storybook-control.cjs @@ -7,6 +7,8 @@ const host = process.env.STORYBOOK_WS_HOST || '127.0.0.1'; const port = Number(process.env.STORYBOOK_WS_PORT) || 7007; const smokeMode = process.env.STORYBOOK_SMOKE_MODE || 'stories'; const smokePhase = parseSmokePhase(process.argv.slice(2)); +// The runtime connects only after Metro serves a cold development bundle, so the first story gets a startup budget. +const initialRenderMilliseconds = Number(process.env.STORYBOOK_SMOKE_INITIAL_RENDER_TIMEOUT_MS) || 300_000; const controller = new AbortController(); let report; let runner; @@ -54,7 +56,7 @@ async function selectStory(storyId, attempts = 45) { throw lastError; } -async function smoke() { +async function smoke(traversal) { if (smokeMode !== 'stories' && smokeMode !== 'stories-and-tests') { throw new Error(`Unsupported Storybook smoke mode "${smokeMode}".`); } @@ -71,7 +73,7 @@ async function smoke() { for (const { id } of entries) report.reporter.emit({ kind: 'work-selected', payload: { workId: `navigation:${id}`, label: `Navigate ${id}` } }); - for (const { id } of entries) { + for (const [entryIndex, { id }] of entries.entries()) { const started = Date.now(); const testId = `navigation:${id}`; const title = `Navigate ${id}`; @@ -79,7 +81,8 @@ async function smoke() { report.reporter.emit({ kind: 'scope-start', payload: { label: title, category: 'phase' } }, testId, context, testId); report.reporter.emit({ kind: 'test-start', payload: { testId, title, attempt: 0 } }, testId, context); try { - await selectStory(id); + if (traversal) await traversal.select(id, entryIndex === 0 ? initialRenderMilliseconds : 30_000); + else await selectStory(id); if (settleMilliseconds > 0) { await new Promise((resolve) => setTimeout(resolve, settleMilliseconds)); } @@ -118,29 +121,10 @@ async function smoke() { } report.reporter.emit({ kind: 'diagnostic', payload: { level: 'info', message: `Rendered ${entries.length} stories.` } }); } - - if (smokeMode === 'stories-and-tests' && smokePhase !== 'stories') { - await runAuthoredTests(); - } } async function runAuthoredTests() { - const manifestPath = process.env.STORYBOOK_DRIVER_MANIFEST; - if (!manifestPath || !fs.existsSync(manifestPath)) { - throw new Error('STORYBOOK_DRIVER_MANIFEST must identify the generated driver manifest when running authored tests.'); - } - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - if ( - manifest.schemaVersion !== 2 || - !['macos', 'win32', 'windows'].includes(manifest.endpoint) || - !Number.isInteger(manifest.driverPort) || - typeof manifest.targetId !== 'string' || - manifest.storyManifest?.schemaVersion !== 1 || - !Array.isArray(manifest.storyManifest.entries) - ) { - throw new Error(`Invalid Desktop Driver manifest at ${manifestPath}.`); - } - + const manifest = readDriverManifest(); const smokeTestsUrl = pathToFileURL(path.join(__dirname, '..', 'lib', 'cli', 'smokeTests.js')).href; const configUrl = pathToFileURL(path.join(__dirname, '..', 'lib', 'config', 'index.js')).href; const { formatDesktopStorybookSmokeTestSummary, runDesktopStorybookSmokeTests } = await import(smokeTestsUrl); @@ -169,6 +153,26 @@ async function runAuthoredTests() { report.reporter.emit({ kind: 'diagnostic', payload: { level: 'info', message: formatDesktopStorybookSmokeTestSummary(result) } }); } +function readDriverManifest() { + const manifestPath = process.env.STORYBOOK_DRIVER_MANIFEST; + if (!manifestPath || !fs.existsSync(manifestPath)) { + throw new Error('STORYBOOK_DRIVER_MANIFEST must identify the generated driver manifest when running authored tests.'); + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if ( + manifest.schemaVersion !== 2 || + !['macos', 'win32', 'windows'].includes(manifest.endpoint) || + !Number.isInteger(manifest.driverPort) || + typeof manifest.targetId !== 'string' || + manifest.storyManifest?.schemaVersion !== 1 || + !Array.isArray(manifest.storyManifest.entries) + ) { + throw new Error(`Invalid Desktop Driver manifest at ${manifestPath}.`); + } + + return manifest; +} + async function main() { const [{ createDesktopReportSession }, { NodeDesktopCommandRunner }] = await Promise.all([ import('@fluentui-react-native/desktop-test/reporting'), @@ -178,11 +182,41 @@ async function main() { runner = new NodeDesktopCommandRunner({ reporter: report.reporter, environment: report.environment }); report.onStop?.(() => controller.abort(new Error('Storybook control interrupted.'))); let failure; + let traversal; try { - await smoke(); + if (process.env.STORYBOOK_DRIVER_MANIFEST) { + const manifest = readDriverManifest(); + const { connectStoryTraversal } = await import(pathToFileURL(path.join(__dirname, '..', 'lib', 'cli', 'storyTraversal.js')).href); + traversal = await connectStoryTraversal({ + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- the Desktop Driver is loopback-only + url: `http://127.0.0.1:${manifest.driverPort}`, + platform: manifest.endpoint, + targetId: manifest.targetId, + signal: controller.signal, + }); + if (smokePhase === 'tests') { + // A relaunched app has no authenticated bridge until its runtime says hello and renders its initial story. + // Authored-test navigation fails fast before then, so wait for one confirmed selection first. + const [firstStory] = manifest.storyManifest.entries; + if (!firstStory) throw new Error('The Desktop Driver manifest did not contain any stories.'); + await traversal.select(firstStory.id, initialRenderMilliseconds); + } + } + await smoke(traversal); + if (smokeMode === 'stories-and-tests' && smokePhase !== 'stories') { + await traversal?.close(); + traversal = undefined; + await runAuthoredTests(); + } } catch (error) { failure = error; } finally { + try { + await traversal?.close(); + } catch (error) { + report.reporter.fail(error, 'cleanup'); + failure = failure === undefined ? error : new AggregateError([failure, error], 'Storybook traversal and cleanup failed.'); + } try { await runner.close(); } catch (error) { diff --git a/packages/agentic/storybook-desktop/package.json b/packages/agentic/storybook-desktop/package.json index a83f1a1542..84a2b9dcdc 100644 --- a/packages/agentic/storybook-desktop/package.json +++ b/packages/agentic/storybook-desktop/package.json @@ -75,7 +75,8 @@ "@rnx-kit/tools-react-native": "catalog:", "commander": "^14.0.2", "expect-webdriverio": "catalog:", - "regexpu-core": "^6.3.1" + "regexpu-core": "^6.3.1", + "ws": "8.22.0" }, "devDependencies": { "@fluentui-react-native/scripts": "workspace:*", @@ -84,6 +85,9 @@ "furn": { "jestPlatform": "react", "knip": { + "extraEntries": [ + "config/controlled-channel.cjs" + ], "ignoreDependencies": [ "@react-native/babel-preset" ] diff --git a/packages/agentic/storybook-desktop/references/contributing.md b/packages/agentic/storybook-desktop/references/contributing.md index 8a1ac7efea..33f470663d 100644 --- a/packages/agentic/storybook-desktop/references/contributing.md +++ b/packages/agentic/storybook-desktop/references/contributing.md @@ -17,6 +17,13 @@ The channel child in `config/server.cjs` constructs its native driver through helper policy, authority, descriptor publication and settled driver shutdown in that shared harness implementation, not a parallel Storybook copy. +Agent manifests use `config/controlled-channel.cjs`, not upstream +`createChannelServer`: only public index generation is reused. Do not add a +WebSocket relay or an HTTP mutation route to this adapter. Runtime events must +remain socket/epoch authenticated, navigation uses driver-owned settlement, +and protected args stay refused. Manifest-backed smoke traversal must acquire +and release its driver session before authored test sessions start. + Input lanes cross these areas. The descriptor schema, merge, and per-case decision belong to `@fluentui-react-native/desktop-driver/authoring`; do not copy them here. `src/cli/inputLane.ts` resolves the requested lane, @@ -31,6 +38,21 @@ Keep story callbacks statically extractable and authentication checks fail-closed. Update references when commands, supported config, outputs, or readiness contracts change. +Authenticated bridge epochs are recorded by `BridgeEpochJournal` in the +service factory's private endpoint/target namespace, separately from settlement +and quarantine. The record binds renderer and epoch to a native-verified PID +and process start time. Identity resolution uses a temporary attached helper, +disposed without closing the app or altering an active session's window records. +Readiness remains disabled until the record is published; a missing startup +lease can be retried on the next story command, never assumed verified. +After a service restart, only a matching record, a `running` exact-incarnation +probe, and a matching current native attachment justify `relaunch-required`. +Initial absence, an unknown/failed probe, or a replaced process stays +`bridge-unavailable`, with no navigation dispatched. Proven exited/replaced +records are removed; reauthentication replaces the old epoch. Neither record +nor relaunch guidance clears settlement quarantine or promises transparent +upstream reconnection. + Run `yarn format`, `yarn lint`, `yarn build`, and `yarn test` from this package. Validate static manifests and the affected app's bundle/native smoke when changing runtime behavior. diff --git a/packages/agentic/storybook-desktop/references/integration.md b/packages/agentic/storybook-desktop/references/integration.md index d3307b5cc0..489f1a2edc 100644 --- a/packages/agentic/storybook-desktop/references/integration.md +++ b/packages/agentic/storybook-desktop/references/integration.md @@ -67,11 +67,77 @@ contribute catalog/channel/context and CSF runner resources; the runtime stays Node-free. Do not add a separate coordinator package or move supervision into desktop-driver or MCP. -Agent mode protects the embedded driver and disables the channel's MCP endpoint, -but does not make all channel/runtime mutation ingress exclusively grant-owned. -Unknown story changes now quarantine the driver target; args updates through the -protected driver are unsupported. Runtime reconnect, complete context fencing, -durable recovery wiring, and channel ingress authority remain work in progress. +Owned Node services inherit the neutral harness's per-command socket lifeline +through a Node preload. Its authenticated handshake gates entry, including +late-spawned descendants. The channel registers explicit disposal before +acquiring resources: stop or parent loss aborts initialization, awaits late +acquisitions, then withdraws the agent descriptor through the shared F1 service +and awaits durable driver settlement before closing the channel. The native +helper remains owned by that service and its existing stdio transport. + +`driver --agent` awaits a typed `service-ready` receipt naming the descriptor, +command incarnation, and producer PID. The neutral resource scope validates +and retains that receipt on the owned channel's resource generation before +the CLI reports readiness. The generated `agent-ready.*.json` file +is a compatibility breadcrumb only, never readiness authority. The receipt +does not prove target acquisition, application readiness, or authenticated +context/render readiness. Borrowed applications and external services are not +enrolled in the lifeline or implicitly terminated. Missing/failed withdrawal +or stalled disposal is incomplete, not successful cleanup; automatic claim +reclaim remains disabled. There is no fixed-time cleanup guarantee. + +Portable Node process contracts exercise parent/wrapper crash, late spawn, +descriptor withdrawal failure, stalled children, and borrowed-process survival. +Live macOS Storybook/Metro/helper survivors still need combined-branch native +qualification; Windows descendant containment is not run on this macOS host. +Accessibility, physical input and capture require their own granted capabilities +and are not qualified by these process fixtures. + +Agent mode does not construct upstream `createChannelServer`. Its public API +has no authorization/interception hook, and its HTTP handlers and WebSocket +relay can broadcast arbitrary mutations. Instead the controlled transport uses +the public `buildIndex` API and an owned HTTP/WebSocket server: only +`GET /index.json` is served, other HTTP requests (including `/send-event`, +`/select-story-sync/*`, and `/mcp`) return 403 without reading or echoing values, +and no client WebSocket frame is relayed to another client. Runtime hello and +ready/error messages are consumed locally, bound to the authenticated socket +and F4 connection epoch. + +Selection and reset require the session-owning driver grant. The orchestrator +sends prepare followed by `setCurrentStory` only to that authenticated runtime, +waits for correlated readiness, then the driver verifies the native marker. +These mutations use the existing settlement, preview invalidation and +quarantine path. Args remain intentionally unsupported +(`story-args-unsupported`), even for the owning grant; the controlled +orchestrator also refuses direct args dispatch. No argument values enter +transport diagnostics or journals. + +Without an agent manifest the upstream channel is **legacy unprotected mode**, +including its HTTP mutation handlers, WebSocket relay and MCP endpoint. It is +not an authority boundary. Channel-only `smoke --mode stories` retains this +legacy path. Manifest-backed traversal (`stories-and-tests`, and the Windows/ +Win32 control script when a driver manifest is supplied) opens one driver +session, selects each story through it, and deletes that session before authored +tests acquire their own. It never falls back to upstream selection after a +driver failure. Only pre-dispatch `bridge-unavailable` can be retried; +uncertain navigation and `relaunch-required` fail without replay. The first +story's retry budget covers a cold development bundle +(`STORYBOOK_SMOKE_INITIAL_RENDER_TIMEOUT_MS`, default 300 s); later stories +use 30 s. The Windows smoke relaunches the app between its `stories` and +`tests` phases, so the `tests` phase first selects the first manifest story +with the same budget and waits for authenticated readiness before authored +tests navigate. A relaunched runtime that has said hello but not yet sent its +initial `storyRendered` reports `bridge-unavailable`, not `relaunch-required`, +so this wait keeps retrying. A reloaded runtime whose new socket authenticates +before the old socket closes retires the old bridge in the same way. Authored-test +navigation itself does not wait for the bridge. + +Epoch/cancellation-aware providers reject stale preview mutations without +invalidating chrome or live secondary windows. Interrupted navigation still +quarantines when dispatch cannot be proven fenced. Native endpoint +qualification of the new ingress remains pending on macOS Paper/Fabric and +Windows Fabric/Win32 Paper. Automatic transport reconnect remains unsupported; see +[readiness and installed upstream limits](readiness.md). See `@fluentui-react-native/desktop-driver/references/current-status.md` and `@fluentui-react-native/desktop-test/references/current-status.md`. diff --git a/packages/agentic/storybook-desktop/references/readiness.md b/packages/agentic/storybook-desktop/references/readiness.md index 92462f29d9..0487cfb47d 100644 --- a/packages/agentic/storybook-desktop/references/readiness.md +++ b/packages/agentic/storybook-desktop/references/readiness.md @@ -22,8 +22,57 @@ channel events. At startup, require both an authenticated runtime hello and the initial `storyRendered` event on the same connection. The hello alone can precede -Storybook's navigation handlers after an app restart. Startup render readiness -does not replace request-correlated readiness for each requested story. +Storybook's navigation handlers after an app restart. Until that initial render +arrives, an open, unfenced replacement connection reports `bridge-unavailable` +with effect `none`, even after the previous bridge closed. A fenced bridge, or +no open bridge after a close, still reports `relaunch-required`. Startup render +readiness does not replace request-correlated readiness for each requested +story. + +Each socket receives a fresh connection epoch in a version-2 hello challenge. +The hello must echo that epoch and the manifest identity. Ready/error events +bind the epoch, request, run, and story; version-1 native markers additionally +bind the preview generation. Missing epoch or marker evidence is not readiness. +An old socket cannot authenticate or complete work on its replacement. +A runtime reload can authenticate its new socket before the old socket reports +close. That valid hello retires the old bridge exactly as a close does: the old +socket is fenced, pending work is rejected with effect `unknown`, and the +replacement awaits its initial render before it becomes selectable. + +The local provider context carries the session lifetime ID and abort signal +separately from serializable inputs. No authenticated bridge means prompt +`bridge-unavailable`, with endpoint/renderer and effect `none`, before dispatch. +Controlled selection sends prepare and navigation directly to the authenticated +socket, waits for correlated runtime readiness, then the driver verifies the +native marker within the same render deadline. Legacy unprotected selection +additionally waits for upstream HTTP navigation. Disconnect promptly rejects +pending work and aborts any legacy HTTP request. Cancellation, +disconnect, or deadline after dispatch fences that socket and leaves the +effect unknown: the driver retains target quarantine. Late ready/error events +cannot release it. Subsequent requests return renderer-specific +`relaunch-required`; reauthentication is not quarantine recovery. + +The app and runtime resolve `@storybook/react-native` **10.5.4**, with +`storybook` **10.6.3**, in the inspected installation. In those exact sources, +`dist/index.js` installs the server channel while constructing Storybook UI; Storybook's +`dist/channels/index.js` creates one WebSocket, emits `channelWSDisconnect` +on close, marks it closed, and drops later sends without reconnecting. +The React Native `dist/channelServer-D-IaG_dn.js` HTTP selection handler +broadcasts uncorrelated navigation and accepts story-ID render events; +aborting its HTTP client cannot retract the broadcast. Do not promise +transparent recovery or use HTTP completion as a render fence. Relaunch the +app when restarting the channel/driver against that running installation. +This is source evidence, not native endpoint qualification or a claim about +other dependency versions. + +The controlled channel avoids that upstream HTTP handler and never relays +inbound WebSocket frames. This is a separate supported adapter, not a patch to +upstream internals or listener-order interception. Deterministic endpoint +contracts exercise HTTP refusals, foreign WebSocket prepare/selection/args, +direct authenticated dispatch, correlated settlement and cancellation fencing. +They do not qualify the native transport: repeat these checks against each +renderer, including driver-owned select/reset, a stale preview action, delayed +ready/error after cancellation, and manifest-backed smoke traversal. Do not patch generated identity or manifests. Change the app configuration or manifest producer, regenerate, then validate the exact platform manifest. diff --git a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts index 8729f91a2c..531ed97042 100644 --- a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts @@ -69,6 +69,7 @@ const nativeDriverTestOptions = { buildNativeDriver: async () => nativeDriverArtifact, resolveNativeDriver: async () => nativeDriverArtifact, writeMacOSApplicationLease: async () => undefined, + connectStoryTraversal: async () => ({ select: async () => undefined, close: async () => null }), }; const createEmptyStoryManifest = async (_config: unknown, platform: 'macos' | 'windows' | 'win32') => ({ endpoint: platform, @@ -220,6 +221,11 @@ class RecordingRunner implements DesktopCommandRunner { stopped = 0; failCommand?: string; exitService?: string; + readiness = jest.fn(async (expected: { tier: 'service-ready'; service: string; descriptor?: string }) => ({ + ...expected, + incarnation: 'fixture-incarnation', + processId: 1, + })); async run(command: PreparedDesktopCommand): Promise { this.foreground.push(command); @@ -231,6 +237,7 @@ class RecordingRunner implements DesktopCommandRunner { start(command: PreparedDesktopCommand): RunningDesktopCommand { this.background.push(command); return { + waitForReadiness: this.readiness, completed: command.context?.service === this.exitService && this.exitService ? Promise.resolve(7) : new Promise(() => {}), stop: async () => { this.stopped += 1; @@ -640,14 +647,17 @@ describe('DesktopStorybookCli', () => { const readyPath = path.join(generated, 'agent-ready.macos.json'); const runner = new RecordingRunner(); const services = new DesktopServiceScope(runner); + const resourceObserver = jest.fn(); + services.setResourceObserver(resourceObserver); const written: string[] = []; const maintained: string[] = []; - const fetch: typeof globalThis.fetch = async (input) => { - if (String(input).endsWith('/index.json')) { - setTimeout(() => fs.writeFileSync(readyPath, JSON.stringify({ descriptor: 'custom.json', processId: 1, schemaVersion: 1 })), 300); - } - return new Response('{}'); - }; + let published = false; + runner.readiness.mockImplementation(async (expected) => { + expect(written).toEqual([]); + published = true; + return { ...expected, incarnation: 'fixture-incarnation', processId: 1 }; + }); + const fetch: typeof globalThis.fetch = async () => new Response('{}'); const createStoryManifest = async () => ({ ...(await createEmptyStoryManifest(undefined, 'macos')), entries: [ @@ -674,8 +684,25 @@ describe('DesktopStorybookCli', () => { signal.throwIfAborted(); throw new Error('unreachable'); }, - onReady: () => written.push('onReady'), - output: { write: (chunk: string) => written.push(`${fs.existsSync(readyPath) ? 'published' : 'unpublished'}: ${chunk}`) > 0 }, + onReady: () => { + expect(resourceObserver).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'desktop:storybook-channel', + generation: expect.any(Number), + ownership: 'owned', + readiness: 'ready', + serviceReady: { + tier: 'service-ready', + service: 'storybook-channel', + descriptor: 'custom.json', + incarnation: 'fixture-incarnation', + processId: 1, + }, + }), + ); + written.push('onReady'); + }, + output: { write: (chunk: string) => written.push(`${published ? 'published' : 'unpublished'}: ${chunk}`) > 0 }, runner, services, }); @@ -689,6 +716,12 @@ describe('DesktopStorybookCli', () => { root: path.resolve('relative-root'), }); expect(maintained).toEqual([manifestPath]); + expect(runner.readiness).toHaveBeenCalledWith( + { tier: 'service-ready', service: 'storybook-channel', descriptor: 'custom.json' }, + 120_000, + expect.any(AbortSignal), + ); + expect(fs.existsSync(readyPath)).toBe(false); await expect(new DesktopStorybookCli(makeConfig(), { createStoryManifest, runner }).test('macos')).rejects.toThrow('serves an agent'); } finally { fs.rmSync(manifestPath, { force: true }); @@ -696,6 +729,33 @@ describe('DesktopStorybookCli', () => { } }); + test('does not trust an agent ready file when correlated readiness fails', async () => { + const generated = path.join(storybookRoot, 'storybook-desktop.generated'); + const manifestPath = path.join(generated, 'driver-manifest.macos.json'); + const readyPath = path.join(generated, 'agent-ready.macos.json'); + const runner = new RecordingRunner(); + runner.readiness.mockImplementation(async () => { + fs.writeFileSync(readyPath, JSON.stringify({ descriptor: 'agent.json', processId: 1, schemaVersion: 1 })); + throw new Error('Uncorrelated service readiness'); + }); + const output = { write: jest.fn(() => true) }; + const cli = new DesktopStorybookCli(makeConfig(), { + ...nativeDriverTestOptions, + createStoryManifest: createEmptyStoryManifest, + fetch: async () => new Response('{}'), + isPortAvailable: async () => true, + output, + runner, + }); + try { + await expect(cli.driver('macos', { agent: { descriptor: 'agent.json' } })).rejects.toThrow('Uncorrelated service readiness'); + expect(output.write).not.toHaveBeenCalled(); + } finally { + fs.rmSync(manifestPath, { force: true }); + fs.rmSync(readyPath, { force: true }); + } + }); + test.each([ { name: 'a configured AUMID', @@ -726,12 +786,7 @@ describe('DesktopStorybookCli', () => { windowTitle: 'Fixture', workingDirectory: 'rex', })); - const fetch: typeof globalThis.fetch = async (input) => { - if (String(input).endsWith('/index.json')) { - setTimeout(() => fs.writeFileSync(readyPath, JSON.stringify({ descriptor: 'agent.json', processId: 1, schemaVersion: 1 })), 50); - } - return new Response('{}'); - }; + const fetch: typeof globalThis.fetch = async () => new Response('{}'); const cli = new DesktopStorybookCli(config, { ...nativeDriverTestOptions, createStoryManifest: createEmptyStoryManifest, @@ -1080,6 +1135,37 @@ describe('DesktopStorybookCli', () => { } }); + test('retains both authoritative traversal and cleanup failures without starting authored tests', async () => { + const navigationFailure = new Error('navigation failed'); + const cleanupFailure = new Error('session cleanup failed'); + const runSmokeTests = jest.fn(); + const cli = new DesktopStorybookCli( + makeConfig({ macos: { run: { command: 'launch-storybook' }, smoke: { stop: { command: 'stop-storybook' } } } }), + { + ...nativeDriverTestOptions, + createStoryManifest: createEmptyStoryManifest, + runner: new RecordingRunner(), + isPortAvailable: async () => true, + output: { write: () => true }, + errorOutput: { write: () => true }, + fetch: async () => new Response(JSON.stringify({ entries: { first: { id: 'first--story', type: 'story' } } })), + runSmokeTests, + connectStoryTraversal: async () => ({ + select: async () => { + throw navigationFailure; + }, + close: async () => { + throw cleanupFailure; + }, + }), + }, + ); + await expect(cli.smoke('macos', { mode: 'stories-and-tests' })).rejects.toMatchObject({ + errors: [{ errors: [expect.objectContaining({ cause: navigationFailure })] }, cleanupFailure], + }); + expect(runSmokeTests).not.toHaveBeenCalled(); + }); + test('runs authored tests after traversing the complete story index', async () => { const runner = new RecordingRunner(); const resolveNativeDriver = jest.fn(async () => nativeDriverArtifact); @@ -1099,9 +1185,6 @@ describe('DesktopStorybookCli', () => { }), ); } - if (url.includes('select-story-sync')) { - events.push(`select:${url.split('/').at(-1)}`); - } return new Response('{}'); }); const runSmokeTests = jest.fn(async () => { @@ -1140,12 +1223,22 @@ describe('DesktopStorybookCli', () => { runner, resolveNativeDriver, writeMacOSApplicationLease, + connectStoryTraversal: async () => ({ + select: async (id) => { + events.push(`select:${id}`); + }, + close: async () => { + events.push('close'); + return null; + }, + }), }, ); await cli.smoke('macos', { mode: 'stories-and-tests' }); - expect(events).toEqual(['lease', 'select:first--story', 'select:second--story', 'tests']); + expect(events).toEqual(['lease', 'select:first--story', 'select:second--story', 'close', 'tests']); + expect(fetch.mock.calls.some(([input]) => input.toString().includes('select-story-sync'))).toBe(false); expect(writeMacOSApplicationLease).toHaveBeenCalledWith( path.join(storybookRoot, 'storybook-desktop.generated', 'driver-manifest.macos.json'), 120_000, diff --git a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts index ff7da2de2f..22000c76e8 100644 --- a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts @@ -48,7 +48,6 @@ import { desktopStorybookTargetId, driverManifestInput, type CreateDesktopStorybookDriverManifestOptions, - type DesktopStorybookAgentManifest, type DesktopStorybookDriverManifest, } from '../driver/driverManifest.js'; import { NodeDesktopCommandRunner, STORYBOOK_VERBOSE } from './commandRunner.js'; @@ -59,6 +58,7 @@ import { describeWdioCounts } from '../testing/laneVerdict.js'; import { decideWdioSelection, describeWdioExclusions, listWdioSelection } from '../testing/selection.js'; import type { WdioSelectionContext } from '../testing/selection.js'; import { resolveWdioOptions, type DesktopStorybookWdioOptions } from '../config/wdio.js'; +import { connectStoryTraversal } from './storyTraversal.js'; type ResolvedDesktopStorybookInstance = DesktopStorybookInstance & { driverManifestPath?: string; @@ -85,6 +85,7 @@ export type DesktopStorybookCliOptions = { verbose?: boolean; isPortAvailable?: (port: number) => Promise; runSmokeTests?: typeof runDesktopStorybookSmokeTests; + connectStoryTraversal?: typeof connectStoryTraversal; resolveNativeDriver?: typeof resolveNativeDesktopDriver; /** Resolves a Windows or Win32 desktop target's verified application identity for `driver --agent`. */ resolveDesktopApplication?: typeof resolveDesktopApplication; @@ -137,6 +138,7 @@ export class DesktopStorybookCli { private readonly verbose: boolean; private readonly isPortAvailable: (port: number) => Promise; private readonly runSmokeTests: typeof runDesktopStorybookSmokeTests; + private readonly connectStoryTraversal: typeof connectStoryTraversal; private readonly resolveNativeDriver: typeof resolveNativeDesktopDriver; private readonly resolveDesktopApplication: typeof resolveDesktopApplication; private readonly writeMacOSApplicationLease: typeof writeMacOSApplicationLease; @@ -177,6 +179,7 @@ export class DesktopStorybookCli { this.verbose = options.verbose ?? process.env[STORYBOOK_VERBOSE] === '1'; this.isPortAvailable = options.isPortAvailable ?? isLoopbackPortAvailable; this.runSmokeTests = options.runSmokeTests ?? runDesktopStorybookSmokeTests; + this.connectStoryTraversal = options.connectStoryTraversal ?? connectStoryTraversal; this.resolveNativeDriver = options.resolveNativeDriver ?? resolveNativeDesktopDriver; this.resolveDesktopApplication = options.resolveDesktopApplication ?? resolveDesktopApplication; this.writeMacOSApplicationLease = options.writeMacOSApplicationLease ?? writeMacOSApplicationLease; @@ -215,7 +218,7 @@ export class DesktopStorybookCli { this.output.write(`Storybook server ready: ${host}:${port}\n`); } this.onReady?.(); - await this.services.guard(new Promise(() => undefined)); + await this.services.guard(this.services.waitForStop()); } catch (error) { failures.push(error); } @@ -283,7 +286,11 @@ export class DesktopStorybookCli { ]); const agentManifest = driverManifest.manifest.agent; if (agentManifest) { - await this.services.guard(waitForAgentConnection(agentManifest, 120_000, this.services.controller.signal)); + await this.services.readyProcess( + 'storybook-channel', + { tier: 'service-ready', service: 'storybook-channel', descriptor: agentManifest.descriptor }, + 120_000, + ); } if (this.reporterMode === 'json' || this.reporterMode === 'ndjson') { this.writeStructured({ @@ -312,7 +319,7 @@ export class DesktopStorybookCli { ? this.maintainMacOSApplicationLease(driverManifest.path, this.services.controller.signal, { onWarning: (message) => this.warn(message), }) - : new Promise(() => undefined), + : this.services.waitForStop(), ); } catch (error) { failures.push(error); @@ -592,9 +599,31 @@ export class DesktopStorybookCli { } await this.writeMacOSApplicationLease(instance.driverManifestPath, smoke.startupTimeoutMs ?? 120_000); } - await this.services.guard( - this.renderEveryStory(serverUrl, smoke.settleMs ?? 0, smoke.initialRenderTimeoutMs ?? smoke.startupTimeoutMs), - ); + const traversal = instance.driverManifestPath + ? await this.connectStoryTraversal({ + url: loopbackUrl(instance.driverPort), + platform, + targetId: this.targetId(platform), + fetch: this.fetch, + signal: this.services.controller.signal, + }) + : undefined; + let traversalFailure: unknown; + try { + await this.services.guard( + this.renderEveryStory(serverUrl, smoke.settleMs ?? 0, smoke.initialRenderTimeoutMs ?? smoke.startupTimeoutMs, traversal?.select), + ); + } catch (error) { + traversalFailure = error; + } + try { + await traversal?.close(); + } catch (error) { + throw traversalFailure === undefined + ? error + : new AggregateError([traversalFailure, error], 'Storybook traversal and cleanup failed.'); + } + if (traversalFailure !== undefined) throw traversalFailure; if (mode === 'stories-and-tests') { const result = await this.services.guard( this.runSmokeTests({ @@ -800,7 +829,12 @@ export class DesktopStorybookCli { }; } - private async renderEveryStory(serverUrl: string, settleMs: number, initialRenderTimeoutMs = 300_000): Promise { + private async renderEveryStory( + serverUrl: string, + settleMs: number, + initialRenderTimeoutMs = 300_000, + select = (storyId: string, timeoutMs: number) => this.selectStory(serverUrl, storyId, timeoutMs), + ): Promise { const storyIndex = await this.getJson(new URL('/index.json', serverUrl)); const entries = Object.values((storyIndex.entries ?? {}) as Record).filter( (entry) => entry.type === 'story' && entry.id, @@ -820,7 +854,7 @@ export class DesktopStorybookCli { this.reporter?.emit({ kind: 'scope-start', payload: { label: `Render ${id}`, category: 'worker' } }, testId, { story: id }, testId); this.reporter?.emit({ kind: 'test-start', payload: { testId, title: `Render ${id}`, attempt: 0 } }, testId, { story: id }); try { - await this.selectStory(serverUrl, id!, entryIndex === 0 ? initialRenderTimeoutMs : 15_000); + await select(id!, entryIndex === 0 ? initialRenderTimeoutMs : 15_000); consecutiveFailures = 0; if (settleMs > 0) { await delay(settleMs); @@ -1208,32 +1242,6 @@ function queryMacOSApplicationsOutput(bundleIdentifier: string): Promise ]); } -/** Waits until the channel service records that it published the agent's connection descriptor. */ -async function waitForAgentConnection(agent: DesktopStorybookAgentManifest, timeoutMs: number, signal: AbortSignal): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - const record: unknown = JSON.parse(fs.readFileSync(agent.readyPath, 'utf8')); - if ( - typeof record === 'object' && - record !== null && - 'schemaVersion' in record && - record.schemaVersion === 1 && - 'descriptor' in record && - record.descriptor === agent.descriptor - ) { - return; - } - lastError = new Error(`The agent readiness record at ${agent.readyPath} does not name ${agent.descriptor}.`); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') lastError = error; - } - await delay(100, signal); - } - throw new Error(`The Storybook channel service did not publish the agent connection within ${timeoutMs}ms.`, { cause: lastError }); -} - export function parseMacOSRunningApplications(output: string, bundleIdentifier: string): MacOSRunningApplication[] { const value: unknown = JSON.parse(output); if (!Array.isArray(value)) { diff --git a/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts b/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts index 404f6a88d7..b1ac6880fd 100644 --- a/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts +++ b/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts @@ -94,6 +94,7 @@ export function createDesktopStorybookCommand(options: CreateDesktopStorybookCom verbose: program.opts<{ verbose?: boolean }>().verbose ?? options.verbose, isPortAvailable: options.isPortAvailable, runSmokeTests: options.runSmokeTests, + connectStoryTraversal: options.connectStoryTraversal, resolveNativeDriver: options.resolveNativeDriver, writeMacOSApplicationLease: options.writeMacOSApplicationLease, reporter: options.reporter, diff --git a/packages/agentic/storybook-desktop/src/cli/storyTraversal.test.ts b/packages/agentic/storybook-desktop/src/cli/storyTraversal.test.ts new file mode 100644 index 0000000000..116133a5ed --- /dev/null +++ b/packages/agentic/storybook-desktop/src/cli/storyTraversal.test.ts @@ -0,0 +1,59 @@ +import { connectStoryTraversal } from './storyTraversal.js'; + +function setup(reason?: string) { + const calls: { method?: string; url: string; body?: unknown }[] = []; + let failures = reason === undefined ? 0 : 1; + const fetch: typeof globalThis.fetch = jest.fn(async (input, init) => { + const url = input.toString(); + calls.push({ method: init?.method, url, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + if (url.endsWith('/session')) return new Response(JSON.stringify({ value: { sessionId: 'session', capabilities: {} } })); + if (init?.method === 'DELETE') { + expect(init.signal?.aborted).toBe(false); + return new Response(JSON.stringify({ value: null })); + } + if (failures-- > 0) { + return new Response(JSON.stringify({ value: { error: 'unknown error', message: 'Not ready', data: { reason } } }), { status: 500 }); + } + return new Response(JSON.stringify({ value: { storyId: 'button--default' } })); + }); + const controller = new AbortController(); + return { + calls, + controller, + connect: () => + connectStoryTraversal({ url: 'https://driver', platform: 'macos', targetId: 'target', fetch, signal: controller.signal }), + }; +} + +test('manifest-backed traversal owns one driver session and deletes it even after cancellation', async () => { + const { calls, controller, connect } = setup(); + const traversal = await connect(); + await traversal.select('button--default', 1000); + controller.abort(); + await traversal.close(); + expect(calls.map(({ method, url }) => [method, url])).toEqual([ + ['POST', 'https://driver/session'], + ['POST', 'https://driver/session/session/furn/story'], + ['DELETE', 'https://driver/session/session'], + ]); + expect(calls[0].body).toMatchObject({ capabilities: { alwaysMatch: { 'furn:target': 'target', 'furn:launchMode': 'attach' } } }); + expect(calls[1].body).toMatchObject({ storyId: 'button--default', requestId: expect.any(String), runId: expect.any(String) }); +}); + +test('initial bridge absence can be retried, but uncertain or relaunch-required navigation cannot', async () => { + for (const reason of ['bridge-unavailable', 'story-not-ready', 'relaunch-required']) { + const { connect, calls } = setup(reason); + const traversal = await connect(); + try { + if (reason === 'bridge-unavailable') { + await expect(traversal.select('button--default', 2000)).resolves.toBeUndefined(); + expect(calls.filter(({ url }) => url.endsWith('/furn/story'))).toHaveLength(2); + } else { + await expect(traversal.select('button--default', 2000)).rejects.toMatchObject({ data: { reason } }); + expect(calls.filter(({ url }) => url.endsWith('/furn/story'))).toHaveLength(1); + } + } finally { + await traversal.close(); + } + } +}); diff --git a/packages/agentic/storybook-desktop/src/cli/storyTraversal.ts b/packages/agentic/storybook-desktop/src/cli/storyTraversal.ts new file mode 100644 index 0000000000..fcd98685db --- /dev/null +++ b/packages/agentic/storybook-desktop/src/cli/storyTraversal.ts @@ -0,0 +1,62 @@ +import { randomUUID } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; + +import { createDesktopDriverClient } from '@fluentui-react-native/desktop-driver/client'; + +export async function connectStoryTraversal(options: { + url: string; + targetId: string; + platform: 'macos' | 'windows' | 'win32'; + signal: AbortSignal; + fetch?: typeof globalThis.fetch; +}) { + const client = createDesktopDriverClient({ + url: options.url, + fetch: (input, init) => + (options.fetch ?? globalThis.fetch)(input, { + ...init, + signal: AbortSignal.any([options.signal, ...(init?.signal ? [init.signal] : [])]), + }), + }); + const session = await client.newSession({ + alwaysMatch: { + platformName: options.platform === 'macos' ? 'macos' : 'windows', + 'furn:target': options.targetId, + 'furn:launchMode': 'attach', + }, + }); + return { + // Cleanup must remain possible after the traversal's signal is cancelled. + close: () => + createDesktopDriverClient({ url: options.url, fetch: options.fetch }).request( + 'DELETE', + `/session/${session.id}`, + undefined, + AbortSignal.timeout(30_000), + ), + select: async (storyId: string, timeoutMs: number) => { + const signal = AbortSignal.any([options.signal, AbortSignal.timeout(timeoutMs)]); + do { + signal.throwIfAborted(); + try { + await session.command('POST', '/furn/story', { storyId, requestId: randomUUID(), runId: randomUUID() }, signal); + return; + } catch (error) { + // Only initial bridge absence proves that nothing was dispatched. Never retry uncertain navigation. + if ( + !error || + typeof error !== 'object' || + !('data' in error) || + !error.data || + typeof error.data !== 'object' || + !('reason' in error.data) || + error.data.reason !== 'bridge-unavailable' + ) + throw error; + await delay(500, undefined, { signal }); + } + } while (!signal.aborted); + signal.throwIfAborted(); + }, + }; +} diff --git a/packages/agentic/storybook-desktop/src/driver/BridgeEpochJournal.ts b/packages/agentic/storybook-desktop/src/driver/BridgeEpochJournal.ts new file mode 100644 index 0000000000..7ae69725ff --- /dev/null +++ b/packages/agentic/storybook-desktop/src/driver/BridgeEpochJournal.ts @@ -0,0 +1,111 @@ +import type { PrivateStorage } from '@fluentui-react-native/desktop-driver/authority'; +import type { ProbeApplication } from '@fluentui-react-native/desktop-driver/server'; +import type { DesktopEndpoint } from '@fluentui-react-native/desktop-driver'; + +type Application = { processId: number; processStartedAt: string }; +type Record = { + application: Application; + endpoint: DesktopEndpoint; + epoch: string; + kind: 'bridge-epoch'; + renderer: 'fabric' | 'paper'; + targetId: string; + version: 1; +}; +type Namespace = Pick; + +export type BridgeEpochRecovery = { + storage: PrivateStorage; + probeApplication?: ProbeApplication; + /** Resolves the configured target through native identity verification, never from a channel payload. */ + resolveApplication(): Promise; +}; + +/** A sibling of settlement records in the factory's stable endpoint/target private namespace. */ +export class BridgeEpochJournal { + private queue: Promise = Promise.resolve(); + private readonly recovery: BridgeEpochRecovery; + private readonly namespace: Namespace; + + constructor(recovery: BridgeEpochRecovery, namespace: Namespace) { + this.recovery = recovery; + this.namespace = namespace; + } + + record(epoch: string): Promise { + return this.serialize(async () => { + const application = await this.recovery.resolveApplication(); + const record: Record = { ...this.namespace, application, epoch, kind: 'bridge-epoch', version: 1 }; + const contents = JSON.stringify(parseRecord(record)); + const previous = await this.recovery.storage.read('bridge-epoch.json'); + if (previous === contents) return; + if (previous !== undefined && !(await this.recovery.storage.remove('bridge-epoch.json', previous))) + throw new Error('The authenticated bridge epoch changed while replacing it.'); + await this.recovery.storage.publish('bridge-epoch.json', contents); + }); + } + + requiresRelaunch(): Promise { + return this.serialize(async () => { + const contents = await this.recovery.storage.read('bridge-epoch.json'); + if (contents === undefined) return false; + const record = parseRecord(JSON.parse(contents)); + if ( + record.endpoint !== this.namespace.endpoint || + record.targetId !== this.namespace.targetId || + record.renderer !== this.namespace.renderer + ) + return false; + const state = await this.recovery.probeApplication?.(record.application); + if (state === 'exited') { + if (!(await this.recovery.storage.remove('bridge-epoch.json', contents))) + throw new Error('The exited bridge epoch changed while removing it.'); + return false; + } + if (state !== 'running') return false; + const current = await this.recovery.resolveApplication(); + if (current.processId !== record.application.processId || current.processStartedAt !== record.application.processStartedAt) { + if (!(await this.recovery.storage.remove('bridge-epoch.json', contents))) + throw new Error('The replaced bridge epoch changed while removing it.'); + return false; + } + + return true; + }); + } + + private serialize(work: () => Promise): Promise { + const result = this.queue.then(work, work); + this.queue = result.catch(() => undefined); + return result; + } +} + +function parseRecord(value: unknown): Record { + if (!value || typeof value !== 'object') throw new Error('Invalid authenticated bridge epoch record.'); + const record = value as Partial; + const application = record.application; + if ( + record.version !== 1 || + record.kind !== 'bridge-epoch' || + !['macos', 'windows', 'win32'].includes(record.endpoint ?? '') || + !['fabric', 'paper'].includes(record.renderer ?? '') || + typeof record.targetId !== 'string' || + record.targetId.length < 1 || + record.targetId.length > 256 || + typeof record.epoch !== 'string' || + record.epoch.length < 1 || + record.epoch.length > 128 || + !application || + !Number.isSafeInteger(application.processId) || + application.processId < 1 || + application.processId > 0x7fffffff || + typeof application.processStartedAt !== 'string' || + application.processStartedAt.length < 1 || + application.processStartedAt.length > 64 || + Object.keys(record).length !== 7 || + Object.keys(application).length !== 2 + ) + throw new Error('Invalid authenticated bridge epoch record.'); + return record as Record; +} diff --git a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts index 411963f731..f595c4097b 100644 --- a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts +++ b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts @@ -1,90 +1,61 @@ -import type { DesktopStoryManifest } from '@fluentui-react-native/desktop-driver'; +import { EventEmitter } from 'node:events'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { runInNewContext } from 'node:vm'; + +import { transformSync } from '@babel/core'; +import { matchesStoryMarker, type StorySelectionRequest } from '@fluentui-react-native/desktop-driver/storybook'; import type { DesktopStorybookDriverManifest } from './driverManifest.js'; import { StorybookChannelOrchestrator } from './StorybookChannelOrchestrator.js'; -import type { StorybookChannelServer } from './StorybookChannelOrchestrator.js'; +import type { BridgeEpochRecovery } from './BridgeEpochJournal.js'; +import { createControlledChannelServer } from '../../config/controlled-channel.cjs'; -class FakeChannelClient { +class Client extends EventEmitter { readyState = 1; - readonly sent: string[] = []; - private readonly messageListeners: ((data: unknown) => void)[] = []; - private readonly closeListeners: (() => void)[] = []; - - on(event: 'close', listener: () => void): void; - on(event: 'message', listener: (data: unknown) => void): void; - on(event: 'close' | 'message', listener: (() => void) | ((data: unknown) => void)): void { - if (event === 'close') { - this.closeListeners.push(listener as () => void); - } else { - this.messageListeners.push(listener as (data: unknown) => void); - } - } - + sent: { type: string; args: Record[] }[] = []; send(message: string): void { - this.sent.push(message); + this.sent.push(JSON.parse(message)); } - - receive(type: string, payload: unknown): void { - const message = JSON.stringify({ type, args: [payload] }); - for (const listener of this.messageListeners) { - listener(message); - } + receive(type: string, payload?: unknown): void { + this.emit('message', JSON.stringify({ type, args: [payload] })); } - - close(): void { - for (const listener of this.closeListeners) { - listener(); - } + get epoch(): string { + return this.sent[0].args[0].connectionEpoch as string; } - authenticate(): void { - this.receive('storyRendered', 'components-button--default'); - this.receive('furn:desktop:hello', runtimeHello); + this.hello(); + this.receive('storyRendered', request.storyId); } -} - -class FakeChannelServer implements StorybookChannelServer { - readonly clients = new Set(); - private readonly connectionListeners: ((client: FakeChannelClient) => void)[] = []; - - on(_event: 'connection', listener: (client: FakeChannelClient) => void): void { - this.connectionListeners.push(listener); + hello(): void { + this.receive('furn:desktop:hello', { + connectionEpoch: this.epoch, + endpoint: manifest.endpoint, + instanceId: manifest.instanceId, + nonce: manifest.bridgeNonce, + platformManifestDigest: manifest.platformManifestDigest, + targetId: manifest.targetId, + version: 2, + }); } - - connect(client: FakeChannelClient): void { - this.clients.add(client); - for (const listener of this.connectionListeners) { - listener(client); - } + ready(overrides: Record = {}): void { + this.receive('furn:desktop:story-ready', { + ...request, + connectionEpoch: this.epoch, + markerVersion: 1, + portablePlanDigest: manifest.portablePlanDigest, + previewGeneration: 1, + ...overrides, + }); } } -const storyManifest: DesktopStoryManifest = { - endpoint: 'windows', - entries: [ - { - id: 'components-button--default', - name: 'Default', - packageName: '@fluentui-react-native/components', - sourcePath: 'src/components/button/button.stories.tsx', - tags: ['story'], - title: 'Components/Button', - }, - ], - platformManifestDigest: 'platform-digest', - portablePlanDigest: 'portable-digest', - schemaVersion: 1, -}; - -const driverManifest: DesktopStorybookDriverManifest = { - application: { - leaseNonce: 'nonce', - leasePath: 'application-lease.json', - windowTitle: 'Agentic Components Storybook', - }, - appName: 'AgenticStorybook', +const request: StorySelectionRequest = { requestId: 'request', runId: 'run', storyId: 'button--default' }; +const manifest: DesktopStorybookDriverManifest = { + application: { leaseNonce: 'lease', leasePath: 'application-lease.json', windowTitle: 'Storybook' }, + appName: 'Storybook', bridgeNonce: 'nonce', - displayName: 'Agentic Components Storybook', + displayName: 'Storybook', driverPort: 4444, endpoint: 'windows', instanceId: 'instance', @@ -107,284 +78,613 @@ const driverManifest: DesktopStorybookDriverManifest = { sourceDigest: 'source', wireProtocol: { major: 1, minor: 0 }, }, - platformManifestDigest: storyManifest.platformManifestDigest, - portablePlanDigest: storyManifest.portablePlanDigest, + platformManifestDigest: 'platform', + portablePlanDigest: 'portable', renderer: 'fabric', schemaVersion: 2, - storyManifest, + storyManifest: { + endpoint: 'windows', + entries: [ + { id: request.storyId, name: 'Default', packageName: 'components', sourcePath: 'button.stories.tsx', tags: [], title: 'Button' }, + ], + platformManifestDigest: 'platform', + portablePlanDigest: 'portable', + schemaVersion: 1, + }, storybookPort: 7007, - targetId: 'agenticstorybook-windows', - testIDPrefix: 'agentic-storybook', -}; - -const runtimeHello = { - endpoint: 'windows', - instanceId: 'instance', - nonce: 'nonce', - platformManifestDigest: 'platform-digest', - targetId: 'agenticstorybook-windows', - version: 1, + targetId: 'target', + testIDPrefix: 'storybook', }; -describe('StorybookChannelOrchestrator', () => { +function setup( + fetch: typeof globalThis.fetch = jest.fn(async () => new Response('{}')), + timeoutMs = 100, + recovery?: BridgeEpochRecovery, + renderer: 'fabric' | 'paper' = 'fabric', + controlled = false, +) { + const server = Object.assign(new EventEmitter(), { clients: new Set() }); + const client = new Client(); + server.clients.add(client); const errorOutput = { write: jest.fn() }; - const createOrchestrator = (options: ConstructorParameters[0]) => - new StorybookChannelOrchestrator({ ...options, errorOutput }); - beforeEach(() => errorOutput.write.mockClear()); + const orchestrator = new StorybookChannelOrchestrator({ + channelServer: server, + driverManifest: { ...manifest, renderer }, + errorOutput, + fetch, + serverUrl: 'https://localhost', + timeoutMs, + recovery, + controlled, + }); + return { client, errorOutput, fetch, orchestrator, server }; +} - test('waits for the authenticated preview to initialize before navigating, including after an app restart', async () => { - const channelServer = new FakeChannelServer(); - const client = new FakeChannelClient(); - const unrelatedClient = new FakeChannelClient(); - channelServer.connect(client); - channelServer.connect(unrelatedClient); +describe('controlled channel endpoint boundary', () => { + test('HTTP and foreign WebSocket selection, prepare and args never reach the authenticated runtime', async () => { + const errors = jest.fn(); + const heartbeat = jest.spyOn(globalThis, 'setInterval'); + const channel = await createControlledChannelServer({ + host: '127.0.0.1', + port: 0, + configPath: 'unused', + buildIndex: async () => ({ entries: {} }), + onFailure: errors, + }); + const port = channel.channel.address().port; + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- exercise the real loopback HTTP boundary + const serverUrl = `http://127.0.0.1:${port}`; + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- exercise the real loopback WebSocket boundary + const socketUrl = `ws://127.0.0.1:${port}`; const fetch = jest.fn(async () => new Response('{}')); - const orchestrator = createOrchestrator({ - channelServer, - driverManifest, + const errorOutput = { write: jest.fn() }; + const orchestrator = new StorybookChannelOrchestrator({ + channelServer: channel.channel, + controlled: true, + driverManifest: manifest, fetch, - serverUrl: 'https://localhost', - timeoutMs: 1000, + serverUrl, + errorOutput, + timeoutMs: 2000, }); - - for (const [index, runtime] of [client, new FakeChannelClient()].entries()) { - if (index > 0) { - client.close(); - channelServer.connect(runtime); + const received: { type: string; args: Record[] }[] = []; + const runtime = new WebSocket(socketUrl); + runtime.addEventListener('message', (event) => received.push(JSON.parse(String(event.data)))); + const foreign = new WebSocket(socketUrl); + const replies: unknown[] = []; + foreign.addEventListener('message', (event) => replies.push(JSON.parse(String(event.data)))); + const waitFor = async (predicate: () => boolean) => { + const end = Date.now() + 2000; + while (!predicate() && Date.now() < end) await new Promise((resolve) => setTimeout(resolve, 5)); + expect(predicate()).toBe(true); + }; + try { + await waitFor(() => received.length > 0 && replies.length > 0); + const epoch = received[0].args[0].connectionEpoch; + runtime.send( + JSON.stringify({ + type: 'furn:desktop:hello', + args: [ + { + version: 2, + connectionEpoch: epoch, + nonce: manifest.bridgeNonce, + endpoint: manifest.endpoint, + instanceId: manifest.instanceId, + targetId: manifest.targetId, + platformManifestDigest: manifest.platformManifestDigest, + }, + ], + }), + ); + runtime.send(JSON.stringify({ type: 'storyRendered', args: [request.storyId] })); + const secret = 'private-argument-value'; + for (const pathname of ['/select-story-sync/button--default', '/send-event', '/mcp', '/unknown']) { + const response = await globalThis.fetch(`${serverUrl}${pathname}`, { + method: 'POST', + body: JSON.stringify({ type: 'updateStoryArgs', args: [{ updatedArgs: { text: secret } }] }), + }); + expect(response.status).toBe(403); + expect(await response.text()).not.toContain(secret); } - runtime.receive('furn:desktop:hello', runtimeHello); - const request = { requestId: `request-${index}`, runId: `run-${index}`, storyId: 'components-button--default' }; + for (const type of [ + 'setCurrentStory', + 'furn:desktop:prepare-story', + 'updateStoryArgs', + 'resetStoryArgs', + 'furn:desktop:cancel-story', + ]) { + foreign.send(JSON.stringify({ type, args: [{ ...request, connectionEpoch: epoch, updatedArgs: { text: secret } }] })); + } + foreign.send(JSON.stringify({ type: 'furn:desktop:request-challenge', args: [] })); + await waitFor(() => replies.length === 2); + expect(received).toHaveLength(1); + await expect(orchestrator.getCurrentStory()).resolves.toBeNull(); const selection = orchestrator.selectStory(request); - unrelatedClient.receive('storyRendered', request.storyId); - await Promise.resolve(); - expect(fetch).toHaveBeenCalledTimes(index); - expect(runtime.sent.some((message) => JSON.parse(message).type === 'furn:desktop:prepare-story')).toBe(false); - - runtime.receive('storyRendered', request.storyId); - await Promise.resolve(); - expect(fetch).toHaveBeenCalledTimes(index + 1); - runtime.receive('furn:desktop:story-ready', { - ...request, - portablePlanDigest: 'portable-digest', - previewGeneration: 1, - }); - await expect(selection).resolves.toMatchObject({ runId: request.runId }); + await waitFor(() => received.length === 3); + expect(received.slice(1).map(({ type }) => type)).toEqual(['furn:desktop:prepare-story', 'setCurrentStory']); + runtime.send( + JSON.stringify({ + type: 'furn:desktop:story-ready', + args: [ + { ...request, connectionEpoch: epoch, markerVersion: 1, portablePlanDigest: manifest.portablePlanDigest, previewGeneration: 1 }, + ], + }), + ); + await expect(selection).resolves.toMatchObject({ ...request, connectionEpoch: epoch }); + foreign.send(JSON.stringify({ type: 'updateStoryArgs', args: [{ storyId: request.storyId, updatedArgs: { text: secret } }] })); + foreign.send(JSON.stringify({ type: 'furn:desktop:request-challenge', args: [] })); + await waitFor(() => replies.length === 3); + await expect(orchestrator.getCurrentStory()).resolves.toMatchObject({ ...request, connectionEpoch: epoch }); + await expect(orchestrator.updateArgs(request.storyId, { text: secret })).rejects.toMatchObject({ effect: 'none' }); + expect(JSON.stringify(received)).not.toContain(secret); + expect(fetch).not.toHaveBeenCalled(); + expect(errors).not.toHaveBeenCalled(); + expect(errorOutput.write).not.toHaveBeenCalled(); + expect((await globalThis.fetch(`${serverUrl}/index.json`)).status).toBe(200); + expect(heartbeat).toHaveBeenCalledWith(expect.any(Function), 10_000); + heartbeat.mock.calls[0][0](); + await waitFor(() => received.length === 4); + expect(received[3]).toEqual({ type: 'ping' }); + } finally { + runtime.close(); + foreign.close(); + await channel.close(); + heartbeat.mockRestore(); } }); - test('reports the requested story, run and HTTP failure details to the server error output', async () => { - const channelServer = new FakeChannelServer(); - const client = new FakeChannelClient(); - channelServer.connect(client); - const orchestrator = createOrchestrator({ - channelServer, - driverManifest, - serverUrl: 'https://localhost', - timeoutMs: 100, - fetch: jest.fn(async () => new Response('preview module failed to load', { status: 500 })), - }); - + test('controlled cancellation fences the dispatched epoch and cannot be revived by delayed ready', async () => { + const { client, orchestrator, fetch } = setup(undefined, 100, undefined, 'fabric', true); client.authenticate(); - await expect( - orchestrator.selectStory({ requestId: 'failure', runId: 'pipeline-run', storyId: 'components-button--default' }), - ).rejects.toThrow('preview module failed to load'); - expect(errorOutput.write).toHaveBeenCalledWith(expect.stringContaining('navigation "components-button--default" (run pipeline-run)')); - expect(errorOutput.write).toHaveBeenCalledWith(expect.stringContaining('status 500: preview module failed to load')); + const controller = new AbortController(); + const selection = orchestrator.selectStory(request, { lifetimeId: 'owner', signal: controller.signal }); + controller.abort(); + await expect(selection).rejects.toHaveProperty('effect', 'unknown'); + client.ready(); + await expect(orchestrator.getCurrentStory()).resolves.toBeNull(); + await expect(orchestrator.resetStory({ ...request, requestId: 'next' })).rejects.toMatchObject({ + data: { reason: 'relaunch-required' }, + effect: 'none', + }); + expect(fetch).not.toHaveBeenCalled(); }); +}); - test('reset navigates from a different page, including after manual navigation away from the last prepared story', async () => { - const channelServer = new FakeChannelServer(); - const client = new FakeChannelClient(); - channelServer.connect(client); - let page = 'unrelated--story'; - let generation = 0; - const fetch = jest.fn(async (url: Parameters[0]) => { - page = decodeURIComponent(String(url).split('/').at(-1)!); - const request = JSON.parse(client.sent.at(-1)!).args[0]; - client.receive('furn:desktop:story-ready', { - ...request, - storyId: page, - previewGeneration: ++generation, - portablePlanDigest: 'portable-digest', +describe('durable authenticated bridge epochs', () => { + const application = { processId: 123, processStartedAt: '2026-11-06T00:00:00.000Z' }; + function recovery() { + const records = new Map(); + return { + records, + resolveApplication: jest.fn(async () => application), + probeApplication: jest.fn< + ReturnType>, + Parameters> + >(async () => 'running'), + storage: { + read: async (name: string) => records.get(name), + publish: async (name: string, value: string) => { + if (records.has(name)) throw new Error('Already published'); + records.set(name, value); + }, + remove: async (name: string, value: string) => records.get(name) === value && records.delete(name), + }, + }; + } + async function authenticate(state: ReturnType, store: ReturnType) { + state.client.authenticate(); + for (let attempt = 0; attempt < 20 && !store.records.has('bridge-epoch.json'); attempt++) + await new Promise((resolve) => setImmediate(resolve)); + expect(store.records.has('bridge-epoch.json')).toBe(true); + } + test.each(['paper', 'fabric'] as const)( + 'initial absence and cold start stay unavailable; %s restart requires relaunch', + async (renderer) => { + const store = recovery(); + const before = setup(undefined, 100, store, renderer); + await expect(before.orchestrator.selectStory(request)).rejects.toMatchObject({ + data: { reason: 'bridge-unavailable', renderer }, + effect: 'none', }); - return new Response('{}'); + expect(store.probeApplication).not.toHaveBeenCalled(); + await authenticate(before, store); + expect(JSON.parse(store.records.get('bridge-epoch.json')!)).toMatchObject({ + application, + endpoint: manifest.endpoint, + epoch: before.client.epoch, + renderer, + targetId: manifest.targetId, + }); + before.client.emit('close'); + const restarted = setup(undefined, 100, store, renderer); + await expect(restarted.orchestrator.selectStory(request)).rejects.toMatchObject({ + data: { reason: 'relaunch-required', renderer }, + effect: 'none', + }); + expect(store.probeApplication).toHaveBeenCalledWith(application); + expect(restarted.fetch).not.toHaveBeenCalled(); + }, + ); + test.each(['exited', 'replaced', 'unknown', 'failed'] as const)('%s incarnation never requires relaunch', async (mode) => { + const store = recovery(); + await authenticate(setup(undefined, 100, store), store); + if (mode === 'exited') store.probeApplication.mockResolvedValue('exited'); + if (mode === 'unknown') store.probeApplication.mockResolvedValue('unknown'); + if (mode === 'failed') store.probeApplication.mockRejectedValue(new Error('Probe failed')); + if (mode === 'replaced') store.resolveApplication.mockResolvedValue({ ...application, processStartedAt: '2026-11-06T01:00:00.000Z' }); + const restarted = setup(undefined, 100, store); + await expect(restarted.orchestrator.selectStory(request)).rejects.toMatchObject({ + data: { reason: 'bridge-unavailable' }, + effect: 'none', }); - const orchestrator = createOrchestrator({ - channelServer, - driverManifest, - fetch, - serverUrl: 'https://localhost', - timeoutMs: 100, + expect(store.records.size).toBe(mode === 'exited' || mode === 'replaced' ? 0 : 1); + if (mode === 'failed') expect(restarted.errorOutput.write).toHaveBeenCalled(); + expect(restarted.fetch).not.toHaveBeenCalled(); + }); + test('another target namespace and renderer are ignored; reauthentication supersedes the epoch', async () => { + const store = recovery(); + const first = setup(undefined, 100, store); + await authenticate(first, store); + const contents = store.records.get('bridge-epoch.json')!; + for (const change of [{ targetId: 'another-target' }, { endpoint: 'macos' }, { renderer: 'paper' }]) { + store.records.set('bridge-epoch.json', JSON.stringify({ ...JSON.parse(contents), ...change })); + await expect(setup(undefined, 100, store).orchestrator.selectStory(request)).rejects.toMatchObject({ + data: { reason: 'bridge-unavailable' }, + effect: 'none', + }); + } + expect(store.probeApplication).not.toHaveBeenCalled(); + store.records.set('bridge-epoch.json', contents); + const next = setup(undefined, 100, store); + next.client.authenticate(); + for (let attempt = 0; attempt < 20 && JSON.parse(store.records.get('bridge-epoch.json')!).epoch !== next.client.epoch; attempt++) + await new Promise((resolve) => setImmediate(resolve)); + expect(JSON.parse(store.records.get('bridge-epoch.json')!).epoch).toBe(next.client.epoch); + expect(next.client.epoch).not.toBe(first.client.epoch); + }); + test('a relaunched runtime awaiting its initial render is unavailable until storyRendered, then selectable', async () => { + const store = recovery(); + const state = setup(undefined, 100, store); + await authenticate(state, store); + state.client.readyState = 3; + state.client.emit('close'); + const replacement = new Client(); + state.server.emit('connection', replacement); + replacement.hello(); + for (let attempt = 0; attempt < 20 && JSON.parse(store.records.get('bridge-epoch.json')!).epoch !== replacement.epoch; attempt++) + await new Promise((resolve) => setImmediate(resolve)); + expect(JSON.parse(store.records.get('bridge-epoch.json')!).epoch).toBe(replacement.epoch); + await expect(state.orchestrator.selectStory(request)).rejects.toMatchObject({ + data: { reason: 'bridge-unavailable' }, + effect: 'none', }); - client.authenticate(); - - await expect( - orchestrator.resetStory({ requestId: 'first', runId: 'first', storyId: 'components-button--default' }), - ).resolves.toMatchObject({ storyId: 'components-button--default', previewGeneration: 1 }); - expect(page).toBe('components-button--default'); - page = 'manually-selected--story'; - await expect( - orchestrator.resetStory({ requestId: 'second', runId: 'second', storyId: 'components-button--default' }), - ).resolves.toMatchObject({ storyId: 'components-button--default', previewGeneration: 2 }); - expect(page).toBe('components-button--default'); - expect(fetch).toHaveBeenCalledTimes(2); + expect(replacement.sent).toHaveLength(1); + replacement.receive('storyRendered', request.storyId); + const selection = state.orchestrator.selectStory(request); + await new Promise((resolve) => setImmediate(resolve)); + replacement.ready({ connectionEpoch: replacement.epoch }); + await expect(selection).resolves.toMatchObject({ connectionEpoch: replacement.epoch }); + }); + test('storage and verified-identity failures never enable dispatch', async () => { + const store = recovery(); + store.resolveApplication.mockRejectedValue(new Error('Identity unavailable')); + const state = setup(undefined, 100, store); + state.client.authenticate(); + await expect(state.orchestrator.selectStory(request)).rejects.toMatchObject({ + data: { reason: 'bridge-unavailable' }, + effect: 'none', + }); + expect(store.records.size).toBe(0); + expect(state.fetch).not.toHaveBeenCalled(); + expect(state.errorOutput.write).toHaveBeenCalled(); }); + test('a published epoch enables selection while cancellation during identity verification never dispatches', async () => { + const store = recovery(); + const state = setup(undefined, 100, store); + await authenticate(state, store); + const selection = state.orchestrator.selectStory(request); + await new Promise((resolve) => setImmediate(resolve)); + state.client.ready(); + await expect(selection).resolves.toMatchObject({ connectionEpoch: state.client.epoch }); - test('bounds navigation even when the runtime is ready but the HTTP selection request never completes', async () => { - const channelServer = new FakeChannelServer(); - const client = new FakeChannelClient(); - channelServer.connect(client); - let signal: AbortSignal | null | undefined; - const fetch = jest.fn((_url: Parameters[0], options?: RequestInit) => { - signal = options?.signal; - const request = JSON.parse(client.sent.at(-1)!).args[0]; - client.receive('furn:desktop:story-ready', { - ...request, - portablePlanDigest: 'portable-digest', - previewGeneration: 1, - }); - return new Promise(() => {}); + let resolveIdentity!: (value: typeof application) => void; + store.resolveApplication.mockImplementationOnce(() => new Promise((resolve) => (resolveIdentity = resolve))); + const next = setup(undefined, 100, store); + next.client.authenticate(); + const controller = new AbortController(); + const cancelled = next.orchestrator.selectStory(request, { signal: controller.signal, lifetimeId: 'cancelled' }); + controller.abort(); + await new Promise((resolve) => setImmediate(resolve)); + resolveIdentity(application); + await expect(cancelled).rejects.toHaveProperty('effect', 'none'); + expect(next.fetch).not.toHaveBeenCalled(); + }); + test('a failed publication and corrupt stored record are logged without dispatch', async () => { + const store = recovery(); + store.storage.publish = async () => { + throw new Error('Storage write failed'); + }; + const state = setup(undefined, 100, store); + state.client.authenticate(); + await expect(state.orchestrator.selectStory(request)).rejects.toMatchObject({ + data: { reason: 'bridge-unavailable' }, + effect: 'none', }); - const orchestrator = createOrchestrator({ - channelServer, - driverManifest, - fetch, - serverUrl: 'https://localhost', - timeoutMs: 10, + expect(state.errorOutput.write).toHaveBeenCalled(); + expect(state.fetch).not.toHaveBeenCalled(); + store.records.set('bridge-epoch.json', '{"kind":"bridge-epoch"}'); + const restarted = setup(undefined, 100, store); + await expect(restarted.orchestrator.selectStory(request)).rejects.toMatchObject({ + data: { reason: 'bridge-unavailable' }, + effect: 'none', }); - client.authenticate(); - await expect(orchestrator.selectStory({ requestId: 'request', runId: 'run', storyId: 'components-button--default' })).rejects.toThrow( - 'Timed out navigating', - ); - expect(signal?.aborted).toBe(true); + expect(restarted.errorOutput.write).toHaveBeenCalled(); + expect(store.probeApplication).not.toHaveBeenCalled(); + expect(restarted.fetch).not.toHaveBeenCalled(); }); +}); - test('authenticates the runtime and correlates selection readiness', async () => { - const channelServer = new FakeChannelServer(); - const client = new FakeChannelClient(); - channelServer.connect(client); - const orchestrator = createOrchestrator({ - channelServer, - driverManifest, - fetch: jest.fn(async () => new Response('{}', { status: 408 })), - // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- test-only loopback Storybook URL - serverUrl: 'http://127.0.0.1:7007', - timeoutMs: 1000, +describe('StorybookChannelOrchestrator epochs and lifetime', () => { + test('absent and hello-only bridges reject before dispatch with no effect', async () => { + const { client, fetch, orchestrator } = setup(); + await expect(orchestrator.selectStory(request)).rejects.toMatchObject({ + data: { endpoint: 'windows', reason: 'bridge-unavailable', renderer: 'fabric' }, + effect: 'none', }); + client.receive('furn:desktop:hello', { + connectionEpoch: client.epoch, + endpoint: manifest.endpoint, + instanceId: manifest.instanceId, + nonce: manifest.bridgeNonce, + platformManifestDigest: manifest.platformManifestDigest, + targetId: manifest.targetId, + version: 2, + }); + await expect(orchestrator.selectStory(request)).rejects.toHaveProperty('effect', 'none'); + expect(fetch).not.toHaveBeenCalled(); + client.receive('storyRendered', request.storyId); + const selection = orchestrator.selectStory(request); + client.ready(); + await expect(selection).resolves.toMatchObject({ connectionEpoch: client.epoch, markerVersion: 1, ...request }); + }); + + test('requires the nonce and current epoch on the same initialized socket', async () => { + const { client, fetch, orchestrator, server } = setup(); + const other = new Client(); + server.emit('connection', other); + client.receive('storyRendered', request.storyId); + other.authenticate(); + client.receive('furn:desktop:hello', { version: 2, connectionEpoch: client.epoch, nonce: 'wrong' }); + const selection = orchestrator.selectStory(request); + client.ready(); + other.ready({ connectionEpoch: client.epoch }); + other.ready(); + await expect(selection).resolves.toMatchObject({ connectionEpoch: other.epoch }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(client.sent).toHaveLength(1); + }); + + test('ignores old ready and errors, and commits current context only after HTTP completion', async () => { + let finish!: (response: Response) => void; + const { client, orchestrator } = setup( + jest.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ), + ); client.authenticate(); - expect(JSON.parse(client.sent[0])).toEqual({ type: 'furn:desktop:request-hello', args: [] }); - client.sent.length = 0; + const selection = orchestrator.selectStory(request); + client.ready({ connectionEpoch: 'old-epoch' }); + client.receive('furn:desktop:story-error', { ...request, connectionEpoch: 'old-epoch' }); + client.ready({ requestId: 'old-request' }); + client.ready({ markerVersion: undefined }); + await expect(orchestrator.getCurrentStory()).resolves.toBeNull(); + client.ready(); + await expect(orchestrator.getCurrentStory()).resolves.toBeNull(); + finish(new Response('{}')); + await expect(selection).resolves.toMatchObject({ runId: request.runId }); + const second = orchestrator.resetStory({ ...request, requestId: 'next', runId: 'next' }); + client.ready(); + await expect(orchestrator.getCurrentStory()).resolves.toBeNull(); + client.ready({ requestId: 'next', runId: 'next', previewGeneration: 2 }); + finish(new Response('{}', { status: 408 })); + await expect(second).resolves.toMatchObject({ previewGeneration: 2, requestId: 'next' }); + }); - const selection = orchestrator.selectStory({ - requestId: 'request-1', - runId: 'run-1', - storyId: 'components-button--default', + test('disconnect promptly fails pending navigation, aborts HTTP and rejects the closed socket', async () => { + let signal: AbortSignal | undefined; + const { client, orchestrator, server } = setup( + jest.fn((_url, init) => { + signal = init?.signal ?? undefined; + return new Promise(() => {}); + }), + 10_000, + ); + client.authenticate(); + const selection = orchestrator.selectStory(request); + client.ready(); + client.readyState = 3; + client.emit('close'); + await expect(selection).rejects.toMatchObject({ effect: 'unknown', message: expect.stringContaining('disconnected') }); + expect(signal?.aborted).toBe(true); + await expect(orchestrator.getCurrentStory()).resolves.toBeNull(); + await expect(orchestrator.selectStory(request)).rejects.toMatchObject({ + data: { reason: 'relaunch-required', endpoint: 'windows', renderer: 'fabric' }, + effect: 'none', }); - await Promise.resolve(); - expect(JSON.parse(client.sent[0])).toEqual({ - type: 'furn:desktop:prepare-story', - args: [{ requestId: 'request-1', runId: 'run-1', storyId: 'components-button--default' }], + const replacement = new Client(); + server.emit('connection', replacement); + replacement.hello(); + // A hello-only replacement awaits its initial render; it is unavailable rather than fenced. + await expect(orchestrator.selectStory(request)).rejects.toMatchObject({ + data: { reason: 'bridge-unavailable', endpoint: 'windows', renderer: 'fabric' }, + effect: 'none', }); - client.receive('furn:desktop:story-ready', { - portablePlanDigest: 'portable-digest', - previewGeneration: 1, - requestId: 'request-1', - runId: 'run-1', - storyId: 'components-button--default', + expect(replacement.sent).toHaveLength(1); + replacement.receive('storyRendered', request.storyId); + expect(replacement.epoch).not.toBe(client.epoch); + // The old socket can neither authenticate again nor replace the new epoch. + client.authenticate(); + client.ready(); + await expect(orchestrator.getCurrentStory()).resolves.toBeNull(); + }); + + test('a reloaded runtime that authenticates before the old socket closes retires the old bridge', async () => { + const { client, fetch, orchestrator, server } = setup( + jest.fn(() => new Promise(() => {})), + 10_000, + ); + client.authenticate(); + const inFlight = orchestrator.selectStory(request); + const replacement = new Client(); + server.emit('connection', replacement); + replacement.hello(); + await expect(inFlight).rejects.toMatchObject({ effect: 'unknown', message: expect.stringContaining('replaced') }); + await expect(orchestrator.getCurrentStory()).resolves.toBeNull(); + // As after a close, the replacement awaits its initial render; nothing reaches either socket meanwhile. + await expect(orchestrator.selectStory({ ...request, requestId: 'after' })).rejects.toMatchObject({ + data: { reason: 'bridge-unavailable' }, + effect: 'none', }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(replacement.sent).toHaveLength(1); + expect(client.sent.filter(({ type }) => type === 'furn:desktop:prepare-story')).toHaveLength(1); - await expect(selection).resolves.toEqual({ - previewGeneration: 1, - runId: 'run-1', - storyId: 'components-button--default', + const idle = setup(); + idle.client.authenticate(); + const reloaded = new Client(); + idle.server.emit('connection', reloaded); + reloaded.hello(); + // Nothing was pending on the old socket: the replacement is only awaiting its initial render. + await expect(idle.orchestrator.selectStory(request)).rejects.toMatchObject({ + data: { reason: 'bridge-unavailable' }, + effect: 'none', }); - await expect(orchestrator.getCurrentStory()).resolves.toMatchObject({ runId: 'run-1' }); + // The still-open old socket can neither reclaim the bridge nor complete work. + idle.client.hello(); + reloaded.receive('storyRendered', request.storyId); + const selection = idle.orchestrator.selectStory(request); + await new Promise((resolve) => setImmediate(resolve)); + idle.client.ready({ connectionEpoch: reloaded.epoch }); + reloaded.ready({ connectionEpoch: reloaded.epoch }); + await expect(selection).resolves.toMatchObject({ connectionEpoch: reloaded.epoch }); + expect(idle.client.sent).toHaveLength(1); }); - test('observes render errors while the selection request is still pending', async () => { - const channelServer = new FakeChannelServer(); - const client = new FakeChannelClient(); - const unauthenticatedClient = new FakeChannelClient(); - channelServer.connect(client); - channelServer.connect(unauthenticatedClient); - let finishFetch: ((response: Response) => void) | undefined; - const fetch = jest.fn(() => new Promise((resolve) => (finishFetch = resolve))); - const orchestrator = createOrchestrator({ - channelServer, - driverManifest, - fetch, - // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- test-only loopback Storybook URL - serverUrl: 'http://127.0.0.1:7007', - timeoutMs: 1000, - }); + test('cancellation before dispatch has no effect; after dispatch fences the epoch until relaunch', async () => { + const controller = new AbortController(); + const { client, fetch, orchestrator } = setup( + jest.fn(() => new Promise(() => {})), + 10_000, + ); client.authenticate(); - - const selection = orchestrator.selectStory({ - requestId: 'request-error', - runId: 'run-error', - storyId: 'components-button--default', + controller.abort(); + await expect(orchestrator.selectStory(request, { lifetimeId: 'session', signal: controller.signal })).rejects.toHaveProperty( + 'effect', + 'none', + ); + expect(fetch).not.toHaveBeenCalled(); + const live = new AbortController(); + const selection = orchestrator.selectStory(request, { lifetimeId: 'session', signal: live.signal }); + live.abort(); + await expect(selection).rejects.toHaveProperty('effect', 'unknown'); + expect(client.sent.at(-1)).toMatchObject({ type: 'furn:desktop:cancel-story' }); + client.authenticate(); + client.ready(); + await expect(orchestrator.selectStory({ ...request, requestId: 'new' })).rejects.toMatchObject({ + data: { reason: 'relaunch-required' }, + effect: 'none', }); - await Promise.resolve(); - let settled = false; - void selection.then( - () => { - settled = true; - }, - () => { - settled = true; - }, + }); + + test('a ready event cannot disable the local HTTP deadline', async () => { + const { client, orchestrator } = setup( + jest.fn(() => new Promise(() => {})), + 10, ); - const errorPayload = { - message: 'render failed', - requestId: 'request-error', - runId: 'run-error', - storyId: 'components-button--default', - }; - unauthenticatedClient.receive('furn:desktop:story-error', errorPayload); - await Promise.resolve(); - expect(settled).toBe(false); - client.receive('furn:desktop:story-error', errorPayload); - await expect(selection).rejects.toThrow('render failed'); - finishFetch?.(new Response('{}')); + client.authenticate(); + const selection = orchestrator.selectStory(request); + client.ready(); + await expect(selection).rejects.toMatchObject({ effect: 'unknown', message: expect.stringContaining('Timed out') }); }); - test('rejects stories outside the exact platform manifest', async () => { - const orchestrator = createOrchestrator({ - channelServer: new FakeChannelServer(), - driverManifest, - fetch: jest.fn(), - // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- test-only loopback Storybook URL - serverUrl: 'http://127.0.0.1:7007', - timeoutMs: 10, - }); + test('render errors stay correlated and HTTP errors retain diagnostic detail', async () => { + const { client, orchestrator } = setup(jest.fn(() => new Promise(() => {}))); + client.authenticate(); + const selection = orchestrator.selectStory(request); + client.receive('furn:desktop:story-error', { ...request, connectionEpoch: client.epoch }); + await expect(selection).rejects.toThrow('failed to render'); + const failed = setup(jest.fn(async () => new Response('module failed to load', { status: 500 }))); + failed.client.authenticate(); + await expect(failed.orchestrator.selectStory(request)).rejects.toThrow('status 500: module failed to load'); + expect(failed.errorOutput.write).toHaveBeenCalledWith(expect.stringContaining('module failed to load')); + }); - await expect(orchestrator.selectStory({ requestId: 'request', runId: 'run', storyId: 'missing--story' })).rejects.toThrow( - 'not present', - ); + test('rejects unknown stories without dispatch', async () => { + const { fetch, orchestrator } = setup(); + await expect(orchestrator.selectStory({ ...request, storyId: 'missing' })).rejects.toHaveProperty('effect', 'none'); + expect(fetch).not.toHaveBeenCalled(); }); - test('cancels reset readiness when the authenticated bridge closes', async () => { - const channelServer = new FakeChannelServer(); - const client = new FakeChannelClient(); - channelServer.connect(client); - const orchestrator = createOrchestrator({ - channelServer, - driverManifest, - fetch: jest.fn(), - // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- test-only loopback Storybook URL - serverUrl: 'http://127.0.0.1:7007', - timeoutMs: 10, + test('malformed messages cannot crash the bridge; duplicate IDs cannot accept an earlier ready result', async () => { + const { client, orchestrator } = setup(); + for (const raw of ['null', '[]', '{}', '{"args":null}', '{"args":true}', '{']) client.emit('message', raw); + client.authenticate(); + const selection = orchestrator.selectStory(request); + client.ready(); + await selection; + await expect(orchestrator.selectStory(request)).rejects.toHaveProperty('effect', 'none'); + client.receive('furn:desktop:story-error', { ...request, connectionEpoch: client.epoch }); + await expect(orchestrator.getCurrentStory()).resolves.toBeNull(); + await expect(orchestrator.selectStory({ ...request, requestId: 'new' })).rejects.toMatchObject({ + data: { reason: 'relaunch-required' }, + effect: 'none', }); + }); + + test('a failing cancellation send cannot replace unknown-effect failure evidence', async () => { + const { client, errorOutput, orchestrator } = setup(jest.fn(() => new Promise(() => {}))); client.authenticate(); - client.readyState = 3; + const controller = new AbortController(); + const selection = orchestrator.selectStory(request, { lifetimeId: 'session', signal: controller.signal }); + client.send = () => { + throw new Error('Socket closed during cancel.'); + }; + controller.abort(); + await expect(selection).rejects.toHaveProperty('effect', 'unknown'); + expect(errorOutput.write).toHaveBeenCalledWith(expect.stringContaining('Socket closed during cancel.')); + }); - await expect( - orchestrator.resetStory({ - requestId: 'request-reset', - runId: 'run-reset', - storyId: 'components-button--default', - }), - ).rejects.toThrow('not connected'); - await new Promise((resolve) => setTimeout(resolve, 20)); + test('actual data-only runtime marker producer conforms to the driver verifier', () => { + const filename = resolve(__dirname, '../../../storybook-desktop-runtime/src/storyMarker.ts'); + const source = readFileSync(filename, 'utf8'); + const code = transformSync(source, { + babelrc: false, + configFile: false, + filename, + presets: ['module:@react-native/babel-preset'], + })?.code; + if (!code) throw new Error('Marker source did not compile.'); + const exports: { createStoryMarker?: (selection: Record) => Record } = {}; + // No require, Node, React, or harness globals are available to the on-device producer. + runInNewContext(code, { exports }); + const expected = { ...request, connectionEpoch: 'epoch', markerVersion: 1 as const, previewGeneration: 3 }; + const produced = exports.createStoryMarker!(expected); + expect(matchesStoryMarker(JSON.stringify(produced), expected)).toBe(true); + for (const patch of [ + { connectionEpoch: 'old' }, + { requestId: 'old' }, + { runId: 'old' }, + { storyId: 'old' }, + { previewGeneration: 2 }, + { markerVersion: 2 }, + ]) + expect(matchesStoryMarker(JSON.stringify({ ...produced, ...patch }), expected)).toBe(false); + expect(matchesStoryMarker(JSON.stringify(produced), { ...expected, connectionEpoch: undefined })).toBe(false); + expect(matchesStoryMarker(JSON.stringify(produced), { ...expected, requestId: undefined })).toBe(false); + expect(matchesStoryMarker(JSON.stringify(produced), { ...expected, markerVersion: undefined })).toBe(false); }); }); diff --git a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts index 52be51a550..2b158a00f6 100644 --- a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts +++ b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts @@ -1,18 +1,19 @@ +import { randomUUID } from 'node:crypto'; + import type { DesktopStoryManifest, StoryOrchestrator, + StoryProviderContext, StoryReadyResult, StorySelectionRequest, -} from '@fluentui-react-native/desktop-driver'; +} from '@fluentui-react-native/desktop-driver/storybook'; +import { StoryBridgeUnavailableError, StoryProviderError } from '@fluentui-react-native/desktop-driver/storybook'; import type { DesktopStorybookDriverManifest } from './driverManifest.js'; +import { BridgeEpochJournal, type BridgeEpochRecovery } from './BridgeEpochJournal.js'; import { writeDesktopStorybookFailure, type DesktopStorybookErrorOutput } from '../../config/diagnostics.cjs'; -type ChannelMessage = { - args?: unknown[]; - type?: string; -}; - +type ChannelMessage = { args?: unknown[]; type?: string }; type ChannelClient = { readyState: number; on(event: 'close', listener: () => void): void; @@ -32,13 +33,16 @@ export type StorybookChannelOrchestratorOptions = { serverUrl: string; timeoutMs?: number; errorOutput?: DesktopStorybookErrorOutput; + recovery?: BridgeEpochRecovery; + /** Exclusive transport: dispatch only to the authenticated runtime, never upstream HTTP. */ + controlled?: boolean; }; type PendingSelection = { + epoch: string; reject(error: Error): void; request: StorySelectionRequest; resolve(result: StoryReadyResult): void; - timer: ReturnType; }; export class StorybookChannelOrchestrator implements StoryOrchestrator { @@ -47,11 +51,19 @@ export class StorybookChannelOrchestrator implements StoryOrchestrator { private readonly serverUrl: string; private readonly timeoutMs: number; private readonly errorOutput: DesktopStorybookErrorOutput; + private readonly controlled: boolean; private readonly pending = new Map(); - private readonly bridgeWaiters = new Set<() => void>(); + private readonly epochs = new WeakMap(); + private readonly usedRequests = new WeakMap>(); private readonly initializedClients = new WeakSet(); - private bridgeConnected = false; + private readonly fencedClients = new WeakSet(); + private readonly recordedClients = new WeakSet(); + private readonly recordings = new WeakMap>(); + private readonly journal?: BridgeEpochJournal; private bridgeClient?: ChannelClient; + private bridgeConnected = false; + private needsRelaunch = false; + private selectionSequence = 0; private currentStory: StoryReadyResult | null = null; constructor({ @@ -61,91 +73,155 @@ export class StorybookChannelOrchestrator implements StoryOrchestrator { serverUrl, timeoutMs = 30_000, errorOutput = process.stderr, + recovery, + controlled = driverManifest.agent !== undefined, }: StorybookChannelOrchestratorOptions) { this.driverManifest = driverManifest; this.fetch = fetch; this.serverUrl = serverUrl.replace(/\/$/, ''); this.timeoutMs = timeoutMs; this.errorOutput = errorOutput; + this.controlled = driverManifest.agent !== undefined || controlled; + this.journal = recovery + ? new BridgeEpochJournal(recovery, { + endpoint: driverManifest.endpoint, + renderer: driverManifest.renderer, + targetId: driverManifest.targetId, + }) + : undefined; channelServer.on('connection', (client) => this.attachClient(client)); - for (const client of channelServer.clients) { - this.attachClient(client); - } + for (const client of channelServer.clients) this.attachClient(client); } - async getManifest(): Promise { + async getManifest(context?: StoryProviderContext): Promise { + context?.signal.throwIfAborted(); return this.driverManifest.storyManifest; } - async getCurrentStory(): Promise { - return this.currentStory; + async getCurrentStory(context?: StoryProviderContext): Promise { + context?.signal.throwIfAborted(); + return this.bridgeConnected ? this.currentStory : null; } - async selectStory(request: StorySelectionRequest): Promise { + async selectStory(request: StorySelectionRequest, context?: StoryProviderContext): Promise { try { - return await this.selectAndPrepareStory(request); + return await this.selectAndPrepareStory(request, context); } catch (error) { writeDesktopStorybookFailure(`navigation "${request.storyId}" (run ${request.runId})`, error, this.errorOutput); throw error; } } - private async selectAndPrepareStory(request: StorySelectionRequest): Promise { + private async selectAndPrepareStory(request: StorySelectionRequest, context?: StoryProviderContext): Promise { this.requireStory(request.storyId); - await this.waitForBridge(); - const ready = this.waitForReady(request); - void ready.catch(() => undefined); + if (context?.signal.aborted) throw new StoryProviderError('Story selection was cancelled before dispatch.', 'none'); + if (this.journal) await this.checkBridgeRecovery(); + if (context?.signal.aborted) throw new StoryProviderError('Story selection was cancelled before dispatch.', 'none'); + const client = this.requireBridge(); + const epoch = this.epochs.get(client)!; + if (this.pending.size) throw new StoryProviderError('A Storybook selection is already pending.', 'none'); + let used = this.usedRequests.get(client); + if (!used) { + used = new Set(); + this.usedRequests.set(client, used); + } + if (used.has(request.requestId)) throw new StoryProviderError('Story request IDs cannot be reused within a connection epoch.', 'none'); + used.add(request.requestId); const controller = new AbortController(); + let dispatched = false; let timer: ReturnType | undefined; + let stop!: (error: Error) => void; + const failure = new Promise((_resolve, reject) => { + stop = reject; + }); + void failure.catch(() => undefined); + const ready = new Promise((resolve) => { + this.pending.set(request.requestId, { epoch, reject: stop, request, resolve }); + }); + const onAbort = () => stop(new Error('Story selection was cancelled.')); + context?.signal.addEventListener('abort', onAbort, { once: true }); try { - this.broadcast('furn:desktop:prepare-story', request); - const navigation = this.fetch(`${this.serverUrl}/select-story-sync/${encodeURIComponent(request.storyId)}`, { - method: 'POST', - signal: controller.signal, - }).then(async (response) => { - if (!response.ok && response.status !== 408) { - const detail = await response.text(); - throw new Error(`Storybook failed to select "${request.storyId}" with status ${response.status}${detail ? `: ${detail}` : '.'}`); - } - }); - return await Promise.race([ - Promise.all([navigation, ready]).then(([, result]) => result), - new Promise((_resolve, reject) => { - timer = setTimeout(() => reject(new Error(`Timed out navigating to Storybook story "${request.storyId}".`)), this.timeoutMs); + timer = setTimeout(() => stop(new Error('Timed out navigating to the Storybook story.')), this.timeoutMs); + this.currentStory = null; + dispatched = true; + client.send( + JSON.stringify({ + type: 'furn:desktop:prepare-story', + args: [{ ...request, connectionEpoch: epoch, selectionSequence: ++this.selectionSequence }], }), - ]); + ); + const navigation = this.controlled + ? Promise.resolve(client.send(JSON.stringify({ type: 'setCurrentStory', args: [{ storyId: request.storyId, viewMode: 'story' }] }))) + : this.fetch(`${this.serverUrl}/select-story-sync/${encodeURIComponent(request.storyId)}`, { + method: 'POST', + signal: controller.signal, + }).then(async (response) => { + if (!response.ok && response.status !== 408) { + const detail = await response.text(); + throw new Error( + `Storybook failed to select "${request.storyId}" with status ${response.status}${detail ? `: ${detail}` : '.'}`, + ); + } + }); + const [, result] = await Promise.race([Promise.all([navigation, ready]), failure]); + this.currentStory = result; + return result; + } catch (error) { + this.currentStory = null; + if (dispatched) { + // A dispatched setCurrentStory may still render after cancellation, on either transport. + this.needsRelaunch = true; + this.bridgeConnected = false; + this.fencedClients.add(client); + if (client.readyState === 1) { + try { + client.send(JSON.stringify({ type: 'furn:desktop:cancel-story', args: [{ ...request, connectionEpoch: epoch }] })); + } catch (cancelError) { + writeDesktopStorybookFailure('cancelling an uncertain selection', cancelError, this.errorOutput); + } + } + } + throw new StoryProviderError(error instanceof Error ? error.message : 'Story selection failed.', dispatched ? 'unknown' : 'none'); } finally { clearTimeout(timer); controller.abort(); - this.cancelPending(request.requestId); + context?.signal.removeEventListener('abort', onAbort); + this.pending.delete(request.requestId); } } - resetStory(request: StorySelectionRequest): Promise { - return this.selectStory(request); + resetStory(request: StorySelectionRequest, context?: StoryProviderContext): Promise { + return this.selectStory(request, context); } - async updateArgs(storyId: string, args: Readonly>): Promise { + async updateArgs(storyId: string, args: Readonly>, context?: StoryProviderContext): Promise { + if (this.controlled) + throw new StoryProviderError('story-args-unsupported: Story args are unsupported on a controlled Storybook service.', 'none'); + if (context?.signal.aborted) throw new StoryProviderError('Story args update was cancelled before dispatch.', 'none'); this.requireStory(storyId); - this.broadcast('updateStoryArgs', { storyId, updatedArgs: args }); + if (this.journal) await this.checkBridgeRecovery(); + if (context?.signal.aborted) throw new StoryProviderError('Story args update was cancelled before dispatch.', 'none'); + const client = this.requireBridge(); + this.currentStory = null; + client.send(JSON.stringify({ type: 'updateStoryArgs', args: [{ storyId, updatedArgs: args }] })); } private attachClient(client: ChannelClient): void { + const epoch = randomUUID(); + this.epochs.set(client, epoch); client.on('message', (data) => this.onMessage(client, data)); client.on('close', () => { - if (this.bridgeClient === client) { - this.bridgeClient = undefined; - this.bridgeConnected = false; - this.currentStory = null; - } + this.epochs.delete(client); this.initializedClients.delete(client); + if (this.bridgeClient === client) this.retireBridge(client, 'The authenticated Storybook runtime bridge disconnected.'); }); if (client.readyState === 1) { - client.send(JSON.stringify({ type: 'furn:desktop:request-hello', args: [] })); + client.send(JSON.stringify({ type: 'furn:desktop:request-hello', args: [{ connectionEpoch: epoch, version: 2 }] })); } } private onMessage(client: ChannelClient, data: unknown): void { + if (!this.epochs.has(client) || client.readyState !== 1) return; let message: ChannelMessage; try { const text = typeof data === 'string' ? data : Buffer.isBuffer(data) ? data.toString('utf8') : String(data); @@ -153,166 +229,178 @@ export class StorybookChannelOrchestrator implements StoryOrchestrator { } catch { return; } - const payload = message.args?.[0]; - if (message.type === 'storyRendered' && typeof payload === 'string') { - this.initializedClients.add(client); - this.resolveBridgeWaiters(client); + if (!message || typeof message !== 'object' || !Array.isArray(message.args)) return; + const payload = message.args[0]; + if (message.type === 'furn:desktop:request-challenge') { + client.send( + JSON.stringify({ + type: 'furn:desktop:request-hello', + args: [{ connectionEpoch: this.epochs.get(client), version: 2 }], + }), + ); return; } - if (!payload || typeof payload !== 'object') { + if (message.type === 'storyRendered' && typeof payload === 'string') { + this.initializedClients.add(client); + this.initializeBridge(client); return; } - if (message.type === 'furn:desktop:hello') { - this.acceptHello(client, payload as Record); - } else if (message.type === 'furn:desktop:story-ready') { - this.acceptReady(client, payload as Record); - } else if (message.type === 'furn:desktop:story-error') { - this.acceptError(client, payload as Record); - } + if (!payload || typeof payload !== 'object') return; + const fields = payload as Record; + if (message.type === 'furn:desktop:hello') this.acceptHello(client, fields); + else if (message.type === 'furn:desktop:story-ready') this.acceptReady(client, fields); + else if (message.type === 'furn:desktop:story-error') this.acceptError(client, fields); } private acceptHello(client: ChannelClient, payload: Record): void { const expected = this.driverManifest; if ( - payload.version !== 1 || + this.fencedClients.has(client) || + payload.version !== 2 || + payload.connectionEpoch !== this.epochs.get(client) || payload.instanceId !== expected.instanceId || payload.endpoint !== expected.endpoint || payload.targetId !== expected.targetId || payload.platformManifestDigest !== expected.platformManifestDigest || payload.nonce !== expected.bridgeNonce - ) { + ) return; - } - if (this.bridgeClient && this.bridgeClient !== client) { - return; - } + // A runtime reload can authenticate its new socket before the old socket reports close; the old context is lost either way. + if (this.bridgeClient && this.bridgeClient !== client) + this.retireBridge(this.bridgeClient, 'The authenticated Storybook runtime bridge was replaced by a new runtime connection.'); this.bridgeClient = client; - this.resolveBridgeWaiters(client); + if (this.journal) this.recordBridge(client); + this.initializeBridge(client); } - private resolveBridgeWaiters(client: ChannelClient): void { - // The bridge can authenticate before Storybook installs its navigation handlers. - if (client !== this.bridgeClient || !this.initializedClients.has(client)) { - return; - } - this.bridgeConnected = true; - for (const resolve of this.bridgeWaiters) { - resolve(); - } - this.bridgeWaiters.clear(); + private retireBridge(client: ChannelClient, reason: string): void { + this.fencedClients.add(client); + this.bridgeClient = undefined; + this.bridgeConnected = false; + this.needsRelaunch = true; + this.currentStory = null; + for (const pending of this.pending.values()) pending.reject(new Error(reason)); } - private acceptReady(client: ChannelClient, payload: Record): void { - if (client !== this.bridgeClient) { - return; - } - const requestId = payload.requestId; - if ( - typeof requestId !== 'string' || - typeof payload.runId !== 'string' || - typeof payload.storyId !== 'string' || - typeof payload.previewGeneration !== 'number' || - payload.portablePlanDigest !== this.driverManifest.portablePlanDigest - ) { - return; - } - const pending = this.pending.get(requestId); - if (!pending) { - return; - } - if (payload.runId !== pending.request.runId || payload.storyId !== pending.request.storyId) { - return; - } - const result = { - previewGeneration: payload.previewGeneration, - runId: payload.runId, - storyId: payload.storyId, - }; - clearTimeout(pending.timer); - this.pending.delete(requestId); - this.currentStory = result; - pending.resolve(result); + private recordBridge(client: ChannelClient): Promise { + const existing = this.recordings.get(client); + if (existing) return existing; + const epoch = this.epochs.get(client); + if (!this.journal || !epoch) return Promise.resolve(); + const recording = this.journal.record(epoch).then(() => { + this.recordedClients.add(client); + this.initializeBridge(client); + }); + this.recordings.set(client, recording); + void recording.catch((error: unknown) => { + this.recordings.delete(client); + writeDesktopStorybookFailure('recording the authenticated bridge epoch', error, this.errorOutput); + }); + return recording; } - private acceptError(client: ChannelClient, payload: Record): void { - if ( - client !== this.bridgeClient || - typeof payload.requestId !== 'string' || - typeof payload.runId !== 'string' || - typeof payload.storyId !== 'string' - ) { + private async checkBridgeRecovery(): Promise { + const client = this.bridgeClient; + if (client && client.readyState === 1 && !this.fencedClients.has(client)) { + try { + await this.recordBridge(client); + } catch { + // The recording path reports the storage/identity failure; it must never enable dispatch. + throw new StoryBridgeUnavailableError(this.driverManifest.endpoint, this.driverManifest.renderer, 'bridge-unavailable'); + } return; } - const pending = this.pending.get(payload.requestId); - if (!pending || pending.request.runId !== payload.runId || pending.request.storyId !== payload.storyId) { - return; + let relaunch = false; + try { + relaunch = await this.journal!.requiresRelaunch(); + } catch (error) { + writeDesktopStorybookFailure('checking the authenticated bridge incarnation', error, this.errorOutput); } - this.rejectPending( - payload.requestId, - new Error(typeof payload.message === 'string' ? payload.message : 'The Storybook runtime failed to render the story.'), + throw new StoryBridgeUnavailableError( + this.driverManifest.endpoint, + this.driverManifest.renderer, + relaunch ? 'relaunch-required' : 'bridge-unavailable', ); } - private waitForBridge(): Promise { - if (this.bridgeConnected) { - return Promise.resolve(); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.bridgeWaiters.delete(onReady); - reject(new Error('Timed out waiting for the authenticated Storybook runtime bridge.')); - }, this.timeoutMs); - const onReady = () => { - clearTimeout(timer); - resolve(); - }; - this.bridgeWaiters.add(onReady); - }); + private initializeBridge(client: ChannelClient): void { + if ( + client !== this.bridgeClient || + !this.initializedClients.has(client) || + this.pending.size || + this.fencedClients.has(client) || + (this.journal && !this.recordedClients.has(client)) + ) + return; + this.bridgeConnected = true; + this.needsRelaunch = false; } - private waitForReady(request: StorySelectionRequest): Promise { - if (this.pending.has(request.requestId)) { - throw new Error(`Story selection request "${request.requestId}" is already pending.`); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pending.delete(request.requestId); - reject(new Error(`Timed out waiting for Storybook story "${request.storyId}" run "${request.runId}".`)); - }, this.timeoutMs); - this.pending.set(request.requestId, { reject, request, resolve, timer }); - }); + private correlatedPending(client: ChannelClient, payload: Record): PendingSelection | undefined { + if (client !== this.bridgeClient || typeof payload.requestId !== 'string') return undefined; + const pending = this.pending.get(payload.requestId); + return pending && + payload.connectionEpoch === pending.epoch && + payload.runId === pending.request.runId && + payload.storyId === pending.request.storyId + ? pending + : undefined; } - private rejectPending(requestId: string, error: Error): void { - const pending = this.pending.get(requestId); - if (!pending) { + private acceptReady(client: ChannelClient, payload: Record): void { + const pending = this.correlatedPending(client, payload); + if ( + !pending || + payload.markerVersion !== 1 || + !Number.isSafeInteger(payload.previewGeneration) || + (payload.previewGeneration as number) < 1 || + payload.portablePlanDigest !== this.driverManifest.portablePlanDigest + ) return; - } - clearTimeout(pending.timer); - this.pending.delete(requestId); - pending.reject(error); + pending.resolve({ + connectionEpoch: pending.epoch, + markerVersion: 1, + previewGeneration: payload.previewGeneration as number, + ...pending.request, + }); } - private cancelPending(requestId: string): void { - const pending = this.pending.get(requestId); - if (!pending) { - return; + private acceptError(client: ChannelClient, payload: Record): void { + this.correlatedPending(client, payload)?.reject(new Error('The Storybook runtime failed to render the story.')); + if ( + client === this.bridgeClient && + this.currentStory && + payload.connectionEpoch === this.currentStory.connectionEpoch && + payload.requestId === this.currentStory.requestId && + payload.runId === this.currentStory.runId && + payload.storyId === this.currentStory.storyId + ) { + this.currentStory = null; + this.needsRelaunch = true; + this.bridgeConnected = false; + this.fencedClients.add(client); } - clearTimeout(pending.timer); - this.pending.delete(requestId); } private requireStory(storyId: string): void { if (!this.driverManifest.storyManifest.entries.some(({ id }) => id === storyId)) { - throw new Error(`Story "${storyId}" is not present in the ${this.driverManifest.endpoint} manifest.`); + throw new StoryProviderError(`Story "${storyId}" is not present in the ${this.driverManifest.endpoint} manifest.`, 'none'); } } - private broadcast(type: string, payload: unknown): void { - const message = JSON.stringify({ type, args: [payload] }); - if (!this.bridgeClient || this.bridgeClient.readyState !== 1) { - throw new Error('The authenticated Storybook runtime bridge is not connected.'); + private requireBridge(): ChannelClient { + const client = this.bridgeClient; + if (!this.bridgeConnected || !client || client.readyState !== 1 || this.needsRelaunch) { + // A replacement runtime that said hello after the previous bridge closed is relaunched, not fenced: until its + // initial storyRendered arrives it is only unavailable, and nothing has been dispatched to it. + const awaitingInitialRender = + client !== undefined && client.readyState === 1 && !this.fencedClients.has(client) && !this.initializedClients.has(client); + throw new StoryBridgeUnavailableError( + this.driverManifest.endpoint, + this.driverManifest.renderer, + this.needsRelaunch && !awaitingInitialRender ? 'relaunch-required' : 'bridge-unavailable', + ); } - this.bridgeClient.send(message); + return client; } } diff --git a/packages/agentic/storybook-desktop/src/driver/serverIntegration.test.ts b/packages/agentic/storybook-desktop/src/driver/serverIntegration.test.ts index c7bf96822d..455f0ac991 100644 --- a/packages/agentic/storybook-desktop/src/driver/serverIntegration.test.ts +++ b/packages/agentic/storybook-desktop/src/driver/serverIntegration.test.ts @@ -52,6 +52,12 @@ describe('desktop Storybook server integration', () => { }); try { + await expect(child.waitForReadiness!({ tier: 'service-ready', service: 'storybook-channel' }, 20_000)).resolves.toEqual({ + tier: 'service-ready', + service: 'storybook-channel', + incarnation: expect.any(String), + processId: expect.any(Number), + }); const [indexResponse, driverResponse] = await Promise.all([ waitForResponse(loopbackUrl(storybookPort, '/index.json')), waitForResponse(loopbackUrl(driverPort, '/status')), diff --git a/packages/agentic/storybook-desktop/src/testing/wdioRunner.test.ts b/packages/agentic/storybook-desktop/src/testing/wdioRunner.test.ts index c69d9b8c56..5725c9fdca 100644 --- a/packages/agentic/storybook-desktop/src/testing/wdioRunner.test.ts +++ b/packages/agentic/storybook-desktop/src/testing/wdioRunner.test.ts @@ -44,7 +44,14 @@ test('executes isolated inline code and releases sessions after assertion failur smoke: { plans: ['fixture-button--pass', 'fixture-button--second'], callbacks: ['fixture-button--pass', 'fixture-button--second'], - selections: ['fixture-button--pass', 'fixture-button--pass', 'fixture-button--second', 'fixture-button--second'], + // The tests phase first waits for runtime readiness by selecting the manifest's first story. + selections: [ + 'fixture-button--crash', + 'fixture-button--pass', + 'fixture-button--pass', + 'fixture-button--second', + 'fixture-button--second', + ], status: 'passed', }, smokeFailureLogged: true, diff --git a/scripts/src/utils/runScript.test.ts b/scripts/src/utils/runScript.test.ts index 44a219b54b..6cdd4041ae 100644 --- a/scripts/src/utils/runScript.test.ts +++ b/scripts/src/utils/runScript.test.ts @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import { it } from 'node:test'; -import { captureScript, runYarn } from './runScript.ts'; +import { captureScript, runScript, runYarn } from './runScript.ts'; it('launches Yarn without relying on a shell-backed spawn', async () => { assert.equal(await runYarn('--version'), 0); @@ -17,3 +20,19 @@ it('captures a package binary output and preserves argument boundaries', async ( assert.equal(result.code, 0); assert.equal(JSON.parse(result.stdout).globalConfig.testNamePattern, pattern); }); + +it('checks markdown with native paths without shell interpretation', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'furn link check & ')); + try { + const config = path.join(directory, 'config file.json'); + const markdown = path.join(directory, 'file (links).md'); + await writeFile(config, '{}'); + await writeFile(markdown, '[Local file](./target.md)\n'); + await writeFile(path.join(directory, 'target.md'), '# Target\n'); + assert.equal(await runScript('markdown-link-check', ['-c', config, markdown]), 0); + await writeFile(markdown, '[Missing file](./missing.md)\n'); + assert.notEqual(await runScript('markdown-link-check', ['-c', config, markdown]), 0); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/scripts/src/worker/test-links.mts b/scripts/src/worker/test-links.mts index 6f30fc8b57..8a5a8c8d25 100644 --- a/scripts/src/worker/test-links.mts +++ b/scripts/src/worker/test-links.mts @@ -1,7 +1,8 @@ import type { WorkerRunnerFunction } from 'lage'; -import { $, fs, glob } from 'zx'; +import { fs, glob } from 'zx'; import { dirname, join, relative, resolve } from 'node:path'; +import { runScript } from '../utils/runScript.ts'; /** * Lage worker that runs `markdown-link-check` for a single package directory. @@ -94,8 +95,8 @@ export const run: WorkerRunnerFunction = async ({ target }) => { const failures: string[] = []; for (const file of files) { const relPath = relative(repoRoot, file); - const result = await $({ cwd: repoRoot, nothrow: true, verbose: true })`yarn markdown-link-check -c ${configPath} ${file}`; - if (result.exitCode !== 0) { + const code = await runScript('markdown-link-check', ['-c', configPath, file], { cwd: repoRoot }); + if (code !== 0) { failures.push(relPath); } } diff --git a/yarn.lock b/yarn.lock index 3c43967c7b..78d0c26639 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4995,6 +4995,7 @@ __metadata: commander: "npm:^14.0.2" expect-webdriverio: "catalog:" regexpu-core: "npm:^6.3.1" + ws: "npm:8.22.0" bin: storybook-desktop: ./config/cli.cjs storybook-server: ./config/server-cli.cjs @@ -22460,6 +22461,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:8.22.0, ws@npm:^8.21.0, ws@npm:^8.21.1": + version: 8.22.0 + resolution: "ws@npm:8.22.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/7304d14ef38898dd366a528a85e2da389c4eebf30f4ce8a007bd2dce6025f9b619cefe44fe2354f0bad0ec541b1c9a4da06a3f3e046ab56f01fd52d5784ecfb2 + languageName: node + linkType: hard + "ws@npm:^6.2.3": version: 6.2.6 resolution: "ws@npm:6.2.6" @@ -22484,21 +22500,6 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.21.0, ws@npm:^8.21.1": - version: 8.22.0 - resolution: "ws@npm:8.22.0" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ">=5.0.2" - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 10c0/7304d14ef38898dd366a528a85e2da389c4eebf30f4ce8a007bd2dce6025f9b619cefe44fe2354f0bad0ec541b1c9a4da06a3f3e046ab56f01fd52d5784ecfb2 - languageName: node - linkType: hard - "wsl-utils@npm:^0.1.0": version: 0.1.0 resolution: "wsl-utils@npm:0.1.0"