diff --git a/.editorconfig b/.editorconfig index a36f3eea0a..c9e74ca966 100644 --- a/.editorconfig +++ b/.editorconfig @@ -154,9 +154,6 @@ dotnet_diagnostic.REACTOR_DIALOG_001.severity = none [src/OpenClaw.Tray.WinUI/Helpers/**/*.cs] dotnet_diagnostic.REACTOR_DIALOG_001.severity = none -[src/OpenClaw.Tray.WinUI/Helpers/InstanceManagementControls.cs] -dotnet_diagnostic.REACTOR_DIALOG_001.severity = none - [src/OpenClaw.Tray.WinUI/Pages/**/*.cs] dotnet_diagnostic.REACTOR_DIALOG_001.severity = none diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21ba90ce74..50768ea515 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -181,6 +181,10 @@ jobs: shell: pwsh run: ./scripts/test-proof-evidence.ps1 + - name: Test safe native failure metadata + shell: pwsh + run: ./scripts/test-ci-native-failure-metadata.ps1 + - name: Test read-only agent readiness shell: pwsh run: ./scripts/test-agent-readiness.ps1 @@ -211,6 +215,10 @@ jobs: ./scripts/test-ci-workflow-contract.ps1 ./scripts/test-ci-crash-metadata-source.ps1 + - name: Validate official Reactor package bootstrap + shell: pwsh + run: ./scripts/test-reactor-packages.ps1 + - name: Validate MSIX CI artifacts shell: pwsh run: ./scripts/test-msix-ci-artifacts.ps1 @@ -608,6 +616,8 @@ jobs: - name: Restore tray test projects run: | + $ErrorActionPreference = 'Stop' + ./scripts/Initialize-ReactorPackages.ps1 dotnet restore src/OpenClaw.Tray.WinUI -r win-x64 dotnet restore tests/OpenClaw.Tray.Tests -r win-x64 dotnet restore tests/OpenClaw.SetupEngine.Tests -r win-x64 @@ -711,6 +721,8 @@ jobs: - name: Restore UI test projects run: | + $ErrorActionPreference = 'Stop' + ./scripts/Initialize-ReactorPackages.ps1 dotnet restore tests/OpenClaw.Tray.UITests -r win-x64 dotnet restore tests/OpenClawTray.FunctionalUI.Tests -r win-x64 @@ -779,6 +791,7 @@ jobs: -Runtime win-x64 -Filter "Category!=Accessibility&Category!=NativeOnboardingProof&Category!=GatewayFixture&Category!=AnimationPreferenceEnabled&Category!=AnimationPreferenceDisabled" -HangTimeoutSeconds 300 + -CollectNativeFailureMetadata -CaptureHostExit - name: Capture non-memory Tray UI crash metadata @@ -1001,6 +1014,8 @@ jobs: - name: Restore E2E project graph run: | + $ErrorActionPreference = 'Stop' + ./scripts/Initialize-ReactorPackages.ps1 dotnet restore src/OpenClaw.Tray.WinUI -r win-x64 dotnet restore tests/OpenClaw.E2ETests -r win-x64 @@ -1077,6 +1092,8 @@ jobs: - name: Restore E2E project graph run: | + $ErrorActionPreference = 'Stop' + ./scripts/Initialize-ReactorPackages.ps1 dotnet restore src/OpenClaw.Tray.WinUI -r win-x64 dotnet restore tests/OpenClaw.E2ETests -r win-x64 @@ -1153,6 +1170,8 @@ jobs: - name: Restore E2E project graph run: | + $ErrorActionPreference = 'Stop' + ./scripts/Initialize-ReactorPackages.ps1 dotnet restore src/OpenClaw.Tray.WinUI -r win-x64 dotnet restore tests/OpenClaw.E2ETests -r win-x64 @@ -1316,7 +1335,10 @@ jobs: restore-keys: nuget-${{ runner.os }}- - name: Restore WinUI Tray App - run: dotnet restore src/OpenClaw.Tray.WinUI -r win-x64 + run: | + $ErrorActionPreference = 'Stop' + ./scripts/Initialize-ReactorPackages.ps1 + dotnet restore src/OpenClaw.Tray.WinUI -r win-x64 - name: Download signed cleanup host if: needs.sign-cleanup-host.result == 'success' @@ -1404,7 +1426,10 @@ jobs: restore-keys: nuget-${{ runner.os }}- - name: Restore WinUI Tray App - run: dotnet restore src/OpenClaw.Tray.WinUI -r win-arm64 + run: | + $ErrorActionPreference = 'Stop' + ./scripts/Initialize-ReactorPackages.ps1 + dotnet restore src/OpenClaw.Tray.WinUI -r win-arm64 - name: Download signed cleanup host if: needs.sign-cleanup-host.result == 'success' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 413362790c..af43fdc719 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -82,7 +82,10 @@ jobs: dotnet-version: 10.0.x - name: Restore dependencies - run: dotnet restore + run: | + $ErrorActionPreference = 'Stop' + ./scripts/Initialize-ReactorPackages.ps1 + dotnet restore - name: Initialize CodeQL uses: github/codeql-action/init@v4 diff --git a/.github/workflows/release-candidate-e2e.yml b/.github/workflows/release-candidate-e2e.yml index f975862f9f..88ce997407 100644 --- a/.github/workflows/release-candidate-e2e.yml +++ b/.github/workflows/release-candidate-e2e.yml @@ -387,6 +387,8 @@ jobs: working-directory: app-under-test shell: pwsh run: | + $ErrorActionPreference = 'Stop' + & "$env:GITHUB_WORKSPACE/scripts/Initialize-ReactorPackages.ps1" -DestinationDirectory (Join-Path (Get-Location) 'artifacts/reactor-packages') dotnet restore src/OpenClaw.Tray.WinUI -r win-x64 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet build src/OpenClaw.Tray.WinUI -c Debug -r win-x64 --no-restore diff --git a/DESIGN.md b/DESIGN.md index d868daecf3..f0196cbd5f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1027,6 +1027,48 @@ The [120-DPI comparison](.agents/design/reference/chat-comparison.png) preserves The Colophon catalog now uses the actual geometry, semantic alpha brushes and Fluent glyphs. It is **not a verified pixel-identical native renderer**: Mica is wallpaper/activation-dependent; this host's native UI Automation reports Segoe UI Variable while Chromium resolves the requested family to Segoe UI; and text rasterization/layout rounding differ. The installed schema also lacks responsive breakpoints, row-hover opacity, gradient elevation borders and native input/flyout behavior. Fixed-size references must not become production layout code. High-contrast preview swatches are resource simulations, not evidence of Windows HC mode. +## Systems page + +Machine, worker and connected-app card lists use spacing token `2` +(`PageResponsiveLayout.GapInner`, 8 effective pixels) between individually +rounded, bordered rows. An expanded machine keeps its contents inside that row's +boundary. Dense capability, session and diagnostic rows are not card lists; +statistic grids retain their separate 12-pixel grid spacing. + +The Systems page (`Systems/SystemsPageView.cs`, Reactor) has more personality than the calm settings surfaces, because it is a fleet dashboard. Its palette lives in `Themes/SystemsResources.xaml`, with Default, Light and HighContrast dictionaries: + +| Role | Resource | Dark | Light | +| --- | --- | --- | --- | +| Load, Gateway host | `SystemsLoadBrush` | `SystemAccentColorLight2` | `SystemAccentColorDark1` | +| Memory, paired nodes, sessions | `SystemsMemoryBrush` | #B9A4FF violet | #6B4FBB violet | +| Disk, workers, storage | `SystemsDiskBrush` | #5FD4D9 teal | #00747A teal | +| Neutral, connected apps | `SystemsNeutralBrush` | `TextFillColorSecondary` | `TextFillColorSecondary` | + +- **Usage.** Each hue has a `*TintBrush` (same color at 10-16% opacity) for glyph chips, pills and meter tracks. Success, caution and critical states use the native `SystemFillColor*` brushes with matching `Systems*TintBrush` tints. The shared native adapter `Presentation/Adapters/PageHero.cs` owns the hero's Windows-accent gradient inside the card; there is no full-page wash. +- **Rules.** + - Load follows the user's accent. Violet and teal are fixed so the three metrics stay distinguishable. + - Every hue appears with a glyph and a text label, so status never relies on color alone. + - In high contrast, every hue maps to `SystemColorHighlightColor` or window colors. The hero wash is removed. +- **Scope.** Statistics show the latest reported reading, a usage meter and its age; there are no trend charts. Windows nodes report processor count, memory and system-drive space, so no processor usage is shown for them. The page reports desktop availability only. Desktop viewing and setup, storage and backup administration, and other deeper machine work stay in the web interface behind the single page-level "Open in web" button. It opens the web Systems page when supported, or the web root on an older Gateway; it does not promise a selected-machine destination, so the machine identifier remains copyable. +- **Responsive container.** `Presentation/PageResponsiveLayout.cs` owns page-host (not window) tiers: Wide at 1000 or more effective pixels, Medium at 720-999, Narrow below 720, supported down to 480. Content is centered, capped at 1200, with 32-pixel horizontal margins (20 on Narrow). Horizontal scrolling and measurement are disabled. The hero and inventory share one scroll. The production window minimum remains 800; narrower proof mounts the actual page in the existing test viewport. +- **Page anatomy.** The shared hero card contains the Systems title, a real Gateway attachment sentence and wrapping Open in web/Add a system actions. The title is 28 semibold, 24 on Narrow; actions center vertically on wider layouts and move below the copy on Narrow. Card radius is 8, padding 24,20 (16 on Narrow), with a noninteractive system-accent gradient inside the card. Online and Issues are the only summary tiles. Measured content width governs their shared layout: two equal columns down to 380, one below it. Online reports machine rows, not connected apps or workers; disconnected copy qualifies retained counts as last reported. Unknown data is not a zero or a successful current report. Refresh is beside the inventory heading. Add a system explains the three pairing steps before opening Connection. "Devices and Gateway" always shows the complete reported inventory in online-first order, without search, status filters or sort controls. Workers and Connected apps retain their own groups. Independent native Expanders preserve siblings and scroll position. Inventory groups keep stable identities when a preceding pairing group is added or removed; a default selection arriving after the initial load expands only after its current native row has loaded. Intentional collapse survives later inventory refreshes. +- **Row attention.** There is no Needs you section or separate Issues view. Pending pairing requests appear first as distinct rows with Wants to join and Review. Machines and workers carry all existing approval, reapproval, offline, update, disabled-permission or reported-error badges in one stable wrapping host below the name and subtitle at every width. This intentionally replaces wide inline placement, preserving the identity's full width and the badge host's native parent across tier changes. Identity stack spacing is 2; the badge host has a 4-pixel top margin, 6-pixel item gaps and 4-pixel line gaps. Badge order, copy and motion remain unchanged. Only names ellipsize; badges retain their words and full-reason tooltips. Issues counts badged inventory entries and pending-request rows, not individual reasons; its action reveals, focuses and highlights the first current badged row without hiding inventory. Row details retain the explanation and appropriate resolution action. Pairing review and offline recovery open Connection; local Windows permission review opens Permissions; remote administration uses Open in web. Systems does not approve pairings or repair remote operating systems. Backup reads, the Last backup tile and backup badges remain closed. +- **Inline details.** Multiple rows may remain expanded independently. Names and subtitles stay single-line with ellipsis and tooltips. Status is a glyph and text, not a filled pill, capped in width so it cannot starve the name. On Narrow, metadata moves below the subtitle, decorative badges disappear, and the chevron stays centered at the right. One native CommandBar owns all node actions and reverse-priority dynamic overflow, with mutations disabled when pairing access is absent. Flat, small-heading sections place Capabilities in a fixed 340-pixel column, then a 32-pixel gap, then Resources, Desktop availability and Related sessions, only on Wide; Medium and Narrow stack with a fixed 16-pixel gap. Resource tiles use equal columns, at least 160 pixels each with 12-pixel gaps, computed from the available inline pane after padding and the container cap. A single tile in the last row stretches across the available columns; other incomplete rows retain equal tile widths. Tiles show value, label and meter without icon badges; freshness sits beside Resources and report age at the right. Every page poll re-renders ages and Live versus Last known, including when nothing, a worker or a connected app is selected, without an extra Gateway read. Presence-only apps expose diagnostic presence data, never machine actions, resources or sessions. +- **Header spacing and metadata.** Machine header content has 8-DIP vertical padding so badges do not touch the native row border. Medium and Wide rows reserve the same 180-DIP metadata column, with status and secondary text aligned at its start; Narrow rows keep metadata below the identity. Update summaries use the short localized "Update required" label, with full instructions retained in the tooltip and expanded details. The Expander still owns its template, chrome and interaction states. +- **Dialogs.** Add and Forget use native ContentDialog title templates with an inline glyph badge. Width is capped at the smaller of 560 and viewport minus 48 on the visible dialog card, so the native overlay spans the window and the card stays centered. Forget explains unpairing from the reviewed Gateway and reapproval, without speculating about active sessions. A 120-pixel label column presents platform, last seen and paired date; below 400 pixels of dialog width labels move above values. The scoped native footer keeps Forget left with AccentButtonStyle and Cancel right normally, and stacks full-width primary then close buttons on Narrow. Forget has no default button and initially focuses Cancel. Add initially focuses Open Connection. Responsive updates never reset focus. Confirmation retains its Gateway epoch guard. +- **Disclosure and copy.** Diagnostics starts collapsed, with commands and PATH in nested expanders. Copied diagnostic summaries are allowlisted and exclude both raw commands and PATH. Related sessions use a subtle icon-only chat button whose accessible name includes the title. No selection animation or resource history is shown. +- **Motion and mast alignment.** The shared hero supplies reduced-motion-aware entrance, reflow and attention-jump transitions; the page applies them to inventory and pairing rows too and observes changes to Windows animation settings. Open in web and Add a system retain native control states and centered glyphs, without icon-specific offsets. Refresh remains a separate inventory action. +- **Hero focus continuity.** The native hero adapter keeps glyph, copy and action containers as fixed siblings under one three-column, two-row Grid. Narrow placement changes only Grid attachments, spans, margins and alignment, never the action parent. Crossing 720 in either direction must retain the original native Add button, action host and focused object without programmatic refocusing. +- **Reported labels and dates.** Records and examples are never invented. Known capabilities use native translations. Unrecognized capability labels only replace separators and capitalize the first character; the exact Gateway ID remains in the tooltip and permission lookup. + +Disconnected inventory keeps one error recovery banner under the hero and an +honest last-report sentence. Retained resource tiles each carry a Last known +label, so freshness remains visible when their heading scrolls out of view. +Named joining devices appear in the row title, not repeated in the guidance. +The alternative hero implementation is awaiting its final shared-helper snapshot +and current-source Light/Dark visual acceptance; this contract is not a claim of +completed native proof. + ## Workspace shell ### Navigation search popup diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d88641eb0b..7845fb7179 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -171,6 +171,19 @@ Legacy MXC payload cleanup uses the Windows PowerShell included with Windows and ### Build Individual Projects +Before a direct WinUI build, restore, run, or the first Visual Studio restore, +initialize the repository-local feed for the published Reactor packages: + +```powershell +.\scripts\Initialize-ReactorPackages.ps1 +``` + +`NuGet.Config` maps Reactor exclusively to this verified feed. `build.ps1` and +`run-app-local.ps1` initialize it before building; `run-app-local.ps1 -NoBuild` +does not acquire packages and requires existing output. Shared-only builds do +not require Reactor. The initializer does not change the global NuGet cache or +configuration. + **Shared Library:** ```bash dotnet build src/OpenClaw.Shared diff --git a/NuGet.Config b/NuGet.Config index 4e800bf67f..b99662cf91 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -2,9 +2,14 @@ + + + + + diff --git a/README.md b/README.md index 4456e77e4b..37d3cebf82 100644 --- a/README.md +++ b/README.md @@ -214,11 +214,18 @@ the same runtime resolver as the app build and rejects a missing or outdated com Direct WinUI builds require a runtime identifier: ```powershell +.\scripts\Initialize-ReactorPackages.ps1 dotnet build .\src\OpenClaw.Tray.WinUI\OpenClaw.Tray.WinUI.csproj -r win-x64 dotnet build .\src\OpenClaw.Tray.WinUI\OpenClaw.Tray.WinUI.csproj -r win-arm64 dotnet build .\src\OpenClaw.Tray.WinUI\OpenClaw.Tray.WinUI.csproj -r win-x64 -p:PackageMsix=true ``` +The initializer verifies the published Reactor packages in the repository-local +feed selected by `NuGet.Config`. Run it before direct restore/build/run commands +or the first Visual Studio restore. `build.ps1` and `run-app-local.ps1` perform +this step automatically when building; `-NoBuild` uses existing output without +acquiring packages. No global NuGet configuration or cache is changed. + ### Run ```powershell diff --git a/build.ps1 b/build.ps1 index 94614edf89..04c01c2c14 100644 --- a/build.ps1 +++ b/build.ps1 @@ -585,6 +585,9 @@ function Build-Project($name, $path, $useRid = $false, $packageMsix = $false) { $dotnetArgs += "-p:MsixPackageBaseVersion=$effectiveMsixBaseVersion" } } + if ($name -eq "WinUI" -or $name -eq "Tray") { + & (Join-Path $repoRoot "scripts\Initialize-ReactorPackages.ps1") + } $result = Invoke-DotNetCaptured $dotnetArgs $exitCode = $LASTEXITCODE diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md index 2a9e0c5d68..90f3171cf1 100644 --- a/docs/ACCESSIBILITY.md +++ b/docs/ACCESSIBILITY.md @@ -12,6 +12,20 @@ dotnet test tests\OpenClaw.Tray.UITests --no-build -c Debug -r win-x64 --filter Those tests launch the app in a real Windows desktop process and scan pages for WCAG violations through Axe.Windows. Failures are summarized in the GitHub Actions step summary and written to `Accessibility.trx`. +The preceding framework UI lane preserves failed test exit codes and writes +`native-failure.json` with allowlisted Application Error module/exception metadata +and installed Windows App Runtime versions. It does not collect memory dumps, +event messages, command lines, paths or environment values. Missing event access +is reported as unavailable, not successful native diagnosis. Testhost phase logs +distinguish COM-wrapper initialization from Windows App SDK bootstrap. +Watchdog-terminated hangs may have no Application Error event; use the phase logs +and interrupted-test sequence in that case. An instrumented passing run does not +erase an earlier crash or establish its cause. +The test application writes unhandled exception types, HRESULTs and managed method +names directly to the standard error stream. This bypasses per-test console +buffering that can lose the last diagnostic on native fail-fast. Exception messages, +data, file paths and locals are not included, and exceptions remain unhandled. + Failure diagnostics retain rule IDs, bounded rule descriptions and canonical control types with scan-result ordinals. They omit UIA names, automation IDs (which can be data-derived), arbitrary properties and desktop focus state. @@ -81,4 +95,29 @@ pairing. ## Relationship to other validation +### Settings navigation keyboard contract + +Back to app and an unhandled Escape leave full Settings for the retained app +destination. Window-owned `SettingsEscapePolicy` preserves native editors, +flyouts, menus and dialogs as the first Escape owners; it does not collapse the +Settings rail. The pane toggle retains the user's saved preference. +`WorkspaceWindowProofTests.SettingsBackToApp_ReturnsToAppDestinationInSameWindow` +covers mouse, Enter and Escape activation, retained chat and notification focus. +`WorkspaceNavigationTests.SettingsEscape_LeavesFullSettingsOnlyWhenNothingElseOwnsEscape` +covers the policy exclusions. + +Workspace still has an 800-DIP minimum width. The below-720-DIP rail is not a +supported window-size claim. Width-driven shell embedding and temporary narrow +rail preferences are superseded by the canonical shared foundation. Page-owned +responsive layouts still use their allocated content width. +`SettingsPageInsetTests.Shell_DeliversFullAndEmbeddedInsetsWithoutMovingBackdropOrReplacingContent` +covers the optional content inset and preserves ordinary pages' existing padding. + +The integrated Config, Local AI, Default Model, Voice, Notifications, Event +Stream and Diagnostics source adapts to allocated width after rail/sidebar +subtraction. Config measures its editor grid and keeps draft controls mounted +while stacking tree/detail panes. Source guards and XAML parsing do not prove +native layout, focus retention, text scaling or six-locale reachability; these +still require current-build native proof before any window-minimum reduction. + Accessibility scans do not replace the required agent closeout validation in `AGENTS.md`. They are an additional focused lane for WinUI accessibility work and a formal CI gate documented in `docs\TEST_COVERAGE.md`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c572ebd00c..7615e3fd3e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -121,6 +121,9 @@ new attempt. This boundary does not change release signing or Windows policy. | Preparing the pinned-page host | `WorkspaceWindow.Pins.QueuePinnedPageHostPreparation` prepares an unbound Settings XAML shell at low dispatcher priority after a pin preference changes. It rechecks window lifetime, shell existence and current Gateway pin scope before preparation. `EnsureSettings` consumes it and binds current state at invocation; window close disposes any unused shell. Preparation does not establish Settings agent identity or subscriptions, select or initialize a page, change history, cache additional pages, or change native transitions. `GatewayFixtureUiTests.PinnedPages_NativeSwitchTiming` records click-to-visible timings and checks that preparation and repeated page visits preserve the chat draft; the source guard protects the preparation boundary and animation contract until equivalent native lifecycle coverage is mandatory | authoritative | | Workspace owner display identity | `WorkspaceIdentitySource` reads the current operator's `users.self` profile (trimmed name, else first non-blank email, else profile id, matching the macOS sidebar label; `IsSharedOwner` marks the `gateway-owner` profile and `SharedOwnerName` keeps only its saved trimmed display name) and, for every valid profile as on macOS, a Gateway-served avatar through `OpenClawGatewayClient.ResolveUserAvatarAsync`, which reuses the existing authenticated media HTTP lease (the lease's fixed Gateway host, header auth, no redirect, 1 MB cap, no SVG) and refuses a blank, `.` or `..` profile id, or an id or revision over 512 characters, before any request; the Gateway avatar route owns upload, GitHub and Gravatar precedence, so the client does not pre-filter on `hasAvatar` or email; avatar failure never clears the name; the avatar request has one end-to-end deadline covering headers and body (`OPENCLAW_USER_AVATAR_TIMEOUT_MS`, default 15000) and the `users.self` read waits for its response for at most `OPENCLAW_USER_SELF_TIMEOUT_MS` (default 12000, counted from when the request has been sent); a timeout is a failure, and caller or connection cancellation stays silent; an explicit profile invalidation (`SelfProfileRevision` change) or connection change cancels the current attempt through one per-attempt linked token, so a stalled `users.self` read or avatar never delays the refreshed identity (the known name stays visible meanwhile; the request API takes no token, so an abandoned `users.self` read is left to the client's own request handling while its result is fenced and its late fault is observed); ordinary presence refreshes never cancel or restart the current attempt (they coalesce into one follow-up read), never return a known profile to Owner, and keep the current image for an unchanged profile id and revision without refetching; every settled avatar outcome (image, not found, unsupported, unauthorized or failed) is remembered per profile id and revision, so a failed avatar stays as initials until the revision changes, an explicit invalidation retries it, a failed `users.self` read forgets the outcome, or the connection changes (no timer-based retry), and an unresolved (cancelled) attempt is never remembered or treated as done, so the next refresh retries it; a failed `users.self` read settles (reports its category and clears the profile, so the footer shows Owner) instead of retrying on a queued ordinary refresh; `GatewayRequestErrors` exposes only codes from fixed protocol lists (anything else becomes `UNKNOWN`; local refusals carry none) and `IdentityFailure` classifies them without claiming a cause: NotReady/Timeout/Unavailable retry on the next lifecycle event; Forbidden/InvalidRequest/Unknown/MalformedReply/ServerMissingScope stay suppressed until invalidate or connection switch; ServerMissingScope is a FORBIDDEN reply carrying the fixed MISSING_SCOPE detail (scope names never captured) and a detail-less Forbidden stays unattributed; LocalScopeGate is reported once per connection when the local scope gate skips `users.self`; `WorkspaceWindow` applies `WorkspaceIdentitySource.FooterLabel` (web Control UI parity for the shared owner: localized `Name · Shared owner` with a saved name, else Shared owner, never its email or id; otherwise the profile label or Owner), plus native `PersonPicture` (initials from the profile label, never from Shared owner or Owner) | authoritative | | Chat and Settings pane footer (account, Dashboard, bell) layout, compact rail, and Chat title-bar actions | `WorkspacePaneFooter` owns layout and control application only; `WorkspaceWindow` is the single applicator of identity, connection, badge, account menu and Dashboard command for the Chat, title-bar and Settings instances; `WorkspaceFooterPlacement` decides title-bar visibility (an embedded pinned page hides the Settings pane, so the Chat sidebar footer serves it) | authoritative | +| Settings width-dependent chrome | Superseded by the canonical shared foundation. Do not restore width-driven embedding, temporary narrow-pane preference, or Escape-to-collapse in `SettingsShellView` / `WorkspaceWindow`. The destination owns embedding, the user's saved choice owns pane state, and window-owned Back to app / Escape owns leaving Settings. Page-responsive sizing remains page-owned; history allocation uses the minimal policy below | closed | +| Systems shell inset presentation | `PageResponsiveLayout` owns pure shared page-host width tiers and content geometry; shared `PageHero` in `Presentation/Adapters/PageHero.cs` owns native rendering of the responsive accent-wash header, summary tiles, transitions and reveal/highlight behavior (its existing `OpenClawTray.Presentation` namespace is retained for caller compatibility). Header glyph, copy and actions remain fixed Grid siblings across width tiers; the adapter changes placement only, never action parentage. `SystemsPage` implements `ISettingsPageContentInset` through its retained `SystemsViewport`, independent of `SystemsController`. `SystemsPageView` subscribes to inset changes and re-reads after subscribing, applying inset only to the keyed ScrollViewer viewport. Its full-frame root, dialogs and feedback overlays do not move; the shared hero card scrolls with inventory and there is no full-page backdrop. Loading/error/disconnected content uses the same scroll allocation. `SystemsViewportTests` protects notification/pre-mount retention and no controller/remount calls; `SystemsPageProofTests.ShellInset_UpdatesRetainedScrollAtConstantWidthWithoutMovingRoot` protects retained controls and zero reset. `Hero_StateMatrixRetainsFocusAndGeometryAcrossMountedWidths` protects original Add/action-host/Grid identities and actual focused-object continuity across startup and both resize directions. Native execution/visual proof remains required | authoritative | +| Tracked Sessions publication | `GatewayService` captures the client generation and canonical `SessionMutationConnectionEpoch`, then rechecks identity, generation, connection and epoch inside the dispatcher before writing the existing `AppState.Sessions` array and pruning previews. Status/auth callbacks retain their independent identity/epoch guards. No second admission layer, mirrored freshness epoch or unused attention aggregate lives in `AppState`. `GatewayServiceSessionPublicationTests` exercises queued publication, client replacement, same-client reattach/reconnect, disconnect and current/null-epoch compatibility through the actual dispatcher. Systems hero counts remain page-scoped inventory/pairing projections | authoritative | | Settings footer status pill and footer Diagnostics/Settings routes; inline per-window account and bell footer controls | Closed in `SettingsShellView` and `WorkspaceWindow.xaml`; host `WorkspacePaneFooter`. General, Diagnostics and About live in "System"; Connection is first under "Gateway"; notification preferences live inside General; status is the account dot/subtitle and account-menu row | closed | | Search shortcut pressed while the search flyout is still closing | `DeferredFlyoutShowState` (pure, unit-tested) decides; `FlyoutShowRequest` adapts flyout events and replays the owner's open path after `Closed` (`WorkspaceWindow.OpenCommandCenter`; Settings replays through the host's `CommandCenterRequested` so modal-dialog deferral applies). Owners cancel queued reopens when their section hides or closes | authoritative | | Shared native command execution | `HubCommandExecutor` executes typed navigation-only search targets through Workspace navigation and the existing Dashboard launcher. Legacy command callers retain settings-store toggle handling; search never emits a toggle | authoritative | @@ -138,7 +141,7 @@ new attempt. This boundary does not change release signing or Windows policy. | Optional release updater dependency lifetime | `UpdateBackendLoader` + non-inlined `ReleaseUpdateBackendFactory`; `UpdatumUpdateBackend` alone owns vendor types | authoritative | | Eager release updater construction in App and vendor-typed download UI | Closed in `App` and `DownloadProgressDialog`; `UpdateCoordinator` admits the release channel before lazy creation and reports unavailable recovery through existing update state | closed | | Shared page presentation and responsive sizing | `PageHero` / `PageHeroIcon` render caller-owned localized content using native Reactor and the existing `PinnedPages` icon assets; `PageResponsiveLayout` owns host-width tiers, padding and tile sizing. Header actions retain native ancestry across resize. No page data, routing or subscriptions move into these helpers | authoritative | -| Settings page history-band allocation | `WorkspaceSettingsLayout` computes allocation; `WorkspaceWindow` applies outer embedded margins and `SettingsShellView` delivers `ISettingsPageContentInset` to opted-in pages on navigation and pane changes. A collapsed embedded host allocates 56 DIPs exactly once, inside opted-in content or outside ordinary pages; existing pages retain their own full-Settings padding. Pane toggles stay above full-frame backdrops. Navigation identity, Back to app, Escape, Dashboard dispatch and saved pane preference remain unchanged. `WorkspaceNavigationTests.HiddenPane_UsesZeroWidthAndReopenRowWithoutCoveringContent` guards the chrome stacking until native opted-in-page coverage is mandatory in closeout | authoritative | +| Settings page history-band allocation | `WorkspaceSettingsLayout` computes allocation; `WorkspaceWindow` applies outer embedded margins and `SettingsShellView` delivers `ISettingsPageContentInset` to opted-in pages on navigation and pane changes. A collapsed embedded host allocates 56 DIPs exactly once, inside opted-in content or outside ordinary pages; existing pages retain their own full-Settings padding. Pane toggles stay above full-frame backdrops. Navigation identity, Back to app, Escape, Dashboard dispatch and saved pane preference remain unchanged. `WorkspaceNavigationTests.HiddenPane_UsesZeroWidthAndReopenRowWithoutCoveringContent` guards chrome stacking; `WorkspaceSettingsLayoutTests.OptedInPageAllocation_PreservesOrdinaryPagePaddingAndNavigationIdentity` guards inset delivery after initialization, reset and the prohibition on adding full-Settings frame margins. Retire these source guards when native opted-in-page coverage is mandatory in closeout | authoritative | | Page view-model activation/deactivation + disposal lifetime | `NavigationScopeManager` | authoritative | | Presentation-layer DI composition root | `AppServiceRegistration` (root `ServiceProvider`, owned by `App`) | authoritative | | Settings snapshot read + field-scoped save + origin-aware change notification | `ISettingsStore`; `SettingsManager` captures the immutable presentation snapshot and commit-order version from the data being persisted, then delivers `Saved` outside save serialization. `SettingsStore` forwards that payload with its writer origin, never resnapshots a concurrent edit. Delayed notifications retain their original versions so consumers can reject stale delivery | authoritative | @@ -913,6 +916,12 @@ leading and trailing pipe. Columns, in order: | id | status | old_owner | closed_responsibility | new_owner | allowed_residue | invariant | guard_test | guard_type | retirement_condition | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| systems-inventory-projection | authoritative | src/OpenClaw.Tray.WinUI/Pages/InstancesPage.xaml.cs | machine inventory joins, presence and status mapping, session relations, measurement freshness and desktop availability projection | SystemsProjection + SystemsFreshness + DesktopPresentation (Shared) + SystemsController (Tray, WinUI-free) | SystemsPage hosts the Reactor SystemsPageView, forwards AppState and Gateway events, runs dialog-confirmed node rename/forget after a live `operator.pairing` check, routes pairing decisions to Connection and opens the web Systems page, or the web root on a Gateway without one, through `IAppCommands.OpenDashboard` | only exact Gateway identities join; node.list entries absent from environments.list are never resurrected; requested placements bind to no machine; Systems never approves pairings, patches Gateway config, reads backup state or probes storage | SystemsProjectionTests.Project_JoinsExactIdentitiesAndClassifiesKinds | behavioral | - | +| instances-page-closed | closed | src/OpenClaw.Tray.WinUI/Pages/InstancesPage.xaml.cs | imperative XAML instance cards and InstanceManagementControls builders | SystemsPageView (Reactor) | instances and nodes routes alias to the systems page | the legacy routes resolve to Systems and no second inventory page exists | HubPageRegistryTests.ResolvePage_OwnsDirectLegacyAndAgentMappings | behavioral | - | +| systems-master-detail-closed | closed | SystemsPageView | independently scrolling inventory ListViews and selected-machine side pane | SystemsPageView single scroll with independent inline Expanders | SystemsController retains the selected id, including unresolved deep links; SystemsPageView observes default selection arriving after a held first inventory and keys inventory groups independently of pending pairing rows | no second machine details pane or nested inventory scroll; expanding a row preserves siblings and scroll position, and removing pairing rows does not remount the device group | SystemsPageProofTests.InlineExpanders_KeepMultipleRowsOpenWithoutChangingScrollPosition | behavioral | - | +| systems-initial-selection | authoritative | SystemsPageView | synchronizing the first asynchronous inventory selection with inline expansion | SystemsPageView selection effect and current native row Loaded state | SystemsController retains default-selection policy; explicit same-ID requests still use SelectionRequest | a default selected after the loading view mounts realizes its details without a second selection; user collapse survives later inventory refresh | SystemsPageProofTests.DelayedInitialInventory_ExpandsDefaultRowWithoutAnotherSelection | behavioral | - | +| systems-inventory-group-identity | authoritative | SystemsPageView | sibling inventory group reconciliation across pending pairing changes | SystemsPageView keyed pairing, device, worker and connected-app groups | rows retain their existing per-identity keys and native disclosure ownership | removing a preceding pairing group retains the device group and expanded native rows instead of remounting their controls | SystemsPageProofTests.HeroAndRowBadges_UseReportedStateAndResponsiveNativeLayout | behavioral | - | +| systems-backup-health-closed | closed | SystemsPageView | backup targets, schedules, push-failure presentation, `backup.status` polling and BackupsPresentation | web interface through the page-level Open in web action | the Systems mast keeps the single Open in web action | no native Backups section, backup attention pip or `backup.status` read; backup administration stays in the web interface | SystemsPageProofTests.SelectedMachine_HasNoBackups | behavioral | - | | optional-updater-lifetime | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | eagerly constructed vendor updater and vendor-typed orchestration | UpdateBackendLoader + ReleaseUpdateBackendFactory + UpdatumUpdateBackend | UpdateCoordinator retains channel admission, prompts, state publication and install concurrency | dependency load failure disables only automatic updates for this process; successful checks and installs retain one backend | UpdateBackendLoaderTests.Load_DependencyFailureIsContainedAndCached | behavioral | - | | app-updater-dependency-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | Updatum imports, fields, constructor calls and startup signatures | UpdateCoordinator + IUpdateBackend | composition of the vendor-free coordinator only | Store, Dev and Debug skip before loading; production optional assembly resolution occurs across a non-inlined factory under the loader catch frame | UpdateBackendLoaderTests.StartupFacingOwners_DoNotReferenceOptionalAssembly | source-shape | when process-level blocked-assembly startup tests cover packaged, Dev, Debug and production Release entry points | | setup-chat-warning-recovery | authoritative | src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs | setup warning lifetime and refresh admission | SetupNativeChatPresentation + SetupNativeChatRefresh | page applies localized InfoBar state, forwards dispatcher/lifecycle/composer navigation events and calls existing authority and renderer owners | clearing a binding clears only presentation; same-client recovery retains draft after full authority checks; composer /new releases the setup target before async creation; node-only events do not remount; visual recheck never retries or settles receipts | SetupNativeChatPresentationTests.SameClientRecoveryRetainsHostAndHealthyRefreshDoesNotReseed | behavioral | - | diff --git a/docs/DATA_FLOW_ARCHITECTURE.md b/docs/DATA_FLOW_ARCHITECTURE.md index 4eaad1b825..7916114156 100644 --- a/docs/DATA_FLOW_ARCHITECTURE.md +++ b/docs/DATA_FLOW_ARCHITECTURE.md @@ -126,7 +126,7 @@ private void OnAppStateChanged(object? sender, PropertyChangedEventArgs e) | ConnectionPage | Status, NodePairList, DevicePairList, Channels, UsageCost, Sessions, GatewaySelf | | SessionsPage | Sessions, ModelsList | | UsagePage | Usage, UsageCost, UsageStatus | -| InstancesPage | Nodes, Presence | +| SystemsPage | Status, Nodes, Presence, Sessions, NodePairList, DevicePairList (plus `SystemsInventoryChanged` and `NodeHostStatsReceived` client events) | | ConfigPage | Config, ConfigSchema | | CronPage | CronList, CronStatus, CronRuns | | WorkspacePage | AgentFilesList, AgentFileContent | @@ -135,6 +135,17 @@ private void OnAppStateChanged(object? sender, PropertyChangedEventArgs e) Pages that don't observe AppState: ChatPage, SettingsPage, SandboxPage, VoiceSettingsPage. +Systems inventory comes from `environments.list`, with `node.list` and +`system.info` supplying exact-identity joins and reported resources. +`node.hostStats` events update those resources; no local sampler feeds the +page directly. Windows host sampling is published to the Gateway first. +`sessions.list` provides related loaded sessions, while `node.pair.list` and +`device.pair.list` provide pairing awareness. Missing desktop flags remain +unknown; invalid resource ranges are unavailable, never zero-usage +substitutes. Legacy inventory derives only from the live Gateway handshake, +reported nodes and presence. Local device identity marks "this device"; local +time supports receipt/freshness, not invented Gateway records or measurements. + Workspace is the only application chat host. Its retained `ChatPage` observes the app-owned `OpenClawChatDataProvider`, not a Settings frame or a tray popup. `WorkspaceWindow` owns agent/session selection and navigation history; external diff --git a/docs/GATEWAY_FIXTURE_TESTING.md b/docs/GATEWAY_FIXTURE_TESTING.md index 1bb02efdc5..38c35130cb 100644 --- a/docs/GATEWAY_FIXTURE_TESTING.md +++ b/docs/GATEWAY_FIXTURE_TESTING.md @@ -435,6 +435,98 @@ and resetting for a new job. Artifacts include owned-window editor captures and the exact synthetic schedules read back after accepted saves. This proves the app/fixture wire path, not production Gateway persistence or scheduling. +## Systems inventory proof + +The `systems-inventory` and `systems-empty` scenarios back the native Systems +page. `systems-inventory` serves `environments.list`, `system.info`, `node.list` +with host statistics, pending node and device pairings, and presence beacons +for connected apps. Session rows carry placement and exec +bindings so machines show related sessions. Timestamps are relative to the +current time, and host statistics change between reads. Node rename and removal +still receive the read-only protocol error; Systems routes pairing decisions to +the Connection page and opens deeper machine and desktop administration in the +web interface. `systems-empty` returns an empty inventory. + +```powershell +.\scripts\run-gateway-fixture.ps1 -AppPath $app -Scenario systems-inventory +.\scripts\run-gateway-fixture.ps1 -AppPath $app -Scenario systems-inventory -Theme Dark +``` + +Navigate with local MCP `app.navigate` and `{"page":"systems"}`, or open one +machine with `{"page":"systems:node:fixture-studio"}`. `-Theme` (Light by +default, or Dark) sets the isolated profile's app theme, because local MCP cannot +change it. + +The page is one scroll, not a master-detail list. Select an inventory row to +expand it inline; other rows can remain expanded independently. +`systems:` still opens exactly that row, and an unavailable id keeps its +missing-selection explanation. "Devices and Gateway" always shows the full +inventory in online-first order, with no search, status filter or sort controls. +The shared hero reports this PC's observed Gateway attachment. Online and Issues +are its only summary tiles; backup reads and badges remain closed. Pending pairing +requests appear first as separate Wants to join rows. Other attention badges stay +on their actual machine/worker rows. Issues reveals the first badged row without +hiding the inventory; there is no separate Issues view. Workers and Connected +apps retain their groups. Pairing review routes to Connection, not inline approval. +Presence does not create machine rows or connection topology. + +For native mounted-control proof on an authorized desktop, use the host's RID +(`win-arm64` on native ARM64): + +```powershell +$env:OPENCLAW_REPO_ROOT = (Get-Location).Path +dotnet test .\tests\OpenClaw.Tray.UITests\OpenClaw.Tray.UITests.csproj ` + -r win-arm64 --filter 'FullyQualifiedName~SystemsPageProofTests' +``` + +This covers inline row invocation, the minimum-height scroll, pending recovery, +read-only access, resource disclosure, management confirmation epochs, the single +page-level direct Open in web action, Issues jump without hidden inventory, independent +expanders and narrow recovery-action layout, and the Capabilities column width +and gap. It is not screenshot proof. Capture the hero, row badges and +expanded row in light, dark and narrow layouts from the current fixture build. Verify +the native CommandBar overflow and keyboard focus, collapsed Diagnostics, +separate CPU count/load and the icon-only chat action. The fixture cannot prove +real pairing or live desktop control. + +`GatewayFixtureUiTests.SystemsDesign_RealAppLightAndDark` launches an isolated +real app for each theme and captures the loaded page, expanded machine, Add +system dialog and cancelled Forget dialog. Set `OPENCLAW_RUN_GATEWAY_FIXTURE_UI=1`, +`OPENCLAW_GATEWAY_FIXTURE_APP` to the current executable and +`OPENCLAW_GATEWAY_FIXTURE_ARTIFACTS` to an evidence directory before selecting +that test. Captures use the existing owned-window capture helper. Mounted +`RenderTargetBitmap` exports can omit compositor surfaces, opacity or inherited +theme brushes; do not treat them as equivalent native screenshot evidence. + +`SystemsPage_OmitsConnectionMapInLightAndDark` verifies that the fixture-mounted +page has no connection-map elements in either theme. `SystemsInventory_ExistingSeparatedCards` +captures separated inventory rows, then scrolls the expanded Gateway's related +sessions and Diagnostics into view before capture. `SystemsHero_TopAndScrolled` +captures the scrolling hero with an unchanged scroll viewport. +`SystemsHero_FullHostLightAndDark` captures wide and medium native shell widths +in both themes. Widths below 720 use the existing mounted viewport route without +lowering the production 800-DIP minimum. These renamed hero cases are source-only +until their granted lane executes; earlier backdrop screenshots do not prove the +new header. Fixture captures remain synthetic proof. + +Add a system opens three instructions and forwards to Connection. Forget names +the node, displays only reported platform, last-seen and approval facts, and +defaults to Cancel. A successful rename or forget shows a four-second in-page +notification; errors remain in the dialog and are announced assertively. + +The page polls the selected machine's reported measurements every `OPENCLAW_SYSTEMS_POLL_SECONDS` +(default 15, minimum 5) and reloads the inventory once per burst of Gateway +events, `OPENCLAW_SYSTEMS_EVENT_REFRESH_MS` (default 1500) after the first one. +Paired Windows nodes publish host statistics immediately after handshake and every +`OPENCLAW_NODE_HOST_STATS_INTERVAL_SECONDS` (default 60). + +Node wire fixtures must therefore correlate command replies rather than assume +the next frame is a reply. `NodeExecDenialWireTests` acknowledges only validated +`node.event` requests for `node.host.stats`, using the Gateway's +`handled: true, reason: "updated"` payload. Wrong invocation ids, unexpected +methods or events, and malformed statistics fail the fixture. Continued +statistics cannot extend its existing invocation cancellation deadline. + ## Targeted context-usage proof `ContextFooterReconcilesLiveReplayHistoryAndAuthoritativeUsage` opts into diff --git a/docs/SETUP.md b/docs/SETUP.md index 475b0d85e1..caa7fc357d 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -96,7 +96,7 @@ OpenClaw Companion responds to `openclaw://` deep links, which can be invoked fr | `openclaw://settings` | Open the Settings page | | `openclaw://setup` | Open the Setup Wizard | | `openclaw://commandcenter` | Open Command Center diagnostics | -| `openclaw://activity` | Legacy activity route; opens Sessions, Usage, Instances, or the Gateway dashboard channels path according to `?filter=` | +| `openclaw://activity` | Legacy activity route; opens Sessions, Usage, Systems, or the Gateway dashboard channels path according to `?filter=` | | `openclaw://history` | Legacy notification-history alias; opens the Gateway dashboard channels path | | `openclaw://healthcheck` | Run a manual health check | | `openclaw://check-updates` | Run a manual update check | diff --git a/docs/WINDOWS_NODE_TESTING.md b/docs/WINDOWS_NODE_TESTING.md index cb089bcb22..1863f87aed 100644 --- a/docs/WINDOWS_NODE_TESTING.md +++ b/docs/WINDOWS_NODE_TESTING.md @@ -28,7 +28,7 @@ For changes touching tray UX, Settings, onboarding, chat/canvas, Command Center, Short version: run required tests, collect a closeout proof pass with `.\run-app-local.ps1 -Isolated` when UI is involved, use computer-use or developer-provided screenshots/output for the active changed UI state, prove MCP with `winnode` or raw JSON-RPC, prove gateway paths when available, and include current-head concrete output under `## Real behavior proof`. Mid-development computer-use/MCP/rubber-duck validation is fine when explicitly requested or needed to unblock work. -### Reactor preview.16 compatibility proof +### Reactor compatibility proof The [fixture Gateway harness](GATEWAY_FIXTURE_TESTING.md) can populate the real app without a running WSL Gateway or AI provider. Run @@ -39,8 +39,8 @@ non-Dev Release binary as well as Debug; an empty page or MCP snapshot is not equivalent to visible native history proof. Streaming still needs separate coverage. -Both Reactor packages use `0.1.0-preview.16`, which includes the recycled-anchor -workaround from +Both Reactor packages use `0.1.0-preview.17`, including the recycled-anchor +workaround introduced in preview.16 from [microsoft/microsoft-ui-reactor#1214](https://github.com/microsoft/microsoft-ui-reactor/pull/1214). This is a Reactor workaround for [microsoft/microsoft-ui-xaml#11865](https://github.com/microsoft/microsoft-ui-xaml/issues/11865), @@ -48,13 +48,15 @@ not evidence of a released native WinUI fix. Keep the existing row-targeted `StartBringItemIntoView` scrolling rather than the application-side extent-only workaround from #1407. -Rechecked on 2026-10-22: `0.1.0-preview.16` remains the latest published version, +Historical check on 2026-10-22: `0.1.0-preview.16` was the latest published version, including previews, in the authoritative NuGet feeds for [Microsoft.UI.Reactor](https://api.nuget.org/v3-flatcontainer/microsoft.ui.reactor/index.json) and [Microsoft.UI.Reactor.Advanced](https://api.nuget.org/v3-flatcontainer/microsoft.ui.reactor.advanced/index.json), and the [upstream releases](https://github.com/microsoft/microsoft-ui-reactor/releases). -Keep both references aligned. The core package requires `System.Drawing.Common` -`10.0.12` or newer; the tray's direct reference meets that floor. +The [official preview.17 release](https://github.com/microsoft/microsoft-ui-reactor/releases/tag/v0.1.0-preview.17) +now supplies the hash-verified packages used here. Keep both references aligned. +Preview.17 requires `System.Drawing.Common` `10.0.14` or newer; the tray's direct +reference meets that floor. Preview.16 enforces pooled-control resets with `REACTOR_POOL_001`. Chat uses Reactor's declarative `HelpText`, `ToolTip` and `ItemStatus` modifiers instead of @@ -71,11 +73,47 @@ assumed a horizontal StackElement and is incompatible with the upstream Grid. Sanitization, parser flags, inert links/images and custom block presentation remain application-owned. +`build.ps1`, Store packaging and CI initialize the hash-verified official +GitHub release packages in the ignored `artifacts\reactor-packages` feed. +Before a direct `dotnet restore` or first test build, run +`.\scripts\Initialize-ReactorPackages.ps1`. `NuGet.Config` maps only the two +Reactor package IDs to that feed; it does not change machine NuGet settings. +Package digests, not the release DLLs' development-style ProductVersion, identify +the published assets. `.\scripts\test-reactor-packages.ps1` checks acquisition, +cache integrity, failure cleanup and source mapping without network access. + Run `ReactorMarkdownListProofTests` in `OpenClaw.Tray.UITests` on the host's native architecture. These mounted tests check ordered/unordered wrapping at 240 DIPs, reflow at 600 DIPs, and nested/loose list content and formatting. Keep the existing table and disposal proof in the focused run. +The Systems page relies on two preview.13 fixes: reconciling a ContentDialog +while it is open (microsoft/microsoft-ui-reactor#1073) and clearing pooled Border +backgrounds so `Theme.Ref` fills survive reuse (microsoft/microsoft-ui-reactor#952). +On the `systems-inventory` fixture, switch between machines and confirm that every +statistics tile keeps its fill and usage meter. + +The published preview.17 train also replaces the task-local dialog-theme and +pooled-Background backports. This is a runtime dependency, not just an API +upgrade: Systems' Add/Rename/Forget dialogs are side-mounted by Reactor, and +its feedback, resource tiles and Forget facts use `Theme.Ref` backgrounds. +Preview.17 includes [ContentDialog theme forwarding](https://github.com/microsoft/microsoft-ui-reactor/pull/1389), +[host-inherited theme resolution](https://github.com/microsoft/microsoft-ui-reactor/pull/1417), +[dialog retirement on host disposal](https://github.com/microsoft/microsoft-ui-reactor/pull/1444), +and [Border background precedence](https://github.com/microsoft/microsoft-ui-reactor/pull/1449) +with [record-background restoration](https://github.com/microsoft/microsoft-ui-reactor/pull/1450). +Stock preview.16 is not equivalent to the earlier patched preview.16 outputs. + +After integrating a new Windows App SDK, rerun +`SystemsPageProofTests.AddAndForgetDialogs_ExposeConsequencesAndSafeDefaults` +in Light and Dark, the stale-epoch Forget regression, and the full Systems +mounted suite. Also verify current-app dialog theme, retirement during navigation, +and feedback/resource fills after reconciliation and pooled reuse. Retain the +shared `PageHeroTests` theme and highlight-background ownership regressions. +Geometry-only or non-null-brush assertions do not prove the painted theme; +current visible proof remains required. Earlier package or SDK results are +historical, not acceptance for the newly integrated dependency graph. + Before release, also exercise repeated session switching while a bring-into-view request is pending, using 240 mixed-height messages. `GatewayFixtureUiTests.RepeatedSessionSwitchingKeepsNativeTimelineResponsive` diff --git a/run-app-local.ps1 b/run-app-local.ps1 index d33e2b7d1a..3ca1e32063 100644 --- a/run-app-local.ps1 +++ b/run-app-local.ps1 @@ -184,6 +184,7 @@ if ([string]::IsNullOrWhiteSpace($RuntimeIdentifier)) { Write-Host "Selected runtime: $RuntimeIdentifier" if (-not $NoBuild) { + & (Join-Path $repoRoot "scripts\Initialize-ReactorPackages.ps1") $dotnetArgs = @("build", $projectPath, "-c", $Configuration, "-r", $RuntimeIdentifier) if ($Dev) { $dotnetArgs += "-p:DevBuild=true" diff --git a/scripts/Build-StoreMsix.ps1 b/scripts/Build-StoreMsix.ps1 index 52a4b41535..af5fed4635 100644 --- a/scripts/Build-StoreMsix.ps1 +++ b/scripts/Build-StoreMsix.ps1 @@ -195,6 +195,7 @@ New-Item -Path $msixBuildDirectory -ItemType Directory -Force | Out-Null $platform = if ($Architecture -eq 'arm64') { 'ARM64' } else { 'x64' } try { + & (Join-Path $PSScriptRoot 'Initialize-ReactorPackages.ps1') $appxOutput = $msixBuildDirectory.TrimEnd('\') + '\' Write-Host "Building unsigned win-$Architecture MSIX with MSBuild." Invoke-CheckedCommand ` diff --git a/scripts/Initialize-ReactorPackages.ps1 b/scripts/Initialize-ReactorPackages.ps1 new file mode 100644 index 0000000000..19d7ddd290 --- /dev/null +++ b/scripts/Initialize-ReactorPackages.ps1 @@ -0,0 +1,56 @@ +[CmdletBinding()] +param( + [string]$DestinationDirectory = (Join-Path $PSScriptRoot '..\artifacts\reactor-packages') +) + +$ErrorActionPreference = 'Stop' + +function Save-VerifiedReactorPackage { + param([string]$Uri, [string]$Sha256, [string]$Destination) + + if (Test-Path -LiteralPath $Destination) { + if ((Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash -ne $Sha256) { + throw "Cached Reactor package digest mismatch: $Destination" + } + return + } + + $temporary = $Destination + '.' + [Guid]::NewGuid().ToString('N') + '.tmp' + try { + Invoke-WebRequest -Uri $Uri -OutFile $temporary -TimeoutSec 120 -UseBasicParsing + if ((Get-FileHash -LiteralPath $temporary -Algorithm SHA256).Hash -ne $Sha256) { + throw "Downloaded Reactor package digest mismatch: $Uri" + } + # A concurrent initializer may have completed the same verified download. + if (Test-Path -LiteralPath $Destination) { + if ((Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash -ne $Sha256) { + throw "Concurrent Reactor package digest mismatch: $Destination" + } + } else { + [IO.File]::Move($temporary, $Destination) + } + } finally { + if (Test-Path -LiteralPath $temporary) { + Remove-Item -LiteralPath $temporary -Force + } + } +} + +function Initialize-ReactorPackages { + param([string]$Directory) + + [IO.Directory]::CreateDirectory($Directory) | Out-Null + $release = 'https://github.com/microsoft/microsoft-ui-reactor/releases/download/v0.1.0-preview.17' + foreach ($package in @( + @{ Name = 'Microsoft.UI.Reactor.0.1.0-preview.17.nupkg'; Sha256 = '7A2BC9C416671F0F8A6B0E397FC6784A3DED7D605D7AD6AA52D14228BDE55A74' }, + @{ Name = 'Microsoft.UI.Reactor.Advanced.0.1.0-preview.17.nupkg'; Sha256 = '0DD50EB6F7D4F54EFF0C8A5372137BC0B6D0338615CFF5FA45103946B37715E3' } + )) { + Save-VerifiedReactorPackage -Uri "$release/$($package.Name)" -Sha256 $package.Sha256 ` + -Destination (Join-Path $Directory $package.Name) + } + Write-Host 'Official Reactor preview.17 packages verified.' +} + +if ($MyInvocation.InvocationName -ne '.') { + Initialize-ReactorPackages -Directory $DestinationDirectory +} diff --git a/scripts/Invoke-CiTest.ps1 b/scripts/Invoke-CiTest.ps1 index c6c59d1de5..264b5a8c96 100644 --- a/scripts/Invoke-CiTest.ps1 +++ b/scripts/Invoke-CiTest.ps1 @@ -11,6 +11,7 @@ param( [string]$Runtime, [string]$Filter, [ValidateRange(0, 3600)][int]$HangTimeoutSeconds = 0, + [switch]$CollectNativeFailureMetadata, [switch]$CaptureHostExit, [ValidateSet('Unchanged', 'Enabled', 'Disabled')][string]$AnimationPreference = 'Unchanged', [switch]$AllowUserAnimationPreferenceChange, @@ -144,6 +145,17 @@ if ($AnimationPreference -ne 'Unchanged' -or $isNativeUiProject) { if ($null -eq $script:testExitCode) { throw 'The test command did not return an exit code.' } if ($script:testExitCode -ne 0) { + if ($CollectNativeFailureMetadata) { + try { + . (Join-Path $PSScriptRoot "NativeTestFailureMetadata.ps1") + Write-CiNativeFailureMetadata -StartedUtc $invocation.startedUtc ` + -TestCommandExitCode $script:testExitCode ` + -OutputPath (Join-Path $ResultsDirectory "native-failure.json") + } catch { + Write-Warning ("Secondary native diagnostic failure ({0}); preserving test exit code {1}." -f + $_.Exception.GetType().Name, $script:testExitCode) + } + } exit $script:testExitCode } $result = Read-TestResultContract -Path $invocation.report -StartedUtc $invocation.startedUtc -ExitCode 0 -AllowSkipped:(-not $RequireAllTestsPassed) diff --git a/scripts/NativeTestFailureMetadata.ps1 b/scripts/NativeTestFailureMetadata.ps1 new file mode 100644 index 0000000000..7d2ecbb06f --- /dev/null +++ b/scripts/NativeTestFailureMetadata.ps1 @@ -0,0 +1,92 @@ +function ConvertTo-CiNativeFault { + param([Parameter(Mandatory)][string]$EventXml) + + $settings = [Xml.XmlReaderSettings]::new() + $settings.DtdProcessing = [Xml.DtdProcessing]::Prohibit + $settings.XmlResolver = $null + $text = [IO.StringReader]::new($EventXml) + $reader = [Xml.XmlReader]::Create($text, $settings) + try { + $document = [Xml.XmlDocument]::new() + $document.XmlResolver = $null + $document.Load($reader) + } finally { $reader.Dispose(); $text.Dispose() } + $values = @{} + foreach ($entry in $document.SelectNodes("//*[local-name()='EventData']/*[local-name()='Data']")) { + $values[$entry.GetAttribute('Name')] = $entry.InnerText + } + $application = [IO.Path]::GetFileName([string]$values['AppName']) + if ($application -notin @('dotnet.exe', 'testhost.exe', 'testhost.x86.exe', + 'dotnet-coverage.exe', 'OpenClaw.Tray.WinUI.exe')) { + return + } + $module = [IO.Path]::GetFileName([string]$values['ModuleName']) + if ($module -notmatch '\A[A-Za-z0-9_.-]{1,128}\.(?:dll|exe)\z') { $module = $null } + $code = [string]$values['ExceptionCode'] + if ($code -match '\A(?:0x)?([0-9A-Fa-f]{8})\z') { $code = '0x' + $Matches[1].ToUpperInvariant() } + else { $code = $null } + $processId = [string]$values['ProcessId'] + if ($processId -notmatch '\A(?:0x[0-9A-Fa-f]{1,8}|[0-9]{1,10})\z') { $processId = $null } + $moduleVersion = [string]$values['ModuleVersion'] + if ($moduleVersion -notmatch '\A[0-9]+(?:\.[0-9]+){1,3}\z') { $moduleVersion = $null } + [pscustomobject][ordered]@{ + application = $application + processId = $processId + module = $module + moduleVersion = $moduleVersion + exceptionCode = $code + } +} + +function Write-CiNativeFailureMetadata { + param( + [Parameter(Mandatory)][DateTime]$StartedUtc, + [Parameter(Mandatory)][int]$TestCommandExitCode, + [Parameter(Mandatory)][string]$OutputPath + ) + + $faults = @() + $faultStatus = 'collected' + $faultErrorType = $null + try { + $events = @(Get-WinEvent -FilterHashtable @{ + LogName = 'Application'; Id = 1000; StartTime = $StartedUtc.ToUniversalTime() + } -ErrorAction Stop) + $faults = @($events | ForEach-Object { ConvertTo-CiNativeFault -EventXml $_.ToXml() }) + if ($faults.Count -eq 0) { $faultStatus = 'no-matching-fault-events' } + } catch { + if ($_.FullyQualifiedErrorId -like 'NoMatchingEventsFound,*') { + $faultStatus = 'no-matching-fault-events' + } else { + $faultStatus = 'unavailable' + $faultErrorType = $_.Exception.GetType().Name + Write-Warning "Native fault metadata unavailable ($faultErrorType)." + } + } + $packages = @() + $runtimeStatus = 'collected' + try { + $packages = @(Get-AppxPackage -Name 'Microsoft.WindowsAppRuntime.*' -ErrorAction Stop | + Sort-Object Name, Version | ForEach-Object { + [pscustomobject]@{ name = $_.Name; version = [string]$_.Version; architecture = [string]$_.Architecture } + }) + if ($packages.Count -eq 0) { $runtimeStatus = 'no-runtime-packages-found' } + } catch { + $runtimeStatus = 'unavailable' + Write-Warning ("Windows App Runtime metadata unavailable ({0})." -f $_.Exception.GetType().Name) + } + # Never serialize event messages/XML, paths, command lines, environment values or dumps. + $summary = [pscustomobject][ordered]@{ + schemaVersion = 1 + startedUtc = $StartedUtc.ToUniversalTime().ToString('o') + testCommandExitCode = $TestCommandExitCode + faultCollection = $faultStatus + faultErrorType = $faultErrorType + faults = $faults + runtimeCollection = $runtimeStatus + runtimePackages = $packages + memoryDumpsCollected = $false + } + [IO.File]::WriteAllText($OutputPath, ($summary | ConvertTo-Json -Depth 6)) + Write-Host "Native failure metadata: $OutputPath (faults=$($faults.Count), collection=$faultStatus)." +} diff --git a/scripts/run-gateway-fixture.ps1 b/scripts/run-gateway-fixture.ps1 index 0e82631c68..3614b5aba6 100644 --- a/scripts/run-gateway-fixture.ps1 +++ b/scripts/run-gateway-fixture.ps1 @@ -10,19 +10,21 @@ param( [Parameter(Mandatory)] [string]$AppPath, [string]$ArtifactsDirectory, - [ValidateSet('multi-session-browse', 'chat-interactive', 'context-usage-snapshots')] + [ValidateSet('multi-session-browse', 'chat-interactive', 'context-usage-snapshots', 'systems-inventory', 'systems-empty')] [string]$Scenario = 'multi-session-browse', [ValidateSet('streaming', 'chat-deltas', 'tools', 'tool-error', 'agent-activity', 'question-choice', 'question-text', 'question-multi', 'approval', 'approval-deny-only', 'slow', 'send-error', 'stream-error', 'disconnect')] [string]$Interaction, [ValidateRange(0, 86400)] [int]$DurationSeconds = 0, [ValidateSet('Debug', 'Release')] - [string]$Configuration = 'Debug' + [string]$Configuration = 'Debug', + [ValidateSet('Light', 'Dark')] + [string]$Theme = 'Light' ) $ErrorActionPreference = 'Stop' $project = Join-Path $PSScriptRoot '..\tests\OpenClaw.GatewayFixtureHost\OpenClaw.GatewayFixtureHost.csproj' $resolvedApp = (Resolve-Path -LiteralPath $AppPath).Path -$hostArgs = @('--app', $resolvedApp, '--scenario', $Scenario) +$hostArgs = @('--app', $resolvedApp, '--scenario', $Scenario, '--theme', $Theme) if ($Interaction) { if ($Scenario -ne 'chat-interactive') { throw '-Interaction requires -Scenario chat-interactive.' } $hostArgs += @('--interaction', $Interaction) diff --git a/scripts/test-ci-native-failure-metadata.ps1 b/scripts/test-ci-native-failure-metadata.ps1 new file mode 100644 index 0000000000..a659667a0a --- /dev/null +++ b/scripts/test-ci-native-failure-metadata.ps1 @@ -0,0 +1,142 @@ +[CmdletBinding()] +param([string]$RepoRoot) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if (-not $RepoRoot) { $RepoRoot = Split-Path -Parent $PSScriptRoot } +. (Join-Path $RepoRoot 'scripts\NativeTestFailureMetadata.ps1') +# Keep child-process result paths below Windows PowerShell 5.1's directory limit. +$root = Join-Path ([IO.Path]::GetTempPath()) ('ci-native-metadata-' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $root | Out-Null +$marker = 'private-value-must-not-be-exported' +$eventXml = @" + + +dotnet.exe0x1234 +C:\$marker\Microsoft.UI.Xaml.dll +2.5.1.0c0000005 +C:\$marker\dotnet.exe +$marker$marker + +"@ +$checks = 0 +try { + $fault = ConvertTo-CiNativeFault $eventXml + if ($fault.application -ne 'dotnet.exe' -or $fault.processId -ne '0x1234' -or + $fault.module -ne 'Microsoft.UI.Xaml.dll' -or $fault.moduleVersion -ne '2.5.1.0' -or + $fault.exceptionCode -ne '0xC0000005') { throw 'Native fault metadata was not projected correctly.' } + if (($fault | ConvertTo-Json) -match $marker) { throw 'Private event fields or paths escaped.' } + $checks++ + if ($null -ne (ConvertTo-CiNativeFault ($eventXml.Replace('dotnet.exe', 'unrelated.exe')))) { + throw 'An unrelated application was admitted.' + } + $checks++ + $invalidXml = $eventXml.Replace('c0000005', $marker).Replace('2.5.1.0', $marker) + $invalidXml = $invalidXml.Replace('0x1234', $marker).Replace('Microsoft.UI.Xaml.dll', "$marker value.dll") + $invalid = ConvertTo-CiNativeFault $invalidXml + if ($null -ne $invalid.exceptionCode -or $null -ne $invalid.moduleVersion -or + $null -ne $invalid.processId -or $null -ne $invalid.module) { throw 'Malformed metadata escaped.' } + $checks++ + $rejected = $false + try { ConvertTo-CiNativeFault ']>&x;' } + catch { $rejected = $true } + if (-not $rejected) { throw 'External entities were not rejected.' } + $checks++ + + $script:eventXml = $eventXml + $script:queryMode = 'events' + function Get-WinEvent { + [CmdletBinding()]param([hashtable]$FilterHashtable) + if ($FilterHashtable.LogName -ne 'Application' -or $FilterHashtable.Id -ne 1000 -or + $FilterHashtable.StartTime -gt [datetime]::UtcNow) { throw 'Unexpected event query.' } + if ($script:queryMode -eq 'denied') { throw [UnauthorizedAccessException]::new($marker) } + if ($script:queryMode -eq 'empty') { + $PSCmdlet.ThrowTerminatingError([Management.Automation.ErrorRecord]::new( + [InvalidOperationException]::new($marker), 'NoMatchingEventsFound', + [Management.Automation.ErrorCategory]::ObjectNotFound, $null)) + } + $event = [pscustomobject]@{ Xml = $script:eventXml; Message = $marker } + $event | Add-Member -MemberType ScriptMethod -Name ToXml -Value { $this.Xml } -PassThru + } + function Get-AppxPackage { + [CmdletBinding()]param([string]$Name) + if ($Name -ne 'Microsoft.WindowsAppRuntime.*') { throw 'Unexpected package query.' } + [pscustomobject]@{ + Name = 'Microsoft.WindowsAppRuntime.2.5'; Version = '2.5.1.0'; Architecture = 'X64' + InstallLocation = $marker; Publisher = $marker; PackageFamilyName = $marker + } + } + foreach ($mode in @('events', 'empty', 'denied')) { + $script:queryMode = $mode + $path = Join-Path $root "$mode.json" + $output = @(Write-CiNativeFailureMetadata -StartedUtc ([datetime]::UtcNow.AddMinutes(-1)) ` + -TestCommandExitCode 73 -OutputPath $path 3>&1) + $json = Get-Content $path -Raw + $summary = $json | ConvertFrom-Json + $expected = switch ($mode) { 'events' { 'collected' }; 'empty' { 'no-matching-fault-events' }; 'denied' { 'unavailable' } } + if ($summary.faultCollection -ne $expected -or $summary.testCommandExitCode -ne 73 -or + $summary.memoryDumpsCollected -ne $false -or $summary.runtimePackages.Count -ne 1) { + throw "$mode lost its result or overclaimed diagnostics." + } + if ($json -match $marker -or ($output -join "`n") -match $marker) { throw "$mode leaked private data." } + $checks++ + } + + $harness = Join-Path $root 'runner.ps1' + Set-Content -LiteralPath $harness -Value @' +param([string]$Repo, [string]$Results, [switch]$BlockMetadataWrite) +$ErrorActionPreference = 'Stop' +$env:OPENCLAW_CI_COLLECT_COVERAGE = 'false' +$global:blockMetadataWrite = $BlockMetadataWrite +function global:dotnet { + if ($global:blockMetadataWrite) { + $resultsIndex = [Array]::IndexOf($args, '--results-directory') + if ($resultsIndex -lt 0) { throw 'The CI runner did not supply its results directory.' } + New-Item -ItemType Directory -Path (Join-Path $args[$resultsIndex + 1] 'native-failure.json') | Out-Null + } + $global:LASTEXITCODE = 73 +} +function global:Get-WinEvent { + [CmdletBinding()]param([hashtable]$FilterHashtable) + throw [UnauthorizedAccessException]::new('private-value-must-not-be-exported') +} +function global:Get-AppxPackage { + [CmdletBinding()]param([string]$Name) + throw [UnauthorizedAccessException]::new('private-value-must-not-be-exported') +} +& (Join-Path $Repo 'scripts\Invoke-CiTest.ps1') -Project 'unused.csproj' ` + -ResultsDirectory $Results -TrxFileName 'native.trx' -CollectNativeFailureMetadata +exit $LASTEXITCODE +'@ + $shell = (Get-Process -Id $PID).Path + foreach ($blockWrite in @($false, $true)) { + $resultsName = if ($blockWrite) { "runner-write-failure-$marker" } else { 'runner-results' } + $results = Join-Path $root $resultsName + $arguments = @('-NoProfile', '-File', $harness, '-Repo', $RepoRoot, '-Results', $results) + if ($blockWrite) { $arguments += '-BlockMetadataWrite' } + $savedPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $output = @(& $shell @arguments 2>&1) + $exitCode = $LASTEXITCODE + } finally { $ErrorActionPreference = $savedPreference } + if ($exitCode -ne 73 -or ($output -join "`n") -match $marker) { + $safeOutput = ($output -join "`n").Replace($marker, '[private fixture marker]') + throw "Secondary diagnostics changed the test exit code or leaked an error message (exit=$exitCode): $safeOutput" + } + $report = @(Get-ChildItem $results -Filter native-failure.json -Recurse) + if ($blockWrite) { + if ($report.Count -ne 1 -or -not $report[0].PSIsContainer -or + ($output -join "`n") -notmatch 'Secondary native diagnostic failure .*preserving test exit code 73') { + throw 'The blocked metadata write did not exercise the secondary diagnostic failure handler.' + } + } elseif ($report.Count -ne 1 -or $report[0].PSIsContainer -or + (Get-Content $report[0].FullName -Raw | ConvertFrom-Json).testCommandExitCode -ne 73) { + throw 'The opted-in CI runner did not preserve its failure receipt.' + } + $checks++ + } + Write-Host "Safe native failure metadata regressions passed: $checks cases." +} finally { + Remove-Item -LiteralPath $root -Recurse -Force +} +$global:LASTEXITCODE = 0 diff --git a/scripts/test-ci-workflow-contract.ps1 b/scripts/test-ci-workflow-contract.ps1 index 1cf7e2bf8f..5792a28441 100644 --- a/scripts/test-ci-workflow-contract.ps1 +++ b/scripts/test-ci-workflow-contract.ps1 @@ -665,6 +665,8 @@ Assert-Contains -Text $trayUiStep -Expected "timeout-minutes: 15" ` -Message "Tray UI tests must have an outer timeout rather than consuming the six-hour job limit." Assert-Contains -Text $trayUiStep -Expected "-HangTimeoutSeconds 300" ` -Message "Tray UI tests must collect the interrupted test sequence on a five-minute hang." +Assert-Contains -Text $trayUiStep -Expected "-CollectNativeFailureMetadata" ` + -Message "Tray UI failures must retain safe native fault metadata." $trayUiCrashWindow = Get-StepBlock -Text $uiJob -Name "Mark Tray UI crash metadata window" Assert-Contains -Text $trayUiCrashWindow -Expected "GITHUB_OUTPUT" ` -Message "Tray UI crash metadata must be bounded by an explicit start time." diff --git a/scripts/test-reactor-packages.ps1 b/scripts/test-reactor-packages.ps1 new file mode 100644 index 0000000000..64f7e5ec97 --- /dev/null +++ b/scripts/test-reactor-packages.ps1 @@ -0,0 +1,137 @@ +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'Initialize-ReactorPackages.ps1') +$directory = Join-Path ([IO.Path]::GetTempPath()) ('reactor-bootstrap-' + [Guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($directory) | Out-Null +$script:downloads = 0 +$script:downloadMode = 'valid' +function Invoke-WebRequest { + param($Uri, $OutFile, $TimeoutSec, [switch]$UseBasicParsing) + $script:downloads++ + if ($script:downloadMode -eq 'error') { throw 'Synthetic network failure.' } + [IO.File]::WriteAllText($OutFile, $(if ($script:downloadMode -eq 'valid') { 'official fixture' } else { 'wrong bytes' })) +} +$passed = 0 +try { + $fixture = Join-Path $directory 'fixture' + [IO.File]::WriteAllText($fixture, 'official fixture') + $hash = (Get-FileHash $fixture -Algorithm SHA256).Hash + $target = Join-Path $directory 'package.nupkg' + Save-VerifiedReactorPackage 'https://example.test/package' $hash $target + if ((Get-FileHash $target).Hash -ne $hash -or $downloads -ne 1) { throw 'Verified download was not published.' } + $passed++ + Save-VerifiedReactorPackage 'https://example.test/package' $hash $target + if ($downloads -ne 1) { throw 'Verified cached package downloaded again.' } + $passed++ + [IO.File]::WriteAllText($target, 'corrupt cache') + $failed = $false + try { Save-VerifiedReactorPackage 'https://example.test/package' $hash $target } + catch { $failed = $_.ToString().Contains('Cached Reactor package digest mismatch') } + if (!$failed -or $downloads -ne 1) { throw 'Corrupt cache was accepted or silently replaced.' } + $passed++ + foreach ($mode in 'invalid', 'error') { + $script:downloadMode = $mode + $target = Join-Path $directory "$mode.nupkg" + $failed = $false + try { Save-VerifiedReactorPackage 'https://example.test/package' $hash $target } + catch { $failed = $_.ToString().Contains($(if ($mode -eq 'invalid') { 'Downloaded Reactor package digest mismatch' } else { 'Synthetic network failure' })) } + if (!$failed -or (Test-Path $target) -or @(Get-ChildItem $directory -Filter '*.tmp').Count) { + throw 'Invalid/failed acquisition published bytes or retained its temporary file.' + } + $passed++ + } + [xml]$config = Get-Content (Join-Path $PSScriptRoot '..\NuGet.Config') -Raw + $source = @($config.configuration.packageSources.add | Where-Object key -eq 'reactor-release') + $mapping = @($config.configuration.packageSourceMapping.packageSource | Where-Object key -eq 'reactor-release') + if ($source.Count -ne 1 -or $source[0].value -ne 'artifacts/reactor-packages' -or + @($mapping.package.pattern).Count -ne 2 -or + 'Microsoft.UI.Reactor' -notin @($mapping.package.pattern) -or + 'Microsoft.UI.Reactor.Advanced' -notin @($mapping.package.pattern)) { + throw 'Portable release feed mapping is missing or broadened.' + } + $passed++ + $scriptDirectory = Join-Path $directory 'scripts' + [IO.Directory]::CreateDirectory($scriptDirectory) | Out-Null + [IO.File]::WriteAllText((Join-Path $scriptDirectory 'Initialize-ReactorPackages.ps1'), "throw 'Synthetic bootstrap caller failure.'") + $script:dotnetReached = $false + function Invoke-DotNetCaptured { param($Arguments) $script:dotnetReached = $true } + $repo = Split-Path $PSScriptRoot -Parent + $parseErrors = $null + $buildAst = [Management.Automation.Language.Parser]::ParseFile((Join-Path $repo 'build.ps1'), [ref]$null, [ref]$parseErrors) + if ($parseErrors) { throw $parseErrors } + $builder = $buildAst.Find({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Build-Project' + }, $true) + . ([scriptblock]::Create($builder.Extent.Text)) + $repoRoot = $directory + $Configuration = 'Debug' + $DevBuild = $false + $failed = $false + try { Build-Project 'WinUI' $fixture } + catch { $failed = $_.ToString().Contains('Synthetic bootstrap caller failure') } + if (!$failed -or $dotnetReached) { throw 'Normal build overwrote bootstrap failure with a later dotnet result.' } + $passed++ + $launcherAst = [Management.Automation.Language.Parser]::ParseFile( + (Join-Path $repo 'run-app-local.ps1'), [ref]$null, [ref]$parseErrors) + if ($parseErrors) { throw $parseErrors } + $localBuild = @($launcherAst.FindAll({ param($node) + $node -is [Management.Automation.Language.IfStatementAst] -and + $node.Clauses[0].Item1.Extent.Text -eq '-not $NoBuild' + }, $true)) + if ($localBuild.Count -ne 1) { throw 'Expected one local-launcher build boundary.' } + [IO.File]::WriteAllText((Join-Path $scriptDirectory 'Initialize-ReactorPackages.ps1'), + '$reactorCalls.Add("bootstrap"); if ($failBootstrap) { throw "Synthetic bootstrap caller failure." }') + function dotnet { $reactorCalls.Add('dotnet'); $global:LASTEXITCODE = 0 } + $projectPath = $fixture + $RuntimeIdentifier = 'win-arm64' + $Dev = $false + foreach ($case in @( + @{ NoBuild = $false; Failure = $false; Calls = 'bootstrap,dotnet'; Throws = $false }, + @{ NoBuild = $false; Failure = $true; Calls = 'bootstrap'; Throws = $true }, + @{ NoBuild = $true; Failure = $false; Calls = ''; Throws = $false }, + @{ NoBuild = $true; Failure = $true; Calls = ''; Throws = $false } + )) { + $NoBuild = $case.NoBuild + $failBootstrap = $case.Failure + $reactorCalls = [Collections.Generic.List[string]]::new() + $failed = $false + try { & ([scriptblock]::Create($localBuild[0].Extent.Text)) } + catch { + if (!$_.ToString().Contains('Synthetic bootstrap caller failure')) { throw } + $failed = $true + } + if ($failed -ne $case.Throws -or ($reactorCalls -join ',') -cne $case.Calls) { + throw "Local launcher bootstrap order/failure/NoBuild contract failed: NoBuild=$NoBuild Failure=$failBootstrap Calls=$reactorCalls" + } + $passed++ + } + [IO.File]::WriteAllText((Join-Path $scriptDirectory 'Initialize-ReactorPackages.ps1'), + "throw 'Synthetic bootstrap caller failure.'") + foreach ($workflow in 'ci.yml', 'codeql.yml', 'release-candidate-e2e.yml') { + $text = Get-Content (Join-Path $repo ".github\workflows\$workflow") -Raw + $blocks = [regex]::Matches($text, '(?m)^(? +)run: \|\r?\n(?(?:\k .*(?:\r?\n|$))+)' ) + foreach ($block in $blocks) { + $body = $block.Groups['body'].Value + if ($body -notmatch 'Initialize-ReactorPackages.ps1') { continue } + if ($body -notmatch '\$ErrorActionPreference\s*=\s*''Stop''') { throw "Nonterminating bootstrap caller: $workflow" } + $initializer = ($body -split '\r?\n' | Where-Object { $_ -match 'Initialize-ReactorPackages.ps1' } | Select-Object -First 1).Trim() + $priorLocation = Get-Location + $previousWorkspace = $env:GITHUB_WORKSPACE + try { + Set-Location $directory + $env:GITHUB_WORKSPACE = $directory + $script:dotnetReached = $false + $failed = $false + $invocation = [scriptblock]::Create("`$ErrorActionPreference='Stop'`n$initializer`n`$script:dotnetReached=`$true") + try { & $invocation } catch { $failed = $_.ToString().Contains('Synthetic bootstrap caller failure') } + if (!$failed -or $dotnetReached) { throw "CI continued after bootstrap failure: $workflow" } + } finally { + Set-Location $priorLocation + $env:GITHUB_WORKSPACE = $previousWorkspace + } + $passed++ + } + } +} finally { + Remove-Item -LiteralPath $directory -Recurse -Force +} +Write-Host "Reactor package bootstrap: $passed controls passed." diff --git a/src/OpenClaw.Connection/OperatorScopeHelper.cs b/src/OpenClaw.Connection/OperatorScopeHelper.cs index 8fe2dda323..6520fcb74b 100644 --- a/src/OpenClaw.Connection/OperatorScopeHelper.cs +++ b/src/OpenClaw.Connection/OperatorScopeHelper.cs @@ -22,6 +22,21 @@ public static bool CanWriteConfig(IReadOnlyList grantedScopes) => public static bool HasAdminScope(IReadOnlyList grantedScopes) => HasScope(grantedScopes, AdminScope); + /// + /// Mirrors the Gateway's operatorScopeSatisfied: an exact grant, admin, or write for + /// read-level scopes. Matching is ordinal, as on the Gateway. + /// + public static bool Satisfies(IReadOnlyList grantedScopes, string requestedScope) + { + var granted = grantedScopes.Select(scope => scope.Trim()).ToArray(); + return granted.Contains(requestedScope, StringComparer.Ordinal) || + granted.Contains(AdminScope, StringComparer.Ordinal) || + (requestedScope is ReadScope or "operator.talk" or "operator.sessions.read" or "operator.sessions.write" && + granted.Contains(WriteScope, StringComparer.Ordinal)) || + (requestedScope == "operator.sessions.read" && + (granted.Contains(ReadScope, StringComparer.Ordinal) || granted.Contains("operator.sessions.write", StringComparer.Ordinal))); + } + private static bool HasScope(IReadOnlyList grantedScopes, string scope) => grantedScopes.Any(s => s.Equals(scope, StringComparison.OrdinalIgnoreCase)); } diff --git a/src/OpenClaw.Shared/IOperatorGatewayClient.cs b/src/OpenClaw.Shared/IOperatorGatewayClient.cs index 513fcc3875..e4ccf554fc 100644 --- a/src/OpenClaw.Shared/IOperatorGatewayClient.cs +++ b/src/OpenClaw.Shared/IOperatorGatewayClient.cs @@ -42,6 +42,10 @@ public interface IOperatorGatewayClient event EventHandler? PresenceUpdated; /// The authenticated profile changed; consumers should reload users.self. event EventHandler? SelfProfileChanged { add { } remove { } } + /// Machine inventory may have changed (presence, pairing, runner inventory or config). + event EventHandler? SystemsInventoryChanged { add { } remove { } } + /// A node published new host statistics. + event EventHandler? NodeHostStatsReceived { add { } remove { } } event EventHandler? AgentsListUpdated; event EventHandler? AgentsListUnsupportedChanged { add { } remove { } } event EventHandler? AgentFilesListUpdated; diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index 28b4217634..a37470595d 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -260,6 +260,8 @@ public SessionInfo Clone() public string? OriginLabel { get; set; } public SessionWorktreeInfo? Worktree { get; set; } public string? ExecNode { get; set; } + /// Gateway execution placement (worker/device). Null for local sessions or older Gateways. + public OpenClaw.Shared.Systems.SessionPlacementInfo? Placement { get; set; } public string? ParentSessionKey { get; set; } public int? SpawnDepth { get; set; } public string? SessionId { get; set; } @@ -537,6 +539,9 @@ public class GatewayNodeInfo public string? RemoteIp { get; set; } public string? PathEnv { get; set; } + /// Latest host statistics the node reported (node.list hostStats). + public OpenClaw.Shared.Systems.NodeHostStats? HostStats { get; set; } + // Timestamps and state public DateTime? ConnectedAt { get; set; } public DateTime? ApprovedAt { get; set; } diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 54936675d8..b5cd163cd4 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -440,6 +440,10 @@ protected override void OnError(Exception ex) public event EventHandler? CronRunsUpdated; public event EventHandler? ConfigUpdated; public event EventHandler? ConfigurationChanged; + /// Machine inventory may have changed (presence, pairing, runner inventory or config). Argument is the event name. + public event EventHandler? SystemsInventoryChanged; + /// A node published new host statistics (node.hostStats). + public event EventHandler? NodeHostStatsReceived; public event EventHandler? ConfigSchemaUpdated; /// @@ -3257,7 +3261,7 @@ private static PairingConnectErrorDetails TryGetPairingConnectErrorDetails(JsonE private readonly record struct PairingConnectErrorDetails(bool IsPairingRequired, string? RequestId); - private static bool IsUnknownMethodError(string errorMessage) + internal static bool IsUnknownMethodError(string errorMessage) { return errorMessage.Contains("unknown method", StringComparison.OrdinalIgnoreCase); } @@ -3630,6 +3634,20 @@ private void HandleEventForConnection( // until the next poll. _ = RequestNodePairListAsync(); _ = RequestNodesAsync(); + if (eventType == "node.pair.resolved") + SystemsInventoryChanged?.Invoke(this, "node.pair.resolved"); + break; + case "node.runnerInventory.changed": + SystemsInventoryChanged?.Invoke(this, "node.runnerInventory.changed"); + break; + case "node.hostStats": + if (root.TryGetProperty("payload", out var statsPayload) && + Systems.SystemsParser.String(statsPayload, "nodeId") is { } statsNodeId && + statsPayload.TryGetProperty("hostStats", out var statsValue) && + Systems.SystemsParser.ParseNodeHostStats(statsValue) is { } nodeStats) + { + NodeHostStatsReceived?.Invoke(this, new Systems.NodeHostStatsEvent(statsNodeId, nodeStats)); + } break; case "device.pair.requested": case "device.pair.resolved": @@ -3640,6 +3658,7 @@ private void HandleEventForConnection( // Presence snapshot broadcast when clients connect/disconnect if (root.TryGetProperty("payload", out var presPayload)) TryParsePresenceFromBroadcast(presPayload); + SystemsInventoryChanged?.Invoke(this, "presence"); break; case "sessions.changed": if (root.TryGetProperty("payload", out var sessionChange) @@ -3664,6 +3683,7 @@ private void HandleEventForConnection( case "config.changed": ConfigurationChanged?.Invoke(this, EventArgs.Empty); ModelCatalogInvalidated?.Invoke(this, EventArgs.Empty); + SystemsInventoryChanged?.Invoke(this, "config.changed"); break; case "chat.metadata.changed": ModelCatalogInvalidated?.Invoke(this, EventArgs.Empty); @@ -4994,6 +5014,10 @@ private void PopulateSessionFromObject(SessionInfo session, JsonElement item, bo else if (authoritativeSessionList) session.Archived = false; if (item.TryGetProperty("execNode", out _)) session.ExecNode = GetString(item, "execNode"); + if (item.TryGetProperty("placement", out _)) + session.Placement = Systems.SystemsParser.ParseSessionPlacement(item); + else if (authoritativeSessionList) + session.Placement = null; if (item.TryGetProperty("parentSessionKey", out _)) session.ParentSessionKey = GetString(item, "parentSessionKey"); if (item.TryGetProperty("spawnDepth", out var spawnDepth)) @@ -5064,6 +5088,19 @@ private void PopulateSessionFromObject(SessionInfo session, JsonElement item, bo private void ParseNodeList(JsonElement nodesPayload) { try + { + if (ParseNodeListPayload(nodesPayload) is { } ordered) + NodesUpdated?.Invoke(this, ordered); + } + catch (Exception ex) + { + _logger.Warn($"Failed to parse node.list: {ex.Message}"); + } + } + + /// Parses a node.list payload; null when it has no node array. + internal static GatewayNodeInfo[]? ParseNodeListPayload(JsonElement nodesPayload) + { { JsonElement nodes = nodesPayload; if (nodesPayload.ValueKind == JsonValueKind.Object) @@ -5075,7 +5112,7 @@ private void ParseNodeList(JsonElement nodesPayload) } if (nodes.ValueKind != JsonValueKind.Array) - return; + return null; var buffer = new GatewayNodeInfo[nodes.GetArrayLength()]; var count = 0; @@ -5180,6 +5217,9 @@ private void ParseNodeList(JsonElement nodesPayload) ModelIdentifier = GetString(nodeElement, "modelIdentifier"), RemoteIp = GetString(nodeElement, "remoteIp"), PathEnv = GetString(nodeElement, "pathEnv"), + HostStats = nodeElement.TryGetProperty("hostStats", out var hostStats) + ? Systems.SystemsParser.ParseNodeHostStats(hostStats) + : null, IsPaired = paired ?? false, IsOnline = online ?? connected ?? status is "ok" or "online" or "connected" or "ready" or "active" }; @@ -5195,11 +5235,7 @@ private void ParseNodeList(JsonElement nodesPayload) return StringComparer.OrdinalIgnoreCase.Compare(a.DisplayName, b.DisplayName); }); - NodesUpdated?.Invoke(this, ordered); - } - catch (Exception ex) - { - _logger.Warn($"Failed to parse node.list: {ex.Message}"); + return ordered; } } diff --git a/src/OpenClaw.Shared/Systems/DesktopPresentation.cs b/src/OpenClaw.Shared/Systems/DesktopPresentation.cs new file mode 100644 index 0000000000..bf7555fbb1 --- /dev/null +++ b/src/OpenClaw.Shared/Systems/DesktopPresentation.cs @@ -0,0 +1,37 @@ +// Projects reported desktop availability without provisioning. Usage: DesktopPresentation.Resolve(row, connected). +namespace OpenClaw.Shared.Systems; + +/// +/// What the desktop area of a machine's details shows. The native app reports availability only; +/// viewing, control and Gateway host desktop setup happen in the web interface. +/// +public enum DesktopSectionKind +{ + /// A desktop is advertised and the machine is online. + Available, + /// A desktop was advertised, but the machine or the Gateway is offline. + Offline, + /// A desktop is advertised, but the machine is starting, stopping or in error. + NotReady, + /// The online Gateway host has no desktop; it can be set up in the web interface. + HostNotSetUp, +} + +public static class DesktopPresentation +{ + /// Null when the machine has no desktop to report, so the section is not shown. + public static DesktopSectionKind? Resolve(SystemRow row, bool connected) + { + ArgumentNullException.ThrowIfNull(row); + var advertised = row.Environment?.Desktop == true || row.Environment?.Worker?.Desktop == true; + if (!advertised) + { + return row.Environment?.Desktop == false && row.Id == SystemsProjection.GatewayId && connected && row.Status == SystemStatus.Online + ? DesktopSectionKind.HostNotSetUp + : null; + } + if (!connected || row.Status == SystemStatus.Offline) + return DesktopSectionKind.Offline; + return row.Status == SystemStatus.Online ? DesktopSectionKind.Available : DesktopSectionKind.NotReady; + } +} diff --git a/src/OpenClaw.Shared/Systems/HostStatsSampler.cs b/src/OpenClaw.Shared/Systems/HostStatsSampler.cs new file mode 100644 index 0000000000..0073a6db7f --- /dev/null +++ b/src/OpenClaw.Shared/Systems/HostStatsSampler.cs @@ -0,0 +1,90 @@ +// Samples node telemetry for publication to the Gateway. Usage: HostStatsSampler.Sample() before sending its node event. +using System.Runtime.InteropServices; +using System.Text.Json.Nodes; + +namespace OpenClaw.Shared.Systems; + +/// +/// Samples this machine for the Gateway's node.host.stats node event. +/// The Gateway schema is closed, so only its fields are sent. Windows has no +/// load average, so that optional field is omitted. +/// +public static class HostStatsSampler +{ + public const string EventName = "node.host.stats"; + /// Matches the Gateway's node publishing cadence. + public static readonly TimeSpan Interval = TimeSpan.FromSeconds( + int.TryParse(Environment.GetEnvironmentVariable("OPENCLAW_NODE_HOST_STATS_INTERVAL_SECONDS"), out var seconds) && seconds >= 10 + ? seconds + : 60); + + public static JsonObject? Sample() + { + if (!OperatingSystem.IsWindows() || !TryReadMemory(out var total, out var free)) + return null; + var payload = new JsonObject + { + ["cpuCount"] = Math.Clamp(Environment.ProcessorCount, 1, 4096), + ["memoryTotalBytes"] = total, + ["memoryFreeBytes"] = Math.Min(free, total), + }; + if (TryReadSystemDisk(out var diskTotal, out var diskFree)) + { + payload["diskTotalBytes"] = diskTotal; + payload["diskAvailableBytes"] = Math.Min(diskFree, diskTotal); + } + return payload; + } + + private static bool TryReadSystemDisk(out long total, out long available) + { + total = available = 0; + try + { + var root = Path.GetPathRoot(Environment.SystemDirectory); + if (string.IsNullOrEmpty(root)) + return false; + var drive = new DriveInfo(root); + if (!drive.IsReady) + return false; + total = drive.TotalSize; + available = drive.AvailableFreeSpace; + return total > 0; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + return false; + } + } + + private static bool TryReadMemory(out long total, out long free) + { + var status = new MemoryStatusEx { Length = (uint)Marshal.SizeOf() }; + if (!GlobalMemoryStatusEx(ref status) || status.TotalPhys == 0) + { + total = free = 0; + return false; + } + total = (long)Math.Min(status.TotalPhys, long.MaxValue); + free = (long)Math.Min(status.AvailPhys, long.MaxValue); + return true; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MemoryStatusEx + { + public uint Length; + public uint MemoryLoad; + public ulong TotalPhys; + public ulong AvailPhys; + public ulong TotalPageFile; + public ulong AvailPageFile; + public ulong TotalVirtual; + public ulong AvailVirtual; + public ulong AvailExtendedVirtual; + } + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GlobalMemoryStatusEx(ref MemoryStatusEx buffer); +} diff --git a/src/OpenClaw.Shared/Systems/SystemsFreshness.cs b/src/OpenClaw.Shared/Systems/SystemsFreshness.cs new file mode 100644 index 0000000000..58190ade03 --- /dev/null +++ b/src/OpenClaw.Shared/Systems/SystemsFreshness.cs @@ -0,0 +1,23 @@ +// Classifies reported measurement freshness. Usage: SystemsFreshness.IsLastKnown(row, connected, failed, now). +namespace OpenClaw.Shared.Systems; + +/// Decides whether shown measurements are live or a last-known report. +public static class SystemsFreshness +{ + public static readonly TimeSpan GatewayStaleAfter = TimeSpan.FromSeconds(30); + // Nodes publish once a minute; allow one missed report before marking their data stale. + public static readonly TimeSpan NodeStaleAfter = TimeSpan.FromSeconds(120); + + public static TimeSpan StaleAfter(string id) => + id == SystemsProjection.GatewayId ? GatewayStaleAfter : NodeStaleAfter; + + /// + /// True when the shown measurements are a last-known report rather than live data: + /// disconnected, offline, a failed refresh, or an old sample. + /// + public static bool IsLastKnown(SystemRow row, bool connected, bool refreshFailed, DateTimeOffset now) => + !connected || + row.Status != SystemStatus.Online || + refreshFailed || + (row.MeasuredAt is { } at && now - at > StaleAfter(row.Id)); +} diff --git a/src/OpenClaw.Shared/Systems/SystemsGateway.cs b/src/OpenClaw.Shared/Systems/SystemsGateway.cs new file mode 100644 index 0000000000..c7d68e1f46 --- /dev/null +++ b/src/OpenClaw.Shared/Systems/SystemsGateway.cs @@ -0,0 +1,108 @@ +// Read-only Systems Gateway transport. Usage: await SystemsGatewayClient.For(client).LoadInventoryAsync(token). +using System.Text.Json; + +namespace OpenClaw.Shared.Systems; + +public sealed record NodeHostStatsEvent(string NodeId, NodeHostStats Stats); + +/// Read-only Gateway requests behind the Systems page. Node management uses the operator client. +public interface ISystemsGateway +{ + /// + /// Reads environments, nodes and Gateway host info together. An inventory failure throws; + /// node and host-info failures are reported as partial errors. + /// + Task LoadInventoryAsync(CancellationToken cancellationToken); + Task<(GatewaySystemInfo Info, DateTimeOffset SampledAt)?> ReadSystemInfoAsync(CancellationToken cancellationToken); + Task> ReadNodesAsync(CancellationToken cancellationToken); +} + +/// Request-delegate implementation so tests can drive it without a socket. +public sealed class SystemsGatewayClient : ISystemsGateway +{ + public const int RequestTimeoutMs = 15_000; + private readonly Func> _request; + private readonly Func _clock; + + public SystemsGatewayClient(Func> request, Func? clock = null) + { + _request = request ?? throw new ArgumentNullException(nameof(request)); + _clock = clock ?? (() => DateTimeOffset.UtcNow); + } + + public static SystemsGatewayClient For(IOperatorGatewayClient client) => + new((method, parameters, timeoutMs) => client.SendWizardRequestAsync(method, parameters, timeoutMs)); + + public async Task LoadInventoryAsync(CancellationToken cancellationToken) + { + var environmentsTask = RequestOptionalAsync("environments.list", new { }, cancellationToken); + var nodesTask = ReadNodesAsync(cancellationToken); + var infoTask = ReadSystemInfoAsync(cancellationToken); + try + { + await Task.WhenAll(environmentsTask, nodesTask, infoTask).ConfigureAwait(false); + } + catch + { + // Each task is inspected below; the inventory read alone decides whether the refresh fails. + } + + var environmentsPayload = await environmentsTask.ConfigureAwait(false); + var environments = environmentsPayload is { } payload ? SystemsParser.ParseEnvironments(payload) : null; + cancellationToken.ThrowIfCancellationRequested(); + + return new SystemsInventorySnapshot + { + Environments = environments, + Nodes = nodesTask.IsCompletedSuccessfully ? nodesTask.Result : Array.Empty(), + SystemInfo = infoTask.IsCompletedSuccessfully ? infoTask.Result?.Info : null, + SystemInfoSampledAt = infoTask.IsCompletedSuccessfully ? infoTask.Result?.SampledAt : null, + Errors = new SystemsPartialErrors( + Nodes: ErrorText(nodesTask), + SystemInfo: ErrorText(infoTask)), + }; + } + + public async Task<(GatewaySystemInfo Info, DateTimeOffset SampledAt)?> ReadSystemInfoAsync(CancellationToken cancellationToken) + { + var payload = await RequestOptionalAsync("system.info", new { }, cancellationToken).ConfigureAwait(false); + if (payload is not { } value) + return null; + var info = SystemsParser.ParseSystemInfo(value) + ?? throw new InvalidOperationException("The Gateway returned invalid system information."); + return (info, _clock()); + } + + public async Task> ReadNodesAsync(CancellationToken cancellationToken) + { + var payload = await RequestOptionalAsync("node.list", new { }, cancellationToken).ConfigureAwait(false); + if (payload is not { } value) + return Array.Empty(); + return OpenClawGatewayClient.ParseNodeListPayload(value) + ?? throw new InvalidOperationException("The Gateway returned an invalid node list."); + } + + private async Task RequestAsync(string method, object parameters, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var result = await _request(method, parameters, RequestTimeoutMs).WaitAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return result; + } + + /// Unknown methods mean an older Gateway, not a failure. + private async Task RequestOptionalAsync(string method, object parameters, CancellationToken cancellationToken) + { + try + { + return await RequestAsync(method, parameters, cancellationToken).ConfigureAwait(false); + } + catch (InvalidOperationException ex) when (OpenClawGatewayClient.IsUnknownMethodError(ex.Message)) + { + return null; + } + } + + private static string? ErrorText(Task task) => + task.IsFaulted ? task.Exception?.InnerException?.Message ?? "Request failed." : null; +} diff --git a/src/OpenClaw.Shared/Systems/SystemsMeters.cs b/src/OpenClaw.Shared/Systems/SystemsMeters.cs new file mode 100644 index 0000000000..d8d2d29e96 --- /dev/null +++ b/src/OpenClaw.Shared/Systems/SystemsMeters.cs @@ -0,0 +1,23 @@ +// Projects reported resource values into meters. Usage: SystemsMeters.MemoryUsedPercent(measurements). +namespace OpenClaw.Shared.Systems; + +/// Usage meters shown under each statistic, as percentages from 0 to 100. +public static class SystemsMeters +{ + public static double? MemoryUsedPercent(HostMeasurements stats) => + Percent(stats.MemoryUsedBytes, stats.MemoryTotalBytes); + + /// One-minute load as a share of the logical cores; a saturated machine reads 100. + public static double? LoadPercent(HostMeasurements stats) => + stats.Load1 is { } load && stats.CpuCount > 0 ? Percent(load, stats.CpuCount) : null; + + public static double? DiskUsedPercent(long availableBytes, long? totalBytes) => + totalBytes is { } total ? Percent(total - availableBytes, total) : null; + + public static double? Percent(double part, double whole) + { + if (!double.IsFinite(part) || !double.IsFinite(whole) || whole <= 0) + return null; + return Math.Clamp(part / whole * 100, 0, 100); + } +} diff --git a/src/OpenClaw.Shared/Systems/SystemsModels.cs b/src/OpenClaw.Shared/Systems/SystemsModels.cs new file mode 100644 index 0000000000..7d4591b077 --- /dev/null +++ b/src/OpenClaw.Shared/Systems/SystemsModels.cs @@ -0,0 +1,144 @@ +// Gateway-reported Systems records. Usage: SystemsParser.ParseEnvironments(payload) yields SystemEnvironment records for SystemsProjection.Project. +namespace OpenClaw.Shared.Systems; + +/// Gateway runtime state for one environment (environments.list status). +public enum SystemStatus +{ + Online, + Offline, + Starting, + Stopping, + Error, + Unknown, +} + +/// Inventory group. Clients are presence-only connections (apps, browsers) without an environment. +public enum SystemKind +{ + Host, + Worker, + Node, + Client, +} + +public enum DesktopLockState +{ + Locked, + Unlocked, + Unknown, +} + +public sealed record RuntimeTargetIssue(string Code, string? Message); + +public sealed record WorkerMetadata( + string ProviderId, + string? ProfileId, + string State, + string? TunnelStatus, + string? Error, + bool? Desktop); + +/// One machine as reported by environments.list. +public sealed record SystemEnvironment +{ + public required string Id { get; init; } + public required string Type { get; init; } + public string? Label { get; init; } + public SystemStatus Status { get; init; } + public string? Platform { get; init; } + public DateTimeOffset? LastConnectedAt { get; init; } + public DateTimeOffset? LastDisconnectedAt { get; init; } + public DateTimeOffset? LastSeenAt { get; init; } + public string? LastSeenReason { get; init; } + public IReadOnlyList Capabilities { get; init; } = Array.Empty(); + public IReadOnlyList InvocableCommands { get; init; } = Array.Empty(); + public bool? Desktop { get; init; } + public DesktopLockState? DesktopAvailability { get; init; } + public IReadOnlyList Issues { get; init; } = Array.Empty(); + public WorkerMetadata? Worker { get; init; } + + /// Paired-node id for node:<id> environments; null otherwise. + public string? NodeId => Id.StartsWith(SystemsProjection.NodeIdPrefix, StringComparison.Ordinal) + ? Id[SystemsProjection.NodeIdPrefix.Length..] + : null; +} + +public sealed record DiskStats(string Path, long TotalBytes, long AvailableBytes); + +/// Resource measurements shared by system.info and node hostStats. +public sealed record HostMeasurements +{ + public int CpuCount { get; init; } + public double? Load1 { get; init; } + public long MemoryTotalBytes { get; init; } + public long MemoryFreeBytes { get; init; } + public long? DiskTotalBytes { get; init; } + public long? DiskAvailableBytes { get; init; } + public IReadOnlyList? Disks { get; init; } + + public long MemoryUsedBytes => Math.Max(0, MemoryTotalBytes - MemoryFreeBytes); +} + +/// Node-reported host statistics and the Gateway receipt time. +public sealed record NodeHostStats(HostMeasurements Measurements, DateTimeOffset UpdatedAt); + +/// Gateway host identity and resource snapshot (system.info). +public sealed record GatewaySystemInfo +{ + public string MachineName { get; init; } = ""; + public string Hostname { get; init; } = ""; + public string Platform { get; init; } = ""; + public string Release { get; init; } = ""; + public string Arch { get; init; } = ""; + public string OsLabel { get; init; } = ""; + public string? LanAddress { get; init; } + public int? Port { get; init; } + public string NodeVersion { get; init; } = ""; + public long UptimeMs { get; init; } + public string? CpuModel { get; init; } + public required HostMeasurements Measurements { get; init; } +} + +public enum SessionPlacementState +{ + Local, + Requested, + Provisioning, + Syncing, + Starting, + Active, + Draining, + Reconciling, + Reclaimed, + Failed, + Unknown, +} + +/// Session execution placement projected by sessions.list. +public sealed record SessionPlacementInfo( + SessionPlacementState State, + string? EnvironmentId, + string? RunnerDeviceId); + +/// Minimal session identity used to relate sessions to machines. +public sealed record SystemsSessionRef( + string Key, + string Title, + SessionPlacementInfo? Placement, + string? ExecNode) +{ + public string? AgentId { get; init; } + public DateTimeOffset? UpdatedAt { get; init; } +} + +/// Recorded bindings, not proof that a model turn ran on the machine. +public enum SessionRelationKind +{ + Placement, + RetainedPlacement, + Runner, + ExecBinding, + Gateway, +} + +public sealed record SessionRelation(SessionRelationKind Kind, SystemsSessionRef Session); diff --git a/src/OpenClaw.Shared/Systems/SystemsParser.cs b/src/OpenClaw.Shared/Systems/SystemsParser.cs new file mode 100644 index 0000000000..fdfd3f77a1 --- /dev/null +++ b/src/OpenClaw.Shared/Systems/SystemsParser.cs @@ -0,0 +1,233 @@ +// Validates reported Systems payloads. Usage: SystemsParser.ParseEnvironments(response). +using System.Text.Json; + +namespace OpenClaw.Shared.Systems; + +/// +/// Tolerant readers for the Systems Gateway payloads. Malformed entries are +/// skipped so one bad row cannot hide the rest of the inventory; a missing +/// top-level collection is a protocol error and throws. +/// +public static class SystemsParser +{ + public static IReadOnlyList ParseEnvironments(JsonElement payload) + { + var items = RequiredArray(payload, "environments"); + var result = new List(items.GetArrayLength()); + foreach (var item in items.EnumerateArray()) + { + if (ParseEnvironment(item) is { } environment) + result.Add(environment); + } + return result; + } + + public static SystemEnvironment? ParseEnvironment(JsonElement item) + { + if (item.ValueKind != JsonValueKind.Object || + String(item, "id") is not { } id || + String(item, "type") is not { } type) + { + return null; + } + + return new SystemEnvironment + { + Id = id, + Type = type, + Label = String(item, "label"), + Status = ParseStatus(String(item, "status")), + Platform = String(item, "platform"), + LastConnectedAt = Time(item, "lastConnectedAtMs"), + LastDisconnectedAt = Time(item, "lastDisconnectedAtMs"), + LastSeenAt = Time(item, "lastSeenAtMs"), + LastSeenReason = String(item, "lastSeenReason"), + Capabilities = Strings(item, "capabilities"), + InvocableCommands = Strings(item, "invocableCommands"), + Desktop = Bool(item, "desktop"), + DesktopAvailability = Object(item, "desktopAvailability") is { } desktop + ? String(desktop, "state") switch + { + "locked" => DesktopLockState.Locked, + "unlocked" => DesktopLockState.Unlocked, + _ => DesktopLockState.Unknown, + } + : null, + Issues = Array(item, "issues")?.EnumerateArray() + .Where(issue => issue.ValueKind == JsonValueKind.Object && String(issue, "code") is not null) + .Select(issue => new RuntimeTargetIssue(String(issue, "code")!, String(issue, "message"))) + .ToArray() ?? System.Array.Empty(), + Worker = Object(item, "worker") is { } worker && String(worker, "providerId") is { } provider + ? new WorkerMetadata( + provider, + String(worker, "profileId"), + String(worker, "state") ?? "unknown", + String(worker, "tunnelStatus"), + String(worker, "error"), + Bool(worker, "desktop")) + : null, + }; + } + + public static SystemStatus ParseStatus(string? status) => status switch + { + "available" => SystemStatus.Online, + "unavailable" => SystemStatus.Offline, + "starting" => SystemStatus.Starting, + "stopping" => SystemStatus.Stopping, + "error" => SystemStatus.Error, + _ => SystemStatus.Unknown, + }; + + public static GatewaySystemInfo? ParseSystemInfo(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object || ParseMeasurements(payload) is not { } measurements) + return null; + return new GatewaySystemInfo + { + MachineName = String(payload, "machineName") ?? "", + Hostname = String(payload, "hostname") ?? "", + Platform = String(payload, "platform") ?? "", + Release = String(payload, "release") ?? "", + Arch = String(payload, "arch") ?? "", + OsLabel = String(payload, "osLabel") ?? "", + LanAddress = String(payload, "lanAddress"), + Port = Int(payload, "port"), + NodeVersion = String(payload, "nodeVersion") ?? "", + UptimeMs = Long(payload, "uptimeMs") ?? 0, + CpuModel = String(payload, "cpuModel"), + Measurements = measurements, + }; + } + + /// Reads a hostStats object from node.list or a node.hostStats event. + public static NodeHostStats? ParseNodeHostStats(JsonElement value) + { + if (value.ValueKind != JsonValueKind.Object || + Time(value, "updatedAtMs") is not { } updatedAt || + ParseMeasurements(value) is not { } measurements) + { + return null; + } + return new NodeHostStats(measurements, updatedAt); + } + + public static HostMeasurements? ParseMeasurements(JsonElement value) + { + if (value.ValueKind != JsonValueKind.Object || + Int(value, "cpuCount") is not { } cpuCount || + Long(value, "memoryTotalBytes") is not { } memoryTotal || + Long(value, "memoryFreeBytes") is not { } memoryFree || + cpuCount <= 0 || memoryTotal <= 0 || memoryFree < 0 || memoryFree > memoryTotal) + { + return null; + } + + // loadAverage is [1, 5, 15] minutes; only the one-minute value is shown. + double? load1 = null; + if (Array(value, "loadAverage") is { } load && load.GetArrayLength() == 3 && + load[0] is { ValueKind: JsonValueKind.Number } first && first.TryGetDouble(out var one) && double.IsFinite(one) && one >= 0) + { + load1 = one; + } + + IReadOnlyList? disks = null; + if (Array(value, "disks") is { } diskArray) + { + disks = diskArray.EnumerateArray() + .Where(disk => disk.ValueKind == JsonValueKind.Object) + .Select(disk => String(disk, "path") is { } path && Long(disk, "totalBytes") is { } total && Long(disk, "availableBytes") is { } available && + total > 0 && available >= 0 && available <= total + ? new DiskStats(path, total, available) + : null) + .OfType() + .ToArray(); + } + + var diskTotal = Long(value, "diskTotalBytes"); + var diskAvailable = Long(value, "diskAvailableBytes"); + var validDisk = diskTotal > 0 && diskAvailable >= 0 && diskAvailable <= diskTotal; + return new HostMeasurements + { + CpuCount = cpuCount, + Load1 = load1, + MemoryTotalBytes = memoryTotal, + MemoryFreeBytes = memoryFree, + DiskTotalBytes = validDisk ? diskTotal : null, + DiskAvailableBytes = validDisk ? diskAvailable : null, + Disks = disks, + }; + } + + /// Reads the placement projection of one sessions.list row. + public static SessionPlacementInfo? ParseSessionPlacement(JsonElement row) + { + if (Object(row, "placement") is not { } placement) + return null; + var runner = Object(placement, "runner"); + return new SessionPlacementInfo( + String(placement, "state") switch + { + "local" => SessionPlacementState.Local, + "requested" => SessionPlacementState.Requested, + "provisioning" => SessionPlacementState.Provisioning, + "syncing" => SessionPlacementState.Syncing, + "starting" => SessionPlacementState.Starting, + "active" => SessionPlacementState.Active, + "draining" => SessionPlacementState.Draining, + "reconciling" => SessionPlacementState.Reconciling, + "reclaimed" => SessionPlacementState.Reclaimed, + "failed" => SessionPlacementState.Failed, + _ => SessionPlacementState.Unknown, + }, + String(placement, "environmentId"), + runner is { } r ? String(r, "deviceId") : null); + } + + private static JsonElement RequiredArray(JsonElement value, string name) => + Array(value, name) ?? throw new InvalidOperationException($"The Gateway returned an invalid {name} list."); + + internal static JsonElement? Array(JsonElement value, string name) => + value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var array) && + array.ValueKind == JsonValueKind.Array ? array : null; + + internal static JsonElement? Object(JsonElement value, string name) => + value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var obj) && + obj.ValueKind == JsonValueKind.Object ? obj : null; + + internal static string? String(JsonElement value, string name) => + value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && + property.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(property.GetString()) + ? property.GetString() + : null; + + private static bool? Bool(JsonElement value, string name) => + value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && + property.ValueKind is JsonValueKind.True or JsonValueKind.False + ? property.GetBoolean() + : null; + + private static long? Long(JsonElement value, string name) + { + if (value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(name, out var property) || + property.ValueKind != JsonValueKind.Number) + { + return null; + } + if (property.TryGetInt64(out var result)) + return result >= 0 ? result : null; + return property.TryGetDouble(out var d) && double.IsFinite(d) && d >= 0 && d <= long.MaxValue ? (long)d : null; + } + + private static int? Int(JsonElement value, string name) => + Long(value, name) is { } l && l <= int.MaxValue ? (int)l : null; + + private static DateTimeOffset? Time(JsonElement value, string name) => + Long(value, name) is { } ms && ms <= 253402300799999 ? DateTimeOffset.FromUnixTimeMilliseconds(ms) : null; + + private static IReadOnlyList Strings(JsonElement value, string name) => + Array(value, name)?.EnumerateArray() + .Where(entry => entry.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(entry.GetString())) + .Select(entry => entry.GetString()!) + .ToArray() ?? System.Array.Empty(); +} diff --git a/src/OpenClaw.Shared/Systems/SystemsProjection.cs b/src/OpenClaw.Shared/Systems/SystemsProjection.cs new file mode 100644 index 0000000000..d0e8d0d703 --- /dev/null +++ b/src/OpenClaw.Shared/Systems/SystemsProjection.cs @@ -0,0 +1,359 @@ +// Joins exact Gateway identities for presentation. Usage: SystemsProjection.Project(snapshot, sessions, options). +namespace OpenClaw.Shared.Systems; + +/// Errors from auxiliary reads. Inventory failures are reported separately because they replace the list. +public sealed record SystemsPartialErrors(string? Nodes = null, string? SystemInfo = null) +{ + public bool Any => Nodes is not null || SystemInfo is not null; + + public IEnumerable Messages => + new[] { Nodes, SystemInfo }.Where(message => message is not null)!; +} + +/// Everything the Gateway reported for one Systems refresh. +public sealed record SystemsInventorySnapshot +{ + /// environments.list rows; null when the Gateway predates the method. + public IReadOnlyList? Environments { get; init; } + public IReadOnlyList Nodes { get; init; } = Array.Empty(); + public IReadOnlyList Presence { get; init; } = Array.Empty(); + public GatewaySystemInfo? SystemInfo { get; init; } + public DateTimeOffset? SystemInfoSampledAt { get; init; } + public SystemsPartialErrors Errors { get; init; } = new(); +} + +/// One machine or connected client in the Systems inventory. +public sealed record SystemRow +{ + public required string Id { get; init; } + public required SystemKind Kind { get; init; } + public required SystemStatus Status { get; init; } + public SystemEnvironment? Environment { get; init; } + public GatewayNodeInfo? Node { get; init; } + /// Presence/merged identity carried over from the Instances page. + public MergedInstance? Instance { get; init; } + public GatewaySystemInfo? SystemInfo { get; init; } + public IReadOnlyList Sessions { get; init; } = Array.Empty(); + /// Reported name; null means the UI supplies a localized fallback (for example "Gateway host"). + public string? Name { get; init; } + public string? Platform { get; init; } + public bool IsThisDevice { get; init; } + public HostMeasurements? Measurements { get; init; } + public DateTimeOffset? MeasuredAt { get; init; } + + public bool IsOnline => Status == SystemStatus.Online; + + public bool NeedsAttention => Kind != SystemKind.Client && + (Status is SystemStatus.Offline or SystemStatus.Error || + Node?.ApprovalState is GatewayNodeApprovalState.PendingApproval or GatewayNodeApprovalState.PendingReapproval || + Environment?.Issues.Any(issue => issue.Code == "update-required") == true); + + /// Rename/forget are offered only for strong node identity matches. + public bool CanManageNode => Node is not null && Kind == SystemKind.Node && + (Instance is null || Instance.CanManageNode || Instance.Node is null); +} + +public sealed record SystemsProjectionOptions +{ + public string? LocalNodeId { get; init; } + public string? LocalHost { get; init; } + public DateTime NowUtc { get; init; } = DateTime.UtcNow; + /// When false (disconnected), the synthesized legacy Gateway row reports Offline. + public bool GatewayConnected { get; init; } = true; + /// Host stats delivered by node.hostStats events; the newer of these and node.list wins. + public IReadOnlyDictionary? HostStats { get; init; } + /// Local clock minus Gateway clock, applied to Gateway receipt times so ages use the local clock. + public TimeSpan GatewayClockOffset { get; init; } +} + +/// +/// Pure projection behind the Systems page. Joins only exact Gateway identities; +/// desktop capability never determines row membership. +/// +public static class SystemsProjection +{ + public const string NodeIdPrefix = "node:"; + public const string GatewayId = "gateway"; + public const string ClientIdPrefix = "client:"; + + public static IReadOnlyList Project( + SystemsInventorySnapshot snapshot, + IReadOnlyList sessions, + SystemsProjectionOptions? options = null) + { + ArgumentNullException.ThrowIfNull(snapshot); + options ??= new SystemsProjectionOptions(); + + var relations = BuildRelations(sessions); + var nodesById = new Dictionary(StringComparer.Ordinal); + foreach (var node in snapshot.Nodes) + nodesById.TryAdd(node.NodeId, node); + + var instances = InstanceMerger.Merge(snapshot.Nodes, snapshot.Presence, new InstanceMergeOptions + { + LocalNodeId = options.LocalNodeId, + LocalHost = options.LocalHost, + // Presence beacons carry Gateway time, so their freshness is judged on the Gateway's clock. + NowUtc = () => options.NowUtc - options.GatewayClockOffset, + }); + + var environments = snapshot.Environments ?? SynthesizeLegacyEnvironments(snapshot, options); + var claimed = new HashSet(ReferenceEqualityComparer.Instance); + var rows = new List(environments.Count + instances.Count); + + foreach (var environment in environments) + { + // Terminal worker records remain in Gateway history after their machine is gone. + if (environment.Worker is { State: "destroyed" or "failed" }) + continue; + + // Only the Gateway's own environment is the host; other non-node types are workers, as on the web. + var kind = environment.Id == GatewayId ? SystemKind.Host + : environment.Type == "node" ? SystemKind.Node + : SystemKind.Worker; + var nodeId = environment.NodeId; + var node = kind == SystemKind.Node && nodeId is not null ? nodesById.GetValueOrDefault(nodeId) : null; + var instance = kind == SystemKind.Host + ? instances.FirstOrDefault(i => i.IsGateway && !claimed.Contains(i)) + : nodeId is null ? null : BestInstanceForNode(instances, nodeId); + foreach (var matched in instances.Where(i => + ReferenceEquals(i, instance) || + (nodeId is not null && string.Equals(i.Node?.NodeId, nodeId, StringComparison.Ordinal)))) + { + claimed.Add(matched); + } + + var systemInfo = environment.Id == GatewayId ? snapshot.SystemInfo : null; + var rowSessions = relations.GetValueOrDefault(environment.Id) ?? (IReadOnlyList)Array.Empty(); + HostMeasurements? measurements = null; + DateTimeOffset? measuredAt = null; + if (systemInfo is not null) + (measurements, measuredAt) = (systemInfo.Measurements, snapshot.SystemInfoSampledAt); + else if (Newest(node?.HostStats, nodeId is null ? null : options.HostStats?.GetValueOrDefault(nodeId)) is { } stats) + (measurements, measuredAt) = (stats.Measurements, stats.UpdatedAt + options.GatewayClockOffset); + + rows.Add(new SystemRow + { + Id = environment.Id, + Kind = kind, + Status = environment.Status, + Environment = environment, + Node = node, + Instance = instance, + SystemInfo = systemInfo, + Sessions = rowSessions, + Name = ResolveName(environment, systemInfo, node, rowSessions), + Platform = PlatformLabels.Pretty( + NonEmpty(systemInfo?.OsLabel) ?? environment.Platform ?? node?.Platform ?? instance?.Platform, + node?.DeviceFamily ?? instance?.DeviceFamily), + IsThisDevice = instance?.IsThisInstance == true || + (nodeId is not null && string.Equals(nodeId, options.LocalNodeId, StringComparison.OrdinalIgnoreCase)), + Measurements = measurements, + MeasuredAt = measuredAt, + }); + } + + foreach (var instance in instances) + { + if (claimed.Contains(instance) || instance.IsGateway) + continue; + // A node suppressed by environments.list stays hidden; never resurrect it from node.list. + if (instance.Node is not null && snapshot.Environments is not null) + continue; + rows.Add(new SystemRow + { + Id = ClientIdPrefix + instance.Key, + Kind = SystemKind.Client, + Status = instance.Status is PresenceStatus.Active or PresenceStatus.Idle + ? SystemStatus.Online + : SystemStatus.Offline, + Instance = instance, + Name = instance.DisplayName, + Platform = PlatformLabels.Pretty(instance.Platform, instance.DeviceFamily), + IsThisDevice = instance.IsThisInstance, + }); + } + + return rows; + } + + private static NodeHostStats? Newest(NodeHostStats? listed, NodeHostStats? pushed) => + listed is null ? pushed : pushed is null || listed.UpdatedAt >= pushed.UpdatedAt ? listed : pushed; + + /// A disconnected Gateway cannot vouch for anything being online, so every row reads Offline. + public static SystemStatus EffectiveStatus(SystemRow row, bool gatewayConnected) => + gatewayConnected ? row.Status : SystemStatus.Offline; + + /// Older Gateways have no environments.list; rebuild the minimum inventory from node.list. + private static IReadOnlyList SynthesizeLegacyEnvironments( + SystemsInventorySnapshot snapshot, SystemsProjectionOptions options) + { + var result = new List + { + new() + { + Id = GatewayId, + Type = "local", + Status = options.GatewayConnected ? SystemStatus.Online : SystemStatus.Offline, + }, + }; + foreach (var node in snapshot.Nodes) + { + result.Add(new SystemEnvironment + { + Id = NodeIdPrefix + node.NodeId, + Type = "node", + Label = node.HasExplicitDisplayName ? node.DisplayName : null, + Status = node.IsOnline ? SystemStatus.Online : SystemStatus.Offline, + Platform = node.Platform, + Capabilities = node.Capabilities, + InvocableCommands = node.Commands, + LastConnectedAt = node.ConnectedAt is { } connected + ? new DateTimeOffset(DateTime.SpecifyKind(connected, DateTimeKind.Utc)) : null, + LastSeenAt = node.LastSeen is { } seen ? new DateTimeOffset(DateTime.SpecifyKind(seen, DateTimeKind.Utc)) : null, + LastSeenReason = node.LastSeenReason, + }); + } + return result; + } + + private static MergedInstance? BestInstanceForNode(IReadOnlyList instances, string nodeId) => + instances + .Where(i => string.Equals(i.Node?.NodeId, nodeId, StringComparison.Ordinal)) + .OrderByDescending(i => i.CanManageNode) + .ThenByDescending(i => i.Presence is not null) + .ThenByDescending(i => i.Timestamp ?? DateTime.MinValue) + .FirstOrDefault(); + + /// Session-to-machine bindings. Requested placements are not yet bound anywhere. + public static Dictionary> BuildRelations(IReadOnlyList sessions) + { + var map = new Dictionary>(StringComparer.Ordinal); + void Add(string id, SessionRelationKind kind, SystemsSessionRef session) + { + if (!map.TryGetValue(id, out var list)) + map[id] = list = new List(); + list.Add(new SessionRelation(kind, session)); + } + + foreach (var session in sessions) + { + if (session.Placement is { } placement && placement.State != SessionPlacementState.Local) + { + if (placement.State == SessionPlacementState.Requested) + continue; + if (placement.EnvironmentId is { } environmentId) + { + Add(environmentId, + placement.State is SessionPlacementState.Reclaimed or SessionPlacementState.Failed + ? SessionRelationKind.RetainedPlacement + : SessionRelationKind.Placement, + session); + } + if (placement.State == SessionPlacementState.Active && placement.RunnerDeviceId is { } runner) + Add(NodeIdPrefix + runner, SessionRelationKind.Runner, session); + continue; + } + + var execNode = session.ExecNode?.Trim(); + if (!string.IsNullOrEmpty(execNode)) + Add(NodeIdPrefix + execNode, SessionRelationKind.ExecBinding, session); + else + Add(GatewayId, SessionRelationKind.Gateway, session); + } + + return map.ToDictionary(pair => pair.Key, pair => (IReadOnlyList)pair.Value, StringComparer.Ordinal); + } + + private static string? ResolveName( + SystemEnvironment environment, + GatewaySystemInfo? systemInfo, + GatewayNodeInfo? node, + IReadOnlyList sessions) + { + var named = NonEmpty(systemInfo?.MachineName) ?? environment.Label ?? + (node?.HasExplicitDisplayName == true ? node.DisplayName : null); + if (named is not null) + return named; + if (environment.Id == GatewayId) + return null; + if (environment.Worker is { } worker) + { + // A worker is best known by the session placed on it, then by its provider profile. + var placed = sessions.FirstOrDefault(r => r.Kind == SessionRelationKind.Placement)?.Session; + if (placed is not null) + return placed.Title; + if (worker.ProfileId is { } profile) + return $"{worker.ProviderId} · {profile}"; + } + return node?.DisplayName is { Length: > 0 } fallback ? fallback : environment.Id; + } + + /// Short, stable fragment of a Gateway-owned id for telling same-profile workers apart. + public static string ShortId(string id) + { + var start = id.LastIndexOf(':') + 1; + return id.Substring(start, Math.Min(6, id.Length - start)); + } + + /// Lists every row, online first, then by numeric-aware name and ordinal identity. + public static IReadOnlyList OrderForInventory( + IEnumerable rows, + Func displayName, + bool gatewayConnected = true) + { + SystemStatus Status(SystemRow row) => EffectiveStatus(row, gatewayConnected); + var comparer = StringComparer.Create(System.Globalization.CultureInfo.CurrentCulture, + System.Globalization.CompareOptions.IgnoreCase | System.Globalization.CompareOptions.NumericOrdering); + return rows + .OrderBy(row => Status(row) == SystemStatus.Online ? 0 : 1) + .ThenBy(displayName, comparer) + .ThenBy(row => row.Id, StringComparer.Ordinal) + .ToArray(); + } + + /// Initial selection: the Gateway host, then the first row. + public static string? DefaultSelection(IReadOnlyList rows) => + rows.FirstOrDefault(row => row.Id == GatewayId)?.Id + ?? rows.FirstOrDefault()?.Id; + + private static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; +} + +/// Human-readable platform names shared by Systems rows. +public static class PlatformLabels +{ + public static string? Pretty(string? raw, string? deviceFamily = null) + { + var trimmed = raw?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + return string.IsNullOrWhiteSpace(deviceFamily) ? null : deviceFamily.Trim(); + foreach (var (prefix, label) in Known) + { + if (trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return label + trimmed[prefix.Length..]; + } + return trimmed switch + { + "win32" => "Windows", + "darwin" => "macOS", + _ => trimmed, + }; + } + + public static bool IsMac(string? platform) => + platform?.StartsWith("macOS", StringComparison.OrdinalIgnoreCase) == true || + string.Equals(platform, "darwin", StringComparison.OrdinalIgnoreCase); + + private static readonly (string Prefix, string Label)[] Known = + [ + ("macos", "macOS"), + ("ipados", "iPadOS"), + ("ios", "iOS"), + ("tvos", "tvOS"), + ("watchos", "watchOS"), + ("windows", "Windows"), + ("linux", "Linux"), + ("android", "Android"), + ]; +} diff --git a/src/OpenClaw.Shared/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs index cee5fcb0e3..05828f2f97 100644 --- a/src/OpenClaw.Shared/WindowsNodeClient.cs +++ b/src/OpenClaw.Shared/WindowsNodeClient.cs @@ -1159,6 +1159,35 @@ private void HandleResponseForConnection( RaiseStatusChanged(ConnectionStatus.Connected); HandshakeSucceeded?.Invoke(this, EventArgs.Empty); + if (_isPaired) + StartHostStatsPublishing(); + } + } + + private Timer? _hostStatsTimer; + + /// Publishes host statistics so the Gateway's Systems inventory can show this machine's resources. + private void StartHostStatsPublishing() + { + StopHostStatsPublishing(); + var timer = new Timer(_ => _ = PublishHostStatsAsync(), null, TimeSpan.Zero, Systems.HostStatsSampler.Interval); + if (Interlocked.CompareExchange(ref _hostStatsTimer, timer, null) is not null) + timer.Dispose(); + } + + private void StopHostStatsPublishing() => Interlocked.Exchange(ref _hostStatsTimer, null)?.Dispose(); + + private async Task PublishHostStatsAsync() + { + try + { + if (!_isConnected || !_isPaired || Systems.HostStatsSampler.Sample() is not { } payload) + return; + await SendNodeEventAsync(Systems.HostStatsSampler.EventName, payload).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.Warn($"Host statistics publish failed: {ex.Message}"); } } @@ -2122,6 +2151,7 @@ protected override bool ShouldAutoReconnect() protected override void OnDisconnected() { + StopHostStatsPublishing(); CancelTransportInvocations(); _isConnected = false; Volatile.Write(ref _pendingConnectRequestId, null); @@ -2136,6 +2166,7 @@ protected override void OnDisconnected() protected override void OnError(Exception ex) { + StopHostStatsPublishing(); CancelTransportInvocations(); _isConnected = false; if (!_pairingBlocked) @@ -2147,6 +2178,7 @@ protected override void OnError(Exception ex) protected override void OnDisposing() { + StopHostStatsPublishing(); CancelTransportInvocations(); } diff --git a/src/OpenClaw.Tray.WinUI/App.xaml b/src/OpenClaw.Tray.WinUI/App.xaml index f0486ff407..ac5c20f6fe 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml +++ b/src/OpenClaw.Tray.WinUI/App.xaml @@ -9,6 +9,7 @@ + - - + + + @@ -112,7 +112,7 @@ - + diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml index 48ac010d3d..e6365b2dce 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml @@ -87,7 +87,11 @@ - + + + + + @@ -97,24 +101,24 @@ Text="Editor" Style="{StaticResource BodyStrongTextBlockStyle}" VerticalAlignment="Center"/> - - - + @@ -123,16 +127,20 @@ Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" BorderThickness="1" CornerRadius="8"> - + + + + + - + - + - @@ -143,13 +151,14 @@ - + @@ -159,19 +168,25 @@ Padding="16,14" BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" BorderThickness="0,0,0,1"> - + + + + + + - + - + + + + + @@ -333,25 +352,25 @@ CornerRadius="12" Padding="9,4" HorizontalAlignment="Left"> - + - - + - + - - - - - - - - - - - - - - - - public sealed partial class LocalAiPage : Page { + private const double CompactActionsWidth = 720; private const double BytesPerGigabyte = 1024d * 1024 * 1024; private LocalAiPageViewModel? _viewModel; private IReadOnlyList? _renderedModelOptions; @@ -31,10 +32,19 @@ public sealed partial class LocalAiPage : Page public LocalAiPage() { InitializeComponent(); + SizeChanged += OnLayoutSizeChanged; DataContextChanged += OnDataContextChanged; Unloaded += OnUnloaded; } + private void OnLayoutSizeChanged(object sender, SizeChangedEventArgs e) + { + var orientation = e.NewSize.Width < CompactActionsWidth ? Orientation.Vertical : Orientation.Horizontal; + EngineActions.Orientation = orientation; + ModelActions.Orientation = orientation; + GatewayActions.Orientation = orientation; + } + private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) { if (_viewModel is not null) diff --git a/src/OpenClaw.Tray.WinUI/Pages/SystemsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SystemsPage.xaml new file mode 100644 index 0000000000..ab2b277094 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Pages/SystemsPage.xaml @@ -0,0 +1,8 @@ + + + + + diff --git a/src/OpenClaw.Tray.WinUI/Pages/SystemsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SystemsPage.xaml.cs new file mode 100644 index 0000000000..ea97da82ad --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Pages/SystemsPage.xaml.cs @@ -0,0 +1,291 @@ +// Connects the Settings frame to Systems state and actions. Usage: navigate to "systems" or "systems:". +using Microsoft.UI.Dispatching; +using Microsoft.UI.Reactor.Hosting; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using OpenClaw.Connection; +using OpenClaw.Shared; +using OpenClaw.Shared.Systems; +using OpenClawTray.Helpers; +using OpenClawTray.Services; +using OpenClawTray.Systems; +using System; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using static Microsoft.UI.Reactor.Factories; + +namespace OpenClawTray.Pages; + +/// +/// Systems page (formerly Instances). Hosts the Reactor , +/// feeds it app state and Gateway events through , and owns +/// the node rename/forget operations its confirmation dialogs run. Pairing decisions stay on +/// the Connection page and web handoffs use the app's dashboard launcher. +/// +public sealed partial class SystemsPage : Page, IDisposable, Presentation.ISettingsPageContentInset +{ + private static App CurrentApp => (App)Application.Current!; + private readonly SystemsController _controller; + private readonly SystemsViewport _viewport = new(); + private ReactorHostControl? _host; + private AppState? _appState; + private IOperatorGatewayClient? _client; + private PairingApprovalCoordinator? _approvals; + private DispatcherQueueTimer? _timer; + private string? _requestedMachine; + private bool _connected; + private string? _gatewayId; + private bool _disposed; + + public SystemsPage() + { + InitializeComponent(); + _controller = new SystemsController(() => CurrentApp.GatewayClient is { } client ? SystemsGatewayClient.For(client) : null); + Unloaded += (_, _) => Teardown(); + } + + void Presentation.ISettingsPageContentInset.ApplyTopInset(double topInset) => + _viewport.ApplyTopInset(topInset); + + protected override void OnNavigatedTo(Microsoft.UI.Xaml.Navigation.NavigationEventArgs e) + { + base.OnNavigatedTo(e); + // systems: opens the page with that machine selected. Frame.Navigated + // (which runs Initialize) fires before OnNavigatedTo, so select here as well. + _requestedMachine = Presentation.HubPageRegistry.SystemsMachine(e.Parameter as string); + if (_requestedMachine is { } machine) + _controller.Select(machine); + } + + /// + /// Systems pages are not cached, so leaving the frame ends this instance. Reactor keeps its tree + /// across Unloaded (which also fires on reparenting), so the final cleanup runs here and when + /// the owning Settings surface is deactivated or disposed (see ). + /// + protected override void OnNavigatedFrom(Microsoft.UI.Xaml.Navigation.NavigationEventArgs e) + { + base.OnNavigatedFrom(e); + Dispose(); + } + + /// Final, idempotent cleanup of the Reactor tree, controller and subscriptions. + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + Teardown(); + if (_host is { } host) + { + _host = null; + if (ReferenceEquals(ReactorHost.Child, host)) + ReactorHost.Child = null; + host.Dispose(); + } + _controller.Dispose(); + } + + /// Called by SettingsShellView when this page becomes the navigation target. + public void Initialize() + { + if (_disposed) + return; + Teardown(); + _appState = CurrentApp.AppState; + if (_appState is not null) + _appState.PropertyChanged += OnAppStateChanged; + _approvals = CurrentApp.PairingApprovals; + if (_approvals is not null) + _approvals.ApprovalsChanged += OnApprovalsChanged; + AttachClient(CurrentApp.GatewayClient); + + if (_host is null) + { + _host = new ReactorHostControl + { + // The page owns its scrolling regions, so the Reactor root must fill the page. + HorizontalContentAlignment = HorizontalAlignment.Stretch, + VerticalContentAlignment = VerticalAlignment.Stretch, + }; + var props = new SystemsPageProps(_controller, CreateActions()) { Viewport = _viewport }; + _host.Mount(_ => Component(props)); + ReactorHost.Child = _host; + } + + _timer = DispatcherQueue.CreateTimer(); + _timer.Interval = SystemsController.PollInterval; + _timer.Tick += (_, _) => _ = _controller.TickAsync(); + _timer.Start(); + + // The controller starts disconnected, so the first connected input starts the inventory load. + _connected = false; + PushInputs(); + if (_requestedMachine is { } machine) + _controller.Select(machine); + if (_appState?.Nodes is { } nodes) + _controller.UpdateNodes(nodes); + } + + /// Sessions and pairings the app clears on disconnect; reloaded on each connection. + private async Task RefreshAuxiliaryAsync() + { + if (CurrentApp.GatewayClient is not { } client) + return; + try + { + await client.RequestSessionsAsync(); + await (_approvals?.RefreshFromGatewayAsync() ?? Task.CompletedTask); + } + catch (Exception ex) + { + Logger.Warn($"[SystemsPage] Auxiliary refresh failed: {ex.Message}"); + } + } + + private void Teardown() + { + _timer?.Stop(); + _timer = null; + if (_appState is not null) + _appState.PropertyChanged -= OnAppStateChanged; + _appState = null; + if (_approvals is not null) + _approvals.ApprovalsChanged -= OnApprovalsChanged; + _approvals = null; + AttachClient(null); + } + + private void AttachClient(IOperatorGatewayClient? client) + { + if (ReferenceEquals(client, _client)) + return; + if (_client is not null) + { + _client.SystemsInventoryChanged -= OnInventoryChanged; + _client.NodeHostStatsReceived -= OnHostStats; + } + _client = client; + if (client is not null) + { + client.SystemsInventoryChanged += OnInventoryChanged; + client.NodeHostStatsReceived += OnHostStats; + } + } + + private void OnAppStateChanged(object? sender, PropertyChangedEventArgs e) => OnUi(() => + { + switch (e.PropertyName) + { + case nameof(AppState.Nodes): + if (_appState?.Nodes is { } nodes) + _controller.UpdateNodes(nodes); + break; + case nameof(AppState.Status): + AttachClient(CurrentApp.GatewayClient); + PushInputs(); + break; + case nameof(AppState.Presence): + case nameof(AppState.Sessions): + case nameof(AppState.NodePairList): + case nameof(AppState.DevicePairList): + PushInputs(); + break; + } + }); + + private void OnApprovalsChanged(object? sender, EventArgs e) => OnUi(PushInputs); + + private void OnInventoryChanged(object? sender, string eventName) => OnUi(_controller.RequestRefresh); + + private void OnHostStats(object? sender, NodeHostStatsEvent update) => + OnUi(() => _controller.ApplyHostStats(update)); + + private void PushInputs() + { + if (_disposed) + return; + var client = CurrentApp.GatewayClient; + var connected = _appState?.Status == ConnectionStatus.Connected && client is { IsConnectedToGateway: true, HasHandshakeSnapshot: true }; + var gatewayId = CurrentApp.Registry?.GetActive()?.Id; + var reconnected = connected && (!_connected || !string.Equals(gatewayId, _gatewayId, StringComparison.Ordinal)); + _connected = connected; + _gatewayId = gatewayId; + _controller.UpdateInputs(new SystemsInputs + { + Connected = connected, + GatewayId = gatewayId, + GatewayName = CurrentApp.Registry?.GetActive()?.FriendlyName, + Scopes = client?.GrantedOperatorScopes ?? Array.Empty(), + Presence = _appState?.Presence ?? Array.Empty(), + Sessions = _appState?.Sessions ?? Array.Empty(), + PendingApprovals = _approvals?.Current ?? Array.Empty(), + NodePairList = _appState?.NodePairList, + OwnNodeIds = CurrentApp.OwnNodeIds, + LocalNodeId = CurrentApp.NodeFullDeviceId, + LocalHost = Environment.MachineName, + }); + if (reconnected) + _ = RefreshAuxiliaryAsync(); + } + + private void OnUi(Action action) + { + if (DispatcherQueue.HasThreadAccess) + action(); + else + DispatcherQueue.TryEnqueue(() => action()); + } + + private SystemsPageActions CreateActions() => new() + { + OpenSession = key => + { + CurrentApp.PendingChatSessionKey = key; + ((IAppCommands)CurrentApp).Navigate("chat"); + }, + Navigate = tag => ((IAppCommands)CurrentApp).Navigate(tag), + CopyText = text => ClipboardHelper.CopyText(text), + // The launcher owns tunnel, credential and browser failures; device tokens never enter the URL. + OpenWeb = path => ((IAppCommands)CurrentApp).OpenDashboard(path), + PostUi = action => DispatcherQueue.TryEnqueue(() => { if (!_disposed) action(); }), + RenameNode = RenameNodeAsync, + ForgetNode = ForgetNodeAsync, + }; + + /// The live client, or the reason a node management request must not be sent. + private static (IOperatorGatewayClient? Client, string? Error) ManagementClient() + { + if (CurrentApp.GatewayClient is not { IsConnectedToGateway: true } client) + return (null, SystemsText.S("Disconnected_Title")); + // Scopes can change after the dialog opened, so check again before sending. + return SystemsController.HasScope(client.GrantedOperatorScopes, "operator.pairing", legacyDefault: true) + ? (client, null) + : (null, SystemsText.S("Manage_PairingRequired")); + } + + private async Task RenameNodeAsync(GatewayNodeInfo node, string name) + { + var (client, error) = ManagementClient(); + if (client is null) + return error; + var result = await client.NodeRenameAsync(node.NodeId, name); + if (!result.Success) + return result.ErrorMessage ?? LocalizationHelper.GetString("NodesPage_Rename_Error_Generic"); + // The Gateway does not broadcast renames. Reload node.list for the app and the inventory, + // whose environment label is the name the page shows. + _ = client.RequestNodesAsync(); + _ = _controller.RefreshAsync(); + return null; + } + + private static async Task ForgetNodeAsync(GatewayNodeInfo node) + { + var (client, error) = ManagementClient(); + if (client is null) + return error; + var result = await client.NodePairRemoveAsync(node.NodeId); + // On success the Gateway's node.pair.resolved broadcast refreshes the inventory. + return result.Success ? null : result.ErrorMessage ?? LocalizationHelper.GetString("NodesPage_Forget_Error_Generic"); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml index 19ea9342fa..7b1a4c6c22 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml @@ -32,7 +32,7 @@ CornerRadius="8" Padding="12" Visibility="Collapsed"> - + - + - + + SelectionChanged="OnModelChanged" MaxWidth="320" HorizontalAlignment="Left"> - + @@ -141,7 +142,7 @@ + SelectionChanged="OnLanguageChanged" MaxWidth="200" HorizontalAlignment="Left"> @@ -165,8 +166,8 @@ + ValueChanged="OnSilenceChanged" MaxWidth="250" + HorizontalAlignment="Stretch"/> @@ -193,7 +194,7 @@ CornerRadius="8" Padding="12" Visibility="Collapsed"> - + - + - + + SelectionChanged="OnTtsProviderChanged" MaxWidth="360" HorizontalAlignment="Left"> @@ -238,8 +239,8 @@ - + SelectionChanged="OnKokoroVoiceChanged" MaxWidth="400" HorizontalAlignment="Left"/> + + IsIndeterminate="False" Maximum="100" Height="6" MaxWidth="400" HorizontalAlignment="Stretch"/> @@ -273,8 +274,8 @@ - + SelectionChanged="OnPiperVoiceChanged" MaxWidth="400" HorizontalAlignment="Left"/> + + IsIndeterminate="False" Maximum="100" Height="6" MaxWidth="400" HorizontalAlignment="Stretch"/> @@ -308,7 +309,7 @@ + SelectionChanged="OnWindowsVoiceChanged" MaxWidth="300" HorizontalAlignment="Left"/>