From de16d0cfea1898776ba3341b8fb6cd98fc902aaf Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:24:32 -0800 Subject: [PATCH 01/36] Share desktop service composition and durable recovery Use one harness factory for generic and Storybook native services, preserve uncertain mutations across restart, and fail closed for corrupt sibling records. Refs #4436. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/shared-desktop-service-recovery.md | 10 + .../references/current-status.md | 38 ++-- .../desktop-driver/references/service.md | 8 + .../src/server/recovery.test.ts | 38 ++++ .../src/server/settlementJournal.ts | 26 +-- packages/agentic/desktop-test/AGENTS.md | 2 + packages/agentic/desktop-test/README.md | 3 + packages/agentic/desktop-test/package.json | 5 + .../desktop-test/references/contributing.md | 4 +- .../desktop-test/references/current-status.md | 36 ++-- .../desktop-test/references/lifecycle.md | 34 +++- .../desktop-test/src/contracts.contract.cjs | 163 ++++++++++++++- .../desktop-test/src/contracts.test.ts | 1 + .../agentic/desktop-test/src/driverService.ts | 157 ++++++++++++++ packages/agentic/desktop-test/src/index.ts | 6 +- .../agentic/desktop-test/src/supervisor.ts | 192 ++++-------------- .../storybook-desktop/config/server.cjs | 2 +- .../references/contributing.md | 5 + 18 files changed, 511 insertions(+), 219 deletions(-) create mode 100644 .changeset/shared-desktop-service-recovery.md create mode 100644 packages/agentic/desktop-test/src/driverService.ts diff --git a/.changeset/shared-desktop-service-recovery.md b/.changeset/shared-desktop-service-recovery.md new file mode 100644 index 0000000000..10865742be --- /dev/null +++ b/.changeset/shared-desktop-service-recovery.md @@ -0,0 +1,10 @@ +--- +'@fluentui-react-native/desktop-test': patch +'@fluentui-react-native/desktop-driver': patch +'@fluentui-react-native/storybook-desktop': patch +--- + +Share native service construction between the generic supervisor and Storybook +channel child, wiring private durable settlement recovery, exact application-exit +probes, protected descriptor lifetime and settled shutdown. Keep corrupt sibling +journal records fenced rather than allowing a valid record to mask uncertainty. diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 871dcc3912..b4d7451557 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -19,25 +19,25 @@ 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. 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. | -| 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 in library; integration in progress | Requires explicit `recovery` options. Shipped generic/Storybook supervisors do not yet wire them; restart can erase uncertainty. Do not use restart as recovery evidence. | -| 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. 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. | +| 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. | 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 diff --git a/packages/agentic/desktop-driver/references/service.md b/packages/agentic/desktop-driver/references/service.md index 281744d35f..279fdf2ceb 100644 --- a/packages/agentic/desktop-driver/references/service.md +++ b/packages/agentic/desktop-driver/references/service.md @@ -336,6 +336,11 @@ does and does not cover. ## Persist quarantines +The generic desktop-test supervisor and Storybook's native channel child use +the shared `@fluentui-react-native/desktop-test/driver-service` factory, which +wires private storage and exact application-incarnation probes by default. +The following explicit options are required only for direct driver embedders. + Pass `recovery` so a target quarantined by an unsettled mutation stays quarantined across a service restart: @@ -362,6 +367,9 @@ await service.recoverTarget('contoso-macos', { evidence: 'operator', quarantineI `evidence: 'operator'` records the operator's own judgment. - Without a probe, an attached application's quarantine lifts only through `recoverTarget`. +- Unreadable storage prevents startup. Any corrupt record, including a corrupt + sibling of an otherwise valid record, quarantines without trusting application + identity; an exit probe cannot clear it. - On Windows, pass `createWindowsApplicationProbe({ artifact })` and `await createWindowsPrivateStorage({ artifact, root })` with the verified Windows native driver artifact. diff --git a/packages/agentic/desktop-driver/src/server/recovery.test.ts b/packages/agentic/desktop-driver/src/server/recovery.test.ts index 7e8ef3d95e..a1067af21d 100644 --- a/packages/agentic/desktop-driver/src/server/recovery.test.ts +++ b/packages/agentic/desktop-driver/src/server/recovery.test.ts @@ -195,6 +195,44 @@ describe('Desktop Driver settlement journal', () => { expect(storage.records.size).toBe(0); }); + test.each(['mutation', 'quarantine'])('a valid sibling cannot hide a corrupt %s record from recovery', async (kind) => { + const storage = new MemoryStorage(); + const service = await startService(storage); + await quarantineByLostClick(service); + await service.harness.close(); + // Lost-response quarantine normally retains only the quarantine record. Add its mutation sibling to exercise both. + const [quarantineName, contents] = [...storage.records.entries()].find(([name]) => name.startsWith('quarantine-'))!; + const record = JSON.parse(contents) as Record; + const mutationName = quarantineName.replace('quarantine-', 'mutation-'); + storage.records.set( + mutationName, + JSON.stringify({ + application: record.application, + command: record.command, + id: record.id, + kind: 'mutation', + sessionId: record.sessionId, + since: record.since, + targetId: record.targetId, + version: 1, + }), + ); + storage.records.set(kind === 'mutation' ? mutationName : quarantineName, '{"corrupt":true}'); + const probe = jest.fn(async (): Promise => 'exited'); + const restarted = await startService(storage, {}, probe); + await expect(restarted.open()).rejects.toMatchObject(quarantinedOnOpen); + const quarantine = restarted.harness.server.sessions.getQuarantine(restarted.harness.target.id)!; + expect(quarantine.application).toBeUndefined(); + await expect( + restarted.harness.server.recoverTarget(restarted.harness.target.id, { + evidence: 'application-exited', + quarantineId: quarantine.id, + }), + ).rejects.toThrow(/not proven/); + expect(probe).not.toHaveBeenCalled(); + expect(storage.records.size).toBe(2); + }); + test('quarantines the target when it cannot record that a mutation settled', async () => { const storage = new MemoryStorage(); const service = await startService(storage); diff --git a/packages/agentic/desktop-driver/src/server/settlementJournal.ts b/packages/agentic/desktop-driver/src/server/settlementJournal.ts index b6f514990b..6d36eb4d1a 100644 --- a/packages/agentic/desktop-driver/src/server/settlementJournal.ts +++ b/packages/agentic/desktop-driver/src/server/settlementJournal.ts @@ -116,25 +116,25 @@ export class SettlementJournal { return undefined; } this.held.set(targetId, { ...(mutation !== undefined ? { mutation } : {}), ...(quarantine !== undefined ? { quarantine } : {}) }); - if (quarantine !== undefined) { - const parsed = parseRecord(quarantineSchema, quarantine, targetId); - if (parsed) { - const { kind: _kind, version: _version, ...record } = parsed; + const parsedQuarantine = quarantine === undefined ? undefined : parseRecord(quarantineSchema, quarantine, targetId); + const parsedMutation = mutation === undefined ? undefined : parseRecord(mutationSchema, mutation, targetId); + // A readable sibling cannot establish which application's effects an unreadable record represents. + const corrupt = (quarantine !== undefined && !parsedQuarantine) || (mutation !== undefined && !parsedMutation); + if (!corrupt) { + if (parsedQuarantine) { + const { kind: _kind, version: _version, ...record } = parsedQuarantine; return record; } - } - if (mutation !== undefined) { - const parsed = parseRecord(mutationSchema, mutation, targetId); - if (parsed) { + if (parsedMutation) { return { - ...(parsed.application ? { application: parsed.application } : {}), - command: parsed.command, + ...(parsedMutation.application ? { application: parsedMutation.application } : {}), + command: parsedMutation.command, effect: 'unknown', - id: parsed.id, + id: parsedMutation.id, message: 'The service stopped before this mutation settled.', origin: 'interrupted', - sessionId: parsed.sessionId, - since: parsed.since, + sessionId: parsedMutation.sessionId, + since: parsedMutation.since, targetId, }; } diff --git a/packages/agentic/desktop-test/AGENTS.md b/packages/agentic/desktop-test/AGENTS.md index 0cd2da0d2f..38b90d9e12 100644 --- a/packages/agentic/desktop-test/AGENTS.md +++ b/packages/agentic/desktop-test/AGENTS.md @@ -25,6 +25,8 @@ and bounded structured output contract. coverage. - Keep published API changes explicit and update the owning reference with the implementation. +- Keep generic and Storybook service composition in `src/driverService.ts`; + never bypass its durable recovery, descriptor lifetime or settled close. ## Validation diff --git a/packages/agentic/desktop-test/README.md b/packages/agentic/desktop-test/README.md index 0a0810397d..d6bd4db16f 100644 --- a/packages/agentic/desktop-test/README.md +++ b/packages/agentic/desktop-test/README.md @@ -65,6 +65,9 @@ when working on the package. artifact directory. - Native helpers are explicitly built or provisioned; normal test runs use verified prebuilt helpers only. +- 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). 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/package.json b/packages/agentic/desktop-test/package.json index 5932feb55b..910b5c8418 100644 --- a/packages/agentic/desktop-test/package.json +++ b/packages/agentic/desktop-test/package.json @@ -45,6 +45,11 @@ "import": "./lib/commands.js", "default": "./src/commands.ts" }, + "./driver-service": { + "types": "./lib/driverService.d.ts", + "import": "./lib/driverService.js", + "default": "./src/driverService.ts" + }, "./wdio": { "types": "./lib/wdio/index.d.ts", "import": "./lib/wdio/index.js", diff --git a/packages/agentic/desktop-test/references/contributing.md b/packages/agentic/desktop-test/references/contributing.md index c0e4493605..e663567618 100644 --- a/packages/agentic/desktop-test/references/contributing.md +++ b/packages/agentic/desktop-test/references/contributing.md @@ -9,7 +9,9 @@ owner_paths: Configuration and host adapters live in `src/config.ts` and `src/adapters.ts`; CLI stages are composed in `src/cli.ts`; process ownership is in -`src/supervisor.ts`; reports are implemented in `src/reporting/`; and WDIO +`src/supervisor.ts`; shared native service, recovery, authority, descriptor and +settled shutdown composition lives in the real `src/driverService.ts` module +(also used by the Storybook channel child); reports are implemented in `src/reporting/`; and WDIO session wiring is in `src/wdio/`. Add contract coverage for schema, lifecycle, reporting protocol, and failure diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index babdf92fea..02fb895fa5 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -17,23 +17,23 @@ and its sub-issues. ## Capability matrix -| Capability | State | Limit or next gate | -| -------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | -| App/suite configuration and standalone installed-app use | Done | Storybook is optional; executable config is trusted operator code, not a sandbox. | -| Native service and helper composition | Done | Generic and Storybook paths still differ; shared factory/policy extraction is pending. | -| 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 | Work in progress | Driver library supports it, but shipped supervisors do not pass recovery storage/probes. Restart is not recovery proof. | -| 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-query/control API | Work in progress | Schemas belong here. Agents may eventually select only declared workload IDs, not commands/paths/environment. | -| 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; 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-query/control API | Work in progress | Schemas belong here. Agents may eventually select only declared workload IDs, not commands/paths/environment. | +| 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 @@ -67,7 +67,7 @@ did not retain a portable raw-report locator; do not imply otherwise. | macOS | Live failed-cleanup and service-restart cases; Paper separately; supervisor death with Metro/channel/helper survivors; exact attached-app preservation. | | Windows / Win32 | Same-branch MCP restart/revocation/dispose/args tests; physical console Ctrl+C; interactive input/capture rerun; process-tree containment and ownership checks. | | Storybook | Authenticated mutation ingress, context fencing/reconnect, consistent helper build policy, manifest provenance and separate nonces. | -| Recovery | Both service paths wire durable uncertainty before claiming crash-safe restart; ledger and exact-resource reconciliation precede automatic reclaim. | +| Recovery | Durable uncertainty is wired in both paths; live restart qualification, ledger and exact-resource reconciliation still precede automatic reclaim. | | Concurrency | No physical-input suites in parallel on one interactive desktop. Separate machines may qualify independent endpoints. | | Reliability | Establish the required repeated-run window rather than interpreting aggregate past successes as a pass. | diff --git a/packages/agentic/desktop-test/references/lifecycle.md b/packages/agentic/desktop-test/references/lifecycle.md index 00277fc8d2..29789f3cf9 100644 --- a/packages/agentic/desktop-test/references/lifecycle.md +++ b/packages/agentic/desktop-test/references/lifecycle.md @@ -35,13 +35,35 @@ 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, durable -quarantine wiring, and crash reconciliation remain work in progress. +by service guards, process-tree survivors, exact resource accounting, and crash +reconciliation remain work in progress. -The driver library can persist settlement uncertainty, but neither shipped -service path currently supplies recovery storage/probes. Restart is therefore -not a safe recovery procedure for uncertain native or Storybook mutations. -Inspect exact resources and follow trusted recovery evidence requirements. +Both native service paths use the real `driverService.ts` composition root +(`@fluentui-react-native/desktop-test/driver-service`). It resolves prebuilt +helpers with `buildPolicy: 'never'`, creates native hosts, loads durable +settlement state before serving, probes the host, then publishes any protected +agent descriptor. Shutdown withdraws the descriptor and revokes its grant +before awaiting the driver listener, session settlement, and host disposal. +The Storybook channel/driver remains a supervised child. + +Recovery uses the platform's validated owner-only private storage, with a +bounded hashed namespace derived from the endpoint and registered target ID. +Ports, run IDs, helper builds, service incarnations, descriptor roots and grants +do not change that namespace. Keep the registered target ID stable across +restarts. An explicit trusted `recovery.root` selects a separate private root; +it must be provisioned with the platform's ownership requirements, never deleted +as disposable run output. Tests and custom hosts can supply `recovery.storage` +and `recovery.probeApplication`; these are operator APIs, not client inputs. + +An unreadable store aborts startup without publishing a descriptor. Corrupt +records quarantine the target without application identity, even when a sibling +record is valid. Only an exact recorded process ID/start-time probe can prove +application exit; native exit events and service death are not proof. +`service.recoverTarget` retains the driver's trusted exact-quarantine-ID +operator API; no new listing, offline recovery command, or model-facing +recovery route is exposed. Automatic stale-claim removal remains unsupported. +Inspect exact resources before explicit operator recovery; restarting alone +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. diff --git a/packages/agentic/desktop-test/src/contracts.contract.cjs b/packages/agentic/desktop-test/src/contracts.contract.cjs index dcc9933d1a..7fef3c4e35 100644 --- a/packages/agentic/desktop-test/src/contracts.contract.cjs +++ b/packages/agentic/desktop-test/src/contracts.contract.cjs @@ -27,6 +27,24 @@ const waitFor = async (condition) => { } }; +function memoryRecovery() { + const records = new Map(); + return { + storage: { + read: async (name) => records.get(name), + publish: async (name, contents) => { + assert(!records.has(name), 'never replace a private record'); + records.set(name, contents); + }, + remove: async (name, contents) => { + if (records.get(name) !== contents) return false; + return records.delete(name); + }, + }, + records, + }; +} + async function main() { const { DesktopRunReporter } = await import(lib('reporting/collector.js')); const { NodeDesktopCommandRunner } = await import(lib('commands.js')); @@ -782,6 +800,7 @@ async function main() { const run = reporter({ kind: 'lifecycle' }); const supervisor = new DesktopSupervisor({ target, + recovery: memoryRecovery(), reporter: run, lockDirectory: path.join(root, 'locks'), hostFactory: async () => new FakeDesktopHost({ endpoint: 'win32', features: { boundedObservation: true } }), @@ -828,6 +847,7 @@ async function main() { const failing = new DesktopSupervisor({ target, + recovery: memoryRecovery(), reporter: reporter({ kind: 'lifecycle' }), lockDirectory: path.join(root, 'locks'), hostFactory: async () => new FakeDesktopHost({ endpoint: 'win32' }), @@ -844,6 +864,7 @@ async function main() { const failedCleanupLocks = path.join(root, 'failed-cleanup-locks'); const failedCleanup = new DesktopSupervisor({ target, + recovery: memoryRecovery(), reporter: reporter({ kind: 'lifecycle' }), lockDirectory: failedCleanupLocks, hostFactory: async () => { @@ -863,7 +884,7 @@ async function main() { assert.throws(() => claimDesktopWorkload('replacement', failedCleanupLocks), /Another desktop workload/); // The Storybook channel child's composition: one service, protected and published from the process that listens. - const { startDesktopService } = await import(lib('supervisor.js')); + const { startDesktopService } = await import(lib('driverService.js')); const { publishDesktopAgent } = await import(lib('agentConnection.js')); const { DesktopAuthority } = await import('@fluentui-react-native/desktop-driver/authority'); const serviceTarget = { endpoint: 'win32', id: 'agent-service', platformName: 'windows', renderer: 'paper', launchModes: ['launch'] }; @@ -882,6 +903,7 @@ async function main() { const servicePublished = []; const service = await startDesktopService(trackedHost(steps), { target: serviceTarget, + recovery: memoryRecovery(), addSecret: (secret) => secrets.push(secret), agent: { descriptor: 'service.json', @@ -911,6 +933,7 @@ async function main() { await assert.rejects( startDesktopService(trackedHost(failedSteps), { target: serviceTarget, + recovery: memoryRecovery(), agent: { publish: async () => { throw new Error('original service publication failure'); @@ -946,6 +969,142 @@ async function main() { release(async () => void lateRemoved++); await new Promise((resolve) => setImmediate(resolve)); assert.equal(lateRemoved, 1, 'a late publication is withdrawn'); + } else if (mode === 'recovery') { + const { DesktopSupervisor } = await import(lib('supervisor.js')); + 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 target = { + endpoint: 'win32', + host: { kind: 'installed', ownership: 'external', application: { windowTitle: 'fake' } }, + id: 'durable-target', + instanceId: 'recovery', + projectRoot: packageRoot, + configPath: path.join(root, 'desktop.config.mjs'), + }; + const capabilities = { alwaysMatch: { platformName: 'windows', 'furn:target': target.id, 'furn:launchMode': 'attach' } }; + const quarantined = (error) => error.data?.reason === 'target-quarantined'; + const uncertain = (error) => error.data?.mutation?.quarantined === true; + for (const firstPath of ['supervisor', 'channel']) { + const recovery = memoryRecovery(); + let state = 'running'; + const probed = []; + recovery.probeApplication = async (application) => { + probed.push(application); + return state; + }; + const start = async (servicePath) => { + const host = new FakeDesktopHost({ endpoint: 'win32' }); + if (servicePath === 'channel') { + const server = await startDesktopService(host, { + target: { endpoint: target.endpoint, id: target.id, platformName: 'windows', launchModes: ['attach'] }, + recovery, + }); + return { server, host, close: () => server.close() }; + } + const supervisor = new DesktopSupervisor({ + target, + recovery, + reporter: reporter({ kind: 'lifecycle' }), + lockDirectory: path.join(root, `locks-${firstPath}`), + hostFactory: async () => host, + }); + await supervisor.start(); + return { server: supervisor.server, host, close: () => supervisor.close() }; + }; + let service = await start(firstPath); + const open = () => createDesktopDriverClient({ url: service.server.url }).newSession(capabilities); + try { + const session = await open(); + const button = await session.findElement('accessibility id', 'button-primary'); + service.host.injectFault('click', { mode: 'lost-response' }); + service.server.sessions.get(session.id).desktopTimeouts.nativeCommand = 10; + await assert.rejects(button.click(), uncertain); + assert(recovery.records.size > 0, 'quarantine must be durable before responding'); + await service.close(); + + service = await start(firstPath === 'supervisor' ? 'channel' : 'supervisor'); + await assert.rejects(open(), quarantined, 'replacement composition cannot forget uncertain attached-target effects'); + const record = service.server.sessions.getQuarantine(target.id); + assert.deepEqual(probed.at(-1), { processId: 1000, processStartedAt: new Date(0).toISOString() }); + state = 'unknown'; + await assert.rejects( + service.server.recoverTarget(target.id, { evidence: 'application-exited', quarantineId: record.id }), + /not proven/, + ); + await assert.rejects(open(), quarantined); + state = 'exited'; + await service.server.recoverTarget(target.id, { evidence: 'application-exited', quarantineId: record.id }); + assert.equal(recovery.records.size, 0); + const recovered = await open(); + await recovered.delete(); + await service.close(); + + // A malformed journal never becomes absence, even when the probe claims exit. + const seed = await start(firstPath); + service = seed; + const seeded = await open(); + const element = await seeded.findElement('accessibility id', 'button-primary'); + seed.host.injectFault('click', { mode: 'lost-response' }); + seed.server.sessions.get(seeded.id).desktopTimeouts.nativeCommand = 10; + await assert.rejects(element.click()); + await seed.close(); + for (const name of recovery.records.keys()) recovery.records.set(name, '{"invalid":true}'); + service = await start(firstPath === 'supervisor' ? 'channel' : 'supervisor'); + await assert.rejects(open(), quarantined); + const corrupt = service.server.sessions.getQuarantine(target.id); + assert.equal(corrupt.application, undefined); + await assert.rejects( + service.server.recoverTarget(target.id, { evidence: 'application-exited', quarantineId: corrupt.id }), + /not proven/, + ); + } finally { + await service.close(); + } + } + + let disposed = 0; + let published = 0; + const host = new FakeDesktopHost({ endpoint: 'win32' }); + host.dispose = async () => void disposed++; + await assert.rejects( + startDesktopService(host, { + target: { endpoint: 'win32', id: target.id, platformName: 'windows' }, + recovery: { + storage: { + ...memoryRecovery().storage, + read: async () => { + throw new Error('inaccessible recovery'); + }, + }, + }, + agent: { + publish: async () => { + published++; + return async () => undefined; + }, + }, + }), + /inaccessible recovery/, + ); + assert.equal(disposed, 1); + assert.equal(published, 0, 'unreadable recovery must fail before descriptor publication'); + + let settle; + const settling = new Promise((resolve) => (settle = resolve)); + const settlingHost = new FakeDesktopHost({ endpoint: 'win32' }); + settlingHost.dispose = () => settling; + const service = await startDesktopService(settlingHost, { + target: { endpoint: 'win32', id: target.id, platformName: 'windows' }, + recovery: memoryRecovery(), + }); + let closed = false; + const closing = service.close().then(() => (closed = true)); + assert.equal(service.close(), service.close(), 'shutdown is one settled operation'); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(closed, false, 'shutdown must await disposal, not just listener closure'); + settle(); + await closing; } else if (mode === 'agent-cli') { const { createDesktopReportSession } = await import(lib('reporting/session.js')); // serve --agent passes the agent options to an integration that serves agents, and rejects one that does not. @@ -1086,6 +1245,7 @@ async function main() { }; const supervisor = new DesktopSupervisor({ target, + recovery: memoryRecovery(), reporter: run, lockDirectory: path.join(root, 'locks'), hostFactory: async () => host, @@ -1127,6 +1287,7 @@ async function main() { await supervisor.start(); const duplicate = new DesktopSupervisor({ target, + recovery: memoryRecovery(), reporter: run, lockDirectory: path.join(root, 'locks'), hostFactory: async () => host, diff --git a/packages/agentic/desktop-test/src/contracts.test.ts b/packages/agentic/desktop-test/src/contracts.test.ts index 078d8430c3..559708c809 100644 --- a/packages/agentic/desktop-test/src/contracts.test.ts +++ b/packages/agentic/desktop-test/src/contracts.test.ts @@ -15,6 +15,7 @@ describe('neutral desktop harness contracts', () => { 'services', 'owned-service', 'agent', + 'recovery', 'agent-cli', 'wdio', 'typescript', diff --git a/packages/agentic/desktop-test/src/driverService.ts b/packages/agentic/desktop-test/src/driverService.ts new file mode 100644 index 0000000000..6dd4d71c6a --- /dev/null +++ b/packages/agentic/desktop-test/src/driverService.ts @@ -0,0 +1,157 @@ +import { createHash } from 'node:crypto'; + +import { NativeDesktopHost, resolveNativeDesktopDriver } from '@fluentui-react-native/desktop-driver'; +import type { DesktopHost, DesktopTarget, NativeDesktopHostOptions, NativeDriverArtifact } from '@fluentui-react-native/desktop-driver'; +import { + createMacOSApplicationProbe, + createMacOSPrivateStorage, + createWindowsApplicationProbe, + createWindowsPrivateStorage, + DesktopAuthority, +} from '@fluentui-react-native/desktop-driver/authority'; +import type { PrivateStorage } from '@fluentui-react-native/desktop-driver/authority'; +import { createDesktopDriverServer } from '@fluentui-react-native/desktop-driver/server'; +import type { DesktopDriverServer, ProbeApplication } from '@fluentui-react-native/desktop-driver/server'; +import { publishDesktopAgent } from './agentConnection.js'; +import type { DesktopAgentConnectionOptions, DesktopAgentPublication, DesktopAgentTarget } from './agentConnection.js'; + +export type DesktopServiceOptions = { + target: Omit; + port?: number; + host?: string; + /** Protects every route and publishes a descriptor only after the host and listener are ready. */ + agent?: DesktopAgentConnectionOptions; + /** Trusted storage/probe overrides. Omitted, use platform owner-only storage and the verified native helper. */ + recovery?: { + root?: string; + storage?: PrivateStorage; + probeApplication?: ProbeApplication; + }; + addSecret?: (secret: string) => void; + signal?: AbortSignal; + guard?: (work: Promise) => Promise; + /** Optional application-specific target context; native authority remains in the driver. */ + createTarget?: (host: DesktopHost) => Promise | undefined>; +}; + +export type DesktopService = DesktopDriverServer & { readonly agentDescriptor?: string }; + +export type NativeDesktopServiceOptions = Omit & + DesktopServiceOptions & { + artifact?: NativeDriverArtifact; + nativeDriver?: DesktopAgentTarget['nativeDriver']; + }; + +/** Normal service startup never builds a helper. Storybook may supply its already resolved artifact. */ +export async function startNativeDesktopService(options: NativeDesktopServiceOptions): Promise { + const artifact = + options.artifact ?? + (await resolveNativeDesktopDriver({ + ...options.nativeDriver, + platform: options.endpoint, + buildPolicy: 'never', + signal: options.signal, + })); + return startDesktopService(new NativeDesktopHost({ ...options, artifact }), options, artifact); +} + +/** One composition root for the generic supervisor and the supervised Storybook channel child. */ +export async function startDesktopService( + desktopHost: DesktopHost, + options: DesktopServiceOptions, + artifact?: NativeDriverArtifact, +): Promise { + const authority = options.agent ? new DesktopAuthority() : undefined; + 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); + options.signal?.throwIfAborted(); + server = await createDesktopDriverServer({ + ...(authority ? { authority } : {}), + host: options.host, + port: options.port, + recovery, + targets: [target], + }); + await desktopHost.probe(options.signal); + options.signal?.throwIfAborted(); + if (options.agent) { + publication = await publishDesktopAgent({ + authority: authority!, + connection: options.agent, + target: { endpoint: options.target.endpoint, id: options.target.id }, + url: server.url, + ...(artifact ? { artifact } : {}), + addSecret: options.addSecret, + signal: options.signal, + guard: options.guard, + }); + } + } catch (error) { + try { + if (server) await server.close(); + else await desktopHost.dispose(); + } catch (cleanup) { + throw new AggregateError([error, cleanup], 'Desktop service startup and disposal failed.'); + } + throw error; + } + const listening = server; + let closing: Promise | undefined; + return { + ...listening, + get agentDescriptor() { + return closing ? undefined : publication?.descriptor; + }, + close: () => + (closing ??= (async () => { + const failures: unknown[] = []; + for (const step of [async () => publication?.withdraw(), () => listening.close()]) { + try { + await step(); + } catch (error) { + failures.push(error); + } + } + if (failures.length === 1) throw failures[0]; + if (failures.length) throw new AggregateError(failures, 'Agent withdrawal and settled service close failed.'); + })()), + }; +} + +async function resolveRecovery( + options: DesktopServiceOptions, + artifact?: NativeDriverArtifact, +): Promise<{ storage: PrivateStorage; probeApplication?: ProbeApplication }> { + if (options.recovery?.storage && options.recovery.root !== undefined) + throw new TypeError('Desktop recovery accepts a storage adapter or a root, not both.'); + const endpoint = options.target.endpoint; + let storage = options.recovery?.storage; + let probeApplication = options.recovery?.probeApplication; + if (!storage) { + if (endpoint === 'macos') storage = await createMacOSPrivateStorage(options.recovery?.root); + else { + if (!artifact) throw new Error('Windows recovery storage requires a verified native helper or a trusted storage adapter.'); + storage = await createWindowsPrivateStorage({ artifact, root: options.recovery?.root }); + } + } + if (!probeApplication && artifact) + probeApplication = endpoint === 'macos' ? createMacOSApplicationProbe({ artifact }) : createWindowsApplicationProbe({ artifact }); + + // Neither service incarnations, grants, listener ports, nor report runs may change the recovery identity. + const prefix = `settlement-${createHash('sha256') + .update(JSON.stringify([endpoint, options.target.id])) + .digest('hex') + .slice(0, 24)}-`; + const privateStorage = storage; + return { + storage: { + read: (name) => privateStorage.read(prefix + name), + publish: (name, contents) => privateStorage.publish(prefix + name, contents), + remove: (name, contents) => privateStorage.remove(prefix + name, contents), + }, + ...(probeApplication ? { probeApplication } : {}), + }; +} diff --git a/packages/agentic/desktop-test/src/index.ts b/packages/agentic/desktop-test/src/index.ts index 152814f41c..946718dc9b 100644 --- a/packages/agentic/desktop-test/src/index.ts +++ b/packages/agentic/desktop-test/src/index.ts @@ -1,5 +1,7 @@ -export { claimDesktopWorkload, DesktopSupervisor, startNativeDesktopService } from './supervisor.js'; -export type { DesktopServiceOptions, DesktopSupervisorOptions } from './supervisor.js'; +export { claimDesktopWorkload, DesktopSupervisor } from './supervisor.js'; +export type { DesktopSupervisorOptions } from './supervisor.js'; +export { startNativeDesktopService } from './driverService.js'; +export type { DesktopServiceOptions } from './driverService.js'; export { createNativeAgentPublisher, defaultAgentDescriptor } from './agentConnection.js'; export type { DesktopAgentConnection, diff --git a/packages/agentic/desktop-test/src/supervisor.ts b/packages/agentic/desktop-test/src/supervisor.ts index fd207976da..4249c82ea6 100644 --- a/packages/agentic/desktop-test/src/supervisor.ts +++ b/packages/agentic/desktop-test/src/supervisor.ts @@ -3,17 +3,10 @@ import os from 'node:os'; import path from 'node:path'; import { createHash } from 'node:crypto'; -import { createDesktopDriverServer, NativeDesktopHost, resolveNativeDesktopDriver } from '@fluentui-react-native/desktop-driver'; -import type { - DesktopDriverServer, - DesktopHost, - DesktopTarget, - NativeDesktopHostOptions, - NativeDriverArtifact, -} from '@fluentui-react-native/desktop-driver'; -import { DesktopAuthority } from '@fluentui-react-native/desktop-driver/authority'; -import { publishDesktopAgent } from './agentConnection.js'; -import type { DesktopAgentConnectionOptions, DesktopAgentPublication } from './agentConnection.js'; +import type { DesktopDriverServer, DesktopHost } from '@fluentui-react-native/desktop-driver'; +import type { DesktopAgentConnectionOptions } from './agentConnection.js'; +import { startDesktopService, startNativeDesktopService } from './driverService.js'; +import type { DesktopService, DesktopServiceOptions } from './driverService.js'; import type { DesktopCommand, ResolvedDesktopTarget } from './config.js'; import type { DesktopIntegration } from './integration.js'; import { NodeDesktopCommandRunner, deadline } from './commands.js'; @@ -38,6 +31,7 @@ export type DesktopSupervisorOptions = { * ready. Every route then requires a grant, so only clients given one, such as the Desktop Driver MCP adapter, connect. */ agent?: DesktopAgentConnectionOptions; + recovery?: DesktopServiceOptions['recovery']; }; export class DesktopSupervisor { @@ -45,14 +39,11 @@ export class DesktopSupervisor { readonly target: ResolvedDesktopTarget; readonly runner: NodeDesktopCommandRunner; readonly services: DesktopServiceScope; - private serverValue?: DesktopDriverServer; + private serverValue?: DesktopService; private transport?: ReportTransport; private opened = false; private releaseClaim?: () => void; private closePromise?: Promise; - private artifact?: NativeDriverArtifact; - private authority?: DesktopAuthority; - private agentPublication?: DesktopAgentPublication; private ownershipUncertain = false; private readonly environment: Record = {}; private readonly workers = new Set(); @@ -74,7 +65,7 @@ export class DesktopSupervisor { /** The record name of the published agent connection descriptor, once it is published. */ get agentDescriptor(): string | undefined { - return this.agentPublication?.descriptor; + return this.serverValue?.agentDescriptor; } async open(interactive = true): Promise { @@ -101,44 +92,36 @@ export class DesktopSupervisor { await this.open(); try { await this.startConfiguredServices(); - const host = this.options.hostFactory ? await this.options.hostFactory(this.target) : await this.createHost(); - try { - const extension = await this.options.integration?.createTarget?.({ host, target: this.target, reporter: this.reporter }); - if (this.options.agent) this.authority = new DesktopAuthority(); - this.serverValue = await createDesktopDriverServer({ - ...(this.authority ? { authority: this.authority } : {}), - targets: [ - { - endpoint: this.target.endpoint, - id: this.target.id, - platformName: this.target.endpoint === 'macos' ? 'macos' : 'windows', - renderer: this.target.host.renderer, - launchTimeoutMs: this.target.launchTimeoutMs, - launchModes: this.target.host.ownership === 'external' ? ['attach'] : ['launch'], - host, - ...extension, - }, - ], - }); - await host.probe(this.services.controller.signal); - } catch (error) { - if (!this.serverValue) { - try { - await host.dispose(); - } catch (cleanup) { - this.ownershipUncertain = true; - throw new AggregateError([error, cleanup], 'Desktop target startup and disposal failed.'); - } - } - throw error; + const options: DesktopServiceOptions = { + target: { + endpoint: this.target.endpoint, + id: this.target.id, + platformName: this.target.endpoint === 'macos' ? 'macos' : 'windows', + renderer: this.target.host.renderer, + launchTimeoutMs: this.target.launchTimeoutMs, + launchModes: this.target.host.ownership === 'external' ? ['attach'] : ['launch'], + }, + agent: this.options.agent, + recovery: this.options.recovery, + signal: this.services.controller.signal, + guard: (work) => this.services.guard(work), + addSecret: (secret) => this.reporter.addSecret(secret), + 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); } this.reporter.emit( { kind: 'service-ready', payload: { message: `WebDriver ${this.server.url}; target ${this.target.id}` } }, 'webdriver', { endpoint: this.target.endpoint, renderer: this.target.host.renderer }, ); - if (this.options.agent) await this.publishAgentConnection(this.options.agent); } catch (error) { + if (error instanceof AggregateError) this.ownershipUncertain = true; this.reporter.fail(error); try { await this.close(); @@ -149,23 +132,6 @@ export class DesktopSupervisor { } } - private async publishAgentConnection(options: DesktopAgentConnectionOptions): Promise { - this.agentPublication = await publishDesktopAgent({ - authority: this.authority!, - connection: options, - target: this.target, - url: this.server.url, - ...(this.artifact ? { artifact: this.artifact } : {}), - addSecret: (secret) => this.reporter.addSecret(secret), - signal: this.services.controller.signal, - guard: (work) => this.services.guard(work), - }); - this.reporter.emit({ - kind: 'diagnostic', - payload: { level: 'info', message: `Published agent connection ${this.agentPublication.descriptor} for target ${this.target.id}` }, - }); - } - async startConfiguredServices(): Promise { for (const service of this.target.services ?? []) { this.services.start(service.id, this.prepare(service.command, service.id)); @@ -173,22 +139,16 @@ export class DesktopSupervisor { } } - private async createHost(): Promise { + private async createService(options: DesktopServiceOptions): Promise { const requiredOS = this.target.endpoint === 'macos' ? 'darwin' : 'win32'; if (process.platform !== requiredOS) throw new Error(`Endpoint ${this.target.endpoint} requires ${requiredOS}; native helpers cannot run on ${process.platform}.`); const application = await resolveDesktopApplication(this.target, this.services.controller.signal); if (application.leaseNonce) this.reporter.addSecret(application.leaseNonce); - const artifact = await resolveNativeDesktopDriver({ - ...this.target.nativeDriver, - platform: this.target.endpoint, - buildPolicy: 'never', - signal: this.services.controller.signal, - }); - this.artifact = artifact; - return new NativeDesktopHost({ + return startNativeDesktopService({ + ...options, application, - artifact, + nativeDriver: this.target.nativeDriver, endpoint: this.target.endpoint, onStderr: (text) => this.reporter.emit({ kind: 'output', payload: { stream: 'stderr', text } }, 'native-helper', { @@ -250,9 +210,6 @@ export class DesktopSupervisor { }; for (const worker of this.workers) await close('worker', () => worker.stop({ failed: true })); this.workers.clear(); - const publication = this.agentPublication; - this.agentPublication = undefined; - if (publication) await close('agent-descriptor', () => publication.withdraw()); 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()); @@ -270,87 +227,6 @@ export class DesktopSupervisor { } } -export type DesktopServiceOptions = { - target: Omit; - port?: number; - host?: string; - /** - * Protects the service with a grant authority and publishes one agent's connection descriptor once it listens. Every - * route then requires a grant. Closing the service withdraws the descriptor and revokes the grant before the listener. - */ - agent?: DesktopAgentConnectionOptions; - /** Receives secrets the service issues, such as the agent grant, so reporters redact them. */ - addSecret?: (secret: string) => void; - signal?: AbortSignal; -}; - -/** Transitional composition for Storybook's channel child; lifecycle policy remains owned by desktop-test. */ -export async function startNativeDesktopService(options: NativeDesktopHostOptions & DesktopServiceOptions): Promise { - return startDesktopService(new NativeDesktopHost(options), options, options.artifact); -} - -/** Serves one target on the given host, and disposes the host if the service cannot start. */ -export async function startDesktopService( - desktopHost: DesktopHost, - options: DesktopServiceOptions, - artifact?: NativeDriverArtifact, -): Promise { - const authority = options.agent ? new DesktopAuthority() : undefined; - let server: DesktopDriverServer; - try { - server = await createDesktopDriverServer({ - ...(authority ? { authority } : {}), - host: options.host, - port: options.port, - targets: [{ ...options.target, host: desktopHost }], - }); - } catch (error) { - try { - await desktopHost.dispose(); - } catch (cleanup) { - throw new AggregateError([error, cleanup], 'Native listener startup and disposal failed.'); - } - throw error; - } - if (!options.agent) return server; - let publication: DesktopAgentPublication; - try { - publication = await publishDesktopAgent({ - authority: authority!, - connection: options.agent, - target: { endpoint: options.target.endpoint, id: options.target.id }, - url: server.url, - ...(artifact ? { artifact } : {}), - ...(options.addSecret ? { addSecret: options.addSecret } : {}), - ...(options.signal ? { signal: options.signal } : {}), - }); - } catch (error) { - try { - await server.close(); - } catch (cleanup) { - throw new AggregateError([error, cleanup], 'Agent publication and service cleanup failed.'); - } - throw error; - } - let closing: Promise | undefined; - return { - ...server, - close: () => - (closing ??= (async () => { - const failures: unknown[] = []; - for (const step of [() => publication.withdraw(), () => server.close()]) { - try { - await step(); - } catch (error) { - failures.push(error); - } - } - if (failures.length === 1) throw failures[0]; - if (failures.length) throw new AggregateError(failures, 'Agent withdrawal and service close failed.'); - })()), - }; -} - 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}`); diff --git a/packages/agentic/storybook-desktop/config/server.cjs b/packages/agentic/storybook-desktop/config/server.cjs index c2a66bda7a..99421e224d 100644 --- a/packages/agentic/storybook-desktop/config/server.cjs +++ b/packages/agentic/storybook-desktop/config/server.cjs @@ -16,7 +16,7 @@ async function startDesktopStorybookServer({ const requireFromProject = createRequire(path.join(projectRoot, 'package.json')); const { createChannelServer } = requireFromProject('@storybook/react-native/node'); const [{ startNativeDesktopService }, { createDesktopReportSession }] = await Promise.all([ - import('@fluentui-react-native/desktop-test'), + import('@fluentui-react-native/desktop-test/driver-service'), import('@fluentui-react-native/desktop-test/reporting'), ]); const report = await createDesktopReportSession({ artifactsRoot: path.join(projectRoot, 'artifacts', 'desktop'), kind: 'lifecycle' }); diff --git a/packages/agentic/storybook-desktop/references/contributing.md b/packages/agentic/storybook-desktop/references/contributing.md index a87ef80506..207be56c30 100644 --- a/packages/agentic/storybook-desktop/references/contributing.md +++ b/packages/agentic/storybook-desktop/references/contributing.md @@ -12,6 +12,11 @@ extraction and target identity live under `src/driver/`; command lifecycle and service ownership live under `src/cli/`; CSF callback extraction and worker execution live under `src/testing/`. +The channel child in `config/server.cjs` constructs its native driver through +`@fluentui-react-native/desktop-test/driver-service`. Keep recovery storage, +helper policy, authority, descriptor publication and settled driver shutdown +in that shared harness implementation, not a parallel Storybook copy. + Keep story callbacks statically extractable and authentication checks fail-closed. Update references when commands, supported config, outputs, or readiness contracts change. From 9ddecc772dbf9e62862b0d9ec06cedbffcd96cf3 Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:24:47 -0800 Subject: [PATCH 02/36] Fence Storybook selections by provider lifetime and connection epoch Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/f4-story-provider-lifetimes.md | 11 + packages/agentic/desktop-driver/README.md | 7 + .../references/current-status.md | 38 +- .../desktop-driver/src/operations/act.ts | 10 + .../desktop-driver/src/operations/runtime.ts | 10 +- .../desktop-driver/src/operations/story.ts | 13 +- .../src/server/SessionManager.ts | 3 +- .../src/server/createDesktopDriverServer.ts | 23 +- .../src/server/sessionStories.ts | 107 ++-- .../src/server/storyOperations.test.ts | 193 +++++- .../agentic/desktop-driver/src/storybook.ts | 71 ++- .../src/testing/FakeStoryOrchestrator.ts | 23 +- .../references/runtime-contract.md | 17 + .../src/DesktopDriverBridge.test.tsx | 130 ++++ .../src/DesktopDriverBridge.tsx | 45 +- .../src/DesktopStoryRoot.tsx | 38 +- .../src/DesktopStorybookConfig.tsx | 40 +- .../src/storyMarker.ts | 20 + .../references/integration.md | 8 +- .../storybook-desktop/references/readiness.md | 30 + .../StorybookChannelOrchestrator.test.ts | 567 ++++++++---------- .../driver/StorybookChannelOrchestrator.ts | 323 +++++----- 22 files changed, 1133 insertions(+), 594 deletions(-) create mode 100644 .changeset/f4-story-provider-lifetimes.md create mode 100644 packages/agentic/storybook-desktop-runtime/src/DesktopDriverBridge.test.tsx create mode 100644 packages/agentic/storybook-desktop-runtime/src/storyMarker.ts 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/packages/agentic/desktop-driver/README.md b/packages/agentic/desktop-driver/README.md index 35fddb1804..ee8e6e5d2d 100644 --- a/packages/agentic/desktop-driver/README.md +++ b/packages/agentic/desktop-driver/README.md @@ -16,6 +16,13 @@ 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. + ## 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 871dcc3912..012bad4c23 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -19,25 +19,25 @@ 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. 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. | -| 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 in library; integration in progress | Requires explicit `recovery` options. Shipped generic/Storybook supervisors do not yet wire them; restart can erase uncertainty. Do not use restart as recovery evidence. | -| 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. 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. | +| 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 in library; integration in progress | Requires explicit `recovery` options. Shipped generic/Storybook supervisors do not yet wire them; restart can erase uncertainty. Do not use restart as recovery evidence. | +| 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 | Minimal catalog port; local lifetime/signal context, authenticated epochs, versioned marker conformance, prompt disconnect failure, and stale preview rejection. Unknown navigation effects still quarantine. | +| Story args on protected driver | Intentionally unsupported | `story-args-unsupported`; legacy unprotected args remain separate. | +| Exclusive Storybook control | Work in progress | Preview mutations recheck provider/native context; chrome/secondary references survive. Channel selection/prepare ingress can still bypass authority; controlled ingress is separate work. | +| 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 | Epoch replacement is fail-closed, not transparent restoration. Inspected RN Storybook 10.5.4 / Storybook 10.6.3 transport does not reconnect; endpoint/renderer-specific relaunch guidance, not quarantine 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. | 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 diff --git a/packages/agentic/desktop-driver/src/operations/act.ts b/packages/agentic/desktop-driver/src/operations/act.ts index f9e2f3e8f0..08797ed7ab 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.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/runtime.ts b/packages/agentic/desktop-driver/src/operations/runtime.ts index 1ad67a6759..3fc1a2d3fb 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'; @@ -71,7 +70,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 +80,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 7b325f2f43..10dfc460b3 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 = { @@ -477,6 +477,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), ); @@ -506,10 +510,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); @@ -537,9 +547,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; } @@ -643,6 +654,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..a59ccb21f7 100644 --- a/packages/agentic/desktop-driver/src/server/sessionStories.ts +++ b/packages/agentic/desktop-driver/src/server/sessionStories.ts @@ -1,10 +1,12 @@ 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'; @@ -24,23 +26,39 @@ 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(); + 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 +79,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,11 +99,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 {}; } + 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)) { + sessions.invalidatePreview(session); + session.story = null; + return { changed: true }; + } const testId = session.target.storyRootTestId; if (!testId) { return { story: { storyId: story.storyId, verification: 'none' } }; @@ -102,20 +135,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.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 +180,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 +187,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..8276456a83 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,187 @@ 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(); + 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; + 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(); + }); + }); + + 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({ diff --git a/packages/agentic/desktop-driver/src/storybook.ts b/packages/agentic/desktop-driver/src/storybook.ts index 4946ad486e..6e1ff742b5 100644 --- a/packages/agentic/desktop-driver/src/storybook.ts +++ b/packages/agentic/desktop-driver/src/storybook.ts @@ -1,5 +1,7 @@ import type { DesktopStoryTests } from './authoring/storyTests.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; @@ -27,15 +29,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/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/references/integration.md b/packages/agentic/storybook-desktop/references/integration.md index d3307b5cc0..f54b7a4b75 100644 --- a/packages/agentic/storybook-desktop/references/integration.md +++ b/packages/agentic/storybook-desktop/references/integration.md @@ -70,8 +70,12 @@ 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. +protected driver are unsupported. Epoch/cancellation-aware story providers +reject stale preview mutations without invalidating chrome or live secondary +windows. Interrupted navigation still quarantines when upstream work cannot +be proven fenced. Automatic transport reconnect, durable recovery wiring, and +channel ingress authority remain work in progress; 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..c8f17a1272 100644 --- a/packages/agentic/storybook-desktop/references/readiness.md +++ b/packages/agentic/storybook-desktop/references/readiness.md @@ -25,6 +25,36 @@ At startup, require both an authenticated runtime hello and the initial Storybook's navigation handlers after an app restart. 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. + +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. +Selection waits for both HTTP navigation and correlated runtime readiness, +then the driver verifies the native marker within the same render deadline. +Disconnect promptly rejects pending work and aborts HTTP. 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. + Do not patch generated identity or manifests. Change the app configuration or manifest producer, regenerate, then validate the exact platform manifest. Generated instance files remain under the consumer's ignored diff --git a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts index 411963f731..4ef18bf55d 100644 --- a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts +++ b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts @@ -1,90 +1,56 @@ -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'; -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); - } -} - -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); + 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, + }); + this.receive('storyRendered', request.storyId); } - - 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 +73,251 @@ 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', + targetId: 'target', + testIDPrefix: 'storybook', }; -const runtimeHello = { - endpoint: 'windows', - instanceId: 'instance', - nonce: 'nonce', - platformManifestDigest: 'platform-digest', - targetId: 'agenticstorybook-windows', - version: 1, -}; - -describe('StorybookChannelOrchestrator', () => { +function setup(fetch: typeof globalThis.fetch = jest.fn(async () => new Response('{}')), timeoutMs = 100) { + 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, + errorOutput, + fetch, + serverUrl: 'https://localhost', + timeoutMs, + }); + 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); - const fetch = jest.fn(async () => new Response('{}')); - const orchestrator = createOrchestrator({ - channelServer, - driverManifest, - fetch, - serverUrl: 'https://localhost', - 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', }); - - for (const [index, runtime] of [client, new FakeChannelClient()].entries()) { - if (index > 0) { - client.close(); - channelServer.connect(runtime); - } - runtime.receive('furn:desktop:hello', runtimeHello); - const request = { requestId: `request-${index}`, runId: `run-${index}`, storyId: 'components-button--default' }; - 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 }); - } + 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('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('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(); - 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 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' }); }); - 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', - }); - return new Response('{}'); - }); - const orchestrator = createOrchestrator({ - channelServer, - driverManifest, - fetch, - serverUrl: 'https://localhost', - timeoutMs: 100, + 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', }); + const replacement = new Client(); + server.emit('connection', replacement); + replacement.authenticate(); + expect(replacement.epoch).not.toBe(client.epoch); + // The old socket can neither authenticate again nor replace the new epoch. 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); + client.ready(); + await expect(orchestrator.getCurrentStory()).resolves.toBeNull(); }); - 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(() => {}); - }); - const orchestrator = createOrchestrator({ - channelServer, - driverManifest, - fetch, - serverUrl: 'https://localhost', - timeoutMs: 10, - }); + 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(); - await expect(orchestrator.selectStory({ requestId: 'request', runId: 'run', storyId: 'components-button--default' })).rejects.toThrow( - 'Timed out navigating', + controller.abort(); + await expect(orchestrator.selectStory(request, { lifetimeId: 'session', signal: controller.signal })).rejects.toHaveProperty( + 'effect', + 'none', ); - expect(signal?.aborted).toBe(true); - }); - - 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, - }); + 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(); - expect(JSON.parse(client.sent[0])).toEqual({ type: 'furn:desktop:request-hello', args: [] }); - client.sent.length = 0; - - const selection = orchestrator.selectStory({ - requestId: 'request-1', - runId: 'run-1', - storyId: 'components-button--default', - }); - 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' }], - }); - client.receive('furn:desktop:story-ready', { - portablePlanDigest: 'portable-digest', - previewGeneration: 1, - requestId: 'request-1', - runId: 'run-1', - storyId: 'components-button--default', + client.ready(); + await expect(orchestrator.selectStory({ ...request, requestId: 'new' })).rejects.toMatchObject({ + data: { reason: 'relaunch-required' }, + effect: 'none', }); + }); - await expect(selection).resolves.toEqual({ - previewGeneration: 1, - runId: 'run-1', - storyId: 'components-button--default', - }); - await expect(orchestrator.getCurrentStory()).resolves.toMatchObject({ runId: 'run-1' }); + test('a ready event cannot disable the local HTTP deadline', async () => { + const { client, orchestrator } = setup( + jest.fn(() => new Promise(() => {})), + 10, + ); + client.authenticate(); + const selection = orchestrator.selectStory(request); + client.ready(); + await expect(selection).rejects.toMatchObject({ effect: 'unknown', message: expect.stringContaining('Timed out') }); }); - 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('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')); + }); - const selection = orchestrator.selectStory({ - requestId: 'request-error', - runId: 'run-error', - storyId: 'components-button--default', - }); - await Promise.resolve(); - let settled = false; - void selection.then( - () => { - settled = true; - }, - () => { - settled = true; - }, - ); - 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('{}')); + 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('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('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', }); - - await expect(orchestrator.selectStory({ requestId: 'request', runId: 'run', storyId: 'missing--story' })).rejects.toThrow( - 'not present', - ); }); - 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('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..06eee2fbf2 100644 --- a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts +++ b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts @@ -1,18 +1,18 @@ +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 { 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; @@ -35,10 +35,10 @@ export type StorybookChannelOrchestratorOptions = { }; type PendingSelection = { + epoch: string; reject(error: Error): void; request: StorySelectionRequest; resolve(result: StoryReadyResult): void; - timer: ReturnType; }; export class StorybookChannelOrchestrator implements StoryOrchestrator { @@ -48,10 +48,14 @@ export class StorybookChannelOrchestrator implements StoryOrchestrator { private readonly timeoutMs: number; private readonly errorOutput: DesktopStorybookErrorOutput; 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 bridgeClient?: ChannelClient; + private bridgeConnected = false; + private needsRelaunch = false; + private selectionSequence = 0; private currentStory: StoryReadyResult | null = null; constructor({ @@ -68,37 +72,64 @@ export class StorybookChannelOrchestrator implements StoryOrchestrator { this.timeoutMs = timeoutMs; this.errorOutput = errorOutput; 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'); + 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); + 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.fetch(`${this.serverUrl}/select-story-sync/${encodeURIComponent(request.storyId)}`, { method: 'POST', signal: controller.signal, @@ -108,44 +139,66 @@ export class StorybookChannelOrchestrator implements StoryOrchestrator { 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); - }), - ]); + const [, result] = await Promise.race([Promise.all([navigation, ready]), failure]); + this.currentStory = result; + return result; + } catch (error) { + this.currentStory = null; + if (dispatched) { + // Upstream HTTP cancellation does not retract setCurrentStory or fence its eventual render. + 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 (context?.signal.aborted) throw new StoryProviderError('Story args update was cancelled before dispatch.', 'none'); this.requireStory(storyId); - this.broadcast('updateStoryArgs', { storyId, updatedArgs: args }); + 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) return; + this.bridgeClient = undefined; + this.bridgeConnected = false; + this.needsRelaunch = true; + this.currentStory = null; + for (const pending of this.pending.values()) pending.reject(new Error('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 +206,112 @@ 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 || + 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 - ) { + payload.nonce !== expected.bridgeNonce || + (this.bridgeClient && this.bridgeClient !== client) + ) return; - } - if (this.bridgeClient && this.bridgeClient !== client) { - return; - } this.bridgeClient = client; - this.resolveBridgeWaiters(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; - } + private initializeBridge(client: ChannelClient): void { + if (client !== this.bridgeClient || !this.initializedClients.has(client) || this.pending.size || this.fencedClients.has(client)) return; this.bridgeConnected = true; - for (const resolve of this.bridgeWaiters) { - resolve(); - } - this.bridgeWaiters.clear(); + this.needsRelaunch = false; + } + + 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 acceptReady(client: ChannelClient, payload: Record): void { - if (client !== this.bridgeClient) { - return; - } - const requestId = payload.requestId; + const pending = this.correlatedPending(client, payload); if ( - typeof requestId !== 'string' || - typeof payload.runId !== 'string' || - typeof payload.storyId !== 'string' || - typeof payload.previewGeneration !== 'number' || + !pending || + payload.markerVersion !== 1 || + !Number.isSafeInteger(payload.previewGeneration) || + (payload.previewGeneration as number) < 1 || 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); + pending.resolve({ + connectionEpoch: pending.epoch, + markerVersion: 1, + previewGeneration: payload.previewGeneration as number, + ...pending.request, + }); } 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 || - typeof payload.requestId !== 'string' || - typeof payload.runId !== 'string' || - typeof payload.storyId !== 'string' + 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 ) { - return; - } - const pending = this.pending.get(payload.requestId); - if (!pending || pending.request.runId !== payload.runId || pending.request.storyId !== payload.storyId) { - return; - } - this.rejectPending( - payload.requestId, - new Error(typeof payload.message === 'string' ? payload.message : 'The Storybook runtime failed to render the story.'), - ); - } - - 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 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 rejectPending(requestId: string, error: Error): void { - const pending = this.pending.get(requestId); - if (!pending) { - return; - } - clearTimeout(pending.timer); - this.pending.delete(requestId); - pending.reject(error); - } - - private cancelPending(requestId: string): void { - const pending = this.pending.get(requestId); - if (!pending) { - return; + 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 { + if (!this.bridgeConnected || !this.bridgeClient || this.bridgeClient.readyState !== 1 || this.needsRelaunch) { + throw new StoryBridgeUnavailableError( + this.driverManifest.endpoint, + this.driverManifest.renderer, + this.needsRelaunch ? 'relaunch-required' : 'bridge-unavailable', + ); } - this.bridgeClient.send(message); + return this.bridgeClient; } } From edbfac2a01a0fe5f31e43514efa7653797aaee20 Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:29:48 -0800 Subject: [PATCH 03/36] Keep lost Storybook context fenced across fresh preview references Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/agentic/desktop-driver/README.md | 2 ++ packages/agentic/desktop-driver/src/operations/act.ts | 2 +- .../agentic/desktop-driver/src/operations/runtime.ts | 2 ++ .../desktop-driver/src/server/sessionStories.ts | 8 ++++++-- .../desktop-driver/src/server/storyOperations.test.ts | 11 +++++++++++ 5 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/agentic/desktop-driver/README.md b/packages/agentic/desktop-driver/README.md index ee8e6e5d2d..835958742d 100644 --- a/packages/agentic/desktop-driver/README.md +++ b/packages/agentic/desktop-driver/README.md @@ -22,6 +22,8 @@ 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 diff --git a/packages/agentic/desktop-driver/src/operations/act.ts b/packages/agentic/desktop-driver/src/operations/act.ts index 08797ed7ab..0214525a58 100644 --- a/packages/agentic/desktop-driver/src/operations/act.ts +++ b/packages/agentic/desktop-driver/src/operations/act.ts @@ -210,7 +210,7 @@ export async function proveSelector( 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.story?.verification === 'unconfirmed') { + 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()); diff --git a/packages/agentic/desktop-driver/src/operations/runtime.ts b/packages/agentic/desktop-driver/src/operations/runtime.ts index 3fc1a2d3fb..d8e0baa0db 100644 --- a/packages/agentic/desktop-driver/src/operations/runtime.ts +++ b/packages/agentic/desktop-driver/src/operations/runtime.ts @@ -61,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. */ diff --git a/packages/agentic/desktop-driver/src/server/sessionStories.ts b/packages/agentic/desktop-driver/src/server/sessionStories.ts index a59ccb21f7..26996fce2c 100644 --- a/packages/agentic/desktop-driver/src/server/sessionStories.ts +++ b/packages/agentic/desktop-driver/src/server/sessionStories.ts @@ -12,6 +12,7 @@ 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 @@ -44,6 +45,7 @@ export async function changeStory( } const verified = await verifyStoryMarker(session, result, signal); signal.throwIfAborted(); + unavailableContexts.delete(session); session.story = result; return { result, verified }; }, @@ -106,7 +108,7 @@ export async function readCurrentStory( ): 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 }), @@ -115,6 +117,7 @@ export async function readCurrentStory( { signal }, ); if (!current || !matchesStoryMarker(JSON.stringify(current), story)) { + unavailableContexts.add(session); sessions.invalidatePreview(session); session.story = null; return { changed: true }; @@ -128,6 +131,7 @@ export async function readCurrentStory( 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 }; @@ -137,7 +141,7 @@ export async function readCurrentStory( export async function assertStoryContext(session: DesktopSession, sessions: SessionManager): Promise { const current = await readCurrentStory(session, sessions, session.commandAbortController.signal); - if (current.changed || current.story?.verification === 'unconfirmed') { + 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', diff --git a/packages/agentic/desktop-driver/src/server/storyOperations.test.ts b/packages/agentic/desktop-driver/src/server/storyOperations.test.ts index 8276456a83..c1ef8f05f2 100644 --- a/packages/agentic/desktop-driver/src/server/storyOperations.test.ts +++ b/packages/agentic/desktop-driver/src/server/storyOperations.test.ts @@ -121,6 +121,8 @@ describe('story operations', () => { 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(); }, @@ -213,12 +215,21 @@ describe('story operations', () => { 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(); }); }); From 6fca1478871f41ec884fb4bd2cff66cff8197833 Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:23:08 -0800 Subject: [PATCH 04/36] Persist authenticated Storybook epochs per app incarnation Use stable private recovery storage and exact native application probes for renderer-specific restart guidance without inferring readiness or clearing quarantine. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/storybook-durable-bridge-epochs.md | 7 + .../references/current-status.md | 38 ++-- .../desktop-test/references/contributing.md | 6 + .../desktop-test/src/contracts.contract.cjs | 26 +++ .../agentic/desktop-test/src/driverService.ts | 7 +- .../storybook-desktop/config/server.cjs | 31 +++- .../references/contributing.md | 15 ++ .../src/driver/BridgeEpochJournal.ts | 111 ++++++++++++ .../StorybookChannelOrchestrator.test.ts | 162 +++++++++++++++++- .../driver/StorybookChannelOrchestrator.ts | 68 +++++++- 10 files changed, 442 insertions(+), 29 deletions(-) create mode 100644 .changeset/storybook-durable-bridge-epochs.md create mode 100644 packages/agentic/storybook-desktop/src/driver/BridgeEpochJournal.ts 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/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index a57acf9435..b790095e51 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -19,25 +19,25 @@ 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. 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. | -| 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 | Minimal catalog port; local lifetime/signal context, authenticated epochs, versioned marker conformance, prompt disconnect failure, and stale preview rejection. Unknown navigation effects still quarantine. | -| Story args on protected driver | Intentionally unsupported | `story-args-unsupported`; legacy unprotected args remain separate. | -| Exclusive Storybook control | Work in progress | Preview mutations recheck provider/native context; chrome/secondary references survive. Channel selection/prepare ingress can still bypass authority; controlled ingress is separate work. | -| 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 | Epoch replacement is fail-closed, not transparent restoration. Inspected RN Storybook 10.5.4 / Storybook 10.6.3 transport does not reconnect; endpoint/renderer-specific relaunch guidance, not quarantine 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. 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. | +| 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. Unknown navigation effects still quarantine. | +| Story args on protected driver | Intentionally unsupported | `story-args-unsupported`; legacy unprotected args remain separate. | +| Exclusive Storybook control | Work in progress | Preview mutations recheck provider/native context; chrome/secondary references survive. Channel selection/prepare ingress can still bypass authority; controlled ingress is separate work. | +| 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 | A private authenticated-epoch record plus exact live-incarnation proof yields renderer-specific relaunch-required after restart, effect none. Initial absence, exited/replaced apps and failed/unknown probes stay bridge-unavailable. 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 diff --git a/packages/agentic/desktop-test/references/contributing.md b/packages/agentic/desktop-test/references/contributing.md index e663567618..71f86334ad 100644 --- a/packages/agentic/desktop-test/references/contributing.md +++ b/packages/agentic/desktop-test/references/contributing.md @@ -14,6 +14,12 @@ settled shutdown composition lives in the real `src/driverService.ts` module (also used by the Storybook channel child); reports are implemented in `src/reporting/`; and WDIO session wiring is in `src/wdio/`. +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. diff --git a/packages/agentic/desktop-test/src/contracts.contract.cjs b/packages/agentic/desktop-test/src/contracts.contract.cjs index 7fef3c4e35..d97aa84fb6 100644 --- a/packages/agentic/desktop-test/src/contracts.contract.cjs +++ b/packages/agentic/desktop-test/src/contracts.contract.cjs @@ -974,6 +974,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/driverService.ts b/packages/agentic/desktop-test/src/driverService.ts index 6dd4d71c6a..844eb3583d 100644 --- a/packages/agentic/desktop-test/src/driverService.ts +++ b/packages/agentic/desktop-test/src/driverService.ts @@ -31,7 +31,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 }; @@ -65,8 +68,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/storybook-desktop/config/server.cjs b/packages/agentic/storybook-desktop/config/server.cjs index 99421e224d..736daef5fc 100644 --- a/packages/agentic/storybook-desktop/config/server.cjs +++ b/packages/agentic/storybook-desktop/config/server.cjs @@ -15,9 +15,10 @@ 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 [{ 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; @@ -155,15 +156,34 @@ 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(); + } + }, + }, }), - }, + }), }); } if (agent) { @@ -185,11 +205,12 @@ async function startDesktopStorybookServer({ } } -function createChannelOrchestrator({ channelServer, driverManifest, serverUrl, StorybookChannelOrchestrator }) { +function createChannelOrchestrator({ channelServer, driverManifest, serverUrl, StorybookChannelOrchestrator, recovery }) { return new StorybookChannelOrchestrator({ channelServer, driverManifest, serverUrl, + recovery, }); } diff --git a/packages/agentic/storybook-desktop/references/contributing.md b/packages/agentic/storybook-desktop/references/contributing.md index 207be56c30..31406bced2 100644 --- a/packages/agentic/storybook-desktop/references/contributing.md +++ b/packages/agentic/storybook-desktop/references/contributing.md @@ -21,6 +21,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/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 4ef18bf55d..b1c6ca8fb8 100644 --- a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts +++ b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts @@ -8,6 +8,7 @@ import { matchesStoryMarker, type StorySelectionRequest } from '@fluentui-react- import type { DesktopStorybookDriverManifest } from './driverManifest.js'; import { StorybookChannelOrchestrator } from './StorybookChannelOrchestrator.js'; +import type { BridgeEpochRecovery } from './BridgeEpochJournal.js'; class Client extends EventEmitter { readyState = 1; @@ -91,22 +92,179 @@ const manifest: DesktopStorybookDriverManifest = { testIDPrefix: 'storybook', }; -function setup(fetch: typeof globalThis.fetch = jest.fn(async () => new Response('{}')), timeoutMs = 100) { +function setup( + fetch: typeof globalThis.fetch = jest.fn(async () => new Response('{}')), + timeoutMs = 100, + recovery?: BridgeEpochRecovery, + renderer: 'fabric' | 'paper' = 'fabric', +) { const server = Object.assign(new EventEmitter(), { clients: new Set() }); const client = new Client(); server.clients.add(client); const errorOutput = { write: jest.fn() }; const orchestrator = new StorybookChannelOrchestrator({ channelServer: server, - driverManifest: manifest, + driverManifest: { ...manifest, renderer }, errorOutput, fetch, serverUrl: 'https://localhost', timeoutMs, + recovery, }); return { client, errorOutput, fetch, orchestrator, server }; } +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', + }); + 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', + }); + 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('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 }); + + 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', + }); + 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', + }); + expect(restarted.errorOutput.write).toHaveBeenCalled(); + expect(store.probeApplication).not.toHaveBeenCalled(); + expect(restarted.fetch).not.toHaveBeenCalled(); + }); +}); + describe('StorybookChannelOrchestrator epochs and lifetime', () => { test('absent and hello-only bridges reject before dispatch with no effect', async () => { const { client, fetch, orchestrator } = setup(); diff --git a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts index 06eee2fbf2..c18c88408c 100644 --- a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts +++ b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts @@ -10,6 +10,7 @@ import type { 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 }; @@ -32,6 +33,7 @@ export type StorybookChannelOrchestratorOptions = { serverUrl: string; timeoutMs?: number; errorOutput?: DesktopStorybookErrorOutput; + recovery?: BridgeEpochRecovery; }; type PendingSelection = { @@ -52,6 +54,9 @@ export class StorybookChannelOrchestrator implements StoryOrchestrator { private readonly usedRequests = new WeakMap>(); private readonly initializedClients = new WeakSet(); 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; @@ -65,12 +70,20 @@ export class StorybookChannelOrchestrator implements StoryOrchestrator { serverUrl, timeoutMs = 30_000, errorOutput = process.stderr, + recovery, }: StorybookChannelOrchestratorOptions) { this.driverManifest = driverManifest; this.fetch = fetch; this.serverUrl = serverUrl.replace(/\/$/, ''); this.timeoutMs = timeoutMs; this.errorOutput = errorOutput; + 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); } @@ -97,6 +110,8 @@ export class StorybookChannelOrchestrator implements StoryOrchestrator { private async selectAndPrepareStory(request: StorySelectionRequest, context?: StoryProviderContext): Promise { this.requireStory(request.storyId); 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'); @@ -173,6 +188,8 @@ export class StorybookChannelOrchestrator implements StoryOrchestrator { async updateArgs(storyId: string, args: Readonly>, context?: StoryProviderContext): Promise { if (context?.signal.aborted) throw new StoryProviderError('Story args update was cancelled before dispatch.', 'none'); this.requireStory(storyId); + 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 }] })); @@ -243,11 +260,60 @@ export class StorybookChannelOrchestrator implements StoryOrchestrator { ) return; this.bridgeClient = client; + if (this.journal) this.recordBridge(client); this.initializeBridge(client); } + 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 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; + } + let relaunch = false; + try { + relaunch = await this.journal!.requiresRelaunch(); + } catch (error) { + writeDesktopStorybookFailure('checking the authenticated bridge incarnation', error, this.errorOutput); + } + throw new StoryBridgeUnavailableError( + this.driverManifest.endpoint, + this.driverManifest.renderer, + relaunch ? 'relaunch-required' : 'bridge-unavailable', + ); + } + private initializeBridge(client: ChannelClient): void { - if (client !== this.bridgeClient || !this.initializedClients.has(client) || this.pending.size || this.fencedClients.has(client)) return; + 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; } From 8d08c889b54bb7c91b19020d7023d604637ebc25 Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:32:26 -0800 Subject: [PATCH 05/36] Record bounded Paper and Fabric restart qualification Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../references/current-status.md | 113 +++++++++++++----- 1 file changed, 85 insertions(+), 28 deletions(-) diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index b790095e51..55eb02499f 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,25 +19,25 @@ 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. 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. | -| 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. Unknown navigation effects still quarantine. | -| Story args on protected driver | Intentionally unsupported | `story-args-unsupported`; legacy unprotected args remain separate. | -| Exclusive Storybook control | Work in progress | Preview mutations recheck provider/native context; chrome/secondary references survive. Channel selection/prepare ingress can still bypass authority; controlled ingress is separate work. | -| 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 | A private authenticated-epoch record plus exact live-incarnation proof yields renderer-specific relaunch-required after restart, effect none. Initial absence, exited/replaced apps and failed/unknown probes stay bridge-unavailable. 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. | +| 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. 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. | +| 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`; legacy unprotected args remain separate. | +| Exclusive Storybook control | Work in progress | Preview mutations recheck provider/native context; chrome/secondary references survive. Channel selection/prepare ingress can still bypass authority; controlled ingress is separate work. | +| 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 @@ -45,14 +45,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,63 @@ Live requalification of these lanes is tracked in the repeated-run support matrix in [#4451](https://github.com/microsoft/fluentui-react-native/issues/4451). +### 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 Preserved from the former root capability report, not newly measured here. From 0df9218b76643998073c9fdac3b9a6eb8940900c Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:05:08 -0800 Subject: [PATCH 06/36] Add cooperative desktop process lifelines and correlated readiness Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/f3-process-lifelines.md | 10 + packages/agentic/desktop-test/README.md | 6 + .../desktop-test/config/process-lifeline.mjs | 107 +++++++++ .../desktop-test/config/process-path.cjs | 4 + .../desktop-test/config/process-path.d.cts | 1 + .../fixtures/process-control/child.mjs | 34 +++ .../fixtures/process-control/parent.mjs | 14 ++ packages/agentic/desktop-test/package.json | 5 + .../desktop-test/references/current-status.md | 36 +-- packages/agentic/desktop-test/src/commands.ts | 31 ++- .../agentic/desktop-test/src/ownedService.ts | 20 ++ .../desktop-test/src/processControl.test.ts | 155 +++++++++++++ .../desktop-test/src/processControl.ts | 219 ++++++++++++++++++ .../storybook-desktop/config/server.cjs | 41 +++- .../references/integration.md | 24 ++ .../src/cli/DesktopStorybookCli.test.ts | 61 +++-- .../src/cli/DesktopStorybookCli.ts | 38 +-- .../src/driver/serverIntegration.test.ts | 6 + 18 files changed, 743 insertions(+), 69 deletions(-) create mode 100644 .changeset/f3-process-lifelines.md create mode 100644 packages/agentic/desktop-test/config/process-lifeline.mjs create mode 100644 packages/agentic/desktop-test/config/process-path.cjs create mode 100644 packages/agentic/desktop-test/config/process-path.d.cts create mode 100644 packages/agentic/desktop-test/fixtures/process-control/child.mjs create mode 100644 packages/agentic/desktop-test/fixtures/process-control/parent.mjs create mode 100644 packages/agentic/desktop-test/src/processControl.test.ts create mode 100644 packages/agentic/desktop-test/src/processControl.ts 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/packages/agentic/desktop-test/README.md b/packages/agentic/desktop-test/README.md index fda75cc0e0..15dde284a8 100644 --- a/packages/agentic/desktop-test/README.md +++ b/packages/agentic/desktop-test/README.md @@ -71,6 +71,12 @@ 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. 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..927193e6ee --- /dev/null +++ b/packages/agentic/desktop-test/config/process-lifeline.mjs @@ -0,0 +1,107 @@ +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. + 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..c948faaef8 --- /dev/null +++ b/packages/agentic/desktop-test/fixtures/process-control/child.mjs @@ -0,0 +1,34 @@ +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 === '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 f86c5cecd6..6a5119a075 100644 --- a/packages/agentic/desktop-test/package.json +++ b/packages/agentic/desktop-test/package.json @@ -55,6 +55,11 @@ "import": "./lib/driverService.js", "default": "./src/driverService.ts" }, + "./process-control": { + "types": "./lib/processControl.d.ts", + "import": "./lib/processControl.js", + "default": "./src/processControl.ts" + }, "./wdio": { "types": "./lib/wdio/index.d.ts", "import": "./lib/wdio/index.js", diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 0e6bd49ec4..fe782b8ffe 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -17,24 +17,24 @@ 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 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 | 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. | +| 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. | See [lifecycle](lifecycle.md), [commands](commands.md), and `@fluentui-react-native/desktop-driver/references/current-status.md` for the 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/ownedService.ts b/packages/agentic/desktop-test/src/ownedService.ts index e5f1d39587..e26f59968c 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; + } finally { + unregister(); + finishDesktopProcess(); + } +} + +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 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 () => { + for (const child of children.splice(0)) if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + fs.rmSync(root, { force: true, recursive: true }); + }); + + 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.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('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('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..dde92d31c7 --- /dev/null +++ b/packages/agentic/desktop-test/src/processControl.ts @@ -0,0 +1,219 @@ +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; + socket.on('error', fail); + socket.on('close', () => { + sockets.delete(socket); + for (const [service, record] of records) { + if (record.processId !== processId) continue; + records.delete(service); + if (!disposed || !stopping) fail(new Error(`Owned service "${service}" lost its readiness producer without settled disposal.`)); + } + 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/storybook-desktop/config/server.cjs b/packages/agentic/storybook-desktop/config/server.cjs index 99421e224d..b393bc1d3e 100644 --- a/packages/agentic/storybook-desktop/config/server.cjs +++ b/packages/agentic/storybook-desktop/config/server.cjs @@ -15,6 +15,14 @@ async function startDesktopStorybookServer({ const requireFromProject = createRequire(path.join(projectRoot, 'package.json')); const { 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 }] = await Promise.all([ import('@fluentui-react-native/desktop-test/driver-service'), import('@fluentui-react-native/desktop-test/reporting'), @@ -31,7 +39,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'); @@ -56,9 +64,18 @@ async function startDesktopStorybookServer({ process.exitCode = await report.close( failures.length ? new AggregateError(failures, 'Storybook channel service failed.') : undefined, ); + unregisterDisposal(); + finishDesktopProcess(); + if (failures.length) throw new AggregateError(failures, 'Storybook channel disposal is incomplete.'); })()); + 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 +83,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 && @@ -140,6 +162,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, @@ -166,20 +189,26 @@ async function startDesktopStorybookServer({ }, }); } + 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; } diff --git a/packages/agentic/storybook-desktop/references/integration.md b/packages/agentic/storybook-desktop/references/integration.md index d3307b5cc0..9e3d0886b6 100644 --- a/packages/agentic/storybook-desktop/references/integration.md +++ b/packages/agentic/storybook-desktop/references/integration.md @@ -67,6 +67,30 @@ 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. +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 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 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 diff --git a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts index c94989b0b2..5e44d2010f 100644 --- a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts @@ -191,6 +191,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); @@ -202,6 +207,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; @@ -501,12 +507,13 @@ describe('DesktopStorybookCli', () => { const services = new DesktopServiceScope(runner); 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: [ @@ -534,7 +541,7 @@ describe('DesktopStorybookCli', () => { throw new Error('unreachable'); }, onReady: () => written.push('onReady'), - output: { write: (chunk: string) => written.push(`${fs.existsSync(readyPath) ? 'published' : 'unpublished'}: ${chunk}`) > 0 }, + output: { write: (chunk: string) => written.push(`${published ? 'published' : 'unpublished'}: ${chunk}`) > 0 }, runner, services, }); @@ -548,6 +555,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 }); @@ -555,6 +568,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', @@ -585,12 +625,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, diff --git a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts index 5a199c7cda..30e699ef4a 100644 --- a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts @@ -40,7 +40,6 @@ import { import { desktopStorybookTargetId, type CreateDesktopStorybookDriverManifestOptions, - type DesktopStorybookAgentManifest, type DesktopStorybookDriverManifest, } from '../driver/driverManifest.js'; import { NodeDesktopCommandRunner, STORYBOOK_VERBOSE } from './commandRunner.js'; @@ -249,7 +248,7 @@ export class DesktopStorybookCli { this.services.start('metro', this.prepareCommand(defaultMetroCommand(resolvedInstance), platform, resolvedInstance)); const failures: unknown[] = []; try { - this.services.start( + const channel = this.services.start( 'storybook-channel', this.prepareCommand( { @@ -269,7 +268,14 @@ export class DesktopStorybookCli { ]); const agentManifest = driverManifest.manifest.agent; if (agentManifest) { - await this.services.guard(waitForAgentConnection(agentManifest, 120_000, this.services.controller.signal)); + if (!channel.waitForReadiness) throw new Error('The owned Storybook channel does not support correlated service readiness.'); + await this.services.guard( + channel.waitForReadiness( + { tier: 'service-ready', service: 'storybook-channel', descriptor: agentManifest.descriptor }, + 120_000, + this.services.controller.signal, + ), + ); } if (this.reporterMode === 'json' || this.reporterMode === 'ndjson') { this.writeStructured({ @@ -1163,32 +1169,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/driver/serverIntegration.test.ts b/packages/agentic/storybook-desktop/src/driver/serverIntegration.test.ts index bdea026b70..97921d9d83 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')), From 9f1bfff06b3163c2b036e8faafbea9192ba2a353 Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:06:38 -0800 Subject: [PATCH 07/36] Protect agent Storybook ingress and use authoritative smoke traversal Retain protected args refusal, isolate legacy channels, and preserve epoch-aware settlement for direct authenticated dispatch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/f5-exclusive-storybook-ingress.md | 9 ++ .../references/current-status.md | 40 +++--- .../src/server/storyOperations.test.ts | 22 +++ packages/agentic/storybook-desktop/AGENTS.md | 2 + packages/agentic/storybook-desktop/README.md | 4 + .../config/controlled-channel.cjs | 59 ++++++++ .../config/controlled-channel.d.cts | 12 ++ .../storybook-desktop/config/server.cjs | 19 ++- .../config/storybook-control.cjs | 62 +++++--- .../agentic/storybook-desktop/package.json | 6 +- .../references/contributing.md | 7 + .../references/integration.md | 42 ++++-- .../storybook-desktop/references/readiness.md | 17 ++- .../src/cli/DesktopStorybookCli.test.ts | 47 +++++- .../src/cli/DesktopStorybookCli.ts | 41 +++++- .../src/cli/createDesktopStorybookCommand.ts | 1 + .../src/cli/storyTraversal.test.ts | 59 ++++++++ .../src/cli/storyTraversal.ts | 62 ++++++++ .../StorybookChannelOrchestrator.test.ts | 134 ++++++++++++++++++ .../driver/StorybookChannelOrchestrator.ts | 31 ++-- yarn.lock | 31 ++-- 21 files changed, 617 insertions(+), 90 deletions(-) create mode 100644 .changeset/f5-exclusive-storybook-ingress.md create mode 100644 packages/agentic/storybook-desktop/config/controlled-channel.cjs create mode 100644 packages/agentic/storybook-desktop/config/controlled-channel.d.cts create mode 100644 packages/agentic/storybook-desktop/src/cli/storyTraversal.test.ts create mode 100644 packages/agentic/storybook-desktop/src/cli/storyTraversal.ts 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/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 4479ca5a62..8810309d8a 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -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 | 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`; legacy unprotected args remain separate. | -| Exclusive Storybook control | Work in progress | Preview mutations recheck provider/native context; chrome/secondary references survive. Channel selection/prepare ingress can still bypass authority; controlled ingress is separate work. | -| 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. | +| 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 diff --git a/packages/agentic/desktop-driver/src/server/storyOperations.test.ts b/packages/agentic/desktop-driver/src/server/storyOperations.test.ts index c1ef8f05f2..bd535ec42a 100644 --- a/packages/agentic/desktop-driver/src/server/storyOperations.test.ts +++ b/packages/agentic/desktop-driver/src/server/storyOperations.test.ts @@ -319,6 +319,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', @@ -326,6 +345,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/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 736daef5fc..bb19304746 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,7 +15,7 @@ async function startDesktopStorybookServer({ } const requireFromProject = createRequire(path.join(projectRoot, 'package.json')); - const { createChannelServer } = requireFromProject('@storybook/react-native/node'); + const { buildIndex, createChannelServer } = requireFromProject('@storybook/react-native/node'); const [{ startNativeDesktopService }, { createDesktopReportSession }, { NativeDesktopHost }] = await Promise.all([ import('@fluentui-react-native/desktop-test/driver-service'), import('@fluentui-react-native/desktop-test/reporting'), @@ -22,6 +23,7 @@ async function startDesktopStorybookServer({ ]); const report = await createDesktopReportSession({ artifactsRoot: path.join(projectRoot, 'artifacts', 'desktop'), kind: 'lifecycle' }); let server; + let controlledChannel; let driver; let agentReadyPath; let keepAlive; @@ -45,7 +47,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()))); } @@ -104,9 +108,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.'); } @@ -211,6 +219,7 @@ function createChannelOrchestrator({ channelServer, driverManifest, serverUrl, S driverManifest, serverUrl, recovery, + controlled: driverManifest.agent !== undefined, }); } diff --git a/packages/agentic/storybook-desktop/config/storybook-control.cjs b/packages/agentic/storybook-desktop/config/storybook-control.cjs index 40df0e989b..95e027a75b 100644 --- a/packages/agentic/storybook-desktop/config/storybook-control.cjs +++ b/packages/agentic/storybook-desktop/config/storybook-control.cjs @@ -54,7 +54,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}".`); } @@ -79,7 +79,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, 30_000); + else await selectStory(id); if (settleMilliseconds > 0) { await new Promise((resolve) => setTimeout(resolve, settleMilliseconds)); } @@ -125,22 +126,7 @@ async function smoke() { } 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); @@ -165,6 +151,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'), @@ -174,11 +180,29 @@ 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 (smokePhase !== 'tests' && 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, + }); + } + await smoke(traversal); } 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 86b5e74526..8219e92998 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 31406bced2..1af89b4cd3 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. + Keep story callbacks statically extractable and authentication checks fail-closed. Update references when commands, supported config, outputs, or readiness contracts change. diff --git a/packages/agentic/storybook-desktop/references/integration.md b/packages/agentic/storybook-desktop/references/integration.md index f54b7a4b75..61f080045f 100644 --- a/packages/agentic/storybook-desktop/references/integration.md +++ b/packages/agentic/storybook-desktop/references/integration.md @@ -67,14 +67,40 @@ 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. Epoch/cancellation-aware story providers -reject stale preview mutations without invalidating chrome or live secondary -windows. Interrupted navigation still quarantines when upstream work cannot -be proven fenced. Automatic transport reconnect, durable recovery wiring, and -channel ingress authority remain work in progress; see +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. + +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 c8f17a1272..04f68a91c6 100644 --- a/packages/agentic/storybook-desktop/references/readiness.md +++ b/packages/agentic/storybook-desktop/references/readiness.md @@ -34,9 +34,11 @@ An old socket cannot authenticate or complete work on its replacement. 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. -Selection waits for both HTTP navigation and correlated runtime readiness, -then the driver verifies the native marker within the same render deadline. -Disconnect promptly rejects pending work and aborts HTTP. Cancellation, +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 @@ -55,6 +57,15 @@ 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. Generated instance files remain under the consumer's ignored diff --git a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts index c94989b0b2..58ec37db9e 100644 --- a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts @@ -67,6 +67,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, @@ -939,6 +940,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); @@ -958,9 +990,6 @@ describe('DesktopStorybookCli', () => { }), ); } - if (url.includes('select-story-sync')) { - events.push(`select:${url.split('/').at(-1)}`); - } return new Response('{}'); }); const runSmokeTests = jest.fn(async () => { @@ -999,12 +1028,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 5a199c7cda..780d1894eb 100644 --- a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts @@ -48,6 +48,7 @@ import type { DesktopCommandRunner, PreparedDesktopCommand, RunningDesktopComman import { formatDesktopStorybookSmokeTestSummary, runDesktopStorybookSmokeTests } from './smokeTests.js'; import { matchesWdioTest, runWdioStoryTests, selectWdioStories } from '../testing/runWdioTests.js'; import { resolveWdioOptions, type DesktopStorybookWdioOptions } from '../config/wdio.js'; +import { connectStoryTraversal } from './storyTraversal.js'; type ResolvedDesktopStorybookInstance = DesktopStorybookInstance & { driverManifestPath?: string; @@ -74,6 +75,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; @@ -125,6 +127,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; @@ -165,6 +168,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; @@ -556,9 +560,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({ @@ -755,7 +781,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, @@ -775,7 +806,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); diff --git a/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts b/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts index 263ab8e307..de931fefbc 100644 --- a/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts +++ b/packages/agentic/storybook-desktop/src/cli/createDesktopStorybookCommand.ts @@ -93,6 +93,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/StorybookChannelOrchestrator.test.ts b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts index b1c6ca8fb8..039198bd82 100644 --- a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts +++ b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts @@ -9,6 +9,7 @@ import { matchesStoryMarker, type StorySelectionRequest } from '@fluentui-react- import type { DesktopStorybookDriverManifest } from './driverManifest.js'; import { StorybookChannelOrchestrator } from './StorybookChannelOrchestrator.js'; import type { BridgeEpochRecovery } from './BridgeEpochJournal.js'; +import { createControlledChannelServer } from '../../config/controlled-channel.cjs'; class Client extends EventEmitter { readyState = 1; @@ -97,6 +98,7 @@ function setup( timeoutMs = 100, recovery?: BridgeEpochRecovery, renderer: 'fabric' | 'paper' = 'fabric', + controlled = false, ) { const server = Object.assign(new EventEmitter(), { clients: new Set() }); const client = new Client(); @@ -110,10 +112,142 @@ function setup( serverUrl: 'https://localhost', timeoutMs, recovery, + controlled, }); return { client, errorOutput, fetch, orchestrator, server }; } +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 errorOutput = { write: jest.fn() }; + const orchestrator = new StorybookChannelOrchestrator({ + channelServer: channel.channel, + controlled: true, + driverManifest: manifest, + fetch, + serverUrl, + errorOutput, + timeoutMs: 2000, + }); + 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); + } + 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); + 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('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(); + 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(); + }); +}); + describe('durable authenticated bridge epochs', () => { const application = { processId: 123, processStartedAt: '2026-11-06T00:00:00.000Z' }; function recovery() { diff --git a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts index c18c88408c..9c7fc1a2dc 100644 --- a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts +++ b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts @@ -34,6 +34,8 @@ export type StorybookChannelOrchestratorOptions = { timeoutMs?: number; errorOutput?: DesktopStorybookErrorOutput; recovery?: BridgeEpochRecovery; + /** Exclusive transport: dispatch only to the authenticated runtime, never upstream HTTP. */ + controlled?: boolean; }; type PendingSelection = { @@ -49,6 +51,7 @@ 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 epochs = new WeakMap(); private readonly usedRequests = new WeakMap>(); @@ -71,12 +74,14 @@ export class StorybookChannelOrchestrator implements StoryOrchestrator { 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, @@ -145,22 +150,26 @@ export class StorybookChannelOrchestrator implements StoryOrchestrator { args: [{ ...request, connectionEpoch: epoch, selectionSequence: ++this.selectionSequence }], }), ); - 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}` : '.'}`); - } - }); + 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) { - // Upstream HTTP cancellation does not retract setCurrentStory or fence its eventual render. + // A dispatched setCurrentStory may still render after cancellation, on either transport. this.needsRelaunch = true; this.bridgeConnected = false; this.fencedClients.add(client); @@ -186,6 +195,8 @@ export class StorybookChannelOrchestrator implements StoryOrchestrator { } 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); if (this.journal) await this.checkBridgeRecovery(); diff --git a/yarn.lock b/yarn.lock index 1ebc22f4c2..f2bae654a1 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" From 944ea8ec7db149c72b2b74b13d5fd59115524fdb Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:08:30 -0800 Subject: [PATCH 08/36] Reject unsuccessful cooperative disposal certification Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../agentic/desktop-test/src/ownedService.ts | 3 +- .../desktop-test/src/processControl.test.ts | 44 +++++++++++++++++++ .../storybook-desktop/config/server.cjs | 2 +- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/agentic/desktop-test/src/ownedService.ts b/packages/agentic/desktop-test/src/ownedService.ts index e26f59968c..5f0ce59a94 100644 --- a/packages/agentic/desktop-test/src/ownedService.ts +++ b/packages/agentic/desktop-test/src/ownedService.ts @@ -15,9 +15,9 @@ export async function runOwnedDesktopService(descriptorPath: string): Promise { 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('stalled readiness and cancellation do not promote another readiness tier', async () => { const control = createDesktopProcessControl(() => undefined); try { diff --git a/packages/agentic/storybook-desktop/config/server.cjs b/packages/agentic/storybook-desktop/config/server.cjs index b393bc1d3e..6c767abaec 100644 --- a/packages/agentic/storybook-desktop/config/server.cjs +++ b/packages/agentic/storybook-desktop/config/server.cjs @@ -65,8 +65,8 @@ async function startDesktopStorybookServer({ failures.length ? new AggregateError(failures, 'Storybook channel service failed.') : undefined, ); unregisterDisposal(); - finishDesktopProcess(); if (failures.length) throw new AggregateError(failures, 'Storybook channel disposal is incomplete.'); + finishDesktopProcess(); })()); const settle = async (error) => { stopping = true; From f33b430161cc2739e224a6ad4c7a96c040d609b2 Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:11:48 -0800 Subject: [PATCH 09/36] Format merged F4 qualification status Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/agentic/desktop-driver/references/current-status.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 4479ca5a62..638c48e871 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -22,10 +22,10 @@ and its sub-issues, which record dependencies and parallel waves. | 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. | +| 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. | +| 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. | From e7253ac7337a9aa3cb933662669ed6c02f2d1052 Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:20:18 -0800 Subject: [PATCH 10/36] Document F15 macOS qualification Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/f15-macos-native-qualification.md | 8 + .../references/current-status.md | 34 ++++ .../references/current-status.md | 35 ++++ .../agentic/desktop-test/references/README.md | 21 +-- .../desktop-test/references/current-status.md | 29 ++++ .../references/native-qualification.md | 163 ++++++++++++++++++ 6 files changed, 280 insertions(+), 10 deletions(-) create mode 100644 .changeset/f15-macos-native-qualification.md create mode 100644 packages/agentic/desktop-test/references/native-qualification.md 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/packages/agentic/desktop-driver-mcp/references/current-status.md b/packages/agentic/desktop-driver-mcp/references/current-status.md index 6a63eb39cd..e0e5b6d341 100644 --- a/packages/agentic/desktop-driver-mcp/references/current-status.md +++ b/packages/agentic/desktop-driver-mcp/references/current-status.md @@ -84,3 +84,37 @@ 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. diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 1e7f7b0ad6..da518055bd 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -63,6 +63,41 @@ 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. ## Measurement ledger 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/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 0e6bd49ec4..699cf9bd28 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -89,3 +89,32 @@ 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 | + +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. 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..99f5917836 --- /dev/null +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -0,0 +1,163 @@ +--- +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 current macOS procedure +is parameterized by renderer; Windows Fabric and Win32 Paper require their own +machines and must remain **Not run** on macOS. Deterministic fake-host results +and Storybook callback smoke results are supporting evidence, not proof of the +real descriptor path. + +## 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. +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 the already-prepared app using the documented app workflow. Start + either `yarn storybook driver --macos --agent` or the Storybook target via + `yarn desktop-test --target macos serve --agent`. Wait for the structured + ready record, including the private descriptor name. Do not print, copy to + artifacts, or expose the descriptor grant. +2. 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. +3. 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. + +| 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 recovery | Lose the bridge context, then render a fresh preview. | Recovery uses a new authenticated epoch and correlated marker; old context is not resumed. | +| 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 smoke against the agent path. | One driver session is used; there is no fallback to HTTP mutation routes. | +| 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**. + +## 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. 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. From 40675ecf0fcebe44ec4e90533f45ac93ad47eb23 Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:25:13 -0800 Subject: [PATCH 11/36] Clarify native qualification startup order Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../references/native-qualification.md | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/agentic/desktop-test/references/native-qualification.md b/packages/agentic/desktop-test/references/native-qualification.md index 99f5917836..e13d80abde 100644 --- a/packages/agentic/desktop-test/references/native-qualification.md +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -17,6 +17,9 @@ machines and must remain **Not run** on macOS. 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: @@ -96,16 +99,20 @@ 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 the already-prepared app using the documented app workflow. Start - either `yarn storybook driver --macos --agent` or the Storybook target via - `yarn desktop-test --target macos serve --agent`. Wait for the structured - ready record, including the private descriptor name. Do not print, copy to - artifacts, or expose the descriptor grant. -2. Connect `desktop-driver-mcp` using its default +1. Start either `yarn storybook driver --macos --agent` or the Storybook target + via `yarn desktop-test --target macos serve --agent`. 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`. 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. -3. Run the following cases against the real descriptor and native service. +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 From 6d9c10dd6f14fdfdc1d07d70dcbc35d5b0b3ac0e Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:27:12 -0800 Subject: [PATCH 12/36] Clarify integrated native qualification cases Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../references/native-qualification.md | 51 ++++++++++--------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/packages/agentic/desktop-test/references/native-qualification.md b/packages/agentic/desktop-test/references/native-qualification.md index e13d80abde..865a61e803 100644 --- a/packages/agentic/desktop-test/references/native-qualification.md +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -118,31 +118,32 @@ supervisor path at a time; do not start `storybook driver --agent` and the injected dispatch point and whether a mutation was applied; never retry an ambiguous mutation. -| 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 recovery | Lose the bridge context, then render a fresh preview. | Recovery uses a new authenticated epoch and correlated marker; old context is not resumed. | -| 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 smoke against the agent path. | One driver session is used; there is no fallback to HTTP mutation routes. | -| F5 legacy separation | Exercise the non-agent legacy channel separately. | Results are labeled unprotected/legacy and are not counted as protected-ingress evidence. | +| 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, From 1ddbc11e8e0edc8953e7afedb77d290653ade493 Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:30:18 -0800 Subject: [PATCH 13/36] Specify Paper selection for agent qualification Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../references/native-qualification.md | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/agentic/desktop-test/references/native-qualification.md b/packages/agentic/desktop-test/references/native-qualification.md index 865a61e803..7ff4efa505 100644 --- a/packages/agentic/desktop-test/references/native-qualification.md +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -99,15 +99,20 @@ 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 `yarn storybook driver --macos --agent` or the Storybook target - via `yarn desktop-test --target macos serve --agent`. 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. +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`. Keep the driver service running. Confirm the - app connects to the instance from the driver's readiness record before - opening an MCP session. + `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 From c6162baab4bebe0e5be6a844efa620b660840a2b Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:32:18 -0800 Subject: [PATCH 14/36] feat(desktop-test): add typed resource ownership Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/desktop-test-resource-ownership.md | 6 + packages/agentic/desktop-test/package.json | 10 + .../desktop-test/references/current-status.md | 9 +- .../desktop-test/references/lifecycle.md | 43 ++- packages/agentic/desktop-test/src/cli.ts | 3 +- packages/agentic/desktop-test/src/config.ts | 66 +++- .../desktop-test/src/contracts.contract.cjs | 337 +++++++++++++++++- .../desktop-test/src/contracts.test.ts | 1 + packages/agentic/desktop-test/src/index.ts | 20 ++ .../agentic/desktop-test/src/integration.ts | 2 + .../agentic/desktop-test/src/ownership.ts | 180 ++++++++++ .../agentic/desktop-test/src/resources.ts | 124 +++++++ packages/agentic/desktop-test/src/services.ts | 199 ++++++++++- .../agentic/desktop-test/src/supervisor.ts | 137 +++++-- .../src/cli/DesktopStorybookCli.ts | 4 +- 15 files changed, 1075 insertions(+), 66 deletions(-) create mode 100644 .changeset/desktop-test-resource-ownership.md create mode 100644 packages/agentic/desktop-test/src/ownership.ts create mode 100644 packages/agentic/desktop-test/src/resources.ts 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/packages/agentic/desktop-test/package.json b/packages/agentic/desktop-test/package.json index f86c5cecd6..09bc044ddc 100644 --- a/packages/agentic/desktop-test/package.json +++ b/packages/agentic/desktop-test/package.json @@ -55,6 +55,16 @@ "import": "./lib/driverService.js", "default": "./src/driverService.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" + }, "./wdio": { "types": "./lib/wdio/index.d.ts", "import": "./lib/wdio/index.js", diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 0e6bd49ec4..a20a21769d 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 @@ -24,14 +24,15 @@ and its sub-issues. | 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. | +| 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 | 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. | +| 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. | diff --git a/packages/agentic/desktop-test/references/lifecycle.md b/packages/agentic/desktop-test/references/lifecycle.md index 29789f3cf9..f06c43eadb 100644 --- a/packages/agentic/desktop-test/references/lifecycle.md +++ b/packages/agentic/desktop-test/references/lifecycle.md @@ -7,13 +7,19 @@ 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 @@ -32,11 +38,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 +81,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/src/cli.ts b/packages/agentic/desktop-test/src/cli.ts index 833b7b1c29..1086fc7a27 100644 --- a/packages/agentic/desktop-test/src/cli.ts +++ b/packages/agentic/desktop-test/src/cli.ts @@ -188,6 +188,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({ @@ -218,7 +219,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); else throw new Error('The imported integration does not implement this suite.'); } diff --git a/packages/agentic/desktop-test/src/config.ts b/packages/agentic/desktop-test/src/config.ts index edf975b6bb..0c7688dbff 100644 --- a/packages/agentic/desktop-test/src/config.ts +++ b/packages/agentic/desktop-test/src/config.ts @@ -6,6 +6,7 @@ import { createHash } from 'node:crypto'; import type { DesktopEndpoint, DesktopRenderer, NativeDesktopApplicationDescriptor } from '@fluentui-react-native/desktop-driver'; 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; @@ -32,7 +33,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 }; }; @@ -73,6 +74,7 @@ export type DesktopConfig = { export type ResolvedDesktopTarget = DesktopTargetConfig & { endpoint: DesktopEndpoint; id: string; + registrationId: string; instanceId: string; projectRoot: string; configPath: string; @@ -96,6 +98,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'], 'desktop config'); @@ -150,7 +156,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); @@ -162,7 +168,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) { @@ -208,10 +246,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(); } } @@ -263,11 +308,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, @@ -277,7 +324,15 @@ export async function loadDesktopConfig(configPath?: string, cwd = process.cwd() }); }; const { config, root, file } = await read(filename); - const targets = Object.fromEntries(await Promise.all(Object.keys(config.targets).map(async (id) => [id, await resolve(file, id, [])]))); + const targets = Object.fromEntries( + await Promise.all( + Object.keys(config.targets).map(async (id) => { + const target = await resolve(file, id, []); + const registrationId = registeredTargetId(file, id, target.endpoint); + return [id, Object.freeze({ ...target, id: registrationId, registrationId })]; + }), + ), + ); return { config, configPath: file, @@ -291,11 +346,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 7fef3c4e35..cd0fc5f5bc 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( @@ -774,14 +794,325 @@ 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 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'); diff --git a/packages/agentic/desktop-test/src/contracts.test.ts b/packages/agentic/desktop-test/src/contracts.test.ts index 559708c809..ea4a349c73 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/index.ts b/packages/agentic/desktop-test/src/index.ts index 946718dc9b..290d776ed2 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 cdb74ede46..1f2df60239 100644 --- a/packages/agentic/desktop-test/src/integration.ts +++ b/packages/agentic/desktop-test/src/integration.ts @@ -4,6 +4,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. @@ -12,6 +13,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/ownership.ts b/packages/agentic/desktop-test/src/ownership.ts new file mode 100644 index 0000000000..322eb5d65b --- /dev/null +++ b/packages/agentic/desktop-test/src/ownership.ts @@ -0,0 +1,180 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createHash, randomUUID } from 'node:crypto'; + +import type { DesktopResourceRecord } from './resources.js'; + +export type DesktopWorkloadOwnerIdentity = Readonly<{ + id: string; + processId: number; + processStartedAt: string; + runId: string; + targetId: string; + targetInstanceId: string; +}>; + +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/resources.ts b/packages/agentic/desktop-test/src/resources.ts new file mode 100644 index 0000000000..725a332a12 --- /dev/null +++ b/packages/agentic/desktop-test/src/resources.ts @@ -0,0 +1,124 @@ +export type DesktopResourceServiceReadyRecord = Readonly<{ + tier: 'service-ready'; + service: string; + descriptor?: string; + incarnation: string; + processId: number; +}>; + +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..b626847fe3 100644 --- a/packages/agentic/desktop-test/src/services.ts +++ b/packages/agentic/desktop-test/src/services.ts @@ -1,6 +1,28 @@ import type { DesktopCommandRunner, PreparedDesktopCommand, RunningDesktopCommand } from './commands.js'; import { deadline } from './commands.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 +35,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 +58,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 +81,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 +113,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 +190,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,6 +204,7 @@ 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 }); })(), ); @@ -104,12 +219,88 @@ 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; + } } diff --git a/packages/agentic/desktop-test/src/supervisor.ts b/packages/agentic/desktop-test/src/supervisor.ts index 4249c82ea6..d9fe4e5671 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 type { DesktopAgentConnectionOptions } from './agentConnection.js'; @@ -12,6 +9,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'; @@ -24,6 +23,7 @@ export type DesktopSupervisorOptions = { reporter: DesktopReportWriter; reportSession?: DesktopReportSession; integration?: DesktopIntegration; + workload?: { kind: 'delegated'; claim: DesktopWorkloadClaim }; hostFactory?: (target: ResolvedDesktopTarget) => Promise; lockDirectory?: string; /** @@ -42,7 +42,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 = {}; @@ -55,7 +57,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 { @@ -71,8 +73,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.'); @@ -81,12 +95,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(); @@ -109,12 +144,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', @@ -133,7 +188,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); } @@ -210,13 +266,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'); @@ -228,21 +285,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/src/cli/DesktopStorybookCli.ts b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts index 5a199c7cda..958381d340 100644 --- a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts @@ -203,7 +203,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); } @@ -298,7 +298,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); From eda04684cda0e6d0b8d6ff3d3d0908ef65e1cd7b Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:34:54 -0800 Subject: [PATCH 15/36] Exercise correlated process receipts through typed resource generations Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../desktop-test/references/lifecycle.md | 7 +++ .../desktop-test/src/processControl.test.ts | 52 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/packages/agentic/desktop-test/references/lifecycle.md b/packages/agentic/desktop-test/references/lifecycle.md index f06c43eadb..65ca2614f0 100644 --- a/packages/agentic/desktop-test/references/lifecycle.md +++ b/packages/agentic/desktop-test/references/lifecycle.md @@ -25,6 +25,13 @@ 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 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 externally owned app is preserved. diff --git a/packages/agentic/desktop-test/src/processControl.test.ts b/packages/agentic/desktop-test/src/processControl.test.ts index 1ed61af8cc..e81bd733de 100644 --- a/packages/agentic/desktop-test/src/processControl.test.ts +++ b/packages/agentic/desktop-test/src/processControl.test.ts @@ -6,6 +6,9 @@ import type { ChildProcess } from 'node:child_process'; import { once } from 'node:events'; 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'); @@ -48,6 +51,55 @@ describe('owned process lifelines', () => { } }); + 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(); + } + }); + test.each([ { mode: 'service', crash: 'parent' }, { mode: 'wrapper', crash: 'parent' }, From 274803c7cdf2083cdc30379654eea98fa468081e Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:37:45 -0800 Subject: [PATCH 16/36] Route Storybook agent readiness through owned resource receipts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../references/integration.md | 4 +++- .../src/cli/DesktopStorybookCli.test.ts | 21 ++++++++++++++++++- .../src/cli/DesktopStorybookCli.ts | 13 +++++------- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/packages/agentic/storybook-desktop/references/integration.md b/packages/agentic/storybook-desktop/references/integration.md index 88e5e87106..a04993ab5a 100644 --- a/packages/agentic/storybook-desktop/references/integration.md +++ b/packages/agentic/storybook-desktop/references/integration.md @@ -76,7 +76,9 @@ 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 generated `agent-ready.*.json` file +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 diff --git a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts index 5e44d2010f..0d7e75898c 100644 --- a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts @@ -369,6 +369,8 @@ describe('DesktopStorybookCli', () => { test('starts the config-owned server with platform and connection options', async () => { const runner = new RecordingRunner(); const services = new DesktopServiceScope(runner); + const resourceObserver = jest.fn(); + services.setResourceObserver(resourceObserver); const output = { write: jest.fn() }; const onReady = jest.fn(() => expect(output.write).toHaveBeenCalledTimes(1)); const fetch: typeof globalThis.fetch = async () => { @@ -540,7 +542,24 @@ describe('DesktopStorybookCli', () => { signal.throwIfAborted(); throw new Error('unreachable'); }, - onReady: () => written.push('onReady'), + onReady: () => { + expect(resourceObserver).toHaveBeenCalledWith( + expect.objectContaining({ + id: '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, diff --git a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts index 1d0dc894db..f8a4a7a78c 100644 --- a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.ts @@ -248,7 +248,7 @@ export class DesktopStorybookCli { this.services.start('metro', this.prepareCommand(defaultMetroCommand(resolvedInstance), platform, resolvedInstance)); const failures: unknown[] = []; try { - const channel = this.services.start( + this.services.start( 'storybook-channel', this.prepareCommand( { @@ -268,13 +268,10 @@ export class DesktopStorybookCli { ]); const agentManifest = driverManifest.manifest.agent; if (agentManifest) { - if (!channel.waitForReadiness) throw new Error('The owned Storybook channel does not support correlated service readiness.'); - await this.services.guard( - channel.waitForReadiness( - { tier: 'service-ready', service: 'storybook-channel', descriptor: agentManifest.descriptor }, - 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') { From 6880e20de4fd83336f469c3566f53917275e0a1b Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:40:36 -0800 Subject: [PATCH 17/36] Scope readiness observer to the agent lifecycle regression Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../storybook-desktop/src/cli/DesktopStorybookCli.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts index 0d7e75898c..9a4b08b831 100644 --- a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts @@ -369,8 +369,6 @@ describe('DesktopStorybookCli', () => { test('starts the config-owned server with platform and connection options', async () => { const runner = new RecordingRunner(); const services = new DesktopServiceScope(runner); - const resourceObserver = jest.fn(); - services.setResourceObserver(resourceObserver); const output = { write: jest.fn() }; const onReady = jest.fn(() => expect(output.write).toHaveBeenCalledTimes(1)); const fetch: typeof globalThis.fetch = async () => { @@ -507,6 +505,8 @@ 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[] = []; let published = false; From ed17f7c39fb98b147616ce588cf62f935b2d359a Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:42:00 -0800 Subject: [PATCH 18/36] feat(desktop-test): consume correlated process readiness Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../desktop-test/references/lifecycle.md | 6 + .../desktop-test/src/contracts.contract.cjs | 107 ++++++++++++++++++ packages/agentic/desktop-test/src/services.ts | 78 +++++++++++++ 3 files changed, 191 insertions(+) diff --git a/packages/agentic/desktop-test/references/lifecycle.md b/packages/agentic/desktop-test/references/lifecycle.md index f06c43eadb..1f022a7f6e 100644 --- a/packages/agentic/desktop-test/references/lifecycle.md +++ b/packages/agentic/desktop-test/references/lifecycle.md @@ -24,6 +24,12 @@ 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. Cleanup drains worker reporting and stdio, closes sessions, releases owned input, and stops exact owned process trees in reverse service order. An diff --git a/packages/agentic/desktop-test/src/contracts.contract.cjs b/packages/agentic/desktop-test/src/contracts.contract.cjs index cd0fc5f5bc..39c965bf13 100644 --- a/packages/agentic/desktop-test/src/contracts.contract.cjs +++ b/packages/agentic/desktop-test/src/contracts.contract.cjs @@ -928,6 +928,113 @@ async function main() { 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', diff --git a/packages/agentic/desktop-test/src/services.ts b/packages/agentic/desktop-test/src/services.ts index b626847fe3..f31e26b4c9 100644 --- a/packages/agentic/desktop-test/src/services.ts +++ b/packages/agentic/desktop-test/src/services.ts @@ -24,6 +24,20 @@ type ResourceEntry = { disposalPromise?: Promise; }; +type DesktopServiceReadyExpectation = Readonly<{ + tier: 'service-ready'; + service: string; + descriptor?: string; +}>; + +type ProcessReadinessCommand = RunningDesktopCommand & { + waitForReadiness?: ( + expected: DesktopServiceReadyExpectation, + timeoutMs?: number, + signal?: AbortSignal, + ) => Promise; +}; + export class DesktopStopRequested extends Error { constructor() { super('Owned desktop service stop requested.'); @@ -210,6 +224,49 @@ export class DesktopServiceScope { ); } + async readyProcess( + id: string, + expected: DesktopServiceReadyExpectation, + 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 as ProcessReadinessCommand).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; @@ -244,6 +301,7 @@ export class DesktopServiceScope { 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.'); })()); @@ -304,3 +362,23 @@ export class DesktopServiceScope { return entry.disposalPromise; } } + +function validateServiceReadyExpectation(value: unknown): asserts value is DesktopServiceReadyExpectation { + 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.'); + } +} From 33e0e40e43d0db1c4f95fd5ea3a6e0fbeb62552b Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:43:58 -0800 Subject: [PATCH 19/36] Assert the namespaced public channel resource identity Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../storybook-desktop/src/cli/DesktopStorybookCli.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts index 9a4b08b831..d0571fe462 100644 --- a/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts +++ b/packages/agentic/storybook-desktop/src/cli/DesktopStorybookCli.test.ts @@ -545,7 +545,7 @@ describe('DesktopStorybookCli', () => { onReady: () => { expect(resourceObserver).toHaveBeenCalledWith( expect.objectContaining({ - id: 'storybook-channel', + id: 'desktop:storybook-channel', generation: expect.any(Number), ownership: 'owned', readiness: 'ready', From 6d0075404064eed8503966819703b2d2337c66a8 Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:48:42 -0800 Subject: [PATCH 20/36] Use canonical process readiness types in the resource scope Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/agentic/desktop-test/src/services.ts | 25 +++---------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/packages/agentic/desktop-test/src/services.ts b/packages/agentic/desktop-test/src/services.ts index f31e26b4c9..5569bcf267 100644 --- a/packages/agentic/desktop-test/src/services.ts +++ b/packages/agentic/desktop-test/src/services.ts @@ -1,5 +1,6 @@ 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 { @@ -24,20 +25,6 @@ type ResourceEntry = { disposalPromise?: Promise; }; -type DesktopServiceReadyExpectation = Readonly<{ - tier: 'service-ready'; - service: string; - descriptor?: string; -}>; - -type ProcessReadinessCommand = RunningDesktopCommand & { - waitForReadiness?: ( - expected: DesktopServiceReadyExpectation, - timeoutMs?: number, - signal?: AbortSignal, - ) => Promise; -}; - export class DesktopStopRequested extends Error { constructor() { super('Owned desktop service stop requested.'); @@ -224,18 +211,14 @@ export class DesktopServiceScope { ); } - async readyProcess( - id: string, - expected: DesktopServiceReadyExpectation, - timeoutMs = 120_000, - ): Promise { + 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 as ProcessReadinessCommand).waitForReadiness; + const waitForReadiness = service.command.waitForReadiness; if (typeof waitForReadiness !== 'function') { resource.record.readiness = 'failed'; this.publish(resource); @@ -363,7 +346,7 @@ export class DesktopServiceScope { } } -function validateServiceReadyExpectation(value: unknown): asserts value is DesktopServiceReadyExpectation { +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.'); } From e594dd6e5e2d59d1dbaa6d74a8079250220e76e3 Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:50:00 -0800 Subject: [PATCH 21/36] Prevent retained preview records from reviving stale references Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/f5-retained-preview-reference.md | 5 ++++ .../desktop-driver-mcp/src/server.test.ts | 29 +++++++++++++++++++ .../desktop-driver/references/operations.md | 12 ++++++-- .../src/operations/refs.test.ts | 23 +++++++++++++++ .../desktop-driver/src/operations/refs.ts | 18 ++++++++---- .../src/server/storyOperations.test.ts | 29 +++++++++++++++++++ 6 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 .changeset/f5-retained-preview-reference.md 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/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/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/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/server/storyOperations.test.ts b/packages/agentic/desktop-driver/src/server/storyOperations.test.ts index bd535ec42a..52cf413941 100644 --- a/packages/agentic/desktop-driver/src/server/storyOperations.test.ts +++ b/packages/agentic/desktop-driver/src/server/storyOperations.test.ts @@ -289,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!; From bb3976e2fd7a5a0d8ea4f78c84ff1e49f719cf66 Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:01:47 -0800 Subject: [PATCH 22/36] docs: record partial native qualification evidence Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../combined-native-partial-evidence.md | 7 ++ .../references/current-status.md | 39 ++++++++ .../references/current-status.md | 45 +++++++++ .../desktop-test/references/current-status.md | 99 +++++++++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 .changeset/combined-native-partial-evidence.md 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/packages/agentic/desktop-driver-mcp/references/current-status.md b/packages/agentic/desktop-driver-mcp/references/current-status.md index e0e5b6d341..a0c2c65b54 100644 --- a/packages/agentic/desktop-driver-mcp/references/current-status.md +++ b/packages/agentic/desktop-driver-mcp/references/current-status.md @@ -118,3 +118,42 @@ 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. diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 6612a9f96d..e4825bc98f 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -261,3 +261,48 @@ 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. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index ecbdbb10ad..9868fa1cb7 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -119,3 +119,102 @@ 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. From 982243ad1040bddd54747b93b9685a55ad369aa9 Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:38:02 -0800 Subject: [PATCH 23/36] docs: record combined native requalification Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../references/current-status.md | 43 ++++++++++++ .../references/current-status.md | 29 ++++++++ .../desktop-test/references/current-status.md | 69 +++++++++++++++++++ 3 files changed, 141 insertions(+) diff --git a/packages/agentic/desktop-driver-mcp/references/current-status.md b/packages/agentic/desktop-driver-mcp/references/current-status.md index a0c2c65b54..201cac6578 100644 --- a/packages/agentic/desktop-driver-mcp/references/current-status.md +++ b/packages/agentic/desktop-driver-mcp/references/current-status.md @@ -157,3 +157,46 @@ 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`. | Failed rediscovery expectation: post-replacement status returned `mcpIsError:true`, `error:"adapter"`, `reason:"service-unavailable"`; close then succeeded. 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. diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index e4825bc98f..4d7d641786 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -306,3 +306,32 @@ 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 failed expected rediscovery: old client's next `desktop_session.status` returned `adapter` / `service-unavailable`; close succeeded. 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 failure is retained, not retried away or attributed to a +specific product defect. The replacement service did start on the same ports, +but the old MCP client did not complete discovery on its next status call; no +mutation was issued during that failed status operation. 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. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 9868fa1cb7..96bd97e331 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -218,3 +218,72 @@ 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 | Failed: old client `desktop_session.status` returned `adapter` / `service-unavailable` after same-port service replacement; old session close then succeeded. `desktop_observe.find` returned 0 matches, so no old-reference assertion was possible. | Not run | Fabric sanitized record: `/Users/jasonmorse/Library/Logs/personal-workflow/f15-native-qualification-a19fd3e1-20261106T0406/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 failed +its expected rediscovery step; this is retained without inferring cause. 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. From 4bfd45c1271f4fc650e177464730e17822aa2c2b Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:46:02 -0800 Subject: [PATCH 24/36] docs: keep native qualification record locator private Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../desktop-test/references/current-status.md | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 96bd97e331..8e412ec6ea 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -240,32 +240,32 @@ 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 | Failed: old client `desktop_session.status` returned `adapter` / `service-unavailable` after same-port service replacement; old session close then succeeded. `desktop_observe.find` returned 0 matches, so no old-reference assertion was possible. | Not run | Fabric sanitized record: `/Users/jasonmorse/Library/Logs/personal-workflow/f15-native-qualification-a19fd3e1-20261106T0406/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. | +| Case | macOS Fabric | macOS Paper | Evidence and artifact | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Same-port replacement | Failed: old client `desktop_session.status` returned `adapter` / `service-unavailable` after same-port service replacement; old session close then succeeded. `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 failed From 0810995939f729481e0857183f8d5321a9003ce5 Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:48:24 -0800 Subject: [PATCH 25/36] docs: classify same-port native replacement as partial The first post-replacement call returned the designed fail-closed service-unavailable rejection; the native sequence did not exercise a later rediscovering call, so the case is partial evidence rather than a failure. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../references/current-status.md | 14 ++--- .../references/current-status.md | 21 ++++--- .../desktop-test/references/current-status.md | 62 ++++++++++--------- 3 files changed, 53 insertions(+), 44 deletions(-) diff --git a/packages/agentic/desktop-driver-mcp/references/current-status.md b/packages/agentic/desktop-driver-mcp/references/current-status.md index 201cac6578..e9ee6d32fa 100644 --- a/packages/agentic/desktop-driver-mcp/references/current-status.md +++ b/packages/agentic/desktop-driver-mcp/references/current-status.md @@ -172,13 +172,13 @@ and `yarn storybook driver --macos --paper --agent`, paired with 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`. | Failed rediscovery expectation: post-replacement status returned `mcpIsError:true`, `error:"adapter"`, `reason:"service-unavailable"`; close then succeeded. 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`. | +| 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 diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 4d7d641786..b0a9b59988 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -321,15 +321,18 @@ 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 failed expected rediscovery: old client's next `desktop_session.status` returned `adapter` / `service-unavailable`; close succeeded. 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 failure is retained, not retried away or attributed to a -specific product defect. The replacement service did start on the same ports, -but the old MCP client did not complete discovery on its next status call; no -mutation was issued during that failed status operation. Manual MCP calls are +| 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 diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 8e412ec6ea..e86bc1ff47 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -240,36 +240,42 @@ 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 | Failed: old client `desktop_session.status` returned `adapter` / `service-unavailable` after same-port service replacement; old session close then succeeded. `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. | +| 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 failed -its expected rediscovery step; this is retained without inferring cause. The +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 From b4f7c2b505f8fa19f20d7a1e27dfe797a3a6455b Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:03:20 -0800 Subject: [PATCH 26/36] fix(desktop-test): report readiness producer loss before socket reset error Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../desktop-test/src/processControl.test.ts | 32 +++++++++++++++++++ .../desktop-test/src/processControl.ts | 10 ++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/agentic/desktop-test/src/processControl.test.ts b/packages/agentic/desktop-test/src/processControl.test.ts index e81bd733de..5cae7fed00 100644 --- a/packages/agentic/desktop-test/src/processControl.test.ts +++ b/packages/agentic/desktop-test/src/processControl.test.ts @@ -4,6 +4,7 @@ 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 { createDesktopProcessControl } from './processControl.js'; import { deadline, NodeDesktopCommandRunner } from './commands.js'; import type { RunningDesktopCommand } from './commands.js'; @@ -211,6 +212,37 @@ describe('owned process lifelines', () => { } }); + 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('stalled readiness and cancellation do not promote another readiness tier', async () => { const control = createDesktopProcessControl(() => undefined); try { diff --git a/packages/agentic/desktop-test/src/processControl.ts b/packages/agentic/desktop-test/src/processControl.ts index dde92d31c7..8f67824e47 100644 --- a/packages/agentic/desktop-test/src/processControl.ts +++ b/packages/agentic/desktop-test/src/processControl.ts @@ -61,14 +61,20 @@ export function createDesktopProcessControl(onFailure: (error: Error) => void) { let buffer = ''; let processId: number | undefined; let disposed = false; - socket.on('error', fail); + let socketError: Error | undefined; + // 'close' always follows 'error'; defer so producer loss (e.g. ECONNRESET on SIGKILL) keeps its specific diagnosis. + socket.on('error', (error) => (socketError ??= error)); 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) fail(new Error(`Owned service "${service}" lost its readiness producer without settled disposal.`)); + if (disposed && stopping) continue; + producerLost = true; + fail(new Error(`Owned service "${service}" lost its readiness producer without settled disposal.`)); } + if (socketError && !producerLost) fail(socketError); for (const notify of changed) notify(); }); socket.on('data', (text: string) => { From f57aadc2a734ae5d9a2eab08d0f318a033e6ac76 Mon Sep 17 00:00:00 2001 From: Jason Morse <35609178+JasonVMo@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:24:02 -0800 Subject: [PATCH 27/36] fix(desktop): repair smoke session and cleanup ordering Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/desktop-test-stop-reset.md | 5 ++++ .changeset/windows-smoke-initial-lease.md | 5 ++++ .../desktop-test/src/processControl.test.ts | 30 +++++++++++++++++++ .../desktop-test/src/processControl.ts | 10 +++++-- .../config/smoke-windows.ps1 | 3 ++ .../config/storybook-control.cjs | 9 +++--- 6 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 .changeset/desktop-test-stop-reset.md create mode 100644 .changeset/windows-smoke-initial-lease.md 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/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/packages/agentic/desktop-test/src/processControl.test.ts b/packages/agentic/desktop-test/src/processControl.test.ts index 5cae7fed00..144ab78644 100644 --- a/packages/agentic/desktop-test/src/processControl.test.ts +++ b/packages/agentic/desktop-test/src/processControl.test.ts @@ -243,6 +243,36 @@ describe('owned process lifelines', () => { } }); + 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 { diff --git a/packages/agentic/desktop-test/src/processControl.ts b/packages/agentic/desktop-test/src/processControl.ts index 8f67824e47..fe8c19a4da 100644 --- a/packages/agentic/desktop-test/src/processControl.ts +++ b/packages/agentic/desktop-test/src/processControl.ts @@ -62,8 +62,14 @@ export function createDesktopProcessControl(onFailure: (error: Error) => void) { 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) => (socketError ??= error)); + socket.on('error', (error) => { + if (!socketError) { + socketError = error; + socketErrorWasUnexpected = !stopping; + } + }); socket.on('close', () => { sockets.delete(socket); let producerLost = false; @@ -74,7 +80,7 @@ export function createDesktopProcessControl(onFailure: (error: Error) => void) { producerLost = true; fail(new Error(`Owned service "${service}" lost its readiness producer without settled disposal.`)); } - if (socketError && !producerLost) fail(socketError); + if (socketError && socketErrorWasUnexpected && !producerLost) fail(socketError); for (const notify of changed) notify(); }); socket.on('data', (text: string) => { diff --git a/packages/agentic/storybook-desktop/config/smoke-windows.ps1 b/packages/agentic/storybook-desktop/config/smoke-windows.ps1 index a936a51469..563d1f460d 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 diff --git a/packages/agentic/storybook-desktop/config/storybook-control.cjs b/packages/agentic/storybook-desktop/config/storybook-control.cjs index 95e027a75b..4765aa3434 100644 --- a/packages/agentic/storybook-desktop/config/storybook-control.cjs +++ b/packages/agentic/storybook-desktop/config/storybook-control.cjs @@ -119,10 +119,6 @@ async function smoke(traversal) { } 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() { @@ -194,6 +190,11 @@ async function main() { }); } await smoke(traversal); + if (smokeMode === 'stories-and-tests' && smokePhase !== 'stories') { + await traversal?.close(); + traversal = undefined; + await runAuthoredTests(); + } } catch (error) { failure = error; } finally { From 0e5819c9a0624ef9a5b68c799a3e0e87240ad6cf Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Fri, 6 Nov 2026 12:43:09 -0800 Subject: [PATCH 28/36] Fix Windows and Win32 Storybook smoke shutdown and first render On Windows a self-directed SIGTERM terminates without running handlers, so a lifeline stop killed delegated Storybook children before they sent their reporting terminal and flush. The lifeline now dispatches installed SIGTERM handlers directly when no disposer is registered. Give the first manifest-backed smoke story the same cold-bundle render budget as the CLI traversal, and stop every owned smoke process before reporting a cleanup failure. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/windows-lifeline-sigterm.md | 9 +++++++++ packages/agentic/desktop-test/README.md | 4 +++- .../desktop-test/config/process-lifeline.mjs | 4 +++- .../fixtures/process-control/child.mjs | 9 +++++++++ .../desktop-test/src/processControl.test.ts | 14 ++++++++++++++ .../storybook-desktop/config/smoke-win32.ps1 | 8 ++++---- .../storybook-desktop/config/smoke-windows.ps1 | 8 ++++---- .../storybook-desktop/config/storybook-control.cjs | 6 ++++-- .../storybook-desktop/references/integration.md | 5 ++++- 9 files changed, 54 insertions(+), 13 deletions(-) create mode 100644 .changeset/windows-lifeline-sigterm.md diff --git a/.changeset/windows-lifeline-sigterm.md b/.changeset/windows-lifeline-sigterm.md new file mode 100644 index 0000000000..59a1012353 --- /dev/null +++ b/.changeset/windows-lifeline-sigterm.md @@ -0,0 +1,9 @@ +--- +'@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, and +stop every owned smoke process before reporting a service cleanup failure. diff --git a/packages/agentic/desktop-test/README.md b/packages/agentic/desktop-test/README.md index 15dde284a8..1e1a15a591 100644 --- a/packages/agentic/desktop-test/README.md +++ b/packages/agentic/desktop-test/README.md @@ -73,7 +73,9 @@ when working on the package. 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. A readiness receipt + 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. diff --git a/packages/agentic/desktop-test/config/process-lifeline.mjs b/packages/agentic/desktop-test/config/process-lifeline.mjs index 927193e6ee..e2544c0cee 100644 --- a/packages/agentic/desktop-test/config/process-lifeline.mjs +++ b/packages/agentic/desktop-test/config/process-lifeline.mjs @@ -36,7 +36,9 @@ if (encoded) { 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. - process.kill(process.pid, 'SIGTERM'); + // 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) => { diff --git a/packages/agentic/desktop-test/fixtures/process-control/child.mjs b/packages/agentic/desktop-test/fixtures/process-control/child.mjs index c948faaef8..d4690b55fe 100644 --- a/packages/agentic/desktop-test/fixtures/process-control/child.mjs +++ b/packages/agentic/desktop-test/fixtures/process-control/child.mjs @@ -15,6 +15,15 @@ if (mode === 'late') { 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); diff --git a/packages/agentic/desktop-test/src/processControl.test.ts b/packages/agentic/desktop-test/src/processControl.test.ts index 144ab78644..bcb45370b3 100644 --- a/packages/agentic/desktop-test/src/processControl.test.ts +++ b/packages/agentic/desktop-test/src/processControl.test.ts @@ -141,6 +141,20 @@ describe('owned process lifelines', () => { 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)); 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 563d1f460d..ba3c61e729 100644 --- a/packages/agentic/storybook-desktop/config/smoke-windows.ps1 +++ b/packages/agentic/storybook-desktop/config/smoke-windows.ps1 @@ -292,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 4765aa3434..15075ab326 100644 --- a/packages/agentic/storybook-desktop/config/storybook-control.cjs +++ b/packages/agentic/storybook-desktop/config/storybook-control.cjs @@ -66,12 +66,14 @@ async function smoke(traversal) { } const settleMilliseconds = Number(process.env.STORYBOOK_SMOKE_SETTLE_MS) || 0; + // 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 failFast = process.env.STORYBOOK_SMOKE_FAIL_FAST === '1'; const failures = []; 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,7 @@ async function smoke(traversal) { 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 { - if (traversal) await traversal.select(id, 30_000); + 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)); diff --git a/packages/agentic/storybook-desktop/references/integration.md b/packages/agentic/storybook-desktop/references/integration.md index 196962290a..07f19e9d5e 100644 --- a/packages/agentic/storybook-desktop/references/integration.md +++ b/packages/agentic/storybook-desktop/references/integration.md @@ -120,7 +120,10 @@ 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. +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. Epoch/cancellation-aware providers reject stale preview mutations without invalidating chrome or live secondary windows. Interrupted navigation still From 178c2f06c5306a141b62fb70c1fc0c93b17f8f19 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Fri, 6 Nov 2026 12:53:27 -0800 Subject: [PATCH 29/36] Wait for Storybook runtime readiness before relaunched authored tests The Windows smoke relaunches the app between its stories and tests phases. This PR made driver navigation fail fast with bridge-unavailable until the runtime authenticates and renders, so the first authored test raced the relaunch. The tests phase now performs one safe pre-dispatch-retried selection with the initial-render budget before authored tests start. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/windows-lifeline-sigterm.md | 6 ++++-- .../storybook-desktop/config/storybook-control.cjs | 13 ++++++++++--- .../storybook-desktop/references/integration.md | 5 ++++- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.changeset/windows-lifeline-sigterm.md b/.changeset/windows-lifeline-sigterm.md index 59a1012353..d04a25323a 100644 --- a/.changeset/windows-lifeline-sigterm.md +++ b/.changeset/windows-lifeline-sigterm.md @@ -5,5 +5,7 @@ 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, and -stop every owned smoke process before reporting a service cleanup failure. +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/packages/agentic/storybook-desktop/config/storybook-control.cjs b/packages/agentic/storybook-desktop/config/storybook-control.cjs index 15075ab326..182daa89a3 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; @@ -66,8 +68,6 @@ async function smoke(traversal) { } const settleMilliseconds = Number(process.env.STORYBOOK_SMOKE_SETTLE_MS) || 0; - // 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 failFast = process.env.STORYBOOK_SMOKE_FAIL_FAST === '1'; const failures = []; for (const { id } of entries) @@ -180,7 +180,7 @@ async function main() { let failure; let traversal; try { - if (smokePhase !== 'tests' && process.env.STORYBOOK_DRIVER_MANIFEST) { + 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({ @@ -190,6 +190,13 @@ async function main() { 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') { diff --git a/packages/agentic/storybook-desktop/references/integration.md b/packages/agentic/storybook-desktop/references/integration.md index 07f19e9d5e..7d5428b49c 100644 --- a/packages/agentic/storybook-desktop/references/integration.md +++ b/packages/agentic/storybook-desktop/references/integration.md @@ -123,7 +123,10 @@ 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. +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. 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 From 98b86d3606f2480638922844b9be7eb6c403e7de Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Fri, 6 Nov 2026 13:01:44 -0800 Subject: [PATCH 30/36] Expect the tests-phase readiness selection in the smoke control contract Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../storybook-desktop/src/testing/wdioRunner.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/agentic/storybook-desktop/src/testing/wdioRunner.test.ts b/packages/agentic/storybook-desktop/src/testing/wdioRunner.test.ts index 01a741bdfd..f3767451b3 100644 --- a/packages/agentic/storybook-desktop/src/testing/wdioRunner.test.ts +++ b/packages/agentic/storybook-desktop/src/testing/wdioRunner.test.ts @@ -43,7 +43,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, From 1e5c1be9512997a21beba060e3abcf827597d403 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Fri, 6 Nov 2026 13:09:13 -0800 Subject: [PATCH 31/36] Pass lifeline preload as a file URL and skip crash-disposal contracts on Windows Node --import rejects raw absolute Windows paths. libuv places non-detached Windows children in a kill-on-close job, so a crashed owner hard-terminates them before cooperative disposal can run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../desktop-test/references/current-status.md | 38 +++++++++---------- .../desktop-test/src/processControl.test.ts | 8 +++- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index e86bc1ff47..916941d948 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -17,25 +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 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. | -| 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. | +| 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 diff --git a/packages/agentic/desktop-test/src/processControl.test.ts b/packages/agentic/desktop-test/src/processControl.test.ts index bcb45370b3..b152d468fc 100644 --- a/packages/agentic/desktop-test/src/processControl.test.ts +++ b/packages/agentic/desktop-test/src/processControl.test.ts @@ -5,6 +5,7 @@ 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'; @@ -13,7 +14,8 @@ 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'); -const preload = path.resolve(__dirname, '../config/process-lifeline.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); @@ -101,7 +103,9 @@ describe('owned process lifelines', () => { } }); - test.each([ + // 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' }, From 1f5333e258d7a256d8511fbac7381baf70ba907a Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Fri, 6 Nov 2026 13:13:40 -0800 Subject: [PATCH 32/36] Await killed children and retry temp cleanup in process control tests On Windows, removing the contract directory immediately after SIGKILL raced file release and failed with EPERM under load. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../agentic/desktop-test/src/processControl.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/agentic/desktop-test/src/processControl.test.ts b/packages/agentic/desktop-test/src/processControl.test.ts index b152d468fc..61ee818c27 100644 --- a/packages/agentic/desktop-test/src/processControl.test.ts +++ b/packages/agentic/desktop-test/src/processControl.test.ts @@ -26,8 +26,16 @@ describe('owned process lifelines', () => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'furn-process-contract-')); }); afterEach(async () => { - for (const child of children.splice(0)) if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); - fs.rmSync(root, { force: true, recursive: true }); + 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 () => { From 8458bde485990ddd90ddad4318c72fdc25d944f7 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Fri, 6 Nov 2026 13:31:51 -0800 Subject: [PATCH 33/36] storybook-desktop: report bridge-unavailable while a relaunched runtime awaits its initial render A relaunched runtime's hello is accepted before its first storyRendered event. In that window requireBridge reported relaunch-required, which traversal does not retry, so the Windows tests-phase readiness select failed. An open, unfenced, uninitialized replacement connection now reports bridge-unavailable with no dispatch; fenced bridges still report relaunch-required. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../windows-relaunch-awaiting-render.md | 7 ++++ .../references/integration.md | 5 ++- .../storybook-desktop/references/readiness.md | 8 +++- .../StorybookChannelOrchestrator.test.ts | 37 ++++++++++++++++++- .../driver/StorybookChannelOrchestrator.ts | 11 ++++-- 5 files changed, 60 insertions(+), 8 deletions(-) create mode 100644 .changeset/windows-relaunch-awaiting-render.md 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/packages/agentic/storybook-desktop/references/integration.md b/packages/agentic/storybook-desktop/references/integration.md index 7d5428b49c..0b735bea2e 100644 --- a/packages/agentic/storybook-desktop/references/integration.md +++ b/packages/agentic/storybook-desktop/references/integration.md @@ -126,7 +126,10 @@ story's retry budget covers a cold development bundle 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. Authored-test navigation itself does not wait for the bridge. +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. 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 diff --git a/packages/agentic/storybook-desktop/references/readiness.md b/packages/agentic/storybook-desktop/references/readiness.md index 04f68a91c6..2adf00de44 100644 --- a/packages/agentic/storybook-desktop/references/readiness.md +++ b/packages/agentic/storybook-desktop/references/readiness.md @@ -22,8 +22,12 @@ 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 diff --git a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts index 039198bd82..75da0b059e 100644 --- a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts +++ b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts @@ -24,6 +24,10 @@ class Client extends EventEmitter { return this.sent[0].args[0].connectionEpoch as string; } authenticate(): void { + this.hello(); + this.receive('storyRendered', request.storyId); + } + hello(): void { this.receive('furn:desktop:hello', { connectionEpoch: this.epoch, endpoint: manifest.endpoint, @@ -33,7 +37,6 @@ class Client extends EventEmitter { targetId: manifest.targetId, version: 2, }); - this.receive('storyRendered', request.storyId); } ready(overrides: Record = {}): void { this.receive('furn:desktop:story-ready', { @@ -340,6 +343,29 @@ describe('durable authenticated bridge epochs', () => { 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', + }); + 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')); @@ -491,7 +517,14 @@ describe('StorybookChannelOrchestrator epochs and lifetime', () => { }); const replacement = new Client(); server.emit('connection', replacement); - replacement.authenticate(); + 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', + }); + 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(); diff --git a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts index 9c7fc1a2dc..be5902d93a 100644 --- a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts +++ b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts @@ -382,13 +382,18 @@ export class StorybookChannelOrchestrator implements StoryOrchestrator { } private requireBridge(): ChannelClient { - if (!this.bridgeConnected || !this.bridgeClient || this.bridgeClient.readyState !== 1 || this.needsRelaunch) { + 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 ? 'relaunch-required' : 'bridge-unavailable', + this.needsRelaunch && !awaitingInitialRender ? 'relaunch-required' : 'bridge-unavailable', ); } - return this.bridgeClient; + return client; } } From a6b89e924b01e5f8d3e459832601d9b8ff05411d Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Fri, 6 Nov 2026 14:16:48 -0800 Subject: [PATCH 34/36] fix(storybook-desktop): retire the old bridge when a reloaded runtime authenticates first A runtime reload can authenticate its new socket before the old socket reports close. The valid hello was dropped while the stale bridge stayed selected, so a select reached the dead context and timed out with an unknown effect. The new hello now fences the old socket, rejects its pending work, and adopts the replacement after its initial render. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../storybook-reload-replaces-bridge.md | 8 ++++ .../references/integration.md | 5 ++- .../storybook-desktop/references/readiness.md | 4 ++ .../StorybookChannelOrchestrator.test.ts | 42 +++++++++++++++++++ .../driver/StorybookChannelOrchestrator.ts | 23 ++++++---- 5 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 .changeset/storybook-reload-replaces-bridge.md 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/packages/agentic/storybook-desktop/references/integration.md b/packages/agentic/storybook-desktop/references/integration.md index 0b735bea2e..489f1a2edc 100644 --- a/packages/agentic/storybook-desktop/references/integration.md +++ b/packages/agentic/storybook-desktop/references/integration.md @@ -128,8 +128,9 @@ use 30 s. The Windows smoke relaunches the app between its `stories` and 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. Authored-test navigation itself does not wait for -the bridge. +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 diff --git a/packages/agentic/storybook-desktop/references/readiness.md b/packages/agentic/storybook-desktop/references/readiness.md index 2adf00de44..0487cfb47d 100644 --- a/packages/agentic/storybook-desktop/references/readiness.md +++ b/packages/agentic/storybook-desktop/references/readiness.md @@ -34,6 +34,10 @@ 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 diff --git a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts index 75da0b059e..f595c4097b 100644 --- a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts +++ b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.test.ts @@ -532,6 +532,48 @@ describe('StorybookChannelOrchestrator epochs and lifetime', () => { 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); + + 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', + }); + // 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('cancellation before dispatch has no effect; after dispatch fences the epoch until relaunch', async () => { const controller = new AbortController(); const { client, fetch, orchestrator } = setup( diff --git a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts index be5902d93a..2b158a00f6 100644 --- a/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts +++ b/packages/agentic/storybook-desktop/src/driver/StorybookChannelOrchestrator.ts @@ -213,12 +213,7 @@ export class StorybookChannelOrchestrator implements StoryOrchestrator { client.on('close', () => { this.epochs.delete(client); this.initializedClients.delete(client); - if (this.bridgeClient !== client) return; - this.bridgeClient = undefined; - this.bridgeConnected = false; - this.needsRelaunch = true; - this.currentStory = null; - for (const pending of this.pending.values()) pending.reject(new Error('The authenticated Storybook runtime bridge disconnected.')); + 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: [{ connectionEpoch: epoch, version: 2 }] })); @@ -260,21 +255,33 @@ export class StorybookChannelOrchestrator implements StoryOrchestrator { private acceptHello(client: ChannelClient, payload: Record): void { const expected = this.driverManifest; if ( + 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 || - (this.bridgeClient && this.bridgeClient !== client) + payload.nonce !== expected.bridgeNonce ) 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; if (this.journal) this.recordBridge(client); this.initializeBridge(client); } + 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 recordBridge(client: ChannelClient): Promise { const existing = this.recordings.get(client); if (existing) return existing; From 386f0585edae2450ab22e6d359e2fe4db3399555 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Fri, 6 Nov 2026 15:03:47 -0800 Subject: [PATCH 35/36] Record Windows Fabric and Win32 Paper native qualification Add Windows parameters to the native qualification runbook and record the Windows Fabric and Win32 Paper descriptor matrices, retry history, and cleanup evidence in the desktop-test, desktop-driver, and desktop-driver-mcp status ledgers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../windows-win32-native-qualification.md | 8 ++ .../references/current-status.md | 26 ++++ .../references/current-status.md | 25 ++++ .../desktop-test/references/current-status.md | 135 +++++++++++++++++- .../references/native-qualification.md | 48 ++++++- 5 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 .changeset/windows-win32-native-qualification.md 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/desktop-driver-mcp/references/current-status.md b/packages/agentic/desktop-driver-mcp/references/current-status.md index e9ee6d32fa..60a5082801 100644 --- a/packages/agentic/desktop-driver-mcp/references/current-status.md +++ b/packages/agentic/desktop-driver-mcp/references/current-status.md @@ -200,3 +200,29 @@ 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. | diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index b0a9b59988..4502de8b8e 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -338,3 +338,28 @@ 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`. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 916941d948..10616e007b 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -109,6 +109,11 @@ App: Agentic Components Storybook / ReactTestApp (configured bundle ID | 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` @@ -292,4 +297,132 @@ 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. +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. diff --git a/packages/agentic/desktop-test/references/native-qualification.md b/packages/agentic/desktop-test/references/native-qualification.md index 92f2869cb0..db15c34143 100644 --- a/packages/agentic/desktop-test/references/native-qualification.md +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -11,9 +11,11 @@ owner_paths: # Native descriptor-path qualification Use this runbook to qualify the desktop foundation against the combined -integration commit, not an individual track branch. The current macOS procedure -is parameterized by renderer; Windows Fabric and Win32 Paper require their own -machines and must remain **Not run** on macOS. Deterministic fake-host results +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. @@ -157,6 +159,40 @@ 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. + ## Cleanup and endpoint limits After each bounded pass, stop only resources owned by that run and record: @@ -173,7 +209,11 @@ 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. Hosted physical-input +**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. From 7b7ef4c5ab33bb0eb6a34566170078488d04f43d Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Fri, 6 Nov 2026 18:10:13 -0800 Subject: [PATCH 36/36] Repair Windows validation and atomic desktop reports; complete qualification evidence Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../windows-native-qualification-repair.md | 10 + .../references/current-status.md | 40 ++++ .../references/current-status.md | 49 +++++ .../desktop-test/references/contributing.md | 4 +- .../desktop-test/references/current-status.md | 204 ++++++++++++++++++ .../references/native-qualification.md | 21 ++ .../desktop-test/references/reporting.md | 8 + .../desktop-test/src/packageBoundary.test.ts | 3 +- .../src/reporting/collector.test.ts | 94 ++++++++ .../desktop-test/src/reporting/collector.ts | 43 +++- scripts/src/utils/runScript.test.ts | 21 +- scripts/src/worker/test-links.mts | 7 +- 12 files changed, 489 insertions(+), 15 deletions(-) create mode 100644 .changeset/windows-native-qualification-repair.md 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/packages/agentic/desktop-driver-mcp/references/current-status.md b/packages/agentic/desktop-driver-mcp/references/current-status.md index 60a5082801..93a9a68c27 100644 --- a/packages/agentic/desktop-driver-mcp/references/current-status.md +++ b/packages/agentic/desktop-driver-mcp/references/current-status.md @@ -226,3 +226,43 @@ Windows host only. | 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/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 4502de8b8e..a08eb0a78e 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -363,3 +363,52 @@ and a new story root `7fqk101` was observable in the same session. After a Win32 `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-test/references/contributing.md b/packages/agentic/desktop-test/references/contributing.md index 1ce84b0bc5..eb09aa950e 100644 --- a/packages/agentic/desktop-test/references/contributing.md +++ b/packages/agentic/desktop-test/references/contributing.md @@ -25,7 +25,9 @@ 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 10616e007b..610a03a16f 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -426,3 +426,207 @@ running. 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/native-qualification.md b/packages/agentic/desktop-test/references/native-qualification.md index db15c34143..46ae480346 100644 --- a/packages/agentic/desktop-test/references/native-qualification.md +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -128,6 +128,15 @@ supervisor path at a time; do not start `storybook driver --agent` and 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. | @@ -192,6 +201,18 @@ Windows: `--windows` is always Fabric and `--win32` is always Paper. 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 diff --git a/packages/agentic/desktop-test/references/reporting.md b/packages/agentic/desktop-test/references/reporting.md index 164b05dc64..d2af0fc47e 100644 --- a/packages/agentic/desktop-test/references/reporting.md +++ b/packages/agentic/desktop-test/references/reporting.md @@ -85,6 +85,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/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/reporting/collector.test.ts b/packages/agentic/desktop-test/src/reporting/collector.test.ts index 40d556cf07..7fd5113279 100644 --- a/packages/agentic/desktop-test/src/reporting/collector.test.ts +++ b/packages/agentic/desktop-test/src/reporting/collector.test.ts @@ -1,10 +1,13 @@ 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'; describe('desktop run reporter final output', () => { + const platform = process.platform; let artifactsRoot: string; beforeEach(() => { @@ -12,6 +15,8 @@ describe('desktop run reporter final output', () => { }); afterEach(() => { + jest.restoreAllMocks(); + Object.defineProperty(process, 'platform', { value: platform }); fs.rmSync(artifactsRoot, { force: true, recursive: true }); }); @@ -46,4 +51,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 4acbd00b8d..ce9e19f60b 100644 --- a/packages/agentic/desktop-test/src/reporting/collector.ts +++ b/packages/agentic/desktop-test/src/reporting/collector.ts @@ -507,24 +507,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/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); } }