diff --git a/.asf.yaml b/.asf.yaml index 6ac3fe6b8b..453ff07f19 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -62,11 +62,18 @@ github: required_approving_review_count: 1 required_status_checks: strict: false - # test is the single job in .github/workflows/ci.yml, covering lint, - # format, build, tsc, knip, the selected workspace suites, e2e and - # Storybook. Renaming it there, or adding a paths filter that stops - # ci.yml from running, freezes every pull request: the check never - # reports and no committer can override it. + # test is the single unconditional job in .github/workflows/ci.yml. It + # runs the install-free contract checks on every change and installs the + # toolchain only for the validation its own planning step selects, so a + # documentation-only change still reports without paying for a build. + # Renaming the job there, adding a paths filter that stops ci.yml from + # running, or splitting the work back across jobs so this context comes + # from an aggregator that can be skipped, freezes every pull request: + # the check never reports and no committer can override it. + # A required context must report on every pull request, so a lane + # behind a paths filter cannot be listed here: the filter would keep + # the workflow from starting and the check would stay pending forever. + # windows_recovery is filtered and therefore deliberately absent. contexts: - test @@ -81,9 +88,11 @@ github: restrict_force_push: true environments: - # These environments are the external human gates around signing secrets - # and npm OIDC. Naming an environment here replaces its settings wholesale, - # so keep every protection rule in this declarative authority. + # These environments are the deployment boundaries around signing secrets + # and npm OIDC. Release publication adds human review; scheduled npm + # Nightly relies on exact ref admission instead. Naming an environment here + # replaces its settings wholesale, so keep every protection rule in this + # declarative authority. release: required_reviewers: - id: M4n5ter @@ -95,7 +104,25 @@ github: policies: - name: "v*-incubating-rc*" type: tag - npm-release: + npm-publication: + required_reviewers: [] + wait_timer: 0 + prevent_self_review: false + deployment_branch_policy: + protected_branches: false + policies: + - name: main + type: branch + nightly: + required_reviewers: [] + wait_timer: 0 + prevent_self_review: false + deployment_branch_policy: + protected_branches: false + policies: + - name: main + type: branch + product-release: required_reviewers: - id: M4n5ter type: User @@ -104,8 +131,8 @@ github: deployment_branch_policy: protected_branches: false policies: - - name: "v*" - type: tag + - name: main + type: branch notifications: commits: commits@maka.apache.org diff --git a/.gitattributes b/.gitattributes index ded2a4ecf0..1aad5a3cf6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,3 +10,8 @@ /.claude export-ignore /.maka-shots export-ignore /maka-proposal-zh-review.txt export-ignore + +# DeepSeek Harness remains available to repository development and benchmarks, +# but its external runtime dependency closure is not an ASF source-release input. +/packages/eval/harbor/deepseek-harness-toolchain/package.json export-ignore +/packages/eval/harbor/deepseek-harness-toolchain/package-lock.json export-ignore diff --git a/.github/ASF_NPM_RELEASE.md b/.github/ASF_NPM_RELEASE.md index 01b11ad9b5..db621a0c88 100644 --- a/.github/ASF_NPM_RELEASE.md +++ b/.github/ASF_NPM_RELEASE.md @@ -72,9 +72,9 @@ registry side effects. After both source-release votes approve the candidate, the product Release workflow creates `v` at that approved commit. The npm Stage workflow -then builds and validates one tarball from that final tag, submits those exact -bytes to npm staging through the protected `npm-release` Environment and OIDC, -and records the stage identity. Human approval with npm 2FA makes the package +then builds and validates one tarball from that final tag in jobs without OIDC. The OIDC job runs +only reviewed `main` publisher code, submits those exact bytes through the main-restricted +`npm-publication` Environment, and records both product-source and publisher identities. Human approval with npm 2FA makes the package public; Finalize verifies the registry bytes, integrity, signature, provenance, and dist-tag. diff --git a/.github/ASF_SOURCE_RELEASE.md b/.github/ASF_SOURCE_RELEASE.md index d936e14637..82d815a1fc 100644 --- a/.github/ASF_SOURCE_RELEASE.md +++ b/.github/ASF_SOURCE_RELEASE.md @@ -19,14 +19,9 @@ # Apache Maka source release runbook -This runbook prepares the official Apache Incubator source-release component. -The first Maka release also requires npm and Desktop convenience artifacts, but -those artifacts have separate build, licensing, signing, and acceptance gates. -They must be built from the exact IPMC-approved source release produced here. - -The workflow implements release mechanics; it does not establish that a commit -is legally ready to release. Before starting a vote, the PPMC and mentors must -confirm the candidate's provenance and release documents. +This runbook prepares the official Apache Incubator source release. npm, Desktop, and other convenience artifacts use separate build, licensing, signing, and acceptance processes; they are not part of this source candidate or its release vote. Stable convenience artifacts, when provided, are built from the approved source release and tag. + +The workflow implements release mechanics; it does not replace the human review of candidate provenance and release documents performed through the project's release vote. ## Candidate contract diff --git a/.github/DESKTOP_NIGHTLY.md b/.github/DESKTOP_NIGHTLY.md new file mode 100644 index 0000000000..854ede602d --- /dev/null +++ b/.github/DESKTOP_NIGHTLY.md @@ -0,0 +1,41 @@ + + +# Desktop Nightly + +Desktop Nightly is an ephemeral developer snapshot, not an Apache release. It builds the current `main` commit every day so contributors can try recent Desktop changes and report problems without waiting for an ASF source-release vote. + +The npm publication workflow gives each snapshot an immutable version such as `0.2.0-dev.42.20260829`. The run number is the sole ordering authority. After that exact npm version is public, it triggers Desktop Nightly with a version-only artifact; the authenticated workflow event supplies the exact source commit and upstream run. Each fresh Desktop Nightly creates a `v` tag protected by the checked-in `Immutable release tags` ruleset and one GitHub draft prerelease containing the macOS and Windows packages, blockmaps, `dev-mac.yml`, `dev.yml`, and one offline Sigstore bundle. The workflow verifies every remote asset before it publishes the prerelease as non-Latest. Packaged Nightlies use the GitHub `dev` channel and verify that downloaded bytes were attested by `.github/workflows/desktop-nightly.yml` on `main`. A formal Desktop build uses the separate stable GitHub Release channel and formal product-release attestation identity. + +Nightly currently uses the same application identity as the formal Desktop. Installing it replaces the existing Maka installation rather than creating a second side-by-side app. Its user data remains in the same location. Testers who need the formal build should reinstall that build before returning to the formal channel. Builds previously downloaded from `nightlies.apache.org` do not migrate automatically; testers must install the newest GitHub prerelease once, after which GitHub Nightlies update automatically. + +## One-time setup + +1. After the checked-in `.asf.yaml` reaches `main`, verify that ASF reconciliation created the `nightly` GitHub Environment with only `main` permitted and no approval gate. Do not maintain that policy manually in GitHub. Configure its macOS signing and notarization secrets: `CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_API_KEY`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`. Do not expose these secrets to repository-wide or pull-request workflows. +2. Configure npm Trusted Publishing for `apache/maka` and `.github/workflows/npm-publication.yml`, restricted to the `npm-publication` Environment and with both `npm publish` and `npm stage publish` allowed. Do not create or store a long-lived npm token. +3. After npm Trusted Publishing is ready, set `NPM_NIGHTLY_ENABLED` to `true`, run `npm publication` from `main` with `channel=nightly`, and verify the exact npm version and `nightly` dist-tag. +4. Set `DESKTOP_NIGHTLY_ENABLED` to `true` and manually dispatch a fresh npm Nightly. Confirm that its successful run triggers `Desktop Nightly`. Do not rerun a failed attempt in place. +5. Verify that `v` points to the exact source SHA and that its GitHub Release is published with Draft off, Prerelease on, Latest off, and exactly the nine expected assets. Install that prerelease on both platforms. +6. Publish one later fresh Nightly and confirm a GitHub-to-GitHub automatic and differential update on both platforms before sharing the channel with testers. + +The npm schedule starts at 18:17 UTC. Before changing the npm tag, the workflow requires its run number to exceed the current `nightly` version. Desktop assembles and verifies a draft before one publish mutation; a packaging, attestation, tag, upload, or digest failure leaves no partially published GitHub Release. Never rerun a failed workflow attempt in place; dispatch a fresh npm Nightly with a newer version. + +GitHub Release retention is intentionally outside this workflow. Do not delete an old Nightly prerelease or its tag while any installed client may need its payload or blockmap. One Nightly is additionally pinned by tag, asset name, and SHA-256 in `scripts/windows-upgrade-baseline.json` as the Windows upgrade gate's baseline: deleting that prerelease fails the gate on every pull request that touches the Windows release path until the pin moves to another published build. Disabling `DESKTOP_NIGHTLY_ENABLED` stops new Desktop publication without mutating tags or releases. + +Remote Runtime Host setup uses the exact `maka-agent@` package embedded in the Desktop manifest. The npm package is verified before Desktop artifacts become visible, so clean remote setup never depends on an unpublished Runtime Host version. diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index 5a1c32ab00..4402722337 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -19,9 +19,10 @@ # Product release checklist -The `Release` workflow is Maka's convenience-artifact release entry point. Desktop and CLI/TUI are +The IPMC-approved source archive on ASF distribution infrastructure is the Apache release. The +`Release` workflow is Maka's convenience-artifact distribution entry point. Desktop and CLI/TUI are built from the exact IPMC-approved ASF source candidate commit. They share that source commit, the -root product version, one convenience tag, one GitHub Release, one Draft decision, and one release +root product version, one convenience tag, one GitHub Release, one Draft decision, and one distribution gate. The workflow creates no Draft until every required artifact job succeeds. Phase 1 requires: @@ -31,9 +32,9 @@ Phase 1 requires: - the signed, notarized, relocatable Apple Silicon CLI/TUI ZIP; - checksums generated after each artifact reaches its final form. -The ASF Desktop artifacts must not contain a Git runtime, a bundled-Git manifest, or Git/Dugite -redistribution notices. Managed-workspace execution remains unavailable until a separately reviewed, -ASF-compatible verified runtime is connected before admission/T1. +The convenience Desktop artifacts must not contain a Git runtime, a bundled-Git manifest, or Git/Dugite +redistribution notices. The retired Git executable-backed managed-workspace path must not be restored; +future workspace execution requires a separately reviewed Gitoxide production composition before admission/T1. The first product release also requires the exact `maka-agent@` npm package. The product tag and Draft must exist before npm staging, but the Draft must remain unpublished until npm is @@ -60,10 +61,17 @@ must never be exposed to fork or ordinary pull-request jobs. Before the first product release, confirm the checked-in `.asf.yaml` has reconciled the live repository: - the `Immutable release tags` ruleset blocks updates, force-pushes, and deletions of `v*` tags; -- the `release` and `npm-release` Environments accept only their declared tag patterns and require a reviewer other than the triggering user; -- enable immutable releases so assets and the associated tag cannot change after publication. - -These controls close the check-to-upload and check-to-stage windows. Keep the Release in Draft while assets and acceptance are incomplete; publishing early must make subsequent mutation fail closed. +- the `release` Environment accepts only its declared source-candidate tag pattern and requires a + reviewer other than the triggering user; +- `npm-publication`, `nightly`, and `product-release` accept only `main`; `product-release` requires + a reviewer other than the triggering user. `npm-publication` and `nightly` have no GitHub approval + gate because scheduled npm and Desktop Nightly publication is automatic; formal npm publication + still requires human 2FA approval after staging. + +These controls close the check-to-upload and check-to-stage windows. Finalize uses GitHub Actions +OIDC rather than a stored signing key to attest every convenience artifact. Keep the Release in +Draft while assets and acceptance are incomplete; Desktop rejects downloaded updates whose exact +bytes and expected filename are not covered by that protected workflow identity. ## Create the complete Draft @@ -79,8 +87,8 @@ These controls close the check-to-upload and check-to-stage windows. Keep the Re 5. Confirm `release-identity`, both Desktop matrix entries, `cli-macos-arm64`, and `publish` pass. A skipped or failed required job must prevent Draft creation. 6. Confirm one Draft named `v` targets the approved source SHA, identifies the ASF source - reference in its notes, is marked as a GitHub prerelease exactly when the product version is a - prerelease, is not marked Latest while it remains a Draft, and contains exactly the manifest + reference in its notes, is not marked as a GitHub prerelease or Latest while it remains a Draft, + and contains exactly the manifest reported by `node scripts/product-release-artifacts.mjs list`. The manifest covers both Desktop platforms and update metadata, the standalone CLI/TUI, and their required checksums. 7. Inspect the CLI ZIP. It must contain `bin/maka`, `RELEASE.json`, `DISCLAIMER-WIP`, `LICENSE`, `NOTICE`, @@ -106,17 +114,22 @@ then rerun. If only the tag exists, the retry creates the missing Draft. Follow [the npm release runbook](../docs/cli-npm-release.md) against the exact product tag and Draft: -1. Run **Stage CLI npm release** from `v` and record its successful run ID and attempt. +1. Record the successful **Release** workflow run ID and attempt that built the Draft assets. Run + **npm publication** with `channel=formal` from `main` and record its successful run ID and + attempt. 2. Inspect the staged tarball and provenance, then approve that exact stage with npm 2FA. -3. Run **Finalize CLI npm channel** from `main` and confirm it verifies the public package bytes, - provenance, signature, and release dist-tag. +3. Run **Finalize product release** from `main`. Its first job verifies the public package + bytes, provenance, signature, and release dist-tag. 4. Install the exact public version on each release platform and complete the npm acceptance steps. -Keep the GitHub Release in Draft throughout this sequence. A failed or rejected npm candidate -requires a new product version; never publish the Draft to work around npm state. - -When every npm and cross-machine acceptance check has passed, publish the Draft. Mark a stable -release as Latest at that final publication boundary; prereleases must remain non-Latest. +Keep the GitHub Release in Draft throughout this sequence. The final workflow job waits at the +`product-release` Environment. Approve it only after every npm and cross-machine acceptance check +has passed. It verifies the live Draft digests against the immutable publication record from the +exact successful Release run, creates Sigstore provenance and an offline +`Maka--attestation.sigstore.json` bundle, then publishes the convenience Release and makes a +stable release Latest in the same GitHub operation. Do not publish or +change the Latest designation manually. A failed or rejected npm candidate requires a new product +version; never publish the Draft to work around npm state. ## Acceptance on another Apple Silicon Mac @@ -155,8 +168,20 @@ Download the installer, Windows Desktop ZIP, and both checksum files through a b 7. Add a clean remote Runtime Host from the packaged Desktop app. Confirm setup installs the exact public `maka-agent@` package and the remote session completes one model turn. -Immediately before publication, reverify that the approved ASF candidate tag and convenience -`v` tag still resolve to the same recorded commit. Publish only after npm Finalize and both -independent-machine acceptance passes. If any required artifact, npm step, or +Immediately before approving the `product-release` Environment, reverify that the approved ASF +candidate tag and convenience `v` tag still resolve to the same recorded commit. Approve +only after npm verification and both independent-machine acceptance passes. If any required artifact, npm step, or acceptance step fails, keep the Draft unpublished, fix the issue, increment the root product version, and run the full workflow again. Never replace an existing release identity. + +After Finalize publishes the convenience Release, download its attestation bundle and verify each +installer or archive independently: + +```sh +gh attestation verify path/to/Maka--mac-arm64.zip \ + --bundle path/to/Maka--attestation.sigstore.json \ + --repo apache/maka \ + --signer-workflow apache/maka/.github/workflows/release-cli-finalize.yml +``` + +Desktop performs the same trust decision before exposing a downloaded update for installation. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3367e20fe..155333837e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,11 +32,22 @@ permissions: contents: read jobs: - # Planning and every selected Linux surface share one runner, so the core - # workflow consumes one automatic job without dropping affected coverage. + # One job, not three. A runner slot is scarcer than a minute on shared + # infrastructure, and splitting planning from validation made every pull + # request queue for a runner three times to reach one verdict. Planning is + # the first step; every later step gates on its outputs, so a + # documentation-only change costs one short allocation rather than two. + # + # The name is `test` because that is the required context in `.asf.yaml`. + # Renaming it would leave that check unreported on every open pull request + # until the rename merged, and nothing could merge while it was unreported. test: - runs-on: ubuntu-latest - timeout-minutes: 120 + # Pinned, not `ubuntu-latest`. The two resolve to the same image, but only + # the alias makes this required context wait at the tail, and the steps + # below already assume this image. `ci-workflow-policy.test.mjs` holds the + # rule for every lane. + runs-on: ubuntu-24.04 + timeout-minutes: 45 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -49,7 +60,7 @@ jobs: BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event_name == 'push' && github.sha || github.event.pull_request.head.sha }} run: | - # PR checks may predate later main changes, so the single core job also + # PR checks may predate later main changes, so the planning lane also # validates the exact merged delta. Dispatches and unavailable history # fail safe to every surface. if [[ "${{ github.event_name }}" == "workflow_dispatch" ]] || [[ "$BASE_SHA" =~ ^0+$ ]] || ! git cat-file -e "${BASE_SHA}^{commit}"; then @@ -59,7 +70,20 @@ jobs: fi - name: Test CI planner - run: node --test --test-concurrency=1 scripts/ci-test-plan.test.mjs scripts/verify-windows-harness.test.mjs + run: >- + node --test --test-concurrency=1 scripts/ci-test-plan.test.mjs + scripts/ci-workflow-policy.test.mjs scripts/verify-windows-harness.test.mjs + + # Pure Node like the planner test, and the labelling workflow imports this + # module directly, so a tier or exclusion change is caught here rather + # than by mislabelling live pull requests. + - name: Test PR effort classification + run: node --test --test-concurrency=1 scripts/pr-effort.test.mjs + + # The scheduled lifecycle workflow imports this pure policy module. Keep + # its time boundaries and exemptions deterministic before it can write. + - name: Test issue and PR lifecycle policy + run: node --test --test-concurrency=1 scripts/issue-pr-lifecycle.test.mjs # Same shape and the same needs: a regenerate-and-diff contract that runs # on Node alone, so it belongs beside the planner test rather than behind @@ -78,8 +102,26 @@ jobs: - name: Test the epoch guard run: node --test --test-concurrency=1 scripts/protocol-epoch-check.test.mjs - - name: Test AX tree audit contract - run: node --test scripts/ax-tree-audit.test.mjs + - name: Test Computer Use script contracts + run: node --test scripts/ax-tree-audit.test.mjs scripts/computer-use/lab-root.test.mjs + + - name: Test script entrypoint contracts + run: node --test scripts/script-entrypoints.test.mjs + + # The packaged Windows lane drives the updater through this wiring. It + # is a few lines, so it is asserted here on every change rather than by + # naming its module in that lane's 25-minute path filter. + - name: Test the packaged update feed wiring + run: node --test --test-concurrency=1 scripts/update-test-feed-wiring.test.mjs + + # Install-free like its neighbours: the gate reads one source file and + # compares it to a hand-edited inventory, so a hook that silently widens + # its scope to the whole tree fails here rather than in a profile (#4109). + - name: Check the hooks scoped to the whole shell + run: npm run check:app-shell-hooks + + - name: Test the app-shell hook gate + run: node --test --test-concurrency=1 scripts/check-app-shell-hooks.test.mjs - name: Verify ASF npm preflight policy run: npm run check:asf-npm @@ -90,8 +132,10 @@ jobs: - name: Check ASF source headers run: npm run check:asf-headers + # Everything below needs an installed toolchain. A plan that selected no + # surface stops at the install-free checks above. - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - if: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' || steps.plan.outputs.asf_source == 'true' || steps.plan.outputs.cli_package == 'true' || steps.plan.outputs.release_contract == 'true' + if: steps.plan.outputs.heavy == 'true' with: node-version: '24' cache: npm @@ -109,13 +153,13 @@ jobs: restore-keys: electron-${{ runner.os }}- - name: Install Linux runtime dependencies - if: steps.plan.outputs.runtime_sandbox == 'true' + if: steps.plan.outputs.runtime_sandbox == 'true' || steps.plan.outputs.state_root_compat == 'true' run: sudo apt-get update && sudo apt-get install -y ripgrep bubblewrap # Ubuntu 24.04 hosted runners gate unprivileged user namespaces through # AppArmor, which otherwise makes bwrap fail while configuring loopback. - name: Enable bubblewrap user namespaces - if: steps.plan.outputs.runtime_sandbox == 'true' + if: steps.plan.outputs.runtime_sandbox == 'true' || steps.plan.outputs.state_root_compat == 'true' run: | if [[ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]]; then sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 @@ -125,9 +169,15 @@ jobs: fi - name: Install dependencies - if: steps.plan.outputs.code == 'true' || steps.plan.outputs.asf_source == 'true' || steps.plan.outputs.cli_package == 'true' || steps.plan.outputs.release_contract == 'true' + if: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' || steps.plan.outputs.asf_source == 'true' || steps.plan.outputs.cli_package == 'true' || steps.plan.outputs.release_contract == 'true' run: npm ci + - name: Check localized TUI copy boundaries + if: steps.plan.outputs.code == 'true' + run: | + npm run check:tui-copy + node --test scripts/check-tui-copy.test.mjs + # The header audit above remains install-free. The complete source gate # also exercises generation and therefore runs after its pinned formatter # dependency is installed, matching the source-candidate workflow. @@ -135,6 +185,20 @@ jobs: if: steps.plan.outputs.asf_source == 'true' run: npm run check:asf-source + # Parsed dependency rules and an exact legacy-debt ledger keep the + # renderer root from absorbing new feature or Desktop ownership while + # the existing AppShell is migrated behind stable boundaries. + - name: Check renderer architecture + if: steps.plan.outputs.code == 'true' + env: + BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} + run: | + if [[ -n "$BASE_SHA" && ! "$BASE_SHA" =~ ^0+$ ]]; then + npm run check:renderer-architecture -- --base "$BASE_SHA" + else + npm run check:renderer-architecture + fi + - name: Lint if: steps.plan.outputs.code == 'true' run: npm run lint @@ -149,7 +213,9 @@ jobs: # directly instead of being hidden behind an earlier compilation failure. - name: Astryx surface inventory if: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' - run: npm run astryx:surface-inventory + run: | + npm run astryx:surface-inventory + npm run astryx:surface-inventory:test - name: Build if: steps.plan.outputs.code == 'true' || steps.plan.outputs.cli_package == 'true' || steps.plan.outputs.release_contract == 'true' @@ -163,6 +229,17 @@ jobs: if: steps.plan.outputs.code == 'true' run: npm run typecheck + # Two drift contracts over the shipped app-icon artwork, sitting beside + # the theme drift check for the same reason: the committed bytes are a + # build output that nothing else re-derives, so without this a change to + # the generator, to the `APP_ICONS` catalog, or to the packaged-resource + # list can go green while the artwork it names no longer matches. Those + # are the inputs it selects on; regenerating the artwork costs about a + # minute, and every other code change used to pay it. + - name: App icon artwork drift + if: steps.plan.outputs.app_icons == 'true' + run: node --test scripts/verify-packaged-app-icons.test.mjs scripts/generate-app-icons.test.mjs + - name: Astryx theme drift if: steps.plan.outputs.code == 'true' run: npm run astryx:theme -- --check @@ -208,6 +285,49 @@ jobs: if: steps.plan.outputs.runtime_host == 'true' run: npm --workspace @maka/runtime-host run test:dist + # A published predecessor writes the durable state; the workspace built + # above reads it. Release packaging is deliberately not in front of this: + # it takes minutes and changes nothing about whether these decoders can + # read that state. The release lanes still qualify exact tarballs. + - id: forward-roll-baseline + name: Resolve the published forward-roll baseline + if: steps.plan.outputs.state_root_compat == 'true' + run: node scripts/release-cli-publication.mjs resolve-nightly-predecessor "$GITHUB_OUTPUT" + + - name: Download the forward-roll baseline + if: steps.plan.outputs.state_root_compat == 'true' + env: + SOURCE_URL: ${{ steps.forward-roll-baseline.outputs.tarball_url }} + SOURCE_INTEGRITY: ${{ steps.forward-roll-baseline.outputs.integrity }} + run: | + set -euo pipefail + source_path="$RUNNER_TEMP/forward-roll-source.tgz" + curl --fail --location --max-filesize 67108864 --proto '=https' --tlsv1.2 \ + --retry 3 --retry-connrefused --retry-delay 2 "$SOURCE_URL" --output "$source_path" + node - "$source_path" "$SOURCE_INTEGRITY" <<'NODE' + const { createHash } = require('node:crypto'); + const { readFileSync } = require('node:fs'); + const bytes = readFileSync(process.argv[2]); + const actual = `sha512-${createHash('sha512').update(bytes).digest('base64')}`; + if (actual !== process.argv[3]) throw new Error('Forward-roll baseline integrity mismatch'); + NODE + { + echo "FORWARD_ROLL_SOURCE=$source_path" + echo "FORWARD_ROLL_SOURCE_SHA256=$(sha256sum "$source_path" | cut -d ' ' -f 1)" + } >> "$GITHUB_ENV" + + - name: Qualify durable state against the published baseline + if: steps.plan.outputs.state_root_compat == 'true' + env: + MAKA_QUALIFICATION_BWRAP_USE_SUDO: '1' + run: | + set -o pipefail + npm run --silent release:cli:qualify-state-root -- \ + --source "$FORWARD_ROLL_SOURCE" \ + --source-sha256 "$FORWARD_ROLL_SOURCE_SHA256" \ + --target-workspace "$PWD" \ + | tee "$RUNNER_TEMP/durable-state-report.json" + - name: Ensure xvfb if: steps.plan.outputs.e2e == 'true' run: command -v xvfb-run >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y xvfb; } @@ -238,10 +358,61 @@ jobs: if: steps.plan.outputs.storybook == 'true' run: npm --workspace @maka/desktop run smoke:storybook + - name: Update stable Rust for CLI packaging + if: steps.plan.outputs.cli_package == 'true' + run: rustup update stable --no-self-update + + - id: cli-rustc + name: Resolve CLI Rust cache version + if: steps.plan.outputs.cli_package == 'true' + shell: bash + run: | + echo "version=$(rustc --version | cut -d ' ' -f 2)" >> "$GITHUB_OUTPUT" + echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + { + echo "KACHE_CACHE_DIR=${{ runner.temp }}/kache-cli-package" + echo "KACHE_RUNTIME_DIR=${{ runner.temp }}/kache-cli-package-runtime" + echo "RUSTC_WRAPPER=kache" + } >> "$GITHUB_ENV" + + - name: Install Kache for CLI packaging + if: steps.plan.outputs.cli_package == 'true' + uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2 + with: + tool: kache@0.16.0 + + - id: cli-kache-cache + name: Restore CLI Rust build cache + if: steps.plan.outputs.cli_package == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/kache-cli-package + key: kache-runtime-host-peer-cli-package-v0.16.0-${{ runner.os }}-${{ runner.arch }}-rust-${{ steps.cli-rustc.outputs.version }}-${{ steps.cli-rustc.outputs.revision }} + restore-keys: | + kache-runtime-host-peer-cli-package-v0.16.0-${{ runner.os }}-${{ runner.arch }}-rust-${{ steps.cli-rustc.outputs.version }}- + + - name: Install cargo-deny for CLI packaging + if: steps.plan.outputs.cli_package == 'true' + uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2 + with: + tool: cargo-deny@0.20.2 + - name: Build CLI release candidate if: steps.plan.outputs.cli_package == 'true' - run: npm run release:cli:pack + run: npm run release:cli:pack -- --allow-dirty + + - name: Report CLI Rust build cache + if: steps.plan.outputs.cli_package == 'true' + shell: bash + run: kache report --format github >> "$GITHUB_STEP_SUMMARY" + + - name: Save CLI Rust build cache + if: steps.plan.outputs.cli_package == 'true' && github.ref_name == github.event.repository.default_branch + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/kache-cli-package + key: ${{ steps.cli-kache-cache.outputs.cache-primary-key }} - name: Validate installed CLI release candidate if: steps.plan.outputs.cli_package == 'true' - run: npm run release:cli:smoke + run: npm run release:cli:smoke -- packages/cli/release/*.tgz diff --git a/.github/workflows/cli-package-validation.yml b/.github/workflows/cli-package-validation.yml index 995e49b050..efd62c0b0b 100644 --- a/.github/workflows/cli-package-validation.yml +++ b/.github/workflows/cli-package-validation.yml @@ -18,6 +18,33 @@ name: CLI package validation on: + pull_request: + paths: + - '.github/workflows/cli-package-validation.yml' + - '.github/workflows/npm-publication.yml' + - '.github/workflows/runtime-host-peer-admission.yml' + - 'deny.toml' + - 'native/runtime-host-peer/**' + - 'package-lock.json' + - 'packages/cli/RUNTIME_HOST_PEER_*' + - 'packages/cli/src/cli-core.ts' + - 'packages/cli/src/runtime-host-cli.ts' + - 'packages/cli/src/runtime-host-peer-*' + - 'packages/cli/src/runtime-host-service-*' + - 'packages/runtime-host/package.json' + - 'packages/runtime-host/src/client/peer-client.ts' + - 'packages/runtime-host/src/peer-mesh/**' + - 'packages/runtime-host/src/server/peer-listener.ts' + - 'packages/runtime-host/src/transport/peer-native.ts' + - 'packages/storage/package.json' + - 'packages/storage/src/file-lifetime-owner.ts' + - 'packages/storage/src/native-file-lock.ts' + - 'scripts/generate-runtime-host-peer-*' + - 'scripts/release-cli-package.mjs' + - 'scripts/qualify-released-cli-state-root.mjs' + - 'scripts/qualify-released-cli-state-root.test.mjs' + - 'scripts/released-cli-state-root-fixture.mjs' + - 'scripts/smoke-release-cli-package.mjs' workflow_call: inputs: source_commit: @@ -25,6 +52,11 @@ on: required: false type: string default: '' + package_version: + description: Exact Product Nightly version; empty builds the checked-in formal version + required: false + type: string + default: '' outputs: release_candidate_artifact_id: description: Immutable artifact produced by the build job @@ -32,6 +64,15 @@ on: release_candidate_run_attempt: description: Workflow attempt that built the immutable artifact value: ${{ jobs.build.outputs.release_candidate_run_attempt }} + release_predecessor_version: + description: Exact npm Nightly version qualified against this candidate + value: ${{ jobs.build.outputs.release_predecessor_version }} + release_predecessor_tarball_url: + description: Exact npm Nightly tarball qualified against this candidate + value: ${{ jobs.build.outputs.release_predecessor_tarball_url }} + release_predecessor_integrity: + description: npm SHA-512 integrity of the Nightly tarball qualified against this candidate + value: ${{ jobs.build.outputs.release_predecessor_integrity }} workflow_dispatch: permissions: @@ -39,15 +80,128 @@ permissions: concurrency: group: cli-package-validation-${{ github.workflow }}-${{ github.ref }} + # Four build targets and four install environments fan out to about fourteen + # jobs per run, which makes this the largest holder of runner slots in the + # repository. Without this, a run a newer push had already invalidated was not + # cancelled — it ran to completion while the replacement queued behind it in + # this same group. Release callers arrive through `workflow_call`, where + # `github.event_name` is the caller's, so publication runs never cancel. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + peer-native: + name: Build direct-peer addon (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + target: darwin-arm64 + rust_target: '' + - runner: ubuntu-24.04-arm + target: linux-arm64 + rust_target: aarch64-unknown-linux-gnu.2.28 + - runner: ubuntu-24.04 + target: linux-x64 + rust_target: x86_64-unknown-linux-gnu.2.28 + - runner: windows-2025 + target: win32-x64 + rust_target: '' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.source_commit || github.sha }} + persist-credentials: false + - name: Update stable Rust + run: rustup update stable --no-self-update + - id: rustc + name: Resolve Rust cache version + shell: bash + run: | + echo "version=$(rustc --version | cut -d ' ' -f 2)" >> "$GITHUB_OUTPUT" + echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + { + echo "KACHE_CACHE_DIR=${{ runner.temp }}/kache" + echo "KACHE_RUNTIME_DIR=${{ runner.temp }}/kache-runtime" + echo "RUSTC_WRAPPER=kache" + } >> "$GITHUB_ENV" + - name: Install Kache + uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2 + with: + tool: kache@0.16.0 + - id: kache-cache + name: Restore Rust build cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/kache + key: kache-runtime-host-peer-release-${{ matrix.target }}-v0.16.0-${{ runner.os }}-${{ runner.arch }}-rust-${{ steps.rustc.outputs.version }}-${{ steps.rustc.outputs.revision }} + restore-keys: | + kache-runtime-host-peer-release-${{ matrix.target }}-v0.16.0-${{ runner.os }}-${{ runner.arch }}-rust-${{ steps.rustc.outputs.version }}- + - name: Install the Linux baseline linker + if: startsWith(matrix.target, 'linux-') + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 + with: + version: 0.16.0 + - name: Install cargo-zigbuild + if: startsWith(matrix.target, 'linux-') + uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2 + with: + tool: cargo-zigbuild@0.23.2 + - name: Build the release addon + env: + MAKA_RUNTIME_HOST_PEER_CARGO_SUBCOMMAND: ${{ matrix.rust_target && 'zigbuild' || '' }} + MAKA_RUNTIME_HOST_PEER_CARGO_TARGET: ${{ matrix.rust_target }} + run: node native/runtime-host-peer/build.mjs + - name: Report Rust build cache + shell: bash + run: kache report --format github >> "$GITHUB_STEP_SUMMARY" + - name: Save Rust build cache + if: github.ref_name == github.event.repository.default_branch + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/kache + key: ${{ steps.kache-cache.outputs.cache-primary-key }} + - name: Enforce the Linux glibc baseline + if: startsWith(matrix.target, 'linux-') + run: | + node - <<'NODE' + const { execFileSync } = require('node:child_process'); + const output = execFileSync('readelf', [ + '--version-info', + 'native/runtime-host-peer/target/release/maka_runtime_host_peer.node', + ], { encoding: 'utf8' }); + const versions = [...output.matchAll(/GLIBC_(\d+)\.(\d+)/g)].map((match) => [ + Number(match[1]), + Number(match[2]), + ]); + const newer = versions.find(([major, minor]) => major > 2 || (major === 2 && minor > 28)); + if (newer) throw new Error(`Direct-peer addon requires GLIBC_${newer.join('.')}`); + NODE + - name: Stage the platform addon + env: + PEER_TARGET: ${{ matrix.target }} + run: node -e "const fs=require('node:fs'),p=require('node:path'); const d=p.join('peer-prebuilds',process.env.PEER_TARGET); fs.mkdirSync(d,{recursive:true}); fs.copyFileSync(p.join('native','runtime-host-peer','target','release','maka_runtime_host_peer.node'),p.join(d,'maka_runtime_host_peer.node'))" + - name: Upload the platform addon + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: runtime-host-peer-${{ matrix.target }} + path: peer-prebuilds + if-no-files-found: error + retention-days: 1 + build: name: Build immutable tarball + needs: peer-native runs-on: ubuntu-24.04 - timeout-minutes: 60 + timeout-minutes: 45 outputs: release_candidate_artifact_id: ${{ steps.release-candidate.outputs.artifact-id }} release_candidate_run_attempt: ${{ github.run_attempt }} + release_predecessor_version: ${{ steps.predecessor.outputs.version }} + release_predecessor_tarball_url: ${{ steps.predecessor.outputs.tarball_url }} + release_predecessor_integrity: ${{ steps.predecessor.outputs.integrity }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -59,7 +213,26 @@ jobs: cache: npm - name: Select the release npm toolchain run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" + # Resolving one npm version is seconds of work against a registry this + # job already reaches, so it rides the runner that waits on the addon + # builds instead of holding a second one for the whole wait. + - name: Resolve the current npm Nightly as immutable evidence + id: predecessor + run: node scripts/release-cli-publication.mjs resolve-nightly-predecessor "$GITHUB_OUTPUT" + - name: Install cargo-deny + uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2 + with: + tool: cargo-deny@0.20.2 + - name: Download direct-peer addons + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: runtime-host-peer-* + path: ${{ runner.temp }}/runtime-host-peer-prebuilds + merge-multiple: true - name: Build the release tarball once + env: + MAKA_CLI_NIGHTLY_VERSION: ${{ inputs.package_version }} + MAKA_RUNTIME_HOST_PEER_PREBUILDS: ${{ runner.temp }}/runtime-host-peer-prebuilds run: npm run release:cli:pack - name: Upload the immutable release candidate id: release-candidate @@ -77,29 +250,35 @@ jobs: name: Validate installed CLI ${{ matrix.name }} needs: build runs-on: ${{ matrix.runner }} - timeout-minutes: 15 + timeout-minutes: 45 strategy: fail-fast: false matrix: include: - - name: Linux x64 / Node 22.19 + # The two supported Node versions share a machine and a tarball, so + # they are two runs of the same smoke rather than two runners. + - name: Linux x64 / Node 22.19 and 24 runner: ubuntu-24.04 node: '22.19.0' + second_node: '24' platform: linux arch: x64 - - name: Linux x64 / Node 24 - runner: ubuntu-24.04 + - name: Linux arm64 / Node 24 + runner: ubuntu-24.04-arm node: '24' + second_node: '' platform: linux - arch: x64 + arch: arm64 - name: macOS arm64 / Node 24 runner: macos-15 node: '24' + second_node: '' platform: darwin arch: arm64 - name: Windows x64 / Node 24 runner: windows-2025 node: '24' + second_node: '' platform: win32 arch: x64 steps: @@ -125,12 +304,141 @@ jobs: path: packages/cli/release - name: Validate the installed tarball run: node scripts/smoke-release-cli-package.mjs + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + if: matrix.second_node != '' + with: + node-version: ${{ matrix.second_node }} + - name: Select the release npm toolchain for the second Node + if: matrix.second_node != '' + run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" + - name: Validate the installed tarball on the second Node + if: matrix.second_node != '' + run: node scripts/smoke-release-cli-package.mjs + + state-root-qualification: + name: Qualify released State Roots + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.source_commit || github.sha }} + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + - name: Select the release npm toolchain + run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" + - name: Require the account-isolation sandbox + run: | + sudo apt-get update + sudo apt-get install --yes bubblewrap + bwrap --version + - name: Download the release candidate + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.build.outputs.release_candidate_artifact_id }} + path: packages/cli/release + # Three runs of one script against one sandbox, not three runners. Two of + # these transitions are between tarballs that were published and frozen, + # so nothing in a pull request can change their outcome except the + # qualifier itself, and the third reads the candidate this run built. + - name: Qualify the released State Root transitions + env: + MAKA_QUALIFICATION_BWRAP_USE_SUDO: '1' + PREDECESSOR_TARBALL_URL: ${{ needs.build.outputs.release_predecessor_tarball_url }} + PREDECESSOR_INTEGRITY: ${{ needs.build.outputs.release_predecessor_integrity }} + run: | + set -euo pipefail + evidence_root="$RUNNER_TEMP/released-state-root" + mkdir -p "$evidence_root" + + qualify() { + local slug="$1" source_url="$2" source_sha256="$3" source_integrity="$4" + local target_kind="$5" target_url="$6" target_sha256="$7" epoch_relation="$8" + local source_path target_path + echo "::group::Qualify $slug" + source_path="$evidence_root/$slug-source.tgz" + curl --fail --location --max-filesize 67108864 --proto '=https' --tlsv1.2 "$source_url" --output "$source_path" + if [[ -n "$source_integrity" ]]; then + node - "$source_path" "$source_integrity" <<'NODE' + const { createHash } = require('node:crypto'); + const { readFileSync } = require('node:fs'); + const bytes = readFileSync(process.argv[2]); + const actual = `sha512-${createHash('sha512').update(bytes).digest('base64')}`; + if (actual !== process.argv[3]) throw new Error('Source tarball integrity mismatch'); + NODE + source_sha256="$(sha256sum "$source_path" | cut -d ' ' -f 1)" + else + test -n "$source_sha256" + fi + if [[ "$target_kind" == 'published' ]]; then + target_path="$evidence_root/$slug-target.tgz" + curl --fail --location --max-filesize 67108864 --proto '=https' --tlsv1.2 "$target_url" --output "$target_path" + test -n "$target_sha256" + else + target_path="$(find packages/cli/release -maxdepth 1 -name '*.tgz' -print -quit)" + test -n "$target_path" + target_path="$(realpath "$target_path")" + target_sha256="$(sha256sum "$target_path" | cut -d ' ' -f 1)" + fi + npm run --silent release:cli:qualify-state-root -- \ + --source "$source_path" \ + --source-sha256 "$source_sha256" \ + --target "$target_path" \ + --target-sha256 "$target_sha256" \ + --expect-epoch-relation "$epoch_relation" \ + | tee "$evidence_root/$slug-report.json" + echo "::endgroup::" + } + + qualify cross-epoch-74-to-76 \ + https://registry.npmjs.org/maka-agent/-/maka-agent-0.2.0-dev.3.20260830.tgz \ + 66b1ce9307c9d5c06eaa7a6cbf533d4747d02caf71c1776c69c7dbfa12c3f414 '' \ + published https://registry.npmjs.org/maka-agent/-/maka-agent-0.2.0-dev.4.20260830.tgz \ + b7d48adb466e16be7ffefbda3a0fcd833cc4108ea502b27778d0f4da680e1fc0 different + + qualify same-epoch-76 \ + https://registry.npmjs.org/maka-agent/-/maka-agent-0.2.0-dev.4.20260830.tgz \ + b7d48adb466e16be7ffefbda3a0fcd833cc4108ea502b27778d0f4da680e1fc0 '' \ + published https://registry.npmjs.org/maka-agent/-/maka-agent-0.2.0-dev.5.20260830.tgz \ + e7a682157c6899fc7f1be86a2d7b0bd0696195a5771d8cc97bd1389a5b74989f same + + qualify current-nightly-predecessor-to-candidate \ + "$PREDECESSOR_TARBALL_URL" '' "$PREDECESSOR_INTEGRITY" \ + candidate '' '' any + # The three transitions used to be three matrix jobs, so one failing left + # the others to upload their own reports. Folded into one step they share + # a `set -e`, and the reports are wanted most on the run that failed — + # `tee` has already written the failing transition's own output by then. + # `if-no-files-found` stays `error` so a broken path is still caught on a + # green run. + - name: Preserve the qualification reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: released-state-root + path: ${{ runner.temp }}/released-state-root/*-report.json + if-no-files-found: error + retention-days: 7 + - name: Require the qualified Nightly predecessor to remain current + env: + PREDECESSOR_VERSION: ${{ needs.build.outputs.release_predecessor_version }} + PREDECESSOR_TARBALL_URL: ${{ needs.build.outputs.release_predecessor_tarball_url }} + PREDECESSOR_INTEGRITY: ${{ needs.build.outputs.release_predecessor_integrity }} + run: | + node scripts/release-cli-publication.mjs assert-nightly-predecessor \ + "$PREDECESSOR_VERSION" \ + "$PREDECESSOR_TARBALL_URL" \ + "$PREDECESSOR_INTEGRITY" eval: name: Validate installed CLI Eval + if: github.event_name != 'pull_request' needs: build runs-on: ubuntu-24.04 - timeout-minutes: 30 + timeout-minutes: 45 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/copilot-auto-review.yml b/.github/workflows/copilot-auto-review.yml index 858a9a08c8..a33f7e1de6 100644 --- a/.github/workflows/copilot-auto-review.yml +++ b/.github/workflows/copilot-auto-review.yml @@ -33,7 +33,7 @@ jobs: github.event.pull_request.draft == false && !endsWith(github.event.pull_request.user.login, '[bot]') && !contains(github.event.pull_request.labels.*.name, 'copilot-skip') - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 steps: diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml index b1b4540837..9219469ff9 100644 --- a/.github/workflows/dependency-audit.yml +++ b/.github/workflows/dependency-audit.yml @@ -34,6 +34,7 @@ on: push: branches: [main] paths: + - .github/workflows/dependency-audit.yml - scripts/audit-shipped-dependencies.mjs - scripts/third-party-closure.mjs - package.json @@ -50,8 +51,8 @@ concurrency: jobs: audit: - runs-on: ubuntu-latest - timeout-minutes: 15 + runs-on: ubuntu-24.04 + timeout-minutes: 45 steps: - name: Check out the repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/desktop-nightly.yml b/.github/workflows/desktop-nightly.yml new file mode 100644 index 0000000000..fb03a3b485 --- /dev/null +++ b/.github/workflows/desktop-nightly.yml @@ -0,0 +1,322 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: Desktop Nightly + +on: + workflow_run: + workflows: + - npm publication + types: + - completed + +permissions: + actions: read + contents: read + +concurrency: + group: desktop-nightly + cancel-in-progress: false + +jobs: + identity: + if: >- + vars.DESKTOP_NIGHTLY_ENABLED == 'true' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.display_title == 'npm nightly publication' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + source_commit: ${{ github.event.workflow_run.head_sha }} + version: ${{ steps.identity.outputs.version }} + steps: + - name: Reject in-place workflow reruns + if: github.run_attempt != 1 + run: | + echo "Desktop Nightly retries require a fresh npm Nightly dispatch" >&2 + exit 1 + + - name: Check out the published Nightly source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false + + - name: Require the successful Apache npm Nightly run + env: + UPSTREAM_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }} + run: | + test "$GITHUB_REPOSITORY" = apache/maka + test "$UPSTREAM_REPOSITORY" = apache/maka + + - name: Download the published Nightly version + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: product-nightly-version + path: ${{ runner.temp }}/product-nightly-version + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Bind Desktop to the exact npm Nightly version + id: identity + env: + VERSION_PATH: ${{ runner.temp }}/product-nightly-version/version.txt + run: | + node scripts/product-nightly.mjs inspect-version "$VERSION_PATH" "$GITHUB_OUTPUT" + + desktop: + needs: identity + strategy: + fail-fast: false + matrix: + include: + - platform: macos + runner: macos-15 + - platform: windows + runner: windows-2025 + runs-on: ${{ matrix.runner }} + environment: nightly + timeout-minutes: 75 + defaults: + run: + shell: bash + env: + MAKA_DESKTOP_NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + steps: + - name: Reject in-place workflow reruns + if: github.run_attempt != 1 + run: | + echo "Desktop Nightly retries require a fresh npm Nightly dispatch" >&2 + exit 1 + + - name: Check out the exact Nightly source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.identity.outputs.source_commit }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Audit the shipped Desktop dependency closure + run: | + npm audit --omit=dev --audit-level=moderate + node scripts/audit-shipped-dependencies.mjs + + - name: Update stable Rust for native Desktop artifacts + run: rustup update stable --no-self-update + + - name: Write the App Store Connect API key + if: matrix.platform == 'macos' + env: + APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey_Maka.p8 + run: | + umask 077 + printf '%s' "$APPLE_API_KEY_CONTENT" > "$APPLE_API_KEY_PATH" + + - name: Package the signed and notarized macOS Nightly + if: matrix.platform == 'macos' + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ runner.temp }}/AuthKey_Maka.p8 + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: npm run package:macos-arm64 + + - name: Notarize and verify the macOS Nightly + if: matrix.platform == 'macos' + env: + APPLE_API_KEY: ${{ runner.temp }}/AuthKey_Maka.p8 + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + DMG_PATH: apps/desktop/release/Maka-${{ needs.identity.outputs.version }}-mac-arm64.dmg + run: | + xcrun notarytool submit "$DMG_PATH" \ + --key "$APPLE_API_KEY" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER" \ + --wait + xcrun stapler staple "$DMG_PATH" + npm run verify:macos-arm64 -- "$DMG_PATH" + + - name: Package the Windows Nightly + if: matrix.platform == 'windows' + run: npm run package:windows-x64 + + - name: Verify the Windows Nightly + if: matrix.platform == 'windows' + run: >- + npm run verify:windows-x64 -- + "apps/desktop/release/Maka-${{ needs.identity.outputs.version }}-win-x64.exe" + + - name: Stage the exact Nightly artifacts + env: + NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + STAGE_DIRECTORY: ${{ runner.temp }}/desktop-nightly + run: | + mkdir -p "$STAGE_DIRECTORY" + if [[ "${{ matrix.platform }}" = macos ]]; then + cp -- \ + "apps/desktop/release/Maka-$NIGHTLY_VERSION-mac-arm64.dmg" \ + "apps/desktop/release/Maka-$NIGHTLY_VERSION-mac-arm64.zip" \ + "apps/desktop/release/Maka-$NIGHTLY_VERSION-mac-arm64.zip.blockmap" \ + apps/desktop/release/dev-mac.yml \ + "$STAGE_DIRECTORY/" + else + cp -- \ + "apps/desktop/release/Maka-$NIGHTLY_VERSION-win-x64.exe" \ + "apps/desktop/release/Maka-$NIGHTLY_VERSION-win-x64.exe.blockmap" \ + "apps/desktop/release/Maka-$NIGHTLY_VERSION-win-x64.zip" \ + apps/desktop/release/dev.yml \ + "$STAGE_DIRECTORY/" + fi + + - name: Preserve the verified Nightly artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: desktop-nightly-${{ matrix.platform }} + path: ${{ runner.temp }}/desktop-nightly + if-no-files-found: error + compression-level: 0 + retention-days: 30 + + publish: + needs: [identity, desktop] + runs-on: ubuntu-24.04 + environment: nightly + timeout-minutes: 20 + permissions: + artifact-metadata: write + attestations: write + contents: write + id-token: write + steps: + - name: Reject in-place workflow reruns + if: github.run_attempt != 1 + run: | + echo "Desktop Nightly retries require a fresh npm Nightly dispatch" >&2 + exit 1 + + - name: Check out the Nightly publisher + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.identity.outputs.source_commit }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: npm + + - name: Install publisher dependencies + run: npm ci --ignore-scripts + + - name: Download both verified Desktop builds + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: desktop-nightly-* + path: ${{ github.workspace }}/.nightly-input + merge-multiple: true + + - name: Stage the exact GitHub Release assets + env: + NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + run: | + node scripts/desktop-nightly.mjs stage \ + "$GITHUB_WORKSPACE/.nightly-input" \ + "$GITHUB_WORKSPACE/.nightly-stage" \ + "$NIGHTLY_VERSION" + + - name: Attest every GitHub Nightly asset subject + id: attest + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ github.workspace }}/.nightly-stage/release/* + + - name: Verify the issued Nightly provenance + env: + ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }} + CERTIFICATE_IDENTITY: https://github.com/${{ github.repository }}/.github/workflows/desktop-nightly.yml@refs/heads/main + GH_TOKEN: ${{ github.token }} + run: | + verified=0 + while IFS= read -r -d '' artifact; do + gh attestation verify "$artifact" \ + --bundle "$ATTESTATION_BUNDLE" \ + --repo "$GITHUB_REPOSITORY" \ + --cert-identity "$CERTIFICATE_IDENTITY" \ + --cert-oidc-issuer https://token.actions.githubusercontent.com + verified=$((verified + 1)) + done < <(find "$GITHUB_WORKSPACE/.nightly-stage/release" -maxdepth 1 -type f -print0) + if (( verified != 8 )); then + echo "Expected 8 verified Desktop Nightly subjects, found $verified" >&2 + exit 1 + fi + + - name: Add the one offline provenance bundle + env: + ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }} + NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + run: | + node scripts/desktop-nightly.mjs add-attestation \ + "$GITHUB_WORKSPACE/.nightly-stage" \ + "$NIGHTLY_VERSION" \ + "$ATTESTATION_BUNDLE" + + - name: Ensure the exact versioned Nightly tag + env: + GH_TOKEN: ${{ github.token }} + NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + SOURCE_COMMIT: ${{ needs.identity.outputs.source_commit }} + run: | + gh auth setup-git + node scripts/product-release-tag.mjs ensure "v$NIGHTLY_VERSION" "$SOURCE_COMMIT" + + - name: Prepare and verify the draft GitHub Prerelease + env: + GH_TOKEN: ${{ github.token }} + NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + SOURCE_COMMIT: ${{ needs.identity.outputs.source_commit }} + run: | + node scripts/desktop-nightly-release.mjs prepare \ + "$GITHUB_WORKSPACE/.nightly-stage/release" \ + "$NIGHTLY_VERSION" \ + "$SOURCE_COMMIT" \ + "$GITHUB_REPOSITORY" + + - name: Publish the complete GitHub Prerelease + env: + GH_TOKEN: ${{ github.token }} + NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + SOURCE_COMMIT: ${{ needs.identity.outputs.source_commit }} + run: | + node scripts/desktop-nightly-release.mjs publish \ + "$GITHUB_WORKSPACE/.nightly-stage/release" \ + "$NIGHTLY_VERSION" \ + "$SOURCE_COMMIT" \ + "$GITHUB_REPOSITORY" diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml new file mode 100644 index 0000000000..f1b35bcd75 --- /dev/null +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -0,0 +1,122 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: Gitoxide helper admission + +on: + pull_request: + paths: + - '.github/workflows/gitoxide-helper-admission.yml' + - 'native/gitoxide-helper/**' + - 'packages/runtime-host/src/server/gitoxide-helper-*.ts' + - 'packages/runtime-host/src/__tests__/gitoxide-helper-*.test.ts' + - 'packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts' + - 'packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts' + - 'docs/architecture/gitoxide-*.md' + push: + branches: + - main + paths: + - '.github/workflows/gitoxide-helper-admission.yml' + - 'native/gitoxide-helper/**' + - 'packages/runtime-host/src/server/gitoxide-helper-*.ts' + - 'packages/runtime-host/src/__tests__/gitoxide-helper-*.test.ts' + - 'packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts' + - 'packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts' + - 'docs/architecture/gitoxide-*.md' + +permissions: + contents: read + +concurrency: + group: gitoxide-helper-admission-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-24.04 + - macos-latest + - windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.18.1 + cache: npm + - id: rustc + name: Resolve Rust cache version + shell: bash + run: | + echo "version=$(rustc --version | cut -d ' ' -f 2)" >> "$GITHUB_OUTPUT" + echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + { + echo "KACHE_CACHE_DIR=${{ runner.temp }}/kache" + echo "KACHE_RUNTIME_DIR=${{ runner.temp }}/kache-runtime" + echo "RUSTC_WRAPPER=kache" + } >> "$GITHUB_ENV" + - name: Install Kache + uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2 + with: + tool: kache@0.16.0 + - id: kache-cache + name: Restore Rust build cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/kache + key: kache-gitoxide-helper-test-v0.16.0-${{ runner.os }}-${{ runner.arch }}-rust-${{ steps.rustc.outputs.version }}-${{ steps.rustc.outputs.revision }} + restore-keys: | + kache-gitoxide-helper-test-v0.16.0-${{ runner.os }}-${{ runner.arch }}-rust-${{ steps.rustc.outputs.version }}- + - name: Check Rust formatting + working-directory: native/gitoxide-helper + run: cargo fmt --check + - name: Test the short-lived Gitoxide helper + working-directory: native/gitoxide-helper + run: cargo test --locked + - name: Report Rust build cache + shell: bash + run: kache report --format github >> "$GITHUB_STEP_SUMMARY" + - name: Save Rust build cache + if: github.ref_name == github.event.repository.default_branch + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/kache + key: ${{ steps.kache-cache.outputs.cache-primary-key }} + - name: Install JavaScript dependencies without packaging hooks + run: npm ci --ignore-scripts + - name: Build the helper invocation owner + run: >- + npm --workspace @maka/core run build && + npm --workspace @maka/storage run build && + npm --workspace @maka/runtime run build && + npm --workspace @maka/runtime-host run build + - name: Test the real helper invocation contract + env: + MAKA_GITOXIDE_HELPER_PATH: ${{ github.workspace }}/native/gitoxide-helper/target/debug/maka-gitoxide-helper${{ runner.os == 'Windows' && '.exe' || '' }} + run: >- + node --test + packages/runtime-host/dist/__tests__/gitoxide-helper-artifact-authority-internal.test.js + packages/runtime-host/dist/__tests__/gitoxide-helper-invocation-internal.test.js + packages/runtime-host/dist/__tests__/gitoxide-repository-admission-authority-internal.test.js diff --git a/.github/workflows/issue-pr-lifecycle.yml b/.github/workflows/issue-pr-lifecycle.yml new file mode 100644 index 0000000000..9515e03821 --- /dev/null +++ b/.github/workflows/issue-pr-lifecycle.yml @@ -0,0 +1,317 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: Issue and PR lifecycle + +on: + schedule: + - cron: "41 3 * * *" + workflow_dispatch: + inputs: + dry_run: + description: Report planned lifecycle changes without writing to GitHub. + required: false + default: true + type: boolean + issues: + types: [reopened] + # Reopen handling mutates labels/comments. Use the trusted base workflow so + # fork PRs cannot force writes through a read-only pull_request token. + pull_request_target: + types: [reopened] + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: issue-pr-lifecycle + cancel-in-progress: false + +jobs: + lifecycle: + if: github.repository == 'apache/maka' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} + with: + script: | + const path = require("node:path") + const { pathToFileURL } = require("node:url") + + const { + LIFECYCLE_LABELS, + STALE_CLOSE_MARKER, + planLifecycle, + } = await import( + pathToFileURL( + path.join(process.env.GITHUB_WORKSPACE, "scripts/issue-pr-lifecycle.mjs"), + ).href + ) + + const { owner, repo } = context.repo + const dryRun = process.env.DRY_RUN === "true" + + const fragments = ` + fragment IssueFields on Issue { + number + state + createdAt + labels(first: 100) { nodes { name } } + assignees(first: 1) { totalCount } + comments(last: 100) { + totalCount + nodes { createdAt body author { __typename login } } + } + } + + fragment PullRequestFields on PullRequest { + number + state + createdAt + labels(first: 100) { nodes { name } } + comments(last: 100) { + totalCount + nodes { createdAt body author { __typename login } } + } + commits(last: 1) { nodes { commit { committedDate } } } + } + ` + + const normalize = (node) => ({ + number: node.number, + state: node.state, + kind: node.__typename === "Issue" ? "issue" : "pull_request", + createdAt: node.createdAt, + lastCommitAt: node.commits?.nodes?.[0]?.commit?.committedDate, + labels: (node.labels?.nodes ?? []).map((label) => label.name), + assigneeCount: node.assignees?.totalCount ?? 0, + comments: node.comments?.nodes ?? [], + commentCount: node.comments?.totalCount ?? node.comments?.nodes?.length ?? 0, + }) + + async function hydrateComments(item) { + if (item.commentCount <= item.comments.length) return item + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: item.number, + per_page: 100, + }) + return { + ...item, + comments: comments.map((comment) => ({ + createdAt: comment.created_at, + body: comment.body, + author: { + __typename: comment.user?.type === "Bot" ? "Bot" : "User", + login: comment.user?.login, + }, + })), + commentCount: comments.length, + } + } + + async function listOpenItems() { + const query = ` + query LifecycleItems($searchQuery: String!, $cursor: String) { + search(query: $searchQuery, type: ISSUE, first: 100, after: $cursor) { + nodes { + __typename + ...IssueFields + ...PullRequestFields + } + pageInfo { hasNextPage endCursor } + } + } + ${fragments} + ` + const items = [] + let cursor + + do { + const result = await github.graphql(query, { + searchQuery: `repo:${owner}/${repo} is:open`, + cursor, + }) + items.push(...result.search.nodes.map(normalize)) + cursor = result.search.pageInfo.hasNextPage + ? result.search.pageInfo.endCursor + : undefined + } while (cursor) + + return items + } + + async function refresh(number) { + const query = ` + query LifecycleItem($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issueOrPullRequest(number: $number) { + __typename + ...IssueFields + ...PullRequestFields + } + } + } + ${fragments} + ` + const result = await github.graphql(query, { owner, repo, number }) + const node = result.repository.issueOrPullRequest + return node ? normalize(node) : undefined + } + + async function ensureLabels() { + for (const label of LIFECYCLE_LABELS) { + try { + await github.rest.issues.getLabel({ owner, repo, name: label.name }) + } catch (error) { + if (error.status !== 404) throw error + if (dryRun) { + core.info(`[dry-run] create label ${label.name}`) + continue + } + await github.rest.issues.createLabel({ owner, repo, ...label }) + core.info(`created label ${label.name}`) + } + } + } + + async function removeStale(number) { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: number, + name: "stale", + }) + } catch (error) { + if (error.status !== 404) throw error + } + } + + async function apply(item, plan, revalidateClose = true) { + const prefix = `${item.kind === "issue" ? "issue" : "PR"} #${item.number}` + core.info(`${dryRun ? "[dry-run] " : ""}${prefix}: ${plan.action} (${plan.reason ?? "policy threshold"})`) + if (dryRun || plan.action === "none") return + + if (plan.action === "warn") { + if (!item.labels.includes("stale")) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: item.number, + labels: ["stale"], + }) + } + await github.rest.issues.createComment({ + owner, + repo, + issue_number: item.number, + body: plan.message, + }) + return + } + + if (plan.action === "unstale") { + await removeStale(item.number) + return + } + + if (plan.action === "close" && revalidateClose) { + let current = await refresh(item.number) + if (!current || current.state !== "OPEN") { + core.info(`${prefix}: close cancelled because the item is no longer open`) + return + } + current = await hydrateComments(current) + const currentPlan = planLifecycle(current) + if (currentPlan.action !== "close") { + core.info(`${prefix}: close cancelled after revalidation`) + await apply(current, currentPlan, false) + return + } + item = current + plan = currentPlan + } + + const alreadyExplained = item.comments.some( + (comment) => + comment.author?.__typename === "Bot" && + comment.body.includes(STALE_CLOSE_MARKER), + ) + if (!alreadyExplained) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: item.number, + body: plan.message, + }) + } + + if (item.kind === "issue") { + await github.rest.issues.update({ + owner, + repo, + issue_number: item.number, + state: "closed", + }) + } else { + await github.rest.pulls.update({ + owner, + repo, + pull_number: item.number, + state: "closed", + }) + } + } + + await ensureLabels() + + if (context.payload.action === "reopened") { + const number = context.payload.issue?.number ?? context.payload.pull_request?.number + if (number !== undefined) { + core.info(`${dryRun ? "[dry-run] " : ""}reopened #${number}: remove stale and reset lifecycle clock`) + if (!dryRun) { + await removeStale(number) + await github.rest.issues.createComment({ + owner, + repo, + issue_number: number, + body: "", + }) + } + return + } + } + + const items = await listOpenItems() + core.info(`evaluating ${items.length} open issues and pull requests`) + for (const item of items) { + const hydrated = await hydrateComments(item) + await apply(hydrated, planLifecycle(hydrated)) + } diff --git a/.github/workflows/model-metadata-upkeep.yml b/.github/workflows/model-metadata-upkeep.yml new file mode 100644 index 0000000000..804ae06203 --- /dev/null +++ b/.github/workflows/model-metadata-upkeep.yml @@ -0,0 +1,163 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: Model metadata upkeep + +on: + schedule: + # Weekly, offset from the hour to reduce peak-time scheduling delays. The + # snapshot is a build input a human reviews, so a nightly cadence would + # only stack five near-identical pull requests against one week of upstream + # movement, on runners the whole foundation shares. + - cron: '41 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: model-metadata-upkeep + cancel-in-progress: false + +jobs: + refresh: + # A fork inherits the schedule but owns neither the branch this pushes nor + # the pull request it opens. + if: github.repository == 'apache/maka' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: write + pull-requests: write + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Report snapshot drift against models.dev + # Exit 2 is "upstream moved", which is the expected outcome and the + # reason this step exists. Any other non-zero status is the command + # itself failing, and the job stops on it. + run: | + npm run --silent check:model-metadata-drift > "$RUNNER_TEMP/drift.txt" || { + status=$? + cat "$RUNNER_TEMP/drift.txt" + [ "$status" -eq 2 ] || exit "$status" + } + + - name: Publish the drift report + run: | + { + echo '### models.dev drift' + echo '' + echo '```text' + cat "$RUNNER_TEMP/drift.txt" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + # --accept-upstream-removals, because the review seat this job is built + # around is the draft pull request below. A person still inspects every + # removal and still decides, in the diff, with the drift report in the + # body; refusing here would only make the job red every week, since it + # cannot rerun itself the way the acknowledgement asks a human to. + - name: Refresh the snapshot from models.dev + run: npm run refresh:model-metadata -- --accept-upstream-removals + + - name: Verify the regenerated outputs + run: npm run check:model-metadata + + - name: Detect a snapshot change + id: change + run: | + if git diff --quiet -- scripts/model-metadata/models-dev-api.snapshot.json; then + echo 'changed=false' >> "$GITHUB_OUTPUT" + else + echo 'changed=true' >> "$GITHUB_OUTPUT" + fi + + - name: Open the review pull request + if: steps.change.outputs.changed == 'true' + env: + BRANCH: automation/model-metadata-refresh + GH_TOKEN: ${{ github.token }} + TITLE: 'chore(model-metadata): refresh the models.dev snapshot' + run: | + { + echo '## Summary' + echo '' + echo 'Scheduled `refresh:model-metadata` run. The snapshot is the build' + echo 'input for the bundled model catalog; this only moves it to what' + echo 'models.dev serves today.' + echo '' + echo 'Refs #4398' + echo '' + echo '## Verification' + echo '' + echo 'The workflow ran `refresh:model-metadata` and `check:model-metadata`' + echo 'before opening this. Drift against upstream at refresh time:' + echo '' + echo '```text' + cat "$RUNNER_TEMP/drift.txt" + echo '```' + } > "$RUNNER_TEMP/pr-body.md" + git config user.name 'Apache Maka' + git config user.email 'commits@maka.apache.org' + git switch -c "$BRANCH" + git add scripts/model-metadata/models-dev-api.snapshot.json + git commit -m "$TITLE" + # The token travels in a header rather than the remote URL, which git + # echoes back in its own error messages. + AUTH="$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')" + REMOTE="https://github.com/${GITHUB_REPOSITORY}" + TIP="$(git -c "http.extraheader=Authorization: Basic $AUTH" \ + ls-remote "$REMOTE" "refs/heads/$BRANCH" | cut -f1)" + if [ -n "$TIP" ]; then + # This branch only ever carries commits this job wrote. Anything + # else is a person working on the open pull request, and a force + # push would erase it. + git -c "http.extraheader=Authorization: Basic $AUTH" \ + fetch --depth=1 "$REMOTE" "refs/heads/$BRANCH" + if [ "$(git log -1 --format=%s FETCH_HEAD)" != "$TITLE" ]; then + echo "::error::$BRANCH carries a commit this workflow did not write; refusing to overwrite it." + exit 1 + fi + git -c "http.extraheader=Authorization: Basic $AUTH" push \ + "--force-with-lease=refs/heads/$BRANCH:$TIP" "$REMOTE" "HEAD:refs/heads/$BRANCH" + else + git -c "http.extraheader=Authorization: Basic $AUTH" push \ + "$REMOTE" "HEAD:refs/heads/$BRANCH" + fi + # A plain existence lookup also succeeds for a closed pull request, + # which would leave a maintainer's decision to close one silently + # disabling this job forever. Only an open one is one to update. + if [ "$(gh pr list --head "$BRANCH" --state open --json number --jq 'length')" -gt 0 ]; then + gh pr edit "$BRANCH" --body-file "$RUNNER_TEMP/pr-body.md" + echo "Updated the open pull request on $BRANCH." + else + gh pr create --draft --base main --head "$BRANCH" \ + --title "$TITLE" --body-file "$RUNNER_TEMP/pr-body.md" + fi diff --git a/.github/workflows/npm-publication.yml b/.github/workflows/npm-publication.yml new file mode 100644 index 0000000000..1fc32f99a1 --- /dev/null +++ b/.github/workflows/npm-publication.yml @@ -0,0 +1,204 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: npm publication +run-name: npm ${{ inputs.channel || 'nightly' }} publication + +on: + schedule: + - cron: '17 18 * * *' + workflow_dispatch: + inputs: + channel: + description: Publication channel + required: true + type: choice + default: nightly + options: + - nightly + - formal + version: + description: Exact formal product version; ignored for Nightly + required: false + type: string + default: '' + +permissions: + contents: read + +concurrency: + group: npm-publication-${{ inputs.channel || 'nightly' }} + cancel-in-progress: false + +jobs: + formal: + if: github.event_name == 'workflow_dispatch' && inputs.channel == 'formal' + permissions: + contents: read + id-token: write + uses: ./.github/workflows/release-cli-stage.yml + with: + version: ${{ inputs.version }} + + identity: + if: vars.NPM_NIGHTLY_ENABLED == 'true' && (github.event_name == 'schedule' || inputs.channel == 'nightly') + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + source_commit: ${{ steps.identity.outputs.source_commit }} + version: ${{ steps.identity.outputs.version }} + steps: + - name: Reject in-place workflow reruns + if: github.run_attempt != 1 + run: | + echo "npm Nightly retries require a fresh workflow dispatch" >&2 + exit 1 + + - name: Check out the scheduled main commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Require the Apache main branch + run: | + test "$GITHUB_REPOSITORY" = apache/maka + test "$GITHUB_REF" = refs/heads/main + + - name: Resolve the immutable Nightly identity + id: identity + env: + GITHUB_SHA: ${{ github.sha }} + GITHUB_RUN_NUMBER: ${{ github.run_number }} + run: node scripts/product-nightly.mjs identity + + cli: + name: Validate npm Nightly + needs: identity + uses: ./.github/workflows/cli-package-validation.yml + with: + source_commit: ${{ needs.identity.outputs.source_commit }} + package_version: ${{ needs.identity.outputs.version }} + + publish: + name: Publish npm Nightly + needs: [identity, cli] + runs-on: ubuntu-24.04 + environment: npm-publication + timeout-minutes: 20 + permissions: + contents: read + id-token: write + steps: + - name: Reject in-place workflow reruns + if: github.run_attempt != 1 + run: | + echo "npm Nightly retries require a fresh workflow dispatch" >&2 + exit 1 + + - name: Check out the Nightly publisher + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.identity.outputs.source_commit }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: npm + + - name: Install publisher dependencies + run: npm ci --ignore-scripts + + - name: Select the publication npm toolchain + run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" + + - name: Download the validated npm Nightly + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.cli.outputs.release_candidate_artifact_id }} + path: packages/cli/release + + - name: Bind the npm Nightly to this workflow run + id: npm-nightly + env: + NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + run: | + node scripts/release-cli-publication.mjs prepare-nightly \ + packages/cli/release \ + "$NIGHTLY_VERSION" \ + "$GITHUB_OUTPUT" + + - name: Require the qualified predecessor and Nightly channel advance + env: + NIGHTLY_VERSION: ${{ steps.npm-nightly.outputs.version }} + PREDECESSOR_VERSION: ${{ needs.cli.outputs.release_predecessor_version }} + PREDECESSOR_TARBALL_URL: ${{ needs.cli.outputs.release_predecessor_tarball_url }} + PREDECESSOR_INTEGRITY: ${{ needs.cli.outputs.release_predecessor_integrity }} + run: | + node scripts/release-cli-publication.mjs assert-nightly-predecessor \ + "$PREDECESSOR_VERSION" \ + "$PREDECESSOR_TARBALL_URL" \ + "$PREDECESSOR_INTEGRITY" + current="$(npm view maka-agent dist-tags.nightly --registry https://registry.npmjs.org/)" + node scripts/product-nightly.mjs assert-channel-advance "$NIGHTLY_VERSION" "$current" + + - name: Publish the exact npm Nightly + env: + NIGHTLY_DIST_TAG: ${{ steps.npm-nightly.outputs.dist_tag }} + NIGHTLY_TARBALL: ${{ steps.npm-nightly.outputs.tarball }} + run: | + test "$NIGHTLY_DIST_TAG" = nightly + npm publish "$NIGHTLY_TARBALL" \ + --tag nightly \ + --registry https://registry.npmjs.org/ \ + --provenance + + - name: Require the public npm Nightly + env: + NIGHTLY_VERSION: ${{ steps.npm-nightly.outputs.version }} + run: | + for attempt in {1..12}; do + published="$(npm view "maka-agent@$NIGHTLY_VERSION" version --registry https://registry.npmjs.org/ 2>/dev/null || true)" + tagged="$(npm view maka-agent dist-tags.nightly --registry https://registry.npmjs.org/ 2>/dev/null || true)" + if [[ "$published" == "$NIGHTLY_VERSION" && "$tagged" == "$NIGHTLY_VERSION" ]]; then + exit 0 + fi + sleep 5 + done + echo "npm Nightly did not become readable with the exact nightly tag" >&2 + exit 1 + + - name: Record the published Product Nightly version + env: + VERSION_DIRECTORY: ${{ runner.temp }}/product-nightly-version + NIGHTLY_VERSION: ${{ steps.npm-nightly.outputs.version }} + run: | + mkdir -p "$VERSION_DIRECTORY" + node scripts/product-nightly.mjs write-version \ + "$VERSION_DIRECTORY/version.txt" \ + "$NIGHTLY_VERSION" + + - name: Hand the exact version to Desktop Nightly + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: product-nightly-version + path: ${{ runner.temp }}/product-nightly-version/version.txt + if-no-files-found: error + compression-level: 0 + retention-days: 30 diff --git a/.github/workflows/pr-effort-label.yml b/.github/workflows/pr-effort-label.yml new file mode 100644 index 0000000000..ac6d32ec8d --- /dev/null +++ b/.github/workflows/pr-effort-label.yml @@ -0,0 +1,134 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: PR effort label + +# Labels a pull request when it first asks for review, then re-checks the whole +# open set once a day. A push changes the diff and so can change the tier, but +# subscribing to every push would run this hundreds of times a day to correct a +# label nobody is misled by in the meantime; the daily sweep absorbs that drift +# and doubles as the recovery path for any event this misses. +on: + pull_request_target: + types: [opened, reopened, ready_for_review] + branches: [main] + schedule: + - cron: "23 4 * * *" + workflow_dispatch: + inputs: + dry_run: + description: Log the tier each open pull request would get without writing labels. + required: false + default: false + type: boolean + +permissions: + contents: read + pull-requests: write + +concurrency: + group: pr-effort-label-${{ github.event.pull_request.number || github.workflow }} + cancel-in-progress: ${{ github.event_name == 'pull_request_target' }} + +jobs: + label: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + + steps: + # pull_request_target runs with a writable token, so the ref is pinned to + # the trusted default-branch commit rather than left to the event's + # default: pull_request_review and friends resolve to refs/pull/N/merge, + # and this job imports the checked-out script. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + DRY_RUN: ${{ inputs.dry_run || false }} + with: + script: | + const path = require("node:path") + const { pathToFileURL } = require("node:url") + + const { planLabels } = await import( + pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, "scripts/pr-effort.mjs")).href + ) + + const { owner, repo } = context.repo + + // Reviewing a tier boundary or an exclusion means seeing it against + // real pull requests, which is this same sweep minus the writes. + const dryRun = process.env.DRY_RUN === "true" + + const targets = context.payload.pull_request + ? [context.payload.pull_request.number] + : ( + await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "open", + per_page: 100, + }) + ).map((pull) => pull.number) + + for (const pull_number of targets) { + const [files, current] = await Promise.all([ + github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number, per_page: 100 }), + github.paginate(github.rest.issues.listLabelsOnIssue, { + owner, + repo, + issue_number: pull_number, + per_page: 100, + }), + ]) + + const plan = planLabels( + files, + current.map((label) => label.name), + ) + + if (dryRun) { + core.info( + `#${pull_number}: ${plan.label} (${plan.lines} readable lines)` + + ` +[${plan.addLabels.join(", ")}] -[${plan.removeLabels.join(", ")}]`, + ) + continue + } + + if (plan.addLabels.length > 0) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pull_number, + labels: plan.addLabels, + }) + } + + for (const name of plan.removeLabels) { + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number: pull_number, name }) + } catch (error) { + // Another run may have removed it first; anything else is real. + if (error.status !== 404) throw error + } + } + + core.info(`#${pull_number}: ${plan.label} (${plan.lines} readable lines)`) + } diff --git a/.github/workflows/release-cli-finalize.yml b/.github/workflows/release-cli-finalize.yml index 7909ce03ca..49c7667a1d 100644 --- a/.github/workflows/release-cli-finalize.yml +++ b/.github/workflows/release-cli-finalize.yml @@ -15,7 +15,7 @@ # specific language governing permissions and limitations # under the License. -name: Finalize CLI npm channel +name: Finalize product release on: workflow_dispatch: @@ -28,6 +28,14 @@ on: description: Successful Stage CLI npm release workflow run attempt required: true type: string + release_run_id: + description: Successful Release workflow run ID that built the Draft assets + required: true + type: string + release_run_attempt: + description: Successful Release workflow run attempt that built the Draft assets + required: true + type: string version: description: Exact staged maka-agent product version required: true @@ -38,7 +46,7 @@ permissions: contents: read concurrency: - group: cli-npm-finalize + group: product-release cancel-in-progress: false jobs: @@ -46,13 +54,20 @@ jobs: name: Verify the public npm channel runs-on: ubuntu-24.04 timeout-minutes: 20 + outputs: + product_tag: ${{ steps.release.outputs.product_tag }} + product_version: ${{ steps.release.outputs.version }} + release_run_id: ${{ steps.release-run.outputs.run_id }} + release_run_attempt: ${{ steps.release-run.outputs.run_attempt }} + source_commit: ${{ steps.release.outputs.source_commit }} + source_reference_tag: ${{ steps.authority.outputs.source_reference_tag }} steps: - name: Require main env: RELEASE_REF: ${{ github.ref }} run: | if [[ "$RELEASE_REF" != "refs/heads/main" ]]; then - echo "CLI npm finalization must be dispatched from main; found $RELEASE_REF" >&2 + echo "Product finalization must be dispatched from main; found $RELEASE_REF" >&2 exit 1 fi @@ -72,6 +87,25 @@ jobs: fi gh api "repos/$GITHUB_REPOSITORY/actions/runs/$STAGE_RUN_ID/attempts/$STAGE_RUN_ATTEMPT" > "$RUNNER_TEMP/stage-run.json" + - name: Load the exact Release workflow run + id: release-run + env: + GH_TOKEN: ${{ github.token }} + RELEASE_RUN_ID: ${{ inputs.release_run_id }} + RELEASE_RUN_ATTEMPT: ${{ inputs.release_run_attempt }} + run: | + if [[ ! "$RELEASE_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then + echo "Release workflow run ID must be a positive integer" >&2 + exit 1 + fi + if [[ ! "$RELEASE_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]; then + echo "Release workflow run attempt must be a positive integer" >&2 + exit 1 + fi + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RELEASE_RUN_ID/attempts/$RELEASE_RUN_ATTEMPT" > "$RUNNER_TEMP/release-run.json" + echo "run_id=$RELEASE_RUN_ID" >> "$GITHUB_OUTPUT" + echo "run_attempt=$RELEASE_RUN_ATTEMPT" >> "$GITHUB_OUTPUT" + - name: Check out the current release verifier uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -96,6 +130,15 @@ jobs: repository: ${{ github.repository }} run-id: ${{ inputs.stage_run_id }} + - name: Download the publication record + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: product-release-record-${{ inputs.release_run_attempt }} + path: ${{ runner.temp }}/product-release-record + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ inputs.release_run_id }} + - name: Verify the stage run and release record id: release env: @@ -108,13 +151,33 @@ jobs: "$GITHUB_OUTPUT" - name: Revalidate the product release authority + id: authority env: GH_TOKEN: ${{ github.token }} PRODUCT_SOURCE_COMMIT: ${{ steps.release.outputs.source_commit }} PRODUCT_TAG: ${{ steps.release.outputs.product_tag }} + RELEASE_RUN_ATTEMPT: ${{ steps.release-run.outputs.run_attempt }} + RELEASE_RUN_ID: ${{ steps.release-run.outputs.run_id }} run: | + node scripts/product-release-authority.mjs verify-build-run \ + "$RUNNER_TEMP/release-run.json" \ + "$PRODUCT_TAG" \ + "$PRODUCT_SOURCE_COMMIT" \ + "$GITHUB_REPOSITORY" \ + "$RELEASE_RUN_ID" \ + "$RELEASE_RUN_ATTEMPT" + source_reference_tag="$(jq -r .head_branch "$RUNNER_TEMP/release-run.json")" + node scripts/product-release-artifacts.mjs inspect-record \ + "$RUNNER_TEMP/product-release-record/product-release.json" \ + "$GITHUB_REPOSITORY" \ + "$PRODUCT_TAG" \ + "$PRODUCT_SOURCE_COMMIT" \ + "$source_reference_tag" \ + "$RELEASE_RUN_ID" \ + "$RELEASE_RUN_ATTEMPT" node scripts/product-release-authority.mjs verify-draft \ "$PRODUCT_TAG" "$PRODUCT_SOURCE_COMMIT" "$GITHUB_REPOSITORY" + echo "source_reference_tag=$source_reference_tag" >> "$GITHUB_OUTPUT" - name: Fetch and verify the public registry bytes run: | @@ -141,3 +204,123 @@ jobs: if-no-files-found: error compression-level: 0 retention-days: 30 + + publish: + name: Publish the attested convenience release + needs: inspect + runs-on: ubuntu-24.04 + timeout-minutes: 30 + environment: + name: product-release + url: https://github.com/apache/maka/releases/tag/${{ needs.inspect.outputs.product_tag }} + permissions: + actions: read + artifact-metadata: write + attestations: write + contents: write + id-token: write + steps: + - name: Check out the current release verifier + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false + + - name: Download the exact verified Release run artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: release-*-${{ needs.inspect.outputs.release_run_attempt }} + path: ${{ runner.temp }}/product-release + merge-multiple: true + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ needs.inspect.outputs.release_run_id }} + + - name: Download the publication record + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: product-release-record-${{ needs.inspect.outputs.release_run_attempt }} + path: ${{ runner.temp }}/product-release-record + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ needs.inspect.outputs.release_run_id }} + + - name: Verify the exact publication input + env: + GH_TOKEN: ${{ github.token }} + PRODUCT_SOURCE_COMMIT: ${{ needs.inspect.outputs.source_commit }} + PRODUCT_SOURCE_REFERENCE_TAG: ${{ needs.inspect.outputs.source_reference_tag }} + PRODUCT_TAG: ${{ needs.inspect.outputs.product_tag }} + RELEASE_RUN_ATTEMPT: ${{ needs.inspect.outputs.release_run_attempt }} + RELEASE_RUN_ID: ${{ needs.inspect.outputs.release_run_id }} + run: | + node scripts/product-release-authority.mjs verify-publication \ + "$PRODUCT_TAG" \ + "$PRODUCT_SOURCE_COMMIT" \ + "$GITHUB_REPOSITORY" \ + "$RUNNER_TEMP/product-release" \ + "$RUNNER_TEMP/product-release-record/product-release.json" \ + "$PRODUCT_SOURCE_REFERENCE_TAG" \ + "$RELEASE_RUN_ID" \ + "$RELEASE_RUN_ATTEMPT" + + - name: Attest the verified convenience artifacts + id: attest + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.0.0 + with: + subject-path: ${{ runner.temp }}/product-release/* + + - name: Verify the issued provenance + env: + ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }} + CERTIFICATE_IDENTITY: https://github.com/${{ github.repository }}/.github/workflows/release-cli-finalize.yml@refs/heads/main + GH_TOKEN: ${{ github.token }} + run: | + verified=0 + while IFS= read -r -d '' artifact; do + gh attestation verify "$artifact" \ + --bundle "$ATTESTATION_BUNDLE" \ + --repo "$GITHUB_REPOSITORY" \ + --cert-identity "$CERTIFICATE_IDENTITY" \ + --cert-oidc-issuer https://token.actions.githubusercontent.com + verified=$((verified + 1)) + done < <(find "$RUNNER_TEMP/product-release" -maxdepth 1 -type f -print0) + if (( verified == 0 )); then + echo "No product release artifacts were verified" >&2 + exit 1 + fi + + - name: Name the offline verification bundle + env: + ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }} + PRODUCT_VERSION: ${{ needs.inspect.outputs.product_version }} + run: >- + cp -- "$ATTESTATION_BUNDLE" + "$RUNNER_TEMP/Maka-${PRODUCT_VERSION}-attestation.sigstore.json" + + - name: Publish the verified convenience release + env: + GH_TOKEN: ${{ github.token }} + PRODUCT_SOURCE_COMMIT: ${{ needs.inspect.outputs.source_commit }} + PRODUCT_SOURCE_REFERENCE_TAG: ${{ needs.inspect.outputs.source_reference_tag }} + PRODUCT_TAG: ${{ needs.inspect.outputs.product_tag }} + PRODUCT_VERSION: ${{ needs.inspect.outputs.product_version }} + RELEASE_RUN_ATTEMPT: ${{ needs.inspect.outputs.release_run_attempt }} + RELEASE_RUN_ID: ${{ needs.inspect.outputs.release_run_id }} + run: | + node scripts/product-release-authority.mjs publish-draft \ + "$PRODUCT_TAG" \ + "$PRODUCT_SOURCE_COMMIT" \ + "$GITHUB_REPOSITORY" \ + "$RUNNER_TEMP/product-release" \ + "$RUNNER_TEMP/product-release-record/product-release.json" \ + "$PRODUCT_SOURCE_REFERENCE_TAG" \ + "$RELEASE_RUN_ID" \ + "$RELEASE_RUN_ATTEMPT" \ + "$RUNNER_TEMP/Maka-${PRODUCT_VERSION}-attestation.sigstore.json" diff --git a/.github/workflows/release-cli-stage.yml b/.github/workflows/release-cli-stage.yml index bda8058009..165bdad170 100644 --- a/.github/workflows/release-cli-stage.yml +++ b/.github/workflows/release-cli-stage.yml @@ -18,7 +18,7 @@ name: Stage CLI npm release on: - workflow_dispatch: + workflow_call: inputs: version: description: Exact maka-agent version from packages/cli/package.json @@ -41,20 +41,28 @@ jobs: source_commit: ${{ steps.product.outputs.source_commit }} product_tag: ${{ steps.product.outputs.product_tag }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out the trusted publisher + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: v${{ inputs.version }} + ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - - name: Require the exact product tag + + - name: Require the Apache main publisher env: RELEASE_REF: ${{ github.ref }} - PRODUCT_TAG: v${{ inputs.version }} + RELEASE_REPOSITORY: ${{ github.repository }} run: | - if [[ "$RELEASE_REF" != "refs/tags/$PRODUCT_TAG" ]]; then - echo "CLI releases must be dispatched from $PRODUCT_TAG; found $RELEASE_REF" >&2 - exit 1 - fi + test "$RELEASE_REPOSITORY" = apache/maka + test "$RELEASE_REF" = refs/heads/main + + - name: Check out the approved product source as data + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: v${{ inputs.version }} + path: product-source + fetch-depth: 1 + persist-credentials: false - name: Resolve the exact product release id: product @@ -62,8 +70,7 @@ jobs: GH_TOKEN: ${{ github.token }} PRODUCT_TAG: v${{ inputs.version }} run: | - source_commit="$(git rev-parse HEAD)" - test "$source_commit" = "$GITHUB_SHA" + source_commit="$(git -C product-source rev-parse HEAD)" node scripts/product-release-authority.mjs verify-draft \ "$PRODUCT_TAG" "$source_commit" "$GITHUB_REPOSITORY" echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT" @@ -73,6 +80,7 @@ jobs: env: GITHUB_SHA: ${{ steps.product.outputs.source_commit }} EXPECTED_PRODUCT_VERSION: ${{ inputs.version }} + PRODUCT_MANIFEST_ROOT: ${{ github.workspace }}/product-source run: node scripts/product-release-identity.mjs validate: @@ -88,15 +96,16 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 environment: - name: npm-release + name: npm-publication url: https://www.npmjs.com/package/maka-agent permissions: contents: read id-token: write steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out trusted staging code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs.authorize.outputs.source_commit }} + ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -119,14 +128,16 @@ jobs: RELEASE_REPOSITORY: ${{ github.repository }} RELEASE_RUN_ID: ${{ github.run_id }} RELEASE_RUN_ATTEMPT: ${{ github.run_attempt }} - RELEASE_SHA: ${{ github.sha }} - RELEASE_WORKFLOW: .github/workflows/release-cli-stage.yml + PRODUCT_SOURCE_SHA: ${{ needs.authorize.outputs.source_commit }} + PUBLISHER_SHA: ${{ github.sha }} + RELEASE_WORKFLOW: .github/workflows/npm-publication.yml run: | node scripts/release-cli-publication.mjs prepare-stage \ packages/cli/release \ "$EXPECTED_VERSION" \ "$PRODUCT_TAG" \ - "$RELEASE_SHA" \ + "$PRODUCT_SOURCE_SHA" \ + "$PUBLISHER_SHA" \ "$RELEASE_RUN_ID" \ "$RELEASE_RUN_ATTEMPT" \ "$RELEASE_REPOSITORY" \ @@ -155,26 +166,28 @@ jobs: echo "## maka-agent@$RELEASE_VERSION staging" echo echo "After this workflow succeeds, review and approve the staged package with 2FA on npmjs.com." - echo "After the package becomes public, run **Finalize CLI npm channel** with:" + echo "After the package becomes public, run **Finalize product release** with:" echo echo "- stage run ID: \`$RELEASE_RUN_ID\`" echo "- stage run attempt: \`$RELEASE_RUN_ATTEMPT\`" echo "- version: \`$RELEASE_VERSION\`" - if [[ "$RELEASE_DIST_TAG" == "latest" ]]; then - echo - echo "Before finalizing this stable release, inspect npm dist-tags. If \`next\` is older than \`latest\`, authenticate interactively with npm and run:" - echo - echo "\`npm dist-tag add \"maka-agent@$RELEASE_VERSION\" next --registry https://registry.npmjs.org/\`" - fi } >> "$GITHUB_STEP_SUMMARY" - name: Submit the candidate to npm staging env: GH_TOKEN: ${{ github.token }} + PREDECESSOR_VERSION: ${{ needs.validate.outputs.release_predecessor_version }} + PREDECESSOR_TARBALL_URL: ${{ needs.validate.outputs.release_predecessor_tarball_url }} + PREDECESSOR_INTEGRITY: ${{ needs.validate.outputs.release_predecessor_integrity }} PRODUCT_SOURCE_COMMIT: ${{ needs.authorize.outputs.source_commit }} PRODUCT_TAG: ${{ needs.authorize.outputs.product_tag }} RELEASE_DIST_TAG: ${{ steps.release.outputs.dist_tag }} RELEASE_TARBALL: ${{ steps.release.outputs.tarball }} run: | + node scripts/release-cli-publication.mjs assert-nightly-predecessor \ + "$PREDECESSOR_VERSION" \ + "$PREDECESSOR_TARBALL_URL" \ + "$PREDECESSOR_INTEGRITY" + node scripts/product-release-authority.mjs verify-draft \ "$PRODUCT_TAG" "$PRODUCT_SOURCE_COMMIT" "$GITHUB_REPOSITORY" diff --git a/.github/workflows/release-windows-check.yml b/.github/workflows/release-windows-check.yml index c8d0c3ae1f..2d7b47f811 100644 --- a/.github/workflows/release-windows-check.yml +++ b/.github/workflows/release-windows-check.yml @@ -32,8 +32,12 @@ on: paths: - 'apps/desktop/electron-builder.config.mjs' - 'apps/desktop/package.json' + - 'native/runtime-host-peer/**' + - 'packages/cli/RUNTIME_HOST_PEER_DEPENDENCIES.rust.tsv' + - 'packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt' - 'scripts/package-windows-x64.mjs' - - 'scripts/verify-windows-sandbox-e2e.mjs' + - 'scripts/generate-runtime-host-peer-dependencies.mjs' + - 'scripts/generate-runtime-host-peer-notices.mjs' - 'scripts/verify-windows-x64.mjs' - 'scripts/verify-windows-sandbox-e2e.mjs' - 'scripts/verify-windows-installer-lifecycle.mjs' @@ -42,11 +46,13 @@ on: - 'scripts/package-windows-autoupdate-next.mjs' # The Abort-path rollback hook ships inside the installer itself. - 'apps/desktop/build/installer.nsh' - # The packaged updater's feed behavior — and the boot wiring that hands - # MAKA_UPDATE_TEST_FEED to it — is only observable on this path. + # The packaged updater's feed behavior is only observable on this path. + # The boot wiring that hands MAKA_UPDATE_TEST_FEED to it is not here: + # that is a few lines, `scripts/update-test-feed-wiring.test.mjs` asserts + # them on every change for free, and naming the file made a 1900-line + # module the single largest source of runs on this 25-minute lane. - 'apps/desktop/src/main/app-update-service.ts' - 'apps/desktop/src/main/main-window.ts' - - 'apps/desktop/src/main/runtime-host-boot.ts' - 'packages/runtime-host/src/client/connect-or-spawn.ts' - 'packages/runtime-host/src/client/launcher.ts' - 'apps/desktop/src/main/windows-maximize-renderer-sync.ts' @@ -56,26 +62,62 @@ on: - 'scripts/npm-spawn.mjs' - 'scripts/generate-third-party-notices.mjs' - 'scripts/generate-windows-cargo-notices.mjs' + - 'scripts/windows-package-source-closure.mjs' + - 'scripts/windows-package-source-closure.test.mjs' + # The packaged worker and its Windows boundary driver are built from + # these sources, so changes here must reach the packaged lifecycle gate. + - 'apps/desktop/scripts/copy-runtime-filesystem-worker.mjs' + - 'packages/runtime/scripts/build-filesystem-worker.mjs' + - 'packages/runtime/src/filesystem-worker/**' + - 'packages/runtime/src/sandbox/**' + - 'packages/runtime/src/path-containment.ts' + - 'packages/runtime/src/sandbox-boundary-path.ts' + - 'packages/runtime/src/apply-patch-file.ts' + - 'packages/runtime/src/child-fd-input.ts' + - 'packages/runtime/src/child-process-lifecycle.ts' + - 'packages/runtime/src/edit-replace.ts' + - 'packages/runtime/src/file-stable-write.ts' + - 'packages/runtime/src/image-file.ts' + - 'packages/runtime/src/process-tree-terminator.ts' + - 'packages/runtime/src/unified-diff.ts' + - 'packages/core/src/absolute-path.ts' + - 'packages/core/src/artifacts.ts' + - 'packages/core/src/attachments.ts' + - 'packages/core/src/permission-profile.ts' + - 'packages/core/src/permission-profile-compiler.ts' + - 'packages/core/src/sandbox-boundary.ts' + - 'packages/core/src/serialized-byte-length.ts' + - 'packages/core/src/windows-path.ts' - 'experiments/windows-sandbox/launcher/**' - 'experiments/windows-sandbox/*.ps1' - '.gitattributes' - 'package.json' - 'package-lock.json' - 'apps/desktop/resources/licenses/cargo/THIRD_PARTY_NOTICES.txt' + - '.github/workflows/release.yml' - '.github/workflows/release-windows-check.yml' + # The list above is a pre-filter, not this lane's import closure, so a + # transitive edit it cannot match would otherwise first be observed on release + # day. Same pairing as the other two Windows lanes. + schedule: + - cron: '17 5 * * *' workflow_dispatch: permissions: contents: read +# Keyed on the pull request number rather than the ref, because `github.ref` is +# refs/heads/main for the nightly and for a dispatch alike: one shared group +# would let a dispatch queue behind the nightly and then be discarded while +# still pending. Same fix the other two Windows lanes already carry. concurrency: - group: release-windows-check-${{ github.ref }} + group: release-windows-check-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: true jobs: package: runs-on: windows-2025 - timeout-minutes: 90 + timeout-minutes: 45 defaults: run: shell: bash @@ -93,6 +135,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Update stable Rust for Desktop native artifacts + run: rustup update stable --no-self-update + - name: Package the Windows installer and ZIP run: npm run package:windows-x64 @@ -101,6 +146,19 @@ jobs: version="$(node -p "require('./apps/desktop/package.json').version")" npm run verify:windows-x64 -- "apps/desktop/release/Maka-${version}-win-x64.exe" + # The baseline is pinned by version, tag, asset name and SHA-256 in + # `scripts/windows-upgrade-baseline.json`, so it is immutable and the + # cache key is that file. Downloading it is this lane's most frequent + # failure — 14 of 36 across 300 runs, always on branches that could not + # have caused it, each false red costing another 25-minute Windows + # runner. A hit skips the network; the step below re-verifies the + # checksum either way, so a corrupt entry still fails loudly. + - name: Restore the pinned Windows upgrade baseline + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: artifacts/windows-upgrade-baseline + key: windows-upgrade-baseline-${{ hashFiles('scripts/windows-upgrade-baseline.json') }} + - name: Download and verify the pinned Windows upgrade baseline id: previous env: @@ -111,6 +169,9 @@ jobs: "$version" artifacts/windows-upgrade-baseline)" echo "exe=$previous_exe" >> "$GITHUB_OUTPUT" + # The pinned release is only one input here. The other is the installer + # this run just built, which this step installs over it, so a pull request + # does decide the outcome. - name: Exercise pinned-version upgrade and uninstall run: | version="$(node -p "require('./apps/desktop/package.json').version")" @@ -128,6 +189,9 @@ jobs: "apps/desktop/release/Maka-${version}-win-x64.exe" \ apps/desktop/release-autoupdate-next + # Consumes only what this run built — the candidate installer and the + # version-bumped one — so it is the pull request's own evidence that the + # `installer.nsh` Abort path still restores the previous installation. - name: Prove deterministic mid-install failure rollback run: | version="$(node -p "require('./apps/desktop/package.json').version")" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32d10e1806..565f571f88 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,16 +29,15 @@ permissions: contents: read concurrency: - group: release + group: product-release cancel-in-progress: false jobs: release-identity: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 outputs: version: ${{ steps.identity.outputs.version }} - is_prerelease: ${{ steps.identity.outputs.is_prerelease }} tag: ${{ steps.identity.outputs.tag }} source_commit: ${{ steps.identity.outputs.source_commit }} source_reference_tag: ${{ steps.identity.outputs.source_reference_tag }} @@ -137,6 +136,9 @@ jobs: - name: Audit shipped desktop closure run: node scripts/audit-shipped-dependencies.mjs + - name: Update stable Rust for Desktop native artifacts + run: rustup update stable --no-self-update + - name: Write App Store Connect API key if: matrix.platform == 'macos' env: @@ -184,6 +186,20 @@ jobs: if: matrix.platform == 'macos' run: npm run verify:macos-arm64 -- "apps/desktop/release/${{ needs.release-identity.outputs.dmg }}" + - name: Build the version-bumped macOS update + if: matrix.platform == 'macos' + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + run: npm run package:macos-autoupdate-next + + - name: Verify macOS automatic update end to end + if: matrix.platform == 'macos' + run: | + npm run verify:macos-autoupdate -- \ + "apps/desktop/release/Maka-${{ needs.release-identity.outputs.version }}-mac-arm64.zip" \ + apps/desktop/release-autoupdate-next + # Windows has no Authenticode certificate yet, so this build is unsigned # and there is nothing to notarize between packaging and verification. - name: Package the Windows installer and ZIP @@ -239,10 +255,10 @@ jobs: - name: Upload the verified release assets uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: release-${{ matrix.platform }} + name: release-desktop-${{ matrix.platform }}-${{ github.run_attempt }} path: ${{ runner.temp }}/release-assets if-no-files-found: error - retention-days: 7 + retention-days: 30 - name: Remove temporary release credentials if: always() && matrix.platform == 'macos' @@ -284,7 +300,7 @@ jobs: - name: Build the CLI production workspace closure run: | - npm --workspace maka-agent run pretest + npm --workspace maka-agent run build:workspace-deps npm --workspace maka-agent run build npm run check:cli-third-party-notices @@ -335,10 +351,10 @@ jobs: - name: Upload the verified CLI release assets uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: release-cli-macos-arm64 + name: release-cli-macos-arm64-${{ github.run_attempt }} path: ${{ runner.temp }}/release-assets if-no-files-found: error - retention-days: 7 + retention-days: 30 - name: Remove temporary release credentials if: always() @@ -348,7 +364,7 @@ jobs: # One draft release carries both platforms, so it is created once, after # every platform has been packaged and verified. needs: [release-identity, desktop, cli-macos-arm64] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: contents: write @@ -367,16 +383,47 @@ jobs: - name: Download the verified release assets uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: release-* + pattern: release-*-${{ github.run_attempt }} path: release-assets merge-multiple: true + - name: Set up the pinned release Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ needs.release-identity.outputs.node_version }} + package-manager-cache: false + + - name: Select the pinned npm release toolchain + run: | + npm install --global --no-audit --no-fund "npm@${{ needs.release-identity.outputs.npm_version }}" + test "$(npm --version)" = "${{ needs.release-identity.outputs.npm_version }}" + + - name: Install the release verifier dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + - name: Verify the exact product artifact manifest + run: node scripts/product-release-artifacts.mjs verify release-assets + + - name: Record the immutable publication evidence + env: + GITHUB_SHA: ${{ needs.release-identity.outputs.source_commit }} + SOURCE_REFERENCE_TAG: ${{ needs.release-identity.outputs.source_reference_tag }} run: | - node scripts/product-release-artifacts.mjs verify release-assets - while IFS= read -r -d '' checksum; do - (cd "$(dirname "$checksum")" && sha256sum -c "$(basename "$checksum")") - done < <(find release-assets -type f -name '*.sha256' -print0) + node scripts/product-release-artifacts.mjs record \ + release-assets \ + "$RUNNER_TEMP/product-release.json" \ + "$GITHUB_REPOSITORY" \ + "$GITHUB_RUN_ID" \ + "$GITHUB_RUN_ATTEMPT" + + - name: Upload the immutable publication evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: product-release-record-${{ github.run_attempt }} + path: ${{ runner.temp }}/product-release.json + if-no-files-found: error + compression-level: 0 + retention-days: 30 - name: Revalidate the live ASF source reference env: @@ -403,7 +450,6 @@ jobs: env: GH_TOKEN: ${{ github.token }} VERSION: ${{ needs.release-identity.outputs.version }} - IS_PRERELEASE: ${{ needs.release-identity.outputs.is_prerelease }} TAG: ${{ needs.release-identity.outputs.tag }} SOURCE_COMMIT: ${{ needs.release-identity.outputs.source_commit }} SOURCE_REFERENCE_TAG: ${{ needs.release-identity.outputs.source_reference_tag }} @@ -414,15 +460,9 @@ jobs: The Windows build is unsigned: SmartScreen warns on first launch, and the download has to be checked against its .sha256 file. - Known limitations: Computer Use and managed-workspace execution are not included in this release. The ASF Desktop artifacts do not distribute a Git runtime." + These files are convenience binaries built from the approved ASF source release; they are not ASF release artifacts. - classification=(--prerelease=false --latest=false) - if [[ "$IS_PRERELEASE" == "true" ]]; then - classification=(--prerelease --latest=false) - elif [[ "$IS_PRERELEASE" != "false" ]]; then - echo "Product prerelease classification must be true or false; found $IS_PRERELEASE" >&2 - exit 1 - fi + Known limitations: Computer Use and Gitoxide-backed workspace execution are not included in this release. The Desktop convenience artifacts do not distribute a Git runtime." if gh release view "$TAG" >/dev/null 2>&1; then is_draft="$(gh release view "$TAG" --json isDraft --jq .isDraft)" @@ -434,7 +474,8 @@ jobs: gh release create "$TAG" \ --draft \ --verify-tag \ - "${classification[@]}" \ + --prerelease=false \ + --latest=false \ --title "Maka ${VERSION}" \ --notes "$notes" fi @@ -444,12 +485,13 @@ jobs: exit 1 fi gh release edit "$TAG" \ - "${classification[@]}" \ + --prerelease=false \ + --latest=false \ --title "Maka ${VERSION}" \ --notes "$notes" actual_prerelease="$(gh release view "$TAG" --json isPrerelease --jq .isPrerelease)" - if [[ "$actual_prerelease" != "$IS_PRERELEASE" ]]; then - echo "Release ${TAG} prerelease state is ${actual_prerelease}, expected ${IS_PRERELEASE}." >&2 + if [[ "$actual_prerelease" != "false" ]]; then + echo "Release ${TAG} must not be a prerelease." >&2 exit 1 fi asset_names="$(gh release view "$TAG" --json assets --jq '.assets[].name')" @@ -482,7 +524,7 @@ jobs: fi done release_state="$(gh release view "$TAG" --json isDraft,isPrerelease --jq '[.isDraft, .isPrerelease] | map(tostring) | join(" ")')" - if [[ "$release_state" != "true $IS_PRERELEASE" ]]; then + if [[ "$release_state" != "true false" ]]; then echo "Release ${TAG} changed state while assets were checked: ${release_state}." >&2 exit 1 fi @@ -490,4 +532,7 @@ jobs: gh release upload "$TAG" "${missing_assets[@]}" fi - echo "Draft release ${TAG} created from ${SOURCE_COMMIT}." >> "$GITHUB_STEP_SUMMARY" + { + echo "Draft release ${TAG} created from ${SOURCE_COMMIT}." + echo "Release workflow run: ${GITHUB_RUN_ID}, attempt: ${GITHUB_RUN_ATTEMPT}." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/runtime-host-owner-platform.yml b/.github/workflows/runtime-host-owner-platform.yml new file mode 100644 index 0000000000..2f1ecaeb4b --- /dev/null +++ b/.github/workflows/runtime-host-owner-platform.yml @@ -0,0 +1,71 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: Runtime Host owner platforms + +on: + pull_request: + branches: [main] + paths: + - '.github/workflows/runtime-host-owner-platform.yml' + - 'package.json' + - 'package-lock.json' + - 'packages/storage/src/process-lifetime-file-update-lock.ts' + - 'packages/runtime-host/src/operator/local-deployment-owner.ts' + - 'packages/runtime-host/src/operator/update-package-evidence.ts' + - 'packages/runtime-host/src/__tests__/local-deployment-owner.test.ts' + - 'packages/runtime-host/src/__tests__/fixtures/local-deployment-owner-claim.ts' + - 'packages/runtime-host/tsconfig.owner-platform.json' + workflow_dispatch: + +concurrency: + group: runtime-host-owner-platform-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + owner: + name: owner (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build Runtime Host test dependencies + run: | + npm --workspace @maka/core run build + npm --workspace @maka/storage run build + npm exec -- tsc --project packages/runtime-host/tsconfig.owner-platform.json + + - name: Test local deployment owner + run: node --test packages/runtime-host/.owner-platform-dist/__tests__/local-deployment-owner.test.js diff --git a/.github/workflows/runtime-host-peer-admission.yml b/.github/workflows/runtime-host-peer-admission.yml new file mode 100644 index 0000000000..0011480711 --- /dev/null +++ b/.github/workflows/runtime-host-peer-admission.yml @@ -0,0 +1,117 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: Runtime Host peer admission + +on: + pull_request: + paths: + - '.github/workflows/runtime-host-peer-admission.yml' + - 'deny.toml' + - 'native/runtime-host-peer/**' + - 'packages/cli/RUNTIME_HOST_PEER_DEPENDENCIES.rust.tsv' + - 'packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt' + - 'scripts/generate-runtime-host-peer-dependencies.mjs' + - 'scripts/generate-runtime-host-peer-notices.mjs' + - 'packages/runtime-host/src/transport/peer-native.ts' + - 'packages/runtime-host/src/server/peer-listener.ts' + push: + branches: + - main + paths: + - '.github/workflows/runtime-host-peer-admission.yml' + - 'deny.toml' + - 'native/runtime-host-peer/**' + - 'packages/cli/RUNTIME_HOST_PEER_DEPENDENCIES.rust.tsv' + - 'packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt' + - 'scripts/generate-runtime-host-peer-dependencies.mjs' + - 'scripts/generate-runtime-host-peer-notices.mjs' + - 'packages/runtime-host/src/transport/peer-native.ts' + - 'packages/runtime-host/src/server/peer-listener.ts' + +permissions: + contents: read + +concurrency: + group: runtime-host-peer-admission-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: quality + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.18.1 + - name: Update stable Rust + run: rustup update stable --no-self-update + - id: rustc + name: Resolve Rust cache version + shell: bash + run: | + echo "version=$(rustc --version | cut -d ' ' -f 2)" >> "$GITHUB_OUTPUT" + echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + { + echo "KACHE_CACHE_DIR=${{ runner.temp }}/kache" + echo "KACHE_RUNTIME_DIR=${{ runner.temp }}/kache-runtime" + echo "RUSTC_WRAPPER=kache" + } >> "$GITHUB_ENV" + - name: Install Kache + uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2 + with: + tool: kache@0.16.0 + - id: kache-cache + name: Restore Rust build cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/kache + key: kache-runtime-host-peer-quality-v0.16.0-${{ runner.os }}-${{ runner.arch }}-rust-${{ steps.rustc.outputs.version }}-${{ steps.rustc.outputs.revision }} + restore-keys: | + kache-runtime-host-peer-quality-v0.16.0-${{ runner.os }}-${{ runner.arch }}-rust-${{ steps.rustc.outputs.version }}- + - name: Check Rust formatting + working-directory: native/runtime-host-peer + run: cargo fmt --check + - name: Lint the native peer + working-directory: native/runtime-host-peer + run: cargo clippy --locked --all-targets -- -D warnings + - name: Install cargo-deny + uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2 + with: + tool: cargo-deny@0.20.2 + - name: Check Cargo dependency inventory + run: npm run check:runtime-host-peer-dependencies + - name: Check Cargo dependency licenses + run: cargo deny --manifest-path native/runtime-host-peer/Cargo.toml --locked --exclude-dev check licenses + - name: Check Cargo dependency notices + run: npm run check:runtime-host-peer-notices + - name: Test the native peer + working-directory: native/runtime-host-peer + run: cargo test --locked + - name: Report Rust build cache + shell: bash + run: kache report --format github >> "$GITHUB_STEP_SUMMARY" + - name: Save Rust build cache + if: github.ref_name == github.event.repository.default_branch + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/kache + key: ${{ steps.kache-cache.outputs.cache-primary-key }} diff --git a/.github/workflows/take.yml b/.github/workflows/take.yml new file mode 100644 index 0000000000..0f2cb5cfe2 --- /dev/null +++ b/.github/workflows/take.yml @@ -0,0 +1,179 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: Assign/unassign the issue via `take` or `untake` comment +on: + issue_comment: + types: created + +permissions: + issues: write + +jobs: + issue_assign: + runs-on: ubuntu-slim + if: (!github.event.issue.pull_request) && (github.event.comment.body == 'take' || github.event.comment.body == 'untake') + concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + queue: max + steps: + - name: Take or untake issue + env: + COMMENT_BODY: ${{ github.event.comment.body }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + USER_LOGIN: ${{ github.event.comment.user.login }} + REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + + API_VERSION="2026-03-10" + ISSUE_ENDPOINT="repos/$REPO/issues/$ISSUE_NUMBER" + ASSIGNEES_ENDPOINT="$ISSUE_ENDPOINT/assignees" + COMMENTS_ENDPOINT="$ISSUE_ENDPOINT/comments" + + post_comment() { + local body="$1" + gh api --silent \ + --method POST \ + --header "X-GitHub-Api-Version: $API_VERSION" \ + "$COMMENTS_ENDPOINT" \ + --raw-field "body=$body" + } + + reply() { + local body="$1" + echo "$body" + post_comment "$body" + } + + fail_with_comment() { + local body="$1" + echo "$body" >&2 + if ! post_comment "$body"; then + echo "Could not post the failure message to issue $ISSUE_NUMBER" >&2 + fi + exit 1 + } + + if [[ "$COMMENT_BODY" == "take" ]]; then + if ! CURRENT_ASSIGNEES="$( + gh api \ + --header "X-GitHub-Api-Version: $API_VERSION" \ + "$ISSUE_ENDPOINT" \ + --jq '[.assignees[].login]' + )"; then + fail_with_comment \ + "I could not inspect the current assignees. Please try again later." + fi + + if jq -e --arg user "$USER_LOGIN" \ + 'any(.[]; . != $user)' <<<"$CURRENT_ASSIGNEES" >/dev/null; then + CURRENT_HOLDERS="$( + jq -r --arg user "$USER_LOGIN" \ + '[.[] | select(. != $user)] | join(", ")' \ + <<<"$CURRENT_ASSIGNEES" + )" + reply \ + "Issue #$ISSUE_NUMBER is already assigned to $CURRENT_HOLDERS, so it was not reassigned." + exit 0 + fi + + if jq -e --arg user "$USER_LOGIN" \ + 'index($user) != null' <<<"$CURRENT_ASSIGNEES" >/dev/null; then + echo "Issue #$ISSUE_NUMBER is already assigned to $USER_LOGIN." + exit 0 + fi + + echo "Assigning issue $ISSUE_NUMBER to $USER_LOGIN" + if ! ASSIGNMENT_RESPONSE="$( + gh api \ + --method POST \ + --header "X-GitHub-Api-Version: $API_VERSION" \ + "$ASSIGNEES_ENDPOINT" \ + --field "assignees[]=$USER_LOGIN" + )"; then + fail_with_comment \ + "I could not assign this issue because the GitHub API request failed. Please try again later." + fi + + if ! jq -e '.assignees | type == "array"' \ + <<<"$ASSIGNMENT_RESPONSE" >/dev/null; then + fail_with_comment \ + "I could not verify the new assignee. Please try again later." + fi + + if ! jq -e --arg user "$USER_LOGIN" \ + '.assignees | map(.login) | index($user) != null' \ + <<<"$ASSIGNMENT_RESPONSE" >/dev/null; then + reply \ + "GitHub did not add $USER_LOGIN as an assignee. The account may not be assignable in this repository." + exit 0 + fi + + if ! jq -e --arg user "$USER_LOGIN" \ + '.assignees | length == 1 and .[0].login == $user' \ + <<<"$ASSIGNMENT_RESPONSE" >/dev/null; then + CURRENT_HOLDERS="$( + jq -r --arg user "$USER_LOGIN" \ + '[.assignees[].login | select(. != $user)] | join(", ")' \ + <<<"$ASSIGNMENT_RESPONSE" + )" + + if ! ROLLBACK_RESPONSE="$( + gh api \ + --method DELETE \ + --header "X-GitHub-Api-Version: $API_VERSION" \ + "$ASSIGNEES_ENDPOINT" \ + --field "assignees[]=$USER_LOGIN" + )"; then + fail_with_comment \ + "Another assignee appeared while take was running, and I could not undo the conflicting assignment." + fi + + if ! jq -e --arg user "$USER_LOGIN" \ + '.assignees | type == "array" and (map(.login) | index($user) == null)' \ + <<<"$ROLLBACK_RESPONSE" >/dev/null; then + fail_with_comment \ + "Another assignee appeared while take was running, and I could not verify that the conflicting assignment was undone." + fi + + reply \ + "Issue #$ISSUE_NUMBER was assigned to $CURRENT_HOLDERS while take was running, so it was not reassigned." + exit 0 + fi + elif [[ "$COMMENT_BODY" == "untake" ]]; then + echo "Unassigning issue $ISSUE_NUMBER from $USER_LOGIN" + if ! UNASSIGNMENT_RESPONSE="$( + gh api \ + --method DELETE \ + --header "X-GitHub-Api-Version: $API_VERSION" \ + "$ASSIGNEES_ENDPOINT" \ + --field "assignees[]=$USER_LOGIN" + )"; then + fail_with_comment \ + "I could not unassign this issue because the GitHub API request failed. Please try again later." + fi + + if ! jq -e --arg user "$USER_LOGIN" \ + '.assignees | type == "array" and (map(.login) | index($user) == null)' \ + <<<"$UNASSIGNMENT_RESPONSE" >/dev/null; then + fail_with_comment \ + "I could not verify that $USER_LOGIN was unassigned. Please try again later." + fi + fi diff --git a/.github/workflows/windows-baseline.yml b/.github/workflows/windows-baseline.yml index a2f812c7a3..8a6fd69cd1 100644 --- a/.github/workflows/windows-baseline.yml +++ b/.github/workflows/windows-baseline.yml @@ -149,7 +149,7 @@ jobs: # Full packages/storage test:dist is ~10 minutes on windows-latest and # mostly duplicates the Linux unit lane. Baseline keeps process/path/ - # lock-sensitive gates here. Release-blocking crash evidence belongs to + # lock-sensitive gates here. Crash and owner-death evidence belongs to # windows-recovery.yml, so this diagnostic lane does not duplicate it. # Bump concurrency carefully — several suites spawn child processes and # fight for disk under high fan-out. @@ -167,7 +167,6 @@ jobs: 'packages/storage/dist/__tests__/write-queue.test.js', 'packages/storage/dist/__tests__/marker-file.test.js', 'packages/storage/dist/__tests__/workspace-root.test.js', - 'packages/storage/dist/__tests__/managed-workspace-owner.test.js', 'packages/storage/dist/__tests__/workspace-identity.test.js' ) node.exe --test --test-concurrency=2 --test-force-exit --test-timeout=60000 ` diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 90dc9b1e5c..cbf1ea3791 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -17,11 +17,137 @@ name: Windows recovery +# The paths below name only what a Windows runner can prove and the required +# `test` lane cannot. Two groups. +# +# First, how the toolchain behaves here: `npm ci` resolution, what the +# dependency patches and the Electron installer produce, what the clean step +# removes, and the Local IPC trust boundary, a PowerShell script with no other +# caller. +# +# Second, every file in the import closure of the suites below that branches on +# `process.platform === 'win32'`. Those branches are skipped on Linux by +# construction, so `test` cannot go red on them however carefully it runs. +# `assertNoWindowsAlternateStreams` is the example that matters: it is the one +# regression this lane has caught that no other lane could have. That second +# group is generated rather than curated, because a hand-picked list cannot be +# shown to be complete and this one has to be. +# +# What is deliberately absent is the portable rest of those workspaces. Every +# source directory in the closure used to be here, which put this lane on 118 of +# the last 200 merges against 40 now. Over 300 runs it never once produced a +# pull-request red that `test` had not already produced on the same commit, +# usually at the very same step — the portable half of these suites is ordinary +# TypeScript, so a defect in it fails on Linux first and blocks the merge there. +# +# The wide filter also did not prevent the one regression it is tempting to +# cite. #4400 changed `packages/storage/src/**`, matched that filter, ran this +# lane three times on the pull request, and passed all three; the alternate +# stream failure appeared only on the main push afterwards. That is a stale-base +# interaction, which no path filter can see and which the unfiltered push below +# exists to catch. That trigger stays unfiltered for the same reason +# `required_status_checks` is `strict: false`, and because GitHub evaluates a +# path filter against the first 300 files of a diff only — a repository-wide +# sweep being exactly the change that touches every recovery authority at once. on: + pull_request: + branches: [main] + paths: + - 'package-lock.json' + - 'patches/**' + - 'scripts/apply-dependency-patches.mjs' + - 'scripts/install-electron-with-retry.mjs' + - 'scripts/run-electron-installer.cjs' + - 'scripts/clean-build.mjs' + - 'scripts/clean-paths.mjs' + - 'scripts/windows-runtime-host-local-ipc-trust.ps1' + # Every file in the import closure of the suites below that branches on + # `process.platform === 'win32'`. Generated, not curated: + # `scripts/windows-package-source-closure.test.mjs` recomputes this set + # from the dist tests the steps run and fails on any difference in either + # direction. + - 'packages/core/src/diagnostic-log.ts' + - 'packages/runtime-host/src/__tests__/control-endpoint.test.ts' + - 'packages/runtime-host/src/__tests__/fixtures/endpoint-hygiene.ts' + - 'packages/runtime-host/src/__tests__/skill-catalog-protocol.test.ts' + - 'packages/runtime-host/src/__tests__/skill-catalog-repository.test.ts' + - 'packages/runtime-host/src/__tests__/skill-catalog-transaction.test.ts' + - 'packages/runtime-host/src/__tests__/skill-catalog-two-client-uds.test.ts' + - 'packages/runtime-host/src/client/client-instance-identity.ts' + - 'packages/runtime-host/src/client/host-profile.ts' + - 'packages/runtime-host/src/client/ssh-tunnel.ts' + - 'packages/runtime-host/src/client/wsl-control.ts' + - 'packages/runtime-host/src/control/access-credential-delivery.ts' + - 'packages/runtime-host/src/control/endpoint.ts' + - 'packages/runtime-host/src/control/registration.ts' + - 'packages/runtime-host/src/control/startup-diagnostic.ts' + - 'packages/runtime-host/src/operator/local-deployment-owner.ts' + - 'packages/runtime-host/src/operator/managed-deployment.ts' + - 'packages/runtime-host/src/peer-mesh/owner.ts' + - 'packages/runtime-host/src/peer-mesh/store.ts' + - 'packages/runtime-host/src/protocol/host-status.ts' + - 'packages/runtime-host/src/protocol/skill-catalog.ts' + - 'packages/runtime-host/src/server/access-credential-store.ts' + - 'packages/runtime-host/src/server/skill-catalog-repository.ts' + - 'packages/runtime-host/src/server/skill-catalog-transaction.ts' + - 'packages/runtime/src/__tests__/runtime-continuation-crash.test.ts' + - 'packages/runtime/src/__tests__/runtime-resume-crash.test.ts' + - 'packages/runtime/src/builtin-tools.ts' + - 'packages/runtime/src/file-stable-write.ts' + - 'packages/runtime/src/filesystem-worker/client.ts' + - 'packages/runtime/src/filesystem-worker/launch-spec.ts' + - 'packages/runtime/src/filesystem-worker/process-runner.ts' + - 'packages/runtime/src/pipe-process-driver.ts' + - 'packages/runtime/src/process-tree-terminator.ts' + - 'packages/runtime/src/pty-process-driver.ts' + - 'packages/runtime/src/sandbox-boundary-declaration.ts' + - 'packages/runtime/src/sandbox/default-sandbox-manager.ts' + - 'packages/runtime/src/sandbox/sandbox-manager.ts' + - 'packages/runtime/src/sandbox/windows-profile.ts' + - 'packages/runtime/src/sandbox/windows-sandbox.ts' + - 'packages/runtime/src/shell-detect.ts' + - 'packages/runtime/src/shell-exec.ts' + - 'packages/storage/src/__tests__/managed-dependency-environment.test.ts' + - 'packages/storage/src/__tests__/root-authority.test.ts' + - 'packages/storage/src/artifact-store.ts' + - 'packages/storage/src/artifact-writer-bootstrap-lock.ts' + - 'packages/storage/src/artifact-writer-lock.ts' + - 'packages/storage/src/credential-store.ts' + - 'packages/storage/src/file-lifetime-owner.ts' + - 'packages/storage/src/managed-dependency-environment.ts' + - 'packages/storage/src/marker-file.ts' + - 'packages/storage/src/memory-bundle-io.ts' + - 'packages/storage/src/native-file-lock.ts' + - 'packages/storage/src/root-authority.ts' + - 'packages/storage/src/runtime-policy/document-io.ts' + - 'packages/storage/src/sqlite-long-term-memory-store.ts' + - 'packages/storage/src/stable-storage.ts' + - '.github/workflows/windows-recovery.yml' + # Unfiltered on purpose: required_status_checks is `strict: false`, so a pull + # request goes green against a stale base and only the merged result proves + # two independently green halves still agree. It is also the backstop for the + # 300-file case above. The nightly alone would find either a day later, + # against a batch of commits instead of one. + push: + branches: [main] + schedule: + # Offset from windows-sandbox-w0 so the Windows lanes do not overlap. + - cron: '17 8 * * *' workflow_dispatch: +# Pull request pushes supersede each other, keyed on the pull request number +# because github.head_ref is a bare branch name two forks can share: a second +# contributor pushing to their own `main` would otherwise cancel the first +# contributor's run, and a cancelled check is not a failed one. +# Scheduled, manual and main-push runs fall back to the run id, since +# github.ref is refs/heads/main for all three and one shared group would let a +# dispatch queue behind the nightly and then be discarded while still pending. +# That deliberately stops main pushes from superseding each other: this lane is +# the only place a merged Windows regression is observed, so every merge needs +# its own evidence rather than only the newest one surviving. concurrency: - group: windows-recovery-${{ github.ref }} + group: windows-recovery-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true permissions: contents: read @@ -30,7 +156,7 @@ jobs: windows_recovery: name: windows_recovery runs-on: windows-latest - timeout-minutes: 30 + timeout-minutes: 45 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -47,12 +173,60 @@ jobs: - name: Build test artifacts run: npm.cmd run build:test + - name: Verify managed dependency alternate streams + shell: pwsh + run: | + node.exe --test --test-reporter=tap --test-concurrency=1 ` + --test-name-pattern="NTFS alternate stream" ` + packages/storage/dist/__tests__/managed-dependency-environment.test.js ` + 2>&1 | Tee-Object -FilePath "$env:RUNNER_TEMP/managed-dependency-ads.tap" + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { exit $exitCode } + $output = Get-Content "$env:RUNNER_TEMP/managed-dependency-ads.tap" + if ($output -notcontains '# tests 3' -or $output -notcontains '# pass 3' -or $output -notcontains '# skipped 0') { + Write-Error 'Managed dependency ADS gate did not run exactly three passing Windows tests' + exit 1 + } + + - name: Verify root initialization replacement race + shell: pwsh + run: | + node.exe --test --test-reporter=tap --test-concurrency=1 ` + --test-name-pattern="rejects replacement before opening the temporary marker" ` + packages/storage/dist/__tests__/root-authority.test.js ` + 2>&1 | Tee-Object -FilePath "$env:RUNNER_TEMP/root-initialization-race.tap" + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { exit $exitCode } + $output = Get-Content "$env:RUNNER_TEMP/root-initialization-race.tap" + if ($output -notcontains '# tests 1' -or $output -notcontains '# pass 1' -or $output -notcontains '# skipped 0') { + Write-Error 'Root initialization race gate did not run exactly one passing Windows test' + exit 1 + } + - name: Verify Runtime Host Local IPC trust boundary shell: pwsh run: | node.exe --test packages/runtime-host/dist/__tests__/control-endpoint.test.js ./scripts/windows-runtime-host-local-ipc-trust.ps1 + - name: Verify Skill catalog on Windows + shell: pwsh + run: | + node.exe --test --test-reporter=tap --test-concurrency=1 ` + packages/runtime-host/dist/__tests__/skill-catalog-coordinator.test.js ` + packages/runtime-host/dist/__tests__/skill-catalog-protocol.test.js ` + packages/runtime-host/dist/__tests__/skill-catalog-repository.test.js ` + packages/runtime-host/dist/__tests__/skill-catalog-transaction.test.js ` + packages/runtime-host/dist/__tests__/skill-catalog-two-client-uds.test.js ` + 2>&1 | Tee-Object -FilePath "$env:RUNNER_TEMP/skill-catalog.tap" + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { exit $exitCode } + $output = Get-Content "$env:RUNNER_TEMP/skill-catalog.tap" + if ($output -notcontains '# tests 91' -or $output -notcontains '# pass 91' -or $output -notcontains '# skipped 0') { + Write-Error 'Skill catalog gate did not run exactly 91 passing Windows tests' + exit 1 + } + - name: Verify SQLite crash recovery shell: pwsh run: | @@ -82,20 +256,3 @@ jobs: Write-Error 'Runtime Host recovery gate did not run exactly two passing tests' exit 1 } - - - name: Verify managed workspace crash recovery - shell: pwsh - run: | - $env:MAKA_STORAGE_STRESS = '1' - node.exe --test --test-reporter=tap --test-concurrency=1 ` - --test-name-pattern="real process crash|real crash|real-process crash|crash after baseline ref publication" ` - packages/storage/dist/__tests__/managed-workspace-baseline.test.js ` - packages/storage/dist/__tests__/git-workspace-service.test.js ` - 2>&1 | Tee-Object -FilePath "$env:RUNNER_TEMP/managed-workspace-recovery.tap" - $exitCode = $LASTEXITCODE - if ($exitCode -ne 0) { exit $exitCode } - $output = Get-Content "$env:RUNNER_TEMP/managed-workspace-recovery.tap" - if ($output -notcontains '# tests 12' -or $output -notcontains '# pass 12' -or $output -notcontains '# skipped 0') { - Write-Error 'Managed workspace recovery gate did not run exactly twelve passing tests' - exit 1 - } diff --git a/.github/workflows/windows-sandbox-w0.yml b/.github/workflows/windows-sandbox-w0.yml index 6ac97ef2b2..d20131f0cc 100644 --- a/.github/workflows/windows-sandbox-w0.yml +++ b/.github/workflows/windows-sandbox-w0.yml @@ -48,7 +48,7 @@ jobs: protocol: name: windows_sandbox_w0_protocol runs-on: windows-2025 - timeout-minutes: 25 + timeout-minutes: 45 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -57,6 +57,29 @@ jobs: with: node-version: '24' cache: npm + - id: rustc + name: Resolve Rust cache version + shell: bash + run: | + echo "version=$(rustc --version | cut -d ' ' -f 2)" >> "$GITHUB_OUTPUT" + echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + { + echo "KACHE_CACHE_DIR=${{ runner.temp }}/kache" + echo "KACHE_RUNTIME_DIR=${{ runner.temp }}/kache-runtime" + echo "RUSTC_WRAPPER=kache" + } >> "$GITHUB_ENV" + - name: Install Kache + uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2 + with: + tool: kache@0.16.0 + - id: kache-cache + name: Restore Rust build cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/kache + key: kache-windows-sandbox-launcher-v0.16.0-${{ runner.os }}-${{ runner.arch }}-rust-${{ steps.rustc.outputs.version }}-${{ steps.rustc.outputs.revision }} + restore-keys: | + kache-windows-sandbox-launcher-v0.16.0-${{ runner.os }}-${{ runner.arch }}-rust-${{ steps.rustc.outputs.version }}- - name: Record atomic launcher capability shell: pwsh run: ./experiments/windows-sandbox/atomic-launch-capability.ps1 @@ -69,6 +92,15 @@ jobs: - name: Verify launcher protocol and broker authorization working-directory: experiments/windows-sandbox/launcher run: cargo test --locked + - name: Report Rust build cache + shell: bash + run: kache report --format github >> "$GITHUB_STEP_SUMMARY" + - name: Save Rust build cache + if: github.ref_name == github.event.repository.default_branch + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/kache + key: ${{ steps.kache-cache.outputs.cache-primary-key }} - name: Verify secure broker pipe shell: pwsh run: ./experiments/windows-sandbox/broker-pipe-smoke.ps1 diff --git a/.gitignore b/.gitignore index 0f6f738912..46829faa83 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ storybook-static # `link-workspace-packages` setting. pnpm-lock.yaml *.tsbuildinfo +/packages/core/src/model-metadata.generated.ts +/packages/runtime/src/telemetry/model-pricing.generated.ts __pycache__/ *.pyc .agents-workspace-data @@ -24,6 +26,10 @@ docs/assets/ apps/desktop/tests/real-window-smoke/ deepseek.key +# Built only by the dedicated Gitoxide helper lane; normal workspace tests do not use Cargo. +/native/gitoxide-helper/target/ +/native/runtime-host-peer/target/ + # Generated Computer Use executor binary; provenance metadata stays tracked. apps/desktop/resources/bin/ # Rebuilt from experiments/windows-sandbox by scripts/package-windows-x64.mjs. @@ -33,6 +39,8 @@ apps/desktop/bundled-git.json # Generated desktop release inputs and outputs. apps/desktop/resources/tools/ apps/desktop/release/ +apps/desktop/release-autoupdate-next/ apps/desktop/release-sources/ packages/cli/release/ +packages/cli/.development/ release/asf/ diff --git a/.maka-shots/workhub-context-after.png b/.maka-shots/workhub-context-after.png new file mode 100644 index 0000000000..8b82852c06 Binary files /dev/null and b/.maka-shots/workhub-context-after.png differ diff --git a/.maka-shots/workhub-context-before.png b/.maka-shots/workhub-context-before.png new file mode 100644 index 0000000000..a2903e2e52 Binary files /dev/null and b/.maka-shots/workhub-context-before.png differ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8973fd8746..07bafbf32d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -27,7 +27,7 @@ Maka has one execution authority: Runtime Host. Desktop, TUI, CLI, bots, and eva flowchart LR C["Desktop / TUI / CLI / Bot"] --> H["Runtime Host"] H --> S["SessionManager"] - S --> R["AgentRun + Runtime Runner"] + S --> R["AgentRun + RuntimeKernel"] R --> T["Tool Runtime"] R --> L["Runtime Event Log"] S --> G["Agent Graph Control Plane"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 99af10ef4c..acffc8fe45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,8 +28,26 @@ - Added `/transcript` to browse long TUI sessions without depending on terminal scrollback, with line, page, and first/last navigation. +### Fixed + +- Fixed a renderer crash dialog reporting React error #185 ("Maximum update depth + exceeded") coming from the composer's prompt-history inline completion (#4117): the + offer engine the 0.1.11 composer fed could flip-flop its announcement state on + real-layout measurements until React hit its nested-update limit, which surfaced as + the crash dialog. The unstable completion wiring was removed from the composer + (#3292), Astryx 0.5.0 no longer ships the engine (#3755), and regression tests now + keep that seam closed. + ### Changed +- Made typed `request()` the sole direct Runtime Host operation API; removed the 17 forwarding + aliases from direct and reconnecting connections while preserving status validation, + subscriptions, capabilities, listeners, lifecycle, and close behavior. +- Collapsed the RuntimeRunner/Flow/Invocation shell into `RuntimeKernel`; backend dispatch, + terminal coalescing, stop/drain, and durable continuation admission now have one production + owner, immutable request snapshots remain enforced at AgentRun acceptance and backend dispatch, + and SessionEvent-to-RuntimeEvent conversion remains a pure mapper. +- Retired the Task Ledger domain: SessionTodo is now the sole authority for in-session work items, and the operational-state schema drops the `workflow_task_ledger_events` table on first open. **Unfinished Tasks are not migrated and are permanently deleted.** This affects workspaces last opened by `v0.1.0` through `v0.1.11`, `cli-v0.1.0-beta.1`, `v0.2.0-incubating-rc1`, or a `v0.2.0-dev` build; those releases wrote Tasks to a table that no shipped build ever bridged into SessionTodo. Before opening such a workspace with this build, finish or export the Tasks you still need, or copy the workspace's `runtime.sqlite` aside — the migration removes the only live copy, so afterwards recovery requires a backup made in advance. - Unified context management under one Runtime-owned policy. `MAKA_CONTEXT_*` environment overrides no longer tune or disable compaction and Tool Result pruning; model-visible archive placeholders are read on demand through bounded `ArchiveRead` calls instead of eager hydration. Previously supported overrides are ignored on upgrade: if Tool Result pruning was set to `off`, pruning is re-enabled, and there is currently no supported replacement opt-out. ## 0.1.11 - 2026-08-18 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1deb36ef9f..89ec9df3eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,6 +25,8 @@ Bug fixes, model provider support, tests, performance work, and documentation merge most readily. Pick something up from [`help wanted`](https://github.com/apache/maka/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) · [`good first issue`](https://github.com/apache/maka/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) · [`bug`](https://github.com/apache/maka/issues?q=is%3Aissue+is%3Aopen+label%3Abug) · [`enhancement`](https://github.com/apache/maka/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement) and claim it in a comment. Use the **Bug report** or **Feature request** template for new issues; report security problems through [SECURITY.md](./SECURITY.md), never as a public issue. Questions, ideas, and not-yet-actionable proposals belong in [Discussions](https://github.com/apache/maka/discussions), which reaches the whole team by email. +To self-assign an unclaimed issue, post a comment whose entire body is exactly `take`; post `untake` to remove your assignment. Other claim text does not trigger the workflow. + Project direction, governance, and material product decisions are discussed publicly on [`dev@maka.apache.org`](https://lists.apache.org/list.html?dev@maka.apache.org) before implementation; implementation-level decisions may live in the pull request. ## Human ownership and AI attribution @@ -43,14 +45,14 @@ Submit only work you have the right to contribute, and record third-party source ## Quick start -Requires Node `>=22.19.0` and npm `11.19.0` (root `package.json`); desktop work needs macOS Apple Silicon. +Requires Node `>=22.19.0` and npm `11.19.0` (root `package.json`). Direct Peer or Peer Mesh Desktop development additionally needs Rust stable 1.98 or newer and Xcode Command Line Tools on macOS, or MSVC Build Tools on Windows. ```sh git clone https://github.com/apache/maka.git cd maka npm install # root only — never inside a workspace npm run build # builds every workspace in dependency order -npm --workspace @maka/core test +npm --workspace @maka/core run test:dist ``` ## Developing Maka @@ -58,10 +60,10 @@ npm --workspace @maka/core test ```sh npm run dev # desktop app with HMR npm run cli:dev # TUI; `npm run cli:dev -- run "…"` runs one non-interactive turn -npm test # all workspaces, or: npm --workspace @maka/core test +npm test # all workspaces, or: npm --workspace @maka/core run test:dist ``` -Building a single workspace only succeeds when its dependencies are already built — when unsure, build from the root. Tests run against compiled output in `dist/`; each workspace's `test` script cleans, builds, then runs `node --test`. Always go through it. +Building a single workspace only succeeds when its dependencies are already built — when unsure, build from the root. Tests run against compiled output in `dist/`, so `test:dist` covers whatever the last build produced; rebuild before running it. `npm test` from the root does both. Before pushing, match CI locally: diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md index b945843117..f7c4d3d041 100644 --- a/CONTRIBUTING.zh-CN.md +++ b/CONTRIBUTING.zh-CN.md @@ -25,6 +25,8 @@ 缺陷修复、模型供应商支持、测试、性能优化和文档最容易被合并。想找活干,从 [`help wanted`](https://github.com/apache/maka/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) · [`good first issue`](https://github.com/apache/maka/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) · [`bug`](https://github.com/apache/maka/issues?q=is%3Aissue+is%3Aopen+label%3Abug) · [`enhancement`](https://github.com/apache/maka/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement) 里挑一个,留言认领。提 issue 走 **Bug report** 或 **Feature request** 模板;安全问题走 [SECURITY.md](./SECURITY.md) 的私密流程,不要开公开 issue。提问、想法和还不成熟的提案发到 [Discussions](https://github.com/apache/maka/discussions)——它会自动进到大家的邮箱,比 issue 更容易被看到。 +若要自助认领一个尚未分配的 issue,请单独评论 `take`(评论正文只能是这个单词);评论 `untake` 可以解除自己的认领。其他认领文字不会触发该工作流。 + 项目方向、治理和重大产品决策在实施前于开发邮件列表 [`dev@maka.apache.org`](https://lists.apache.org/list.html?dev@maka.apache.org) 上公开讨论;实现层面的技术决策可以在 PR 中讨论。 ## 人类责任与 AI 归因 @@ -43,14 +45,14 @@ ## 快速开始 -需要 Node `>=22.19.0` 和 npm `11.19.0`(见根 `package.json`);桌面端开发需要 macOS Apple Silicon。 +需要 Node `>=22.19.0` 和 npm `11.19.0`(见根 `package.json`)。开发 Desktop Direct Peer 或 Peer Mesh 还需要 Rust stable 1.98 或更高版本,以及 macOS 的 Xcode Command Line Tools 或 Windows 的 MSVC Build Tools。 ```sh git clone https://github.com/apache/maka.git cd maka npm install # 只在根目录装 —— 不要在某个 workspace 里跑 npm run build # 按依赖顺序构建全部 workspace -npm --workspace @maka/core test +npm --workspace @maka/core run test:dist ``` ## 开发 @@ -58,10 +60,10 @@ npm --workspace @maka/core test ```sh npm run dev # 带 HMR 的桌面应用 npm run cli:dev # TUI;`npm run cli:dev -- run "…"` 非交互地跑一个 Turn -npm test # 全部 workspace,或:npm --workspace @maka/core test +npm test # 全部 workspace,或:npm --workspace @maka/core run test:dist ``` -只有依赖都已构建好时,单独构建某个 workspace 才会成功——拿不准就从根目录构建。测试跑的是 `dist/` 里的编译产物;每个 workspace 的 `test` 脚本都会先清理、再构建,然后执行 `node --test`。务必走它。 +只有依赖都已构建好时,单独构建某个 workspace 才会成功——拿不准就从根目录构建。测试跑的是 `dist/` 里的编译产物,`test:dist` 覆盖的是最近一次构建的结果,跑之前先重新构建。根目录的 `npm test` 会把两步都做掉。 推送前先在本地对齐 CI: diff --git a/DESIGN.md b/DESIGN.md index 8918cf1d14..452e810153 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -113,6 +113,7 @@ Prose uses exactly three tiers — `--foreground`, `--foreground-secondary`, `-- - **The Three-Tier Reading Rule.** Prose uses primary, secondary, or muted. Neutral washes are surfaces, not extra text tiers. `--foreground-dimmed` is retired — its call sites name secondary directly — and must never come back with a definition of its own (contract-tested). - **The One Colorspace Rule.** Every derivation inside a token family uses one colorspace (`oklch` for ink, contract-tested). Mixing `srgb` and `oklch` derivations produces "same literal, different value" drift, and the ladder is where it bites hardest because its rungs only mean anything relative to each other. - **Astryx's `secondary` is not this ladder's secondary.** `Text color="secondary"` reads `--color-text-secondary`, which resolves to a fixed neutral literal outside the containers `astryx-mount.css` bridges and to `--muted-foreground` inside them. Neither is `--foreground-secondary`. One word, three results — so pick an ink tier by the token, and treat a primitive's color prop as its own vocabulary. +- **`--color-text-disabled` is a deliberate exemption, not an oversight.** Astryx's `light-dark(#a3a3a3, #525252)` measures 2.52:1 / 2.29:1 — under the AA floor the three prose tiers hold. It stays: a disabled control read at prose contrast stops reading as disabled, and the versions of this token that clear 4.5:1 looked wrong beside the enabled rows they sit in. It is not prose and it is not a fourth tier; it is the one place the floor is traded for the state being legible *as a state*. Do not "fix" it, and do not cite it as precedent for another sub-AA value — an audit that finds it has found the exemption, not a bug. - **Links use the solid accent tier** (`--accent-solid`), never raw `--accent` — the accent identifies interaction, and the solid tier is the only accent variant that clears text contrast on every palette. A selection wash or outline is not link text and derives from `--accent` instead — deriving a surface from the link name lets a text-contrast rule silently govern a background. ## 4. Borders @@ -144,12 +145,12 @@ Nothing interactive is square. One ladder, assigned monotonically by box height: | Radius | Maka tier | Astryx tier | Assign to | |---|---|---|---| | 6px | control | inner | chips, keycaps, nested inlays, and product-drawn compact controls | -| 10px | card | element | cards, rows-as-cards, list containers; Astryx `Button`, `Input`, `SegmentedControl` | -| 12px | container | container | modals, panels, portal surfaces; Astryx `Card`, `Dialog`, `DropdownMenu` | +| 10px | surface | element | cards, rows-as-cards, list containers; Astryx `Button`, `Input`, `SegmentedControl` | +| 12px | modal | container | modals, panels, portal surfaces; Astryx `Card`, `Dialog`, `DropdownMenu` | | 28px | chat | chat | the conversation surface as one shape: user bubble, assistant bubble, composer dock. Sourced from Astryx's `--radius-chat` by `ChatMessageBubble` and `ChatComposer` themselves; product CSS never restates it | -| full | pill (999px) | full (9999px) | badges, pills, circular controls | +| pill | pill | full | badges, pills, circular controls | -- **The Two-Name Rule.** These are one ladder under two vocabularies, and the names never line up: Maka's `control` is Astryx's `inner`, Maka's `card` is Astryx's `element`, Maka's `modal` is Astryx's `container`. Resolve a tier from the box, never from the token name that sounds right. The paired values agree *today* but are independent literals, not aliases — an Astryx upgrade can move one side silently, so a mismatch is a real failure mode rather than an impossibility. The chat rung is the one tier the product does not assign: `ChatMessageBubble` and `ChatComposer` both resolve `--radius-chat` on their own, which is why the bubble and the dock round together, and why setting a bubble radius or a non-default `density` in product code silently breaks the pair. Astryx's `--radius-page` carries the same 28px literal but is a different token with no Maka tier and no product consumer; reaching for it to match the chat surface is inventing a rung. +- **The Two-Name Rule.** These are one ladder under two vocabularies, and the names never line up: Maka's `control` is Astryx's `inner`, Maka's `surface` is Astryx's `element`, Maka's `modal` is Astryx's `container`, Maka's `pill` is Astryx's `full`. Resolve a tier from the box, never from the token name that sounds right. **The px side is the authority and the Astryx name is an alias to it** — `makaTheme.ts` emits `--radius-element: var(--radius-surface)` and its three siblings, so an upstream rung change cannot move one name out from under the other, and the ladder cannot be half in px and half in rem (which the 16px-root note in that file is the reason to avoid). Both vocabularies are live in product CSS and both are correct to read; they resolve to the same number by construction rather than by coincidence. The chat rung is the one tier the product does not assign: `ChatMessageBubble` and `ChatComposer` both resolve `--radius-chat` on their own, which is why the bubble and the dock round together, and why setting a bubble radius or a non-default `density` in product code silently breaks the pair. Astryx's `--radius-page` carries the same 28px literal but is a different token with no Maka tier and no product consumer; reaching for it to match the chat surface is inventing a rung. - **The Full-Bleed Rule.** `border-radius: 0` is legal only on true full-bleed rows — an element flush with its container on both sides. Radius and gap move together: if it has breathing room, it has corners. - **Proportional marks.** Product-drawn icon plates use ratio-owned radius (~25–27% of the box edge), recorded in prose because Stitch accepts only absolute units. @@ -169,14 +170,17 @@ Use the system UI stack with explicit platform CJK fallbacks; Geist Variable is **The Four-Pixel Line Rule.** Text line boxes land on the 4px grid. Mono is technical, never decorative. +**The One Measure Rule.** Line length is a typographic decision, so the app has one reading column and one token for it: `--maka-reading-measure`. The container holds it — a turn, the composer, a report — and no component carries a measure of its own, because a measure inside a component is one its container cannot override. Two measures on one column is what the reader sees as two right edges. + ## 8. Color Specification The palette is cool-neutral and quiet; color is generated to spec, not picked by eye. - **Brand mark** is fixed `#71a8fd`; it identifies Maka and is never the general CTA color. - **Interaction accent** follows the active palette for focus, selection, and live state; **links and accent-colored text use the solid tier** (§3). Astryx's own semantic components are the exception — `Badge` and `StatusDot` carry fixed literals inherited from the neutral theme and follow neither the palette nor the families below. -- **Status families** — `--info`, `--success`, `--warning`, `--destructive` — are generated, not picked: one lightness per mode, each hue keeping its own chroma, every member clearing AA. The residual contrast spread within a mode is hue physics — at equal lightness, yellow carries more luminance than blue — and flattening it would abandon the shared-lightness premise that makes them a family. All four are declared in one block per mode, because the time warning sat thirty lines from its siblings is the time it lost its dark override and dropped under AA unnoticed. A louder band at ~90% gamut chroma exists only for 8px status dots: dots must read at a glance, washes must not shout. These are colors; what a state *means* is a separate vocabulary (§9). -- **Tinted surfaces** (status washes behind rows and banners) derive from the same status hues; hand-rolled `oklch()` status washes at call sites are forbidden — consume the family. The family is `--{status}-wash` (0.08 fill) and `--{status}-wash-border` (0.24, ~3x the fill), every member derived with `oklch(from var(--{status}) ...)` so a status regeneration flows through it. A **strong** tier (0.12 / 0.40) exists for warnings about data destruction or an action the user cannot undo, and for nothing else — it is not the loud option for a notice that wants attention. Palette swatches are not washes: a swatch's job is to show a palette's real colour, so its literals stay. The family is kept complete even where a rung has no consumer yet: a family with holes in it sends the next author back to hand-rolling an alpha, which is the etiology of the fourteen that drifted. +- **Status families** — `--success`, `--warning`, `--destructive` — are generated, not picked: one lightness per mode, each hue keeping its own chroma, every member clearing AA. These are Astryx's three statuses, and Maka has no fourth: there is **no `--info` colour**. "Info" is a semantic slot, not a hue — a `tone="info"` tile paints with the accent, so an informational surface follows the user's palette instead of introducing a second blue that competes with it. `` is the one place that does not yet: `.astryx-banner.info` re-declares the tint and the text colour **on the banner element itself**, which no ancestor bridge can outrank, so info banners stay upstream's fixed blue. Converging that means overriding a component rule rather than a token, and it is tracked on #3446, not done here. The residual contrast spread within a mode is hue physics — at equal lightness, yellow carries more luminance than red — and flattening it would abandon the shared-lightness premise that makes them a family. All three are declared in one block per mode, because the time warning sat thirty lines from its siblings is the time it lost its dark override and dropped under AA unnoticed. A louder band at ~90% gamut chroma exists only for 8px status dots: dots must read at a glance, tinted surfaces must not shout. These are colors; what a state *means* is a separate vocabulary (§9). +- **Tinted surfaces** are Astryx's own tokens: `--color-success-muted`, `--color-warning-muted`, `--color-error-muted` and `--color-accent-muted`. Product CSS reads those four names and nothing else; a hand-rolled `oklch(from var(--destructive) …)` at a call site is forbidden. **One rung, no border rung, no strong tier** — the shape upstream ships. All four are `oklch(from var(--{hue}) l c h / 0.24)`, the alpha the neutral theme's own pastels already sit at, so a Banner and a product row painted side by side read as one material. Same weight is not enough: they must be the same **construction**, because an opaque tint does not compose, and a tinted chip on a tinted panel then resolves to its parent's exact pixel and vanishes. The three status rungs are emitted by `makaTheme.ts`; `--color-accent-muted` needs the unlayered bridge in `maka-tokens.css` instead, because `maka.css` re-declares the accent pair at component level and a root rule in the same layer would lose. A border around a tinted surface is `--border`: the fill already states the status, and a second tinted edge is §4's One Means Rule. A tinted border on an **un**tinted surface is a different case and does read a `-muted` token — the stat tile's outline tones and the tool-output edges are the live examples. The Maka `--{status}-wash` / `-wash-border` vocabulary this replaced was a second authority for the same surface — its two tiers and four rungs each grew a call site that disagreed with the rung beside it. Palette swatches are not tinted surfaces: a swatch's job is to show a palette's real colour, so its literals stay. +- **The solid tier is deliberately not converged.** `--success` / `--warning` / `--destructive` remain Maka's names for status ink, dots and solid fills, while Astryx calls the same idea `--color-success` / `--color-warning` / `--color-error`. Only the tinted tier has one authority today. Converging the solid tier means moving status text, status dots and status buttons at once; it is a change of its own, not an omission from this one. - **Identity colors** (avatars, channel marks) live in one 4.2–4.8:1 contrast band; desaturation for muted states happens at constant OKLab lightness. **The Signal, Not Texture Rule.** Accent communicates action or state. Never use it as a background flood, gradient, glow, or substitute for hierarchy. @@ -188,7 +192,7 @@ Use Astryx primitives as the default seam. New work composes product meaning thr - **Controls:** Maka uses a 20/24/28/32/36/40px height ruler with 32px as the default; Astryx owns the 28/32/36px variants. Hover is restrained; press may use `scale(0.98)`; keyboard focus is always visible. At most one inverted (filled) element per control. Hover washes come in exactly two lanes: product rows and controls take `--state-hover-bg`; chrome that must stay in lockstep with Astryx internals takes `--color-overlay-hover`. Hand-mixed hover alphas are drift. - **Fields:** labels, descriptions, and validation belong to the field primitive; input focus belongs to its control. Keep disabled reasons discoverable through the owning control's tooltip; do not rebuild field chrome around a bare input. - **Badges and status:** Badge is 20px high and pill-shaped. Choose variants by meaning, not hue. -- **Status vocabulary:** what a state means is named once, in `packages/ui/src/status-vocabulary.ts`, and every status dot resolves its color through it — a surface never maps its own domain state onto a color. The semantics are `success` (proven healthy), `active` (the system is working), `attention` (waiting on a person), `error` (broken now), `neutral` (a settled fact); collapsing `active` and `attention` is the mistake that vocabulary exists to prevent. It deliberately has no `info` — two callers meant opposite things by it — but that is a statement about *dot semantics*, not about the `--info` color, which is live. Note that Astryx's `Badge` and `StatusDot` render fixed inherited literals, so an Astryx `info` pill and Maka's `--info` are two unrelated blues that merely look alike; a dot's color does not come from the family in §8. +- **Status vocabulary:** what a state means is named once, in `packages/ui/src/status-vocabulary.ts`, and every status dot resolves its color through it — a surface never maps its own domain state onto a color. The semantics are `success` (proven healthy), `active` (the system is working), `attention` (waiting on a person), `error` (broken now), `neutral` (a settled fact); collapsing `active` and `attention` is the mistake that vocabulary exists to prevent. It deliberately has no `info` — two callers meant opposite things by it. Note that Astryx's `Badge` and `StatusDot` render fixed inherited literals, so an Astryx `info` pill does not follow the palette; a dot's color does not come from the family in §8. - **Counters:** a count is one step smaller and quieter than its label (supporting role, muted ink, `tabular-nums`) and follows its parent's active state back to full ink. Never bolder than the label it counts. - **Scrollbars:** one app-wide recipe — a 6px pill (10px hit area, 2px transparent inset), thumb at `--border-strong`, one step darker on hover, no painted track. Surfaces may hide their own bars; none may restyle them. - **Cards:** Astryx Card uses container radius, 12px default padding, and no resting elevation. Astryx components own their geometry. @@ -243,7 +247,7 @@ Three mutually exclusive forms, chosen by structural predictability — never by ### Don't (the forbidden list — some items are contract-tested, the rest are review-blocked): -- **Don't** write a bare `oklch()` status color at a call site, or hand-roll a status wash — consume the families (§8). +- **Don't** write a bare `oklch()` status color at a call site, or hand-roll a status tint — consume the families (§8). - **Don't** use `border-radius: 0` off a full-bleed row (§6). - **Don't** put an illustration in an empty-state icon slot, add anything past tier 3's single action, or ship a state attribute with no visible feedback (§10). - **Don't** hardcode `background: white` or any literal surface color — resolve a ladder tier (§2), or `--surface-paper` when the content's own contrast is not ours to control (foreign documents, QR codes) and inverting it would break the content rather than restyle it. @@ -253,5 +257,5 @@ Three mutually exclusive forms, chosen by structural predictability — never by - **Don't** use generic AI gradients, glowing borders, sparkle, decorative "thinking," or default glassmorphism. - **Don't** personify the agent through mascots, fake emotion, excessive avatars, or chat ornament; the optional user-supplied pet is the only exception. - **Don't** turn every region into a card or every status into a colored pill. -- **Don't** introduce another accent, spacing ruler, radius tier, icon system, text axis, or parallel component path. +- **Don't** introduce another accent, spacing ruler, radius tier, icon system, text axis, reading measure, or parallel component path (§7). - **Don't** copy primitive internals, progress, versions, palette inventories, or surface inventories into this document. diff --git a/LICENSE b/LICENSE index 2d8fc74e5d..6371a078e8 100644 --- a/LICENSE +++ b/LICENSE @@ -230,13 +230,13 @@ Astryx Source: https://github.com/facebook/astryx ChatReasoning revision: c9fe4379e9959b9ba5eeff56def34c752223450e -Theme revision: 110987b4505dc44119b94bed53d92b9840088a61 +Theme revision: ea888ef33283ea44e37817d36bc8e84a0568bc30 Copyright (c) 2026 Meta Platforms, Inc. License: MIT Maka includes an ejected adaptation of Astryx ChatReasoning v0.1.9 and generated -theme artifacts based on Astryx theme-neutral v0.4.5. Maka also redistributes -`patches/@astryxdesign+core+0.4.5.patch`, which modifies the MIT-licensed Astryx +theme artifacts based on Astryx theme-neutral v0.5.2. Maka also redistributes +`patches/@astryxdesign+core+0.5.2.patch`, which modifies the MIT-licensed Astryx core package. The following MIT License applies to that material: MIT License @@ -261,6 +261,53 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Sigstore dependency patch + +Source: https://www.npmjs.com/package/@sigstore/core/v/4.0.1 +Repository: https://github.com/sigstore/sigstore-js/tree/main/packages/core +Version: 4.0.1 +Dependency patch: patches/@sigstore+core+4.0.1.patch +Copyright 2023 The Sigstore Authors +License: Apache-2.0 + +Maka redistributes a source patch that selects the signature digest in +@sigstore/core. The patch modifies Apache-2.0 source and is covered by the +Apache License, Version 2.0 reproduced above. + +TUF metadata models dependency patch + +Source: https://www.npmjs.com/package/@tufjs/models/v/5.0.0 +Repository: https://github.com/theupdateframework/tuf-js/tree/main/packages/models +Version: 5.0.0 +Dependency patch: patches/@tufjs+models+5.0.0.patch +Copyright (c) 2022 GitHub and the TUF Contributors +License: MIT + +Maka redistributes a source patch that selects the signature digest in +@tufjs/models. The following MIT License applies to that material: + +MIT License + +Copyright (c) 2022 GitHub and the TUF Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + node-pty dependency patch Source: https://www.npmjs.com/package/node-pty/v/1.2.0-beta.15 @@ -450,14 +497,18 @@ License: MIT packages/core/src/model-metadata.generated.ts and packages/runtime/src/telemetry/model-pricing.generated.ts are generated from the -models.dev catalog by scripts/sync-model-metadata.mjs and are redistributed with -Maka. The exact selected input projection is stored at +models.dev catalog by scripts/sync-model-metadata.mjs during installation or +build and are redistributed with compiled Maka packages, but are not tracked as +source. The exact selected input projection is stored at scripts/model-metadata/models-dev-api.snapshot.json. Its origin record either binds a refresh to the downloaded response digest or identifies the exact Maka commit whose generated outputs seeded the migration snapshot. The snapshot -also carries a digest of its normalized projection; the generated-file headers -bind each output to that digest. They copy and transform selected model and -provider metadata from that catalog, including upstream display names and +is the sole build input for a particular repository revision and release; +models.dev remains the upstream source used by an explicit refresh. Normal +installation and build paths do not fetch the latest catalog implicitly. The +snapshot also carries a digest of its normalized projection; the generated-file +headers bind each output to that digest. They copy and transform selected model +and provider metadata from that catalog, including upstream display names and descriptive text, lifecycle status values, reasoning effort options, context and output limits, modalities, and prices. Which providers are kept, which fields are kept, and the order they appear in are decided by the generator. The diff --git a/README.md b/README.md index 58fc692e35..93d6154eb4 100644 --- a/README.md +++ b/README.md @@ -25,14 +25,11 @@

GitHub stars - GitHub downloads License: Apache 2.0 macOS Apple Silicon Windows unsigned preview Linux not yet supported -

- -

+ DeepWiki: third-party AI-generated docs 中文文档

@@ -43,13 +40,18 @@ machine, through one Runtime Host.

+

+ Download Desktop Nightly
+ Daily builds from main for developers and testers. Not an ASF release, not intended for production use. +

+ ![Maka — Your work. Your agent.](./.github/assets/maka-hero.en.png) > [!NOTE] > Apache Maka (Incubating) is an effort undergoing incubation at The Apache Software Foundation (ASF), sponsored by the Apache Incubator PMC. Incubation is required of all newly accepted projects until a further review indicates that the infrastructure, communications, and decision-making process have stabilized in a manner consistent with other successful ASF projects. While incubation status is not necessarily a reflection of the completeness or stability of the code, it does indicate that the project has yet to be fully endorsed by the ASF. [DISCLAIMER-WIP](./DISCLAIMER-WIP) records the issues the project is currently aware of. > [!IMPORTANT] -> Maka is under active development. The macOS Apple Silicon desktop build is an early public release; data formats, CLI commands, and experimental capabilities may still change. +> Maka is under active development. Data formats, CLI commands, and experimental capabilities may still change. ## Why Maka @@ -99,7 +101,7 @@ Apache Maka has not made an Apache release yet. Everything currently published f Once Apache releases exist, the official release is the source release published by the ASF and approved by the podling PPMC and the Incubator PMC. A package built from that source and distributed elsewhere, for example through a package registry or as a Desktop installer, is a convenience artifact rather than the release itself, and it is valid only when it is built from an approved source release. [`.github/ASF_SOURCE_RELEASE.md`](./.github/ASF_SOURCE_RELEASE.md) holds the candidate contract, signing path, and verification steps. -Until an approved source release exists, this README recommends no prebuilt download. Build and run Maka from source as described below. Desktop currently targets Apple Silicon Macs (`arm64`). Intel Macs and Linux are not supported yet. [Windows](docs/windows-support.md) is an unsigned preview, not a supported release tier. +[Desktop Nightly](https://github.com/apache/maka/releases) is built daily from `main` for developers and testers. Choose the newest **Maka Desktop Nightly** prerelease; after installation, the app updates automatically on the Nightly channel. It is not an ASF release and is not intended for production use. Desktop currently targets Apple Silicon Macs (`arm64`). Intel Macs and Linux are not supported yet. [Windows](docs/windows-support.md) is an unsigned preview, not a supported release tier. ### Requirements @@ -123,6 +125,15 @@ npm run dev npm run dev:full ``` +Direct Peer and Peer Mesh development additionally requires Rust stable 1.98 or newer and the +platform linker (Xcode Command Line Tools on macOS, MSVC Build Tools on Windows). Use the +peer-enabled entry point so the native addon is built before Desktop starts: + +```sh +npm run dev:peer # HMR +npm run dev:full:peer # full build +``` + If dependencies were installed with `ELECTRON_SKIP_BINARY_DOWNLOAD=1`, install the Electron platform binary before starting: ```sh @@ -190,17 +201,20 @@ Start with [ARCHITECTURE.md](./ARCHITECTURE.md). It provides the system map, cod ## Repository layout ```text -apps/desktop/ Electron main / preload / React renderer - -packages/core/ Pure contracts for Sessions, Events, Permissions, and Connections -packages/storage/ SQLite operational state, configuration, and payload stores -packages/runtime/ AgentRun, model adapters, tools, context, and recovery -packages/eval/ Experiment cells, attempts, results, and executor/subject adapters -packages/cli/ TUI and non-interactive CLI -packages/ui/ Shared conversation, Markdown, Artifact, and UI primitives - -docs/ Architecture, product, security, privacy, and test contracts -scripts/ Build hygiene, visual checks, smoke tests, and release helpers +apps/desktop/ Electron main / preload / React renderer + +packages/core/ Pure contracts for Sessions, Events, Permissions, and Connections +packages/storage/ SQLite operational state, configuration, and payload stores +packages/mcp/ Provider-neutral Model Context Protocol client integration +packages/runtime/ AgentRun, model adapters, tools, context, and recovery +packages/runtime-host/ Single-owner Runtime Host lifecycle, protocol, and client bootstrap +packages/eval/ Experiment cells, attempts, results, and executor/subject adapters +packages/computer-use/ Computer-use backend selection, host lifecycle, and protocol adapters +packages/cli/ TUI and non-interactive CLI +packages/ui/ Shared conversation, Markdown, Artifact, and UI primitives + +docs/ Architecture, product, security, privacy, and test contracts +scripts/ Build hygiene, visual checks, smoke tests, and release helpers ``` ## Local data and recovery @@ -239,16 +253,16 @@ npm run check:release Run one workspace in isolation: ```sh -npm --workspace @maka/runtime test -npm --workspace @maka/eval test -npm --workspace @maka/desktop test +npm --workspace @maka/runtime run test:dist +npm --workspace @maka/eval run test:dist +npm --workspace @maka/desktop run test:dist ``` Use `refresh:model-metadata` to fetch the current catalog from models.dev, update the committed snapshot, and regenerate the derived TypeScript files. A refresh fails closed when any committed model, capability, provider override, or pricing field disappears; after reviewing an intentional upstream removal, acknowledge it with `npm run refresh:model-metadata -- --accept-upstream-removals`. `sync:model-metadata` is intentionally offline: it only regenerates those files from the committed snapshot. Keep access-path-specific overrides in `model-metadata.ts`; do not edit the generated files by hand. ```sh npm run refresh:model-metadata -npm --workspace @maka/core test +npm --workspace @maka/core run test:dist ``` Desktop real-window and visual verification: diff --git a/README.zh-CN.md b/README.zh-CN.md index 21e363d77a..6c64fb51ff 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -25,14 +25,11 @@

GitHub stars - GitHub downloads License: Apache 2.0 macOS Apple Silicon Windows 未签名预览 Linux 尚未支持 -

- -

+ DeepWiki:第三方 AI 生成文档 English

@@ -41,13 +38,18 @@ Maka 在沙箱边界下阅读项目、执行工具,并把模型消息和工具调用保存为可恢复的运行事实——数据在本机,执行走同一个 Runtime Host。

+

+ 下载 Desktop Nightly
+ 每天从 main 构建,面向开发者和测试者。不是 ASF release,也不适合生产使用。 +

+ ![Maka——你的工作,你的 Agent。](./.github/assets/maka-hero.zh-CN.png) > [!NOTE] > Apache Maka (Incubating) 是一个正在 Apache 软件基金会(ASF)孵化的项目,由 Apache Incubator PMC 提供 sponsor。所有新接受的项目都必须经过孵化,直到进一步审查表明其基础设施、沟通方式和决策流程已经稳定到与其他成功的 ASF 项目一致的程度。孵化状态并不必然反映代码的完成度或稳定性,但它确实表明该项目尚未得到 ASF 的完全认可。项目当前已知的问题记录在 [DISCLAIMER-WIP](./DISCLAIMER-WIP)(以英文原文为准)。 > [!IMPORTANT] -> Maka 仍在活跃开发中。macOS Apple Silicon 桌面版是首个早期公开版本,数据格式、CLI 和实验能力仍可能变化。 +> Maka 仍在活跃开发中。数据格式、CLI 和实验能力仍可能变化。 ## 为什么是 Maka @@ -97,7 +99,7 @@ Apache Maka 目前还没有发布过 Apache release。当前从本仓库或包 在 Apache release 出现之后,官方 release 指的是由 ASF 发布、并经 podling PPMC 和 Incubator PMC 批准的源码 release。由该源码构建并通过其他渠道分发的包,例如包管理器中的包或 Desktop 安装程序,属于 convenience artifact,本身不是 release,并且只有在由获批源码 release 构建时才有效。候选契约、签名路径和验包步骤见 [`.github/ASF_SOURCE_RELEASE.md`](./.github/ASF_SOURCE_RELEASE.md)。 -在获批源码 release 出现之前,本 README 不推荐任何预构建下载,请按下文从源码构建并运行 Maka。Desktop 目前面向 Apple Silicon Mac(`arm64`)。暂不支持 Intel Mac 和 Linux。[Windows](docs/windows-support.md) 是未签名预览,不是正式支持的发布层级。 +[Desktop Nightly](https://github.com/apache/maka/releases) 面向开发者和测试者,每天从 `main` 构建。请选择最新的 **Maka Desktop Nightly** prerelease;安装后,应用会在 Nightly 渠道自动更新。它不是 ASF release,不适合生产使用。Desktop 目前面向 Apple Silicon Mac(`arm64`)。暂不支持 Intel Mac 和 Linux。[Windows](docs/windows-support.md) 是未签名预览,不是正式支持的发布层级。 ### 环境要求 @@ -121,6 +123,15 @@ npm run dev npm run dev:full ``` +开发 Direct Peer 和 Peer Mesh 还需要 Rust stable 1.98 或更高版本及平台 linker +(macOS 使用 Xcode Command Line Tools,Windows 使用 MSVC Build Tools)。使用 Peer 开发入口, +Desktop 会在启动前构建原生 addon: + +```sh +npm run dev:peer # HMR +npm run dev:full:peer # 完整构建 +``` + 如果安装时设置过 `ELECTRON_SKIP_BINARY_DOWNLOAD=1`,启动前需要补装 Electron 平台二进制: ```sh @@ -187,17 +198,20 @@ Experiment → Cells → Attempts → Results ## 仓库结构 ```text -apps/desktop/ Electron main / preload / React renderer - -packages/core/ Session、Event、Permission、Connection 等纯 contracts -packages/storage/ SQLite 运行状态、配置与 payload stores -packages/runtime/ AgentRun、模型适配、工具、上下文和恢复 -packages/eval/ Experiment cell、attempt、result 与 executor/subject adapter -packages/cli/ TUI 和非交互 CLI -packages/ui/ 共享对话、Markdown、Artifact 与 UI primitives - -docs/ 架构、产品、安全、隐私和测试契约 -scripts/ Build hygiene、视觉检查、smoke 和 release helpers +apps/desktop/ Electron main / preload / React renderer + +packages/core/ Session、Event、Permission、Connection 等纯 contracts +packages/storage/ SQLite 运行状态、配置与 payload stores +packages/mcp/ 与提供商无关的 Model Context Protocol 客户端集成 +packages/runtime/ AgentRun、模型适配、工具、上下文和恢复 +packages/runtime-host/ 单一所有者的 Runtime Host 生命周期、协议和客户端启动 +packages/eval/ Experiment cell、attempt、result 与 executor/subject adapter +packages/computer-use/ Computer Use 后端选择、Host 生命周期和协议适配 +packages/cli/ TUI 和非交互 CLI +packages/ui/ 共享对话、Markdown、Artifact 与 UI primitives + +docs/ 架构、产品、安全、隐私和测试契约 +scripts/ Build hygiene、视觉检查、smoke 和 release helpers ``` ## 本地数据与恢复 @@ -236,16 +250,16 @@ npm run check:release 针对单个 workspace: ```sh -npm --workspace @maka/runtime test -npm --workspace @maka/eval test -npm --workspace @maka/desktop test +npm --workspace @maka/runtime run test:dist +npm --workspace @maka/eval run test:dist +npm --workspace @maka/desktop run test:dist ``` 用 `refresh:model-metadata` 从 models.dev 获取当前目录、更新仓库内快照,并重新生成派生的 TypeScript 文件。已提交的模型、能力、provider override 或 pricing 字段消失时,refresh 会 fail closed;审查确认上游确实有意删除后,用 `npm run refresh:model-metadata -- --accept-upstream-removals` 显式确认。`sync:model-metadata` 刻意保持离线,只会从已提交快照重新生成这些文件。访问路径特有的 override 写在 `model-metadata.ts`,不要手动修改生成文件。 ```sh npm run refresh:model-metadata -npm --workspace @maka/core test +npm --workspace @maka/core run test:dist ``` Desktop 的真实窗口与视觉验证: diff --git a/SECURITY.md b/SECURITY.md index 22021811da..d5c1c575ca 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -80,7 +80,7 @@ and they are NOT equally load-bearing. user's OS account: filesystem, network, OS permissions (Keychain / Microphone / Screen recording). -### 2.2 The boundary: the OS user account +### 2.2 OS enforcement boundaries **The only enforcement boundary against an adversarial LLM is the operating system.** Nothing inside the agent process constitutes @@ -94,11 +94,28 @@ because they are useful UX safety nets — they catch accidental output and slow down adversarial output enough for a human to notice — but we do not ship them as guarantees. -Maka does not run tools in a separate process or container by default. The -runtime exposes a macOS Seatbelt command transformer for restricted profiles, -but current product compositions do not yet route command execution through -it, so it is not a product boundary today. Externally isolated runtimes may -supply their own boundary. See +The OS user account is Maka's outer trust envelope. Inside that envelope, +Runtime Host also routes selected agent tool effects through OS-enforced child +process sandboxes when the active session has a restricted managed +`ExecutionBoundary`. On macOS and Linux, non-PTY Bash commands and the +filesystem worker run through Seatbelt and bubblewrap respectively. On +Windows, the AppContainer boundary currently covers the purpose-built +filesystem worker only; arbitrary-shell Bash is unavailable and fails closed +when the active profile requires a command sandbox. + +This is not universal tool containment. Bypass boundaries and unrestricted, +disabled, or external profiles do not add a Maka-managed local sandbox; +external environments may supply their own isolation. Managed PTY Bash is +refused when the active profile requires sandboxing. Client-launched +integrated terminals are host PTYs outside the managed agent execution +boundary, and runtime or attachment resource reads do not execute through the +local filesystem worker. The standard `workspace-write` profile permits the +workspace roots and the configured temporary roots. + +Seatbelt, bubblewrap, and AppContainer are OS enforcement mechanisms. The +in-process checks that decide whether to request or invoke them remain +heuristics, not containment. The exact product coverage and fail-closed +selection rules are documented in [`packages/runtime/src/sandbox/README.md`](./packages/runtime/src/sandbox/README.md). ### 2.3 Boundaries we DO treat as load-bearing @@ -106,7 +123,14 @@ supply their own boundary. See 1. **OS user account.** Tools run with the user's privileges. The user is expected to run Maka as a non-admin account on systems where that matters. -2. **Credential-at-rest boundaries.** The provider credential store +2. **Restricted managed tool sandboxes.** For the product surfaces listed in + §2.2, the active session `ExecutionBoundary` is compiled into a per-command + Seatbelt, bubblewrap, or AppContainer policy. Required enforcement fails + closed rather than silently retrying on the host. This boundary is limited + to the documented tool and platform matrix; it does not include bypass, + unrestricted, disabled, external, managed PTY, or integrated-terminal + execution. +3. **Credential-at-rest boundaries.** The provider credential store writes `credentials.json` as versioned plaintext JSON under the user's workspace directory. Its load-bearing boundary is the OS user account plus filesystem controls: directory mode 0o700, @@ -119,21 +143,21 @@ supply their own boundary. See this boundary anymore. Pre-existing safeStorage-encrypted credential or token files are not imported; users with only those copies must re-authenticate. -3. **Renderer process sandbox + preload IPC bridge.** The +4. **Renderer process sandbox + preload IPC bridge.** The renderer cannot reach files, network, or shell directly. Every IPC handler in `apps/desktop/src/main/main.ts` is the trust boundary between renderer-controlled input and main-process action. Renderer code is treated as semi-trusted: it can read masked / sanitized data, but cleartext secrets never cross the boundary in the main-to-renderer direction (see §4). -4. **Settings sensitive masking.** Tokens, API keys, and proxy +5. **Settings sensitive masking.** Tokens, API keys, and proxy passwords are masked at the IPC store boundary (`maskAppSettings` in `apps/desktop/src/main/settings-ipc-helpers.ts`). Re-submitting the mask sentinel `••••••` is interpreted as "keep current" by the merge logic; an empty string is interpreted as an explicit clear. The Tavily API key follows the same boundary. -5. **Network egress through user-configured proxies.** The +6. **Network egress through user-configured proxies.** The `network.proxy` settings drive Electron's session proxy. Tools that bypass `proxiedFetch` (Tavily lives in main, uses standard fetch) are individually audited. @@ -156,8 +180,10 @@ are welcome as ordinary issues, not security advisories. URLs out of agent tool results before they reach a renderer ``. 4. **`PermissionMode.ask`** as default. Mode names are UX controls, - not security boundaries; even permissive modes remain subject to - the current policy table and OS isolation boundary. + not security boundaries. `ask` and `explore` establish restricted + managed profiles, while `bypass` explicitly does not promise a + Maka-managed local sandbox; the active `ExecutionBoundary` is the + execution authority. 5. **Modal-lifecycle contract test.** Catches the React `useEffect`-before-`if (!open) return null` pattern that can violate React hook ordering. Static analysis only. @@ -204,6 +230,10 @@ boundary in §2.3 was crossed. Examples: result envelopes, log lines). - A tool intended to be permission-gated bypasses the PermissionEngine. +- A non-PTY Bash command or local-path filesystem operation under a + restricted managed boundary escapes its effective Seatbelt, + bubblewrap, or supported AppContainer policy, or silently falls + back to host execution when required enforcement is unavailable. Reports against the §2.4 heuristics are out of scope as security advisories. They are welcome as ordinary issues or pull requests. diff --git a/apps/desktop/assets/app-icons/amber-crt.png b/apps/desktop/assets/app-icons/amber-crt.png new file mode 100644 index 0000000000..fc761be52a Binary files /dev/null and b/apps/desktop/assets/app-icons/amber-crt.png differ diff --git a/apps/desktop/assets/app-icons/amber.png b/apps/desktop/assets/app-icons/amber.png new file mode 100644 index 0000000000..35eeb088ef Binary files /dev/null and b/apps/desktop/assets/app-icons/amber.png differ diff --git a/apps/desktop/assets/app-icons/carbon.png b/apps/desktop/assets/app-icons/carbon.png new file mode 100644 index 0000000000..118a8f031e Binary files /dev/null and b/apps/desktop/assets/app-icons/carbon.png differ diff --git a/apps/desktop/assets/app-icons/chrome.png b/apps/desktop/assets/app-icons/chrome.png new file mode 100644 index 0000000000..bf7cef4e58 Binary files /dev/null and b/apps/desktop/assets/app-icons/chrome.png differ diff --git a/apps/desktop/assets/app-icons/clay.png b/apps/desktop/assets/app-icons/clay.png new file mode 100644 index 0000000000..fb47d8248e Binary files /dev/null and b/apps/desktop/assets/app-icons/clay.png differ diff --git a/apps/desktop/assets/app-icons/desert.png b/apps/desktop/assets/app-icons/desert.png new file mode 100644 index 0000000000..e8b99b49cf Binary files /dev/null and b/apps/desktop/assets/app-icons/desert.png differ diff --git a/apps/desktop/assets/app-icons/dust.png b/apps/desktop/assets/app-icons/dust.png new file mode 100644 index 0000000000..4d7f1359fe Binary files /dev/null and b/apps/desktop/assets/app-icons/dust.png differ diff --git a/apps/desktop/assets/app-icons/fog.png b/apps/desktop/assets/app-icons/fog.png new file mode 100644 index 0000000000..1b08085679 Binary files /dev/null and b/apps/desktop/assets/app-icons/fog.png differ diff --git a/apps/desktop/assets/app-icons/glacier.png b/apps/desktop/assets/app-icons/glacier.png new file mode 100644 index 0000000000..b8cc3cf972 Binary files /dev/null and b/apps/desktop/assets/app-icons/glacier.png differ diff --git a/apps/desktop/assets/app-icons/gold.png b/apps/desktop/assets/app-icons/gold.png new file mode 100644 index 0000000000..23fdf4cd33 Binary files /dev/null and b/apps/desktop/assets/app-icons/gold.png differ diff --git a/apps/desktop/assets/app-icons/hazard.png b/apps/desktop/assets/app-icons/hazard.png new file mode 100644 index 0000000000..2945dec384 Binary files /dev/null and b/apps/desktop/assets/app-icons/hazard.png differ diff --git a/apps/desktop/assets/app-icons/magenta.png b/apps/desktop/assets/app-icons/magenta.png new file mode 100644 index 0000000000..99e9896af6 Binary files /dev/null and b/apps/desktop/assets/app-icons/magenta.png differ diff --git a/apps/desktop/assets/app-icons/matrix.png b/apps/desktop/assets/app-icons/matrix.png new file mode 100644 index 0000000000..26f4ea2e02 Binary files /dev/null and b/apps/desktop/assets/app-icons/matrix.png differ diff --git a/apps/desktop/assets/app-icons/midnight.png b/apps/desktop/assets/app-icons/midnight.png new file mode 100644 index 0000000000..d4cdafa62b Binary files /dev/null and b/apps/desktop/assets/app-icons/midnight.png differ diff --git a/apps/desktop/assets/app-icons/mono-black.png b/apps/desktop/assets/app-icons/mono-black.png new file mode 100644 index 0000000000..f42bf4ca67 Binary files /dev/null and b/apps/desktop/assets/app-icons/mono-black.png differ diff --git a/apps/desktop/assets/app-icons/mono-white.png b/apps/desktop/assets/app-icons/mono-white.png new file mode 100644 index 0000000000..bc572d679d Binary files /dev/null and b/apps/desktop/assets/app-icons/mono-white.png differ diff --git a/apps/desktop/assets/app-icons/moss.png b/apps/desktop/assets/app-icons/moss.png new file mode 100644 index 0000000000..897c9159dd Binary files /dev/null and b/apps/desktop/assets/app-icons/moss.png differ diff --git a/apps/desktop/assets/app-icons/neon-cyan.png b/apps/desktop/assets/app-icons/neon-cyan.png new file mode 100644 index 0000000000..e847e89a7a Binary files /dev/null and b/apps/desktop/assets/app-icons/neon-cyan.png differ diff --git a/apps/desktop/assets/app-icons/obsidian.png b/apps/desktop/assets/app-icons/obsidian.png new file mode 100644 index 0000000000..c087c465bb Binary files /dev/null and b/apps/desktop/assets/app-icons/obsidian.png differ diff --git a/apps/desktop/assets/app-icons/ocean.png b/apps/desktop/assets/app-icons/ocean.png new file mode 100644 index 0000000000..61775aa3f1 Binary files /dev/null and b/apps/desktop/assets/app-icons/ocean.png differ diff --git a/apps/desktop/assets/app-icons/sage.png b/apps/desktop/assets/app-icons/sage.png new file mode 100644 index 0000000000..b0c453d8f8 Binary files /dev/null and b/apps/desktop/assets/app-icons/sage.png differ diff --git a/apps/desktop/assets/app-icons/slate.png b/apps/desktop/assets/app-icons/slate.png new file mode 100644 index 0000000000..12ac8d73aa Binary files /dev/null and b/apps/desktop/assets/app-icons/slate.png differ diff --git a/apps/desktop/assets/app-icons/sunset.png b/apps/desktop/assets/app-icons/sunset.png new file mode 100644 index 0000000000..82ceb8811f Binary files /dev/null and b/apps/desktop/assets/app-icons/sunset.png differ diff --git a/apps/desktop/assets/app-icons/terracotta.png b/apps/desktop/assets/app-icons/terracotta.png new file mode 100644 index 0000000000..d9cd10f8e2 Binary files /dev/null and b/apps/desktop/assets/app-icons/terracotta.png differ diff --git a/apps/desktop/build/background.png b/apps/desktop/build/background.png new file mode 100644 index 0000000000..46e4393408 Binary files /dev/null and b/apps/desktop/build/background.png differ diff --git a/apps/desktop/build/background@2x.png b/apps/desktop/build/background@2x.png new file mode 100644 index 0000000000..ebfb6909d9 Binary files /dev/null and b/apps/desktop/build/background@2x.png differ diff --git a/apps/desktop/e2e/accessibility-coverage.spec.ts b/apps/desktop/e2e/accessibility-coverage.spec.ts index 8279c7c8e8..322ed8d8fc 100644 --- a/apps/desktop/e2e/accessibility-coverage.spec.ts +++ b/apps/desktop/e2e/accessibility-coverage.spec.ts @@ -17,7 +17,8 @@ * under the License. */ -import type { CDPSession, Page } from '@playwright/test'; +import { FAKE_HOLD_OPEN_PROMPT } from '@maka/runtime/test-only/fake-backend'; +import type { CDPSession, Locator, Page } from '@playwright/test'; import { expect, test, COMPOSER_INPUT } from './fixtures'; import { auditAxTree } from '../../../scripts/ax-tree-audit.mjs'; import { groupedNav } from '../src/renderer/settings/settings-nav'; @@ -40,6 +41,29 @@ async function openSettings(page: Page): Promise { await expect(page.getByRole('main', { name: '设置内容' })).toBeVisible(); } +async function tabTo(page: Page, target: Locator, label: string, limit = 30): Promise { + for (let index = 0; index < limit; index += 1) { + if (await target.evaluate((element) => element === document.activeElement)) return; + await page.keyboard.press('Tab'); + } + expect( + await target.evaluate((element) => element === document.activeElement), + `${label} is not reachable within ${limit} Tab presses`, + ).toBe(true); +} + +async function enterMainFromSkipLink(page: Page): Promise { + await page.evaluate(() => { + document.body.tabIndex = -1; + document.body.focus(); + }); + const skipLink = page.getByRole('link', { name: '跳到主要内容' }); + await tabTo(page, skipLink, 'skip link', 10); + await page.keyboard.press('Enter'); + await expect(page.getByRole('main')).toBeFocused(); + await page.evaluate(() => document.body.removeAttribute('tabindex')); +} + test('every settings page exposes named actionable controls', async ({ window: page }) => { await openSettings(page); const cdp = await page.context().newCDPSession(page); @@ -83,25 +107,25 @@ test('module pages and global overlays expose named actionable controls', async const extensionsNavigation = page.getByRole('navigation', { name: /扩展内容/ }); await expect( extensionsNavigation.getByRole('button', { name: '技能', exact: true }), - ).toHaveAttribute('aria-current', 'page'); + ).toHaveAttribute('aria-current', 'true'); await assertAxHealth(cdp, 'extensions/skills'); const mcpButton = extensionsNavigation.getByRole('button', { name: 'MCP', exact: true }); await mcpButton.click(); - await expect(mcpButton).toHaveAttribute('aria-current', 'page'); + await expect(mcpButton).toHaveAttribute('aria-current', 'true'); await assertAxHealth(cdp, 'extensions/mcp'); await navigation.getByRole('button', { name: /定时任务/ }).click(); const automationsNavigation = page.getByRole('navigation', { name: /定时任务内容/ }); await expect( automationsNavigation.getByRole('button', { name: '定时任务', exact: true }), - ).toHaveAttribute('aria-current', 'page'); + ).toHaveAttribute('aria-current', 'true'); await assertAxHealth(cdp, 'automations/scheduled-tasks'); const dailyReviewButton = automationsNavigation.getByRole('button', { name: '每日回顾', exact: true, }); await dailyReviewButton.click(); - await expect(dailyReviewButton).toHaveAttribute('aria-current', 'page'); + await expect(dailyReviewButton).toHaveAttribute('aria-current', 'true'); await assertAxHealth(cdp, 'automations/daily-review'); await page.keyboard.press('Shift+Slash'); @@ -118,6 +142,104 @@ test('module pages and global overlays expose named actionable controls', async await page.keyboard.press('Escape'); }); +test('data-backed conversation exposes ordered todos and keyboard access to tools, models, and Graph', async ({ + accessibilityNarrativeWindow: page, +}) => { + const cdp = await page.context().newCDPSession(page); + await expect(page.getByRole('region', { name: /对话:/ })).toBeVisible(); + const todoRegion = page.getByRole('region', { name: '任务待办' }); + await expect(todoRegion).toBeVisible(); + await expect(todoRegion.getByRole('listitem')).toHaveText([ + '补齐桌面端无障碍覆盖', + '核对模型选择器的键盘路径', + '确认工具结果可以展开阅读', + ]); + await assertAxHealth(cdp, 'conversation/data-backed'); + + await expect(page.getByRole('main')).toHaveCount(1); + await enterMainFromSkipLink(page); + + const toolCall = page.getByRole('button', { name: /^检查测试状态/ }); + await tabTo(page, toolCall, 'tool result'); + await page.keyboard.press('Enter'); + await expect(toolCall).toHaveAttribute('aria-expanded', 'true'); + await expect(page.locator('[data-slot="tool-output"]')).toContainText('core 41 passing'); + await assertAxHealth(cdp, 'conversation/tool-result-expanded'); + await page.keyboard.press('Enter'); + await expect(toolCall).toHaveAttribute('aria-expanded', 'false'); + + const modelSwitcher = page.getByRole('button', { name: '切换当前任务模型' }); + await tabTo(page, modelSwitcher, 'model picker'); + await page.keyboard.press('Enter'); + await expect(page.getByRole('menuitem', { name: /glm-5\.1/ })).toBeVisible(); + await assertAxHealth(cdp, 'conversation/model-picker'); + const availableModel = page.getByRole('menuitem', { name: 'glm-4.5', exact: true }); + await expect(availableModel).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(modelSwitcher).toContainText('glm-4.5'); + + const composer = page.locator(COMPOSER_INPUT); + await tabTo(page, composer, 'composer', 60); + await page.keyboard.insertText('/graph on'); + const send = page.getByRole('button', { name: '发送' }); + await tabTo(page, send, 'Send button', 20); + await page.keyboard.press('Enter'); + await expect(page.getByText('Graph Mode 已开启', { exact: true })).toBeVisible(); + await assertAxHealth(cdp, 'overlay/graph-mode-toast'); + + const graphPanel = page.getByRole('region', { name: 'Agent Graph' }); + await expect(graphPanel).toBeVisible(); + await expect(graphPanel).toContainText('等待主 Agent 创建 operator…'); + const collapseGraph = graphPanel.getByRole('button', { name: '收起 Agent Graph' }); + await tabTo(page, collapseGraph, 'Graph collapse', 60); + await page.keyboard.press('Enter'); + await expect( + graphPanel.getByRole('button', { name: '展开 Agent Graph' }), + ).toHaveAttribute('aria-expanded', 'false'); + await assertAxHealth(cdp, 'conversation/agent-graph-empty'); +}); + +test('toast and error states expose healthy live regions', async ({ window: page }) => { + const cdp = await page.context().newCDPSession(page); + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('/graph history'); + await composer.press('Enter'); + await expect(page.getByText('Graph 历史', { exact: true })).toBeVisible(); + await assertAxHealth(cdp, 'overlay/graph-history-toast'); + + await page.evaluate(async () => { + await window.maka.connections.setDefaultModel(null); + await window.maka.settings.updateClient({ workHub: { enabled: true } }); + }); + const failure = page.getByRole('alert'); + await expect(failure).toContainText('WorkHub 暂时无法启动'); + await expect(failure).toContainText('请检查当前 Runtime Host 的默认模型配置'); + await assertAxHealth(cdp, 'workhub/startup-error'); +}); + +test('a streaming answer exposes a healthy live conversation state', async ({ window: page }) => { + const cdp = await page.context().newCDPSession(page); + await enterMainFromSkipLink(page); + const composer = page.locator(COMPOSER_INPUT); + await tabTo(page, composer, 'streaming composer', 60); + await page.keyboard.insertText(FAKE_HOLD_OPEN_PROMPT); + const send = page.getByRole('button', { name: '发送' }); + await tabTo(page, send, 'streaming Send button', 20); + await page.keyboard.press('Enter'); + + await expect(page.locator('.maka-bubble-streaming')).toContainText('Fake backend waiting'); + await expect(page.getByRole('button', { name: '停止' })).toBeEnabled(); + await assertAxHealth(cdp, 'conversation/streaming'); + + const stop = page.getByRole('button', { name: '停止' }); + await tabTo(page, stop, 'streaming Stop button', 20); + await page.keyboard.press('Enter'); + await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { + timeout: 20_000, + }); + await assertAxHealth(cdp, 'conversation/stopped'); +}); + test('composer and workbar entry points expose named actionable controls', async ({ window: page, }) => { @@ -126,11 +248,15 @@ test('composer and workbar entry points expose named actionable controls', async await expect(page.getByRole('region', { name: '新任务对话' })).toBeVisible(); await assertAxHealth(cdp, 'conversation/new-task'); + await enterMainFromSkipLink(page); const composer = page.locator(COMPOSER_INPUT); const prompt = 'create a session for accessibility coverage'; - await composer.fill(prompt); - await expect(page.getByRole('button', { name: '发送' })).toBeEnabled(); - await composer.press('Enter'); + await tabTo(page, composer, 'new-task composer', 60); + await page.keyboard.insertText(prompt); + const send = page.getByRole('button', { name: '发送' }); + await expect(send).toBeEnabled(); + await tabTo(page, send, 'new-task Send button', 20); + await page.keyboard.press('Enter'); await expect(page.getByText(`Fake backend received: ${prompt}`)).toBeVisible({ timeout: 30_000, }); @@ -159,6 +285,13 @@ test('composer and workbar entry points expose named actionable controls', async const activeTab = page.getByRole('tab', { name: new RegExp(panel) }); await expect(activeTab).toBeVisible(); await expect(activeTab).toHaveAttribute('aria-selected', 'true'); + if (panel === '终端') { + await expect(page.getByRole('region', { name: '任务终端' })).toBeVisible(); + } else if (panel === '浏览器') { + await expect(page.getByRole('region', { name: '嵌入式浏览器' })).toBeVisible(); + } else if (panel === '待办') { + await expect(page.getByRole('region', { name: '任务待办' })).toBeVisible(); + } await assertAxHealth(cdp, `workbar/${panel}`); if (panel !== workbarPanels.at(-1)) { await page.getByRole('button', { name: '打开工作栏标签' }).first().click(); diff --git a/apps/desktop/e2e/composer-directory-reference.spec.ts b/apps/desktop/e2e/composer-directory-reference.spec.ts new file mode 100644 index 0000000000..d0636bd654 --- /dev/null +++ b/apps/desktop/e2e/composer-directory-reference.spec.ts @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { COMPOSER_INPUT, expect, test } from './fixtures'; + +test('a folder reference is removable, survives send/reload, and leaves project selection unchanged', async ({ + directoryReferenceWindow: { page, folder }, +}, testInfo) => { + const composer = page.locator(COMPOSER_INPUT); + const project = page.locator('button.maka-workspace-picker'); + // The composer can mount before TaskEntry loads the initial project selection. + // Compare the settled selection, not the generic label shown during loading. + const originalProject = '选择项目:无项目'; + await expect(project).toHaveAttribute('aria-label', originalProject); + const pick = async (keyboard = false) => { + const trigger = page.locator('.maka-composer-plus-menu button').first(); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + if (keyboard) { + // Exercise keyboard reopening as well. Astryx intentionally ignores pointer + // reopening within 50ms of dismiss; the native chooser mock returns instantly. + await trigger.press('ArrowDown'); + } else { + await trigger.click(); + } + await expect(trigger).toHaveAttribute('aria-expanded', 'true'); + await page.getByRole('menuitem', { name: '引用文件夹', exact: true }).click(); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }; + + await pick(); + const chip = page.locator('.maka-composer-context-drawer .maka-composer-attachment-token'); + await expect(chip).toContainText('referenced-source'); + await chip.getByRole('button').click(); + await expect(chip).toHaveCount(0); + await pick(true); + await expect(chip).toContainText('referenced-source'); + await expect(project).toHaveAttribute('aria-label', originalProject); + await composer.fill('请检查引用目录'); + await page.screenshot({ path: testInfo.outputPath('directory-reference-staged.png') }); + await composer.press('Enter'); + + const user = page.getByLabel('你发送的消息').first(); + await expect(user).toContainText('请检查引用目录'); + await expect(user).toContainText('referenced-source'); + await expect(user).not.toContainText('README.md'); + const transcript = page.getByRole('log'); + await expect(transcript).not.toContainText('README.md'); + await expect(transcript).not.toContainText('"status":"listed"'); + await expect(transcript).not.toContainText('DO_NOT_READ_FILE_CONTENTS'); + await expect(transcript).not.toContainText('deep.txt'); + await expect(chip).toHaveCount(0); + await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 }); + + const sessions = await page.evaluate(() => window.maka.sessions.list()); + expect(sessions).toHaveLength(1); + expect(sessions[0]!.cwd).not.toBe(folder); + await page.reload(); + await expect(page.getByLabel('你发送的消息').first()).toContainText('referenced-source'); + await expect(page.getByRole('log')).not.toContainText('README.md'); + await page.screenshot({ path: testInfo.outputPath('directory-reference-sent.png') }); +}); diff --git a/apps/desktop/e2e/composer-plus-menu-stability.spec.ts b/apps/desktop/e2e/composer-plus-menu-stability.spec.ts index 569df394bf..5c5275726b 100644 --- a/apps/desktop/e2e/composer-plus-menu-stability.spec.ts +++ b/apps/desktop/e2e/composer-plus-menu-stability.spec.ts @@ -69,6 +69,21 @@ async function rejectBridgeLatch( ); } +// The fake echo can paint before Runtime Host publishes terminal turn ownership. +// Use the catalog's known-empty live set as the barrier before latching its next read. +async function waitForSessionTurnToSettle( + page: import('@playwright/test').Page, +): Promise { + await expect + .poll(async () => + page.evaluate(async () => { + const sessions = await window.maka.sessions.list(); + return sessions[0]?.runningTurnIds ?? null; + }), + ) + .toEqual([]); +} + /** * Toggling Plan from the + menu must not move the menu. * @@ -266,6 +281,7 @@ test('two rapid Plan toggles land on the last requested state', async ({ await composer.fill('alpha-marker'); await composer.press('Enter'); await expect(page.getByText(/Fake backend received: alpha-marker/)).toBeVisible(); + await waitForSessionTurnToSettle(page); await page.getByRole('button', { name: '添加上下文' }).click(); const menu = page.getByRole('menu', { name: '添加上下文' }); @@ -300,6 +316,7 @@ test('a failed catalog refresh keeps the committed Plan state visible', async ({ await composer.fill('alpha-marker'); await composer.press('Enter'); await expect(page.getByText(/Fake backend received: alpha-marker/)).toBeVisible(); + await waitForSessionTurnToSettle(page); await page.getByRole('button', { name: '添加上下文' }).click(); const menu = page.getByRole('menu', { name: '添加上下文' }); @@ -322,6 +339,7 @@ test('latest Plan intent still reaches the Host after a catalog refresh fails', await composer.fill('alpha-marker'); await composer.press('Enter'); await expect(page.getByText(/Fake backend received: alpha-marker/)).toBeVisible(); + await waitForSessionTurnToSettle(page); await page.getByRole('button', { name: '添加上下文' }).click(); const planRow = page.getByRole('menu', { name: '添加上下文' }) @@ -352,6 +370,7 @@ test('deleting the session while a toggle is pending settles clean', async ({ await composer.fill('alpha-marker'); await composer.press('Enter'); await expect(page.getByText(/Fake backend received: alpha-marker/)).toBeVisible(); + await waitForSessionTurnToSettle(page); await page.getByRole('button', { name: '添加上下文' }).click(); const menu = page.getByRole('menu', { name: '添加上下文' }); diff --git a/apps/desktop/e2e/composer-undo.spec.ts b/apps/desktop/e2e/composer-undo.spec.ts new file mode 100644 index 0000000000..80c7946d8e --- /dev/null +++ b/apps/desktop/e2e/composer-undo.spec.ts @@ -0,0 +1,143 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Locator } from '@playwright/test'; +import { expect, test, COMPOSER_INPUT } from './fixtures'; + +const TYPED = 'x'; +const PASTED = '中文 & "quoted"\r\nhttps://example.test/path?x=1&y=2\n第二行 <>&'; +const PASTED_AS_PLAIN_TEXT = PASTED.replace(/\r\n/g, '\n'); +const UNDO_SHORTCUT = process.platform === 'darwin' ? 'Meta+z' : 'Control+z'; + +async function expectComposerText( + composer: Locator, + expected: string, +): Promise { + await expect + .poll(() => composer.evaluate((element) => element.innerText.replace(/\r\n/g, '\n'))) + .toBe(expected); +} + +async function pastePlainText(composer: Locator, pasted: string): Promise { + await composer.evaluate((element, text) => { + const transfer = new DataTransfer(); + transfer.setData('text/plain', text); + element.dispatchEvent( + new ClipboardEvent('paste', { + bubbles: true, + cancelable: true, + clipboardData: transfer, + }), + ); + }, pasted); +} + +test('plain-text paste is undone separately from prior typing', async ({ window: page }) => { + const composer = page.locator(COMPOSER_INPUT); + + await composer.click(); + await page.keyboard.type(TYPED); + await expectComposerText(composer, TYPED); + + await pastePlainText(composer, PASTED); + await expectComposerText(composer, `${TYPED}${PASTED_AS_PLAIN_TEXT}`); + + await page.keyboard.press(UNDO_SHORTCUT); + await expectComposerText(composer, TYPED); + + await page.keyboard.press(UNDO_SHORTCUT); + await expectComposerText(composer, ''); + + await page.keyboard.press(UNDO_SHORTCUT); + await expectComposerText(composer, ''); +}); + +test('pasted absolute path sends on the first Enter without opening the slash menu', async ({ + window: page, +}) => { + const composer = page.locator(COMPOSER_INPUT); + const pasted = '/Users/me/notes.txt'; + + await expect(page.locator('button.maka-workspace-picker')).toBeEnabled(); + await composer.click(); + await pastePlainText(composer, pasted); + + await expect(composer).toHaveAttribute('aria-expanded', 'false'); + await composer.press('Enter'); + await expect(page.getByText(`Fake backend received: ${pasted}`)).toBeVisible(); +}); + +test('pasted mention-looking text does not open the file menu', async ({ window: page }) => { + const composer = page.locator(COMPOSER_INPUT); + + await expect(page.locator('button.maka-workspace-picker')).toBeEnabled(); + await composer.click(); + await pastePlainText(composer, 'review @name'); + + await expect(composer).toHaveAttribute('aria-expanded', 'false'); + await expect(page.getByRole('listbox')).toHaveCount(0); +}); + +test('plain-text paste closes an existing trigger menu before the first Enter', async ({ + window: page, +}) => { + const composer = page.locator(COMPOSER_INPUT); + const pasted = 'Users/me/notes.txt'; + + await expect(page.locator('button.maka-workspace-picker')).toBeEnabled(); + await composer.click(); + await composer.pressSequentially('/'); + await expect(composer).toHaveAttribute('aria-expanded', 'true'); + + await pastePlainText(composer, pasted); + + await expect(composer).toHaveAttribute('aria-expanded', 'false'); + await composer.press('Enter'); + await expect(page.getByText(`Fake backend received: /${pasted}`)).toBeVisible(); +}); + +test('plain-text paste synchronizes the controlled draft once', async ({ window: page }) => { + const composer = page.locator(COMPOSER_INPUT); + + await expect(page.locator('button.maka-workspace-picker')).toBeEnabled(); + await page.evaluate(() => { + const storageKey = 'maka-new-task-reload-intent-v1'; + sessionStorage.setItem(storageKey, JSON.stringify({ draft: '' })); + const originalSetItem = Storage.prototype.setItem; + let writes = 0; + Storage.prototype.setItem = function setItem(key: string, value: string): void { + if (key === storageKey) writes += 1; + originalSetItem.call(this, key, value); + }; + Object.defineProperty(globalThis, '__makaComposerDraftWrites', { + configurable: true, + get: () => writes, + }); + }); + + await composer.click(); + await pastePlainText(composer, 'one controlled change'); + + const writes = await page.evaluate( + () => + (globalThis as typeof globalThis & { __makaComposerDraftWrites?: number }) + .__makaComposerDraftWrites ?? 0, + ); + expect(writes).toBe(1); +}); diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index f4c9f8ef5a..e3c7a46c2b 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -24,11 +24,9 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; -import { - createProjectCatalog, - createSessionStore, - createSettingsStore, -} from '@maka/storage'; +import { createProjectCatalog } from '@maka/storage/project-catalog'; +import { createSessionStore } from '@maka/storage/session-store'; +import { createSettingsStore } from '@maka/storage/settings-store'; import { resolveStorageRoot, tryAcquireInteractiveRootOwner, @@ -56,6 +54,38 @@ export const PARENT_REMOVAL_CHILD_NAME = '应归档的子任务'; /** Directory basename, and so the Project name the workspace picker lists. */ export const NEW_TASK_PROJECT_NAME = 'new-task-project'; +/** + * Restore the navigation column through the titlebar action when a test needs + * controls that only exist in the expanded sidebar. The fixture starts with + * the sidebar collapsed, and the action follows the configured UI locale. + */ +export async function ensureSidebarExpanded(page: Page): Promise { + const expandSidebar = page.getByRole('button', { + name: /^(?:展开侧边栏|Expand sidebar)$/, + }); + if (!(await expandSidebar.isVisible())) return; + + await expandSidebar.click(); + await expect( + page.getByRole('button', { name: /^(?:收起侧边栏|Collapse sidebar)$/ }), + ).toBeVisible(); +} + +/** + * Wait for the default Host's Coordination Session and the WorkHub projection + * to agree that the surface is ready. A mounted WorkHub main is not sufficient: + * it is also rendered while the Host reconnects and the projection reloads. + */ +export async function waitForWorkHubReady(page: Page, workCount: number): Promise { + await expect + .poll(async () => { + const snapshot = await page.evaluate(() => window.maka.runtimeHostProfiles.getSnapshot()); + return snapshot.entries.find(({ isDefault }) => isDefault)?.readiness; + }) + .toBe('ready'); + await expect(page.getByText(`${workCount} 项工作`, { exact: true })).toBeVisible(); +} + /** * Wait for Runtime's authoritative Skill projection, not merely for the * composer DOM to mount. The renderer requests this projection after its first @@ -150,6 +180,28 @@ async function seedE2eLocale(userDataDir: string, locale: 'zh' | 'en'): Promise< }); } +/** Rows for the rail-render contract: enough that a stray render is loud. */ +export const RAIL_RENDER_SESSION_COUNT = 12; + +async function seedRailRenderSessions(userDataDir: string): Promise { + const workspaceRoot = path.join(userDataDir, 'workspaces', 'default'); + const store = createSessionStore(workspaceRoot); + try { + for (let index = 0; index < RAIL_RENDER_SESSION_COUNT; index += 1) { + await store.create({ + cwd: path.join(userDataDir, 'project'), + llmConnectionSlug: 'e2e', + model: 'claude-sonnet-4-5-20250929', + permissionMode: 'ask', + name: `Rail row ${index}`, + labels: [], + }); + } + } finally { + await store.close?.(); + } +} + async function seedParentRemovalSessions(userDataDir: string): Promise { const workspaceRoot = path.join(userDataDir, 'workspaces', 'default'); const store = createSessionStore(workspaceRoot); @@ -339,6 +391,7 @@ async function withE2eWindow( invocableSkills, gitReviewExtraFiles, parentRemovalSessions, + railRenderSessions, newTaskProject, }: { seed: boolean; @@ -354,9 +407,10 @@ async function withE2eWindow( invocableSkills?: boolean; gitReviewExtraFiles?: number; parentRemovalSessions?: boolean; + railRenderSessions?: boolean; newTaskProject?: boolean; }, - use: (page: Page, context: { userDataDir: string }) => Promise, + use: (page: Page, context: { userDataDir: string; app: ElectronApplication }) => Promise, ): Promise { const userDataDir = await mkdtemp(path.join(tmpdir(), 'maka-e2e-')); // Lives inside the throwaway userData dir so the existing teardown removes @@ -369,6 +423,7 @@ async function withE2eWindow( try { if (seed) await seedE2eConnection(userDataDir); if (parentRemovalSessions) await seedParentRemovalSessions(userDataDir); + if (railRenderSessions) await seedRailRenderSessions(userDataDir); if (invocableSkills) await seedE2eInvocableSkills(userDataDir); if (gitReviewExtraFiles !== undefined) { await seedE2eGitReviewProject(userDataDir, gitReviewExtraFiles); @@ -422,7 +477,7 @@ async function withE2eWindow( const rendererDetail = rendererLogs.length > 0 ? `\nRenderer console:\n${rendererLogs.join('\n')}` : ''; throw new Error(`${detail}${mainDetail}${rendererDetail}`, { cause: error }); } - await use(page, { userDataDir }); + await use(page, { userDataDir, app }); } finally { try { if (app) await closeElectronApplication(app, 5_000); @@ -440,11 +495,32 @@ export const test = base.extend<{ linkColorWindow: Page; projectSidebarWindow: Page; parentRemovalWindow: Page; + railRenderWindow: Page; promptRailWindow: Page; + partialHistoryWindow: Page; promptRailMotionWindow: Page; requestHeaderRowWindow: Page; newTaskTargetWindow: Page; + directoryReferenceWindow: { page: Page; folder: string }; + accessibilityNarrativeWindow: Page; }>({ + directoryReferenceWindow: async ({}, use) => { + await withE2eWindow( + { seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh', showWindow: true }, + async (page, { userDataDir, app }) => { + const folder = path.join(userDataDir, 'referenced-source'); + await mkdir(path.join(folder, 'nested'), { recursive: true }); + await writeFile(path.join(folder, 'README.md'), 'DO_NOT_READ_FILE_CONTENTS'); + await writeFile(path.join(folder, 'nested', 'deep.txt'), 'DO_NOT_DESCEND'); + // Replace only the OS chooser. IPC, Host admission, message delivery, + // event persistence and rendering still run through the real stack. + await app.evaluate(({ dialog }, selectedPath) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [selectedPath] }); + }, folder); + await use({ page, folder }); + }, + ); + }, // Seeded: a pre-staged connection clears onboarding so the composer is ready. window: async ({}, use) => { await withE2eWindow({ seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh' }, use); @@ -522,6 +598,17 @@ export const test = base.extend<{ use, ); }, + railRenderWindow: async ({}, use) => { + await withE2eWindow( + { + seed: true, + readinessSelector: COMPOSER_INPUT, + locale: 'zh', + railRenderSessions: true, + }, + use, + ); + }, // A multi-prompt transcript for the prompt anchor rail. Shown, because every // assertion in prompt-rail.spec.ts is geometry the compositor has to settle. promptRailWindow: async ({}, use) => { @@ -536,6 +623,17 @@ export const test = base.extend<{ showWindow: true, }, use); }, + // A transcript larger than the bounded Desktop range. Clicking an unloaded + // prompt exercises the real load-around path and its partial-history UI. + partialHistoryWindow: async ({}, use) => { + await withE2eWindow({ + seed: false, + readinessSelector: '[data-turn-id]', + e2eFixtureScenario: 'chat-partial-history', + locale: 'zh', + showWindow: true, + }, use); + }, // The same transcript, scrolling the way the shipped app scrolls. Separate // from `promptRailWindow` because it is only the jump that needs a scroll // still in flight, and paying for one everywhere costs several seconds per @@ -562,6 +660,18 @@ export const test = base.extend<{ showWindow: true, }, use); }, + // A data-backed conversation with settled tool evidence and a populated + // task ledger. Shown because the accessibility journey follows real native + // focus order through the transcript into the composer controls. + accessibilityNarrativeWindow: async ({}, use) => { + await withE2eWindow({ + seed: false, + readinessSelector: '[data-turn-id]', + e2eFixtureScenario: 'turn-narrative', + locale: 'zh', + showWindow: true, + }, use); + }, }); export { expect }; diff --git a/apps/desktop/e2e/goal-dialog-budget.spec.ts b/apps/desktop/e2e/goal-dialog-budget.spec.ts index 28f7d1efc1..14b56668e0 100644 --- a/apps/desktop/e2e/goal-dialog-budget.spec.ts +++ b/apps/desktop/e2e/goal-dialog-budget.spec.ts @@ -82,4 +82,20 @@ test('an unsendable budget blocks Start instead of arming a different one', asyn }; }) .toEqual({ condition: '所有测试通过', maxIterations: 25, tokenBudget: 5000 }); + + // The Host read above proves persistence; this proves the same broadcast now + // reaches the provider-owned chat projection without AppShell reading it. + const goalContext = page + .getByRole('region', { name: '任务上下文' }) + .filter({ visible: true }); + await expect( + goalContext + .getByText(/目标 0 \/ 25 · .* · 0 \/ 5k/) + .filter({ visible: true }), + ).toBeVisible(); + await expect( + goalContext + .getByRole('button', { name: '暂停自主执行目标(已进行 0/25 轮)' }) + .filter({ visible: true }), + ).toBeVisible(); }); diff --git a/apps/desktop/e2e/module-hub.spec.ts b/apps/desktop/e2e/module-hub.spec.ts new file mode 100644 index 0000000000..91609cfcfd --- /dev/null +++ b/apps/desktop/e2e/module-hub.spec.ts @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { expect, test } from './fixtures'; + +test('Module Hub switches all four leaves and opens scheduled creation once', async ({ + window: page, +}) => { + const expand = page.getByRole('button', { name: '展开侧边栏' }); + if (await expand.isVisible()) await expand.click(); + const sidebar = page.getByRole('navigation', { name: '任务列表' }); + + await sidebar.getByRole('button', { name: '扩展', exact: true }).click(); + await expect(page.locator('[data-module="skills"]')).toBeVisible(); + const extensions = page.getByRole('navigation', { name: /扩展内容/ }); + await extensions.getByRole('button', { name: 'MCP', exact: true }).click(); + await expect( + extensions.getByRole('button', { name: 'MCP', exact: true }), + ).toHaveAttribute('aria-current', 'true'); + + await sidebar.getByRole('button', { name: /定时任务/ }).click(); + await expect(page.locator('[data-module="scheduled-tasks"]')).toBeVisible(); + const automations = page.getByRole('navigation', { name: /定时任务内容/ }); + await automations.getByRole('button', { name: '每日回顾', exact: true }).click(); + await expect(page.locator('[data-module="daily-review"]')).toBeVisible(); + + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k'); + const palette = page.getByRole('dialog', { name: '命令面板' }); + await expect(palette).toBeVisible(); + await palette.getByRole('option', { name: /新建定时任务/ }).click(); + + const createDialog = page.getByRole('dialog', { name: '新建定时任务' }); + await expect(createDialog).toBeVisible(); + await expect(createDialog).toHaveCount(1); + await expect(page.locator('[data-module="scheduled-tasks"]')).toBeVisible(); +}); diff --git a/apps/desktop/e2e/native-transcript-perf.spec.ts b/apps/desktop/e2e/native-transcript-perf.spec.ts new file mode 100644 index 0000000000..4c7eafb09b --- /dev/null +++ b/apps/desktop/e2e/native-transcript-perf.spec.ts @@ -0,0 +1,441 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { CDPSession, Page } from '@playwright/test'; +import { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers'; +import * as transcriptContract from '../src/preload/transcript-contract'; +import { ensureSidebarExpanded, expect, test } from './fixtures'; + +const PERF_ENABLED = process.env.MAKA_TRANSCRIPT_PERF === '1'; +const STRESS_ENABLED = process.env.MAKA_TRANSCRIPT_STRESS === '1'; +const SCROLLER = '[data-chat-scroll-container="true"]'; + +interface BrowserCounters { + heapBytes: number; + nodes: number; + documents: number; + jsEventListeners: number; +} + +interface FrameSample { + intervals: number[]; + loafDurations: number[]; + loafSupported: boolean; +} + +interface StressSample extends BrowserCounters { + sweep: number; + iteration: number; + firstTurnId: string | null; + lastTurnId: string | null; + mountedTurns: number; +} + +interface StressSweep { + sweep: number; + successfulPages: number; + samples: StressSample[]; +} + +interface HeapGrowth { + endpointRatio: number; + slopeBytesPerPage: number; + projectedRatio: number; +} + +/** The predeclared secondary heap/DOM release gate permits at most 10% growth. */ +const SECONDARY_RESOURCE_GROWTH_RATIO = 0.1; + +function positiveHeapGrowth(samples: readonly StressSample[]): HeapGrowth { + const first = samples[0]!; + const last = samples.at(-1)!; + const meanIteration = samples.reduce((sum, sample) => sum + sample.iteration, 0) + / samples.length; + const meanHeap = samples.reduce((sum, sample) => sum + sample.heapBytes, 0) + / samples.length; + const slopeNumerator = samples.reduce( + (sum, sample) => sum + (sample.iteration - meanIteration) * (sample.heapBytes - meanHeap), + 0, + ); + const slopeDenominator = samples.reduce( + (sum, sample) => sum + (sample.iteration - meanIteration) ** 2, + 0, + ); + const slopeBytesPerPage = slopeDenominator === 0 ? 0 : slopeNumerator / slopeDenominator; + const iterationSpan = last.iteration - first.iteration; + return { + endpointRatio: Math.max(0, last.heapBytes - first.heapBytes) / first.heapBytes, + slopeBytesPerPage, + projectedRatio: Math.max(0, slopeBytesPerPage * iterationSpan) / first.heapBytes, + }; +} + +function percentile(values: readonly number[], probability: number): number { + if (values.length === 0) return 0; + const ordered = [...values].sort((left, right) => left - right); + return ordered[Math.min(ordered.length - 1, Math.ceil(probability * ordered.length) - 1)]!; +} + +async function collectGarbage(cdp: CDPSession): Promise { + await cdp.send('HeapProfiler.enable'); + await cdp.send('HeapProfiler.collectGarbage'); +} + +async function browserCounters(cdp: CDPSession): Promise { + const [heap, dom] = await Promise.all([ + cdp.send('Runtime.getHeapUsage'), + cdp.send('Memory.getDOMCounters'), + ]); + return { + heapBytes: heap.usedSize, + nodes: dom.nodes, + documents: dom.documents, + jsEventListeners: dom.jsEventListeners, + }; +} + +async function performanceMetrics(cdp: CDPSession): Promise> { + const result = await cdp.send('Performance.getMetrics'); + return new Map(result.metrics.map(({ name, value }) => [name, value])); +} + +function metricDelta( + before: ReadonlyMap, + after: ReadonlyMap, + name: string, +): number { + return (after.get(name) ?? 0) - (before.get(name) ?? 0); +} + +async function prepareFrameRecorder(page: Page): Promise { + await page.evaluate(() => { + const state: FrameSample & { lastFrame: number | null; running: boolean } = { + intervals: [], + loafDurations: [], + loafSupported: PerformanceObserver.supportedEntryTypes + .includes('long-animation-frame'), + lastFrame: null, + running: false, + }; + Object.assign(window, { __makaTranscriptPerf: state }); + if (state.loafSupported) { + try { + const observer = new PerformanceObserver((list) => { + if (!state.running) return; + state.loafDurations.push(...list.getEntries().map((entry) => entry.duration)); + }); + observer.observe({ type: 'long-animation-frame', buffered: false }); + } catch { + state.loafSupported = false; + } + } + }); +} + +async function scrollGesture(page: Page, delta: number, frames = 240): Promise { + return page.evaluate(async ({ selector, delta, frames }) => { + type Recorder = FrameSample & { lastFrame: number | null; running: boolean }; + const root = document.querySelector(selector); + const recorder = (window as Window & { __makaTranscriptPerf?: Recorder }) + .__makaTranscriptPerf; + if (!root || !recorder) throw new Error('the transcript performance probe is missing'); + recorder.intervals.length = 0; + recorder.loafDurations.length = 0; + recorder.lastFrame = null; + recorder.running = true; + const start = root.scrollTop; + await new Promise((resolve) => { + let completed = 0; + const tick = (now: number) => { + if (recorder.lastFrame !== null) recorder.intervals.push(now - recorder.lastFrame); + recorder.lastFrame = now; + completed += 1; + root.scrollTop = start + (delta * completed) / frames; + if (completed >= frames) { + resolve(); + return; + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + recorder.running = false; + return { + intervals: [...recorder.intervals], + loafDurations: [...recorder.loafDurations], + loafSupported: recorder.loafSupported, + }; + }, { selector: SCROLLER, delta, frames }); +} + +async function moveToTail(page: Page): Promise { + await page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + root.scrollTop = root.scrollHeight; + }, SCROLLER); +} + +async function returnToLatest(page: Page): Promise { + const returnLatest = page.getByRole('button', { + name: /^(?:返回最新消息|Return to latest)$/, + }); + if (await returnLatest.isVisible()) await returnLatest.click(); + else await page.locator('.maka-prompt-rail-tick').last().click({ force: true }); +} + +async function traverseFullHistoryAndReturnToTail(page: Page): Promise { + for (let iteration = 0; iteration < PROMPT_RAIL_PROMPT_COUNT; iteration += 1) { + const firstBefore = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'); + if (firstBefore?.endsWith('-1')) break; + await page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + root.scrollTop = 0; + root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); + }, SCROLLER); + await expect.poll(async () => + page.locator('[data-turn-id]').first().getAttribute('data-turn-id'), + ).not.toBe(firstBefore); + } + await expect(page.locator('[data-turn-id="turn-prompt-rail-1"]')).toHaveCount(1); + await returnToLatest(page); + await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) + .toHaveCount(1); +} + +async function measureSessionSwitch(page: Page): Promise { + await ensureSidebarExpanded(page); + const rows = page.locator('.maka-session-row'); + const selected = rows.locator('button.astryx-side-nav-item.selected'); + const originalId = await selected.evaluate((button) => + button.closest('.maka-session-row')?.getAttribute('data-session-id'), + ); + if (!originalId) throw new Error('the prompt-rail Session is not selected'); + const otherId = await rows.evaluateAll( + (elements, current) => elements + .map((element) => element.getAttribute('data-session-id')) + .find((sessionId) => sessionId !== current) ?? null, + originalId, + ); + if (!otherId) throw new Error('the fixture has no second Session'); + const start = performance.now(); + await page.locator(`.maka-session-row[data-session-id=${JSON.stringify(otherId)}] button`) + .first() + .click(); + await expect(page.locator( + `.maka-session-row[data-session-id=${JSON.stringify(otherId)}] button.selected`, + )).toHaveCount(1); + await page.locator(`.maka-session-row[data-session-id=${JSON.stringify(originalId)}] button`) + .first() + .click(); + await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); + return performance.now() - start; +} + +test('LoAF capability is explicit when Chromium cannot measure it', async ({ + promptRailWindow: page, +}) => { + test.skip(!PERF_ENABLED, 'manual same-build CDP A/B harness'); + await page.evaluate(() => { + Object.defineProperty(PerformanceObserver, 'supportedEntryTypes', { + configurable: true, + value: [], + }); + }); + await prepareFrameRecorder(page); + const frames = await scrollGesture(page, -1, 2); + expect(frames.loafSupported).toBe(false); +}); + +test('warm native transcript scroll metrics', async ({ promptRailWindow: page }) => { + test.skip(!PERF_ENABLED, 'manual same-build CDP A/B harness'); + test.setTimeout(90_000); + await page.setViewportSize({ width: 1_000, height: 700 }); + await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); + const cdp = await page.context().newCDPSession(page); + await cdp.send('Performance.enable'); + await prepareFrameRecorder(page); + await traverseFullHistoryAndReturnToTail(page); + await moveToTail(page); + + // Warm Chromium, React and the transcript path in both directions before sampling. + await scrollGesture(page, -600, 120); + await scrollGesture(page, 600, 120); + await moveToTail(page); + await collectGarbage(cdp); + await page.evaluate(() => new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + )); + const before = await performanceMetrics(cdp); + const frames = await scrollGesture(page, -600); + expect( + frames.loafSupported, + 'Chromium does not support the long-animation-frame release metric', + ).toBe(true); + const after = await performanceMetrics(cdp); + await collectGarbage(cdp); + const counters = await browserCounters(cdp); + const sourceTurns = await page.locator('[data-turn-source-count]').first() + .getAttribute('data-turn-source-count'); + const mountedTurns = await page.locator('[data-turn-id]').count(); + const domElements = await page.locator('*').count(); + const sessionSwitchMs = await measureSessionSwitch(page); + const result = { + sourceTurns: Number(sourceTurns), + mountedTurns, + domElements, + taskMs: metricDelta(before, after, 'TaskDuration') * 1_000, + scriptMs: metricDelta(before, after, 'ScriptDuration') * 1_000, + layoutMs: metricDelta(before, after, 'LayoutDuration') * 1_000, + recalcStyleMs: metricDelta(before, after, 'RecalcStyleDuration') * 1_000, + heapBytes: counters.heapBytes, + nodes: counters.nodes, + documents: counters.documents, + jsEventListeners: counters.jsEventListeners, + frameCount: frames.intervals.length, + frameP95Ms: percentile(frames.intervals, 0.95), + frameP99Ms: percentile(frames.intervals, 0.99), + frameMaxMs: Math.max(...frames.intervals), + framesOver12_5Ms: frames.intervals.filter((duration) => duration > 12.5).length, + loafOver50Ms: frames.loafDurations.filter((duration) => duration > 50).length, + loafMaxMs: Math.max(0, ...frames.loafDurations), + loafSupported: frames.loafSupported, + sessionSwitchMs, + }; + console.log(`TRANSCRIPT_PERF ${JSON.stringify(result)}`); +}); + +test('600+ Turn repeated paging keeps the active range on a memory plateau', async ({ + promptRailWindow: page, +}) => { + test.skip(!STRESS_ENABLED, 'manual 600+ Turn stress harness'); + test.setTimeout(180_000); + await page.setViewportSize({ width: 1_000, height: 700 }); + const cdp = await page.context().newCDPSession(page); + expect(PROMPT_RAIL_PROMPT_COUNT).toBeGreaterThanOrEqual(600); + const sweeps: StressSweep[] = []; + const heapGrowth: Array = []; + const captureSample = async (sweep: number, iteration: number): Promise => { + await collectGarbage(cdp); + const counters = await browserCounters(cdp); + const sample = { + sweep, + iteration, + firstTurnId: await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'), + lastTurnId: await page.locator('[data-turn-id]').last().getAttribute('data-turn-id'), + mountedTurns: await page.locator('[data-turn-id]').count(), + ...counters, + }; + return sample; + }; + + // Warm the paging composition once, independent of the fixture's history depth. + const latestFirstTurn = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'); + await page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + root.scrollTop = 0; + root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); + }, SCROLLER); + await expect.poll(async () => + page.locator('[data-turn-id]').first().getAttribute('data-turn-id'), + ).not.toBe(latestFirstTurn); + await returnToLatest(page); + await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) + .toHaveCount(1); + + for (let sweep = 1; sweep <= 2; sweep += 1) { + const samples: StressSample[] = [await captureSample(sweep, 0)]; + let successfulPages = 0; + for (let iteration = 1; iteration <= PROMPT_RAIL_PROMPT_COUNT; iteration += 1) { + const firstBefore = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'); + if (firstBefore?.endsWith('-1')) break; + await page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + root.scrollTop = 0; + root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); + }, SCROLLER); + await expect.poll(async () => + page.locator('[data-turn-id]').first().getAttribute('data-turn-id'), + ).not.toBe(firstBefore); + successfulPages += 1; + if (iteration % 10 !== 0) continue; + samples.push(await captureSample(sweep, iteration)); + } + await expect(page.locator('[data-turn-id]').first()) + .toHaveAttribute('data-turn-id', 'turn-prompt-rail-1'); + if (samples.at(-1)!.iteration !== successfulPages) { + samples.push(await captureSample(sweep, successfulPages)); + } + sweeps.push({ sweep, successfulPages, samples }); + const growth = { sweep, ...positiveHeapGrowth(samples) }; + heapGrowth.push(growth); + console.log(`TRANSCRIPT_STRESS_SWEEP ${JSON.stringify({ + sweep, + successfulPages, + samples, + heapGrowth: growth, + })}`); + expect(growth.endpointRatio).toBeLessThanOrEqual(SECONDARY_RESOURCE_GROWTH_RATIO); + expect(growth.projectedRatio).toBeLessThanOrEqual(SECONDARY_RESOURCE_GROWTH_RATIO); + if (sweep < 2) { + await returnToLatest(page); + await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) + .toHaveCount(1); + } + } + + const firstSweep = sweeps[0]!; + const secondSweep = sweeps[1]!; + expect(firstSweep.successfulPages).toBeGreaterThan(0); + expect(secondSweep.successfulPages).toBeGreaterThan(0); + const firstNodesByIteration = new Map( + firstSweep.samples.map((sample) => [sample.iteration, sample.nodes]), + ); + let nodeMaxSecondToFirstRatio = 0; + for (const [index, sample] of secondSweep.samples.entries()) { + const firstNodes = index === secondSweep.samples.length - 1 + ? firstSweep.samples.at(-1)!.nodes + : firstNodesByIteration.get(sample.iteration); + expect(firstNodes).toBeDefined(); + const ratio = sample.nodes / firstNodes!; + nodeMaxSecondToFirstRatio = Math.max(nodeMaxSecondToFirstRatio, ratio); + } + + const allSamples = sweeps.flatMap((sweep) => sweep.samples); + const mountedMax = Math.max(...allSamples.map((sample) => sample.mountedTurns)); + console.log(`TRANSCRIPT_STRESS ${JSON.stringify({ + fixtureTurns: PROMPT_RAIL_PROMPT_COUNT, + sweeps, + mountedMax, + nodeMin: Math.min(...allSamples.map((sample) => sample.nodes)), + nodeMax: Math.max(...allSamples.map((sample) => sample.nodes)), + nodeMaxSecondToFirstRatio, + heapGrowth, + })}`); + expect(mountedMax).toBeLessThanOrEqual( + transcriptContract.DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, + ); + expect(nodeMaxSecondToFirstRatio).toBeLessThanOrEqual( + 1 + SECONDARY_RESOURCE_GROWTH_RATIO, + ); +}); diff --git a/apps/desktop/e2e/new-task-draft-target.spec.ts b/apps/desktop/e2e/new-task-draft-target.spec.ts index ce7b554c5b..866d109eb8 100644 --- a/apps/desktop/e2e/new-task-draft-target.spec.ts +++ b/apps/desktop/e2e/new-task-draft-target.spec.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { Page } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; import { COMPOSER_INPUT, NEW_TASK_PROJECT_NAME, expect, test } from './fixtures'; /** @@ -50,6 +50,15 @@ async function settle(page: Page): Promise { ); } +// Picking an item hides the menu, and DropdownMenu then swallows a reopen +// click for 50ms. +async function openPicker(page: Page, picker: Locator): Promise { + await expect(async () => { + await picker.click(); + await expect(page.getByRole('menuitem').first()).toBeVisible({ timeout: 500 }); + }).toPass({ timeout: 10_000 }); +} + test('the new-task draft follows the Project chosen under the composer', async ({ newTaskTargetWindow: page, }) => { @@ -62,7 +71,9 @@ test('the new-task draft follows the Project chosen under the composer', async ( await expect(picker).toHaveAttribute('aria-label', new RegExp(NEW_TASK_PROJECT_NAME)); await composer.click(); - await page.keyboard.type(DRAFT); + // Two keystrokes in one frame — no human rate — make ChatComposerInput + // rewrite the editor and reset the caret. + await page.keyboard.type(DRAFT, { delay: 20 }); await expect(composer).toHaveText(DRAFT); await picker.click(); @@ -74,7 +85,7 @@ test('the new-task draft follows the Project chosen under the composer', async ( await settle(page); await expect(composer).toHaveText(DRAFT); - await picker.click(); + await openPicker(page, picker); await page.getByRole('menuitem', { name: NEW_TASK_PROJECT_NAME, exact: true }).click(); await expect(picker).toHaveAttribute('aria-label', new RegExp(NEW_TASK_PROJECT_NAME)); await settle(page); diff --git a/apps/desktop/e2e/new-task-reload.spec.ts b/apps/desktop/e2e/new-task-reload.spec.ts index 1afcc3b21e..0c2c30fd95 100644 --- a/apps/desktop/e2e/new-task-reload.spec.ts +++ b/apps/desktop/e2e/new-task-reload.spec.ts @@ -17,7 +17,7 @@ * under the License. */ -import { COMPOSER_INPUT, expect, test } from './fixtures'; +import { COMPOSER_INPUT, ensureSidebarExpanded, expect, test } from './fixtures'; test('an explicit new task survives a renderer reload without reopening history', async ({ window: page, @@ -27,6 +27,7 @@ test('an explicit new task survives a renderer reload without reopening history' await composer.press('Enter'); await expect(page.getByText(/Fake backend received: create history/)).toBeVisible(); + await ensureSidebarExpanded(page); await page.getByRole('button', { name: '新任务', exact: true }).click(); await expect(page.locator('.maka-turn')).toHaveCount(0); await composer.fill('draft survives renderer replacement'); diff --git a/apps/desktop/e2e/onboarding-viewport.spec.ts b/apps/desktop/e2e/onboarding-viewport.spec.ts index aec6a1683f..0605dd3b49 100644 --- a/apps/desktop/e2e/onboarding-viewport.spec.ts +++ b/apps/desktop/e2e/onboarding-viewport.spec.ts @@ -41,6 +41,7 @@ test('first-run onboarding stays within the chat viewport', async ({ onboardingW surfaceBottom: surfaceRect.bottom, viewportTop: scrollRect.top, viewportBottom: scrollRect.bottom, + onboardingLayout: scrollContainer.dataset.makaOnboarding, cardTop: cardRect.top, cardBottom: cardRect.bottom, }; @@ -50,6 +51,7 @@ test('first-run onboarding stays within the chat viewport', async ({ onboardingW geometry.pageClientHeight, ); expect(geometry.scrollHeight, JSON.stringify(geometry, null, 2)).toBe(geometry.clientHeight); + expect(geometry.onboardingLayout).toBe('true'); expect(geometry.surfaceTop).toBeGreaterThanOrEqual(geometry.viewportTop); expect(geometry.surfaceBottom).toBeLessThanOrEqual(geometry.viewportBottom); expect(geometry.cardTop).toBeGreaterThanOrEqual(geometry.viewportTop); diff --git a/apps/desktop/e2e/parent-session-deletion.spec.ts b/apps/desktop/e2e/parent-session-deletion.spec.ts index cdee277d19..629022dfe4 100644 --- a/apps/desktop/e2e/parent-session-deletion.spec.ts +++ b/apps/desktop/e2e/parent-session-deletion.spec.ts @@ -43,6 +43,10 @@ test('deleting a parent task archives its linked subagent task', async ({ name: `删除 "${PARENT_REMOVAL_PARENT_NAME}"`, }); await expect(confirm).toBeVisible(); + // The confirm warns that the linked subtask is kept and archived rather than + // destroyed, so the archived row that appears next is not a surprise. It names + // no count — the Host owns the exact number and reports it in the toast. + await expect(confirm.getByText(/子任务.*归档/)).toBeVisible(); await confirm.getByRole('button', { name: '删除', exact: true }).click(); await expect(parentRow).toHaveCount(0); diff --git a/apps/desktop/e2e/partial-history-notice.spec.ts b/apps/desktop/e2e/partial-history-notice.spec.ts new file mode 100644 index 0000000000..9b70c12b6a --- /dev/null +++ b/apps/desktop/e2e/partial-history-notice.spec.ts @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Page } from '@playwright/test'; +import { expect, test } from './fixtures'; + +const NOTICE = '.maka-transcript-history-controls'; + +async function waitForPaint(page: Page): Promise { + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + })); +} + +async function noticePresentation(page: Page) { + return page.locator(NOTICE).evaluate((notice) => { + const style = getComputedStyle(notice); + const box = notice.getBoundingClientRect(); + const composer = document.querySelector('.maka-composer-astryx'); + if (!composer) throw new Error('the composer is missing'); + const composerBox = composer.getBoundingClientRect(); + return { + backgroundColor: style.backgroundColor, + borderWidths: [ + style.borderTopWidth, + style.borderRightWidth, + style.borderBottomWidth, + style.borderLeftWidth, + ], + display: style.display, + flexWrap: style.flexWrap, + justifyContent: style.justifyContent, + widthDelta: Math.abs(box.width - composerBox.width), + centerDelta: Math.abs( + (box.left + box.right) / 2 - (composerBox.left + composerBox.right) / 2, + ), + fitsViewport: box.left >= 0 && box.right <= document.documentElement.clientWidth, + hasHorizontalOverflow: notice.scrollWidth > notice.clientWidth, + }; + }); +} + +test('partial history is a quiet reading-column control with neutral rail ticks', async ({ + partialHistoryWindow: page, +}) => { + await page.setViewportSize({ width: 1_400, height: 800 }); + await expect(page.locator(NOTICE)).toHaveCount(0); + + const firstPrompt = page.locator( + '.maka-prompt-rail-tick[data-prompt-turn-id="turn-partial-history-1"]', + ); + await expect(firstPrompt).toBeVisible(); + await firstPrompt.click(); + + const notice = page.locator(NOTICE); + await expect(notice).toBeVisible(); + await expect(notice).toContainText('正在查看较早的消息'); + await expect(notice.getByRole('button', { name: '返回最新消息' })).toBeVisible(); + await expect(notice).not.toContainText(/保存|加载/); + + const regular = await noticePresentation(page); + expect(regular).toEqual({ + backgroundColor: 'rgba(0, 0, 0, 0)', + borderWidths: ['0px', '0px', '0px', '0px'], + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'center', + widthDelta: expect.any(Number), + centerDelta: expect.any(Number), + fitsViewport: true, + hasHorizontalOverflow: false, + }); + expect(regular.widthDelta).toBeLessThanOrEqual(1); + expect(regular.centerDelta).toBeLessThanOrEqual(1); + + await page.mouse.move(0, 0); + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); + const railPresentation = await page.evaluate(() => { + const ticks = [...document.querySelectorAll('.maka-prompt-rail-tick')]; + const presentation = (tick: HTMLElement) => { + const bar = tick.querySelector('.maka-prompt-rail-tick-bar'); + if (!bar) throw new Error('a prompt rail tick is missing its bar'); + const style = getComputedStyle(bar); + return { + backgroundColor: style.backgroundColor, + borderStyle: style.borderStyle, + borderWidth: style.borderWidth, + boxShadow: style.boxShadow, + }; + }; + const neutralPaint = ticks + .filter((tick) => tick.dataset.active !== 'true' && !tick.matches(':hover')) + .map(presentation); + const residentStyleRules = [...document.styleSheets].flatMap((sheet) => + [...sheet.cssRules].filter((rule) => rule.cssText.includes('data-resident')) + ); + return { + residentAttributeCount: document.querySelectorAll('[data-resident]').length, + residentStyleRuleCount: residentStyleRules.length, + neutralTickCount: neutralPaint.length, + neutralPaintCount: new Set(neutralPaint.map((paint) => JSON.stringify(paint))).size, + }; + }); + expect(railPresentation.residentAttributeCount).toBe(0); + expect(railPresentation.residentStyleRuleCount).toBe(0); + expect(railPresentation.neutralTickCount).toBeGreaterThan(1); + expect(railPresentation.neutralPaintCount).toBe(1); + + await page.setViewportSize({ width: 520, height: 720 }); + await waitForPaint(page); + const narrow = await noticePresentation(page); + expect(narrow.centerDelta).toBeLessThanOrEqual(1); + expect(narrow.fitsViewport).toBe(true); + expect(narrow.hasHorizontalOverflow).toBe(false); + + await notice.getByRole('button', { name: '返回最新消息' }).click(); + await expect(notice).toHaveCount(0); + await expect( + page.locator('[data-turn-id="turn-partial-history-8"]'), + ).toBeVisible(); +}); diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts index acef92f51e..efbcec3dd0 100644 --- a/apps/desktop/e2e/prompt-rail.spec.ts +++ b/apps/desktop/e2e/prompt-rail.spec.ts @@ -18,7 +18,8 @@ */ import { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers'; -import { expect, test } from './fixtures'; +import { DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS } from '../src/preload/transcript-contract'; +import { ensureSidebarExpanded, expect, test } from './fixtures'; import type { Page } from '@playwright/test'; const MAX_PROMPT_RAIL_TICKS = 64; @@ -91,6 +92,22 @@ async function scrollTranscriptTo(page: Page, position: 'top' | 'bottom'): Promi if (!scroller) throw new Error('the chat scroll container is missing'); scroller.scrollTop = where === 'top' ? 0 : scroller.scrollHeight; }, position); + await notifyTranscriptScrolled(page); + await waitForPaintedFrames(page); +} + +async function scrollTranscriptAwayFromTail(page: Page): Promise { + await page.evaluate(() => { + const root = document.querySelector('[data-chat-scroll-container="true"]'); + if (!root) throw new Error('the chat scroll container is missing'); + const historyLoadBand = Math.max(640, root.clientHeight * 2); + root.scrollTop = Math.min( + root.scrollHeight - root.clientHeight - 100, + historyLoadBand + 200, + ); + root.dispatchEvent(new Event('scroll')); + }); + await waitForPaintedFrames(page); } async function waitForPaintedFrames(page: Page, count = 2): Promise { @@ -114,15 +131,6 @@ function notifyTranscriptScrolled(page: Page): Promise { }); } -async function loadPromptRailBeyondVirtualWindow(page: Page): Promise { - const transcript = page.locator('.maka-chat-message-list'); - await scrollTranscriptTo(page, 'top'); - await transcript.hover(); - await page.mouse.wheel(0, -100); - await expect.poll(async () => Number(await transcript.getAttribute('data-turn-source-count'))) - .toBeGreaterThan(100); -} - test('every tick paints a bar with a real box', async ({ promptRailWindow: page }) => { // Measured over ALL ticks, not a sample: a helper that skips what it cannot // evaluate creates its blind spot exactly where a regression lives. @@ -245,13 +253,16 @@ test('the first click of a session lands on its prompt and holds', async ({ // Bounded on both sides: below is the turn never arriving, above is it // arriving and then being pulled off the top of the scrollport. await expect - .poll(async () => (await landing())?.offset, { message: 'the clicked prompt reaches the top' }) - .toBeGreaterThan(-24); - expect((await landing())?.offset).toBeLessThan(24); + .poll(async () => { + const offset = (await landing())?.offset; + return offset === undefined ? Number.POSITIVE_INFINITY : Math.abs(offset); + }, { message: 'the clicked prompt reaches the top' }) + .toBeLessThan(24); expect((await landing())?.tickIsCurrent).toBe(true); - // And stays: the fill runs on idle callbacks after the jump, so a jump that - // only wins the first frame reads as landing and then sliding away. + // And stays: turns keep resolving their content and remeasuring after the + // jump, so a jump that only wins the first frame reads as landing and then + // sliding away. await page.waitForTimeout(1_200); const settled = await landing(); expect(settled?.offset).toBeGreaterThan(-24); @@ -259,43 +270,37 @@ test('the first click of a session lands on its prompt and holds', async ({ expect(settled?.tickIsCurrent).toBe(true); }); -test('long transcripts keep a bounded mounted turn window', async ({ +test('active transcript Turns keep stable DOM identities while scrolling', async ({ promptRailWindow: page, }) => { - const count = async () => page.locator('[data-virtual-turn-id]').count(); - await page.locator('[data-chat-scroll-container="true"][data-turn-window="ready"]').waitFor(); - await loadPromptRailBeyondVirtualWindow(page); - expect(await page.evaluate(() => { - const transcript = document.querySelector('.maka-chat-message-list'); - const rows = transcript?.firstElementChild; - const turn = document.querySelector('[data-virtual-turn-id]'); - if (!rows || !turn) throw new Error('the virtual transcript is missing'); - return { - list: Number.parseFloat(getComputedStyle(rows).rowGap), - turn: Number.parseFloat(getComputedStyle(turn).rowGap), - }; - })).toEqual({ list: 16, turn: 16 }); - expect(await count()).toBeGreaterThan(0); - expect(await count()).toBeLessThanOrEqual(100); - await page.locator('.maka-prompt-rail-tick').first().click({ force: true }); - await expect(page.locator('[data-turn-id="turn-prompt-rail-1"]')).toHaveCount(1); - expect(await count()).toBeGreaterThan(0); - expect(await count()).toBeLessThanOrEqual(100); + const sourceCount = Number( + await page.locator('.maka-chat-message-list').getAttribute('data-turn-source-count'), + ); + expect(sourceCount).toBe(DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS); + expect(await page.locator('[data-turn-id]').count()).toBe(sourceCount); + await page.evaluate(() => { + for (const turn of document.querySelectorAll('[data-turn-id]')) { + turn.dataset.stableMountProbe = turn.dataset.turnId; + } + }); + + await scrollTranscriptTo(page, 'bottom'); + await scrollTranscriptAwayFromTail(page); + + expect(await page.locator('[data-turn-id]').count()).toBe(sourceCount); + expect(await page.locator('[data-turn-id][data-stable-mount-probe]').count()).toBe(sourceCount); }); -test('evicting a turn-owned sibling interaction hands focus back to the transcript', async ({ +test('scrolling away preserves a turn-owned focus and selection', async ({ promptRailWindow: page, }) => { - const scroller = page.locator('[data-chat-scroll-container="true"][data-turn-window="ready"]'); - await scroller.waitFor(); - await loadPromptRailBeyondVirtualWindow(page); await scrollTranscriptTo(page, 'bottom'); - await expect(page.locator('[data-virtual-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); - const retainedTurnId = await page.evaluate(() => { - const turns = document.querySelectorAll('[data-virtual-turn-id]'); - const turn = turns.item(turns.length - 1); - if (!turn?.dataset.virtualTurnId) throw new Error('the mounted turn is missing'); + await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); + await page.evaluate(() => { + const turn = document.querySelector('[data-turn-id="turn-prompt-rail-120"]'); + if (!turn) throw new Error('the tail Turn is missing'); const turnOwnedAction = document.createElement('button'); + turnOwnedAction.dataset.turnOwnedAction = 'true'; turnOwnedAction.textContent = 'Turn-owned action'; turn.append(turnOwnedAction); turnOwnedAction.focus(); @@ -304,41 +309,77 @@ test('evicting a turn-owned sibling interaction hands focus back to the transcri const selection = document.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); - return turn.dataset.virtualTurnId; }); + await scrollTranscriptAwayFromTail(page); - // The injected control resizes the tail Turn. That can queue a scroll-anchor - // restore captured at the bottom. Let that setup-only restore finish before - // the one-shot jump so the assertion still catches any later restore that - // would pin the viewport back on the retained Turn (#3121). - await waitForPaintedFrames(page); - await scrollTranscriptTo(page, 'top'); - await notifyTranscriptScrolled(page); - await expect.poll(async () => page.evaluate((turnId) => { - const root = document.querySelector('[data-chat-scroll-container="true"]'); - if (!root) throw new Error('the chat scroll container is missing'); - const mounted = [...document.querySelectorAll('[data-virtual-turn-id]')] - .map((turn) => turn.dataset.virtualTurnId ?? ''); + await expect.poll(async () => page.evaluate(() => { const active = document.activeElement; return { - retained: mounted.includes(turnId), - scrollTop: Math.round(root.scrollTop), - firstMounted: mounted[0] ?? null, - lastMounted: mounted.at(-1) ?? null, - focusOnTranscript: active instanceof HTMLElement - && active.classList.contains('maka-chat-message-list'), - selectionCollapsed: document.getSelection()?.isCollapsed ?? true, + retained: document.querySelector('[data-turn-id="turn-prompt-rail-120"]') !== null, + focusRetained: active instanceof HTMLElement + && active.dataset.turnOwnedAction === 'true', + selectionRetained: document.getSelection()?.isCollapsed === false, }; - }, retainedTurnId), { - message: 'the retained tail turn leaves after one jump to the top', - }).toMatchObject({ - retained: false, - scrollTop: 0, - focusOnTranscript: true, - selectionCollapsed: true, + })).toEqual({ + retained: true, + focusRetained: true, + selectionRetained: true, }); }); +test('offscreen active Turns remain findable and accessible', async ({ + promptRailWindow: page, +}) => { + const firstTurnId = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'); + const turnNumber = Number(firstTurnId?.split('-').at(-1)); + expect(turnNumber).toBeGreaterThan(0); + const needle = `第 ${turnNumber} 个问题`; + await scrollTranscriptTo(page, 'bottom'); + + const found = await page.evaluate((text) => { + document.getSelection()?.removeAllRanges(); + return (window as Window & { find(text: string): boolean }).find(text); + }, needle); + expect(found).toBe(true); + expect(await page.evaluate(() => document.getSelection()?.toString() ?? '')).toContain(needle); + + const cdp = await page.context().newCDPSession(page); + const tree = await cdp.send('Accessibility.getFullAXTree'); + expect(tree.nodes.some((node) => node.name?.value?.includes(needle))).toBe(true); +}); + +test('switching sessions reconstructs only the Host active range', async ({ + promptRailWindow: page, +}) => { + await ensureSidebarExpanded(page); + const rows = page.locator('.maka-session-row'); + const selected = rows.locator('button.astryx-side-nav-item.selected'); + const originalId = await selected + .evaluate((button) => button.closest('.maka-session-row')?.getAttribute('data-session-id')); + if (!originalId) throw new Error('the prompt-rail Session is not selected'); + const otherId = await rows.evaluateAll( + (rows, selected) => rows + .map((row) => row.getAttribute('data-session-id')) + .find((sessionId) => sessionId !== selected) ?? null, + originalId, + ); + if (!otherId) throw new Error('the fixture has no second Session'); + await page.locator(`.maka-session-row[data-session-id=${JSON.stringify(otherId)}] button`) + .first() + .click(); + await expect(page.locator( + `.maka-session-row[data-session-id=${JSON.stringify(otherId)}] button.selected`, + )) + .toHaveCount(1); + + await page.locator(`.maka-session-row[data-session-id=${JSON.stringify(originalId)}] button`) + .first() + .click(); + await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); + expect(await page.locator('[data-turn-id]').count()) + .toBe(DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS); +}); + test('a tick is what the pointer lands on, not the scrollbar', async ({ promptRailWindow: page, }) => { diff --git a/apps/desktop/e2e/request-header-row-contract.spec.ts b/apps/desktop/e2e/request-header-row-contract.spec.ts index 743afe201d..c820b17743 100644 --- a/apps/desktop/e2e/request-header-row-contract.spec.ts +++ b/apps/desktop/e2e/request-header-row-contract.spec.ts @@ -18,7 +18,7 @@ */ import { expect, test } from './fixtures'; -import { getProviderSettingsCopy } from '../src/renderer/locales/settings-provider-copy'; +import { getProviderSettingsCopy } from '../src/renderer/features/connection-settings'; /** * The custom request header row's remove button centres on the FIELD, not on diff --git a/apps/desktop/e2e/session-rail-render-contract.spec.ts b/apps/desktop/e2e/session-rail-render-contract.spec.ts new file mode 100644 index 0000000000..722209475e --- /dev/null +++ b/apps/desktop/e2e/session-rail-render-contract.spec.ts @@ -0,0 +1,240 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { expect, type Page } from '@playwright/test'; +import { + ensureSidebarExpanded, + RAIL_RENDER_SESSION_COUNT, + test, +} from './fixtures.js'; + +/** + * Switching a session moves one row's selection. What it must not do is rewrite + * the rest of the rail. + * + * Deliberately a budget on the OUTCOME rather than an assertion about + * identities or `memo`. The rail's cost has had several independent causes — + * `setActiveId` changing identity every AppShell render, `Intl` formatters + * rebuilt per row, catalog refreshes replacing unchanged row objects — and each + * was invisible to the others. A DOM-write budget catches all of them and the + * ones not yet found, including anything that raises the number of commits a + * switch produces (#4109). + * + * The counter reads inline `style` writes on rail buttons because that is the + * dominant term: every Astryx button removes and re-adds its `anchor-name` per + * render, so one wasted rail render is two style writes per button plus the + * style recalculation they force. + * + * The assertion that carries the contract is `rowsTouched`, not the total. + * Attributing each write to its row makes the budget independent of how many + * rows the fixture seeds, and closes the hole a total-only budget leaves: a + * regression that re-renders the whole rail exactly ONCE stays under any total + * generous enough not to flake, but it cannot touch two rows. That is also the + * missing middle of the fix's own claim — identity is fixed so `memo` holds, and + * `memo` holding means untouched rows produce no DOM work at all. + * + * The last two assertions are the timing half, folded in from #4109's own rail + * budget rather than kept as a second spec: how much was rewritten does not say + * whether the user saw it. The selection must pass through exactly one row on + * its way to the clicked one, and the status badges — which belong to sessions + * whose state did not change — must not be torn down and rebuilt underneath it. + * + * `styleWrites > 0` is the counter's own liveness check. Every write counted + * here comes from an Astryx ref callback that is not wrapped in `useCallback`; + * if that upstream detail is ever memoised, both the healthy and the regressed + * reading collapse to zero and a one-sided budget would pass forever without + * ever failing again. + */ +const RAIL_ROWS_TOUCHED_BUDGET = 2; +/** The leaving row and the arriving row, two writes each, doubled for slack. */ +const RAIL_STYLE_WRITE_BUDGET = 8; + +interface RailCounters { + /** Cumulative since the last `resetRailCounters`; what the budgets read. */ + styleWrites: number; + rowIds: string[]; + rowRemounts: number; + /** + * The selected row after each batch of records, appended only when it moved. + * Counting `aria-current` writes instead would read a re-render that rewrites + * the same selection as the selection moving; this reads what the user sees. + */ + selectedRowIds: (string | null)[]; + /** + * Status badges added or removed. They belong to sessions whose state did not + * change, so rebuilding them is the visible half of a rail re-render: the + * badges flash. `rowRemounts` does not cover it — the row survives. + */ + statusNodeChanges: number; + /** Drained by every quiet poll, so it reports only the latest interval. */ + delta: number; +} + +type RailWindow = Window & { __railCounters: RailCounters }; + +/** + * Waits until the rail has been silent for ~300ms. + * + * A fixed `waitForTimeout` would be the only thing standing between a slow + * machine and a red run: `toHaveCount` proves the rows mounted, not that the + * commits behind them are done, and one late catalog refresh writes more than + * the whole budget. `retries` is 0 in `playwright.config.ts`, so that failure + * would land on an unrelated pull request. + */ +async function waitForRailQuiet(page: Page): Promise { + let quietPolls = 0; + await expect + .poll( + async () => { + const delta = await page.evaluate(() => { + const counters = (window as unknown as RailWindow).__railCounters; + const seen = counters.delta; + counters.delta = 0; + return seen; + }); + quietPolls = delta === 0 ? quietPolls + 1 : 0; + return quietPolls; + }, + { timeout: 15_000, intervals: [100] }, + ) + .toBeGreaterThanOrEqual(3); +} + +test('switching sessions does not rewrite the whole Session rail', async ({ + railRenderWindow: page, +}) => { + await ensureSidebarExpanded(page); + + const rows = page.locator('.maka-session-row'); + await expect(rows).toHaveCount(RAIL_RENDER_SESSION_COUNT); + + const target = page.locator('.maka-session-row button.astryx-side-nav-item', { + hasText: 'Rail row 3', + }); + const selected = page.locator('.maka-session-row button.astryx-side-nav-item.selected'); + await expect(target).toBeVisible(); + + await page.evaluate(() => { + const selectedRowId = (): string | null => + document + .querySelector('.maka-session-row button.astryx-side-nav-item.selected') + ?.closest('.maka-session-row') + ?.getAttribute('data-session-id') ?? null; + + const counters = { + styleWrites: 0, + rowIds: [] as string[], + rowRemounts: 0, + selectedRowIds: [selectedRowId()] as (string | null)[], + statusNodeChanges: 0, + delta: 0, + }; + (window as unknown as { __railCounters: typeof counters }).__railCounters = counters; + const observer = new MutationObserver((records) => { + for (const record of records) { + if (record.type === 'childList') { + for (const node of record.addedNodes) { + const element = node as Element; + if (element.nodeType !== 1) continue; + // A row that unmounts and remounts writes its `anchor-name` once + // on the way in, from a ref callback that runs AFTER insertion — + // so an attribute-only counter reads a whole rail remount as + // cheaper than a rail re-render. Count the remounts directly. + if (element.classList?.contains('maka-session-row')) counters.rowRemounts += 1; + } + for (const node of [...record.addedNodes, ...record.removedNodes]) { + const element = node as Element; + if (element.nodeType !== 1) continue; + if ( + element.matches?.('[data-session-status]') || + element.querySelector?.('[data-session-status]') + ) { + counters.statusNodeChanges += 1; + } + } + continue; + } + const row = (record.target as Element).closest?.('.maka-session-row'); + if (!row) continue; + counters.styleWrites += 1; + counters.delta += 1; + const rowId = row.getAttribute('data-session-id'); + if (rowId && !counters.rowIds.includes(rowId)) counters.rowIds.push(rowId); + } + const selected = selectedRowId(); + if (selected !== counters.selectedRowIds.at(-1)) counters.selectedRowIds.push(selected); + }); + observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['style'], + }); + (window as unknown as { __railObserver: MutationObserver }).__railObserver = observer; + }); + + // Settle first: the budget is about a switch, not about arriving. + await waitForRailQuiet(page); + await page.evaluate(() => { + const counters = (window as unknown as RailWindow).__railCounters; + counters.styleWrites = 0; + counters.rowIds = []; + counters.rowRemounts = 0; + counters.selectedRowIds = counters.selectedRowIds.slice(-1); + counters.statusNodeChanges = 0; + counters.delta = 0; + }); + + const targetId = await page + .locator('.maka-session-row', { hasText: 'Rail row 3' }) + .getAttribute('data-session-id'); + expect(targetId).toBeTruthy(); + + await target.click(); + await expect(selected).toHaveText(/Rail row 3/); + // Let the post-switch commit cascade finish before reading the counters. + await waitForRailQuiet(page); + + const counted = await page.evaluate(() => { + const scope = window as unknown as RailWindow & { __railObserver: MutationObserver }; + scope.__railObserver.disconnect(); + const { styleWrites, rowIds, rowRemounts, selectedRowIds, statusNodeChanges } = + scope.__railCounters; + return { styleWrites, rowIds, rowRemounts, selectedRowIds, statusNodeChanges }; + }); + + expect( + counted.rowIds.length, + `rail rows touched by one session switch, of ${RAIL_RENDER_SESSION_COUNT} (${counted.rowIds.join(', ')})`, + ).toBeLessThanOrEqual(RAIL_ROWS_TOUCHED_BUDGET); + expect(counted.rowRemounts, 'rail rows remounted by one session switch').toBe(0); + expect(counted.styleWrites, 'the style-write counter never fired').toBeGreaterThan(0); + expect(counted.styleWrites, 'rail inline-style writes for one session switch').toBeLessThanOrEqual( + RAIL_STYLE_WRITE_BUDGET, + ); + + // The timing half. A budget says how much was rewritten, not whether the user + // saw it happen: a switch that lands the selection on a third row and takes it + // back stays well inside every count above (#4109). + expect( + counted.selectedRowIds.slice(1), + 'rows the selection passed through during one switch', + ).toEqual([targetId]); + expect(counted.statusNodeChanges, 'status badges rebuilt by one session switch').toBe(0); +}); diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts index 04decdddca..777767e19a 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -51,6 +51,69 @@ async function createSession(page: Page, prompt: string) { return { composer, sessionId: sessionId!, sidebar }; } +test('a collapsed workbar never flashes during the first send', async ({ + window: page, +}) => { + await page.evaluate(() => { + const watch = { visibleRightWorkbar: false }; + const inspect = () => { + const panel = document.querySelector( + '.maka-session-workbar[data-placement="right"]', + ); + if ( + panel && + getComputedStyle(panel).display !== 'none' && + panel.getBoundingClientRect().width > 0 + ) { + watch.visibleRightWorkbar = true; + } + }; + const observer = new MutationObserver(inspect); + observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + }); + ( + window as typeof window & { + __makaFirstSendWorkbarWatch?: typeof watch; + __makaFirstSendWorkbarWatchStop?: () => void; + } + ).__makaFirstSendWorkbarWatch = watch; + ( + window as typeof window & { + __makaFirstSendWorkbarWatchStop?: () => void; + } + ).__makaFirstSendWorkbarWatchStop = () => { + inspect(); + observer.disconnect(); + }; + }); + + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('create a session without opening the workbar'); + await page.getByRole('button', { name: '发送' }).click(); + const expandWorkbar = page.getByRole('button', { name: '展开任务工作栏' }); + await expect(expandWorkbar).toBeVisible({ + timeout: 20_000, + }); + + const watch = await page.evaluate(() => { + const target = window as typeof window & { + __makaFirstSendWorkbarWatch?: { visibleRightWorkbar: boolean }; + __makaFirstSendWorkbarWatchStop?: () => void; + }; + target.__makaFirstSendWorkbarWatchStop?.(); + return target.__makaFirstSendWorkbarWatch; + }); + expect(watch?.visibleRightWorkbar, 'the collapsed right workbar stayed hidden').toBe(false); + + await expandWorkbar.evaluate((button) => button.click()); + await expect( + page.locator('.maka-session-workbar[data-placement="right"]'), + ).toBeVisible(); +}); + async function waitForCompanionForkId(page: Page, sourceSessionId: string) { let forkId: string | undefined; await expect diff --git a/apps/desktop/e2e/settings-row-focus-ring.spec.ts b/apps/desktop/e2e/settings-row-focus-ring.spec.ts index 060f02f143..76200d2849 100644 --- a/apps/desktop/e2e/settings-row-focus-ring.spec.ts +++ b/apps/desktop/e2e/settings-row-focus-ring.spec.ts @@ -17,7 +17,7 @@ * under the License. */ -import { expect, test } from './fixtures'; +import { ensureSidebarExpanded, expect, test } from './fixtures'; import type { Locator, Page } from '@playwright/test'; /** @@ -48,6 +48,7 @@ import type { Locator, Page } from '@playwright/test'; */ async function openSettingsPage(page: Page, section: string) { + await ensureSidebarExpanded(page); await page.getByRole('button', { name: '设置' }).click(); await page.getByRole('button', { name: section, exact: true }).click(); } diff --git a/apps/desktop/e2e/settings.spec.ts b/apps/desktop/e2e/settings.spec.ts index ddab3bf2c5..c7a421ef23 100644 --- a/apps/desktop/e2e/settings.spec.ts +++ b/apps/desktop/e2e/settings.spec.ts @@ -17,7 +17,14 @@ * under the License. */ -import { test, expect, COMPOSER_INPUT } from './fixtures'; +import { COMPOSER_INPUT, ensureSidebarExpanded, expect, test } from './fixtures'; + +interface SettingsChunkLatchWindow extends Window { + makaE2eLatch?: { + arm(key: 'settings.chunk'): void; + release(key: 'settings.chunk'): void; + }; +} async function choiceContentGeometry(card: import('@playwright/test').Locator) { return card.evaluate((element) => { @@ -36,6 +43,55 @@ async function choiceContentGeometry(card: import('@playwright/test').Locator) { }); } +test('Settings loading surface owns unmodified Escape', async ({ window: page }) => { + const latchInstalled = await page.evaluate(() => { + const e2eLatch = (window as unknown as SettingsChunkLatchWindow).makaE2eLatch; + e2eLatch?.arm('settings.chunk'); + return e2eLatch !== undefined; + }); + expect(latchInstalled, 'the preload E2E latch is installed').toBe(true); + + try { + await ensureSidebarExpanded(page); + await page.getByRole('button', { name: '设置' }).click(); + + const loadingSurface = page.locator('.maka-lazy-fallback'); + await expect(loadingSurface).toBeVisible(); + + for (const modifier of ['ctrlKey', 'metaKey', 'altKey'] as const) { + const wasNotPrevented = await page.evaluate((key) => + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + [key]: true, + bubbles: true, + cancelable: true, + }), + ), modifier); + expect(wasNotPrevented).toBe(true); + await expect(loadingSurface).toBeVisible(); + } + + const wasNotPrevented = await page.evaluate(() => + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }), + ), + ); + expect(wasNotPrevented).toBe(false); + await expect(page.locator('.settingsModal')).toHaveCount(0); + } finally { + await page.evaluate(() => + (window as unknown as SettingsChunkLatchWindow).makaE2eLatch?.release( + 'settings.chunk', + ), + ); + } +}); + test('opening settings commits an active titlebar rename', async ({ window: page }) => { const composer = page.locator(COMPOSER_INPUT); await composer.fill('create a session for settings rename'); @@ -75,6 +131,7 @@ test('settings hides expanded workbar chrome and restores it on close', async ({ const taskTab = workbarToolbar.getByRole('tab', { name: '待办' }); await expect(taskTab).toBeVisible(); + await ensureSidebarExpanded(page); await page.getByRole('button', { name: '设置' }).click(); await expect(page.getByRole('main', { name: '设置内容' })).toBeVisible(); await expect(workbar).not.toBeVisible(); @@ -86,6 +143,7 @@ test('settings hides expanded workbar chrome and restores it on close', async ({ test('wide settings gutters scroll the whole main pane', async ({ window: page }) => { await page.setViewportSize({ width: 1600, height: 520 }); + await ensureSidebarExpanded(page); await page.getByRole('button', { name: '设置' }).click(); await page.getByRole('button', { name: '通用', exact: true }).click(); await expect(page.getByRole('textbox', { name: '助手语气偏好' })).toBeEnabled(); @@ -129,6 +187,7 @@ test('appearance choice content stays vertically centered in stretched grid rows await page.reload(); await page.waitForSelector(COMPOSER_INPUT); await page.setViewportSize({ width: 1650, height: 992 }); + await ensureSidebarExpanded(page); await page.getByRole('button', { name: 'Settings' }).click(); await page.getByRole('button', { name: 'Appearance', exact: true }).click(); await expect(page.getByRole('heading', { name: 'App icon' })).toBeVisible(); @@ -145,6 +204,7 @@ test('appearance choice content stays vertically centered in stretched grid rows test('reopening settings keeps the last-ready General page stable while refreshing', async ({ window: page, }) => { + await ensureSidebarExpanded(page); await page.getByRole('button', { name: '设置' }).click(); const settings = page.locator('.settingsSurface'); await page.getByRole('button', { name: '通用', exact: true }).click(); diff --git a/apps/desktop/e2e/sidebar-geometry.spec.ts b/apps/desktop/e2e/sidebar-geometry.spec.ts new file mode 100644 index 0000000000..e30acf885a --- /dev/null +++ b/apps/desktop/e2e/sidebar-geometry.spec.ts @@ -0,0 +1,157 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* + * Locks the two invariants of the Astryx sidenav column that live only in + * hand-tuned CSS and were, before this file, verified by nothing (#3834): + * + * 1. Geometry — `.maka-sidenav-motion { height: 100% }` (shell-layout.css). + * Without a definite height the wrapper grows to its unclipped content and + * the footer leaves the window. `sidenav footer stays inside the window` + * is the lock shell-layout.css's comment already names by this path. + * + * 2. The resize handle — the two workaround rules that keep it grabbable: + * `top: var(--h-titlebar)` (shell-layout.css) and `transform: none + * !important` (sidebar.css, the Astryx hitAreaOffsetX fix). A regression + * in either leaves a column that looks right and simply cannot be dragged. + * `resize handle drag ...` grabs the handle at its vertical centre — the + * exact point the bug left ungrabbable — and asserts the width changes. + * + * Both run on `projectSidebarWindow`: the only fixture that boots the shell + * expanded (so the handle is mounted) with a populated, overflowing list (60 + * sessions, so the footer has content to be pushed past). It opens with the + * search modal over an inert shell, so dismiss it first — the same first step + * sidebar-project-row.spec.ts takes. + */ + +import { expect, test } from './fixtures'; +import { + SESSION_LIST_EXPANDED_MAX_WIDTH, + SESSION_LIST_EXPANDED_MIN_WIDTH, +} from '../src/renderer/features/session-navigation/testing'; +import type { Locator, Page } from '@playwright/test'; + +async function revealPopulatedSidebar(page: Page): Promise { + await page.keyboard.press('Escape'); + await expect(page.locator('[data-maka-contract="search-modal"]')).not.toBeVisible(); + const sidebar = page.getByRole('navigation', { name: '任务列表' }); + await expect(sidebar).toBeVisible(); + return sidebar; +} + +async function wrapperWidth(wrapper: Locator): Promise { + const box = await wrapper.boundingBox(); + if (!box) throw new Error('sidenav wrapper (.maka-sidenav-motion) has no visible bounds'); + return box.width; +} + +test('sidenav footer stays inside the window under an overflowing list', async ({ + projectSidebarWindow: page, +}) => { + await revealPopulatedSidebar(page); + + // Precondition: the list genuinely overflows its scrollport. Without this the + // footer-in-window assertion below would pass even if `height: 100%` were + // dropped, because short content never grows past the wrapper's parent. + const listOverflows = await page.evaluate(() => { + const nav = document.querySelector('nav.maka-session-panel'); + if (!nav) return false; + return [nav, ...nav.querySelectorAll('*')].some( + (element) => + element.scrollHeight - element.clientHeight > 4 && + getComputedStyle(element).overflowY !== 'visible', + ); + }); + expect(listOverflows).toBe(true); + + const wrapper = page.locator('.maka-sidenav-motion'); + const footer = page.locator('.maka-session-panel-footer'); + await expect(footer).toBeVisible(); + + const innerHeight = await page.evaluate(() => window.innerHeight); + const [wrapperBox, footerBox] = await Promise.all([ + wrapper.boundingBox(), + footer.boundingBox(), + ]); + expect(wrapperBox).not.toBeNull(); + expect(footerBox).not.toBeNull(); + + // The definite height caps the wrapper at the window; the footer rides its + // bottom edge. Drop `height: 100%` and the wrapper grows to ~60 rows tall, + // taking the footer far below the fold — both bottoms then exceed innerHeight. + expect(wrapperBox!.y + wrapperBox!.height).toBeLessThanOrEqual(innerHeight + 1); + expect(footerBox!.y + footerBox!.height).toBeLessThanOrEqual(innerHeight + 1); +}); + +test('resize handle drag from its vertical centre changes the column width', async ({ + projectSidebarWindow: page, +}) => { + await revealPopulatedSidebar(page); + + const handle = page.getByTestId('astryx-sidenav-resize-handle'); + await expect(handle).toBeVisible(); + const wrapper = page.locator('.maka-sidenav-motion'); + + const initialWidth = await wrapperWidth(wrapper); + expect(initialWidth).toBeGreaterThanOrEqual(SESSION_LIST_EXPANDED_MIN_WIDTH); + expect(initialWidth).toBeLessThanOrEqual(SESSION_LIST_EXPANDED_MAX_WIDTH); + + const handleBox = await handle.boundingBox(); + if (!handleBox) throw new Error('resize handle has no visible bounds'); + // Grab the vertical centre. The Astryx hitAreaOffsetX bug leaves only the top + // half grabbable, so the centre no-ops unless sidebar.css's `transform: none + // !important` is applied — this point is the regression probe for that rule. + const grabX = handleBox.x + handleBox.width / 2; + const grabY = handleBox.y + handleBox.height / 2; + + await handle.hover(); + await page.mouse.move(grabX, grabY); + await page.mouse.down(); + await page.mouse.move(grabX + 80, grabY, { steps: 20 }); + + // Astryx flags the separator while a drag is in flight; shell-layout.css keys + // its transition-suppression (`:has([data-resizing])`) on it, so the width + // tracks the pointer live rather than easing behind it. + await expect(handle).toHaveAttribute('data-resizing', /.*/); + await expect.poll(() => wrapperWidth(wrapper)).toBeGreaterThan(initialWidth + 40); + await page.mouse.up(); + await expect(handle).not.toHaveAttribute('data-resizing', /.*/); + + const widenedWidth = await wrapperWidth(wrapper); + expect(widenedWidth).toBeGreaterThan(initialWidth + 40); + expect(widenedWidth).toBeLessThanOrEqual(SESSION_LIST_EXPANDED_MAX_WIDTH + 1); + + // Drag the other way to prove the handle also narrows the column, grabbing + // the centre again at the handle's new right-edge position. + const widenedHandleBox = await handle.boundingBox(); + if (!widenedHandleBox) throw new Error('resize handle lost its bounds after widening'); + const shrinkX = widenedHandleBox.x + widenedHandleBox.width / 2; + const shrinkY = widenedHandleBox.y + widenedHandleBox.height / 2; + + await handle.hover(); + await page.mouse.move(shrinkX, shrinkY); + await page.mouse.down(); + await page.mouse.move(shrinkX - 120, shrinkY, { steps: 20 }); + await expect.poll(() => wrapperWidth(wrapper)).toBeLessThan(widenedWidth - 40); + await page.mouse.up(); + + const narrowedWidth = await wrapperWidth(wrapper); + expect(narrowedWidth).toBeLessThan(widenedWidth - 40); + expect(narrowedWidth).toBeGreaterThanOrEqual(SESSION_LIST_EXPANDED_MIN_WIDTH); +}); diff --git a/apps/desktop/e2e/sidebar-project-row.spec.ts b/apps/desktop/e2e/sidebar-project-row.spec.ts index 25df1c485e..10951a555e 100644 --- a/apps/desktop/e2e/sidebar-project-row.spec.ts +++ b/apps/desktop/e2e/sidebar-project-row.spec.ts @@ -147,6 +147,61 @@ test('task row action menu accepts pointer selection', async ({ await expect(page.getByRole('dialog', { name: '重命名任务' })).toBeVisible(); }); +test('project and task rows show contextual hover details', async ({ + projectSidebarWindow: page, +}) => { + await page.keyboard.press('Escape'); + await expect(page.locator('[data-maka-contract="search-modal"]')).not.toBeVisible(); + + const sidebar = page.getByRole('navigation', { name: '任务列表' }); + const taskSessionId = `${LONG_SIDEBAR_SESSION_PREFIX}00`; + const taskRow = sessionRow(sidebar, taskSessionId); + const taskNavigation = taskRow.locator('.astryx-side-nav-item'); + await expect(taskNavigation).toHaveAccessibleDescription( + /已归档第 00 条研究记录。.*glm-5\.1/, + ); + // A new Electron window can open under the cursor left by the prior test; + // force a real leave → enter transition so the delayed hover trigger fires. + await page.mouse.move(800, 400); + await taskNavigation.hover(); + + const taskCard = page.locator('.maka-sidebar-hover-card[data-kind="session"]'); + await expect(taskCard).toBeVisible(); + await expect(taskCard.locator('.maka-sidebar-hover-card-title')).toHaveText('任务 00'); + await expect(taskCard.locator('.maka-sidebar-hover-card-preview')).toHaveText( + '已归档第 00 条研究记录。', + ); + await expect(taskCard.locator('.maka-sidebar-hover-card-path')).toContainText( + 'e2e-fixture-sidebar-search-modal-open', + ); + await expect(taskCard.locator('.maka-sidebar-hover-card-meta')).toContainText('glm-5.1'); + + await sidebar.getByRole('radio', { name: '按项目', exact: true }).click(); + const projectRow = sidebar.locator( + `[data-project-id="project:${LONG_SIDEBAR_PROJECT_ID}"]`, + ); + const projectNavigation = projectRow.locator(':scope > div > .astryx-side-nav-item'); + await expect(projectNavigation.locator('.astryx-badge')).toHaveCount(0); + await expect(projectNavigation).toHaveAccessibleDescription(/3 个任务.*目录可用/); + await projectNavigation.hover(); + + const projectCard = page.locator('.maka-sidebar-hover-card[data-kind="project"]'); + await expect(projectCard).toBeVisible(); + await expect(projectCard.locator('.maka-sidebar-hover-card-title')).toHaveText( + LONG_SIDEBAR_PROJECT_NAME, + ); + await expect(projectCard.locator('.maka-sidebar-hover-card-meta')).toContainText('3 个任务'); + await expect(projectCard.locator('.maka-sidebar-hover-card-meta')).toContainText('目录可用'); + + const ungroupedRow = sidebar.locator('[data-project-id="__ungrouped__"]'); + const ungroupedNavigation = ungroupedRow.locator(':scope > div > .astryx-side-nav-item'); + await expect(ungroupedNavigation.locator('.astryx-badge')).toHaveCount(0); + await ungroupedNavigation.focus(); + await expect( + page.getByRole('dialog', { name: '未归属项目 分组详情', exact: true }), + ).toBeVisible(); +}); + test('rail grouping survives a renderer reload', async ({ projectSidebarWindow: page }) => { await page.keyboard.press('Escape'); await expect(page.locator('[data-maka-contract="search-modal"]')).not.toBeVisible(); diff --git a/apps/desktop/e2e/sidebar-titlebar-actions.spec.ts b/apps/desktop/e2e/sidebar-titlebar-actions.spec.ts index ae6f630853..4996b30fab 100644 --- a/apps/desktop/e2e/sidebar-titlebar-actions.spec.ts +++ b/apps/desktop/e2e/sidebar-titlebar-actions.spec.ts @@ -39,3 +39,21 @@ test('expanded sidebar chrome follows Astryx toolbar geometry', async ({ window expect(trailingInset).toBeLessThanOrEqual(16); await expect(actions).toHaveCSS('column-gap', '4px'); }); + +test('collapsed sidebar removes its icon rail but keeps the titlebar restore action', async ({ + window, +}) => { + const sidebar = window.getByRole('navigation', { name: '任务列表' }); + const collapseSidebar = window.getByRole('button', { name: '收起侧边栏' }); + + if (await collapseSidebar.isVisible()) await collapseSidebar.click(); + + await expect(sidebar).toBeHidden(); + await expect(window.locator('.maka-sidenav-motion')).toHaveCSS('width', '0px'); + const expandSidebar = window.getByRole('button', { name: '展开侧边栏' }); + await expect(expandSidebar).toBeVisible(); + + await expandSidebar.click(); + await expect(sidebar).toBeVisible(); + await expect(window.getByRole('button', { name: '收起侧边栏' })).toBeVisible(); +}); diff --git a/apps/desktop/e2e/slash-command-menu.spec.ts b/apps/desktop/e2e/slash-command-menu.spec.ts index 49c705992c..d78016aa9b 100644 --- a/apps/desktop/e2e/slash-command-menu.spec.ts +++ b/apps/desktop/e2e/slash-command-menu.spec.ts @@ -135,6 +135,79 @@ test('offers commands only for the first token and keeps explicit Skill queries await expect(inlineMenu.getByRole('group', { name: '命令' })).toHaveCount(0); }); +test('does not open the slash menu for path separators', async ({ + invocableSkillsWindow: page, +}) => { + const composer = page.locator(COMPOSER_INPUT); + const menu = page.getByRole('listbox', { name: '命令和技能' }); + + for (const prefix of ['帮我整理到/Users', 'path/to/file']) { + await composer.fill(prefix); + await composer.evaluate((editable) => { + // Chromium can put the next typed character in a new text node. Recreate + // that observed input shape without letting Playwright normalize the DOM. + const node = editable.appendChild(document.createTextNode('/')); + const range = document.createRange(); + range.setStart(node, 1); + range.collapse(true); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + editable.dispatchEvent(new InputEvent('input', { + bubbles: true, + data: '/', + inputType: 'insertText', + })); + }); + + await expect(menu).toHaveCount(0); + await expect.poll(() => composer.textContent()).toBe(`${prefix}/`); + await expect + .poll(() => + composer.evaluate((editable) => { + const selection = window.getSelection(); + if (!selection?.focusNode || !editable.contains(selection.focusNode)) return -1; + const range = document.createRange(); + range.selectNodeContents(editable); + range.setEnd(selection.focusNode, selection.focusOffset); + return range.toString().length; + }), + ) + .toBe(`${prefix}/`.length); + } +}); + +test('opens the slash menu after a DOM block break', async ({ + invocableSkillsWindow: page, +}) => { + const composer = page.locator(COMPOSER_INPUT); + const menu = page.getByRole('listbox', { name: '命令和技能' }); + + await composer.fill('first line'); + await composer.evaluate((editable) => { + const block = document.createElement('div'); + const slash = block.appendChild(document.createTextNode('/')); + editable.appendChild(block); + + const range = document.createRange(); + range.setStart(slash, 1); + range.collapse(true); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + editable.dispatchEvent(new InputEvent('input', { + bubbles: true, + data: '/', + inputType: 'insertText', + })); + }); + + await expect.poll(() => composer.evaluate((editable) => editable.innerText)).toBe( + 'first line\n/', + ); + await expect(menu).toBeVisible(); +}); + test('dispatches /side instead of steering it into a running turn', async ({ invocableSkillsWindow: page, }) => { @@ -162,3 +235,117 @@ test('dispatches /side instead of steering it into a running turn', async ({ await expect(page.locator('.maka-quote-workbar-panel')).toHaveCount(1); await page.getByRole('button', { name: '停止' }).click(); }); + +test('an open menu keeps its container and skills group across projection refreshes', async ({ + invocableSkillsWindow: page, +}) => { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('seed session'); + await composer.press('Enter'); + await expect(page.getByText('Fake backend received: seed session')).toBeVisible(); + await expect(page.getByRole('button', { name: '停止' })).toHaveCount(0); + + // The completed turn publishes its own projection refresh. Wait on the + // composer's public loading state so that work cannot spill into the window + // this test is about. + await page.getByRole('button', { name: '添加上下文' }).click(); + const contextMenu = page.getByRole('menu', { name: '添加上下文' }); + await expect(contextMenu.getByRole('menuitem', { name: /选择技能/ })).not.toHaveAttribute( + 'aria-busy', + 'true', + ); + await page.keyboard.press('Escape'); + await expect(contextMenu).toHaveCount(0); + + await composer.click(); + await composer.pressSequentially('/'); + const menu = page.getByRole('listbox', { name: '命令和技能' }); + await expect(menu.getByRole('group', { name: 'Skills' })).toBeVisible(); + + // A thinking-level change publishes the session's 'updated' event and + // reloads the Skill projection without changing what the menu shows: the + // exact same-content refresh that used to alternate the popup (#2667). + const observation = await menu.evaluate(async (menuElement) => { + const sessions = await ( + window as unknown as { + maka: { sessions: { list(): Promise> } }; + } + ).maka.sessions.list(); + const sessionId = sessions[0]?.id; + if (!sessionId) throw new Error('Session missing before projection refresh'); + + // Arm immediately before the refreshes and only on this popover. The + // document body also contains unrelated overlays whose teardown says + // nothing about this menu's identity. + const menuContainer = menuElement.parentElement; + const state = { menuRemovals: 0, skillsGroupRemovals: 0 }; + const skillsGroup = menuElement.querySelector('[role="group"][aria-label="Skills"]'); + if (!menuContainer) throw new Error('Slash menu container missing before projection refresh'); + if (!skillsGroup) throw new Error('Skills group missing before projection refresh'); + const recordRemoval = ( + mutations: MutationRecord[], + watchedNode: Node, + key: 'menuRemovals' | 'skillsGroupRemovals', + ) => { + for (const mutation of mutations) { + for (const node of mutation.removedNodes) { + if (node === watchedNode) state[key] += 1; + } + } + }; + const menuObserver = new MutationObserver((mutations) => { + recordRemoval(mutations, menuElement, 'menuRemovals'); + }); + const skillsGroupObserver = new MutationObserver((mutations) => { + recordRemoval(mutations, skillsGroup, 'skillsGroupRemovals'); + }); + menuObserver.observe(menuContainer, { childList: true }); + skillsGroupObserver.observe(menuElement, { childList: true }); + const maka = ( + window as unknown as { + maka: { + sessions: { + setThinkingLevel(id: string, level?: null): Promise; + }; + }; + } + ).maka; + const e2eControls = ( + window as unknown as { + makaE2eLatch?: { + waitForInvocableSkillsCall(sessionId: string): Promise; + }; + } + ).makaE2eLatch; + if (!e2eControls) throw new Error('E2E bridge controls missing before projection refresh'); + try { + for (let round = 0; round < 3; round += 1) { + const projectionSettled = e2eControls.waitForInvocableSkillsCall(sessionId); + await maka.sessions.setThinkingLevel(sessionId, null); + await projectionSettled; + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + } + } finally { + // Drain the final queued batch before closing the exact refresh window; + // polling a monotonic counter cannot turn a failure into success. + recordRemoval(menuObserver.takeRecords(), menuElement, 'menuRemovals'); + recordRemoval(skillsGroupObserver.takeRecords(), skillsGroup, 'skillsGroupRemovals'); + menuObserver.disconnect(); + skillsGroupObserver.disconnect(); + } + return { + ...state, + menuConnected: menuElement.isConnected, + skillsGroupConnected: skillsGroup.isConnected, + }; + }); + expect(observation).toEqual({ + menuRemovals: 0, + skillsGroupRemovals: 0, + menuConnected: true, + skillsGroupConnected: true, + }); + await expect(menu.getByRole('group', { name: 'Skills' })).toBeVisible(); +}); diff --git a/apps/desktop/e2e/streaming-remount.spec.ts b/apps/desktop/e2e/streaming-remount.spec.ts index ca47298563..998cd3c6ba 100644 --- a/apps/desktop/e2e/streaming-remount.spec.ts +++ b/apps/desktop/e2e/streaming-remount.spec.ts @@ -23,7 +23,14 @@ import { FAKE_WAIT_FOR_STEERING_LARGE_RESPONSE_PROMPT, } from '@maka/runtime/test-only/fake-backend'; import type { Locator } from '@playwright/test'; -import { expect, COMPOSER_INPUT, test } from './fixtures'; +import { COMPOSER_INPUT, ensureSidebarExpanded, expect, test } from './fixtures'; + +interface SessionObservationLatchWindow extends Window { + /** E2E-only preload affordance; see the MAKA_E2E block in preload.ts. */ + makaE2eLatch?: { + rejectNextSessionObservation(message: string): void; + }; +} function sessionRow(sidebar: Locator, sessionId: string): Locator { return sidebar.locator(`[data-session-id=${JSON.stringify(sessionId)}]`); @@ -36,6 +43,28 @@ async function steerActiveTurn(composer: Locator, text: string): Promise { await composer.press('Shift+Enter'); } +test('a failed first observation seed reconnects to the live Turn', async ({ window: page }) => { + const latchInstalled = await page.evaluate(() => { + const latch = (window as SessionObservationLatchWindow).makaE2eLatch; + if (!latch) return false; + latch.rejectNextSessionObservation('forced first observation failure'); + return true; + }); + expect(latchInstalled, 'the preload E2E latch is installed').toBe(true); + + const composer = page.locator(COMPOSER_INPUT); + await composer.fill(FAKE_HOLD_OPEN_PROMPT); + await composer.press('Enter'); + + await expect(page.locator('.maka-bubble-streaming')).toContainText( + 'Fake backend waiting', + ); + await page.getByRole('button', { name: '停止' }).click(); + await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { + timeout: 20_000, + }); +}); + test('remounting a live surface leaves accumulated output settled', async ({ window: page, }) => { @@ -51,14 +80,13 @@ test('remounting a live surface leaves accumulated output settled', async ({ await expect(liveBubble).toContainText(accumulatedOutput); const sidebar = page.getByRole('navigation', { name: '任务列表' }); + await ensureSidebarExpanded(page); await sidebar.getByRole('button', { name: '扩展' }).click(); await expect(page.locator('[data-module="skills"]')).toBeVisible(); await expect(liveBubble).toHaveCount(0); - // Back the way the product actually offers: the rail is collapsed here, so - // the task rows are not rendered and there is no 任务 row to press (#2984). - // Widening it is the titlebar's job, and the task left behind is still - // `activeId`, so it comes back marked and one click away. - await page.getByRole('button', { name: '展开侧边栏' }).click(); + // Return through the task row the product exposes. Module navigation can + // preserve either sidebar state, so restore it only when it is collapsed. + await ensureSidebarExpanded(page); const currentTaskRow = sidebar.locator( '[data-maka-contract="session-row"] [aria-current="page"]', ); @@ -131,7 +159,7 @@ test('keeps a completed reply after an interrupted turn and conversation remount await composer.fill(FAKE_HOLD_OPEN_PROMPT); await composer.press('Enter'); await expect(page.locator('.maka-bubble-streaming')).toContainText( - 'Fake backend waiting for the test to stop the Turn.', + 'Fake backend waiting', ); const originalSessionId = await sidebar .locator('[data-session-id]:has([aria-current="page"])') @@ -231,11 +259,25 @@ test('returning to a live conversation settles output accumulated while away', a unsubscribe(); resolve(); }); - void window.maka.sessions.steer(sessionId, steering).catch((error) => { - window.clearTimeout(timeout); - unsubscribe(); - reject(error); - }); + // Runtime Host decides what this Message becomes; the test only needs it + // to reach the running Turn, so anything short of an accepted admission + // fails closed rather than waiting out the timeout. + void window.maka.sessions + .submitMessage(sessionId, 'current_turn', { + messageId: crypto.randomUUID(), + text: steering, + }) + .then((result) => { + if (result.ok) return; + window.clearTimeout(timeout); + unsubscribe(); + reject(new Error(`Runtime Host refused the steering Message: ${result.reason}`)); + }) + .catch((error) => { + window.clearTimeout(timeout); + unsubscribe(); + reject(error); + }); }), { sessionId: originalSessionId!, steering: backgroundSteering }, ); diff --git a/apps/desktop/e2e/transcript-measure.spec.ts b/apps/desktop/e2e/transcript-measure.spec.ts new file mode 100644 index 0000000000..b8d6bd196a --- /dev/null +++ b/apps/desktop/e2e/transcript-measure.spec.ts @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { expect, test, COMPOSER_INPUT } from './fixtures'; + +test('assistant prose reaches the transcript column edge on a wide window', async ({ + window: page, +}) => { + await page.setViewportSize({ width: 1400, height: 800 }); + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('measure'); + await composer.press('Enter'); + + const answer = page.getByRole('article', { name: 'Maka 的回答' }).last(); + const paragraph = answer.locator('[role="paragraph"]').first(); + await expect(paragraph).toBeVisible(); + + const edges = await paragraph.evaluate((element) => { + const turn = element.closest('.maka-turn'); + if (!turn) throw new Error('assistant paragraph is not inside a turn'); + const turnRect = turn.getBoundingClientRect(); + return { + turnWidth: turnRect.width, + rightGap: turnRect.right - element.getBoundingClientRect().right, + }; + }); + + // Guard against a false pass when both boxes are viewport-constrained: + // a turn wider than Astryx's own 680px cap is the case that regressed. + expect(edges.turnWidth).toBeGreaterThan(680); + expect(edges.rightGap).toBeLessThanOrEqual(1); +}); diff --git a/apps/desktop/e2e/transcript-scroll.spec.ts b/apps/desktop/e2e/transcript-scroll.spec.ts new file mode 100644 index 0000000000..acc885b2d7 --- /dev/null +++ b/apps/desktop/e2e/transcript-scroll.spec.ts @@ -0,0 +1,584 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { expect, test, COMPOSER_INPUT } from './fixtures'; +import type { Page } from '@playwright/test'; + +/** + * Where the transcript is looking, in a real Chromium with a real scroller. + * + * Two rounds of this work shipped green and wrong, both times because the + * instrument could not see the property being claimed: a CLS measurement is + * blind to scroll position, and a linkedom harness decides the effect ordering + * its own assertions then confirm. Nothing below reads a ref or a flag — each + * test states where an element or the viewport ended up, and the app has to put + * it there. + * + * Positions are asserted against an element or against the scroller's own end, + * never as a pixel delta: a delta is satisfiable by two wrongs (the content + * grew by as much as the view moved), which is the bug class that produced the + * `scrollHeight`-difference compensation this replaces. + */ + +const SCROLLER = '[data-chat-scroll-container="true"]'; +const REGENERATE = /^重新生成回答/; +/** Astryx's dock affordance, relabelled by `ChatSurfaceLayout`. */ +const SCROLL_TO_BOTTOM = /^滚动主对话到底部$/; + +/** Sixty lines: more than one viewport once the fake backend echoes it back. */ +const LONG_PROMPT = Array.from( + { length: 60 }, + (_, index) => `第 ${index} 行:这一段用来把转录推过滚动视口的高度。`, +).join('\n'); + +function distanceToTail(page: Page): Promise { + return scrollMetrics(page).then((metrics) => metrics.distance); +} + +/** + * The distance plus the three numbers it came from. A failure that reports only + * the distance cannot say whether the transcript grew past the reader or the + * viewport shrank under them, and those have different causes. + */ +function scrollMetrics(page: Page): Promise<{ + distance: number; + scrollTop: number; + scrollHeight: number; + clientHeight: number; +}> { + return page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + return { + distance: Math.round(root.scrollHeight - root.scrollTop - root.clientHeight), + scrollTop: Math.round(root.scrollTop), + scrollHeight: root.scrollHeight, + clientHeight: root.clientHeight, + }; + }, SCROLLER); +} + +/** + * Whether the dock affordance is actually offered. It is always in the DOM — + * Astryx toggles opacity and pointer-events — so presence proves nothing and + * `toBeVisible` passes on the transparent one. + */ +function scrollButtonOffered(page: Page): Promise { + return page.evaluate((name) => { + const button = [...document.querySelectorAll('button')].find( + (candidate) => candidate.getAttribute('aria-label') === name + || candidate.textContent?.trim() === name, + ); + if (!button) throw new Error(`the "${name}" affordance is missing`); + const style = getComputedStyle(button); + return style.pointerEvents !== 'none' && Number(style.opacity) > 0.5; + }, '滚动主对话到底部'); +} + +function turnTop(page: Page, turnId: string): Promise { + return page.evaluate((id) => { + const turn = document.querySelector(`[data-turn-id="${CSS.escape(id)}"]`); + if (!turn) throw new Error(`turn ${id} is not mounted`); + return Math.round(turn.getBoundingClientRect().top); + }, turnId); +} + +/** + * Sample the tail through the frames a growing transcript produces. + * + * Read at the start of each frame, which is one frame behind the pin: the + * content commits, the next frame's layout delivers the resize, and the write + * lands before that frame paints. So the view can only ever be behind by what + * arrived since the last delivery — never more, and never cumulatively. That is + * what `worstLag` against `worstFrameGrowth` states, and it is a property no + * fixed pixel budget can express: a transcript that stopped following instead + * falls behind by the whole of `grewBy`. + */ +function measureTailLag(page: Page, frames: number): Promise<{ + worstLag: number; + worstFrameGrowth: number; + grewBy: number; + viewportHeight: number; +}> { + return page.evaluate(([selector, frameCount]) => new Promise<{ + worstLag: number; + worstFrameGrowth: number; + grewBy: number; + viewportHeight: number; + }>((resolve) => { + const root = document.querySelector(selector as string); + if (!root) throw new Error('the chat scroll container is missing'); + const startedAt = root.scrollHeight; + let previousScrollHeight = startedAt; + let worstLag = 0; + let worstFrameGrowth = 0; + let left = frameCount as number; + const tick = (): void => { + const settledTail = previousScrollHeight - root.clientHeight; + worstLag = Math.max(worstLag, Math.abs(root.scrollTop - settledTail)); + worstFrameGrowth = Math.max(worstFrameGrowth, root.scrollHeight - previousScrollHeight); + previousScrollHeight = root.scrollHeight; + // Stops on the content, not on a frame count: when the answer starts + // arriving is the backend's business, and a fixed window can expire + // before it does. + const enough = root.scrollHeight - startedAt > root.clientHeight; + if (enough || --left <= 0) { + resolve({ + worstLag: Math.round(worstLag), + worstFrameGrowth: Math.round(worstFrameGrowth), + grewBy: Math.round(root.scrollHeight - startedAt), + viewportHeight: root.clientHeight, + }); + } else requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }), [SCROLLER, frames] as const); +} + +async function sendPrompt(page: Page, text: string): Promise { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill(text); + await composer.press('Enter'); +} + +/** Answered turns, so a second send can be waited for without a stale match. */ +function answeredTurns(page: Page) { + return page.getByRole('button', { name: REGENERATE }); +} + +async function scrollTranscriptTo(page: Page, top: number): Promise { + await page.evaluate(([selector, position]) => { + const root = document.querySelector(selector as string); + if (!root) throw new Error('the chat scroll container is missing'); + root.scrollTop = position as number; + }, [SCROLLER, top] as const); + await waitForPaintedFrames(page); +} + +async function waitForPaintedFrames(page: Page, count = 3): Promise { + await page.evaluate((frames) => new Promise((resolve) => { + const tick = (left: number) => { + if (left <= 0) { + resolve(); + return; + } + requestAnimationFrame(() => tick(left - 1)); + }; + tick(frames); + }), count); +} + +test('a streaming answer keeps the viewport at the tail', async ({ window: page }) => { + // A full fake-backend turn, streamed nine characters at a time. + test.slow(); + await page.setViewportSize({ width: 900, height: 700 }); + await sendPrompt(page, LONG_PROMPT); + + // Measured through the stream, not only at the end: the failure this guards + // against is the tail slipping away *while* content arrives, which a single + // reading afterwards cannot tell apart from a view dragged back at the last + // delta. + const lag = await measureTailLag(page, 1_200); + await expect(answeredTurns(page)).toHaveCount(1, { timeout: 30_000 }); + + // The samples have to have covered more than a viewport of real growth, or + // every reading above is a stationary transcript and proves nothing. + expect(lag.grewBy).toBeGreaterThan(lag.viewportHeight); + expect(lag.worstLag).toBeLessThanOrEqual(lag.worstFrameGrowth + 8); + const settled = await scrollMetrics(page); + expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); + expect(await scrollButtonOffered(page)).toBe(false); +}); + +/** + * The turn wrappers are not the transcript. It also renders the optimistic user + * message, the no-tail live fallback and orphaned conversation items outside + * them, so a growth signal that watches turns has a blind spot the size of + * whatever else gets rendered next. + * + * Grown here rather than by sending a Follow Up: whether the optimistic message + * is ever on screen is the host's timing, and it was measured both appearing + * and being overtaken by its own answer within the same fixture. What is under + * test is not that message — it is that `scrollHeight` growing anywhere is + * enough, which is a property of the scroller and needs no help from the + * transcript to state. + */ +test('content that grows outside the turn wrappers is followed too', async ({ + window: page, +}) => { + test.slow(); + await page.setViewportSize({ width: 900, height: 700 }); + await sendPrompt(page, LONG_PROMPT); + await expect(answeredTurns(page)).toHaveCount(1, { timeout: 30_000 }); + expect(await distanceToTail(page)).toBeLessThanOrEqual(4); + + const outsideTurnWrapper = await page.evaluate(() => { + const list = document.querySelector('.maka-chat-message-list'); + if (!list) throw new Error('the transcript content box is missing'); + const grown = document.createElement('div'); + grown.dataset.outsideTurnGrowth = 'true'; + grown.style.height = '600px'; + list.append(grown); + return grown.closest('[data-transcript-turn-id]') === null; + }); + await waitForPaintedFrames(page); + + // Outside a wrapper is what makes this the uncovered path: growth inside one + // is what every other test in this file already exercises. + expect(outsideTurnWrapper, 'the injected box landed inside a turn wrapper').toBe(true); + const settled = await scrollMetrics(page); + expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); +}); + +test('content that arrives after the reader scrolls up does not pull them back', async ({ + window: page, +}) => { + test.slow(); + await page.setViewportSize({ width: 900, height: 700 }); + await sendPrompt(page, LONG_PROMPT); + await expect(answeredTurns(page)).toHaveCount(1, { timeout: 30_000 }); + + const transcript = page.locator('.maka-chat-message-list'); + await transcript.hover(); + await page.mouse.wheel(0, -500); + await waitForPaintedFrames(page); + const before = await distanceToTail(page); + expect(before).toBeGreaterThan(100); + expect(await scrollButtonOffered(page)).toBe(true); + + const anchorTurnId = await page.evaluate(() => { + const turn = document.querySelector('[data-turn-id]'); + const turnId = turn?.dataset.turnId; + if (!turnId) throw new Error('the transcript has no mounted turn'); + return turnId; + }); + const anchorTop = await turnTop(page, anchorTurnId); + + await sendPrompt(page, LONG_PROMPT); + await expect(answeredTurns(page)).toHaveCount(2, { timeout: 30_000 }); + await waitForPaintedFrames(page); + + // The turn the reader was on is still where it was. Everything that arrived, + // arrived below them. + expect(Math.abs((await turnTop(page, anchorTurnId)) - anchorTop)).toBeLessThanOrEqual(4); + expect(await distanceToTail(page)).toBeGreaterThan(before); +}); + +test('a gesture a nested scroller consumed does not release the tail', async ({ + window: page, +}) => { + test.slow(); + await page.setViewportSize({ width: 900, height: 700 }); + await sendPrompt(page, LONG_PROMPT); + await expect(answeredTurns(page)).toHaveCount(1, { timeout: 30_000 }); + const settled = await scrollMetrics(page); + expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); + + // A real scroller inside the transcript, standing in for a tool-output box + // (`.maka-tool-output-body`, `max-height: 256px; overflow-y: auto`) or a pty + // terminal. Built here rather than fixtured because what is under test is + // Chromium's scroll chain, which does not care where the element came from, + // and no fixture reliably produces an output tall enough to overflow. + const nested = await page.evaluate(() => { + const turns = document.querySelectorAll('[data-turn-id]'); + const turn = turns[turns.length - 1]; + if (!turn) throw new Error('the transcript has no mounted turn'); + const box = document.createElement('div'); + box.dataset.nestedScroller = 'true'; + box.style.cssText = 'max-height:120px;overflow-y:auto'; + const filler = document.createElement('div'); + filler.style.height = '2000px'; + box.append(filler); + turn.append(box); + // Away from both ends, so scrolling up inside it never reaches a boundary + // and never chains to the transcript. + box.scrollTop = 600; + return box.scrollTop; + }); + + // Appending is growth like any other, so the pin brings the new box into + // view — which also keeps Playwright's hover from scrolling to reach it. + await waitForPaintedFrames(page); + expect(await distanceToTail(page)).toBeLessThanOrEqual(4); + + // The real input pipeline, over the nested element: the gesture crosses the + // transcript on its way up the tree, the nested element consumes it, and the + // transcript never moves — so no `scroll` follows. A tail-follow that watches + // gestures reads this as the reader leaving; one that watches position cannot + // see it at all. Astryx's stock predicate is the former, and its + // `animatingRef` was measured sitting at `true` on a resting transcript, so + // an upward wheel here released the tail with nothing having scrolled. + await page.locator('[data-nested-scroller="true"]').hover(); + await page.mouse.wheel(0, -400); + await waitForPaintedFrames(page); + const nestedAfter = await page.evaluate( + () => document.querySelector('[data-nested-scroller="true"]')?.scrollTop ?? -1, + ); + // The nested box moved, which is what makes this a gesture the transcript + // never saw. Without this the test would pass on a wheel that did nothing. + expect(nestedAfter).toBeLessThan(nested); + expect(await distanceToTail(page)).toBeLessThanOrEqual(4); + + // The touch equivalent, which no synthetic-free path can produce here. + await page.evaluate(() => { + const target = document.querySelector('[data-turn-id]'); + if (!target) throw new Error('the transcript has no mounted turn'); + target.dispatchEvent(new Event('touchmove', { bubbles: true })); + }); + await waitForPaintedFrames(page); + + // Following is unharmed: a whole further answer lands and the tail is still + // under the reader. A release would have left them a screen and a half up, + // with no gesture of their own to explain it. + await sendPrompt(page, LONG_PROMPT); + await expect(answeredTurns(page)).toHaveCount(2, { timeout: 30_000 }); + await waitForPaintedFrames(page); + expect(await distanceToTail(page)).toBeLessThanOrEqual(4); + expect(await scrollButtonOffered(page)).toBe(false); +}); + +test('a nested scroller near the history boundary does not request an earlier range', async ({ + promptRailWindow: page, +}) => { + await page.setViewportSize({ width: 900, height: 1500 }); + await waitForPaintedFrames(page, 6); + const metrics = await scrollMetrics(page); + expect(metrics.scrollTop).toBeLessThanOrEqual(Math.max(640, metrics.clientHeight * 2)); + expect(metrics.distance).toBeLessThanOrEqual(4); + + const nestedBefore = await page.evaluate((selector) => { + const root = document.querySelector(selector); + const list = root?.querySelector('.maka-chat-message-list'); + if (!root || !list) throw new Error('the active transcript range is missing'); + const box = document.createElement('div'); + box.dataset.nestedHistoryScroller = 'true'; + box.style.cssText = [ + 'position:fixed', + 'top:160px', + 'left:160px', + 'width:240px', + 'height:120px', + 'overflow-y:auto', + 'z-index:9999', + ].join(';'); + const filler = document.createElement('div'); + filler.style.height = '2000px'; + box.append(filler); + // A Turn uses `content-visibility:auto`, whose paint containment prevents + // a fixed descendant from reliably winning hit testing over sibling Turns + // on Linux/Xvfb. Keep the fixture inside the transcript event path without + // putting it inside the product containment boundary being tested. + list.append(box); + box.scrollTop = 600; + return box.scrollTop; + }, SCROLLER); + + const nested = page.locator('[data-nested-history-scroller="true"]'); + const box = await nested.boundingBox(); + if (!box) throw new Error('the nested history scroller is not rendered'); + const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + expect(await page.evaluate(({ x, y }) => + document.elementFromPoint(x, y)?.closest('[data-nested-history-scroller="true"]') !== null, + point)).toBe(true); + await page.mouse.move(point.x, point.y); + await page.mouse.wheel(0, -400); + await waitForPaintedFrames(page); + + const nestedAfter = await page.evaluate(() => + document.querySelector('[data-nested-history-scroller="true"]')?.scrollTop ?? -1, + ); + expect(nestedAfter).toBeLessThan(nestedBefore); + await page.evaluate(() => { + const list = document.querySelector('.maka-chat-message-list'); + if (!list) throw new Error('the transcript content box is missing'); + const grown = document.createElement('div'); + grown.style.height = '600px'; + list.append(grown); + }); + await waitForPaintedFrames(page, 6); + expect(await distanceToTail(page)).toBeLessThanOrEqual(4); +}); + +test('the dock affordance returns the reader to the tail', async ({ window: page }) => { + test.slow(); + await page.setViewportSize({ width: 900, height: 700 }); + await sendPrompt(page, LONG_PROMPT); + await expect(answeredTurns(page)).toHaveCount(1, { timeout: 30_000 }); + + await scrollTranscriptTo(page, 0); + // Offered at all is the assertion: with Astryx's scroll layer off, its + // `isScrolledUp` never updates again, so the stock button would stay + // transparent forever. This one reads Maka's pin. + expect(await scrollButtonOffered(page)).toBe(true); + + await page.getByRole('button', { name: SCROLL_TO_BOTTOM }).click(); + await waitForPaintedFrames(page); + expect(await distanceToTail(page)).toBeLessThanOrEqual(4); + expect(await scrollButtonOffered(page)).toBe(false); +}); + +test('earlier history lands above the turn the reader is on', async ({ + promptRailWindow: page, +}) => { + const firstLoadedTurn = () => page + .locator('[data-transcript-turn-id]') + .first() + .getAttribute('data-transcript-turn-id'); + const firstBefore = await firstLoadedTurn(); + + // Just short of the band that asks for more, so the active range has painted + // turns around the reader before the load starts. Landing straight on zero + // leaves no visible turn above the load boundary to identify as the anchor. + await page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + root.scrollTop = Math.max(640, root.clientHeight * 2) + 400; + }, SCROLLER); + await waitForPaintedFrames(page, 6); + + // The move that asks for earlier history, and the reading of where the + // reader is, in one task. Keep the reader near the active range's head: an + // anchor near its tail can already have a complete bounded range around it, + // so a valid load has no reason to move the first resident Turn. + const anchor = await page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + root.scrollTop = Math.min(300, root.scrollHeight - root.clientHeight); + const rootTop = root.getBoundingClientRect().top; + const turn = [...root.querySelectorAll('[data-turn-id]')].find( + (candidate) => candidate.getBoundingClientRect().bottom > rootTop, + ); + const turnId = turn?.dataset.turnId; + if (!turn || !turnId) throw new Error('no turn is on screen'); + const anchor = { turnId, top: Math.round(turn.getBoundingClientRect().top) }; + root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); + return anchor; + }, SCROLLER); + + await expect.poll(firstLoadedTurn, { timeout: 20_000 }).not.toBe(firstBefore); + await waitForPaintedFrames(page); + + // The turns that arrived went above the reader, and the reader did not go + // with them. Asserting the element rather than a `scrollTop` delta is the + // point: a compensation computed from `scrollHeight` satisfies the delta + // while putting the reader somewhere else entirely. + await expect.poll( + async () => Math.abs((await turnTop(page, anchor.turnId)) - anchor.top), + ).toBeLessThanOrEqual(4); +}); + +test('history asked for at the very top of the scroller still lands above the reader', async ({ + promptRailWindow: page, +}) => { + const firstLoadedTurn = () => page + .locator('[data-transcript-turn-id]') + .first() + .getAttribute('data-transcript-turn-id'); + const firstBefore = await firstLoadedTurn(); + + // The one position where the browser declines to anchor, and the one the + // wheel-to-load path puts the reader in. + await page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + root.scrollTop = 0; + }, SCROLLER); + + await expect.poll(firstLoadedTurn, { timeout: 20_000 }).not.toBe(firstBefore); + await waitForPaintedFrames(page); + + // Anchoring resumes at an offset of one pixel, so the offset itself is the + // evidence: left at zero the browser holds the scroller at the top and every + // turn that arrives pushes the reader's content down the viewport instead. + const offset = await page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + return root.scrollTop; + }, SCROLLER); + expect(offset).toBeGreaterThanOrEqual(1); +}); + +/** + * A transcript shorter than about three viewports has its tail inside the band + * that asks for earlier history, so "near the start" cannot mean the reader + * wants it. + * + * The other half of that rule — a wheel the scroller cannot act on releases the + * pin, because it is the reader asking for what is above them — has no assertion + * here, and not for want of trying. Its only observable consequence is that a + * later arrival does not take the reader back down, and in this fixture the + * reader who asks is already at the tail: anchoring holds them at the same + * distance from it, the session takes no new turns, and a viewport change moves + * them the same way pinned or not. An assertion that passes either way is worse + * than none. `transcript-scroll-authority.test.ts` covers the state machine it + * turns on. + */ +test('following the tail does not ask for the history above it', async ({ + promptRailWindow: page, +}) => { + const firstLoadedTurn = () => page + .locator('[data-transcript-turn-id]') + .first() + .getAttribute('data-transcript-turn-id'); + const firstBefore = await firstLoadedTurn(); + + // Tall enough that the tail sits inside `max(640, clientHeight * 2)`. The + // resize itself is a growth signal, so the pin writes the tail and that write + // dispatches the scroll event this test is about. + await page.setViewportSize({ width: 900, height: 1500 }); + await waitForPaintedFrames(page, 6); + + const settled = await scrollMetrics(page); + expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); + expect( + settled.scrollTop, + `the tail must be inside the load band for this test to mean anything: ${JSON.stringify(settled)}`, + ).toBeLessThanOrEqual(Math.max(640, settled.clientHeight * 2)); + + // Nothing arrived that the reader did not ask for. + await waitForPaintedFrames(page, 12); + expect(await firstLoadedTurn()).toBe(firstBefore); +}); + +test('a wheel a short scroller cannot act on still asks for history', async ({ + promptRailWindow: page, +}) => { + const firstLoadedTurn = () => page + .locator('[data-transcript-turn-id]') + .first() + .getAttribute('data-transcript-turn-id'); + const firstBefore = await firstLoadedTurn(); + + await page.setViewportSize({ width: 900, height: 4000 }); + await waitForPaintedFrames(page, 6); + const asked = await scrollMetrics(page); + expect(asked.distance, JSON.stringify(asked)).toBeLessThanOrEqual(4); + + // Dispatched rather than driven, and that is the point: the case is a wheel + // the scroller cannot act on — already at zero, or too short to move — where + // no scroll follows and the authority never learns the reader asked. A real + // `mouse.wheel` would scroll, and the scroll alone would carry the request. + await page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); + }, SCROLLER); + + await expect.poll(firstLoadedTurn, { timeout: 20_000 }).not.toBe(firstBefore); +}); diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts index 271fd2fc8f..53c5dc39db 100644 --- a/apps/desktop/e2e/workhub-layout.spec.ts +++ b/apps/desktop/e2e/workhub-layout.spec.ts @@ -17,9 +17,9 @@ * under the License. */ -import { expect, test, COMPOSER_INPUT } from './fixtures'; +import { COMPOSER_INPUT, expect, test, waitForWorkHubReady } from './fixtures'; -test('WorkHub target metadata does not overlap the submitted Session result', async ({ +test('WorkHub target metadata remains within the submitted Session control', async ({ window: page, }) => { const composer = page.locator(COMPOSER_INPUT); @@ -36,29 +36,48 @@ test('WorkHub target metadata does not overlap the submitted Session result', as await page.evaluate(async () => { await window.maka.settings.updateClient({ workHub: { enabled: true } }); }); - await expect(page.getByRole('main', { name: 'WorkHub' })).toBeVisible(); + await waitForWorkHubReady(page, 1); + const routedPrompt = `继续${sessionName},补充重复投递测试点。`; const workHubComposer = page.locator( '.workhub-surface .maka-composer-editor [contenteditable="true"]', ); - await workHubComposer.fill(`继续${sessionName},补充重复投递测试点。`); + await workHubComposer.fill(routedPrompt); await workHubComposer.press('Enter'); - await expect(page.locator('.workhub-result')).toBeVisible(); + const submittedTurn = page.locator('.workhub-turn', { hasText: routedPrompt }); + await expect(submittedTurn.locator('.workhub-submitted-session small')).toBeVisible(); - const geometry = await page.evaluate(() => { - const button = document.querySelector('.workhub-submitted > button')!; + const buttonContainsProject = await submittedTurn.evaluate((turn) => { + const button = turn.querySelector('.workhub-submitted > button')!; const project = button.querySelector('.workhub-submitted-session small')!; - const result = document.querySelector('.workhub-result')!; const buttonBox = button.getBoundingClientRect(); const projectBox = project.getBoundingClientRect(); - const resultBox = result.getBoundingClientRect(); - return { - buttonContainsProject: buttonBox.bottom >= projectBox.bottom, - overlapHeight: - Math.min(projectBox.bottom, resultBox.bottom) - Math.max(projectBox.top, resultBox.top), - }; + return buttonBox.bottom >= projectBox.bottom; }); - expect(geometry.buttonContainsProject).toBe(true); - expect(geometry.overlapHeight).toBeLessThanOrEqual(0); + expect(buttonContainsProject).toBe(true); +}); + +test('WorkHub explains Coordination startup failure and recovers after a default model is set', async ({ + window: page, +}) => { + await page.evaluate(async () => { + await window.maka.connections.setDefaultModel(null); + await window.maka.settings.updateClient({ workHub: { enabled: true } }); + }); + + const failure = page.getByRole('alert'); + await expect(failure).toContainText('WorkHub 暂时无法启动'); + await expect(failure).toContainText('请检查当前 Runtime Host 的默认模型配置'); + + await page.evaluate(async () => { + await window.maka.connections.setDefaultModel({ + slug: 'e2e', + model: 'claude-sonnet-4-5-20250929', + }); + }); + + await expect(page.getByRole('region', { name: 'WorkHub' })).toBeVisible(); + await expect(page.locator('.workhub-empty')).toContainText('从这里继续所有工作'); + await expect(page.locator('.workhub-surface .maka-composer-editor')).toBeVisible(); }); diff --git a/apps/desktop/e2e/workhub-reconstruction.spec.ts b/apps/desktop/e2e/workhub-reconstruction.spec.ts index d81dec3861..69bad64557 100644 --- a/apps/desktop/e2e/workhub-reconstruction.spec.ts +++ b/apps/desktop/e2e/workhub-reconstruction.spec.ts @@ -17,9 +17,15 @@ * under the License. */ -import { expect, test, COMPOSER_INPUT } from './fixtures'; +import { + COMPOSER_INPUT, + ensureSidebarExpanded, + expect, + test, + waitForWorkHubReady, +} from './fixtures'; -test('WorkHub rebuilds Session conversation after navigating away and back', async ({ +test('WorkHub rebuilds delegated execution feedback after navigating away and back', async ({ window: page, }) => { const initialPrompt = '检查支付回调重复投递时的幂等性'; @@ -37,28 +43,119 @@ test('WorkHub rebuilds Session conversation after navigating away and back', asy await page.evaluate(async () => { await window.maka.settings.updateClient({ workHub: { enabled: true } }); }); - await expect(page.getByRole('main', { name: 'WorkHub' })).toBeVisible(); - await expect( - page.locator('.workhub-projected-turn .workhub-user-bubble > p', { - hasText: initialPrompt, - }), - ).toBeVisible(); + await expect(page.getByRole('region', { name: 'WorkHub' })).toBeVisible(); + // The conversation is the Coordination Session transcript. An ordinary + // Session is a routing target and a status row, never a turn in WorkHub. + await waitForWorkHubReady(page, 1); + await expect(page.locator('.workhub-turn')).toHaveCount(0); + await expect(page.locator('.workhub-empty h2')).toHaveText('从这里继续所有工作'); - const routedPrompt = `继续${sessionName},补充重复投递测试点。`; + const routedPrompt = '继续这个工作,补充重复投递测试点。'; const workHubComposer = page.locator( '.workhub-surface .maka-composer-editor [contenteditable="true"]', ); await workHubComposer.fill(routedPrompt); await workHubComposer.press('Enter'); - await expect(page.locator('.workhub-submitted').last()).toBeVisible(); - await page.locator('.workhub-submitted > button').last().click(); - await expect(page.getByRole('main', { name: 'WorkHub' })).toBeHidden(); + const routedTurn = page.locator('.workhub-turn', { hasText: routedPrompt }); + await expect(routedTurn.locator('.workhub-submitted')).toBeVisible(); + await routedTurn.locator('.workhub-submitted > button').click(); + await expect(page.getByRole('region', { name: 'WorkHub' })).toBeHidden(); + await ensureSidebarExpanded(page); await page.getByRole('button', { name: 'WorkHub', exact: true }).click(); - await expect(page.getByRole('main', { name: 'WorkHub' })).toBeVisible(); + await waitForWorkHubReady(page, 1); + await expect(page.getByRole('region', { name: 'WorkHub' })).toBeVisible(); await expect( page.locator('.workhub-projected-turn .workhub-user-bubble > p', { hasText: routedPrompt, }), ).toBeVisible(); + await expect( + page.locator('.workhub-projected-turn', { hasText: routedPrompt }) + .locator('.workhub-submitted-state'), + ).toHaveText('关联有效 · 已完成'); +}); + +test('WorkHub explicitly announces a newly created work item', async ({ window: page }) => { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('检查支付回调重复投递时的幂等性'); + await composer.press('Enter'); + await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { + timeout: 20_000, + }); + await page.evaluate(async () => { + await window.maka.settings.updateClient({ workHub: { enabled: true } }); + }); + await waitForWorkHubReady(page, 1); + + const prompt = '创建一个新的 Session,名为登录稳定性'; + const workHubComposer = page.locator( + '.workhub-surface .maka-composer-editor [contenteditable="true"]', + ); + await workHubComposer.fill(prompt); + await workHubComposer.press('Enter'); + + const createdTurn = page.locator('.workhub-turn', { hasText: prompt }); + await expect(createdTurn.locator('.workhub-submitted')).toContainText('已创建新工作:'); + await expect(createdTurn.locator('.workhub-submitted-session strong')).toHaveText('登录稳定性'); +}); + +test('WorkHub replaces the exact linked delegation across Sessions', async ({ + window: page, +}) => { + const sourceSessionName = '检查支付回调重复投递时的幂等性'; + const composer = page.locator(COMPOSER_INPUT); + await composer.fill(sourceSessionName); + await composer.press('Enter'); + await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { + timeout: 20_000, + }); + await page.evaluate(async (name) => { + const sourceSession = (await window.maka.sessions.list())[0]; + if (!sourceSession) throw new Error('Source Session was not found'); + await window.maka.sessions.rename(sourceSession.id, name); + }, sourceSessionName); + await page.evaluate(async () => { + await window.maka.settings.updateClient({ workHub: { enabled: true } }); + }); + await expect(page.getByRole('region', { name: 'WorkHub' })).toBeVisible(); + await page.evaluate(async () => { + await window.maka.sessions.create({ name: '登录稳定性' }); + }); + await waitForWorkHubReady(page, 2); + + const workHubComposer = page.locator( + '.workhub-surface .maka-composer-editor [contenteditable="true"]', + ); + await workHubComposer.fill('继续这个工作,补充重复投递测试点。'); + await workHubComposer.press('Enter'); + const continuedTurn = page.locator('.workhub-turn', { + hasText: '继续这个工作,补充重复投递测试点。', + }); + await expect( + continuedTurn.locator('.workhub-submitted-session strong'), + ).toHaveText(sourceSessionName); + await waitForWorkHubReady(page, 2); + + await workHubComposer.fill('不是这个,换成登录稳定性,补充刷新令牌失败判定。'); + await expect( + page.locator('.workhub-surface').getByRole('button', { name: '发送' }), + ).toBeEnabled(); + await workHubComposer.press('Enter'); + + const correctionTurn = page.locator('.workhub-turn', { + hasText: '不是这个,换成登录稳定性,补充刷新令牌失败判定。', + }); + await expect( + correctionTurn.locator('.workhub-submitted-session strong'), + ).toHaveText('登录稳定性'); + await expect(correctionTurn.locator('.workhub-error')).toHaveCount(0); + await expect( + continuedTurn.locator('.workhub-submitted-state'), + ).toHaveText('已被更正'); + await expect( + correctionTurn.locator('.workhub-submitted-state'), + ).toContainText( + /^关联有效 · (?:已接收|进行中|等待你|已完成|失败|已中止|正在恢复)$/u, + ); }); diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 321216a77a..652e0cd08d 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -17,25 +17,63 @@ * under the License. */ -import { readFileSync } from 'node:fs'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { + resolveDesktopBuildVersion, + resolveRuntimeHostSetupPackage, +} from '../../scripts/desktop-nightly.mjs'; +import { workspaceReleaseManifest } from '../../scripts/release-cli-file-policy.mjs'; import { resolveProductManifestIdentity } from '../../scripts/product-release-identity.mjs'; function readManifest(relativePath) { return JSON.parse(readFileSync(new URL(relativePath, import.meta.url), 'utf8')); } +// Some license files below ship inside third-party packages that apps/desktop +// depends on (electron, @fontsource-variable/geist*). Locate each package by +// resolving its manifest rather than assuming its node_modules location: +// `../../node_modules/` only resolves when the installer hoists these +// packages to the workspace root, but they are declared in apps/desktop, not +// the root. electron-builder logs a warning and still exits 0 when a `from` +// path is missing, so a non-hoisting layout would silently drop the notices +// (verify-packaged-app.mjs then fails far from the cause). resolve() finds the +// package wherever the installer placed it — hoisted or nested. +const require = createRequire(import.meta.url); +function resolvePackageFile(packageName, relativePath) { + return join(dirname(require.resolve(`${packageName}/package.json`)), relativePath); +} + +async function stageReleaseManifests({ packager }) { + const stage = await packager.info.tempDirManager.createTempDir({ + prefix: 'maka-release-manifests', + }); + for (const name of ['mcp', 'runtime', 'runtime-host']) { + const directory = join(stage, name); + mkdirSync(directory, { recursive: true }); + writeFileSync( + join(directory, 'package.json'), + `${JSON.stringify(workspaceReleaseManifest(readManifest(`../../packages/${name}/package.json`)), null, 2)}\n`, + ); + } + packager.config.files.push({ from: stage, to: 'node_modules/@maka' }); +} + +const rootManifest = readManifest('../../package.json'); const { runtimeHostSetupPackage } = resolveProductManifestIdentity({ - rootManifest: readManifest('../../package.json'), + rootManifest, desktopManifest: readManifest('./package.json'), cliManifest: readManifest('../../packages/cli/package.json'), }); -export default { +const baseDesktopBuilderConfig = { appId: 'com.maka.desktop', productName: 'Maka', artifactName: 'Maka-${version}-mac-${arch}.${ext}', asar: true, - extraMetadata: { runtimeHostSetupPackage }, + beforePack: stageReleaseManifests, + extraMetadata: { runtimeHostSetupPackage, makaUpdateChannel: 'release' }, directories: { output: 'release', }, @@ -52,6 +90,7 @@ export default { 'dist/**/*', 'dist-renderer/**/*', 'package.json', + '!node_modules/@maka/{mcp,runtime,runtime-host}/package.json', '!**/__tests__/**', // FakeBackend and the Desktop E2E candidate bootstrap live under // `test-only/`; they must not reach a packaged app. @@ -89,6 +128,14 @@ export default { from: 'resources/workers/filesystem-worker.js', to: 'workers/filesystem-worker.js', }, + { + from: '../../native/runtime-host-peer/target/release/maka_runtime_host_peer.node', + to: 'runtime-host-peer/maka_runtime_host_peer.node', + }, + { + from: '../../packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt', + to: 'licenses/runtime-host-peer/THIRD_PARTY_NOTICES.txt', + }, ...(process.platform === 'win32' ? [ { @@ -118,11 +165,11 @@ export default { to: 'licenses/maka/DISCLAIMER-WIP', }, { - from: '../../node_modules/electron/dist/LICENSE', + from: resolvePackageFile('electron', 'dist/LICENSE'), to: 'licenses/electron/LICENSE', }, { - from: '../../node_modules/electron/dist/LICENSES.chromium.html', + from: resolvePackageFile('electron', 'dist/LICENSES.chromium.html'), to: 'licenses/electron/LICENSES.chromium.html', }, { @@ -134,11 +181,11 @@ export default { to: 'licenses/renderer/THIRD_PARTY_LICENSES.txt', }, { - from: '../../node_modules/@fontsource-variable/geist/LICENSE', + from: resolvePackageFile('@fontsource-variable/geist', 'LICENSE'), to: 'licenses/renderer/GEIST_LICENSE.txt', }, { - from: '../../node_modules/@fontsource-variable/geist-mono/LICENSE', + from: resolvePackageFile('@fontsource-variable/geist-mono', 'LICENSE'), to: 'licenses/renderer/GEIST_MONO_LICENSE.txt', }, { @@ -174,7 +221,14 @@ export default { { target: 'zip', arch: ['arm64'] }, ], category: 'public.app-category.productivity', - icon: 'assets/icon.png', + // The bundle icon is what Finder, Launchpad and the installer show, and + // none of those run our code — so it cannot follow the user's choice and + // has to be the shipped default. `assets/icon.png` is the original mascot + // mark, which is still selectable as the `default` id but is no longer the + // default; pointing the bundle at it would leave every surface outside the + // running app on the old artwork. Kept in step with `DEFAULT_APP_ICON` by + // a test in scripts/verify-packaged-app-icons.test.mjs. + icon: 'assets/app-icons/sky.png', forceCodeSigning: true, hardenedRuntime: true, notarize: true, @@ -186,6 +240,16 @@ export default { }, }, dmg: { + title: 'Maka Installer', + // Relative to electron-builder's default buildResources directory (build/). + background: 'background.png', + window: { width: 540, height: 380 }, + iconSize: 112, + iconTextSize: 16, + contents: [ + { x: 130, y: 190, type: 'file' }, + { x: 410, y: 190, type: 'link', path: '/Applications' }, + ], sign: true, // Stapling the notarization ticket after electron-builder exits changes the // DMG bytes. macOS updates use the ZIP, so do not publish a stale DMG hash @@ -198,7 +262,9 @@ export default { { target: 'zip', arch: ['x64'] }, ], artifactName: 'Maka-${version}-win-${arch}.${ext}', - icon: 'assets/icon.png', + // Same reason as `mac.icon` above: the .exe, the installer and the + // shortcut are drawn by the OS from this file, not by us. + icon: 'assets/app-icons/sky.png', // No Authenticode certificate yet. Being unsigned is the absence of one: // electron-builder skips signing when no certificate is configured, and // `forceCodeSigning` is left off so that skip is not an error. Nothing here @@ -222,3 +288,21 @@ export default { }, ], }; + +export function resolveDesktopBuilderConfig(environment = process.env) { + const nightlyVersion = environment.MAKA_DESKTOP_NIGHTLY_VERSION?.trim(); + if (!nightlyVersion) return baseDesktopBuilderConfig; + const version = resolveDesktopBuildVersion(rootManifest.version, environment); + return { + ...baseDesktopBuilderConfig, + extraMetadata: { + ...baseDesktopBuilderConfig.extraMetadata, + version, + runtimeHostSetupPackage: resolveRuntimeHostSetupPackage(rootManifest.version, environment), + makaUpdateChannel: 'nightly', + }, + publish: [{ provider: 'github', owner: 'apache', repo: 'maka', channel: 'dev' }], + }; +} + +export default resolveDesktopBuilderConfig(); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b08ce8ef13..43f1b4b13d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -10,12 +10,16 @@ "main": "dist/main/main.js", "scripts": { "start": "node scripts/start-dev-app.mjs", + "start:peer": "npm run prepare:runtime-host-peer && node scripts/start-dev-app.mjs --runtime-host-peer", "prepare:dev-app": "node scripts/prepare-dev-app.mjs", + "prepare:runtime-host-peer": "node ../../native/runtime-host-peer/build.mjs", "dev": "node scripts/dev.mjs", + "dev:peer": "npm run prepare:runtime-host-peer && node scripts/dev.mjs --runtime-host-peer", "dev:hmr": "node scripts/dev.mjs", "storybook": "storybook dev -p 6006 -c .storybook", "build-storybook": "storybook build -c .storybook --output-dir storybook-static", "smoke:storybook": "node ../../scripts/storybook-visual-smoke.mjs", + "check:architecture": "node --test scripts/check-renderer-architecture.test.mjs && node scripts/check-renderer-architecture.mjs", "build": "npm run build:resources && npm run build:main && npm run build:preload && npm run build:overlay && npm run build:renderer", "build:resources": "node scripts/copy-runtime-filesystem-worker.mjs", "build:test": "npm run build:main && npm run build:preload && npm run build:overlay", @@ -24,14 +28,12 @@ "build:main": "tsc -p tsconfig.main.json", "build:preload": "esbuild src/preload/preload.ts --bundle --platform=node --format=cjs --outfile=dist/preload/preload.cjs --external:electron", "build:overlay": "node ../../scripts/build-cursor-overlay.mjs", - "build:renderer": "vite build && node ../../scripts/check-third-party-notices.mjs", + "build:renderer": "vite build && node scripts/check-renderer-entry-output.mjs && node ../../scripts/check-third-party-notices.mjs", "build:workspace-deps": "npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/ui run build", "package:macos-arm64": "electron-builder --config electron-builder.config.mjs --mac --arm64 --publish never", "package:windows-x64": "electron-builder --config electron-builder.config.mjs --win --x64 --publish never", "typecheck": "tsc -p tsconfig.preload.json --noEmit && tsc -p tsconfig.main.json --noEmit && tsc -p tsconfig.renderer.json --noEmit && tsc -p tsconfig.storybook.json --noEmit", "typecheck:stories": "tsc -p tsconfig.storybook.json --noEmit", - "pretest": "npm run build:workspace-deps", - "test": "npm run clean:main && npm run build:main && node --test \"dist/main/**/*.test.js\"", "test:dist": "node --test \"dist/main/**/*.test.js\" scripts/dev-app-runtime.test.mjs", "e2e": "npm run build:with-deps && playwright test --config e2e/playwright.config.ts", "build:with-deps": "npm run build:workspace-deps && npm run build", @@ -49,6 +51,9 @@ "@maka/runtime": "0.1.0", "@maka/runtime-host": "0.1.0", "@maka/storage": "0.1.0", + "@sigstore/bundle": "5.0.0", + "@sigstore/tuf": "5.0.0", + "@sigstore/verify": "4.1.2", "electron-updater": "^6.8.9", "node-pty": "^1.2.0-beta.15", "qrcode": "^1.5.4", @@ -57,8 +62,8 @@ }, "devDependencies": { "@ant-design/icons-svg": "4.5.0", - "@astryxdesign/core": "0.4.5", - "@astryxdesign/theme-neutral": "0.4.5", + "@astryxdesign/core": "0.5.2", + "@astryxdesign/theme-neutral": "0.5.2", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@fontsource-variable/geist": "^5.3.0", diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json new file mode 100644 index 0000000000..fc1b4e558b --- /dev/null +++ b/apps/desktop/renderer-architecture.json @@ -0,0 +1,5654 @@ +{ + "version": 1, + "legacyRendererFiles": [ + "src/renderer/agent-graph-panel-visibility.ts", + "src/renderer/agent-graph-panel.tsx", + "src/renderer/agent-graph-refresh.ts", + "src/renderer/app-shell-app-update.ts", + "src/renderer/app-shell-chat-actions.ts", + "src/renderer/app-shell-chrome-actions.tsx", + "src/renderer/app-shell-command-actions.ts", + "src/renderer/app-shell-context-compaction.ts", + "src/renderer/app-shell-copy.ts", + "src/renderer/app-shell-detail-panel.tsx", + "src/renderer/app-shell-e2e-fixture.ts", + "src/renderer/app-shell-effects.ts", + "src/renderer/app-shell-overlays.tsx", + "src/renderer/app-shell-project-actions.ts", + "src/renderer/app-shell-revision-actions.ts", + "src/renderer/app-shell-session-events.ts", + "src/renderer/app-shell-session-settings-actions.ts", + "src/renderer/app-shell-session-start-actions.ts", + "src/renderer/app-shell-session-ui-state.ts", + "src/renderer/app-shell-stop-action.ts", + "src/renderer/app-shell-turn-actions.ts", + "src/renderer/app-shell-turn-view-model.ts", + "src/renderer/app-shell.tsx", + "src/renderer/app-update-install.ts", + "src/renderer/app.tsx", + "src/renderer/astryx-theme-mode.ts", + "src/renderer/astryx-theme/maka.js", + "src/renderer/astryx-theme/makaTheme.ts", + "src/renderer/astryx-theme/type-scale.ts", + "src/renderer/attachment-preflight.ts", + "src/renderer/bootstrap-selection-lease.ts", + "src/renderer/browser-storage.ts", + "src/renderer/cached-theme-bootstrap.ts", + "src/renderer/chat-composer-region.tsx", + "src/renderer/chat-message-surface.tsx", + "src/renderer/chat-recovery-notice.tsx", + "src/renderer/command-palette-commands.ts", + "src/renderer/command-palette-types.ts", + "src/renderer/command-palette.tsx", + "src/renderer/composer-attachments.ts", + "src/renderer/composer-defaults.ts", + "src/renderer/composer-mentions.tsx", + "src/renderer/computer-use-overlay/engine/cursor-candidate-grid.ts", + "src/renderer/computer-use-overlay/engine/cursor-engine.ts", + "src/renderer/computer-use-overlay/engine/palette.ts", + "src/renderer/conversation-markdown.ts", + "src/renderer/custom-pet-companion-model.ts", + "src/renderer/custom-pet-companion.tsx", + "src/renderer/daily-review-actions.ts", + "src/renderer/default-runtime-host-operation.ts", + "src/renderer/derive-turn-lineage-badges.ts", + "src/renderer/desktop-execution-boundary-surface.ts", + "src/renderer/desktop-slash-command.ts", + "src/renderer/desktop-transcript-range-store.ts", + "src/renderer/error-boundary.tsx", + "src/renderer/follow-up-submit-routing.ts", + "src/renderer/interrupted-resume.ts", + "src/renderer/keyboard-help.tsx", + "src/renderer/live-content-seed.ts", + "src/renderer/live-turn-reconciler.tsx", + "src/renderer/live-turn-snapshot.ts", + "src/renderer/local-memory-digest.ts", + "src/renderer/locales/artifact-copy.ts", + "src/renderer/locales/browser-copy.ts", + "src/renderer/locales/conversation-copy.ts", + "src/renderer/locales/external-session-import-copy.ts", + "src/renderer/locales/mcp-copy.ts", + "src/renderer/locales/onboarding-copy.ts", + "src/renderer/locales/permission-center-copy.ts", + "src/renderer/locales/plan-mode-copy.ts", + "src/renderer/locales/session-collaboration-copy.ts", + "src/renderer/locales/settings-bot-copy.ts", + "src/renderer/locales/settings-daily-review-copy.ts", + "src/renderer/locales/settings-data-copy.ts", + "src/renderer/locales/settings-health-copy.ts", + "src/renderer/locales/settings-memory-copy.ts", + "src/renderer/locales/settings-navigation-copy.ts", + "src/renderer/locales/settings-preferences-copy.ts", + "src/renderer/locales/settings-projects-copy.ts", + "src/renderer/locales/settings-shared-copy.ts", + "src/renderer/locales/settings-subagents-copy.ts", + "src/renderer/locales/settings-tasks-copy.ts", + "src/renderer/locales/settings-test-result-copy.ts", + "src/renderer/locales/settings-usage-copy.ts", + "src/renderer/locales/settings-web-search-copy.ts", + "src/renderer/locales/shell-copy.ts", + "src/renderer/locales/shell-remaining-copy.ts", + "src/renderer/main.tsx", + "src/renderer/mcp-brand-contrast.ts", + "src/renderer/mcp-brand-marks.tsx", + "src/renderer/mcp-catalog.ts", + "src/renderer/mcp-command-line.ts", + "src/renderer/mcp-editor-validation.ts", + "src/renderer/mcp-page-model.ts", + "src/renderer/mcp-page.tsx", + "src/renderer/model-catalog-choices.ts", + "src/renderer/model-connection-errors.ts", + "src/renderer/model-wait-state.ts", + "src/renderer/nav-selection.ts", + "src/renderer/new-task-reload-intent.ts", + "src/renderer/observable-state.ts", + "src/renderer/onboarding-hero-copy.ts", + "src/renderer/onboarding-hero.tsx", + "src/renderer/onboarding-provider-types.ts", + "src/renderer/open-path.ts", + "src/renderer/pending-items.ts", + "src/renderer/pending-session-view.ts", + "src/renderer/plan-mode-panel.tsx", + "src/renderer/project-path-display.ts", + "src/renderer/remote-project-directory-dialog.tsx", + "src/renderer/scroll-motion-policy.ts", + "src/renderer/session-catalog-state.ts", + "src/renderer/session-collaboration-dialog.tsx", + "src/renderer/session-copy-attempt.ts", + "src/renderer/session-error-presentation.ts", + "src/renderer/session-event-health.ts", + "src/renderer/session-health-notice.ts", + "src/renderer/session-message-settlement.ts", + "src/renderer/session-read-state.ts", + "src/renderer/session-status-presentation.ts", + "src/renderer/session-trace-refresh.ts", + "src/renderer/session-turn-request-composer.tsx", + "src/renderer/session-workspace-actions.ts", + "src/renderer/session-workspace-errors.ts", + "src/renderer/settings/about-settings-page.tsx", + "src/renderer/settings/about-update-status.ts", + "src/renderer/settings/action-guard.ts", + "src/renderer/settings/appearance-settings-page.tsx", + "src/renderer/settings/bot-chat-detail.tsx", + "src/renderer/settings/bot-chat-overview.tsx", + "src/renderer/settings/bot-chat-settings-page.tsx", + "src/renderer/settings/bot-chat-shared.tsx", + "src/renderer/settings/bot-onboarding-modal.tsx", + "src/renderer/settings/bot-settings-view-model.ts", + "src/renderer/settings/bot-wechat-login.tsx", + "src/renderer/settings/connection-name-draft.ts", + "src/renderer/settings/custom-pet-library-model.ts", + "src/renderer/settings/custom-pet-settings-section.tsx", + "src/renderer/settings/daily-review-settings-page.tsx", + "src/renderer/settings/data-settings-page.tsx", + "src/renderer/settings/general-settings-page.tsx", + "src/renderer/settings/health-center-page.tsx", + "src/renderer/settings/import-tasks-settings-page.tsx", + "src/renderer/settings/memory-entry-list.tsx", + "src/renderer/settings/memory-settings-labels.ts", + "src/renderer/settings/memory-settings-page.tsx", + "src/renderer/settings/memory-settings-sections.tsx", + "src/renderer/settings/memory-settings-view-model.ts", + "src/renderer/settings/nav-group-summary.ts", + "src/renderer/settings/oauth-login-flow-guard.ts", + "src/renderer/settings/optimistic-settings-draft-controller.ts", + "src/renderer/settings/password-input.tsx", + "src/renderer/settings/permission-center-page.tsx", + "src/renderer/settings/personalization-settings-section.tsx", + "src/renderer/settings/projects-settings-page.tsx", + "src/renderer/settings/provider-add-form.tsx", + "src/renderer/settings/provider-add-model-dialog.tsx", + "src/renderer/settings/provider-add-submission.ts", + "src/renderer/settings/provider-brand-marks.tsx", + "src/renderer/settings/provider-catalog-page.tsx", + "src/renderer/settings/provider-connection-detail.tsx", + "src/renderer/settings/provider-connection-status.ts", + "src/renderer/settings/provider-display-copy.ts", + "src/renderer/settings/provider-display.tsx", + "src/renderer/settings/provider-enabled-model-manager.tsx", + "src/renderer/settings/provider-endpoint-presentation.ts", + "src/renderer/settings/provider-oauth-section.tsx", + "src/renderer/settings/providers-panel.tsx", + "src/renderer/settings/relay-profile-draft.ts", + "src/renderer/settings/relay-thinking-bulk.ts", + "src/renderer/settings/request-customization-editor.tsx", + "src/renderer/settings/runtime-host-connection-code-dialog.tsx", + "src/renderer/settings/runtime-host-interaction-boundary.tsx", + "src/renderer/settings/runtime-host-management-dialog.tsx", + "src/renderer/settings/runtime-host-onboarding-dialog.tsx", + "src/renderer/settings/runtime-host-profiles-section.tsx", + "src/renderer/settings/runtime-host-project-directory-editor.tsx", + "src/renderer/settings/runtime-host-settings-bridge.ts", + "src/renderer/settings/runtime-host-settings-target.tsx", + "src/renderer/settings/runtime-host-ssh-terminal-dialog.tsx", + "src/renderer/settings/settings-error-copy.ts", + "src/renderer/settings/settings-expandable-row.tsx", + "src/renderer/settings/settings-metric-card.tsx", + "src/renderer/settings/settings-modal.tsx", + "src/renderer/settings/settings-nav.ts", + "src/renderer/settings/settings-request-authority.ts", + "src/renderer/settings/settings-resource-state.ts", + "src/renderer/settings/settings-route-focus.ts", + "src/renderer/settings/settings-route-header.tsx", + "src/renderer/settings/settings-rows.tsx", + "src/renderer/settings/settings-section.tsx", + "src/renderer/settings/settings-skeleton.tsx", + "src/renderer/settings/settings-snapshot-cache.ts", + "src/renderer/settings/settings-status-badge.ts", + "src/renderer/settings/settings-status-summary-filter.tsx", + "src/renderer/settings/settings-surface.tsx", + "src/renderer/settings/subagent-preset-presentation.ts", + "src/renderer/settings/subagent-settings-page.tsx", + "src/renderer/settings/task-catalog-rows.ts", + "src/renderer/settings/tasks-settings-page.tsx", + "src/renderer/settings/ui-locale-update-gate.ts", + "src/renderer/settings/usage-settings-page.tsx", + "src/renderer/settings/use-action-guard.ts", + "src/renderer/settings/use-connection-detail.ts", + "src/renderer/settings/use-memory-settings-controller.ts", + "src/renderer/settings/use-oauth-login-flow.ts", + "src/renderer/settings/use-optimistic-settings-draft.ts", + "src/renderer/settings/web-search-settings-page.tsx", + "src/renderer/settled-message-merge.ts", + "src/renderer/settled-session-transients.ts", + "src/renderer/shell-chat-model-selection.ts", + "src/renderer/shell-run-update-state.ts", + "src/renderer/side-chat-command.ts", + "src/renderer/skill-invocation-feedback.ts", + "src/renderer/stale-sessions.ts", + "src/renderer/task-readiness-notice.ts", + "src/renderer/theme.ts", + "src/renderer/titlebar-dim-color.ts", + "src/renderer/titlebar-modal-sync.ts", + "src/renderer/transient-message-projection.ts", + "src/renderer/turn-footer-actions.ts", + "src/renderer/use-active-execution-boundary.ts", + "src/renderer/use-app-shell-composer-quotes.ts", + "src/renderer/use-app-shell-session-list.ts", + "src/renderer/use-app-shell-session-ui-reads.ts", + "src/renderer/use-app-shell-session-workspace.ts", + "src/renderer/use-composer-attachments.ts", + "src/renderer/use-deep-research-run.ts", + "src/renderer/use-delayed-flag.ts", + "src/renderer/use-external-store-selector.ts", + "src/renderer/use-new-task-choice.ts", + "src/renderer/use-onboarding-snapshot.ts", + "src/renderer/use-project-context.ts", + "src/renderer/use-session-setting-intent.ts", + "src/renderer/use-settings-modal.ts", + "src/renderer/use-shell-appearance.ts", + "src/renderer/use-shell-chat-model.ts", + "src/renderer/use-shell-connections.ts", + "src/renderer/use-shell-live-turn.ts", + "src/renderer/use-shell-memory-pill.ts", + "src/renderer/use-shell-resume.ts", + "src/renderer/use-shell-search.ts", + "src/renderer/use-stable-actions.ts", + "src/renderer/use-system-ui-locale.ts", + "src/renderer/use-task-submission-readiness.ts", + "src/renderer/use-turn-action-registry.ts", + "src/renderer/use-work-board.ts", + "src/renderer/work-board-panel.tsx", + "src/renderer/workhub-controller.ts", + "src/renderer/workhub-coordination-host-scope.ts", + "src/renderer/workhub-coordination-lifecycle.ts", + "src/renderer/workhub-coordination-port.ts", + "src/renderer/workhub-route-policy.ts", + "src/renderer/workhub-send-lease.ts", + "src/renderer/workhub-session-port.ts", + "src/renderer/workhub-surface.tsx", + "src/renderer/workspace-readiness-recovery.ts" + ], + "legacyGrowthDirectories": [ + "src/renderer/astryx-theme", + "src/renderer/computer-use-overlay", + "src/renderer/locales", + "src/renderer/settings" + ], + "legacyFeatureImports": [ + "src/renderer/features/goals/controller/use-goal-controller.ts -> src/renderer/locales/shell-copy", + "src/renderer/features/goals/ui/goal-dialog.tsx -> src/renderer/locales/shell-copy", + "src/renderer/features/module-hub/controller/use-daily-review-controller.ts -> src/renderer/daily-review-actions", + "src/renderer/features/module-hub/controller/use-daily-review-controller.ts -> src/renderer/locales/shell-copy", + "src/renderer/features/module-hub/controller/use-daily-review-controller.ts -> src/renderer/locales/shell-remaining-copy", + "src/renderer/features/module-hub/controller/use-scheduled-tasks-controller.ts -> src/renderer/locales/shell-copy", + "src/renderer/features/module-hub/controller/use-scheduled-tasks-controller.ts -> src/renderer/locales/shell-remaining-copy", + "src/renderer/features/module-hub/controller/use-skills-controller.ts -> src/renderer/locales/shell-copy", + "src/renderer/features/module-hub/ui/module-hub-host.tsx -> src/renderer/mcp-page", + "src/renderer/features/session-navigation/controller/session-row-actions.ts -> src/renderer/locales/shell-copy", + "src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts -> src/renderer/use-external-store-selector", + "src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts -> src/renderer/use-external-store-selector", + "src/renderer/features/session-navigation/model/session-list-layout.ts -> src/renderer/browser-storage", + "src/renderer/features/session-navigation/model/session-project-grouping.ts -> src/renderer/locales/shell-remaining-copy", + "src/renderer/features/session-navigation/model/session-rail-layout-store.ts -> src/renderer/browser-storage", + "src/renderer/features/session-navigation/model/session-rail-layout-store.ts -> src/renderer/observable-state", + "src/renderer/features/task-entry/controller/use-task-entry-controller.ts -> src/renderer/locales/shell-copy", + "src/renderer/features/task-entry/model/task-entry-selection.ts -> src/renderer/new-task-reload-intent", + "src/renderer/features/task-entry/ui/task-entry-host.tsx -> src/renderer/remote-project-directory-dialog", + "src/renderer/features/workbar/controller/use-workbar-controller.ts -> src/renderer/browser-storage", + "src/renderer/features/workbar/controller/use-workbar-controller.ts -> src/renderer/locales/conversation-copy", + "src/renderer/features/workbar/controller/use-workbar-controller.ts -> src/renderer/locales/shell-copy", + "src/renderer/features/workbar/controller/use-workbar-controller.ts -> src/renderer/side-chat-command", + "src/renderer/features/workbar/model/workbar-layout.ts -> src/renderer/browser-storage", + "src/renderer/features/workbar/model/workbar-tabs.ts -> src/renderer/browser-storage", + "src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx -> src/renderer/locales/artifact-copy", + "src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx -> src/renderer/open-path", + "src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx -> src/renderer/locales/artifact-copy", + "src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx -> src/renderer/locales/artifact-copy", + "src/renderer/features/workbar/tools/browser/browser-panel.tsx -> src/renderer/locales/browser-copy", + "src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx -> src/renderer/locales/conversation-copy", + "src/renderer/features/workbar/tools/inspector/use-session-trace.ts -> src/renderer/session-trace-refresh", + "src/renderer/features/workbar/tools/review/session-review-panel.tsx -> src/renderer/locales/conversation-copy", + "src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts -> src/renderer/model-connection-errors", + "src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts -> src/renderer/session-copy-attempt", + "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/attachment-preflight", + "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/composer-attachments", + "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/composer-mentions", + "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/locales/conversation-copy", + "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/scroll-motion-policy", + "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/turn-footer-actions", + "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/use-composer-attachments", + "src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts -> src/renderer/locales/conversation-copy", + "src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts -> src/renderer/settled-message-merge", + "src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx -> src/renderer/locales/conversation-copy", + "src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx -> src/renderer/theme", + "src/renderer/features/workbar/ui/side-chat-close-confirmation.tsx -> src/renderer/locales/conversation-copy", + "src/renderer/features/workbar/ui/workbar-host.tsx -> src/renderer/locales/shell-copy", + "src/renderer/features/workbar/ui/workbar-surface.tsx -> src/renderer/locales/conversation-copy", + "src/renderer/features/workbar/ui/workbar-surface.tsx -> src/renderer/work-board-panel", + "src/renderer/features/workbar/ui/workbar-toggle.tsx -> src/renderer/locales/shell-copy" + ], + "legacyPlatformImports": [ + "src/renderer/platform/desktop/create-workbar-services.ts -> src/renderer/session-message-settlement" + ], + "legacyAppShell": { + "files": { + "src/renderer/app-shell-app-update.ts": { + "importDeclarations": 2, + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "@maka/ui": 1 + }, + "importSpecifiers": 2, + "nonTriviaTokens": 92 + }, + "src/renderer/app-shell-chat-actions.ts": { + "importDeclarations": 26, + "bridgePaths": { + "window.maka.newTasks.create": 1, + "window.maka.sessions.remove": 1, + "window.maka.sessions.respondToSandboxBoundary": 1, + "window.maka.sessions.respondToUserQuestion": 1, + "window.maka.sessions.submitMessage": 1 + }, + "environmentCapabilities": { + "window": 1 + }, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [ + "createAppShellChatActions" + ], + "dependencyPaths": { + "../preload/bridge-contract.js": 2, + "./app-shell-copy.js": 1, + "./app-shell-session-ui-state.js": 1, + "./attachment-preflight.js": 1, + "./composer-attachments.js": 1, + "./desktop-transcript-range-store.js": 1, + "./locales/shell-copy.js": 1, + "./model-connection-errors.js": 1, + "./session-message-settlement.js": 1, + "./session-workspace-actions.js": 1, + "./session-workspace-errors.js": 1, + "./skill-invocation-feedback.js": 1, + "@maka/core/collaboration": 1, + "@maka/core/events": 2, + "@maka/core/model-thinking": 1, + "@maka/core/orchestration": 1, + "@maka/core/runtime-inputs": 1, + "@maka/core/sandbox-boundary": 1, + "@maka/core/session": 1, + "@maka/core/session-name": 1, + "@maka/core/settings": 1, + "@maka/core/ui-locale": 1, + "@maka/core/user-question": 1, + "@maka/runtime/skill-invocation": 1, + "@maka/ui": 1 + }, + "importSpecifiers": 39, + "nonTriviaTokens": 4278 + }, + "src/renderer/app-shell-chrome-actions.tsx": { + "importDeclarations": 5, + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/shell-copy": 1, + "@astryxdesign/core/Icon": 1, + "@astryxdesign/core/Tooltip": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1 + }, + "importSpecifiers": 8, + "nonTriviaTokens": 408 + }, + "src/renderer/app-shell-command-actions.ts": { + "importDeclarations": 16, + "bridgePaths": { + "window.maka.connections.setDefault": 1, + "window.maka.connections.test": 1, + "window.maka.diagnostics.copyReport": 1, + "window.maka.memory.openFile": 1, + "window.maka.sessions.saveConversationToFile": 1, + "window.maka.settings.testNetworkProxy": 1 + }, + "environmentCapabilities": { + "navigator.clipboard.writeText": 1 + }, + "hookCalls": { + "useRef": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/diagnostics-contract.js": 1, + "./app-shell-copy.js": 1, + "./application/contracts/session-start-mode.js": 1, + "./command-palette-commands.js": 1, + "./command-palette-types.js": 1, + "./conversation-markdown.js": 1, + "./default-runtime-host-operation.js": 1, + "./locales/settings-test-result-copy.js": 1, + "./locales/shell-copy.js": 1, + "@maka/core/llm-connections": 1, + "@maka/core/permission": 1, + "@maka/core/session": 1, + "@maka/core/settings": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1, + "react": 1 + }, + "importSpecifiers": 22, + "nonTriviaTokens": 2307 + }, + "src/renderer/app-shell-context-compaction.ts": { + "importDeclarations": 4, + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/shell-copy.js": 1, + "@maka/core/events": 1, + "@maka/core/ui-locale": 1, + "@maka/runtime-host/protocol": 1 + }, + "importSpecifiers": 4, + "nonTriviaTokens": 612 + }, + "src/renderer/app-shell-copy.ts": { + "importDeclarations": 5, + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/shell-copy.js": 1, + "@maka/core/llm-connections": 1, + "@maka/core/redaction": 1, + "@maka/core/text-file-import": 1, + "@maka/core/ui-locale": 1 + }, + "importSpecifiers": 6, + "nonTriviaTokens": 515 + }, + "src/renderer/app-shell-detail-panel.tsx": { + "importDeclarations": 1, + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "react": 1 + }, + "importSpecifiers": 1, + "nonTriviaTokens": 87 + }, + "src/renderer/app-shell-e2e-fixture.ts": { + "importDeclarations": 6, + "bridgePaths": { + "window.maka.e2eFixture.getState": 1 + }, + "environmentCapabilities": { + "document.documentElement.setAttribute": 4 + }, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [ + "createAppShellE2eFixtureActions" + ], + "dependencyPaths": { + "./features/workbar": 1, + "./theme": 1, + "@maka/core/settings": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1, + "react": 1 + }, + "importSpecifiers": 8, + "nonTriviaTokens": 672 + }, + "src/renderer/app-shell-effects.ts": { + "importDeclarations": 23, + "bridgePaths": { + "window.maka.app.info": 1, + "window.maka.appWindow.subscribeCommand": 1, + "window.maka.connections.subscribeEvents": 1, + "window.maka.runtimeHostProfiles.subscribeChanges": 1, + "window.maka.sessions.subscribeChanges": 1, + "window.maka.sessions.subscribeEvents": 1, + "window.maka.settings.subscribeClientChanged": 1, + "window.maka.settings.subscribeExternalChanged": 1, + "window.maka.shellRuns.list": 1, + "window.maka.shellRuns.subscribeResync": 1, + "window.maka.shellRuns.subscribeUpdates": 1, + "window.maka.transcripts.open": 1 + }, + "environmentCapabilities": { + "cancelAnimationFrame": 1, + "document.addEventListener": 1, + "document.documentElement.setAttribute": 1, + "document.removeEventListener": 1, + "document.visibilityState": 1, + "requestAnimationFrame": 1, + "window": 3, + "window.clearInterval": 1, + "window.clearTimeout": 3, + "window.setInterval": 1, + "window.setTimeout": 2 + }, + "hookCalls": { + "useEffect": 9, + "useHotkeys": 1, + "useLayoutEffect": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "../shared/runtime-host-identity.js": 1, + "./app-shell-copy": 1, + "./browser-storage": 1, + "./desktop-transcript-range-store.js": 1, + "./locales/conversation-copy.js": 1, + "./nav-selection.js": 1, + "./session-event-health": 1, + "./shell-run-update-state.js": 1, + "./theme": 1, + "./titlebar-modal-sync": 1, + "@astryxdesign/core/hooks": 1, + "@maka/core/connections": 1, + "@maka/core/events": 2, + "@maka/core/redaction": 1, + "@maka/core/session": 1, + "@maka/core/session-event-health": 2, + "@maka/core/settings": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1, + "react": 1 + }, + "importSpecifiers": 39, + "nonTriviaTokens": 3836 + }, + "src/renderer/app-shell-overlays.tsx": { + "importDeclarations": 14, + "bridgePaths": {}, + "environmentCapabilities": { + "window": 4, + "window.addEventListener": 1, + "window.removeEventListener": 1 + }, + "hookCalls": { + "useAppShellCommands": 1, + "useHotkeys": 1, + "useLayoutEffect": 2, + "useRef": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "./app-shell-command-actions": 1, + "./command-palette": 1, + "./keyboard-help": 1, + "./locales/shell-remaining-copy.js": 1, + "./settings/settings-modal": 1, + "./settings/tasks-settings-page": 1, + "./settings/ui-locale-update-gate": 1, + "@astryxdesign/core/hooks": 1, + "@astryxdesign/core/Spinner": 1, + "@maka/core/llm-connections": 1, + "@maka/core/settings": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1, + "react": 1 + }, + "importSpecifiers": 22, + "nonTriviaTokens": 977 + }, + "src/renderer/app-shell-project-actions.ts": { + "importDeclarations": 9, + "bridgePaths": { + "window.maka.app.openPath": 4, + "window.maka.app.resolveProjectGitInfo": 1, + "window.maka.projects.add": 1, + "window.maka.projects.archive": 1, + "window.maka.projects.getDefaultContext": 1, + "window.maka.projects.relink": 1, + "window.maka.projects.rename": 1, + "window.maka.projects.restore": 1, + "window.maka.projects.select": 3 + }, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [ + "createAppShellProjectActions" + ], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "./app-shell-copy": 1, + "./default-runtime-host-operation.js": 1, + "./locales/shell-copy.js": 1, + "./open-path": 1, + "./session-workspace-errors": 1, + "@maka/core/project": 1, + "@maka/core/ui-locale": 1, + "react": 1 + }, + "importSpecifiers": 15, + "nonTriviaTokens": 2284 + }, + "src/renderer/app-shell-revision-actions.ts": { + "importDeclarations": 11, + "bridgePaths": { + "window.maka.sessions.abandonSessionCopy": 2, + "window.maka.sessions.reviseBeforeTurn": 1 + }, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [ + "createAppShellRevisionActions" + ], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "./locales/conversation-copy.js": 1, + "./locales/shell-copy.js": 1, + "./session-copy-attempt.js": 1, + "./session-message-settlement.js": 1, + "./session-workspace-actions.js": 1, + "./session-workspace-errors.js": 1, + "@maka/core/session": 2, + "@maka/core/ui-locale": 1, + "@maka/ui": 1 + }, + "importSpecifiers": 17, + "nonTriviaTokens": 2316 + }, + "src/renderer/app-shell-session-events.ts": { + "importDeclarations": 8, + "bridgePaths": {}, + "environmentCapabilities": { + "requestAnimationFrame": 1, + "window.setTimeout": 2 + }, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [ + "createAppShellSessionDisplayBatch", + "createAppShellSessionEventHandlers" + ], + "dependencyPaths": { + "./app-shell-chat-actions.js": 1, + "./app-shell-session-ui-state.js": 1, + "./locales/conversation-copy.js": 1, + "./model-connection-errors.js": 1, + "@maka/core/events": 1, + "@maka/core/session": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1 + }, + "importSpecifiers": 21, + "nonTriviaTokens": 2974 + }, + "src/renderer/app-shell-session-settings-actions.ts": { + "importDeclarations": 9, + "bridgePaths": { + "window.maka.sessions.setModel": 1, + "window.maka.sessions.setPermissionMode": 1, + "window.maka.sessions.setThinkingLevel": 1 + }, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [ + "createAppShellSessionSettingsActions" + ], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "./app-shell-session-ui-state.js": 1, + "./locales/shell-copy.js": 1, + "@maka/core/llm-connections": 1, + "@maka/core/model-thinking": 1, + "@maka/core/permission": 1, + "@maka/core/session": 1, + "@maka/core/settings": 1, + "@maka/core/ui-locale": 1 + }, + "importSpecifiers": 11, + "nonTriviaTokens": 1264 + }, + "src/renderer/app-shell-session-start-actions.ts": { + "importDeclarations": 7, + "bridgePaths": { + "window.maka.newTasks.create": 1, + "window.maka.onboarding.setMilestone": 1 + }, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [ + "createAppShellSessionStartActions" + ], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "./application/contracts/session-start-mode.js": 1, + "./locales/shell-copy.js": 1, + "./model-connection-errors.js": 1, + "./session-workspace-errors.js": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1 + }, + "importSpecifiers": 11, + "nonTriviaTokens": 650 + }, + "src/renderer/app-shell-session-ui-state.ts": { + "importDeclarations": 6, + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useRef": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [ + "createAppShellSessionUiStateController" + ], + "dependencyPaths": { + "./observable-state.js": 1, + "./shell-run-update-state.js": 1, + "@maka/core/events": 1, + "@maka/core/session-event-health": 1, + "@maka/ui": 1, + "react": 1 + }, + "importSpecifiers": 8, + "nonTriviaTokens": 1223 + }, + "src/renderer/app-shell-stop-action.ts": { + "importDeclarations": 4, + "bridgePaths": { + "window.maka.sessions.stop": 1 + }, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [ + "createAppShellStopAction" + ], + "dependencyPaths": { + "./app-shell-session-ui-state.js": 1, + "./locales/conversation-copy.js": 1, + "./locales/shell-copy.js": 1, + "@maka/core/ui-locale": 1 + }, + "importSpecifiers": 4, + "nonTriviaTokens": 302 + }, + "src/renderer/app-shell-turn-actions.ts": { + "importDeclarations": 9, + "bridgePaths": { + "window.maka.sessions.branchFromTurn": 1, + "window.maka.sessions.regenerateTurn": 1 + }, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [ + "createAppShellTurnActions" + ], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "./locales/conversation-copy.js": 1, + "./locales/shell-copy.js": 1, + "./session-copy-attempt.js": 1, + "./session-workspace-actions.js": 1, + "./session-workspace-errors.js": 1, + "@maka/core/session": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1 + }, + "importSpecifiers": 10, + "nonTriviaTokens": 654 + }, + "src/renderer/app-shell-turn-view-model.ts": { + "importDeclarations": 7, + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useRef": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [ + "createAppShellTurnPresentationDerivation" + ], + "dependencyPaths": { + "./derive-turn-lineage-badges.js": 1, + "./interrupted-resume.js": 1, + "./session-status-presentation.js": 1, + "./turn-footer-actions.js": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1, + "react": 1 + }, + "importSpecifiers": 18, + "nonTriviaTokens": 1425 + }, + "src/renderer/app-shell.tsx": { + "importDeclarations": 107, + "bridgePaths": { + "window.maka.app.installUpdate": 1, + "window.maka.app.retryUpdateDownload": 1, + "window.maka.app.subscribeUpdateStatus": 1, + "window.maka.app.updateStatus": 1, + "window.maka.attachments": 1, + "window.maka.attachments.readBytes": 1, + "window.maka.connections.subscribeEvents": 1, + "window.maka.diagnostics.copyPreviousMainProcessInterruption": 1, + "window.maka.diagnostics.copyReport": 1, + "window.maka.diagnostics.takePreviousMainProcessInterruption": 1, + "window.maka.notifications.runEnded": 1, + "window.maka.onboarding.setMilestone": 1, + "window.maka.runtimeHostProfiles.subscribeChanges": 1, + "window.maka.sessions": 1, + "window.maka.sessions.abandonPlanProposal": 1, + "window.maka.sessions.compact": 1, + "window.maka.sessions.getPlanState": 1, + "window.maka.sessions.listActiveInteractions": 1, + "window.maka.sessions.listTurnLandmarks": 1, + "window.maka.sessions.promoteQueueEntry": 1, + "window.maka.sessions.reorderQueueEntries": 1, + "window.maka.sessions.retractQueueEntry": 1, + "window.maka.sessions.setCollaborationMode": 1, + "window.maka.sessions.setOrchestrationMode": 1, + "window.maka.sessions.subscribeActiveInteractions": 1, + "window.maka.sessions.updateQueueEntry": 1, + "window.maka.settings.getClient": 1, + "window.maka.settings.subscribeClientChanged": 1, + "window.maka.transcripts": 2, + "window.maka.workHub.act": 1, + "window.maka.workHub.candidates": 1, + "window.maka.workHub.record": 1, + "window.maka.workHub.resolveCoordinationSession": 1 + }, + "environmentCapabilities": { + "document.querySelector": 1, + "requestAnimationFrame": 1, + "window": 1, + "window.clearTimeout": 4, + "window.requestAnimationFrame": 4, + "window.setTimeout": 2 + }, + "hookCalls": { + "useActiveExecutionBoundary": 1, + "useActiveSessionEvents": 1, + "useAppShellBootstrapSubscriptions": 1, + "useAppShellComposerQuotes": 1, + "useAppShellHostEffects": 1, + "useAppShellNavRefSync": 1, + "useAppShellPersistenceEffects": 1, + "useAppShellProjectContext": 1, + "useAppShellSessionUiReads": 1, + "useAppShellSessionWorkspace": 1, + "useAppShellTurnPresentation": 1, + "useCommandPalette": 1, + "useComposerAttachments": 1, + "useEffect": 14, + "useKeyboardHelp": 1, + "useLayoutEffect": 2, + "useModuleHubController": 1, + "useNewTaskChoice": 1, + "useOnboardingSnapshot": 1, + "usePlanModeState": 1, + "useRef": 25, + "useSessionCollaborationDialog": 1, + "useSessionEventHealthPolling": 1, + "useSessionNavigationReads": 1, + "useSessionSettingIntent": 2, + "useSettingsModal": 1, + "useShellAppearance": 1, + "useShellChatModel": 1, + "useShellConnections": 3, + "useShellLiveTurn": 1, + "useShellMemoryPill": 1, + "useShellResume": 1, + "useShellRunUpdates": 1, + "useShellSearch": 1, + "useStableActions": 7, + "useState": 17, + "useSystemUiLocale": 1, + "useTaskEntryController": 1, + "useTaskSubmissionReadiness": 1, + "useToast": 1, + "useTurnActionRegistry": 1, + "useWorkbarController": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "../preload/transcript-contract.js": 1, + "./agent-graph-panel": 1, + "./app-shell-app-update": 1, + "./app-shell-chat-actions": 1, + "./app-shell-chrome-actions": 1, + "./app-shell-command-actions": 1, + "./app-shell-context-compaction": 1, + "./app-shell-detail-panel": 1, + "./app-shell-e2e-fixture": 1, + "./app-shell-effects": 1, + "./app-shell-overlays": 1, + "./app-shell-revision-actions": 1, + "./app-shell-session-events": 1, + "./app-shell-session-settings-actions": 1, + "./app-shell-session-start-actions": 1, + "./app-shell-stop-action": 1, + "./app-shell-turn-actions": 1, + "./app-shell-turn-view-model": 1, + "./app-update-install": 1, + "./chat-composer-region": 1, + "./chat-message-surface": 1, + "./command-palette": 1, + "./composer-defaults": 1, + "./composer-mentions": 1, + "./custom-pet-companion": 1, + "./custom-pet-companion-model": 1, + "./default-runtime-host-operation.js": 1, + "./desktop-execution-boundary-surface": 1, + "./desktop-slash-command": 1, + "./error-boundary": 1, + "./features/goals": 1, + "./features/module-hub": 1, + "./features/session-collaboration": 1, + "./features/session-navigation": 1, + "./features/task-entry": 1, + "./features/workbar": 1, + "./follow-up-submit-routing": 1, + "./keyboard-help": 1, + "./live-content-seed": 1, + "./live-turn-reconciler": 1, + "./locales/conversation-copy": 1, + "./locales/session-collaboration-copy": 1, + "./locales/shell-copy": 1, + "./locales/shell-remaining-copy.js": 1, + "./model-connection-errors": 1, + "./nav-selection": 1, + "./pending-items": 1, + "./pending-session-view": 1, + "./plan-mode-panel": 1, + "./scroll-motion-policy": 1, + "./session-collaboration-dialog": 1, + "./session-turn-request-composer.js": 1, + "./session-workspace-errors": 1, + "./settings/provider-brand-marks": 1, + "./settings/provider-display": 1, + "./settings/runtime-host-ssh-terminal-dialog.js": 1, + "./settings/tasks-settings-page": 1, + "./stale-sessions": 1, + "./task-readiness-notice": 1, + "./use-active-execution-boundary": 1, + "./use-app-shell-composer-quotes": 1, + "./use-app-shell-session-ui-reads": 1, + "./use-app-shell-session-workspace": 1, + "./use-composer-attachments": 1, + "./use-external-store-selector": 1, + "./use-new-task-choice": 1, + "./use-onboarding-snapshot": 1, + "./use-project-context": 1, + "./use-session-setting-intent": 1, + "./use-settings-modal": 1, + "./use-shell-appearance": 1, + "./use-shell-chat-model": 1, + "./use-shell-connections": 1, + "./use-shell-live-turn": 1, + "./use-shell-memory-pill": 1, + "./use-shell-resume": 1, + "./use-shell-search": 1, + "./use-stable-actions": 1, + "./use-system-ui-locale": 1, + "./use-task-submission-readiness": 1, + "./use-turn-action-registry": 1, + "./workhub-controller.js": 1, + "./workhub-coordination-host-scope.js": 1, + "./workhub-coordination-lifecycle.js": 1, + "./workhub-coordination-port.js": 1, + "./workhub-session-port.js": 1, + "./workhub-surface.js": 1, + "./workspace-readiness-recovery": 1, + "@astryxdesign/core/AppShell": 1, + "@astryxdesign/core/Button": 1, + "@maka/core/connections": 1, + "@maka/core/events": 1, + "@maka/core/onboarding-milestone": 1, + "@maka/core/orchestration": 1, + "@maka/core/project": 1, + "@maka/core/session": 2, + "@maka/core/session-revisions": 1, + "@maka/core/settings": 1, + "@maka/core/slash-command-catalog": 2, + "@maka/core/ui-locale": 2, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + }, + "importSpecifiers": 186, + "nonTriviaTokens": 15800 + }, + "src/renderer/use-app-shell-composer-quotes.ts": { + "importDeclarations": 3, + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useState": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./pending-items.js": 1, + "@maka/core/events": 1, + "react": 1 + }, + "importSpecifiers": 7, + "nonTriviaTokens": 360 + }, + "src/renderer/use-app-shell-session-list.ts": { + "importDeclarations": 11, + "bridgePaths": { + "window.maka.sessions.list": 1 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useExternalStoreSelector": 3, + "useRef": 4, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "./live-turn-snapshot.js": 1, + "./locales/conversation-copy.js": 1, + "./locales/shell-copy.js": 1, + "./session-catalog-state.js": 1, + "./session-read-state.js": 1, + "./session-status-presentation.js": 1, + "./settled-session-transients.js": 1, + "./use-external-store-selector.js": 1, + "@maka/ui": 1, + "react": 1 + }, + "importSpecifiers": 17, + "nonTriviaTokens": 581 + }, + "src/renderer/use-app-shell-session-ui-reads.ts": { + "importDeclarations": 4, + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useExternalStoreSelector": 9 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./app-shell-session-ui-state.js": 1, + "./live-turn-snapshot.js": 1, + "./use-external-store-selector.js": 1, + "@maka/ui": 1 + }, + "importSpecifiers": 10, + "nonTriviaTokens": 391 + }, + "src/renderer/use-app-shell-session-workspace.ts": { + "importDeclarations": 11, + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useAppShellSessionList": 1, + "useAppShellSessionUiState": 1, + "useExternalStoreSelector": 1, + "useRef": 7, + "useSessionCatalogController": 1, + "useState": 3 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./app-shell-session-ui-state.js": 1, + "./bootstrap-selection-lease.js": 1, + "./desktop-transcript-range-store.js": 1, + "./new-task-reload-intent.js": 1, + "./session-catalog-state.js": 1, + "./session-workspace-actions.js": 1, + "./use-app-shell-session-list.js": 1, + "./use-external-store-selector.js": 1, + "@maka/core/session": 1, + "@maka/ui": 1, + "react": 1 + }, + "importSpecifiers": 14, + "nonTriviaTokens": 480 + } + }, + "closure": { + "src/preload/diagnostics-contract.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/preload/external-session-catalog.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../shared/runtime-host-identity.js": 1, + "@maka/runtime-host/protocol": 1 + } + }, + "src/preload/external-session-import-result.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/session": 1 + } + }, + "src/preload/runtime-host-renderer-operations.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/runtime-host/protocol": 1 + } + }, + "src/preload/transcript-contract.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/agent-graph-panel-visibility.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/agent-graph-panel.tsx": { + "bridgePaths": { + "window.maka.graphs.getSnapshot": 1, + "window.maka.graphs.listCurrentEpochs": 1, + "window.maka.graphs.listEpochs": 2, + "window.maka.graphs.stop": 1, + "window.maka.graphs.subscribe": 1 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 2, + "useRef": 4, + "useState": 9 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./agent-graph-panel-visibility.js": 1, + "./agent-graph-refresh.js": 1, + "@astryxdesign/core/Banner": 1, + "@astryxdesign/core/Button": 1, + "@astryxdesign/core/EmptyState": 1, + "@astryxdesign/core/Spinner": 1, + "@maka/core/ui-locale": 1, + "@maka/runtime-host/client": 1, + "@maka/runtime-host/protocol": 1, + "@maka/runtime/stream-graph-read-model": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/agent-graph-refresh.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/app-update-install.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/bridge-contract.js": 1 + } + }, + "src/renderer/astryx-theme/type-scale.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/attachment-preflight.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/conversation-copy.js": 1, + "@maka/core/attachments": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/bootstrap-selection-lease.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/browser-storage.ts": { + "bridgePaths": {}, + "environmentCapabilities": { + "localStorage.getItem": 1, + "localStorage.setItem": 1 + }, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/chat-composer-region.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useComposerMentionsContext": 1, + "useLayoutEffect": 1, + "useRef": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./composer-mentions.js": 1, + "./new-task-reload-intent.js": 1, + "@maka/ui": 2, + "react": 1 + } + }, + "src/renderer/chat-message-surface.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useDeepResearchRun": 1, + "useExternalStoreSelector": 2, + "useState": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./app-shell-session-ui-state": 1, + "./chat-recovery-notice": 1, + "./locales/conversation-copy": 1, + "./locales/shell-copy": 1, + "./onboarding-hero": 1, + "./task-readiness-notice": 1, + "./use-app-shell-session-ui-reads": 1, + "./use-deep-research-run": 1, + "./use-external-store-selector": 1, + "./use-shell-chat-model": 1, + "./workspace-readiness-recovery": 1, + "@astryxdesign/core": 1, + "@maka/core/deep-research": 1, + "@maka/core/llm-connections": 1, + "@maka/core/onboarding": 1, + "@maka/core/settings": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/chat-recovery-notice.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./use-shell-chat-model": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/command-palette-commands.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./command-palette-types.js": 1, + "./locales/shell-copy.js": 1, + "./settings/settings-nav.js": 1, + "@maka/core/llm-connections": 1, + "@maka/core/permission": 1, + "@maka/core/provider-registry": 1, + "@maka/core/session": 1, + "@maka/core/settings": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1 + } + }, + "src/renderer/command-palette-types.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/ui/icons": 1 + } + }, + "src/renderer/command-palette.tsx": { + "bridgePaths": {}, + "environmentCapabilities": { + "window.cancelAnimationFrame": 1, + "window.requestAnimationFrame": 1 + }, + "hookCalls": { + "useEffect": 1, + "useHotkeys": 1, + "useRef": 1, + "useState": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./command-palette-types": 2, + "./locales/shell-copy": 1, + "@astryxdesign/core/EmptyState": 1, + "@astryxdesign/core/hooks": 1, + "@astryxdesign/core/Kbd": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/composer-attachments.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/events": 1 + } + }, + "src/renderer/composer-defaults.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./browser-storage": 1 + } + }, + "src/renderer/composer-mentions.tsx": { + "bridgePaths": { + "window.maka.mcp.subscribeChanges": 1, + "window.maka.newTasks.listInvocableSkills": 1, + "window.maka.newTasks.searchFiles": 1, + "window.maka.newTasks.subscribeChanges": 1, + "window.maka.sessions.subscribeChanges": 1, + "window.maka.skills.listInvocable": 1, + "window.maka.workspace.searchFiles": 1 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 1, + "useState": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "@maka/core/settings": 1, + "@maka/runtime/skill-invocation": 1, + "react": 1 + } + }, + "src/renderer/conversation-markdown.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/shell-remaining-copy.js": 1, + "@maka/core/session": 2, + "@maka/core/ui-locale": 1, + "@maka/ui": 1 + } + }, + "src/renderer/custom-pet-companion-model.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/pet": 1, + "@maka/core/session": 1 + } + }, + "src/renderer/custom-pet-companion.tsx": { + "bridgePaths": { + "window.maka.pets": 1, + "window.maka.pets.subscribeChanges": 1 + }, + "environmentCapabilities": { + "document": 1, + "document.documentElement": 5, + "document.documentElement.dataset.makaE2eFixture": 1, + "document.documentElement.dataset.makaReducedMotion": 1, + "MutationObserver": 1, + "window": 1, + "window.cancelAnimationFrame": 1, + "window.matchMedia": 3, + "window.requestAnimationFrame": 2 + }, + "hookCalls": { + "useEffect": 4, + "useMountedRef": 1, + "useRef": 4, + "useState": 3 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./custom-pet-companion-model.js": 1, + "@maka/core/pet": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/daily-review-actions.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/daily-review": 2, + "@maka/core/redaction": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/default-runtime-host-operation.ts": { + "bridgePaths": { + "window.maka.runtimeHostProfiles.getDefaultHost": 2 + }, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/bridge-contract.js": 1 + } + }, + "src/renderer/derive-turn-lineage-badges.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/conversation-copy.js": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1 + } + }, + "src/renderer/desktop-execution-boundary-surface.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/permission": 1, + "@maka/core/sandbox-boundary": 2 + } + }, + "src/renderer/desktop-slash-command.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./side-chat-command.js": 1, + "@maka/core/graph-command": 1, + "@maka/core/swarm-command": 1 + } + }, + "src/renderer/desktop-transcript-range-store.ts": { + "bridgePaths": {}, + "environmentCapabilities": { + "window.clearTimeout": 1, + "window.setTimeout": 1 + }, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/transcript-contract.js": 1, + "../shared/desktop-session-projection.js": 1, + "../shared/runtime-host-identity.js": 1, + "@maka/core/persisted-value": 1, + "@maka/core/session": 1 + } + }, + "src/renderer/error-boundary.tsx": { + "bridgePaths": { + "window.maka.diagnostics": 3, + "window.maka.diagnostics.copyReport": 1 + }, + "environmentCapabilities": { + "location": 3, + "navigator": 1, + "navigator.clipboard.writeText": 1, + "navigator.userAgent": 2, + "window": 1, + "window.location.href": 2, + "window.location.reload": 1 + }, + "hookCalls": {}, + "lifecycleMethods": { + "componentDidCatch": 1, + "componentDidMount": 1, + "componentWillUnmount": 1, + "getDerivedStateFromError": 1, + "setState": 5, + "state": 5 + }, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/shell-copy.js": 1, + "@maka/core/diagnostic-log": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/follow-up-submit-routing.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/events": 1 + } + }, + "src/renderer/interrupted-resume.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/keyboard-help.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useHotkeys": 1, + "useState": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/shell-copy": 1, + "@astryxdesign/core/Dialog": 1, + "@astryxdesign/core/Heading": 1, + "@astryxdesign/core/hooks": 1, + "@astryxdesign/core/Kbd": 1, + "@astryxdesign/core/Layout": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/live-content-seed.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/live-turn-reconciler.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 1, + "useExternalStoreSelector": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./app-shell-session-ui-state": 1, + "./use-app-shell-session-ui-reads": 1, + "./use-external-store-selector": 1, + "@maka/core/session": 1, + "react": 1 + } + }, + "src/renderer/live-turn-snapshot.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./model-wait-state.js": 1, + "./session-event-health.js": 1, + "@maka/ui": 1 + } + }, + "src/renderer/local-memory-digest.ts": { + "bridgePaths": {}, + "environmentCapabilities": { + "window.crypto.subtle": 3, + "window.crypto.subtle.digest": 2 + }, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/local-memory": 1 + } + }, + "src/renderer/locales/artifact-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/locales/browser-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/locales/conversation-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/connection-readiness": 1, + "@maka/core/model-call-attempt": 1, + "@maka/core/session-send-projection": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/locales/external-session-import-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/locales/mcp-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/locales/onboarding-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../onboarding-hero-copy.js": 1, + "@maka/core/onboarding": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/locales/permission-center-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/capabilities": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1 + } + }, + "src/renderer/locales/plan-mode-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/plan": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/locales/session-collaboration-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/locales/settings-bot-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/bot-chat-settings": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1 + } + }, + "src/renderer/locales/settings-daily-review-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/locales/settings-data-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": { + "history": 3 + }, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/ui-locale": 1, + "@maka/storage/config-transfer": 1 + } + }, + "src/renderer/locales/settings-health-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/health": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1 + } + }, + "src/renderer/locales/settings-memory-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/local-memory": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/locales/settings-navigation-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../settings/nav-group-summary.js": 1, + "@maka/core/settings": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/locales/settings-preferences-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/settings": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/locales/settings-projects-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../../preload/bridge-contract.js": 2, + "@maka/core/ui-locale": 1, + "@maka/runtime-host/operator": 1 + } + }, + "src/renderer/locales/settings-shared-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/locales/settings-subagents-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/model-thinking": 1, + "@maka/core/subagent-settings": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/locales/settings-tasks-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/locales/settings-test-result-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": { + "location": 1 + }, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/settings": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/locales/settings-usage-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/locales/settings-web-search-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/ui-locale": 1, + "@maka/core/web-search": 1 + } + }, + "src/renderer/locales/shell-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/goal": 1, + "@maka/core/model-thinking": 1, + "@maka/core/permission": 1, + "@maka/core/redaction": 1, + "@maka/core/settings": 1, + "@maka/core/slash-command-catalog": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/locales/shell-remaining-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/mcp-brand-contrast.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/mcp-brand-marks.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./mcp-brand-contrast.js": 1, + "./mcp-catalog": 1, + "@ant-design/icons-svg/es/asn/DingtalkOutlined.js": 1, + "@ant-design/icons-svg/es/types.js": 1, + "react": 1, + "simple-icons": 2 + } + }, + "src/renderer/mcp-catalog.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/mcp": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/mcp-command-line.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/mcp-editor-validation.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./mcp-command-line.js": 1 + } + }, + "src/renderer/mcp-page-model.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/mcp-copy.js": 1, + "./mcp-command-line.js": 1, + "@maka/core/mcp": 2 + } + }, + "src/renderer/mcp-page.tsx": { + "bridgePaths": { + "window.maka.mcp.cancelInstall": 1, + "window.maka.mcp.getConfig": 1, + "window.maka.mcp.importConfig": 1, + "window.maka.mcp.install": 1, + "window.maka.mcp.listStatuses": 1, + "window.maka.mcp.remove": 1, + "window.maka.mcp.subscribeChanges": 1, + "window.maka.mcp.test": 1, + "window.maka.mcp.upsert": 2 + }, + "environmentCapabilities": { + "cancelAnimationFrame": 1, + "requestAnimationFrame": 1, + "window.requestAnimationFrame": 2, + "window.setTimeout": 1 + }, + "hookCalls": { + "useEffect": 3, + "useMountedRef": 1, + "useRef": 4, + "useRovingRowFocus": 1, + "useState": 11, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./default-runtime-host-operation.js": 1, + "./locales/mcp-copy": 1, + "./mcp-brand-marks": 1, + "./mcp-catalog": 1, + "./mcp-command-line": 1, + "./mcp-editor-validation": 1, + "./mcp-page-model": 1, + "./settings/settings-error-copy": 1, + "@astryxdesign/core": 1, + "@astryxdesign/core/Dialog": 1, + "@astryxdesign/core/Layout": 1, + "@astryxdesign/core/MetadataList": 1, + "@maka/core/mcp": 2, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/model-catalog-choices.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/shell-remaining-copy.js": 1, + "@maka/core/llm-connections": 2, + "@maka/core/model-catalog": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/model-connection-errors.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./application/contracts/connection-error-cleaner.js": 1, + "./locales/conversation-copy.js": 1, + "./locales/shell-copy.js": 1, + "./session-error-presentation.js": 1, + "@maka/core/connection-readiness": 1, + "@maka/core/events": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/model-wait-state.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/nav-selection.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./browser-storage.js": 1, + "@maka/ui": 1 + } + }, + "src/renderer/new-task-reload-intent.ts": { + "bridgePaths": {}, + "environmentCapabilities": { + "sessionStorage": 2 + }, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/observable-state.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/onboarding-hero-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/onboarding-copy.js": 1, + "@maka/core/onboarding": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/onboarding-hero.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useUiLocale": 3 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/onboarding-copy": 1, + "./onboarding-hero-copy": 1, + "./onboarding-provider-types": 1, + "./settings/provider-display": 1, + "@astryxdesign/core": 1, + "@maka/core/llm-connections": 1, + "@maka/core/onboarding": 1, + "@maka/core/settings": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/onboarding-provider-types.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/llm-connections": 1 + } + }, + "src/renderer/open-path.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/shell-copy.js": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/pending-items.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/pending-session-view.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/permission": 1, + "@maka/core/session": 1 + } + }, + "src/renderer/plan-mode-panel.tsx": { + "bridgePaths": { + "window.maka.sessions.abandonPlanExecution": 1, + "window.maka.sessions.approvePlan": 1, + "window.maka.sessions.getPlanState": 1, + "window.maka.sessions.requestPlanRevision": 1, + "window.maka.sessions.resumePlan": 1, + "window.maka.sessions.subscribeEvents": 1, + "window.maka.sessions.subscribePlanChanges": 1 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 2, + "useRef": 2, + "useState": 4, + "useToast": 1, + "useUiLocale": 3 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/plan-mode-copy.js": 1, + "@astryxdesign/core/Banner": 1, + "@astryxdesign/core/Collapsible": 1, + "@maka/core/events": 1, + "@maka/core/plan": 1, + "@maka/core/session": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/project-path-display.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/remote-project-directory-dialog.tsx": { + "bridgePaths": { + "window.maka.projects.getDirectoryRoots": 1, + "window.maka.projects.listDirectory": 2, + "window.maka.projects.registerDirectory": 1 + }, + "environmentCapabilities": { + "window.cancelAnimationFrame": 1, + "window.requestAnimationFrame": 1 + }, + "hookCalls": { + "useEffect": 2, + "useRef": 4, + "useState": 8, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "./locales/shell-copy.js": 1, + "@astryxdesign/core/Button": 1, + "@astryxdesign/core/Dialog": 1, + "@astryxdesign/core/DropdownMenu": 1, + "@astryxdesign/core/Layout": 1, + "@astryxdesign/core/Stack": 1, + "@astryxdesign/core/Text": 1, + "@maka/core/project": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/scroll-motion-policy.ts": { + "bridgePaths": {}, + "environmentCapabilities": { + "document.documentElement": 2, + "document.documentElement.dataset.makaE2eFixture": 1, + "document.documentElement.dataset.makaReducedMotion": 1, + "document.documentElement.dataset.makaScrollMotion": 5, + "window.matchMedia": 2 + }, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/session-catalog-state.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useRef": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "./observable-state.js": 1, + "react": 1 + } + }, + "src/renderer/session-collaboration-dialog.tsx": { + "bridgePaths": { + "window.maka.localRuntimeHostRemoteAccess.getSnapshot": 2, + "window.maka.sessionCollaboration.decideTurnRequest": 1, + "window.maka.sessionCollaboration.getAccess": 1, + "window.maka.sessionCollaboration.getTurnRequests": 1, + "window.maka.sessionCollaboration.prepareInvitation": 1, + "window.maka.sessionCollaboration.revokeGrant": 1, + "window.maka.sessionCollaboration.revokePrincipal": 1 + }, + "environmentCapabilities": { + "navigator.clipboard.writeText": 1, + "window.clearTimeout": 1, + "window.setTimeout": 1 + }, + "hookCalls": { + "useEffect": 1, + "useState": 6, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/session-collaboration-copy.js": 1, + "./session-turn-request-composer.js": 1, + "@astryxdesign/core": 1, + "@astryxdesign/core/Dialog": 1, + "@astryxdesign/core/Layout": 1, + "@maka/runtime-host/protocol": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/session-copy-attempt.ts": { + "bridgePaths": {}, + "environmentCapabilities": { + "sessionStorage": 2 + }, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/session-error-presentation.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/conversation-copy.js": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/session-event-health.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/session": 1, + "@maka/core/session-event-health": 2, + "@maka/core/tool-result-status": 1, + "@maka/ui": 1 + } + }, + "src/renderer/session-health-notice.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/conversation-copy.js": 1, + "@maka/core/llm-connections": 1, + "@maka/core/session-send-projection": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/session-message-settlement.ts": { + "bridgePaths": { + "window.maka.transcripts": 1 + }, + "environmentCapabilities": { + "window.clearTimeout": 2, + "window.setTimeout": 2 + }, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "./desktop-transcript-range-store.js": 1, + "@maka/core/session": 1 + } + }, + "src/renderer/session-read-state.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/session": 1 + } + }, + "src/renderer/session-status-presentation.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/conversation-copy.js": 1, + "./session-error-presentation.js": 1, + "@maka/core/sandbox-boundary": 1, + "@maka/core/session": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/session-trace-refresh.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/events": 1 + } + }, + "src/renderer/session-turn-request-composer.tsx": { + "bridgePaths": { + "window.maka.sessionCollaboration.acknowledgeTurnRequest": 1, + "window.maka.sessionCollaboration.getTurnRequests": 2, + "window.maka.sessionCollaboration.requestTurn": 1 + }, + "environmentCapabilities": { + "window.clearTimeout": 1, + "window.setTimeout": 1 + }, + "hookCalls": { + "useEffect": 1, + "useRef": 2, + "useState": 6, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/session-collaboration-copy.js": 1, + "@maka/runtime-host/protocol": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/session-workspace-actions.ts": { + "bridgePaths": { + "window.maka.sessions.queryCancelledMessages": 1 + }, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./desktop-transcript-range-store.js": 1, + "./new-task-reload-intent.js": 1, + "./transient-message-projection.js": 1, + "@maka/core/session": 1, + "@maka/runtime-host/protocol": 1, + "@maka/ui": 1 + } + }, + "src/renderer/session-workspace-errors.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/shell-copy.js": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1 + } + }, + "src/renderer/settings/about-settings-page.tsx": { + "bridgePaths": { + "window.maka.app.checkForUpdates": 1, + "window.maka.app.info": 1, + "window.maka.app.subscribeUpdateStatus": 1, + "window.maka.app.updateStatus": 1, + "window.maka.diagnostics.copyReport": 1 + }, + "environmentCapabilities": { + "window": 1 + }, + "hookCalls": { + "useActionGuard": 2, + "useEffect": 2, + "useMountedRef": 1, + "useState": 5, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../../preload/bridge-contract.js": 1, + "../default-runtime-host-operation.js": 1, + "../locales/settings-preferences-copy.js": 1, + "../locales/settings-shared-copy.js": 1, + "./about-update-status.js": 1, + "./settings-error-copy.js": 1, + "./settings-rows.js": 1, + "./settings-section.js": 1, + "./settings-skeleton.js": 1, + "./use-action-guard.js": 1, + "@astryxdesign/core": 1, + "@astryxdesign/core/Kbd": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/about-update-status.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../../preload/bridge-contract.js": 1, + "../locales/settings-preferences-copy.js": 1 + } + }, + "src/renderer/settings/action-guard.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/settings/appearance-settings-page.tsx": { + "bridgePaths": { + "window.maka.app.iconPreviews": 2, + "window.maka.app.importIcon": 1, + "window.maka.app.removeIcon": 1, + "window.maka.app.selectIcon": 1 + }, + "environmentCapabilities": { + "window": 2 + }, + "hookCalls": { + "useEffect": 2, + "useMountedRef": 1, + "useRef": 1, + "useState": 6, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-preferences-copy.js": 1, + "../theme": 1, + "./custom-pet-settings-section.js": 1, + "./settings-error-copy": 1, + "./settings-section": 1, + "@astryxdesign/core": 1, + "@maka/core/settings": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/settings/bot-chat-detail.tsx": { + "bridgePaths": {}, + "environmentCapabilities": { + "window.cancelAnimationFrame": 4, + "window.requestAnimationFrame": 4 + }, + "hookCalls": { + "useEffect": 5, + "useMountedRef": 1, + "useState": 5, + "useToast": 1, + "useUiLocale": 3 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-bot-copy": 1, + "./bot-chat-shared": 1, + "./bot-onboarding-modal": 1, + "./bot-settings-view-model": 1, + "./bot-wechat-login": 1, + "./password-input": 1, + "./settings-section": 1, + "@astryxdesign/core": 1, + "@maka/core/bot-chat-settings": 1, + "@maka/core/bot-onboarding": 1, + "@maka/core/settings": 1, + "@maka/runtime/bots": 1, + "@maka/ui": 2, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/bot-chat-overview.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-bot-copy": 1, + "./bot-chat-shared": 1, + "./bot-settings-view-model": 1, + "./settings-section": 1, + "@astryxdesign/core": 1, + "@maka/core/bot-chat-settings": 1, + "@maka/core/settings": 1, + "@maka/runtime/bots": 1, + "@maka/ui": 2, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/bot-chat-settings-page.tsx": { + "bridgePaths": { + "window.maka.e2eFixture.getState": 1, + "window.maka.settings.bots.listStatuses": 2, + "window.maka.settings.bots.restart": 1, + "window.maka.settings.bots.subscribeStatusChanges": 1, + "window.maka.settings.testBotChannel": 2 + }, + "environmentCapabilities": { + "window": 1 + }, + "hookCalls": { + "useEffect": 3, + "useMountedRef": 1, + "useRef": 1, + "useState": 6, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-bot-copy": 1, + "../locales/settings-test-result-copy": 1, + "./bot-chat-detail": 1, + "./bot-chat-overview": 1, + "./bot-chat-shared": 1, + "./settings-error-copy": 1, + "@maka/core/bot-chat-settings": 1, + "@maka/core/bot-onboarding": 1, + "@maka/core/settings": 1, + "@maka/runtime/bots": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/settings/bot-chat-shared.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-bot-copy": 1, + "@maka/core/bot-chat-settings": 1, + "@maka/core/ui-locale": 1, + "@maka/runtime/bots": 1, + "@maka/ui": 1 + } + }, + "src/renderer/settings/bot-onboarding-modal.tsx": { + "bridgePaths": { + "window.maka.settings.bots.onboarding.cancel": 1, + "window.maka.settings.bots.onboarding.openInBrowser": 1, + "window.maka.settings.bots.onboarding.poll": 1, + "window.maka.settings.bots.onboarding.start": 1 + }, + "environmentCapabilities": { + "window.clearTimeout": 1, + "window.setTimeout": 1 + }, + "hookCalls": { + "useEffect": 3, + "useMountedRef": 1, + "useRef": 4, + "useState": 3, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-bot-copy": 1, + "./bot-chat-shared": 1, + "./settings-error-copy": 1, + "@astryxdesign/core": 1, + "@astryxdesign/core/Dialog": 1, + "@astryxdesign/core/Layout": 1, + "@maka/core/bot-onboarding": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/bot-settings-view-model.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/bot-chat-settings": 1, + "@maka/core/bot-events": 1, + "@maka/runtime/bots": 1 + } + }, + "src/renderer/settings/bot-wechat-login.tsx": { + "bridgePaths": { + "window.maka.settings.bots.wechatQrCode": 1 + }, + "environmentCapabilities": { + "window.clearInterval": 1, + "window.setInterval": 1 + }, + "hookCalls": { + "useEffect": 2, + "useRef": 2, + "useState": 4, + "useUiLocale": 2 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-bot-copy": 1, + "./password-input": 1, + "./settings-error-copy": 1, + "@astryxdesign/core/Collapsible": 1, + "@astryxdesign/core/Dialog": 1, + "@astryxdesign/core/Layout": 1, + "@maka/core/bot-chat-settings": 1, + "@maka/runtime/bots": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/connection-name-draft.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/settings/custom-pet-library-model.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/pet": 1 + } + }, + "src/renderer/settings/custom-pet-settings-section.tsx": { + "bridgePaths": { + "window.maka.pets.getSelection": 1, + "window.maka.pets.importLocalDirectory": 1, + "window.maka.pets.list": 1, + "window.maka.pets.remove": 1, + "window.maka.pets.select": 1, + "window.maka.pets.subscribeChanges": 1 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 1, + "useMountedRef": 1, + "useRef": 1, + "useState": 3, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-preferences-copy.js": 1, + "./custom-pet-library-model.js": 1, + "./settings-error-copy": 1, + "./settings-section": 1, + "@astryxdesign/core": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/settings/daily-review-settings-page.tsx": { + "bridgePaths": { + "window.maka.dailyReview": 2, + "window.maka.dailyReview.getConfig": 3, + "window.maka.dailyReview.setConfig": 3 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useActionGuard": 1, + "useEffect": 2, + "useMountedRef": 1, + "useRuntimeHostSettingsErrorReporter": 1, + "useRuntimeHostSettingsTarget": 1, + "useState": 6, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-daily-review-copy": 1, + "../model-catalog-choices": 1, + "./runtime-host-settings-target.js": 1, + "./settings-error-copy": 1, + "./settings-section": 1, + "./settings-skeleton": 1, + "./use-action-guard": 1, + "@astryxdesign/core": 1, + "@maka/core/daily-review": 1, + "@maka/core/llm-connections": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/settings/data-settings-page.tsx": { + "bridgePaths": { + "window.maka.app.info": 1, + "window.maka.app.openPath": 1, + "window.maka.config.export": 1, + "window.maka.config.import": 1 + }, + "environmentCapabilities": { + "history": 1, + "navigator.clipboard.writeText": 1, + "window": 2 + }, + "hookCalls": { + "useActionGuard": 1, + "useEffect": 1, + "useMountedRef": 1, + "useOptionalRuntimeHostSettingsTarget": 1, + "useState": 6, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-data-copy": 1, + "../locales/settings-shared-copy.js": 1, + "../open-path": 1, + "./runtime-host-settings-target.js": 1, + "./settings-error-copy": 1, + "./settings-rows": 1, + "./settings-section": 1, + "./use-action-guard": 1, + "@maka/storage/config-transfer": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/settings/general-settings-page.tsx": { + "bridgePaths": {}, + "environmentCapabilities": { + "window": 4 + }, + "hookCalls": { + "useActionGuard": 2, + "useEffect": 1, + "useKeyedActionGuard": 1, + "useMountedRef": 2, + "useOptimisticSettingsDraft": 1, + "useOptionalRuntimeHostSettingsTarget": 2, + "useRuntimeHostSettingsTarget": 2, + "useState": 7, + "useToast": 4, + "useUiLocale": 4 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../features/connection-settings": 1, + "../locales/settings-preferences-copy.js": 1, + "../locales/settings-shared-copy.js": 1, + "../locales/settings-test-result-copy.js": 1, + "../locales/shell-copy.js": 1, + "./password-input": 1, + "./personalization-settings-section": 1, + "./provider-brand-marks": 1, + "./runtime-host-settings-target.js": 1, + "./settings-error-copy": 1, + "./settings-resource-state.js": 1, + "./settings-section": 1, + "./settings-skeleton.js": 1, + "./use-action-guard": 1, + "./use-optimistic-settings-draft": 1, + "@maka/core/chat-model-choice": 1, + "@maka/core/llm-connections": 1, + "@maka/core/model-thinking": 1, + "@maka/core/settings": 3, + "@maka/core/settings/network-settings": 1, + "@maka/ui": 2, + "react": 1 + } + }, + "src/renderer/settings/health-center-page.tsx": { + "bridgePaths": { + "window.maka.health.getSnapshot": 1 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 2, + "useRuntimeHostSettingsTarget": 1, + "useState": 5, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-health-copy": 1, + "./runtime-host-settings-target.js": 1, + "./settings-error-copy": 1, + "./settings-section": 1, + "./settings-skeleton": 1, + "./settings-status-summary-filter": 1, + "@astryxdesign/core": 1, + "@maka/core/health": 2, + "@maka/ui": 2, + "react": 1 + } + }, + "src/renderer/settings/import-tasks-settings-page.tsx": { + "bridgePaths": { + "window.maka.externalSessions.import": 1, + "window.maka.externalSessions.list": 2, + "window.maka.externalSessions.listSources": 1 + }, + "environmentCapabilities": { + "clearTimeout": 2, + "setTimeout": 2 + }, + "hookCalls": { + "useEffect": 5, + "useMountedRef": 1, + "useRef": 5, + "useRuntimeHostSettingsTarget": 1, + "useState": 18, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../../preload/bridge-contract.js": 1, + "../../preload/external-session-catalog.js": 1, + "../locales/external-session-import-copy.js": 1, + "../locales/shell-copy.js": 1, + "./runtime-host-settings-target.js": 1, + "./settings-section.js": 1, + "@astryxdesign/core/Banner": 1, + "@astryxdesign/core/Button": 1, + "@astryxdesign/core/CheckboxInput": 1, + "@astryxdesign/core/EmptyState": 1, + "@astryxdesign/core/List": 1, + "@astryxdesign/core/SegmentedControl": 1, + "@astryxdesign/core/Stack": 1, + "@astryxdesign/core/TextInput": 1, + "@maka/core/external-session": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/memory-entry-list.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-memory-copy": 1, + "./memory-settings-labels": 1, + "@astryxdesign/core": 1, + "@maka/core/local-memory": 1, + "@maka/ui": 1 + } + }, + "src/renderer/settings/memory-settings-labels.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-memory-copy": 1, + "@maka/core/local-memory": 1, + "@maka/ui": 1 + } + }, + "src/renderer/settings/memory-settings-page.tsx": { + "bridgePaths": {}, + "environmentCapabilities": { + "window": 1 + }, + "hookCalls": { + "useMemoryDocumentController": 1, + "useState": 2, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-memory-copy": 1, + "../locales/settings-shared-copy.js": 1, + "./memory-entry-list": 1, + "./memory-settings-labels": 1, + "./memory-settings-sections": 1, + "./settings-section": 1, + "./use-memory-settings-controller": 1, + "@maka/core/settings": 1, + "@maka/ui": 2, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/memory-settings-sections.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-memory-copy": 1, + "./settings-section": 1, + "@maka/ui": 1 + } + }, + "src/renderer/settings/memory-settings-view-model.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-memory-copy.js": 1, + "./memory-settings-labels.js": 1, + "@maka/core/local-memory": 2, + "@maka/core/settings": 1, + "@maka/ui": 1 + } + }, + "src/renderer/settings/nav-group-summary.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/settings/oauth-login-flow-guard.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/settings/optimistic-settings-draft-controller.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/settings/password-input.tsx": { + "bridgePaths": {}, + "environmentCapabilities": { + "navigator.clipboard.writeText": 1, + "window.clearTimeout": 2, + "window.setTimeout": 1 + }, + "hookCalls": { + "useActionGuard": 1, + "useEffect": 1, + "useMountedRef": 1, + "useRef": 1, + "useState": 3, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-preferences-copy.js": 1, + "./use-action-guard": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/permission-center-page.tsx": { + "bridgePaths": { + "window.maka.capabilities.getSnapshot": 1, + "window.maka.permissions.getSnapshot": 1, + "window.maka.permissions.openSystemSettings": 1, + "window.maka.permissions.requestAccess": 1, + "window.maka.permissions.startDragOnboarding": 1 + }, + "environmentCapabilities": { + "document.addEventListener": 1, + "document.removeEventListener": 1, + "document.visibilityState": 1, + "window.addEventListener": 1, + "window.removeEventListener": 1 + }, + "hookCalls": { + "useActionGuard": 1, + "useEffect": 3, + "useMountedRef": 1, + "useRuntimeHostSettingsErrorReporter": 1, + "useRuntimeHostSettingsTarget": 1, + "useState": 7, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/permission-center-copy": 1, + "./runtime-host-settings-target.js": 1, + "./settings-error-copy": 1, + "./settings-section": 1, + "./settings-skeleton": 1, + "./settings-status-summary-filter": 1, + "./use-action-guard": 1, + "@astryxdesign/core": 1, + "@maka/core/capabilities": 2, + "@maka/core/ui-locale": 1, + "@maka/ui": 3, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/personalization-settings-section.tsx": { + "bridgePaths": {}, + "environmentCapabilities": { + "clearTimeout": 3, + "setTimeout": 1, + "window": 1 + }, + "hookCalls": { + "useEffect": 2, + "useMountedRef": 1, + "useOptionalRuntimeHostSettingsTarget": 1, + "useRef": 4, + "useState": 4, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-preferences-copy.js": 1, + "../locales/settings-shared-copy": 1, + "./runtime-host-settings-target.js": 1, + "./settings-error-copy": 1, + "./settings-expandable-row": 1, + "./settings-section": 1, + "./settings-skeleton.js": 1, + "@astryxdesign/core": 1, + "@maka/core/settings": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/settings/projects-settings-page.tsx": { + "bridgePaths": { + "window.maka.app.info": 1, + "window.maka.projects.add": 1, + "window.maka.projects.archive": 1, + "window.maka.projects.getSnapshot": 1, + "window.maka.projects.rename": 1, + "window.maka.projects.reveal": 1, + "window.maka.projects.subscribeChanges": 1, + "window.maka.runtimeHostProfiles.subscribeChanges": 1 + }, + "environmentCapabilities": { + "window": 1 + }, + "hookCalls": { + "useEffect": 1, + "useKeyedActionGuard": 1, + "useMountedRef": 1, + "useOptionalRuntimeHostSettingsTarget": 1, + "useRef": 2, + "useState": 6, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../../preload/bridge-contract.js": 1, + "../locales/settings-projects-copy.js": 1, + "../locales/settings-shared-copy.js": 1, + "../project-path-display.js": 1, + "../remote-project-directory-dialog.js": 1, + "./runtime-host-interaction-boundary.js": 1, + "./runtime-host-profiles-section.js": 1, + "./runtime-host-settings-target.js": 1, + "./settings-error-copy": 1, + "./settings-section": 1, + "./use-action-guard": 1, + "@astryxdesign/core": 1, + "@maka/core/project": 1, + "@maka/core/settings": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/provider-add-form.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useActionGuard": 1, + "useMountedRef": 1, + "useState": 11, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../features/connection-settings": 1, + "../model-catalog-choices": 1, + "./password-input": 1, + "./provider-add-submission": 1, + "./provider-display": 1, + "./request-customization-editor": 1, + "./use-action-guard": 1, + "@astryxdesign/core": 1, + "@astryxdesign/core/Collapsible": 1, + "@maka/core/llm-connections": 3, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/settings/provider-add-model-dialog.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useState": 4, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../features/connection-settings": 1, + "@astryxdesign/core/Dialog": 1, + "@astryxdesign/core/FormLayout": 1, + "@astryxdesign/core/Layout": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/settings/provider-add-submission.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/llm-connections": 2 + } + }, + "src/renderer/settings/provider-brand-marks.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../assets/provider-brands/alibabacloud.svg": 1, + "../assets/provider-brands/cerebras.svg": 1, + "../assets/provider-brands/cloudflare.svg": 1, + "../assets/provider-brands/cohere.svg": 1, + "../assets/provider-brands/deepinfra.svg": 1, + "../assets/provider-brands/fireworks.svg": 1, + "../assets/provider-brands/groq.svg": 1, + "../assets/provider-brands/huggingface.svg": 1, + "../assets/provider-brands/hunyuan.svg": 1, + "../assets/provider-brands/lmstudio.svg": 1, + "../assets/provider-brands/localai.svg": 1, + "../assets/provider-brands/mistral.svg": 1, + "../assets/provider-brands/nvidia.svg": 1, + "../assets/provider-brands/opencode.svg": 1, + "../assets/provider-brands/openrouter.svg": 1, + "../assets/provider-brands/stepfun.svg": 1, + "../assets/provider-brands/tencentcloud.svg": 1, + "../assets/provider-brands/together.svg": 1, + "../assets/provider-brands/vercel.svg": 1, + "../assets/provider-brands/volcengine.svg": 1, + "../assets/provider-brands/xai.svg": 1, + "../assets/provider-brands/xiaomimimo.svg": 1, + "../assets/provider-brands/zai.svg": 1, + "../assets/provider-brands/zenmux.svg": 1, + "@maka/core/llm-connections": 1, + "react": 1, + "simple-icons": 1 + } + }, + "src/renderer/settings/provider-catalog-page.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useOAuthCards": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../features/connection-settings": 1, + "./provider-add-form": 1, + "./provider-display": 1, + "./provider-oauth-section": 1, + "@astryxdesign/core": 1, + "@maka/core/llm-connections": 1, + "@maka/core/provider-registry": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1 + } + }, + "src/renderer/settings/provider-connection-detail.tsx": { + "bridgePaths": { + "window.maka.githubCopilotSubscription.connectExistingLogin": 1 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useActionGuard": 1, + "useConnectionDetail": 1, + "useEffect": 2, + "useMountedRef": 3, + "useOAuthLoginFlow": 1, + "useRuntimeHostSettingsErrorReporter": 3, + "useRuntimeHostSettingsTarget": 1, + "useState": 8, + "useToast": 2, + "useUiLocale": 4 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../features/connection-settings": 1, + "./password-input": 1, + "./provider-add-model-dialog": 1, + "./provider-display": 1, + "./provider-enabled-model-manager": 1, + "./provider-endpoint-presentation": 1, + "./relay-thinking-bulk": 1, + "./request-customization-editor": 1, + "./runtime-host-settings-target.js": 1, + "./settings-expandable-row": 1, + "./settings-section": 1, + "./use-action-guard": 1, + "./use-connection-detail": 1, + "./use-oauth-login-flow": 1, + "@astryxdesign/core": 1, + "@maka/core/llm-connections": 1, + "@maka/core/model-metadata": 1, + "@maka/core/model-thinking": 1, + "@maka/ui": 2, + "react": 1 + } + }, + "src/renderer/settings/provider-connection-status.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../features/connection-settings/index.js": 1, + "@maka/core/llm-connections": 1, + "@maka/core/provider-registry": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1 + } + }, + "src/renderer/settings/provider-display-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/llm-connections": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/settings/provider-display.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./provider-brand-marks": 1, + "./provider-display-copy": 1, + "@maka/core/llm-connections": 1 + } + }, + "src/renderer/settings/provider-enabled-model-manager.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../features/connection-settings": 1, + "@astryxdesign/core": 1, + "@maka/core/model-catalog": 1, + "@maka/ui": 1 + } + }, + "src/renderer/settings/provider-endpoint-presentation.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/display-redaction": 1, + "@maka/core/llm-connections": 1, + "@maka/core/model-metadata": 1 + } + }, + "src/renderer/settings/provider-oauth-section.tsx": { + "bridgePaths": { + "window.maka.githubCopilotSubscription.connectExistingLogin": 1, + "window.maka.githubCopilotSubscription.getAccountState": 2, + "window.maka.githubCopilotSubscription.logout": 1, + "window.maka.githubCopilotSubscription.refreshTokens": 1, + "window.maka.openAiCodex": 1, + "window.maka.xaiOAuth": 1 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 1, + "useMountedRef": 1, + "useOAuthLoginFlow": 2, + "useRef": 1, + "useRuntimeHostSettingsGenerationKey": 1, + "useRuntimeHostSettingsTarget": 3, + "useState": 2, + "useUiLocale": 3 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../../preload/bridge-contract.js": 1, + "../features/connection-settings": 1, + "./runtime-host-settings-bridge.js": 1, + "./runtime-host-settings-target.js": 1, + "./use-oauth-login-flow": 1, + "@astryxdesign/core": 1, + "@maka/core/llm-connections": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/settings/providers-panel.tsx": { + "bridgePaths": {}, + "environmentCapabilities": { + "document.querySelector": 1 + }, + "hookCalls": { + "useEffect": 4, + "useMountedRef": 1, + "useRef": 6, + "useRuntimeHostSettingsErrorReporter": 1, + "useSettingsRouteFocus": 1, + "useState": 6, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../features/connection-settings": 2, + "./provider-catalog-page": 1, + "./provider-connection-detail": 1, + "./provider-connection-status": 1, + "./provider-display": 1, + "./provider-oauth-section": 1, + "./runtime-host-settings-target.js": 1, + "./settings-error-copy": 1, + "./settings-route-focus": 1, + "./settings-route-header": 1, + "@astryxdesign/core": 1, + "@maka/core/llm-connections": 1, + "@maka/core/provider-registry": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/relay-profile-draft.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/model-thinking": 1 + } + }, + "src/renderer/settings/relay-thinking-bulk.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/model-thinking": 1 + } + }, + "src/renderer/settings/request-customization-editor.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./password-input": 1, + "@astryxdesign/core": 1, + "@maka/core/llm-connections": 1, + "@maka/core/runtime-policy": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1 + } + }, + "src/renderer/settings/runtime-host-connection-code-dialog.tsx": { + "bridgePaths": { + "window.maka.runtimeHostProfiles.importConnectionCode": 1 + }, + "environmentCapabilities": { + "navigator.clipboard.writeText": 1 + }, + "hookCalls": { + "useState": 2, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-projects-copy.js": 1, + "./settings-error-copy.js": 1, + "@astryxdesign/core/Dialog": 1, + "@astryxdesign/core/Layout": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/settings/runtime-host-interaction-boundary.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "react": 1 + } + }, + "src/renderer/settings/runtime-host-management-dialog.tsx": { + "bridgePaths": { + "window.maka.runtimeHostManagement.configureDirectPeer": 1, + "window.maka.runtimeHostManagement.configureProjectDirectories": 1, + "window.maka.runtimeHostManagement.getDirectPeer": 3, + "window.maka.runtimeHostManagement.getUpdatePolicy": 2, + "window.maka.runtimeHostManagement.listCredentials": 1, + "window.maka.runtimeHostManagement.reconcileUpdate": 1, + "window.maka.runtimeHostManagement.revokeCredential": 1, + "window.maka.runtimeHostManagement.rotateCredential": 1, + "window.maka.runtimeHostManagement.run": 2, + "window.maka.runtimeHostManagement.setUpdatePolicy": 1, + "window.maka.runtimeHostManagement.subscribeProgress": 1, + "window.maka.runtimeHostManagement.update": 1 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 2, + "useLayoutEffect": 1, + "useRef": 2, + "useState": 18, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../../preload/bridge-contract.js": 1, + "../../shared/runtime-host-project-directory-policy.js": 1, + "../locales/settings-projects-copy.js": 1, + "./runtime-host-project-directory-editor.js": 1, + "./settings-error-copy.js": 1, + "@astryxdesign/core/Dialog": 1, + "@astryxdesign/core/Layout": 1, + "@astryxdesign/core/Switch": 1, + "@astryxdesign/core/Text": 1, + "@astryxdesign/core/Tooltip": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/runtime-host-onboarding-dialog.tsx": { + "bridgePaths": { + "window.maka.runtimeHostOnboarding.cancel": 2, + "window.maka.runtimeHostOnboarding.getSnapshot": 1, + "window.maka.runtimeHostOnboarding.listWslDistributions": 1, + "window.maka.runtimeHostOnboarding.reset": 2, + "window.maka.runtimeHostOnboarding.start": 1, + "window.maka.runtimeHostOnboarding.subscribe": 1 + }, + "environmentCapabilities": { + "navigator.userAgent.includes": 2 + }, + "hookCalls": { + "useEffect": 2, + "useRef": 2, + "useState": 8, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../../preload/bridge-contract.js": 1, + "../../shared/runtime-host-project-directory-policy.js": 1, + "../locales/settings-projects-copy.js": 1, + "./runtime-host-project-directory-editor.js": 1, + "@astryxdesign/core": 1, + "@astryxdesign/core/Dialog": 1, + "@astryxdesign/core/Layout": 1, + "@astryxdesign/core/Text": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/settings/runtime-host-profiles-section.tsx": { + "bridgePaths": { + "window.maka.localRuntimeHostRemoteAccess.createConnectionCode": 1, + "window.maka.localRuntimeHostRemoteAccess.disable": 1, + "window.maka.localRuntimeHostRemoteAccess.enable": 1, + "window.maka.localRuntimeHostRemoteAccess.getSnapshot": 1, + "window.maka.localRuntimeHostRemoteAccess.revokeSharedAccess": 1, + "window.maka.runtimeHostProfiles.addAndEnable": 1, + "window.maka.runtimeHostProfiles.getSnapshot": 1, + "window.maka.runtimeHostProfiles.remove": 1, + "window.maka.runtimeHostProfiles.setDefault": 1, + "window.maka.runtimeHostProfiles.setEnabled": 1, + "window.maka.runtimeHostProfiles.subscribeChanges": 1 + }, + "environmentCapabilities": { + "window": 1 + }, + "hookCalls": { + "useEffect": 1, + "useMountedRef": 1, + "useState": 10, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../../preload/bridge-contract.js": 1, + "../features/runtime-host-management": 1, + "../features/session-collaboration": 1, + "../locales/session-collaboration-copy.js": 1, + "../locales/settings-projects-copy.js": 1, + "./password-input.js": 1, + "./runtime-host-connection-code-dialog.js": 1, + "./runtime-host-management-dialog.js": 1, + "./runtime-host-onboarding-dialog.js": 1, + "./settings-error-copy.js": 1, + "./settings-section.js": 1, + "@astryxdesign/core": 1, + "@maka/runtime-host/client": 1, + "@maka/runtime-host/protocol": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/runtime-host-project-directory-editor.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-projects-copy.js": 1, + "@maka/runtime-host/protocol": 1, + "@maka/ui": 1 + } + }, + "src/renderer/settings/runtime-host-settings-bridge.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../../preload/bridge-contract.js": 1, + "./use-oauth-login-flow.js": 1 + } + }, + "src/renderer/settings/runtime-host-settings-target.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useToast": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../../preload/bridge-contract.js": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/settings/runtime-host-ssh-terminal-dialog.tsx": { + "bridgePaths": { + "window.maka.runtimeHostSshTerminal.cancel": 1, + "window.maka.runtimeHostSshTerminal.getSnapshot": 1, + "window.maka.runtimeHostSshTerminal.resize": 1, + "window.maka.runtimeHostSshTerminal.subscribe": 1, + "window.maka.runtimeHostSshTerminal.write": 1 + }, + "environmentCapabilities": { + "requestAnimationFrame": 1, + "ResizeObserver": 1 + }, + "hookCalls": { + "useEffect": 2, + "useRef": 5, + "useState": 2, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../../preload/bridge-contract.js": 1, + "../locales/settings-projects-copy.js": 1, + "../theme": 1, + "@astryxdesign/core/Dialog": 1, + "@astryxdesign/core/Layout": 1, + "@maka/ui": 1, + "@xterm/addon-fit": 1, + "@xterm/xterm": 1, + "react": 1 + } + }, + "src/renderer/settings/settings-error-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-shared-copy.js": 1, + "@maka/core/redaction": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1 + } + }, + "src/renderer/settings/settings-expandable-row.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 1, + "useRef": 3 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./settings-section": 1, + "@astryxdesign/core": 1, + "react": 1 + } + }, + "src/renderer/settings/settings-metric-card.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/ui": 1 + } + }, + "src/renderer/settings/settings-modal.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useRef": 2, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../../preload/bridge-contract.js": 1, + "../locales/settings-shared-copy": 1, + "./settings-nav": 2, + "./settings-surface": 1, + "./tasks-settings-page": 1, + "./ui-locale-update-gate": 1, + "@maka/core/llm-connections": 1, + "@maka/core/settings": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/settings/settings-nav.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../browser-storage.js": 1, + "../locales/settings-navigation-copy.js": 1, + "./nav-group-summary.js": 1, + "@maka/core/settings": 1, + "@maka/core/ui-locale": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/settings-request-authority.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/settings/settings-resource-state.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/settings/settings-route-focus.ts": { + "bridgePaths": {}, + "environmentCapabilities": { + "window.cancelAnimationFrame": 1, + "window.requestAnimationFrame": 1 + }, + "hookCalls": { + "useEffect": 2, + "useRef": 2 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "react": 1 + } + }, + "src/renderer/settings/settings-route-header.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@astryxdesign/core": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/settings-rows.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./settings-section.js": 1, + "react": 1 + } + }, + "src/renderer/settings/settings-section.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@astryxdesign/core": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/settings/settings-skeleton.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-shared-copy.js": 1, + "./settings-section.js": 1, + "@astryxdesign/core": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/settings/settings-snapshot-cache.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../../preload/bridge-contract.js": 1, + "@maka/core/llm-connections": 1, + "@maka/core/settings": 1 + } + }, + "src/renderer/settings/settings-status-badge.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/settings/settings-status-summary-filter.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@astryxdesign/core": 1 + } + }, + "src/renderer/settings/settings-surface.tsx": { + "bridgePaths": { + "window.maka": 1, + "window.maka.runtimeHostProfiles.getSnapshot": 1, + "window.maka.runtimeHostProfiles.subscribeChanges": 1, + "window.maka.settings.get": 1, + "window.maka.settings.getClient": 1, + "window.maka.settings.subscribeClientChanged": 1, + "window.maka.settings.subscribeExternalChanged": 1, + "window.maka.settings.testNetworkProxy": 1, + "window.maka.settings.update": 1, + "window.maka.settings.updateClient": 1, + "window.maka.settings.usageStats": 1 + }, + "environmentCapabilities": { + "window": 2, + "window.addEventListener": 1, + "window.removeEventListener": 1 + }, + "hookCalls": { + "useEffect": 10, + "useMediaQuery": 1, + "useMountedRef": 1, + "useRef": 10, + "useState": 12, + "useToast": 1, + "useUiLocale": 2 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../../preload/bridge-contract.js": 1, + "../../shared/settings-ownership.js": 1, + "../browser-storage": 1, + "../features/connection-settings": 1, + "../locales/settings-navigation-copy.js": 1, + "../locales/settings-shared-copy.js": 1, + "./about-settings-page": 1, + "./appearance-settings-page": 1, + "./bot-chat-settings-page": 1, + "./daily-review-settings-page": 1, + "./data-settings-page": 1, + "./general-settings-page": 1, + "./health-center-page": 1, + "./import-tasks-settings-page": 1, + "./memory-settings-page": 1, + "./permission-center-page": 1, + "./projects-settings-page": 1, + "./providers-panel": 1, + "./runtime-host-interaction-boundary.js": 1, + "./runtime-host-settings-target.js": 1, + "./settings-error-copy": 1, + "./settings-nav": 1, + "./settings-request-authority.js": 1, + "./settings-resource-state.js": 1, + "./settings-rows": 1, + "./settings-section": 1, + "./settings-skeleton": 1, + "./settings-snapshot-cache.js": 1, + "./subagent-settings-page": 1, + "./tasks-settings-page": 1, + "./ui-locale-update-gate": 1, + "./usage-settings-page": 1, + "./web-search-settings-page": 1, + "@astryxdesign/core": 1, + "@maka/core/llm-connections": 1, + "@maka/core/settings": 2, + "@maka/core/ui-locale": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/subagent-preset-presentation.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./settings-status-badge.js": 1, + "@maka/core/llm-connections": 2, + "@maka/core/provider-registry": 1, + "@maka/core/subagent-settings": 1 + } + }, + "src/renderer/settings/subagent-settings-page.tsx": { + "bridgePaths": {}, + "environmentCapabilities": { + "document.querySelector": 2, + "window": 1 + }, + "hookCalls": { + "useRef": 2, + "useRuntimeHostSettingsErrorReporter": 1, + "useSettingsRouteFocus": 1, + "useState": 5, + "useToast": 1, + "useUiLocale": 2 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-subagents-copy.js": 1, + "./runtime-host-settings-target.js": 1, + "./settings-error-copy.js": 1, + "./settings-route-focus.js": 1, + "./settings-route-header.js": 1, + "./settings-rows.js": 1, + "./settings-section.js": 1, + "./settings-status-badge.js": 1, + "./subagent-preset-presentation.js": 1, + "@astryxdesign/core": 1, + "@maka/core/llm-connections": 1, + "@maka/core/model-thinking": 1, + "@maka/core/settings": 1, + "@maka/core/subagent-settings": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/task-catalog-rows.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../features/session-navigation/index.js": 1, + "@maka/core/session": 1 + } + }, + "src/renderer/settings/tasks-settings-page.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useMountedRef": 1, + "useState": 2, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../../preload/bridge-contract.js": 1, + "../features/session-navigation": 1, + "../locales/settings-shared-copy.js": 1, + "../locales/settings-tasks-copy.js": 1, + "./settings-error-copy": 1, + "./settings-section": 1, + "./task-catalog-rows": 1, + "@astryxdesign/core": 1, + "@astryxdesign/core/List": 1, + "@astryxdesign/core/TextInput": 1, + "@maka/core/project": 1, + "@maka/core/relative-time": 1, + "@maka/runtime-host/profile-kind": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/ui-locale-update-gate.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/settings/usage-settings-page.tsx": { + "bridgePaths": {}, + "environmentCapabilities": { + "window": 1 + }, + "hookCalls": { + "useActionGuard": 1, + "useOptimisticSettingsDraft": 1, + "useState": 1, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../../shared/runtime-host-identity.js": 1, + "../locales/settings-usage-copy": 1, + "./settings-error-copy": 1, + "./settings-metric-card": 1, + "./settings-section": 1, + "./use-action-guard": 1, + "./use-optimistic-settings-draft": 1, + "@astryxdesign/core": 1, + "@maka/core/settings": 1, + "@maka/core/ui-locale": 1, + "@maka/core/usage-ledger-merge": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/use-action-guard.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 2, + "useRef": 2 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./action-guard.js": 1, + "./oauth-login-flow-guard.js": 1, + "react": 1 + } + }, + "src/renderer/settings/use-connection-detail.ts": { + "bridgePaths": { + "window.maka.openAiCodex": 1, + "window.maka.xaiOAuth": 1 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 6, + "useKeyedActionGuard": 1, + "useMountedRef": 1, + "useRef": 4, + "useRuntimeHostSettingsErrorReporter": 1, + "useRuntimeHostSettingsTarget": 1, + "useState": 14, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../../preload/bridge-contract.js": 1, + "../features/connection-settings": 1, + "./connection-name-draft.js": 1, + "./provider-connection-status": 1, + "./relay-profile-draft": 1, + "./relay-thinking-bulk": 1, + "./runtime-host-settings-bridge.js": 1, + "./runtime-host-settings-target.js": 1, + "./use-action-guard": 1, + "./use-oauth-login-flow": 1, + "@maka/core/llm-connections": 3, + "@maka/core/model-catalog": 1, + "@maka/core/model-thinking": 1, + "@maka/core/provider-registry": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/settings/use-memory-settings-controller.ts": { + "bridgePaths": { + "window.maka.app.openPath": 1, + "window.maka.memory.getState": 1, + "window.maka.memory.openBackup": 1, + "window.maka.memory.openFile": 1, + "window.maka.memory.openLatestBackup": 1, + "window.maka.memory.reset": 1, + "window.maka.memory.restoreBackup": 1, + "window.maka.memory.restoreLatestBackup": 1, + "window.maka.memory.save": 3, + "window.maka.memory.setAgentReadEnabled": 1, + "window.maka.memory.setEnabled": 1 + }, + "environmentCapabilities": { + "navigator.clipboard.writeText": 4, + "requestAnimationFrame": 1 + }, + "hookCalls": { + "useEffect": 2, + "useKeyedActionGuard": 1, + "useRef": 4, + "useRuntimeHostSettingsErrorReporter": 1, + "useRuntimeHostSettingsTarget": 1, + "useState": 11, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../local-memory-digest": 1, + "../locales/settings-memory-copy": 1, + "../open-path": 1, + "../scroll-motion-policy": 1, + "./memory-settings-labels": 1, + "./memory-settings-view-model": 1, + "./runtime-host-settings-target.js": 1, + "./settings-error-copy": 1, + "./use-action-guard": 1, + "@maka/core/local-memory": 2, + "@maka/core/settings": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/settings/use-oauth-login-flow.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 1, + "useMountedRef": 1, + "useRef": 2, + "useRuntimeHostSettingsErrorReporter": 1, + "useState": 5, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../features/connection-settings": 1, + "./oauth-login-flow-guard": 1, + "./runtime-host-settings-target.js": 1, + "@maka/core/redaction": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/settings/use-optimistic-settings-draft.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 2, + "useMountedRef": 1, + "useRef": 4, + "useState": 2 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./optimistic-settings-draft-controller": 1, + "@maka/ui": 1, + "react": 2 + } + }, + "src/renderer/settings/web-search-settings-page.tsx": { + "bridgePaths": { + "window.maka.webSearch.query": 1, + "window.maka.webSearch.test": 1 + }, + "environmentCapabilities": { + "window": 2 + }, + "hookCalls": { + "useKeyedActionGuard": 1, + "useMountedRef": 1, + "useRef": 1, + "useRuntimeHostSettingsErrorReporter": 1, + "useRuntimeHostSettingsTarget": 1, + "useState": 8, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-shared-copy.js": 1, + "../locales/settings-web-search-copy": 1, + "./password-input": 1, + "./runtime-host-settings-target.js": 1, + "./settings-error-copy": 1, + "./settings-section": 1, + "./use-action-guard": 1, + "@astryxdesign/core": 1, + "@maka/core/search": 1, + "@maka/core/settings": 1, + "@maka/core/web-search": 2, + "@maka/ui": 2, + "react": 1 + } + }, + "src/renderer/settled-message-merge.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/session": 1 + } + }, + "src/renderer/settled-session-transients.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/session": 1, + "@maka/ui": 1 + } + }, + "src/renderer/shell-chat-model-selection.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/chat-model-choice": 1 + } + }, + "src/renderer/shell-run-update-state.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/events": 1, + "@maka/core/shell-run-result": 1 + } + }, + "src/renderer/side-chat-command.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/skill-invocation-feedback.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/shell-copy.js": 1, + "@maka/core/ui-locale": 1, + "@maka/runtime/skill-invocation": 1 + } + }, + "src/renderer/stale-sessions.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/session-send-projection": 1 + } + }, + "src/renderer/task-readiness-notice.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/session-send-projection": 1, + "@maka/core/task-submission-readiness": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/theme.ts": { + "bridgePaths": { + "window.maka.appWindow.setThemeSource": 1, + "window.maka.appWindow.setTitlebarControlsVisible": 1, + "window.maka.appWindow.setTitleBarOverlayTheme": 1 + }, + "environmentCapabilities": { + "document.createElement": 1, + "document.documentElement": 9, + "document.documentElement.classList.contains": 1, + "document.documentElement.classList.toggle": 1, + "document.documentElement.removeAttribute": 1, + "document.documentElement.setAttribute": 1, + "document.documentElement.style.colorScheme": 1, + "document.documentElement.style.fontSize": 1, + "document.querySelector": 1, + "window.matchMedia": 1 + }, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./astryx-theme/type-scale.js": 1, + "./browser-storage": 1, + "./titlebar-dim-color.js": 1, + "@maka/core/settings": 1 + } + }, + "src/renderer/titlebar-dim-color.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/titlebar-modal-sync.ts": { + "bridgePaths": {}, + "environmentCapabilities": { + "document.documentElement": 1, + "document.querySelector": 1, + "MutationObserver": 1 + }, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./theme": 1 + } + }, + "src/renderer/transient-message-projection.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/session": 1, + "@maka/ui": 1 + } + }, + "src/renderer/turn-footer-actions.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/conversation-copy.js": 1, + "@maka/core/session": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/use-active-execution-boundary.ts": { + "bridgePaths": { + "window.maka.sessions.readExecutionBoundary": 1 + }, + "environmentCapabilities": { + "clearTimeout": 1, + "setTimeout": 1 + }, + "hookCalls": { + "useEffect": 2, + "useRef": 1, + "useState": 3 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/sandbox-boundary": 1, + "react": 1 + } + }, + "src/renderer/use-composer-attachments.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 2, + "useRef": 2, + "useState": 2, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./composer-attachments.js": 1, + "./locales/conversation-copy.js": 1, + "./locales/shell-copy.js": 1, + "./pending-items.js": 1, + "@maka/core/attachments": 1, + "@maka/core/events": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/use-deep-research-run.ts": { + "bridgePaths": { + "window.maka.deepResearch.get": 1, + "window.maka.deepResearch.subscribeChanges": 1 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 1, + "useState": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/deep-research-run": 1, + "react": 1 + } + }, + "src/renderer/use-delayed-flag.ts": { + "bridgePaths": {}, + "environmentCapabilities": { + "window.clearTimeout": 1, + "window.setTimeout": 1 + }, + "hookCalls": { + "useEffect": 2, + "useRef": 1, + "useState": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./model-wait-state.js": 1, + "react": 1 + } + }, + "src/renderer/use-external-store-selector.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useSyncExternalStore": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "react": 1 + } + }, + "src/renderer/use-new-task-choice.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 1, + "useState": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./new-task-reload-intent.js": 1, + "react": 1 + } + }, + "src/renderer/use-onboarding-snapshot.ts": { + "bridgePaths": { + "window.maka.connections.subscribeEvents": 1, + "window.maka.onboarding.getSnapshot": 1, + "window.maka.sessions.subscribeChanges": 1 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 1, + "useRef": 5, + "useState": 2, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "./locales/onboarding-copy.js": 1, + "@maka/core/llm-connections": 1, + "@maka/core/onboarding-milestone": 1, + "@maka/core/redaction": 1, + "@maka/core/session": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/use-project-context.ts": { + "bridgePaths": { + "window.maka.app.sessionProjectInfo": 1, + "window.maka.projects.getDefaultContext": 1, + "window.maka.projects.getLocalSnapshot": 1, + "window.maka.projects.getSnapshot": 1, + "window.maka.projects.subscribeChanges": 2, + "window.maka.projects.subscribeLocalChanges": 1 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 4, + "useRef": 3, + "useStableActions": 1, + "useState": 8 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "./app-shell-project-actions": 1, + "./default-runtime-host-operation.js": 1, + "./use-stable-actions.js": 1, + "@maka/core/project": 1, + "@maka/core/ui-locale": 1, + "@maka/runtime-host/profile-kind": 1, + "react": 1 + } + }, + "src/renderer/use-session-setting-intent.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 1, + "useRef": 2, + "useState": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "react": 1 + } + }, + "src/renderer/use-settings-modal.ts": { + "bridgePaths": {}, + "environmentCapabilities": { + "document.activeElement": 1, + "document.activeElement.blur": 1 + }, + "hookCalls": { + "useState": 5 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./browser-storage": 1, + "@maka/core/llm-connections": 1, + "@maka/core/settings": 1, + "react": 1 + } + }, + "src/renderer/use-shell-appearance.ts": { + "bridgePaths": { + "window.maka.e2eFixture.getState": 1, + "window.maka.settings.get": 1, + "window.maka.settings.getClient": 1 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useState": 6 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/shell-copy": 1, + "./settings/ui-locale-update-gate": 1, + "./theme": 1, + "@maka/core/model-thinking": 1, + "@maka/core/settings": 1, + "@maka/core/ui-locale": 1, + "react": 1 + } + }, + "src/renderer/use-shell-chat-model.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useNewTaskChoice": 2 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./composer-defaults.js": 1, + "./locales/conversation-copy.js": 1, + "./session-health-notice.js": 1, + "./shell-chat-model-selection.js": 2, + "./use-new-task-choice.js": 1, + "@maka/core/chat-model-choice": 1, + "@maka/core/llm-connections": 1, + "@maka/core/model-thinking": 1, + "@maka/core/session": 1, + "@maka/core/session-send-projection": 1, + "@maka/core/settings": 1, + "@maka/core/ui-locale": 1, + "react": 1 + } + }, + "src/renderer/use-shell-connections.ts": { + "bridgePaths": { + "window.maka.connections.getSnapshot": 2, + "window.maka.newTasks.getConnections": 1 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useLayoutEffect": 2, + "useRef": 2, + "useState": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "../shared/desktop-connection-snapshot.js": 1, + "../shared/runtime-host-identity.js": 1, + "./default-runtime-host-operation.js": 1, + "./locales/shell-copy.js": 1, + "./locales/shell-remaining-copy.js": 1, + "@maka/core/connections": 1, + "@maka/core/ui-locale": 1, + "react": 1 + } + }, + "src/renderer/use-shell-live-turn.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useDelayedFlag": 3 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./live-turn-snapshot.js": 1, + "./model-wait-state.js": 1, + "./use-delayed-flag.js": 1, + "@maka/core/session": 1 + } + }, + "src/renderer/use-shell-memory-pill.ts": { + "bridgePaths": { + "window.maka.memory.getState": 2 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 1, + "useRef": 1, + "useState": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./default-runtime-host-operation.js": 1, + "./locales/shell-copy.js": 1, + "@maka/core/ui-locale": 1, + "react": 1 + } + }, + "src/renderer/use-shell-resume.ts": { + "bridgePaths": { + "window.maka.sessions.resumeLatest": 1 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useState": 2 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/shell-copy.js": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/use-shell-search.ts": { + "bridgePaths": { + "window.maka.search.thread": 1 + }, + "environmentCapabilities": { + "window": 1 + }, + "hookCalls": { + "useState": 2 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "react": 1 + } + }, + "src/renderer/use-stable-actions.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useLayoutEffect": 1, + "useRef": 1, + "useState": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "react": 1 + } + }, + "src/renderer/use-system-ui-locale.ts": { + "bridgePaths": {}, + "environmentCapabilities": { + "navigator": 1, + "navigator.languages": 1, + "window": 1, + "window.addEventListener": 1, + "window.removeEventListener": 1 + }, + "hookCalls": { + "useEffect": 1, + "useState": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/ui-locale": 1, + "react": 1 + } + }, + "src/renderer/use-task-submission-readiness.ts": { + "bridgePaths": { + "window.maka.newTasks.getReadiness": 1, + "window.maka.taskReadiness.getSnapshot": 1 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 1, + "useRef": 1, + "useState": 2 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "@maka/core/task-submission-readiness": 1, + "react": 1 + } + }, + "src/renderer/use-turn-action-registry.ts": { + "bridgePaths": {}, + "environmentCapabilities": { + "clearTimeout": 2, + "setTimeout": 1 + }, + "hookCalls": { + "useRef": 3, + "useState": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "react": 1 + } + }, + "src/renderer/use-work-board.ts": { + "bridgePaths": { + "window.maka.workBoard.archive": 1, + "window.maka.workBoard.create": 1, + "window.maka.workBoard.list": 2, + "window.maka.workBoard.remove": 1, + "window.maka.workBoard.subscribeChanges": 1, + "window.maka.workBoard.unarchive": 1, + "window.maka.workBoard.update": 1 + }, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 1, + "useRef": 2, + "useState": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../shared/work-board-ipc.js": 1, + "@maka/core/work-board": 1, + "@maka/storage/work-board-store": 1, + "react": 1 + } + }, + "src/renderer/work-board-panel.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 1, + "useRef": 1, + "useState": 7, + "useUiLocale": 1, + "useWorkBoard": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/conversation-copy.js": 1, + "./use-work-board.js": 1, + "@astryxdesign/core": 1, + "@astryxdesign/core/Button": 1, + "@astryxdesign/core/TextInput": 1, + "@maka/core/work-board": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/workhub-controller.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./workhub-route-policy.js": 1, + "@maka/runtime-host/protocol": 1 + } + }, + "src/renderer/workhub-coordination-host-scope.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../shared/runtime-host-identity.js": 1, + "./workhub-session-port.js": 1 + } + }, + "src/renderer/workhub-coordination-lifecycle.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "../shared/runtime-host-identity.js": 1 + } + }, + "src/renderer/workhub-coordination-port.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./desktop-transcript-range-store.js": 1, + "./workhub-controller.js": 2, + "./workhub-session-port.js": 1, + "@maka/core/session": 1, + "@maka/runtime-host/protocol": 1 + } + }, + "src/renderer/workhub-route-policy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./application/contracts/workhub-request-intent.js": 1 + } + }, + "src/renderer/workhub-send-lease.ts": { + "bridgePaths": {}, + "environmentCapabilities": { + "document": 1, + "localStorage": 1, + "window": 1, + "window.localStorage": 1 + }, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/workhub-session-port.ts": { + "bridgePaths": {}, + "environmentCapabilities": { + "window.clearTimeout": 1, + "window.setTimeout": 1 + }, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/transcript-contract.js": 1, + "../shared/runtime-host-identity.js": 1, + "./desktop-transcript-range-store.js": 1, + "./workhub-controller.js": 2, + "@maka/core/session": 1 + } + }, + "src/renderer/workhub-surface.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 2, + "useRef": 3, + "useState": 8 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./workhub-controller.js": 1, + "./workhub-coordination-port.js": 1, + "./workhub-send-lease.js": 1, + "@astryxdesign/core": 1, + "@astryxdesign/core/Button": 1, + "@maka/core/ui-locale": 1, + "@maka/ui": 1, + "react": 1 + } + }, + "src/renderer/workspace-readiness-recovery.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./onboarding-hero-copy.js": 1, + "@maka/core/onboarding": 1, + "@maka/core/ui-locale": 1 + } + }, + "src/shared/desktop-connection-snapshot.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/chat-model-choice": 1, + "@maka/core/llm-connections": 1 + } + }, + "src/shared/desktop-session-projection.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./runtime-host-identity.js": 1, + "@maka/core/daily-review": 1, + "@maka/core/events": 1, + "@maka/core/session": 1, + "@maka/core/settings": 1, + "@maka/runtime-host/profile-kind": 1 + } + }, + "src/shared/goal-arm.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/runtime/goal-state": 1 + } + }, + "src/shared/runtime-host-identity.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/shared/runtime-host-project-directory-policy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/runtime-host/protocol": 1 + } + }, + "src/shared/settings-ownership.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/settings": 1 + } + }, + "src/shared/work-board-ipc.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/storage/work-board-store": 1 + } + } + } + }, + "rootDebt": { + "src/renderer/app.tsx": { + "importDeclarations": 6, + "bridgePaths": { + "window.maka.appWindow.notifyRendererReady": 1 + }, + "environmentCapabilities": { + "cancelAnimationFrame": 2, + "requestAnimationFrame": 2 + }, + "hookCalls": { + "useAstryxThemeMode": 1, + "useEffect": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "./app-shell": 1, + "./astryx-theme-mode": 1, + "./astryx-theme/maka": 1, + "@astryxdesign/core/theme": 1, + "react": 1 + }, + "importSpecifiers": 7, + "nonTriviaTokens": 206 + }, + "src/renderer/main.tsx": { + "importDeclarations": 8, + "bridgePaths": { + "window.maka.onboarding.getSnapshot": 2 + }, + "environmentCapabilities": { + "document.getElementById": 1, + "setTimeout": 2 + }, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../preload/bridge-contract.js": 1, + "./app": 1, + "./cached-theme-bootstrap": 1, + "./composition/desktop-feature-services": 1, + "./styles.css": 1, + "./use-system-ui-locale": 1, + "@maka/ui": 1, + "react-dom/client": 1 + }, + "importSpecifiers": 8, + "nonTriviaTokens": 268 + } + }, + "rootDebtClosure": { + "src/renderer/astryx-theme-mode.ts": { + "bridgePaths": {}, + "environmentCapabilities": { + "document.documentElement": 1, + "document.documentElement.classList.contains": 1, + "MutationObserver": 1 + }, + "hookCalls": { + "useEffect": 1, + "useState": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@astryxdesign/core/theme": 1, + "react": 1 + } + }, + "src/renderer/astryx-theme/maka.js": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@astryxdesign/theme-neutral": 1 + } + }, + "src/renderer/cached-theme-bootstrap.ts": { + "bridgePaths": {}, + "environmentCapabilities": { + "document.documentElement.classList.add": 1, + "document.documentElement.setAttribute": 1, + "document.documentElement.style.colorScheme": 2, + "window.matchMedia": 1 + }, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./browser-storage": 1, + "./theme": 1 + } + } + }, + "ownership": [ + { + "capability": "renderer-bootstrap-entry", + "targetZone": "bootstrap", + "legacyPaths": [ + "src/renderer/main.tsx" + ] + }, + { + "capability": "application-mount-lifecycle", + "targetZone": "composition", + "legacyPaths": [ + "src/renderer/app.tsx" + ] + }, + { + "capability": "shell-root-and-frame", + "targetZone": "shell", + "legacyPaths": [ + "src/renderer/app-shell-chrome-actions.tsx", + "src/renderer/app-shell-detail-panel.tsx" + ] + }, + { + "capability": "app-shell-integration-knot", + "targetZone": "split-by-capability", + "legacyPaths": [ + "src/renderer/app-shell.tsx" + ] + }, + { + "capability": "app-lifecycle", + "targetZone": "application/app-lifecycle", + "legacyPaths": [ + "src/renderer/app-shell-app-update.ts" + ] + }, + { + "capability": "task-submission-and-composer-drafts", + "targetZone": "features/conversation", + "legacyPaths": [ + "src/renderer/app-shell-chat-actions.ts", + "src/renderer/app-shell-session-start-actions.ts", + "src/renderer/use-app-shell-composer-quotes.ts" + ] + }, + { + "capability": "session-controls-and-turn-actions", + "targetZone": "features/conversation", + "legacyPaths": [ + "src/renderer/app-shell-context-compaction.ts", + "src/renderer/app-shell-revision-actions.ts", + "src/renderer/app-shell-session-settings-actions.ts", + "src/renderer/app-shell-stop-action.ts", + "src/renderer/app-shell-turn-actions.ts" + ] + }, + { + "capability": "conversation-runtime-and-presentation", + "targetZone": "features/conversation", + "legacyPaths": [ + "src/renderer/app-shell-session-events.ts", + "src/renderer/app-shell-session-ui-state.ts", + "src/renderer/app-shell-turn-view-model.ts", + "src/renderer/use-app-shell-session-ui-reads.ts" + ] + }, + { + "capability": "session-catalog-and-scope", + "targetZone": "application/session", + "legacyPaths": [ + "src/renderer/use-app-shell-session-list.ts", + "src/renderer/use-app-shell-session-workspace.ts" + ] + }, + { + "capability": "workspace-projection", + "targetZone": "application/workspace", + "legacyPaths": [ + "src/renderer/app-shell-project-actions.ts" + ] + }, + { + "capability": "commands-and-overlays", + "targetZone": "application/overlays", + "legacyPaths": [ + "src/renderer/app-shell-command-actions.ts", + "src/renderer/app-shell-overlays.tsx" + ] + }, + { + "capability": "mixed-bootstrap-and-conversation-effects", + "targetZone": "split-by-capability", + "legacyPaths": [ + "src/renderer/app-shell-copy.ts", + "src/renderer/app-shell-effects.ts" + ] + }, + { + "capability": "desktop-e2e-fixture", + "targetZone": "testing", + "legacyPaths": [ + "src/renderer/app-shell-e2e-fixture.ts" + ] + } + ] +} diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index 71eea8ee63..d0d2e2fbc9 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -120,7 +120,7 @@ limitations under the License. ================================================================================ -Package: @ai-sdk/open-responses@2.0.29 +Package: @ai-sdk/open-responses@2.0.34 Declared license: Apache-2.0 Selected license: Apache-2.0 Repository: https://github.com/vercel/ai#packages/open-responses @@ -208,6 +208,28 @@ limitations under the License. ================================================================================ +Package: @ai-sdk/provider@4.0.8 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: https://github.com/vercel/ai#packages/provider + +--- LICENSE --- +Copyright 2023 Vercel, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +================================================================================ + Package: @ai-sdk/provider-utils@5.0.28 Declared license: Apache-2.0 Selected license: Apache-2.0 @@ -418,6 +440,216 @@ Apache License ================================================================================ +Package: @ai-sdk/provider-utils@5.0.32 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: https://github.com/vercel/ai#packages/provider-utils + +--- VERSION-PINNED LICENSE TEXT OVERRIDE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +================================================================================ + Package: @ant-design/icons-svg@4.5.0 Declared license: MIT Selected license: MIT @@ -478,7 +710,7 @@ SOFTWARE. ================================================================================ -Package: @astryxdesign/core@0.4.5 +Package: @astryxdesign/core@0.5.2 Declared license: MIT Selected license: MIT Repository: git+https://github.com/facebook/astryx.git#packages/core @@ -508,7 +740,7 @@ SOFTWARE. ================================================================================ -Package: @astryxdesign/theme-neutral@0.4.5 +Package: @astryxdesign/theme-neutral@0.5.2 Declared license: MIT Selected license: MIT Repository: git+https://github.com/facebook/astryx.git#packages/themes/neutral @@ -987,6 +1219,35 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ================================================================================ +Package: @gar/promise-retry@1.0.3 +Declared license: MIT +Selected license: MIT +Repository: git://github.com/wraithgar/node-promise-retry.git + +--- LICENSE --- +Copyright (c) 2011 Tim Koschützki (tim@debuggable.com), Felix Geisendörfer (felix@debuggable.com) +Copyright (c) 2014 IndigoUnited + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ + Package: @iconify/types@2.0.0 Declared license: MIT Selected license: MIT @@ -2108,40 +2369,1090 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -================================================================================ +================================================================================ + +Package: @protobufjs/utf8@1.1.2 +Declared license: BSD-3-Clause +Selected license: BSD-3-Clause +Repository: https://github.com/protobufjs/protobuf.js.git + +--- LICENSE --- +Copyright (c) 2016, Daniel Wirtz All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +* Neither the name of its author, nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +================================================================================ + +Package: @sigstore/bundle@5.0.0 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: git+https://github.com/sigstore/sigstore-js.git + +--- LICENSE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 The Sigstore Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +================================================================================ + +Package: @sigstore/core@4.0.1 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: git+https://github.com/sigstore/sigstore-js.git + +--- LICENSE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 The Sigstore Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +================================================================================ + +Package: @sigstore/protobuf-specs@0.5.2 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: git+https://github.com/sigstore/protobuf-specs.git + +--- LICENSE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 The Sigstore Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +================================================================================ + +Package: @sigstore/tuf@5.0.0 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: git+https://github.com/sigstore/sigstore-js.git + +--- LICENSE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 The Sigstore Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +================================================================================ + +Package: @sigstore/verify@4.1.2 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: git+https://github.com/sigstore/sigstore-js.git + +--- VERSION-PINNED LICENSE TEXT OVERRIDE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. -Package: @protobufjs/utf8@1.1.2 -Declared license: BSD-3-Clause -Selected license: BSD-3-Clause -Repository: https://github.com/protobufjs/protobuf.js.git + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. ---- LICENSE --- -Copyright (c) 2016, Daniel Wirtz All rights reserved. + Copyright [yyyy] [name of copyright owner] -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at -* Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. -* Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. -* Neither the name of its author, nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. + http://www.apache.org/licenses/LICENSE-2.0 -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. ================================================================================ @@ -2329,6 +3640,66 @@ SOFTWARE. ================================================================================ +Package: @tufjs/canonical-json@2.0.0 +Declared license: MIT +Selected license: MIT +Repository: git+https://github.com/theupdateframework/tuf-js.git + +--- LICENSE --- +MIT License + +Copyright (c) 2022 GitHub and the TUF Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + +Package: @tufjs/models@5.0.0 +Declared license: MIT +Selected license: MIT +Repository: git+https://github.com/theupdateframework/tuf-js.git + +--- LICENSE --- +MIT License + +Copyright (c) 2022 GitHub and the TUF Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: @types/d3@7.4.3 Declared license: MIT Selected license: MIT @@ -4238,6 +5609,38 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ================================================================================ +Package: balanced-match@4.0.4 +Declared license: MIT +Selected license: MIT +Repository: git://github.com/juliangruber/balanced-match.git + +--- LICENSE.md --- +(MIT) + +Original code Copyright Julian Gruber + +Port to TypeScript Copyright Isaac Z. Schlueter + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: bare-addon-resolve@1.10.1 Declared license: Apache-2.0 Selected license: Apache-2.0 @@ -4890,6 +6293,38 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ================================================================================ +Package: brace-expansion@5.0.9 +Declared license: MIT +Selected license: MIT +Repository: git+https://github.com/juliangruber/brace-expansion.git + +--- LICENSE --- +MIT License + +Copyright Julian Gruber + +TypeScript port Copyright Isaac Z. Schlueter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: builder-util-runtime@9.7.0 Declared license: MIT Selected license: MIT @@ -10211,6 +11646,86 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ +Package: minimatch@10.2.5 +Declared license: BlueOak-1.0.0 +Selected license: BlueOak-1.0.0 +Repository: git@github.com:isaacs/minimatch + +--- LICENSE.md --- +# Blue Oak Model License + +Version 1.0.0 + +## Purpose + +This license gives everyone as much permission to work with +this software as possible, while protecting contributors +from liability. + +## Acceptance + +In order to receive this license, you must agree to its +rules. The rules of this license are both obligations +under that agreement and conditions to your license. +You must not do anything with this software that triggers +a rule that you cannot or will not follow. + +## Copyright + +Each contributor licenses you to do everything with this +software that would otherwise infringe that contributor's +copyright in it. + +## Notices + +You must ensure that everyone who gets a copy of +any part of this software from you, with or without +changes, also gets the text of this license or a link to +. + +## Excuse + +If anyone notifies you in writing that you have not +complied with [Notices](#notices), you can keep your +license by taking all practical steps to comply within 30 +days after the notice. If you do not do so, your license +ends immediately. + +## Patent + +Each contributor licenses you to do everything with this +software that would otherwise infringe any patent claims +they can license or become able to license. + +## Reliability + +No contributor can revoke this license. + +## No Liability + +**_As far as the law allows, this software comes as is, +without any warranty or condition, and no contributor +will be liable to anyone for any damages related to this +software or this license, under any kind of legal claim._** + +================================================================================ + +Package: minisearch@7.2.0 +Declared license: MIT +Selected license: MIT +Repository: https://github.com/lucaong/minisearch.git + +--- LICENSE.txt --- +Copyright 2022 Luca Ongaro + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + Package: ms@2.1.3 Declared license: MIT Selected license: MIT @@ -12297,6 +13812,36 @@ PERFORMANCE OF THIS SOFTWARE. ================================================================================ +Package: tuf-js@6.0.0 +Declared license: MIT +Selected license: MIT +Repository: git+https://github.com/theupdateframework/tuf-js.git + +--- LICENSE --- +MIT License + +Copyright (c) 2022 GitHub and the TUF Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: turndown@7.2.4 Declared license: MIT Selected license: MIT diff --git a/apps/desktop/scripts/check-renderer-architecture.mjs b/apps/desktop/scripts/check-renderer-architecture.mjs new file mode 100644 index 0000000000..2d1258152f --- /dev/null +++ b/apps/desktop/scripts/check-renderer-architecture.mjs @@ -0,0 +1,2586 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { builtinModules } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parse } from '@babel/parser'; + +const STATEFUL_HOOKS = new Set([ + 'use', + 'useActionState', + 'useDeferredValue', + 'useEffect', + 'useImperativeHandle', + 'useInsertionEffect', + 'useLayoutEffect', + 'useOptimistic', + 'useReducer', + 'useRef', + 'useState', + 'useSyncExternalStore', + 'useTransition', +]); +const REACT_LIFECYCLE_METHODS = new Set([ + 'UNSAFE_componentWillMount', + 'UNSAFE_componentWillReceiveProps', + 'UNSAFE_componentWillUpdate', + 'componentDidCatch', + 'componentDidMount', + 'componentDidUpdate', + 'componentWillMount', + 'componentWillReceiveProps', + 'componentWillUnmount', + 'componentWillUpdate', + 'getDerivedStateFromError', + 'getDerivedStateFromProps', + 'getSnapshotBeforeUpdate', + 'shouldComponentUpdate', +]); +const ENVIRONMENT_CALLS = new Set([ + 'BroadcastChannel', + 'EventSource', + 'FileReader', + 'IntersectionObserver', + 'MutationObserver', + 'ResizeObserver', + 'SharedWorker', + 'WebSocket', + 'Worker', + 'XMLHttpRequest', + 'addEventListener', + 'cancelAnimationFrame', + 'clearInterval', + 'clearTimeout', + 'fetch', + 'matchMedia', + 'queueMicrotask', + 'requestAnimationFrame', + 'removeEventListener', + 'setInterval', + 'setTimeout', +]); +const ENVIRONMENT_OBJECTS = new Set([ + 'document', + 'history', + 'indexedDB', + 'localStorage', + 'location', + 'navigator', + 'sessionStorage', +]); +const FORBIDDEN_ENVIRONMENT_IMPORTS = new Set([ + 'electron', + ...builtinModules, + ...builtinModules.map((module) => `node:${module.replace(/^node:/u, '')}`), +]); +const SOURCE_EXTENSION = /\.(?:(?:c|m)?(?:js|ts)x?)$/u; +const SOURCE_FILE = SOURCE_EXTENSION; +const DECLARATION_FILE = /\.d\.(?:(?:c|m)?ts)$/u; +const LEGACY_APP_SHELL_FILE = /^(?:app-shell(?:-.+)?|use-app-shell-.+)\.(?:(?:c|m)?(?:js|ts)x?)$/u; +const LEGACY_APP_SHELL_MODULE = 'src/renderer/app-shell'; +const LEGACY_APP_SHELL_ADAPTER = 'src/renderer/composition/legacy-desktop-region.tsx'; +const RENDERER_ENTRY_INDEX = 'src/renderer/index.html'; +const RENDERER_ENTRY_SOURCE = 'src/renderer/main.tsx'; +const RENDERER_ENTRY_SCRIPT_SOURCE = '/main.tsx'; +const RENDERER_LOADER_SOURCE = 'src/main/main-window.ts'; +const MAIN_RENDERER_LOADER_SOURCE = 'src/main/main-renderer-loader.ts'; +const RENDERER_VITE_CONFIG = 'vite.config.ts'; +const RENDERER_BUILD_SCRIPT = + 'vite build && node scripts/check-renderer-entry-output.mjs && node ../../scripts/check-third-party-notices.mjs'; +const DESKTOP_SELF_PREFIX = '@maka/desktop/'; +const CAPABILITY_DEBT_METRICS = [ + 'actionFactories', + 'bridgePaths', + 'dependencyPaths', + 'environmentCapabilities', + 'hookCalls', + 'lifecycleMethods', + 'unresolvedDependencies', +]; +const ROOT_DEBT_METRICS = [ + ...CAPABILITY_DEBT_METRICS, + 'importDeclarations', + 'importSpecifiers', + 'nonTriviaTokens', +]; +const DEFAULT_LEGACY_GROWTH_DIRECTORIES = [ + 'src/renderer/astryx-theme', + 'src/renderer/computer-use-overlay', + 'src/renderer/locales', + 'src/renderer/settings', +]; +const PARSER_PLUGINS = [ + 'explicitResourceManagement', + 'importAttributes', + 'jsx', + 'typescript', +]; + +function normalizePath(value) { + return value.split(sep).join('/'); +} + +function sortedObject(value) { + return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))); +} + +function sameJson(left, right) { + return JSON.stringify(sortedObject(left)) === JSON.stringify(sortedObject(right)); +} + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isSortedUniqueStrings(value) { + return ( + Array.isArray(value) && + value.every((item) => typeof item === 'string') && + JSON.stringify(value) === JSON.stringify([...new Set(value)].sort()) + ); +} + +function validateCountMap(value) { + return ( + isRecord(value) && + Object.entries(value).every( + ([key, count]) => key.length > 0 && Number.isInteger(count) && count >= 0, + ) + ); +} + +function validateArchitectureConfig(config, label, violations) { + if (!isRecord(config)) { + violations.push(`${label} architecture ledger must be an object`); + return false; + } + let valid = true; + const reject = (message) => { + valid = false; + violations.push(`${label} architecture ledger: ${message}`); + }; + if (config.version !== 1) reject('version must be 1'); + if (!isSortedUniqueStrings(config.legacyRendererFiles)) { + reject('legacyRendererFiles must be sorted unique strings'); + } + if (!isSortedUniqueStrings(config.legacyGrowthDirectories)) { + reject('legacyGrowthDirectories must be sorted unique strings'); + } else if ( + config.legacyGrowthDirectories.some( + (path) => !path.startsWith('src/renderer/') || path.includes('..') || path.includes('\\'), + ) + ) { + reject('legacyGrowthDirectories must contain normalized renderer directories'); + } + for (const field of ['legacyFeatureImports', 'legacyPlatformImports']) { + if (!isSortedUniqueStrings(config[field])) reject(`${field} must be sorted unique strings`); + } + if ( + !isRecord(config.legacyAppShell) || + !isRecord(config.legacyAppShell.files) || + !isRecord(config.legacyAppShell.closure) + ) { + reject('legacyAppShell.files and legacyAppShell.closure must be objects'); + } + if (!isRecord(config.rootDebt)) reject('rootDebt must be an object'); + if (!isRecord(config.rootDebtClosure)) reject('rootDebtClosure must be an object'); + if (!Array.isArray(config.ownership)) reject('ownership must be an array'); + if (!valid) return false; + + const debtSections = [ + { entries: Object.entries(config.legacyAppShell.files), full: true }, + { entries: Object.entries(config.legacyAppShell.closure), full: false }, + { entries: Object.entries(config.rootDebt), full: true }, + { entries: Object.entries(config.rootDebtClosure), full: false }, + ]; + for (const section of debtSections) { + for (const [path, debt] of section.entries) { + const requiredPrefix = section.full ? 'src/renderer/' : 'src/'; + if (!path.startsWith(requiredPrefix) || path.includes('..') || path.includes('\\')) { + reject(`${path}: debt path must be a normalized ${section.full ? 'renderer' : 'Desktop'} source path`); + } + if (!isRecord(debt)) { + reject(`${path}: debt entry must be an object`); + continue; + } + if (section.full) { + for (const metric of ['importDeclarations', 'importSpecifiers', 'nonTriviaTokens']) { + if (!Number.isInteger(debt[metric]) || debt[metric] < 0) reject(`${path}: ${metric} must be a non-negative integer`); + } + } + if (!Number.isInteger(debt.unresolvedDependencies) || debt.unresolvedDependencies < 0) { + reject(`${path}: unresolvedDependencies must be a non-negative integer`); + } + for (const metric of ['bridgePaths', 'dependencyPaths', 'environmentCapabilities', 'hookCalls', 'lifecycleMethods']) { + if (!validateCountMap(debt[metric])) reject(`${path}: ${metric} must be a non-negative count map`); + } + if (!isSortedUniqueStrings(debt.actionFactories)) reject(`${path}: actionFactories must be sorted unique strings`); + } + } + + const capabilities = new Set(); + const targetZone = /^(?:application(?:\/[a-z0-9-]+)*|bootstrap|composition|features\/[a-z0-9-]+|shell|split-by-capability|testing)$/u; + for (const owner of config.ownership) { + if (!isRecord(owner)) { + reject('ownership entries must be objects'); + continue; + } + if (typeof owner.capability !== 'string' || owner.capability.length === 0) reject('ownership capability must be non-empty'); + if (capabilities.has(owner.capability)) reject(`duplicate ownership capability ${owner.capability}`); + capabilities.add(owner.capability); + if (typeof owner.targetZone !== 'string' || !targetZone.test(owner.targetZone)) { + reject(`${owner.capability}: unsupported targetZone ${String(owner.targetZone)}`); + } + if (!isSortedUniqueStrings(owner.legacyPaths)) { + reject(`${owner.capability}: legacyPaths must be sorted unique strings`); + } + } + return valid; +} + +function childNodes(node) { + const children = []; + for (const [key, value] of Object.entries(node)) { + if (['comments', 'end', 'errors', 'extra', 'loc', 'start', 'tokens'].includes(key)) continue; + if (Array.isArray(value)) { + for (const item of value) { + if (item && typeof item === 'object' && typeof item.type === 'string') children.push(item); + } + continue; + } + if (value && typeof value === 'object' && typeof value.type === 'string') children.push(value); + } + return children; +} + +function isMemberExpression(node) { + return node?.type === 'MemberExpression' || node?.type === 'OptionalMemberExpression'; +} + +function memberName(node) { + if (node?.type === 'Identifier') return node.name; + if (node?.type === 'StringLiteral') return node.value; + return undefined; +} + +function unwrapExpression(node) { + let current = node; + while ( + current && + ['ParenthesizedExpression', 'TSAsExpression', 'TSNonNullExpression', 'TSSatisfiesExpression', 'TSTypeAssertion', 'TypeCastExpression'].includes( + current.type, + ) + ) { + current = current.expression; + } + return current; +} + +function memberPropertyName(node) { + if (!isMemberExpression(node)) return undefined; + const property = unwrapExpression(node.property); + if (!node.computed && property?.type === 'Identifier') return property.name; + return staticString(property); +} + +function isWindowExpression(node, windowAliases = new Set(['globalThis', 'window'])) { + const expression = unwrapExpression(node); + if (expression?.type === 'Identifier' && windowAliases.has(expression.name)) return true; + return ( + isMemberExpression(expression) && + unwrapExpression(expression.object)?.type === 'Identifier' && + windowAliases.has(unwrapExpression(expression.object).name) && + memberPropertyName(expression) === 'window' + ); +} + +function isWindowMaka(node, windowAliases) { + const expression = unwrapExpression(node); + return ( + isMemberExpression(expression) && + isWindowExpression(expression.object, windowAliases) && + memberPropertyName(expression) === 'maka' + ); +} + +function hookCallNames( + node, + hookAliases, + hookNamespaces, + reactNamespaces, + parents, + hookBindings, +) { + const expression = unwrapExpression(node); + if (expression?.type === 'Identifier') { + const binding = lexicalBindingIdentifier(expression, expression.name, parents); + return binding + ? hookBindings.hookAliasBindings.get(binding) + : hookAliases.get(expression.name); + } + if ( + isMemberExpression(expression) && + unwrapExpression(expression.object)?.type === 'Identifier' + ) { + const name = memberPropertyName(expression); + const namespaceNode = unwrapExpression(expression.object); + const namespace = namespaceNode.name; + const binding = lexicalBindingIdentifier(namespaceNode, namespace, parents); + const reactNamespace = binding + ? hookBindings.reactNamespaceBindings.has(binding) + : reactNamespaces.has(namespace); + const hookNamespace = binding + ? hookBindings.hookNamespaceBindings.has(binding) + : hookNamespaces.has(namespace); + if (reactNamespace && name && STATEFUL_HOOKS.has(name)) return new Set([name]); + if (hookNamespace && name && /^use[A-Z0-9]/u.test(name)) return new Set([name]); + } + return undefined; +} + +function addHookNames(aliases, key, names) { + if (!names) return false; + let known = aliases.get(key); + if (!known) { + known = new Set(); + aliases.set(key, known); + } + let changed = false; + for (const name of names) { + if (known.has(name)) continue; + known.add(name); + changed = true; + } + return changed; +} + +function staticString(node) { + if (node?.type === 'StringLiteral') return node.value; + if (node?.type === 'BinaryExpression' && node.operator === '+') { + const left = staticString(node.left); + const right = staticString(node.right); + return left !== undefined && right !== undefined ? `${left}${right}` : undefined; + } + if (node?.type === 'TemplateLiteral') { + let value = ''; + for (let index = 0; index < node.quasis.length; index += 1) { + value += node.quasis[index]?.value.cooked ?? node.quasis[index]?.value.raw ?? ''; + if (index < node.expressions.length) { + const expression = staticString(node.expressions[index]); + if (expression === undefined) return undefined; + value += expression; + } + } + return value; + } + return undefined; +} + +function environmentPath( + node, + windowAliases, + environmentAliases = new Map(), + parents = new WeakMap(), +) { + const expression = unwrapExpression(node); + if (expression?.type === 'Identifier') { + if (environmentAliases.has(expression.name)) return environmentAliases.get(expression.name); + if (windowAliases.has(expression.name)) return 'window'; + if (ENVIRONMENT_OBJECTS.has(expression.name) && !environmentIdentifierIsShadowed(expression, expression.name, parents)) { + return expression.name; + } + return undefined; + } + if (!isMemberExpression(expression)) return undefined; + const base = environmentPath( + expression.object, + windowAliases, + environmentAliases, + parents, + ); + const property = memberPropertyName(expression); + if (!base || !property) return undefined; + return `${base}.${property}`; +} + +function bridgePath(node, windowAliases, bridgeAliases = new Map()) { + const expression = unwrapExpression(node); + if (expression?.type === 'Identifier') return bridgeAliases.get(expression.name); + if (isWindowMaka(expression, windowAliases)) return 'window.maka'; + if (!isMemberExpression(expression)) return undefined; + const base = bridgePath(expression.object, windowAliases, bridgeAliases); + const property = memberPropertyName(expression); + if (!base || !property) return undefined; + return `${base}.${property}`; +} + +function sourceDependency(node) { + if ( + (node.type === 'ImportDeclaration' || + node.type === 'ExportAllDeclaration' || + node.type === 'ExportNamedDeclaration') && + staticString(node.source) !== undefined + ) { + return staticString(node.source); + } + if (node.type === 'ImportExpression' && staticString(node.source) !== undefined) { + return staticString(node.source); + } + if ( + node.type === 'TSImportEqualsDeclaration' && + node.moduleReference?.type === 'TSExternalModuleReference' && + staticString(node.moduleReference.expression) !== undefined + ) { + return staticString(node.moduleReference.expression); + } + if (node.type === 'TSImportType' && staticString(node.argument) !== undefined) { + return staticString(node.argument); + } + if ( + (node.type === 'CallExpression' || node.type === 'OptionalCallExpression') && + staticString(node.arguments?.[0]) !== undefined && + (node.callee?.type === 'Import' || + (node.callee?.type === 'Identifier' && node.callee.name === 'require')) + ) { + return staticString(node.arguments[0]); + } + return undefined; +} + +function bindingNames(pattern) { + if (!pattern) return []; + if (pattern.type === 'Identifier') return [pattern.name]; + if (pattern.type === 'AssignmentPattern') return bindingNames(pattern.left); + if (pattern.type === 'RestElement') return bindingNames(pattern.argument); + if (pattern.type === 'TSParameterProperty') return bindingNames(pattern.parameter); + if (pattern.type === 'ArrayPattern') return pattern.elements.flatMap((element) => bindingNames(element)); + if (pattern.type === 'ObjectPattern') { + return pattern.properties.flatMap((property) => + property.type === 'RestElement' ? bindingNames(property.argument) : bindingNames(property.value), + ); + } + return []; +} + +function buildParentMap(root) { + const parents = new WeakMap(); + function visit(node) { + for (const child of childNodes(node)) { + parents.set(child, node); + visit(child); + } + } + visit(root); + return parents; +} + +function statementBindings(statement) { + const declaration = + statement?.type === 'ExportNamedDeclaration' || statement?.type === 'ExportDefaultDeclaration' + ? statement.declaration + : statement; + if (!declaration) return []; + if (declaration.type === 'ImportDeclaration') return declaration.specifiers.map((specifier) => specifier.local.name); + if (declaration.type === 'VariableDeclaration') { + return declaration.declarations.flatMap((item) => bindingNames(item.id)); + } + if (declaration.type === 'FunctionDeclaration' || declaration.type === 'ClassDeclaration') { + return bindingNames(declaration.id); + } + return []; +} + +function bindingIdentifier(pattern, name) { + if (!pattern) return undefined; + if (pattern.type === 'Identifier') return pattern.name === name ? pattern : undefined; + if (pattern.type === 'AssignmentPattern') return bindingIdentifier(pattern.left, name); + if (pattern.type === 'RestElement') return bindingIdentifier(pattern.argument, name); + if (pattern.type === 'TSParameterProperty') return bindingIdentifier(pattern.parameter, name); + if (pattern.type === 'ArrayPattern') { + return pattern.elements + .map((element) => bindingIdentifier(element, name)) + .find(Boolean); + } + if (pattern.type === 'ObjectPattern') { + return pattern.properties + .map((property) => + property.type === 'RestElement' + ? bindingIdentifier(property.argument, name) + : bindingIdentifier(property.value, name), + ) + .find(Boolean); + } + return undefined; +} + +function statementBindingIdentifier(statement, name, includeVar = true) { + const declaration = + statement?.type === 'ExportNamedDeclaration' || statement?.type === 'ExportDefaultDeclaration' + ? statement.declaration + : statement; + if (!declaration) return undefined; + if (declaration.type === 'ImportDeclaration') { + return declaration.specifiers + .map((specifier) => specifier.local) + .find((local) => local.name === name); + } + if (declaration.type === 'VariableDeclaration') { + if (!includeVar && declaration.kind === 'var') return undefined; + return declaration.declarations + .map((item) => bindingIdentifier(item.id, name)) + .find(Boolean); + } + if (declaration.type === 'FunctionDeclaration' || declaration.type === 'ClassDeclaration') { + return bindingIdentifier(declaration.id, name); + } + return undefined; +} + +function hoistedVarBindingIdentifier(root, name) { + let found; + function visit(node, isRoot = false) { + if (!node || found) return; + if ( + !isRoot && + [ + 'ArrowFunctionExpression', + 'ClassDeclaration', + 'ClassExpression', + 'ClassMethod', + 'ClassPrivateMethod', + 'FunctionDeclaration', + 'FunctionExpression', + 'ObjectMethod', + 'StaticBlock', + ].includes(node.type) + ) { + return; + } + if (node.type === 'VariableDeclaration' && node.kind === 'var') { + found = node.declarations + .map((item) => bindingIdentifier(item.id, name)) + .find(Boolean); + if (found) return; + } + for (const child of childNodes(node)) visit(child); + } + visit(root, true); + return found; +} + +function lexicalBindingIdentifier(node, name, parents) { + let current = parents.get(node); + while (current) { + if ( + current.type === 'FunctionDeclaration' || + current.type === 'FunctionExpression' || + current.type === 'ArrowFunctionExpression' || + current.type === 'ObjectMethod' || + current.type === 'ClassMethod' || + current.type === 'ClassPrivateMethod' + ) { + const parameter = current.params + .map((item) => bindingIdentifier(item, name)) + .find(Boolean); + if (parameter) return parameter; + if (current.type !== 'ArrowFunctionExpression' && !current.type.endsWith('Method')) { + const functionName = bindingIdentifier(current.id, name); + if (functionName) return functionName; + } + const hoisted = hoistedVarBindingIdentifier(current.body, name); + if (hoisted) return hoisted; + } + if (current.type === 'CatchClause') { + const caught = bindingIdentifier(current.param, name); + if (caught) return caught; + } + if (current.type === 'Program' || current.type === 'BlockStatement') { + const declared = current.body + .map((statement) => statementBindingIdentifier(statement, name, false)) + .find(Boolean); + if (declared) return declared; + if (current.type === 'Program') { + const hoisted = hoistedVarBindingIdentifier(current, name); + if (hoisted) return hoisted; + } + } + if ( + current.type === 'ForStatement' && + current.init?.type === 'VariableDeclaration' && + current.init.kind !== 'var' + ) { + const declared = statementBindingIdentifier(current.init, name, false); + if (declared) return declared; + } + if ( + (current.type === 'ForInStatement' || current.type === 'ForOfStatement') && + current.left?.type === 'VariableDeclaration' && + current.left.kind !== 'var' + ) { + const declared = statementBindingIdentifier(current.left, name, false); + if (declared) return declared; + } + current = parents.get(current); + } + return undefined; +} + +function environmentIdentifierIsShadowed(node, name, parents) { + let current = parents.get(node); + while (current) { + if ( + current.type === 'FunctionDeclaration' || + current.type === 'FunctionExpression' || + current.type === 'ArrowFunctionExpression' || + current.type === 'ObjectMethod' || + current.type === 'ClassMethod' || + current.type === 'ClassPrivateMethod' + ) { + if ( + current.type !== 'ArrowFunctionExpression' && + !current.type.endsWith('Method') && + bindingNames(current.id).includes(name) + ) { + return true; + } + if (current.params.some((parameter) => bindingNames(parameter).includes(name))) return true; + } + if (current.type === 'CatchClause' && bindingNames(current.param).includes(name)) return true; + if (current.type === 'Program' || current.type === 'BlockStatement') { + if (current.body.some((statement) => statementBindings(statement).includes(name))) return true; + } + if ( + current.type === 'ForStatement' && + current.init?.type === 'VariableDeclaration' && + statementBindings(current.init).includes(name) + ) { + return true; + } + if ( + (current.type === 'ForInStatement' || current.type === 'ForOfStatement') && + current.left?.type === 'VariableDeclaration' && + statementBindings(current.left).includes(name) + ) { + return true; + } + current = parents.get(current); + } + return false; +} + +function collectAliases(program, parents) { + const hookAliases = new Map(); + const hookAliasBindings = new Map(); + const hookNamespaces = new Set(); + const hookNamespaceBindings = new WeakSet(); + const reactComponentBases = new Set(); + const reactNamespaces = new Set(); + const reactNamespaceBindings = new WeakSet(); + const hookBindings = { + hookAliasBindings, + hookNamespaceBindings, + reactNamespaceBindings, + }; + for (const statement of program.body) { + if (statement.type !== 'ImportDeclaration') continue; + const isReact = statement.source.value === 'react'; + for (const specifier of statement.specifiers) { + if (isReact && (specifier.type === 'ImportDefaultSpecifier' || specifier.type === 'ImportNamespaceSpecifier')) { + reactNamespaces.add(specifier.local.name); + reactNamespaceBindings.add(specifier.local); + } else if (isReact && specifier.type === 'ImportSpecifier') { + const imported = memberName(specifier.imported); + if (imported && STATEFUL_HOOKS.has(imported)) { + addHookNames(hookAliases, specifier.local.name, [imported]); + addHookNames(hookAliasBindings, specifier.local, [imported]); + } + if (imported === 'Component' || imported === 'PureComponent') reactComponentBases.add(specifier.local.name); + } else if (!isReact) { + const imported = specifier.type === 'ImportSpecifier' ? memberName(specifier.imported) : specifier.local.name; + if (imported && /^use[A-Z0-9]/u.test(imported)) { + addHookNames(hookAliases, specifier.local.name, [imported]); + addHookNames(hookAliasBindings, specifier.local, [imported]); + } + if (specifier.type === 'ImportNamespaceSpecifier') { + hookNamespaces.add(specifier.local.name); + hookNamespaceBindings.add(specifier.local); + } + } + } + } + + const windowAliases = new Set(['globalThis', 'self', 'window']); + const bridgeAliases = new Map(); + const environmentAliases = new Map(); + let changed = true; + while (changed) { + changed = false; + const addAlias = (aliases, name, path) => { + if (!path || aliases.has(name)) return; + aliases.set(name, path); + changed = true; + }; + function visit(node) { + if ( + node.type === 'ClassDeclaration' && + node.id?.type === 'Identifier' && + isReactComponentClass(node, reactComponentBases, reactNamespaces) && + !reactComponentBases.has(node.id.name) + ) { + reactComponentBases.add(node.id.name); + changed = true; + } + if (node.type === 'VariableDeclarator' && node.id?.type === 'Identifier') { + const declaredBinding = + lexicalBindingIdentifier(node.id, node.id.name, parents) ?? node.id; + const init = unwrapExpression(node.init); + const initBinding = + init?.type === 'Identifier' + ? lexicalBindingIdentifier(init, init.name, parents) + : undefined; + const initIsReactNamespace = + init?.type === 'Identifier' && + (initBinding + ? reactNamespaceBindings.has(initBinding) + : reactNamespaces.has(init.name)); + const initIsHookNamespace = + init?.type === 'Identifier' && + (initBinding + ? hookNamespaceBindings.has(initBinding) + : hookNamespaces.has(init.name)); + if (initIsReactNamespace && !reactNamespaceBindings.has(declaredBinding)) { + reactNamespaces.add(node.id.name); + reactNamespaceBindings.add(declaredBinding); + changed = true; + } + if (initIsHookNamespace && !hookNamespaceBindings.has(declaredBinding)) { + hookNamespaces.add(node.id.name); + hookNamespaceBindings.add(declaredBinding); + changed = true; + } + if (isWindowExpression(node.init, windowAliases) && !windowAliases.has(node.id.name)) { + windowAliases.add(node.id.name); + changed = true; + } + addAlias(bridgeAliases, node.id.name, bridgePath(node.init, windowAliases, bridgeAliases)); + const environment = environmentPath( + node.init, + windowAliases, + environmentAliases, + parents, + ); + if (environment && !environment.startsWith('window.maka')) { + addAlias(environmentAliases, node.id.name, environment); + } + if ( + node.init?.type === 'Identifier' && + ENVIRONMENT_CALLS.has(node.init.name) && + !environmentIdentifierIsShadowed(node.init, node.init.name, parents) + ) { + addAlias(environmentAliases, node.id.name, node.init.name); + } + const hooks = hookCallNames( + node.init, + hookAliases, + hookNamespaces, + reactNamespaces, + parents, + hookBindings, + ); + const nameChanged = addHookNames(hookAliases, node.id.name, hooks); + const bindingChanged = addHookNames( + hookAliasBindings, + declaredBinding, + hooks, + ); + if (nameChanged || bindingChanged) { + changed = true; + } + } + if (node.type === 'VariableDeclarator' && node.id?.type === 'ObjectPattern') { + const bridgeBase = bridgePath(node.init, windowAliases, bridgeAliases); + const environmentBase = environmentPath( + node.init, + windowAliases, + environmentAliases, + parents, + ); + for (const property of node.id.properties) { + if (property.type !== 'ObjectProperty' || property.computed || property.value?.type !== 'Identifier') continue; + const propertyName = memberName(property.key); + if (!propertyName) continue; + if (bridgeBase) addAlias(bridgeAliases, property.value.name, `${bridgeBase}.${propertyName}`); + if (isWindowExpression(node.init, windowAliases) && propertyName === 'maka') { + addAlias(bridgeAliases, property.value.name, 'window.maka'); + } + if (environmentBase && !environmentBase.startsWith('window.maka')) { + addAlias(environmentAliases, property.value.name, `${environmentBase}.${propertyName}`); + } + } + } + if (node.type === 'AssignmentExpression' && node.left?.type === 'Identifier') { + const right = unwrapExpression(node.right); + const rightBinding = + right?.type === 'Identifier' + ? lexicalBindingIdentifier(right, right.name, parents) + : undefined; + const leftBinding = lexicalBindingIdentifier(node.left, node.left.name, parents); + const rightIsReactNamespace = + right?.type === 'Identifier' && + (rightBinding + ? reactNamespaceBindings.has(rightBinding) + : reactNamespaces.has(right.name)); + const rightIsHookNamespace = + right?.type === 'Identifier' && + (rightBinding + ? hookNamespaceBindings.has(rightBinding) + : hookNamespaces.has(right.name)); + if ( + rightIsReactNamespace && + (leftBinding + ? !reactNamespaceBindings.has(leftBinding) + : !reactNamespaces.has(node.left.name)) + ) { + reactNamespaces.add(node.left.name); + if (leftBinding) reactNamespaceBindings.add(leftBinding); + changed = true; + } + if ( + rightIsHookNamespace && + (leftBinding + ? !hookNamespaceBindings.has(leftBinding) + : !hookNamespaces.has(node.left.name)) + ) { + hookNamespaces.add(node.left.name); + if (leftBinding) hookNamespaceBindings.add(leftBinding); + changed = true; + } + addAlias(bridgeAliases, node.left.name, bridgePath(node.right, windowAliases, bridgeAliases)); + const environment = environmentPath( + node.right, + windowAliases, + environmentAliases, + parents, + ); + if (environment && !environment.startsWith('window.maka')) { + addAlias(environmentAliases, node.left.name, environment); + } + if ( + node.right?.type === 'Identifier' && + ENVIRONMENT_CALLS.has(node.right.name) && + !environmentIdentifierIsShadowed(node.right, node.right.name, parents) + ) { + addAlias(environmentAliases, node.left.name, node.right.name); + } + const hooks = hookCallNames( + node.right, + hookAliases, + hookNamespaces, + reactNamespaces, + parents, + hookBindings, + ); + const nameChanged = addHookNames(hookAliases, node.left.name, hooks); + const bindingChanged = leftBinding + ? addHookNames(hookAliasBindings, leftBinding, hooks) + : false; + if (nameChanged || bindingChanged) { + changed = true; + } + } + if ( + node.type === 'VariableDeclarator' && + node.id?.type === 'ObjectPattern' && + unwrapExpression(node.init)?.type === 'Identifier' + ) { + const namespaceNode = unwrapExpression(node.init); + const namespace = namespaceNode.name; + const namespaceBinding = lexicalBindingIdentifier( + namespaceNode, + namespace, + parents, + ); + const reactNamespace = namespaceBinding + ? reactNamespaceBindings.has(namespaceBinding) + : reactNamespaces.has(namespace); + const hookNamespace = namespaceBinding + ? hookNamespaceBindings.has(namespaceBinding) + : hookNamespaces.has(namespace); + if (reactNamespace || hookNamespace) { + for (const property of node.id.properties) { + if (property.type !== 'ObjectProperty' || property.computed) continue; + const imported = memberName(property.key); + const recognized = reactNamespace + ? imported && STATEFUL_HOOKS.has(imported) + : imported && /^use[A-Z0-9]/u.test(imported); + const local = + property.value?.type === 'Identifier' + ? property.value + : property.value?.type === 'AssignmentPattern' && + property.value.left?.type === 'Identifier' + ? property.value.left + : undefined; + if (recognized && local) { + const localBinding = + lexicalBindingIdentifier(local, local.name, parents) ?? local; + const nameChanged = addHookNames(hookAliases, local.name, [imported]); + const bindingChanged = addHookNames( + hookAliasBindings, + localBinding, + [imported], + ); + if (nameChanged || bindingChanged) { + changed = true; + } + } + } + } + } + for (const child of childNodes(node)) visit(child); + } + visit(program); + } + return { + bridgeAliases, + environmentAliases, + hookAliases, + hookBindings, + hookNamespaces, + reactComponentBases, + reactNamespaces, + windowAliases, + }; +} + +function isReactComponentClass(node, reactComponentBases, reactNamespaces) { + if (node?.type !== 'ClassDeclaration' && node?.type !== 'ClassExpression') return false; + const superClass = unwrapExpression(node.superClass); + if (superClass?.type === 'Identifier') return reactComponentBases.has(superClass.name); + return ( + isMemberExpression(superClass) && + unwrapExpression(superClass.object)?.type === 'Identifier' && + reactNamespaces.has(unwrapExpression(superClass.object).name) && + ['Component', 'PureComponent'].includes(memberPropertyName(superClass)) + ); +} + +function enclosingClass(node, parents) { + let current = parents.get(node); + while (current) { + if (current.type === 'ClassDeclaration' || current.type === 'ClassExpression') return current; + current = parents.get(current); + } + return undefined; +} + +export function analyzeRendererSource(source, file = 'fixture.ts') { + const typedSource = /\.(?:(?:c|m)?ts|tsx)$/u.test(file); + const jsxSource = /\.(?:jsx|tsx)$/u.test(file); + const ast = parse(source, { + createImportExpressions: true, + errorRecovery: false, + plugins: PARSER_PLUGINS.filter((plugin) => (plugin === 'typescript' ? typedSource : plugin === 'jsx' ? jsxSource : true)), + sourceFilename: file, + sourceType: 'module', + tokens: true, + }); + const parents = buildParentMap(ast.program); + const { + bridgeAliases, + environmentAliases, + hookAliases, + hookBindings, + hookNamespaces, + reactComponentBases, + reactNamespaces, + windowAliases, + } = collectAliases(ast.program, parents); + const bridgePaths = {}; + const environmentCapabilities = {}; + const hookCalls = {}; + const lifecycleMethods = {}; + const actionFactories = []; + const dependencies = []; + const dependencyPaths = {}; + let importDeclarations = 0; + let importSpecifiers = 0; + let unresolvedDependencies = 0; + + function recordBridgePath(path) { + bridgePaths[path] = (bridgePaths[path] ?? 0) + 1; + } + + function recordEnvironmentCapability(path) { + environmentCapabilities[path] = (environmentCapabilities[path] ?? 0) + 1; + } + + function visit(node, parent) { + if (node.type === 'ImportDeclaration') { + importDeclarations += 1; + importSpecifiers += node.specifiers.length; + } + if (node.type === 'TSImportEqualsDeclaration') { + importDeclarations += 1; + importSpecifiers += 1; + } + const dependency = sourceDependency(node); + if (dependency !== undefined) { + dependencies.push(dependency); + dependencyPaths[dependency] = (dependencyPaths[dependency] ?? 0) + 1; + } + if ( + (node.type === 'ImportExpression' && staticString(node.source) === undefined) || + ((node.type === 'CallExpression' || node.type === 'OptionalCallExpression') && + (node.callee?.type === 'Import' || + (node.callee?.type === 'Identifier' && node.callee.name === 'require')) && + staticString(node.arguments?.[0]) === undefined) || + ((node.type === 'CallExpression' || node.type === 'OptionalCallExpression') && + isMemberExpression(node.callee) && + unwrapExpression(node.callee.object)?.type === 'MetaProperty' && + unwrapExpression(node.callee.object).meta?.name === 'import' && + unwrapExpression(node.callee.object).property?.name === 'meta' && + ['glob', 'globEager'].includes(memberPropertyName(node.callee))) + ) { + unresolvedDependencies += 1; + } + + const directEnvironmentPath = environmentPath( + node, + windowAliases, + environmentAliases, + parents, + ); + if ( + directEnvironmentPath && + !directEnvironmentPath.startsWith('window.maka') && + !(isMemberExpression(parent) && unwrapExpression(parent.object) === node) + ) { + recordEnvironmentCapability(directEnvironmentPath); + } + + if (isMemberExpression(node) && memberPropertyName(node) === undefined) { + const dynamicBridgeBase = bridgePath(node.object, windowAliases, bridgeAliases); + if (dynamicBridgeBase) recordBridgePath(`${dynamicBridgeBase}.*`); + const dynamicEnvironmentBase = environmentPath( + node.object, + windowAliases, + environmentAliases, + parents, + ); + if (dynamicEnvironmentBase && !dynamicEnvironmentBase.startsWith('window.maka')) { + recordEnvironmentCapability(`${dynamicEnvironmentBase}.*`); + } + } + + const directBridgePath = bridgePath(node, windowAliases, bridgeAliases); + if ( + directBridgePath && + !(isMemberExpression(parent) && unwrapExpression(parent.object) === node) && + !(node.type === 'Identifier' && parent?.type === 'VariableDeclarator' && parent.id === node) + ) { + recordBridgePath(directBridgePath); + } + if ( + node.type === 'VariableDeclarator' && + node.id?.type === 'ObjectPattern' && + isWindowExpression(node.init, windowAliases) + ) { + for (const property of node.id.properties) { + if (property.type === 'ObjectProperty' && !property.computed && memberName(property.key) === 'maka') { + recordBridgePath('window.maka'); + } + } + } + + if (node.type === 'CallExpression' || node.type === 'OptionalCallExpression') { + const names = hookCallNames( + node.callee, + hookAliases, + hookNamespaces, + reactNamespaces, + parents, + hookBindings, + ); + for (const name of names ?? []) { + hookCalls[name] = (hookCalls[name] ?? 0) + 1; + } + const callee = unwrapExpression(node.callee); + if ( + callee?.type === 'Identifier' && + ENVIRONMENT_CALLS.has(callee.name) && + !environmentIdentifierIsShadowed(callee, callee.name, parents) + ) { + recordEnvironmentCapability(callee.name); + } + } + if (node.type === 'NewExpression') { + const callee = unwrapExpression(node.callee); + if ( + callee?.type === 'Identifier' && + ENVIRONMENT_CALLS.has(callee.name) && + !environmentIdentifierIsShadowed(callee, callee.name, parents) + ) { + recordEnvironmentCapability(callee.name); + } + } + const ownerClass = enclosingClass(node, parents); + const reactComponentOwner = isReactComponentClass(ownerClass, reactComponentBases, reactNamespaces); + if (reactComponentOwner && (node.type === 'ClassMethod' || node.type === 'ClassPrivateMethod')) { + const name = memberName(node.key); + if (name && REACT_LIFECYCLE_METHODS.has(name)) { + lifecycleMethods[name] = (lifecycleMethods[name] ?? 0) + 1; + } + } + if (reactComponentOwner && ['ClassProperty', 'ClassPrivateProperty', 'PropertyDefinition'].includes(node.type)) { + const name = memberName(node.key); + if (name === 'state' || (name && REACT_LIFECYCLE_METHODS.has(name))) { + lifecycleMethods[name] = (lifecycleMethods[name] ?? 0) + 1; + } + } + if ( + reactComponentOwner && + isMemberExpression(node) && + unwrapExpression(node.object)?.type === 'ThisExpression' && + ['setState', 'state'].includes(memberPropertyName(node)) + ) { + const name = memberPropertyName(node); + lifecycleMethods[name] = (lifecycleMethods[name] ?? 0) + 1; + } + + if ( + (node.type === 'FunctionDeclaration' || node.type === 'VariableDeclarator') && + node.id?.type === 'Identifier' && + node.id.name.startsWith('createAppShell') + ) { + actionFactories.push(node.id.name); + } + + for (const child of childNodes(node)) { + parents.set(child, node); + visit(child, node); + } + } + + visit(ast.program, undefined); + return { + actionFactories: actionFactories.sort(), + bridgePaths: sortedObject(bridgePaths), + dependencies, + dependencyPaths: sortedObject(dependencyPaths), + environmentCapabilities: sortedObject(environmentCapabilities), + hookCalls: sortedObject(hookCalls), + importDeclarations, + importSpecifiers, + lifecycleMethods: sortedObject(lifecycleMethods), + nonTriviaTokens: ast.tokens.length, + unresolvedDependencies, + }; +} + +function sourceFiles(root, { includeDeclarations = false } = {}) { + if (!existsSync(root)) return []; + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const path = join(root, entry.name); + if (entry.isDirectory()) return sourceFiles(path, { includeDeclarations }); + return SOURCE_FILE.test(entry.name) && (includeDeclarations || !DECLARATION_FILE.test(entry.name)) ? [path] : []; + }); +} + +function zoneFor(path) { + const normalized = normalizePath(path).replace(SOURCE_EXTENSION, ''); + if (normalized.startsWith('src/main/')) return { kind: 'main' }; + if (normalized.startsWith('src/preload/')) return { kind: 'preload' }; + if (!normalized.startsWith('src/renderer/')) return { kind: 'external' }; + const feature = normalized.match(/^src\/renderer\/features\/([^/]+)(?:\/(.*))?$/u); + if (feature) return { kind: 'feature', name: feature[1], subpath: feature[2] ?? '' }; + for (const kind of ['application', 'bootstrap', 'composition', 'shell']) { + const prefix = `src/renderer/${kind}/`; + if (normalized.startsWith(prefix)) return { kind, subpath: normalized.slice(prefix.length) }; + } + if (normalized.startsWith('src/renderer/platform/desktop/')) return { kind: 'platform' }; + return { kind: 'legacy' }; +} + +function isRootClosureDebtSource(path) { + return ['external', 'legacy', 'main', 'preload'].includes(zoneFor(path).kind); +} + +function resolveDependency(desktopRoot, importer, dependency) { + let target; + if (dependency.startsWith('.')) target = resolve(dirname(importer), dependency); + else if (dependency.startsWith(DESKTOP_SELF_PREFIX)) target = resolve(desktopRoot, dependency.slice(DESKTOP_SELF_PREFIX.length)); + else return undefined; + const normalized = normalizePath(target); + return normalized.replace(/\.(?:c|m)?(?:js|ts)x?$/u, ''); +} + +function sourceFileIndex(desktopRoot) { + const index = new Map(); + const paths = sourceFiles(resolve(desktopRoot, 'src'), { includeDeclarations: true }).sort(); + for (const path of paths.filter((path) => !DECLARATION_FILE.test(path))) { + const key = normalizePath(path).replace(SOURCE_EXTENSION, ''); + if (!index.has(key)) index.set(key, path); + } + for (const path of paths.filter((path) => DECLARATION_FILE.test(path))) { + const key = normalizePath(path).replace(SOURCE_EXTENSION, ''); + index.set(key, path); + const runtimeAlias = key.replace(/\.d$/u, ''); + if (!index.has(runtimeAlias)) index.set(runtimeAlias, path); + } + return index; +} + +function resolveSourceFile(desktopRoot, importer, dependency, index) { + const target = resolveDependency(desktopRoot, importer, dependency); + if (!target) return undefined; + return index.get(target) ?? index.get(`${target}/index`); +} + +function isSourceLikeDependency(dependency) { + const withoutQuery = dependency.split(/[?#]/u, 1)[0]; + const lastSegment = withoutQuery.split('/').at(-1) ?? ''; + return !lastSegment.includes('.') || SOURCE_EXTENSION.test(lastSegment); +} + +function isAllowedLegacyGrowthPath(config, path) { + return config.legacyGrowthDirectories.some((directory) => path.startsWith(`${directory}/`)); +} + +function capabilityDebtMetrics(analysis) { + return { + bridgePaths: analysis.bridgePaths, + environmentCapabilities: analysis.environmentCapabilities, + hookCalls: analysis.hookCalls, + lifecycleMethods: analysis.lifecycleMethods, + unresolvedDependencies: analysis.unresolvedDependencies, + actionFactories: analysis.actionFactories, + dependencyPaths: analysis.dependencyPaths, + }; +} + +function debtMetrics(analysis) { + return { + importDeclarations: analysis.importDeclarations, + ...capabilityDebtMetrics(analysis), + importSpecifiers: analysis.importSpecifiers, + nonTriviaTokens: analysis.nonTriviaTokens, + }; +} + +function collectRootDependencyClosure(desktopRoot, rootPaths, violations = [], label = 'renderer root') { + const index = sourceFileIndex(desktopRoot); + const sourceRoot = normalizePath(resolve(desktopRoot, 'src')); + const roots = new Set(rootPaths); + const queue = rootPaths.map((path) => resolve(desktopRoot, path)); + const seen = new Set(); + const closure = new Set(); + + while (queue.length > 0) { + const file = queue.shift(); + const fileKey = normalizePath(file).replace(SOURCE_EXTENSION, ''); + if (seen.has(fileKey) || !existsSync(file)) continue; + seen.add(fileKey); + const fileRelative = normalizePath(relative(desktopRoot, file)); + let analysis; + try { + analysis = analyzeRendererSource(readFileSync(file, 'utf8'), fileRelative); + } catch (error) { + violations.push(`${fileRelative}: could not parse ${label} dependency closure source: ${error instanceof Error ? error.message : String(error)}`); + continue; + } + if (analysis.unresolvedDependencies > 0) { + violations.push(`${fileRelative}: ${label} closure contains a non-static import, require, or import.meta glob`); + } + for (const dependency of analysis.dependencies) { + const unresolvedTarget = resolveDependency(desktopRoot, file, dependency); + if (!unresolvedTarget) continue; + const normalizedTarget = normalizePath(unresolvedTarget); + if (normalizedTarget !== sourceRoot && !normalizedTarget.startsWith(`${sourceRoot}/`)) continue; + const target = resolveSourceFile(desktopRoot, file, dependency, index); + if (!target) { + if (isSourceLikeDependency(dependency)) { + violations.push(`${fileRelative}: ${label} closure dependency does not resolve to Desktop source: ${dependency}`); + } + continue; + } + const targetRelative = normalizePath(relative(desktopRoot, target)); + if (!seen.has(normalizePath(target).replace(SOURCE_EXTENSION, ''))) queue.push(target); + if (isRootClosureDebtSource(targetRelative) && !DECLARATION_FILE.test(target) && !roots.has(targetRelative)) { + closure.add(targetRelative); + } + } + } + return [...closure].sort(); +} + +function featureForAbsolutePath(desktopRoot, absolutePath) { + const rendererFeatures = normalizePath(resolve(desktopRoot, 'src/renderer/features')); + if (absolutePath !== rendererFeatures && !absolutePath.startsWith(`${rendererFeatures}/`)) return undefined; + const rest = absolutePath.slice(rendererFeatures.length).replace(/^\//u, ''); + const [name, ...segments] = rest.split('/'); + return { name, subpath: segments.join('/') }; +} + +function isTestConsumer(path) { + const normalized = normalizePath(path); + return ( + normalized.includes('/__tests__/') || + normalized.includes('/e2e/') || + normalized.includes('/stories/') || + /\.(?:spec|stories|test)\.[^/]+$/u.test(normalized) + ); +} + +function isPublicFeaturePath(subpath) { + return subpath === '' || subpath === 'index'; +} + +function isFeatureTestSupportEntry(subpath) { + return subpath === 'stories' || subpath === 'testing'; +} + +function isApplicationContractPath(path) { + return path === 'src/renderer/application/contracts' || path.startsWith('src/renderer/application/contracts/'); +} + +function isPublicApplicationPath(path) { + if (isApplicationContractPath(path)) return true; + return /^src\/renderer\/application\/[^/]+(?:\/index)?$/u.test(path); +} + +function isForbiddenEnvironmentDependency(dependency) { + return FORBIDDEN_ENVIRONMENT_IMPORTS.has(dependency) || dependency.startsWith('electron/'); +} + +function validateDependencies({ + allowedLegacyFeatureImports, + allowedLegacyPlatformImports, + analysis, + desktopRoot, + file, + fileRelative, + observedLegacyFeatureImports, + observedLegacyPlatformImports, + violations, +}) { + const sourceZone = zoneFor(fileRelative); + for (const dependency of analysis.dependencies) { + if ( + ['application', 'bootstrap', 'composition', 'feature', 'platform', 'shell'].includes(sourceZone.kind) && + !isTestConsumer(file) && + isForbiddenEnvironmentDependency(dependency) + ) { + violations.push(`${fileRelative}: ${sourceZone.kind} code imports forbidden environment module: ${dependency}`); + continue; + } + const target = resolveDependency(desktopRoot, file, dependency); + if (!target) continue; + const targetRelative = normalizePath(relative(desktopRoot, target)); + const targetZone = zoneFor(targetRelative); + const targetFeature = featureForAbsolutePath(desktopRoot, target); + + if (targetFeature) { + if (sourceZone.kind === 'feature' && sourceZone.name !== targetFeature.name) { + violations.push(`${fileRelative}: feature ${sourceZone.name} imports feature ${targetFeature.name}: ${dependency}`); + continue; + } + if ( + sourceZone.kind === 'feature' && + sourceZone.name === targetFeature.name && + isFeatureTestSupportEntry(targetFeature.subpath) && + !isFeatureTestSupportEntry(sourceZone.subpath) && + !isTestConsumer(file) + ) { + violations.push( + `${fileRelative}: feature production code cannot import its ${targetFeature.subpath} entry: ${dependency}`, + ); + } + if (sourceZone.kind !== 'feature') { + const supportEntry = isFeatureTestSupportEntry(targetFeature.subpath); + if (!isPublicFeaturePath(targetFeature.subpath) && !(supportEntry && isTestConsumer(file))) { + violations.push( + `${fileRelative}: feature imports must use index${isTestConsumer(file) ? ', testing, or stories' : ''}: ${dependency}`, + ); + } + if (supportEntry && !isTestConsumer(file)) { + violations.push( + `${fileRelative}: production code imports a feature ${targetFeature.subpath} entry: ${dependency}`, + ); + } + } + } + + if (sourceZone.kind === 'feature') { + if (targetZone.kind === 'legacy') { + const edge = `${fileRelative} -> ${targetRelative}`; + observedLegacyFeatureImports.add(edge); + if (!allowedLegacyFeatureImports.has(edge)) { + violations.push(`${fileRelative}: feature imports unbudgeted renderer legacy code: ${dependency}`); + } + } + if (targetZone.kind === 'application' && !isApplicationContractPath(targetRelative)) { + violations.push(`${fileRelative}: feature imports application implementation instead of a contract: ${dependency}`); + } + if (['bootstrap', 'composition', 'main', 'platform', 'preload', 'shell'].includes(targetZone.kind)) { + violations.push(`${fileRelative}: feature imports a forbidden Desktop/shell module: ${dependency}`); + } + } + + if (sourceZone.kind === 'shell') { + const allowedApplicationContract = isApplicationContractPath(targetRelative); + if ( + targetZone.kind === 'feature' || + targetZone.kind === 'platform' || + targetZone.kind === 'legacy' || + targetZone.kind === 'bootstrap' || + targetZone.kind === 'composition' || + (targetZone.kind === 'application' && !allowedApplicationContract) || + targetZone.kind === 'main' || + targetZone.kind === 'preload' + ) { + violations.push(`${fileRelative}: shell imports forbidden implementation module: ${dependency}`); + } + } + + if (sourceZone.kind === 'application') { + if ( + sourceZone.subpath === 'contracts' || + sourceZone.subpath.startsWith('contracts/') + ) { + if (targetZone.kind === 'application' && !isApplicationContractPath(targetRelative)) { + violations.push(`${fileRelative}: application contracts import application implementation: ${dependency}`); + } + } + if ( + targetZone.kind === 'feature' || + targetZone.kind === 'platform' || + targetZone.kind === 'shell' || + targetZone.kind === 'bootstrap' || + targetZone.kind === 'composition' || + targetZone.kind === 'legacy' || + targetZone.kind === 'main' || + targetZone.kind === 'preload' + ) { + violations.push(`${fileRelative}: application authority imports an outer implementation: ${dependency}`); + } + } + + if (sourceZone.kind === 'composition') { + if (targetZone.kind === 'application' && !isPublicApplicationPath(targetRelative)) { + violations.push(`${fileRelative}: composition imports application implementation instead of a public entry: ${dependency}`); + } + const allowedLegacyAdapter = + fileRelative === LEGACY_APP_SHELL_ADAPTER && targetRelative === LEGACY_APP_SHELL_MODULE; + if ( + targetZone.kind === 'bootstrap' || + targetZone.kind === 'main' || + targetZone.kind === 'preload' || + (targetZone.kind === 'legacy' && !allowedLegacyAdapter) + ) { + violations.push(`${fileRelative}: composition imports a forbidden implementation module: ${dependency}`); + } + } + + if (sourceZone.kind === 'bootstrap') { + if (!['bootstrap', 'composition', 'external', 'platform'].includes(targetZone.kind)) { + violations.push(`${fileRelative}: bootstrap imports outside composition/platform: ${dependency}`); + } + } + + if (sourceZone.kind === 'platform') { + if (targetZone.kind === 'application' && !isPublicApplicationPath(targetRelative)) { + violations.push(`${fileRelative}: Desktop adapter imports application implementation instead of a public entry: ${dependency}`); + } + if (targetZone.kind === 'legacy') { + const edge = `${fileRelative} -> ${targetRelative}`; + observedLegacyPlatformImports.add(edge); + if (!allowedLegacyPlatformImports.has(edge)) { + violations.push(`${fileRelative}: Desktop adapter imports unbudgeted renderer legacy code: ${dependency}`); + } + } + if (['bootstrap', 'composition', 'main', 'shell'].includes(targetZone.kind)) { + violations.push(`${fileRelative}: Desktop adapter imports an outer or legacy implementation: ${dependency}`); + } + } + } +} + +function validateStrictZone({ fileRelative, analysis, violations }) { + const zone = zoneFor(fileRelative); + if (['application', 'bootstrap', 'composition', 'feature', 'shell'].includes(zone.kind)) { + if (Object.keys(analysis.bridgePaths).length > 0) { + violations.push(`${fileRelative}: ${zone.kind} code accesses the Desktop global bridge`); + } + if (Object.keys(analysis.environmentCapabilities).some((path) => path.endsWith('.*'))) { + violations.push(`${fileRelative}: ${zone.kind} code contains non-static global environment access`); + } + } + if (['bootstrap', 'composition', 'platform', 'shell'].includes(zone.kind)) { + if (Object.keys(analysis.hookCalls).length > 0) { + violations.push(`${fileRelative}: ${zone.kind} code owns stateful React hooks`); + } + if (Object.keys(analysis.lifecycleMethods).length > 0) { + violations.push(`${fileRelative}: ${zone.kind} code owns React class lifecycle methods`); + } + const environmentPaths = Object.keys(analysis.environmentCapabilities); + const bootstrapEnvironmentIsOnlyMountLookup = + zone.kind === 'bootstrap' && + environmentPaths.every((path) => ['document.getElementById', 'document.querySelector'].includes(path)); + if (zone.kind !== 'platform' && environmentPaths.length > 0 && !bootstrapEnvironmentIsOnlyMountLookup) { + violations.push(`${fileRelative}: ${zone.kind} code directly accesses browser environment capabilities`); + } + } + if (zone.kind === 'platform' && (/\.(?:(?:c|m)?(?:jsx|tsx))$/u.test(fileRelative) || analysis.dependencies.includes('react'))) { + violations.push(`${fileRelative}: Desktop adapters cannot own React UI`); + } + if (['application', 'bootstrap', 'composition', 'feature', 'platform', 'shell'].includes(zone.kind) && analysis.unresolvedDependencies > 0) { + violations.push(`${fileRelative}: ${zone.kind} code contains a non-static import or require`); + } +} + +function validateMetric(path, metric, actual, expected, violations) { + if (metric === 'actionFactories') { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + violations.push(`${path}: ${metric} changed; expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`); + } + return; + } + if (typeof expected === 'number') { + if (actual !== expected) violations.push(`${path}: ${metric} changed; expected ${expected}, received ${actual}`); + return; + } + if (!sameJson(actual, expected)) { + violations.push(`${path}: ${metric} changed; expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`); + } +} + +function validateDebtFile(desktopRoot, path, expected, violations, metrics = ROOT_DEBT_METRICS) { + const absolutePath = resolve(desktopRoot, path); + if (!existsSync(absolutePath)) { + violations.push(`${path}: debt ledger entry points to a missing file`); + return; + } + const analysis = analyzeRendererSource(readFileSync(absolutePath, 'utf8'), path); + for (const metric of metrics) { + validateMetric(path, metric, analysis[metric], expected[metric], violations); + } +} + +function validateLegacyLedger(desktopRoot, config, violations) { + const rendererRoot = resolve(desktopRoot, 'src/renderer'); + const actualLegacyFiles = sourceFiles(rendererRoot) + .map((path) => normalizePath(relative(desktopRoot, path))) + .filter((path) => zoneFor(path).kind === 'legacy') + .sort(); + const expectedLegacyFiles = [...config.legacyRendererFiles].sort(); + if (JSON.stringify(actualLegacyFiles) !== JSON.stringify(expectedLegacyFiles)) { + violations.push( + `unclassified legacy renderer file set changed; expected ${JSON.stringify(expectedLegacyFiles)}, received ${JSON.stringify(actualLegacyFiles)}`, + ); + } + + const actualFiles = readdirSync(rendererRoot, { withFileTypes: true }) + .filter((entry) => entry.isFile() && LEGACY_APP_SHELL_FILE.test(entry.name)) + .map((entry) => `src/renderer/${entry.name}`) + .sort(); + const expectedFiles = Object.keys(config.legacyAppShell.files).sort(); + if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { + violations.push(`legacy AppShell file set changed; expected ${JSON.stringify(expectedFiles)}, received ${JSON.stringify(actualFiles)}`); + } + + for (const [path, expected] of Object.entries(config.legacyAppShell.files)) { + validateDebtFile(desktopRoot, path, expected, violations); + } + const actualClosure = collectRootDependencyClosure(desktopRoot, expectedFiles, violations, 'AppShell'); + const expectedClosure = Object.keys(config.legacyAppShell.closure).sort(); + if (JSON.stringify(actualClosure) !== JSON.stringify(expectedClosure)) { + violations.push( + `legacy AppShell transitive renderer closure changed; expected ${JSON.stringify(expectedClosure)}, received ${JSON.stringify(actualClosure)}`, + ); + } + for (const [path, expected] of Object.entries(config.legacyAppShell.closure)) { + if (config.legacyAppShell.files[path]) { + violations.push(`${path}: AppShell root file must not also appear in its transitive closure`); + continue; + } + if (!isRootClosureDebtSource(path) || DECLARATION_FILE.test(path)) { + violations.push(`${path}: AppShell closure debt must point to a non-owner Desktop source`); + } + validateDebtFile(desktopRoot, path, expected, violations, CAPABILITY_DEBT_METRICS); + } + for (const [path, expected] of Object.entries(config.rootDebt)) { + validateDebtFile(desktopRoot, path, expected, violations); + } + const appShellDebtPaths = new Set([...expectedFiles, ...expectedClosure]); + const rootDebtPaths = Object.keys(config.rootDebt).sort(); + const actualRootClosure = collectRootDependencyClosure(desktopRoot, rootDebtPaths, violations, 'renderer root') + .filter((path) => !appShellDebtPaths.has(path)); + const expectedRootClosure = Object.keys(config.rootDebtClosure).sort(); + if (JSON.stringify(actualRootClosure) !== JSON.stringify(expectedRootClosure)) { + violations.push( + `renderer root transitive legacy closure changed; expected ${JSON.stringify(expectedRootClosure)}, received ${JSON.stringify(actualRootClosure)}`, + ); + } + for (const [path, expected] of Object.entries(config.rootDebtClosure)) { + if (config.rootDebt[path] || appShellDebtPaths.has(path)) { + violations.push(`${path}: renderer root closure debt overlaps another root debt section`); + continue; + } + if (!isRootClosureDebtSource(path) || DECLARATION_FILE.test(path)) { + violations.push(`${path}: renderer root closure debt must point to a non-owner Desktop source`); + } + validateDebtFile(desktopRoot, path, expected, violations, CAPABILITY_DEBT_METRICS); + } + + const ownedPaths = new Map(); + for (const owner of config.ownership) { + if (!owner.capability || !owner.targetZone || !Array.isArray(owner.legacyPaths)) { + violations.push('ownership entries require capability, targetZone, and legacyPaths'); + continue; + } + for (const path of owner.legacyPaths) { + if (ownedPaths.has(path)) { + violations.push(`${path}: assigned to both ${ownedPaths.get(path)} and ${owner.capability}`); + } + ownedPaths.set(path, owner.capability); + } + } + const expectedOwnedPaths = [...expectedFiles, ...Object.keys(config.rootDebt)]; + for (const path of expectedOwnedPaths) { + if (!ownedPaths.has(path)) violations.push(`${path}: missing target owner in the architecture ledger`); + } + for (const path of ownedPaths.keys()) { + if (!config.legacyAppShell.files[path] && !config.rootDebt[path]) { + violations.push(`${path}: ownership ledger path is not recorded renderer root debt`); + } + } +} + +function htmlAttribute(attributes, name) { + const match = attributes.match( + new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'iu'), + ); + return match?.[1] ?? match?.[2]; +} + +function parseContractSource(desktopRoot, path, violations) { + const absolutePath = resolve(desktopRoot, path); + if (!existsSync(absolutePath)) { + violations.push(`${path}: renderer entry contract source is missing`); + return undefined; + } + try { + return parse(readFileSync(absolutePath, 'utf8'), { + createImportExpressions: true, + errorRecovery: false, + plugins: PARSER_PLUGINS.filter((plugin) => plugin !== 'jsx'), + sourceFilename: path, + sourceType: 'module', + }).program; + } catch (error) { + violations.push( + `${path}: could not parse renderer entry contract: ${error instanceof Error ? error.message : String(error)}`, + ); + return undefined; + } +} + +function nodesIn(program) { + if (!program) return []; + const nodes = []; + function visit(node) { + nodes.push(node); + for (const child of childNodes(node)) visit(child); + } + visit(program); + return nodes; +} + +function objectPropertyValues(object, name) { + if (object?.type !== 'ObjectExpression') return []; + return object.properties + .filter( + (property) => + property.type === 'ObjectProperty' && + !property.computed && + memberName(property.key) === name, + ) + .map((property) => unwrapExpression(property.value)); +} + +function isIdentifier(node, name) { + return unwrapExpression(node)?.type === 'Identifier' && unwrapExpression(node).name === name; +} + +function hasNamedImport(program, source, importedName, localName = importedName) { + return program.body.some( + (node) => + node.type === 'ImportDeclaration' && + staticString(node.source) === source && + node.specifiers.some( + (specifier) => + specifier.type === 'ImportSpecifier' && + memberName(specifier.imported) === importedName && + specifier.local.name === localName, + ), + ); +} + +function hasDefaultImport(program, source, localName) { + return program.body.some( + (node) => + node.type === 'ImportDeclaration' && + staticString(node.source) === source && + node.specifiers.some( + (specifier) => + specifier.type === 'ImportDefaultSpecifier' && specifier.local.name === localName, + ), + ); +} + +function isNamedMember(node, objectName, propertyName) { + const expression = unwrapExpression(node); + return ( + isMemberExpression(expression) && + isIdentifier(expression.object, objectName) && + memberPropertyName(expression) === propertyName + ); +} + +function isMemberCall(node, objectName, methodName, argumentObject, argumentProperty) { + const expression = unwrapExpression(node); + return ( + expression?.type === 'CallExpression' && + isMemberExpression(expression.callee) && + isIdentifier(expression.callee.object, objectName) && + memberPropertyName(expression.callee) === methodName && + expression.arguments.length === 1 && + isNamedMember(expression.arguments[0], argumentObject, argumentProperty) + ); +} + +function isRendererEntryPathInitializer(node) { + const expression = unwrapExpression(node); + return ( + expression?.type === 'CallExpression' && + isIdentifier(expression.callee, 'join') && + expression.arguments.length === 5 && + isIdentifier(expression.arguments[0], 'mainModuleDirectory') && + JSON.stringify(expression.arguments.slice(1).map(staticString)) === + JSON.stringify(['..', '..', 'dist-renderer', 'index.html']) + ); +} + +function isRendererEntryUrlInitializer(node) { + const expression = unwrapExpression(node); + if (expression?.type !== 'LogicalExpression' || expression.operator !== '??') return false; + const left = unwrapExpression(expression.left); + const right = unwrapExpression(expression.right); + const viteDevServerUrl = isIdentifier(left, 'viteDevServerUrl'); + const fileUrl = + isMemberExpression(right) && + memberPropertyName(right) === 'href' && + unwrapExpression(right.object)?.type === 'CallExpression' && + isIdentifier(unwrapExpression(right.object).callee, 'pathToFileURL') && + unwrapExpression(right.object).arguments.length === 1 && + isIdentifier(unwrapExpression(right.object).arguments[0], 'rendererEntryPath'); + return viteDevServerUrl && fileUrl; +} + +function isViteDevServerFlag(node) { + const expression = unwrapExpression(node); + const inner = expression?.type === 'UnaryExpression' && expression.operator === '!' ? unwrapExpression(expression.argument) : undefined; + return ( + inner?.type === 'UnaryExpression' && + inner.operator === '!' && + isIdentifier(inner.argument, 'viteDevServerUrl') + ); +} + +function frozenRendererEntryObject(node) { + const expression = unwrapExpression(node); + if ( + expression?.type !== 'CallExpression' || + expression.arguments.length !== 1 || + !isMemberExpression(expression.callee) || + expression.callee.computed || + !isIdentifier(expression.callee.object, 'Object') || + memberPropertyName(expression.callee) !== 'freeze' + ) { + return undefined; + } + const object = unwrapExpression(expression.arguments[0]); + return object?.type === 'ObjectExpression' ? object : undefined; +} + +function expressionRootIdentifier(node) { + let expression = unwrapExpression(node); + while (isMemberExpression(expression)) expression = unwrapExpression(expression.object); + return expression?.type === 'Identifier' ? expression.name : undefined; +} + +function mutatesIdentifier(node, name) { + if (node.type === 'AssignmentExpression') return expressionRootIdentifier(node.left) === name; + if (node.type === 'UpdateExpression') return expressionRootIdentifier(node.argument) === name; + return ( + node.type === 'UnaryExpression' && + node.operator === 'delete' && + expressionRootIdentifier(node.argument) === name + ); +} + +function navigationApiToken(node) { + if (node?.type === 'Identifier' && ['loadFile', 'loadURL'].includes(node.name)) return node.name; + const value = staticString(node); + if (['loadFile', 'loadURL'].includes(value ?? '')) return value; + return undefined; +} + +function isOnlyAwaitedMemberCall(block, objectName, methodName, argumentObject, argumentProperty) { + if (block?.type !== 'BlockStatement' || block.body.length !== 1) return false; + const [statement] = block.body; + const expression = statement?.type === 'ExpressionStatement' ? unwrapExpression(statement.expression) : undefined; + return ( + expression?.type === 'AwaitExpression' && + isMemberCall(expression.argument, objectName, methodName, argumentObject, argumentProperty) + ); +} + +function isImportMetaDirectory(node) { + const expression = unwrapExpression(node); + return ( + isMemberExpression(expression) && + unwrapExpression(expression.object)?.type === 'MetaProperty' && + unwrapExpression(expression.object).meta?.name === 'import' && + unwrapExpression(expression.object).property?.name === 'meta' && + memberPropertyName(expression) === 'dirname' + ); +} + +function isProcessViteDevServerUrl(node) { + const expression = unwrapExpression(node); + return ( + isMemberExpression(expression) && + isMemberExpression(expression.object) && + isIdentifier(expression.object.object, 'process') && + memberPropertyName(expression.object) === 'env' && + memberPropertyName(expression) === 'VITE_DEV_SERVER_URL' + ); +} + +function validateMainRendererLoader(desktopRoot, violations) { + const program = parseContractSource(desktopRoot, MAIN_RENDERER_LOADER_SOURCE, violations); + if (!program) return; + const nodes = nodesIn(program); + const declarators = (name) => + nodes.filter( + (node) => + node.type === 'VariableDeclarator' && + node.id?.type === 'Identifier' && + node.id.name === name, + ); + const entryPaths = declarators('rendererEntryPath'); + const entryUrls = declarators('rendererEntryUrl'); + const loadCalls = nodes.filter( + (node) => + node.type === 'CallExpression' && + isMemberExpression(node.callee) && + ['loadFile', 'loadURL'].includes(memberPropertyName(node.callee)), + ); + const loaderFunctions = nodes.filter( + (node) => node.type === 'FunctionDeclaration' && node.id?.name === 'loadMainRenderer', + ); + const resolverFunctions = nodes.filter( + (node) => node.type === 'FunctionDeclaration' && node.id?.name === 'resolveMainRendererEntry', + ); + const [loaderFunction] = loaderFunctions; + const [resolverFunction] = resolverFunctions; + const ifStatements = loaderFunction ? nodesIn(loaderFunction.body).filter((node) => node.type === 'IfStatement') : []; + const [loadBranch] = ifStatements; + const resolverReturns = resolverFunction + ? nodesIn(resolverFunction.body).filter((node) => node.type === 'ReturnStatement') + : []; + const [resolverReturn] = resolverReturns; + const frozenEntry = frozenRendererEntryObject(resolverReturn?.argument); + const returnFilePaths = objectPropertyValues(frozenEntry, 'filePath'); + const returnUrls = objectPropertyValues(frozenEntry, 'url'); + const returnDevFlags = objectPropertyValues(frozenEntry, 'useDevServer'); + const navigationTokens = nodes.filter((node) => navigationApiToken(node)); + const valid = + hasNamedImport(program, 'node:path', 'join') && + hasNamedImport(program, 'node:url', 'pathToFileURL') && + !program.body.some((statement) => statementBindings(statement).includes('Object')) && + resolverFunctions.length === 1 && + resolverFunction.async !== true && + JSON.stringify(resolverFunction.params.map((parameter) => parameter.type === 'Identifier' ? parameter.name : undefined)) === + JSON.stringify(['mainModuleDirectory', 'viteDevServerUrl']) && + resolverFunction.body.body.length === 3 && + resolverReturns.length === 1 && + Boolean(frozenEntry) && + returnFilePaths.length === 1 && + isIdentifier(returnFilePaths[0], 'rendererEntryPath') && + returnUrls.length === 1 && + isIdentifier(returnUrls[0], 'rendererEntryUrl') && + returnDevFlags.length === 1 && + isViteDevServerFlag(returnDevFlags[0]) && + loaderFunctions.length === 1 && + loaderFunction.async === true && + JSON.stringify(loaderFunction.params.map((parameter) => parameter.type === 'Identifier' ? parameter.name : undefined)) === + JSON.stringify(['mainWindow', 'rendererEntry']) && + loaderFunction.body.body.length === 1 && + entryPaths.length === 1 && + isRendererEntryPathInitializer(entryPaths[0].init) && + entryUrls.length === 1 && + isRendererEntryUrlInitializer(entryUrls[0].init) && + loadCalls.length === 2 && + loadCalls.filter((node) => isMemberCall(node, 'mainWindow', 'loadFile', 'rendererEntry', 'filePath')).length === 1 && + loadCalls.filter((node) => isMemberCall(node, 'mainWindow', 'loadURL', 'rendererEntry', 'url')).length === 1 && + navigationTokens.length === 4 && + ifStatements.length === 1 && + isNamedMember(loadBranch.test, 'rendererEntry', 'useDevServer') && + isOnlyAwaitedMemberCall(loadBranch.consequent, 'mainWindow', 'loadURL', 'rendererEntry', 'url') && + isOnlyAwaitedMemberCall(loadBranch.alternate, 'mainWindow', 'loadFile', 'rendererEntry', 'filePath'); + if (!valid) { + violations.push( + `${MAIN_RENDERER_LOADER_SOURCE}: renderer loader must load only the pinned dist-renderer/index.html entry`, + ); + } +} + +function validateMainWindowEntryContract(desktopRoot, violations) { + const program = parseContractSource(desktopRoot, RENDERER_LOADER_SOURCE, violations); + if (!program) return; + const nodes = nodesIn(program); + const parents = buildParentMap(program); + const loaderImports = program.body.filter( + (node) => + node.type === 'ImportDeclaration' && + staticString(node.source) === './main-renderer-loader.js' && + ['loadMainRenderer', 'resolveMainRendererEntry'].every((name) => + node.specifiers.some( + (specifier) => + specifier.type === 'ImportSpecifier' && + memberName(specifier.imported) === name && + specifier.local.name === name, + ), + ), + ); + const entryDeclarators = nodes.filter( + (node) => + node.type === 'VariableDeclarator' && + node.id?.type === 'Identifier' && + node.id.name === 'rendererEntry' && + unwrapExpression(node.init)?.type === 'CallExpression' && + isIdentifier(unwrapExpression(node.init).callee, 'resolveMainRendererEntry') && + unwrapExpression(node.init).arguments.length === 2 && + isImportMetaDirectory(unwrapExpression(node.init).arguments[0]) && + isProcessViteDevServerUrl(unwrapExpression(node.init).arguments[1]), + ); + const loaderCalls = nodes.filter( + (node) => + node.type === 'CallExpression' && + isIdentifier(node.callee, 'loadMainRenderer') && + node.arguments.length === 2 && + isIdentifier(node.arguments[0], 'mainWindow') && + isIdentifier(node.arguments[1], 'rendererEntry'), + ); + const entryDeclaration = entryDeclarators.length === 1 ? parents.get(entryDeclarators[0]) : undefined; + const entryMutations = nodes.filter((node) => mutatesIdentifier(node, 'rendererEntry')); + if ( + loaderImports.length !== 1 || + entryDeclarators.length !== 1 || + entryDeclaration?.type !== 'VariableDeclaration' || + entryDeclaration.kind !== 'const' || + loaderCalls.length !== 1 || + entryMutations.length > 0 || + nodes.some((node) => navigationApiToken(node)) + ) { + violations.push( + `${RENDERER_LOADER_SOURCE}: main window must delegate exactly once to the pinned renderer loader`, + ); + } + + const allowedNavigationFiles = new Set([ + 'src/main/browser-message-box.ts', + 'src/main/browser/controller.ts', + 'src/main/computer-use/cursor-overlay-window.ts', + 'src/main/computer-use/pip-electron.ts', + MAIN_RENDERER_LOADER_SOURCE, + 'src/main/permission-overlay/permission-overlay-main.ts', + ]); + for (const file of sourceFiles(resolve(desktopRoot, 'src/main'))) { + const fileRelative = normalizePath(relative(desktopRoot, file)); + if (isTestConsumer(fileRelative) || allowedNavigationFiles.has(fileRelative)) continue; + const sourceProgram = parseContractSource(desktopRoot, fileRelative, violations); + if (sourceProgram && nodesIn(sourceProgram).some((node) => navigationApiToken(node))) { + violations.push(`${fileRelative}: main-process navigation APIs are restricted to approved loaders`); + } + } + + validateMainRendererLoader(desktopRoot, violations); +} + +function validateViteEntryContract(desktopRoot, violations) { + const program = parseContractSource(desktopRoot, RENDERER_VITE_CONFIG, violations); + if (!program) return; + const defaultExports = program.body.filter((node) => node.type === 'ExportDefaultDeclaration'); + const declaration = unwrapExpression(defaultExports[0]?.declaration); + const config = + defaultExports.length === 1 && + declaration?.type === 'CallExpression' && + isIdentifier(declaration.callee, 'defineConfig') && + declaration.arguments.length === 1 + ? unwrapExpression(declaration.arguments[0]) + : undefined; + const roots = objectPropertyValues(config, 'root'); + const builds = objectPropertyValues(config, 'build'); + const plugins = objectPropertyValues(config, 'plugins'); + const outDirs = objectPropertyValues(builds[0], 'outDir'); + const rollupOptions = objectPropertyValues(builds[0], 'rollupOptions'); + const hasInputOverride = rollupOptions.some( + (options) => options?.type !== 'ObjectExpression' || objectPropertyValues(options, 'input').length > 0, + ); + const hasContractSpread = + config?.type !== 'ObjectExpression' || + config.properties.some((property) => property.type === 'SpreadElement') || + builds[0]?.type !== 'ObjectExpression' || + builds[0].properties.some((property) => property.type === 'SpreadElement'); + const pluginElements = plugins[0]?.type === 'ArrayExpression' ? plugins[0].elements : []; + const pluginCall = (index, name, argumentCount) => { + const expression = unwrapExpression(pluginElements[index]); + return expression?.type === 'CallExpression' && + isIdentifier(expression.callee, name) && + expression.arguments.length === argumentCount + ? expression + : undefined; + }; + const reactCall = pluginCall(0, 'react', 0); + const dependencyPatchesCall = pluginCall(1, 'dependencyPatchesCachePlugin', 1); + const bundledPackagesCall = pluginCall(2, 'bundledNpmPackagesPlugin', 0); + const rendererContractCall = pluginCall(3, 'rendererEntryContractPlugin', 1); + const rendererContractRoot = unwrapExpression(rendererContractCall?.arguments[0]); + const hasPinnedRendererContractRoot = + rendererContractRoot?.type === 'CallExpression' && + isIdentifier(rendererContractRoot.callee, 'resolve') && + rendererContractRoot.arguments.length === 2 && + isImportMetaDirectory(rendererContractRoot.arguments[0]) && + staticString(rendererContractRoot.arguments[1]) === 'src/renderer'; + const hasPinnedImports = + hasNamedImport(program, 'vite', 'defineConfig') && + hasNamedImport(program, 'node:path', 'resolve') && + hasDefaultImport(program, '@vitejs/plugin-react', 'react') && + hasNamedImport(program, './vite-dependency-patches.js', 'dependencyPatchesCachePlugin') && + hasNamedImport(program, './vite-bundled-packages.js', 'bundledNpmPackagesPlugin') && + hasNamedImport( + program, + './scripts/vite-renderer-entry-contract.js', + 'rendererEntryContractPlugin', + ); + const hasPinnedPlugins = + pluginElements.length === 4 && + Boolean(reactCall) && + Boolean(dependencyPatchesCall) && + isIdentifier(dependencyPatchesCall.arguments[0], 'REPO_ROOT') && + Boolean(bundledPackagesCall) && + Boolean(rendererContractCall) && + hasPinnedRendererContractRoot; + if ( + roots.length !== 1 || + staticString(roots[0]) !== 'src/renderer' || + builds.length !== 1 || + outDirs.length !== 1 || + staticString(outDirs[0]) !== '../../dist-renderer' || + plugins.length !== 1 || + !hasPinnedImports || + !hasPinnedPlugins || + hasInputOverride || + hasContractSpread + ) { + violations.push( + `${RENDERER_VITE_CONFIG}: Vite must build src/renderer/index.html into dist-renderer without an input override`, + ); + } + + const packagePath = resolve(desktopRoot, 'package.json'); + let buildScript; + try { + buildScript = JSON.parse(readFileSync(packagePath, 'utf8')).scripts?.['build:renderer']; + } catch { + buildScript = undefined; + } + if (buildScript !== RENDERER_BUILD_SCRIPT) { + violations.push('package.json: build:renderer must retain the pinned Vite renderer entry command'); + } +} + +function validateRendererEntryContract(desktopRoot, config, violations) { + const indexPath = resolve(desktopRoot, RENDERER_ENTRY_INDEX); + const entryPath = resolve(desktopRoot, RENDERER_ENTRY_SOURCE); + if (!existsSync(indexPath)) { + violations.push(`${RENDERER_ENTRY_INDEX}: renderer entry index is missing`); + return; + } + if (!existsSync(entryPath)) { + violations.push(`${RENDERER_ENTRY_SOURCE}: renderer entry source is missing`); + } + if (!Object.hasOwn(config.rootDebt, RENDERER_ENTRY_SOURCE)) { + violations.push(`${RENDERER_ENTRY_SOURCE}: renderer entry must retain its permanent rootDebt guard`); + } + + const html = readFileSync(indexPath, 'utf8'); + const openingScriptCount = html.match(/]*)>([\s\S]*?)<\/script\s*>/giu)]; + const [script] = scripts; + const isPinnedEntry = + openingScriptCount === 1 && + scripts.length === 1 && + htmlAttribute(script?.[1] ?? '', 'type') === 'module' && + htmlAttribute(script?.[1] ?? '', 'src') === RENDERER_ENTRY_SCRIPT_SOURCE && + (script?.[2] ?? '').trim().length === 0; + if (!isPinnedEntry) { + violations.push( + `${RENDERER_ENTRY_INDEX}: renderer entry contract requires exactly one empty external module script for ${RENDERER_ENTRY_SCRIPT_SOURCE}`, + ); + } + validateMainWindowEntryContract(desktopRoot, violations); + validateViteEntryContract(desktopRoot, violations); +} + +function metricTotal(value) { + if (typeof value === 'number') return value; + if (Array.isArray(value)) return value.length; + return Object.values(value).reduce((total, count) => total + count, 0); +} + +function allowsMigrationDependency({ base, current, dependency, desktopRoot, path, section }) { + if (metricTotal(current.dependencyPaths) > metricTotal(base.dependencyPaths)) return false; + const target = resolveDependency(desktopRoot, resolve(desktopRoot, path), dependency); + if (!target) return false; + const targetRelative = normalizePath(relative(desktopRoot, target)); + const targetZone = zoneFor(targetRelative); + if (section === 'legacyAppShell' || section === 'legacyAppShellClosure') { + if (targetZone.kind === 'shell' || isPublicApplicationPath(targetRelative)) return true; + const targetFeature = featureForAbsolutePath(desktopRoot, target); + return Boolean(targetFeature && isPublicFeaturePath(targetFeature.subpath)); + } + if (section === 'rootDebt') return ['bootstrap', 'composition'].includes(targetZone.kind); + return ['application', 'bootstrap', 'composition', 'platform'].includes(targetZone.kind); +} + +function debtForPath(desktopRoot, path) { + return debtMetrics(analyzeRendererSource(readFileSync(resolve(desktopRoot, path), 'utf8'), path)); +} + +function capabilityDebtForPath(desktopRoot, path) { + return capabilityDebtMetrics(analyzeRendererSource(readFileSync(resolve(desktopRoot, path), 'utf8'), path)); +} + +function collectLegacyImportEdges(desktopRoot) { + const feature = new Set(); + const platform = new Set(); + for (const file of sourceFiles(resolve(desktopRoot, 'src'))) { + const fileRelative = normalizePath(relative(desktopRoot, file)); + const sourceZone = zoneFor(fileRelative); + if (sourceZone.kind !== 'feature' && sourceZone.kind !== 'platform') continue; + const analysis = analyzeRendererSource(readFileSync(file, 'utf8'), fileRelative); + for (const dependency of analysis.dependencies) { + const target = resolveDependency(desktopRoot, file, dependency); + if (!target) continue; + const targetRelative = normalizePath(relative(desktopRoot, target)); + if (zoneFor(targetRelative).kind !== 'legacy') continue; + const edge = `${fileRelative} -> ${targetRelative}`; + if (sourceZone.kind === 'feature') feature.add(edge); + else platform.add(edge); + } + } + return { feature: [...feature].sort(), platform: [...platform].sort() }; +} + +export function generateArchitectureConfig(desktopRoot, config) { + const rendererRoot = resolve(desktopRoot, 'src/renderer'); + const legacyRendererFiles = sourceFiles(rendererRoot) + .map((path) => normalizePath(relative(desktopRoot, path))) + .filter((path) => zoneFor(path).kind === 'legacy') + .sort(); + const appShellFiles = readdirSync(rendererRoot, { withFileTypes: true }) + .filter((entry) => entry.isFile() && LEGACY_APP_SHELL_FILE.test(entry.name)) + .map((entry) => `src/renderer/${entry.name}`) + .sort(); + const closureViolations = []; + const closureFiles = collectRootDependencyClosure(desktopRoot, appShellFiles, closureViolations, 'AppShell'); + if (closureViolations.length > 0) { + throw new Error(`cannot generate AppShell closure:\n${closureViolations.join('\n')}`); + } + const imports = collectLegacyImportEdges(desktopRoot); + const rootDebt = {}; + for (const path of Object.keys(config.rootDebt ?? {}).sort()) { + if (existsSync(resolve(desktopRoot, path))) rootDebt[path] = debtForPath(desktopRoot, path); + } + const appShellDebtPaths = new Set([...appShellFiles, ...closureFiles]); + const rootDebtClosureFiles = collectRootDependencyClosure( + desktopRoot, + Object.keys(rootDebt), + closureViolations, + 'renderer root', + ).filter((path) => !appShellDebtPaths.has(path)); + if (closureViolations.length > 0) { + throw new Error(`cannot generate renderer root closure:\n${closureViolations.join('\n')}`); + } + return { + version: 1, + legacyRendererFiles, + legacyGrowthDirectories: config.legacyGrowthDirectories ?? DEFAULT_LEGACY_GROWTH_DIRECTORIES, + legacyFeatureImports: imports.feature, + legacyPlatformImports: imports.platform, + legacyAppShell: { + files: Object.fromEntries(appShellFiles.map((path) => [path, debtForPath(desktopRoot, path)])), + closure: Object.fromEntries(closureFiles.map((path) => [path, capabilityDebtForPath(desktopRoot, path)])), + }, + rootDebt, + rootDebtClosure: Object.fromEntries( + rootDebtClosureFiles.map((path) => [path, capabilityDebtForPath(desktopRoot, path)]), + ), + ownership: config.ownership ?? [], + }; +} + +function validateMonotonicDebt(config, baseConfig, desktopRoot, violations) { + if (!baseConfig) return; + for (const section of ['legacyAppShell', 'legacyAppShellClosure', 'rootDebt', 'rootDebtClosure']) { + const currentFiles = + section === 'legacyAppShell' + ? config.legacyAppShell.files + : section === 'legacyAppShellClosure' + ? config.legacyAppShell.closure + : section === 'rootDebt' + ? config.rootDebt + : config.rootDebtClosure; + const baseFiles = + section === 'legacyAppShell' + ? baseConfig.legacyAppShell.files + : section === 'legacyAppShellClosure' + ? baseConfig.legacyAppShell.closure + : section === 'rootDebt' + ? baseConfig.rootDebt + : baseConfig.rootDebtClosure; + for (const path of Object.keys(currentFiles)) { + const shiftedAppShellBase = + section === 'rootDebtClosure' ? baseConfig.legacyAppShell.closure[path] : undefined; + if (!baseFiles[path] && !shiftedAppShellBase) { + violations.push(`${path}: new ${section} debt entries are forbidden`); + } + } + if (section === 'rootDebt') { + for (const path of Object.keys(baseFiles)) { + if (!currentFiles[path] && existsSync(resolve(desktopRoot, path))) { + violations.push(`${path}: permanent root entry guard cannot be removed while the source exists`); + } + } + } + for (const [path, current] of Object.entries(currentFiles)) { + const base = + baseFiles[path] ?? + (section === 'rootDebtClosure' ? baseConfig.legacyAppShell.closure[path] : undefined); + if (!base) continue; + const metrics = section.endsWith('Closure') ? CAPABILITY_DEBT_METRICS : ROOT_DEBT_METRICS; + for (const metric of metrics) { + if (metricTotal(current[metric]) > metricTotal(base[metric])) { + violations.push(`${path}: ${metric} debt increased from ${metricTotal(base[metric])} to ${metricTotal(current[metric])}`); + } + if (['bridgePaths', 'environmentCapabilities', 'hookCalls', 'lifecycleMethods'].includes(metric)) { + const increases = Object.fromEntries( + Object.entries(current[metric]) + .map(([key, count]) => [key, Math.max(0, count - (base[metric][key] ?? 0))]) + .filter(([, count]) => count > 0), + ); + for (const [key, count] of Object.entries(increases)) { + if (count > 0) { + violations.push(`${path}: new or increased ${metric} debt ${key}`); + } + } + } + if (metric === 'actionFactories') { + for (const factory of current.actionFactories) { + if (!base.actionFactories.includes(factory)) violations.push(`${path}: new action factory debt ${factory}`); + } + } + if (metric === 'dependencyPaths') { + for (const [dependency, count] of Object.entries(current.dependencyPaths)) { + if ( + count > (base.dependencyPaths[dependency] ?? 0) && + !allowsMigrationDependency({ base, current, dependency, desktopRoot, path, section }) + ) { + violations.push(`${path}: new dependency debt ${dependency}`); + } + } + } + } + } + } + const baseLegacyFiles = new Set(baseConfig.legacyRendererFiles); + for (const path of config.legacyRendererFiles) { + if (!baseLegacyFiles.has(path) && !isAllowedLegacyGrowthPath(config, path)) { + violations.push(`${path}: new unclassified renderer source files are forbidden outside approved legacy directories`); + } + } + const baseLegacyGrowthDirectories = new Set(baseConfig.legacyGrowthDirectories); + for (const path of config.legacyGrowthDirectories) { + if (!baseLegacyGrowthDirectories.has(path)) { + violations.push(`${path}: new legacy growth directories are forbidden`); + } + } + const baseLegacyFeatureImports = new Set(baseConfig.legacyFeatureImports); + for (const edge of config.legacyFeatureImports) { + if (!baseLegacyFeatureImports.has(edge)) violations.push(`${edge}: new feature-to-legacy imports are forbidden`); + } + const baseLegacyPlatformImports = new Set(baseConfig.legacyPlatformImports); + for (const edge of config.legacyPlatformImports) { + if (!baseLegacyPlatformImports.has(edge)) violations.push(`${edge}: new platform-to-legacy imports are forbidden`); + } +} + +export function checkRendererArchitecture({ + desktopRoot, + config, + baseConfig, + enforceRendererEntryContract = true, +} = {}) { + const resolvedDesktopRoot = resolve(desktopRoot ?? fileURLToPath(new URL('..', import.meta.url))); + const resolvedConfig = config ?? JSON.parse(readFileSync(join(resolvedDesktopRoot, 'renderer-architecture.json'), 'utf8')); + const violations = []; + + const currentConfigValid = validateArchitectureConfig(resolvedConfig, 'current', violations); + const baseConfigValid = baseConfig ? validateArchitectureConfig(baseConfig, 'base', violations) : true; + if (!currentConfigValid || !baseConfigValid) return violations.sort(); + if (enforceRendererEntryContract) { + validateRendererEntryContract(resolvedDesktopRoot, resolvedConfig, violations); + } + validateLegacyLedger(resolvedDesktopRoot, resolvedConfig, violations); + validateMonotonicDebt(resolvedConfig, baseConfig, resolvedDesktopRoot, violations); + const allowedLegacyFeatureImports = new Set(resolvedConfig.legacyFeatureImports); + const allowedLegacyPlatformImports = new Set(resolvedConfig.legacyPlatformImports); + const observedLegacyFeatureImports = new Set(); + const observedLegacyPlatformImports = new Set(); + + for (const scanRoot of ['src', 'stories', 'e2e']) { + for (const file of sourceFiles(resolve(resolvedDesktopRoot, scanRoot))) { + const fileRelative = normalizePath(relative(resolvedDesktopRoot, file)); + let analysis; + try { + analysis = analyzeRendererSource(readFileSync(file, 'utf8'), fileRelative); + } catch (error) { + violations.push(`${fileRelative}: could not parse source: ${error instanceof Error ? error.message : String(error)}`); + continue; + } + validateStrictZone({ fileRelative, analysis, violations }); + validateDependencies({ + allowedLegacyFeatureImports, + allowedLegacyPlatformImports, + analysis, + desktopRoot: resolvedDesktopRoot, + file, + fileRelative, + observedLegacyFeatureImports, + observedLegacyPlatformImports, + violations, + }); + } + } + + for (const edge of allowedLegacyFeatureImports) { + if (!observedLegacyFeatureImports.has(edge)) violations.push(`${edge}: stale feature-to-legacy import budget`); + } + for (const edge of allowedLegacyPlatformImports) { + if (!observedLegacyPlatformImports.has(edge)) violations.push(`${edge}: stale platform-to-legacy import budget`); + } + + return violations.sort(); +} + +// The monotonic-debt ratchet must measure debt against what the base commit's +// source tree *actually* contained, not against the numbers its ledger happened +// to record. A ledger that under-reports its own tree (for example, one +// generated on a branch that predated files already merged into main) would +// otherwise make a faithful baseline correction look like brand-new debt and +// wedge the ledger permanently. We materialize the base tree and re-derive its +// debt, keeping the base ledger only as the source of policy fields (hook +// transitions, growth directories, root-debt key set, ownership). +function deriveBaseTreeConfig(repoRoot, desktopRoot, base, baseCommittedConfig) { + const scratch = mkdtempSync(join(tmpdir(), 'renderer-arch-base-')); + const worktreePath = join(scratch, 'tree'); + try { + execFileSync('git', ['worktree', 'add', '--detach', worktreePath, base], { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + const baseDesktopRoot = resolve(worktreePath, relative(repoRoot, desktopRoot)); + return generateArchitectureConfig(baseDesktopRoot, baseCommittedConfig); + } finally { + try { + execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { + cwd: repoRoot, + stdio: 'ignore', + }); + } catch { + try { + execFileSync('git', ['worktree', 'prune'], { cwd: repoRoot, stdio: 'ignore' }); + } catch { + // Ignore prune failures; the scratch removal below is the real cleanup. + } + } + try { + rmSync(scratch, { recursive: true, force: true }); + } catch { + // Best-effort cleanup of the scratch directory. + } + } +} + +function loadBaseConfig(repoRoot, desktopRoot, base) { + if (!base) return { baseConfig: undefined, introducedLedger: false }; + const relativeConfig = normalizePath(relative(repoRoot, join(desktopRoot, 'renderer-architecture.json'))); + try { + execFileSync('git', ['rev-parse', '--verify', `${base}^{commit}`], { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + throw new Error(`base ref does not resolve to a commit: ${base}`); + } + + let source; + try { + source = execFileSync('git', ['show', `${base}:${relativeConfig}`], { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + const diffStatus = execFileSync('git', ['diff', '--name-status', base, '--', relativeConfig], { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + const worktreeStatus = execFileSync( + 'git', + ['status', '--porcelain=v1', '--untracked-files=all', '--', relativeConfig], + { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }, + ).trim(); + if (diffStatus === `A\t${relativeConfig}` || worktreeStatus === `?? ${relativeConfig}`) { + return { baseConfig: undefined, introducedLedger: true }; + } + throw new Error(`base ledger is missing at ${base}:${relativeConfig}`); + } + + let baseCommittedConfig; + try { + baseCommittedConfig = JSON.parse(source); + } catch (error) { + throw new Error( + `base ledger is invalid JSON at ${base}:${relativeConfig}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + try { + return { + baseConfig: deriveBaseTreeConfig(repoRoot, desktopRoot, base, baseCommittedConfig), + introducedLedger: false, + }; + } catch (error) { + // If the base tree cannot be materialized or analyzed (e.g. git worktree is + // unavailable), fall back to the committed base ledger so the ratchet still + // runs. This restores the pre-fix behavior rather than crashing the check. + console.warn( + `Renderer architecture check: could not derive base tree debt at ${base}; ` + + `falling back to the committed base ledger. (${error instanceof Error ? error.message : String(error)})`, + ); + return { baseConfig: baseCommittedConfig, introducedLedger: false }; + } +} + +function parseCliArguments(args) { + let base; + let write = false; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === '--write' && !write) { + write = true; + continue; + } + if (argument === '--base' && base === undefined) { + const value = args[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error('usage: check-renderer-architecture.mjs [--write] [--base ]'); + } + base = value; + index += 1; + continue; + } + throw new Error('usage: check-renderer-architecture.mjs [--write] [--base ]'); + } + return { base, write }; +} + +function runCli() { + const desktopRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); + const repoRoot = resolve(desktopRoot, '../..'); + let base; + let config; + let loadedBase; + let write; + try { + ({ base, write } = parseCliArguments(process.argv.slice(2))); + config = JSON.parse(readFileSync(join(desktopRoot, 'renderer-architecture.json'), 'utf8')); + loadedBase = loadBaseConfig(repoRoot, desktopRoot, base); + if (write) { + config = generateArchitectureConfig(desktopRoot, config); + writeFileSync(join(desktopRoot, 'renderer-architecture.json'), `${JSON.stringify(config, null, 2)}\n`); + console.log('Renderer architecture ledger updated.'); + } + } catch (error) { + console.error(`Renderer architecture check could not start: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + return; + } + const { baseConfig, introducedLedger } = loadedBase; + const violations = checkRendererArchitecture({ baseConfig, config, desktopRoot }); + if (violations.length > 0) { + console.error('Renderer architecture check failed:'); + for (const violation of violations) console.error(`- ${violation}`); + process.exitCode = 1; + return; + } + if (introducedLedger) { + console.log(`Renderer architecture check passed; ${base} has no ledger and this change introduces it.`); + return; + } + console.log(`Renderer architecture check passed${baseConfig ? ` against ${base}` : ''}.`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) runCli(); diff --git a/apps/desktop/scripts/check-renderer-architecture.test.mjs b/apps/desktop/scripts/check-renderer-architecture.test.mjs new file mode 100644 index 0000000000..27caed53af --- /dev/null +++ b/apps/desktop/scripts/check-renderer-architecture.test.mjs @@ -0,0 +1,2307 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { + analyzeRendererSource, + checkRendererArchitecture, + generateArchitectureConfig, +} from './check-renderer-architecture.mjs'; +import { + assertRendererEntryHtml, + rendererEntryContractPlugin, +} from './vite-renderer-entry-contract.ts'; + +const TRANSITIVE_APP_SHELL_PATH = 'src/renderer/app-shell.ts'; +const TRANSITIVE_LEGACY_HELPER_PATH = 'src/renderer/legacy-session-helper.ts'; +const RENDERER_ENTRY_PATH = 'src/renderer/main.tsx'; + +function emptyDebt(overrides = {}) { + return { + importDeclarations: 0, + importSpecifiers: 0, + nonTriviaTokens: 0, + dependencyPaths: {}, + bridgePaths: {}, + environmentCapabilities: {}, + hookCalls: {}, + lifecycleMethods: {}, + unresolvedDependencies: 0, + actionFactories: [], + ...overrides, + }; +} + +function architectureConfig({ + legacyAppShellClosureDebt, + legacyFeatureImports = [], + legacyFiles = {}, + legacyGrowthDirectories = [], + legacyPlatformImports = [], + rootDebt = {}, + rootDebtClosure = {}, + legacyRendererFiles = Object.keys(rootDebt), + ownership = [], +} = {}) { + return { + version: 1, + legacyRendererFiles: [...legacyRendererFiles].sort(), + legacyGrowthDirectories: [...legacyGrowthDirectories].sort(), + legacyFeatureImports: [...legacyFeatureImports].sort(), + legacyPlatformImports: [...legacyPlatformImports].sort(), + legacyAppShell: { + files: legacyFiles, + closure: legacyAppShellClosureDebt ?? {}, + }, + rootDebt, + rootDebtClosure, + ownership, + }; +} + +function debtForSource(source, path) { + const analysis = analyzeRendererSource(source, path); + return { + importDeclarations: analysis.importDeclarations, + importSpecifiers: analysis.importSpecifiers, + nonTriviaTokens: analysis.nonTriviaTokens, + dependencyPaths: analysis.dependencyPaths, + bridgePaths: analysis.bridgePaths, + environmentCapabilities: analysis.environmentCapabilities, + hookCalls: analysis.hookCalls, + lifecycleMethods: analysis.lifecycleMethods, + unresolvedDependencies: analysis.unresolvedDependencies, + actionFactories: analysis.actionFactories, + }; +} + +function capabilityDebtForSource(source, path) { + const debt = debtForSource(source, path); + return { + actionFactories: debt.actionFactories, + bridgePaths: debt.bridgePaths, + dependencyPaths: debt.dependencyPaths, + environmentCapabilities: debt.environmentCapabilities, + hookCalls: debt.hookCalls, + lifecycleMethods: debt.lifecycleMethods, + unresolvedDependencies: debt.unresolvedDependencies, + }; +} + +async function withDesktopFixture(files, run) { + const desktopRoot = await mkdtemp(join(tmpdir(), 'maka-renderer-architecture-')); + try { + for (const [path, source] of Object.entries(files)) { + const absolutePath = join(desktopRoot, path); + await mkdir(dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, source, 'utf8'); + } + return await run(desktopRoot); + } finally { + await rm(desktopRoot, { force: true, recursive: true }); + } +} + +function violationsFor(desktopRoot, config = architectureConfig(), baseConfig) { + return checkRendererArchitecture({ + baseConfig, + config, + desktopRoot, + enforceRendererEntryContract: false, + }); +} + +function assertHasViolation(violations, pattern) { + assert.ok( + violations.some((violation) => pattern.test(violation)), + `Expected a violation matching ${pattern}, received:\n${violations.join('\n')}`, + ); +} + +function transitiveAppShellFiles(helperSource, extraFiles = {}) { + return { + [TRANSITIVE_APP_SHELL_PATH]: ` + import { legacySessionHelper } from './legacy-session-helper.js'; + export const AppShell = legacySessionHelper; + `, + [TRANSITIVE_LEGACY_HELPER_PATH]: helperSource, + ...extraFiles, + }; +} + +function transitiveAppShellSeedConfig() { + return architectureConfig({ + ownership: [ + { + capability: 'fixture-app-shell', + targetZone: 'shell', + legacyPaths: [TRANSITIVE_APP_SHELL_PATH], + }, + ], + }); +} + +function rendererEntrySeedConfig() { + return architectureConfig({ + rootDebt: { [RENDERER_ENTRY_PATH]: emptyDebt() }, + ownership: [ + { + capability: 'fixture-root', + targetZone: 'bootstrap', + legacyPaths: [RENDERER_ENTRY_PATH], + }, + ], + }); +} + +function rendererEntryContractFiles(overrides = {}) { + return { + 'src/renderer/index.html': ` + + + `, + [RENDERER_ENTRY_PATH]: `export const main = true;`, + 'src/main/main-window.ts': ` + import { loadMainRenderer, resolveMainRendererEntry } from './main-renderer-loader.js'; + async function createWindow() { + const rendererEntry = resolveMainRendererEntry(import.meta.dirname, process.env.VITE_DEV_SERVER_URL); + await loadMainRenderer(mainWindow, rendererEntry); + } + `, + 'src/main/main-renderer-loader.ts': ` + import { join } from 'node:path'; + import { pathToFileURL } from 'node:url'; + interface MainRendererWindow { + loadFile(path: string): Promise; + loadURL(url: string): Promise; + } + interface MainRendererEntry { + readonly filePath: string; + readonly url: string; + readonly useDevServer: boolean; + } + export function resolveMainRendererEntry( + mainModuleDirectory: string, + viteDevServerUrl: string | undefined, + ): MainRendererEntry { + const rendererEntryPath = join(mainModuleDirectory, '..', '..', 'dist-renderer', 'index.html'); + const rendererEntryUrl = viteDevServerUrl ?? pathToFileURL(rendererEntryPath).href; + return Object.freeze({ + filePath: rendererEntryPath, + url: rendererEntryUrl, + useDevServer: !!viteDevServerUrl, + }); + } + export async function loadMainRenderer( + mainWindow: MainRendererWindow, + rendererEntry: MainRendererEntry, + ): Promise { + if (rendererEntry.useDevServer) { + await mainWindow.loadURL(rendererEntry.url); + } else { + await mainWindow.loadFile(rendererEntry.filePath); + } + } + `, + 'vite.config.ts': ` + import react from '@vitejs/plugin-react'; + import { resolve } from 'node:path'; + import { defineConfig } from 'vite'; + import { rendererEntryContractPlugin } from './scripts/vite-renderer-entry-contract.js'; + import { bundledNpmPackagesPlugin } from './vite-bundled-packages.js'; + import { dependencyPatchesCachePlugin } from './vite-dependency-patches.js'; + const REPO_ROOT = '/fixture'; + export default defineConfig({ + root: 'src/renderer', + plugins: [ + react(), + dependencyPatchesCachePlugin(REPO_ROOT), + bundledNpmPackagesPlugin(), + rendererEntryContractPlugin(resolve(import.meta.dirname, 'src/renderer')), + ], + build: { outDir: '../../dist-renderer' }, + }); + `, + 'package.json': JSON.stringify({ + scripts: { + 'build:renderer': + 'vite build && node scripts/check-renderer-entry-output.mjs && node ../../scripts/check-third-party-notices.mjs', + }, + }), + ...overrides, + }; +} + +function runRendererEntryBundleContract(directImports) { + const plugin = rendererEntryContractPlugin('/fixture/src/renderer'); + plugin.configResolved({ root: '/fixture/src/renderer' }); + assert.equal(typeof plugin.generateBundle, 'function'); + plugin.generateBundle.call( + { + emitFile() {}, + error(message) { + throw new Error(message); + }, + getModuleInfo(id) { + return id === '/fixture/src/renderer/index.html' ? { importedIds: directImports } : null; + }, + }, + {}, + { + 'assets/index.js': { + type: 'chunk', + isEntry: true, + facadeModuleId: '/fixture/src/renderer/index.html', + }, + }, + ); +} + +function canonicalRendererEntryHtml(extraBody = '', policy = "script-src 'self'") { + return ` + + + + + + + Maka + + + +
+ ${extraBody} + + + + `; +} + +describe('renderer architecture checker fixtures', () => { + it('accepts the legal inward dependency graph and testing entry boundary', async () => { + await withDesktopFixture( + { + 'src/renderer/application/contracts/regions.ts': ` + export interface RegionModel { readonly id: string } + export const fetch = () => 'injected-contract'; + `, + 'src/renderer/application/session/session-scope.ts': ` + import type { RegionModel } from '../contracts/regions.js'; + export type SessionScope = RegionModel; + `, + 'src/renderer/shell/shell-frame.tsx': ` + import type { RegionModel } from '../application/contracts/regions.js'; + import { fetch } from '../application/contracts/regions.js'; + export function ShellFrame(props: { readonly region: RegionModel }) { + return
; + } + `, + 'src/renderer/features/alpha/index.ts': ` + export { AlphaHost } from './ui/alpha-host.js'; + export type { AlphaServices } from './ports.js'; + `, + 'src/renderer/features/alpha/ports.ts': ` + export interface AlphaServices { readonly read: () => Promise } + `, + 'src/renderer/features/alpha/testing.ts': ` + export function createFakeAlphaServices() { + return { read: async () => 'fixture' }; + } + `, + 'src/renderer/features/alpha/stories.ts': ` + export const alphaStoryModel = { id: 'alpha-story' }; + `, + 'src/renderer/features/alpha/ui/alpha-host.tsx': ` + import type { RegionModel } from '../../../application/contracts/regions.js'; + export function AlphaHost(props: RegionModel) { + return
{props.id}
; + } + `, + 'src/renderer/platform/desktop/create-alpha-services.ts': ` + import type { AlphaServices } from '../../features/alpha/index.js'; + export function createAlphaServices(): AlphaServices { + return { read: async () => 'desktop' }; + } + `, + 'src/renderer/composition/desktop-application.tsx': ` + import { AlphaHost } from '../features/alpha/index.js'; + import { createAlphaServices } from '../platform/desktop/create-alpha-services.js'; + import { ShellFrame } from '../shell/shell-frame.js'; + void createAlphaServices; + export function DesktopApplication() { + return <>; + } + `, + 'src/main/__tests__/alpha-boundary.test.ts': ` + import { createFakeAlphaServices } from '../../renderer/features/alpha/testing.js'; + void createFakeAlphaServices; + `, + 'stories/alpha.stories.tsx': ` + import { createFakeAlphaServices } from '../src/renderer/features/alpha/testing.js'; + import { alphaStoryModel } from '../src/renderer/features/alpha/stories.js'; + export const services = createFakeAlphaServices(); + export const model = alphaStoryModel; + `, + }, + (desktopRoot) => { + assert.deepEqual(violationsFor(desktopRoot), []); + }, + ); + }); + + it('resolves @maka/desktop self-imports before enforcing zone boundaries', async () => { + await withDesktopFixture( + { + 'src/renderer/features/alpha/controller.ts': ` + import { desktopOnly } from '@maka/desktop/src/renderer/platform/desktop/private.js'; + export const alpha = desktopOnly; + `, + 'src/renderer/shell/shell-frame.ts': ` + import { BetaHost } from '@maka/desktop/src/renderer/features/beta/index.js'; + export const frame = BetaHost; + `, + 'src/renderer/application/session/session-scope.ts': ` + import { BetaHost } from '@maka/desktop/src/renderer/features/beta/index.js'; + export const scope = BetaHost; + `, + 'src/renderer/features/beta/index.ts': 'export const BetaHost = true;', + 'src/renderer/platform/desktop/private.ts': 'export const desktopOnly = true;', + }, + (desktopRoot) => { + const violations = violationsFor(desktopRoot); + assertHasViolation( + violations, + /features\/alpha\/controller\.ts: feature imports a forbidden Desktop\/shell module/u, + ); + assertHasViolation( + violations, + /shell\/shell-frame\.ts: shell imports forbidden implementation module/u, + ); + assertHasViolation( + violations, + /application\/session\/session-scope\.ts: application authority imports an outer implementation/u, + ); + }, + ); + }); + + it('rejects direct browser environment access in shell and composition files', async () => { + await withDesktopFixture( + { + 'src/renderer/shell/local-storage.ts': ` + export const persisted = localStorage.getItem('shell-layout'); + `, + 'src/renderer/shell/window-listener.ts': ` + window.addEventListener('resize', () => undefined); + `, + 'src/renderer/composition/fetch.ts': ` + export const request = fetch('/renderer-bootstrap'); + `, + 'src/renderer/composition/timers.ts': ` + setTimeout(() => undefined, 1); + requestAnimationFrame(() => undefined); + `, + 'src/renderer/shell/aliased-environment.ts': ` + const later = setTimeout; + const retrieve = fetch; + later(() => undefined, 1); + void retrieve('/shell-data'); + `, + 'src/renderer/composition/browser-globals.ts': ` + void indexedDB.open('maka'); + history.pushState({}, '', '/'); + void new FileReader(); + addEventListener('online', () => undefined); + `, + }, + (desktopRoot) => { + const violations = violationsFor(desktopRoot); + for (const path of [ + 'src/renderer/shell/local-storage.ts', + 'src/renderer/shell/window-listener.ts', + 'src/renderer/composition/fetch.ts', + 'src/renderer/composition/timers.ts', + 'src/renderer/shell/aliased-environment.ts', + 'src/renderer/composition/browser-globals.ts', + ]) { + assertHasViolation( + violations, + new RegExp( + `^${path.replaceAll('/', '\\/').replaceAll('.', '\\.')}.*directly accesses browser environment capabilities$`, + 'u', + ), + ); + } + }, + ); + }); + + it('resolves browser-global shadowing lexically instead of masking the whole file', () => { + const analysis = analyzeRendererSource( + ` + function useInjected(fetch, history, setTimeout) { + fetch('/injected'); + history.pushState({}, '', '/injected'); + setTimeout(() => undefined, 1); + } + void useInjected; + fetch('/global'); + history.pushState({}, '', '/global'); + setTimeout(() => undefined, 1); + `, + 'src/renderer/shell/lexical-browser-globals.ts', + ); + + assert.equal(analysis.environmentCapabilities.fetch, 1); + assert.equal(analysis.environmentCapabilities['history.pushState'], 1); + assert.equal(analysis.environmentCapabilities.setTimeout, 1); + }); + + it('rejects computed and optional access to the Desktop bridge in strict zones', async () => { + await withDesktopFixture( + { + 'src/renderer/application/computed-bridge.ts': ` + void window['maka'].sessions.list(); + `, + 'src/renderer/application/optional-bridge.ts': ` + void window?.maka?.sessions?.subscribeEvents?.(() => undefined); + `, + 'src/renderer/application/dynamic-global.ts': ` + const bridgeName = 'maka'; + void window[bridgeName]; + `, + }, + (desktopRoot) => { + const violations = violationsFor(desktopRoot); + assertHasViolation( + violations, + /^src\/renderer\/application\/computed-bridge\.ts: application code accesses the Desktop global bridge$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/application\/optional-bridge\.ts: application code accesses the Desktop global bridge$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/application\/dynamic-global\.ts: application code contains non-static global environment access$/u, + ); + }, + ); + }); + + it('rejects cross-feature, deep feature, and production testing imports', async () => { + await withDesktopFixture( + { + 'src/renderer/features/alpha/controller.ts': ` + export async function loadBeta() { + return import('../beta/index.js'); + } + `, + 'src/renderer/features/beta/index.ts': ` + export const beta = true; + `, + 'src/renderer/composition/deep-feature.ts': ` + const alphaController = require('../features/alpha/controller/use-alpha.js'); + void alphaController; + `, + 'src/renderer/composition/testing-in-production.ts': ` + import { createFakeAlphaServices } from '../features/alpha/testing.js'; + void createFakeAlphaServices; + `, + }, + (desktopRoot) => { + const violations = violationsFor(desktopRoot); + assertHasViolation( + violations, + /^src\/renderer\/features\/alpha\/controller\.ts: feature alpha imports feature beta:/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/composition\/deep-feature\.ts: feature imports must use index:/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/composition\/testing-in-production\.ts: production code imports a feature testing entry:/u, + ); + }, + ); + }); + + it('rejects a feature production module importing its own testing entry', async () => { + await withDesktopFixture( + { + 'src/renderer/features/alpha/controller.ts': ` + import { fakeAlpha } from './testing.js'; + export const alpha = fakeAlpha; + `, + 'src/renderer/features/alpha/testing.ts': 'export const fakeAlpha = true;', + }, + (desktopRoot) => { + assertHasViolation( + violationsFor(desktopRoot), + /features\/alpha\/controller\.ts:.*testing entry/u, + ); + }, + ); + }); + + it('keeps feature stories entries test-only', async () => { + await withDesktopFixture( + { + 'src/renderer/features/alpha/stories.ts': 'export const storyModel = true;', + 'src/renderer/composition/stories-in-production.ts': ` + import { storyModel } from '../features/alpha/stories.js'; + export const leakedStoryModel = storyModel; + `, + }, + (desktopRoot) => { + assertHasViolation( + violationsFor(desktopRoot), + /composition\/stories-in-production\.ts: production code imports a feature stories entry/u, + ); + }, + ); + }); + + it('rejects an application contract re-exporting application implementation', async () => { + await withDesktopFixture( + { + 'src/renderer/application/contracts/index.ts': ` + export { sessionStore } from '../session/session-store.js'; + `, + 'src/renderer/application/session/session-store.ts': ` + export const sessionStore = true; + `, + }, + (desktopRoot) => { + assertHasViolation( + violationsFor(desktopRoot), + /application\/contracts\/index\.ts:.*(?:contract|contracts).*implementation/iu, + ); + }, + ); + }); + + it('rejects composition and Desktop adapters importing deep application implementation', async () => { + await withDesktopFixture( + { + 'src/renderer/application/session/session-store.ts': `export const sessionStore = true;`, + 'src/renderer/composition/deep-application.ts': ` + import { sessionStore } from '../application/session/session-store.js'; + export const composed = sessionStore; + `, + 'src/renderer/platform/desktop/deep-application.ts': ` + import { sessionStore } from '../../application/session/session-store.js'; + export const adapted = sessionStore; + `, + }, + (desktopRoot) => { + const violations = violationsFor(desktopRoot); + assertHasViolation( + violations, + /composition\/deep-application\.ts: composition imports application implementation instead of a public entry/u, + ); + assertHasViolation( + violations, + /platform\/desktop\/deep-application\.ts: Desktop adapter imports application implementation instead of a public entry/u, + ); + }, + ); + }); + + it('rejects Electron and node built-in imports from strict feature code', async () => { + await withDesktopFixture( + { + 'src/renderer/features/alpha/electron-import.ts': ` + import { ipcRenderer } from 'electron'; + export const ipc = ipcRenderer; + `, + 'src/renderer/features/alpha/node-import.ts': ` + import { readFile } from 'node:fs/promises'; + export const read = readFile; + `, + 'src/renderer/features/alpha/controller.test.ts': ` + import { strict as assert } from 'node:assert'; + assert.equal(1, 1); + `, + }, + (desktopRoot) => { + const violations = violationsFor(desktopRoot); + assertHasViolation( + violations, + /features\/alpha\/electron-import\.ts:.*feature.*electron/iu, + ); + assertHasViolation( + violations, + /features\/alpha\/node-import\.ts:.*feature.*node:fs/iu, + ); + assert.ok( + !violations.some((violation) => violation.includes('controller.test.ts')), + `Feature tests may use the Node test environment:\n${violations.join('\n')}`, + ); + }, + ); + }); + + it('keeps Desktop platform adapters free of UI lifecycle and runtime module loading', async () => { + await withDesktopFixture( + { + 'src/renderer/platform/desktop/stateful-view.tsx': ` + import * as React from 'react'; + import { useState } from 'react'; + export function StatefulView() { + const [open] = useState(false); + return
{String(open)}
; + } + export class StatefulAdapter extends React.Component { + componentDidMount() {} + render() { return null; } + } + `, + 'src/renderer/platform/desktop/dynamic-adapter.ts': ` + export function loadAdapter(path: string) { return import(path); } + `, + 'src/renderer/platform/desktop/electron-adapter.ts': ` + import { ipcRenderer } from 'electron'; + export const ipc = ipcRenderer; + `, + 'src/renderer/platform/desktop/node-adapter.ts': ` + import { readFile } from 'node:fs/promises'; + export const read = readFile; + `, + 'src/renderer/platform/desktop/allowed-capabilities.ts': ` + export function createAllowedCapabilities() { + localStorage.getItem('desktop-adapter'); + return window.maka.sessions.list(); + } + `, + }, + (desktopRoot) => { + const violations = violationsFor(desktopRoot); + assertHasViolation( + violations, + /platform\/desktop\/stateful-view\.tsx: platform code owns stateful React hooks/u, + ); + assertHasViolation( + violations, + /platform\/desktop\/stateful-view\.tsx: platform code owns React class lifecycle methods/u, + ); + assertHasViolation( + violations, + /platform\/desktop\/stateful-view\.tsx: Desktop adapters cannot own React UI/u, + ); + assertHasViolation( + violations, + /platform\/desktop\/dynamic-adapter\.ts: platform code contains a non-static import or require/u, + ); + assertHasViolation( + violations, + /platform\/desktop\/electron-adapter\.ts: platform code imports forbidden environment module: electron/u, + ); + assertHasViolation( + violations, + /platform\/desktop\/node-adapter\.ts: platform code imports forbidden environment module: node:fs\/promises/u, + ); + assert.ok( + !violations.some((violation) => violation.includes('allowed-capabilities.ts')), + `Desktop adapters may own the bridge and browser environment:\n${violations.join('\n')}`, + ); + }, + ); + }); + + it('rejects new AppShell-family helper files outside the debt ledger', async () => { + await withDesktopFixture( + { + 'src/renderer/app-shell-fresh-owner.ts': 'export const owner = true;', + 'src/renderer/use-app-shell-fresh-owner.ts': 'export const owner = true;', + }, + (desktopRoot) => { + const violations = violationsFor(desktopRoot); + assertHasViolation( + violations, + /^legacy AppShell file set changed;.*app-shell-fresh-owner\.ts.*use-app-shell-fresh-owner\.ts/u, + ); + }, + ); + }); + + it('generates the transitive legacy closure owned directly by AppShell', async () => { + const helperSource = ` + export const legacySessionHelper = 'legacy-session'; + `; + + await withDesktopFixture( + transitiveAppShellFiles(helperSource), + (desktopRoot) => { + const generated = generateArchitectureConfig( + desktopRoot, + transitiveAppShellSeedConfig(), + ); + + assert.deepEqual( + Object.keys(generated.legacyAppShell.closure), + [TRANSITIVE_LEGACY_HELPER_PATH], + ); + assert.deepEqual( + generated.legacyAppShell.closure[TRANSITIVE_LEGACY_HELPER_PATH], + capabilityDebtForSource(helperSource, TRANSITIVE_LEGACY_HELPER_PATH), + ); + }, + ); + }); + + it('prefers runtime source over a same-stem declaration in the AppShell closure', async () => { + const runtimePath = 'src/renderer/legacy-session-helper.js'; + const runtimeSource = ` + export function legacySessionHelper() { + return window.maka.sessions.list(); + } + `; + + await withDesktopFixture( + { + [TRANSITIVE_APP_SHELL_PATH]: ` + import { legacySessionHelper } from './legacy-session-helper.js'; + export const AppShell = legacySessionHelper; + `, + 'src/renderer/legacy-session-helper.d.ts': ` + export declare function legacySessionHelper(): Promise; + `, + [runtimePath]: runtimeSource, + }, + (desktopRoot) => { + const generated = generateArchitectureConfig( + desktopRoot, + transitiveAppShellSeedConfig(), + ); + + assert.deepEqual(Object.keys(generated.legacyAppShell.closure), [runtimePath]); + assert.deepEqual( + generated.legacyAppShell.closure[runtimePath], + capabilityDebtForSource(runtimeSource, runtimePath), + ); + }, + ); + }); + + it('ratchets legacy helpers reachable through a feature public ownership boundary', async () => { + await withDesktopFixture( + { + [TRANSITIVE_APP_SHELL_PATH]: ` + import { AlphaHost } from './features/alpha/index.js'; + export const AppShell = AlphaHost; + `, + 'src/renderer/features/alpha/index.ts': ` + import { legacySessionHelper } from '../../legacy-session-helper.js'; + export const AlphaHost = legacySessionHelper; + `, + [TRANSITIVE_LEGACY_HELPER_PATH]: ` + import { useState } from 'react'; + export function legacySessionHelper() { + void window.maka.sessions.list(); + localStorage.getItem('feature-owned'); + return useState(false); + } + `, + }, + (desktopRoot) => { + const currentConfig = generateArchitectureConfig( + desktopRoot, + transitiveAppShellSeedConfig(), + ); + assert.deepEqual( + Object.keys(currentConfig.legacyAppShell.closure), + [TRANSITIVE_LEGACY_HELPER_PATH], + ); + + const baseConfig = structuredClone(currentConfig); + const baseHelperDebt = baseConfig.legacyAppShell.closure[TRANSITIVE_LEGACY_HELPER_PATH]; + baseHelperDebt.hookCalls = {}; + baseHelperDebt.bridgePaths = {}; + baseHelperDebt.environmentCapabilities = {}; + + const violations = violationsFor(desktopRoot, currentConfig, baseConfig); + assertHasViolation( + violations, + /^src\/renderer\/legacy-session-helper\.ts: new or increased hookCalls debt useState$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/legacy-session-helper\.ts: new or increased bridgePaths debt window\.maka\.sessions\.list$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/legacy-session-helper\.ts: new or increased environmentCapabilities debt localStorage\.getItem$/u, + ); + }, + ); + }); + + it('ratchets legacy helpers reachable through a shared Desktop boundary', async () => { + await withDesktopFixture( + { + [TRANSITIVE_APP_SHELL_PATH]: ` + import { rootBridge } from '../shared/root-bridge.js'; + export const AppShell = rootBridge; + `, + 'src/shared/root-bridge.ts': ` + import { legacySessionHelper } from '../renderer/legacy-session-helper.js'; + export const rootBridge = legacySessionHelper; + `, + [TRANSITIVE_LEGACY_HELPER_PATH]: ` + export function legacySessionHelper() { + return window.maka.sessions.list(); + } + `, + }, + (desktopRoot) => { + const currentConfig = generateArchitectureConfig( + desktopRoot, + transitiveAppShellSeedConfig(), + ); + assert.deepEqual( + Object.keys(currentConfig.legacyAppShell.closure), + [TRANSITIVE_LEGACY_HELPER_PATH, 'src/shared/root-bridge.ts'], + ); + + const baseConfig = structuredClone(currentConfig); + baseConfig.legacyAppShell.closure[TRANSITIVE_LEGACY_HELPER_PATH].bridgePaths = {}; + + assertHasViolation( + violationsFor(desktopRoot, currentConfig, baseConfig), + /^src\/renderer\/legacy-session-helper\.ts: new or increased bridgePaths debt window\.maka\.sessions\.list$/u, + ); + }, + ); + }); + + it('ratchets capability debt owned directly by a shared Desktop intermediary', async () => { + const sharedPath = 'src/shared/root-bridge.ts'; + await withDesktopFixture( + { + [TRANSITIVE_APP_SHELL_PATH]: ` + import { rootBridge } from '../shared/root-bridge.js'; + export const AppShell = rootBridge; + `, + [sharedPath]: ` + import { useState } from 'react'; + export function rootBridge() { + void window.maka.sessions.list(); + localStorage.getItem('shared-root-state'); + return useState(false); + } + `, + }, + (desktopRoot) => { + const currentConfig = generateArchitectureConfig( + desktopRoot, + transitiveAppShellSeedConfig(), + ); + assert.deepEqual(Object.keys(currentConfig.legacyAppShell.closure), [sharedPath]); + + const baseConfig = structuredClone(currentConfig); + const baseSharedDebt = baseConfig.legacyAppShell.closure[sharedPath]; + baseSharedDebt.hookCalls = {}; + baseSharedDebt.bridgePaths = {}; + baseSharedDebt.environmentCapabilities = {}; + + const violations = violationsFor(desktopRoot, currentConfig, baseConfig); + assertHasViolation( + violations, + /^src\/shared\/root-bridge\.ts: new or increased hookCalls debt useState$/u, + ); + assertHasViolation( + violations, + /^src\/shared\/root-bridge\.ts: new or increased bridgePaths debt window\.maka\.sessions\.list$/u, + ); + assertHasViolation( + violations, + /^src\/shared\/root-bridge\.ts: new or increased environmentCapabilities debt localStorage\.getItem$/u, + ); + }, + ); + }); + + it('allows support debt to leave the AppShell closure but rejects the reverse ownership regression', async () => { + const mainPath = 'src/renderer/main.tsx'; + const compositionPath = 'src/renderer/composition/desktop-application.ts'; + const sharedPath = 'src/shared/root-bridge.ts'; + const appShellOwnsSupport = ` + import { rootBridge } from '../shared/root-bridge.js'; + export const AppShell = rootBridge; + `; + const compositionUsesAppShell = ` + import { AppShell } from '../app-shell.js'; + export const DesktopApplication = AppShell; + `; + const compositionOwnsSupport = ` + import { rootBridge } from '../../shared/root-bridge.js'; + export const DesktopApplication = rootBridge; + `; + + await withDesktopFixture( + { + [mainPath]: ` + import { DesktopApplication } from './composition/desktop-application.js'; + export const main = DesktopApplication; + `, + [compositionPath]: compositionUsesAppShell, + [TRANSITIVE_APP_SHELL_PATH]: appShellOwnsSupport, + [sharedPath]: `export const rootBridge = window.maka.sessions.list();`, + }, + async (desktopRoot) => { + const seedConfig = architectureConfig({ + rootDebt: { [mainPath]: emptyDebt() }, + ownership: [ + { + capability: 'fixture-app-shell', + targetZone: 'shell', + legacyPaths: [TRANSITIVE_APP_SHELL_PATH], + }, + { + capability: 'fixture-root', + targetZone: 'bootstrap', + legacyPaths: [mainPath], + }, + ], + }); + const appShellOwnedConfig = generateArchitectureConfig(desktopRoot, seedConfig); + assert.deepEqual(Object.keys(appShellOwnedConfig.legacyAppShell.closure), [sharedPath]); + assert.deepEqual(Object.keys(appShellOwnedConfig.rootDebtClosure), []); + + await writeFile(join(desktopRoot, compositionPath), compositionOwnsSupport, 'utf8'); + await writeFile( + join(desktopRoot, TRANSITIVE_APP_SHELL_PATH), + `export const AppShell = true;`, + 'utf8', + ); + const rootOwnedConfig = generateArchitectureConfig(desktopRoot, appShellOwnedConfig); + assert.deepEqual(Object.keys(rootOwnedConfig.legacyAppShell.closure), []); + assert.deepEqual(Object.keys(rootOwnedConfig.rootDebtClosure), [sharedPath]); + assert.deepEqual( + violationsFor(desktopRoot, rootOwnedConfig, appShellOwnedConfig), + [], + ); + + await writeFile(join(desktopRoot, compositionPath), compositionUsesAppShell, 'utf8'); + await writeFile( + join(desktopRoot, TRANSITIVE_APP_SHELL_PATH), + appShellOwnsSupport, + 'utf8', + ); + const regressedConfig = generateArchitectureConfig(desktopRoot, rootOwnedConfig); + assertHasViolation( + violationsFor(desktopRoot, regressedConfig, rootOwnedConfig), + /^src\/shared\/root-bridge\.ts: new legacyAppShellClosure debt entries are forbidden$/u, + ); + }, + ); + }); + + it('rejects stateful hook growth inside a transitive legacy AppShell helper', async () => { + await withDesktopFixture( + transitiveAppShellFiles(` + import { useState } from 'react'; + export function legacySessionHelper() { + const [session] = useState('legacy-session'); + return session; + } + `), + (desktopRoot) => { + const currentConfig = generateArchitectureConfig( + desktopRoot, + transitiveAppShellSeedConfig(), + ); + const baseConfig = structuredClone(currentConfig); + baseConfig.legacyAppShell.closure[TRANSITIVE_LEGACY_HELPER_PATH].hookCalls = {}; + + const violations = violationsFor(desktopRoot, currentConfig, baseConfig); + assertHasViolation( + violations, + /^src\/renderer\/legacy-session-helper\.ts: hookCalls debt increased from 0 to 1$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/legacy-session-helper\.ts: new or increased hookCalls debt useState$/u, + ); + }, + ); + }); + + it('rejects a feature controller Hook returning to AppShell after provider migration', async () => { + const providerOwnedAppShell = ` + import { GoalProvider } from './features/goals/index.js'; + export const AppShell = GoalProvider; + `; + await withDesktopFixture( + { + [TRANSITIVE_APP_SHELL_PATH]: providerOwnedAppShell, + 'src/renderer/features/goals/index.ts': ` + export const GoalProvider = true; + export function useGoalController() { return true; } + `, + }, + async (desktopRoot) => { + const seedConfig = transitiveAppShellSeedConfig(); + const providerOwnedConfig = generateArchitectureConfig(desktopRoot, seedConfig); + + await writeFile( + join(desktopRoot, TRANSITIVE_APP_SHELL_PATH), + ` + import { GoalProvider, useGoalController } from './features/goals/index.js'; + export const AppShell = [GoalProvider, useGoalController()]; + `, + 'utf8', + ); + const regressedConfig = generateArchitectureConfig( + desktopRoot, + providerOwnedConfig, + ); + const violations = violationsFor( + desktopRoot, + regressedConfig, + providerOwnedConfig, + ); + + assertHasViolation( + violations, + /^src\/renderer\/app-shell\.ts: hookCalls debt increased from 0 to 1$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/app-shell\.ts: new or increased hookCalls debt useGoalController$/u, + ); + }, + ); + }); + it('rejects bridge and environment capability growth inside a transitive legacy AppShell helper', async () => { + await withDesktopFixture( + transitiveAppShellFiles(` + export function legacySessionHelper() { + void window.maka.sessions.list(); + return localStorage.getItem('legacy-session'); + } + `), + (desktopRoot) => { + const currentConfig = generateArchitectureConfig( + desktopRoot, + transitiveAppShellSeedConfig(), + ); + const baseConfig = structuredClone(currentConfig); + const baseHelperDebt = baseConfig.legacyAppShell.closure[TRANSITIVE_LEGACY_HELPER_PATH]; + baseHelperDebt.bridgePaths = {}; + baseHelperDebt.environmentCapabilities = {}; + + const violations = violationsFor(desktopRoot, currentConfig, baseConfig); + assertHasViolation( + violations, + /^src\/renderer\/legacy-session-helper\.ts: new or increased bridgePaths debt window\.maka\.sessions\.list$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/legacy-session-helper\.ts: new or increased environmentCapabilities debt localStorage\.getItem$/u, + ); + }, + ); + }); + + it('rejects dependency growth inside a transitive legacy AppShell helper', async () => { + const dependencyPath = 'src/renderer/legacy-session-store.ts'; + await withDesktopFixture( + transitiveAppShellFiles( + ` + import { legacySessionStore } from './legacy-session-store.js'; + export const legacySessionHelper = legacySessionStore; + `, + { + [dependencyPath]: `export const legacySessionStore = 'legacy-session';`, + }, + ), + (desktopRoot) => { + const currentConfig = generateArchitectureConfig( + desktopRoot, + transitiveAppShellSeedConfig(), + ); + const baseConfig = structuredClone(currentConfig); + baseConfig.legacyAppShell.closure[TRANSITIVE_LEGACY_HELPER_PATH].dependencyPaths = {}; + + const violations = violationsFor(desktopRoot, currentConfig, baseConfig); + assertHasViolation( + violations, + /^src\/renderer\/legacy-session-helper\.ts: dependencyPaths debt increased from 0 to 1$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/legacy-session-helper\.ts: new dependency debt \.\/legacy-session-store\.js$/u, + ); + }, + ); + }); + + it('rejects a stale AppShell closure ledger when another legacy file becomes reachable', async () => { + const newlyReachablePath = 'src/renderer/legacy-session-store.ts'; + await withDesktopFixture( + transitiveAppShellFiles(` + export const legacySessionHelper = 'legacy-session'; + `), + async (desktopRoot) => { + const staleConfig = generateArchitectureConfig( + desktopRoot, + transitiveAppShellSeedConfig(), + ); + const expandedHelperSource = ` + import { legacySessionStore } from './legacy-session-store.js'; + export const legacySessionHelper = legacySessionStore; + `; + await writeFile( + join(desktopRoot, TRANSITIVE_LEGACY_HELPER_PATH), + expandedHelperSource, + 'utf8', + ); + await writeFile( + join(desktopRoot, newlyReachablePath), + `export const legacySessionStore = 'legacy-session';`, + 'utf8', + ); + staleConfig.legacyRendererFiles = [ + ...staleConfig.legacyRendererFiles, + newlyReachablePath, + ].sort(); + staleConfig.legacyAppShell.closure[TRANSITIVE_LEGACY_HELPER_PATH] = capabilityDebtForSource( + expandedHelperSource, + TRANSITIVE_LEGACY_HELPER_PATH, + ); + + assertHasViolation( + violationsFor(desktopRoot, staleConfig), + /legacy AppShell transitive renderer closure changed;.*legacy-session-store\.ts/u, + ); + }, + ); + }); + + it('rejects non-static dependency escape hatches inside the AppShell legacy closure', async () => { + await withDesktopFixture( + transitiveAppShellFiles( + ` + export const hiddenLegacyOwners = import.meta.glob('./legacy-session-store.ts'); + `, + { + 'src/renderer/legacy-session-store.ts': ` + void window.maka.sessions.list(); + `, + }, + ), + (desktopRoot) => { + assert.throws( + () => generateArchitectureConfig(desktopRoot, transitiveAppShellSeedConfig()), + /legacy-session-helper\.ts: AppShell closure contains a non-static import, require, or import\.meta glob/u, + ); + }, + ); + }); + + it('rejects stateful shell hooks and imports from legacy implementation', async () => { + await withDesktopFixture( + { + 'src/renderer/legacy-session-owner.ts': 'export const legacySessionOwner = true;', + 'src/renderer/shell/shell-frame.tsx': ` + import * as React from 'react'; + import { legacySessionOwner } from '../legacy-session-owner.js'; + export function ShellFrame() { + const [open] = React.useState(legacySessionOwner); + return
; + } + `, + 'src/renderer/shell/custom-hook.ts': ` + import { useExternalSession } from '@example/session-hooks'; + export const session = useExternalSession(); + `, + 'src/renderer/shell/namespace-hook.ts': ` + import * as hooks from '@example/session-hooks'; + export const session = hooks.useExternalSession(); + `, + 'src/renderer/shell/react-namespace-alias.ts': ` + import * as React from 'react'; + const R = React; + export const state = R.useState(false); + `, + 'src/renderer/shell/react-19-hooks.tsx': ` + import { useActionState, useInsertionEffect, useOptimistic, useTransition } from 'react'; + export function StatefulFrame() { + useActionState(async (state: number) => state, 0); + useInsertionEffect(() => undefined, []); + useOptimistic(0); + useTransition(); + return null; + } + `, + 'src/renderer/composition/class-lifecycle.tsx': ` + import * as React from 'react'; + export class DesktopComposition extends React.Component { + componentDidMount() {} + render() { return null; } + } + `, + 'src/renderer/composition/class-field-lifecycle.tsx': ` + import * as React from 'react'; + export class DesktopComposition extends React.Component { + componentDidMount = () => {}; + render() { return null; } + } + `, + 'src/renderer/composition/class-state.tsx': ` + import * as React from 'react'; + export class StatefulComposition extends React.Component { + constructor() { + super({}); + this.state = { mounted: false }; + } + render() { return null; } + } + `, + 'src/renderer/composition/feature-namespace-hook.ts': ` + import * as session from '../features/alpha/index.js'; + export const controller = session.useSessionController(); + `, + 'src/renderer/features/alpha/index.ts': ` + export function useSessionController() { return {}; } + `, + 'src/renderer/composition/plain-class.ts': ` + export class DomainLifecycleName { + componentDidMount() { return 'not React'; } + } + `, + }, + (desktopRoot) => { + const violations = violationsFor(desktopRoot); + assertHasViolation( + violations, + /^src\/renderer\/shell\/shell-frame\.tsx: shell code owns stateful React hooks$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/shell\/shell-frame\.tsx: shell imports forbidden implementation module:/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/shell\/custom-hook\.ts: shell code owns stateful React hooks$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/shell\/namespace-hook\.ts: shell code owns stateful React hooks$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/shell\/react-namespace-alias\.ts: shell code owns stateful React hooks$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/shell\/react-19-hooks\.tsx: shell code owns stateful React hooks$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/composition\/class-lifecycle\.tsx: composition code owns React class lifecycle methods$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/composition\/class-field-lifecycle\.tsx: composition code owns React class lifecycle methods$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/composition\/class-state\.tsx: composition code owns React class lifecycle methods$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/composition\/feature-namespace-hook\.ts: composition code owns stateful React hooks$/u, + ); + assert.ok( + !violations.some((violation) => violation.includes('plain-class.ts')), + `Non-React classes must not be treated as React lifecycle owners:\n${violations.join('\n')}`, + ); + }, + ); + }); + + it('rejects bridge debt that grows relative to the base ledger', async () => { + const debtPath = 'src/renderer/legacy-root.ts'; + const currentConfig = architectureConfig({ + rootDebt: { + [debtPath]: emptyDebt({ bridgePaths: { 'window.maka.sessions.list': 1 } }), + }, + }); + const baseConfig = architectureConfig({ + rootDebt: { + [debtPath]: emptyDebt(), + }, + }); + + await withDesktopFixture( + { + [debtPath]: 'void window.maka.sessions.list();', + }, + (desktopRoot) => { + const violations = violationsFor(desktopRoot, currentConfig, baseConfig); + assertHasViolation( + violations, + /^src\/renderer\/legacy-root\.ts: bridgePaths debt increased from 0 to 1$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/legacy-root\.ts: new or increased bridgePaths debt window\.maka\.sessions\.list$/u, + ); + }, + ); + }); + + it('accepts the single pinned renderer module entry', async () => { + await withDesktopFixture( + rendererEntryContractFiles(), + (desktopRoot) => { + const config = generateArchitectureConfig(desktopRoot, rendererEntrySeedConfig()); + assert.deepEqual(checkRendererArchitecture({ config, desktopRoot }), []); + }, + ); + }); + + it('rejects replacing the pinned renderer module entry with an alternate source', async () => { + await withDesktopFixture( + rendererEntryContractFiles({ + 'src/renderer/index.html': ` + + + `, + 'src/renderer/settings/alternate-entry.tsx': ` + void window.maka.sessions.list(); + export const alternateEntry = true; + `, + }), + (desktopRoot) => { + const config = generateArchitectureConfig(desktopRoot, rendererEntrySeedConfig()); + assertHasViolation( + checkRendererArchitecture({ config, desktopRoot }), + /^src\/renderer\/index\.html: renderer entry contract requires exactly one empty external module script for \/main\.tsx$/u, + ); + }, + ); + }); + + it('rejects adding another renderer module entry beside the pinned source', async () => { + await withDesktopFixture( + rendererEntryContractFiles({ + 'src/renderer/index.html': ` + + + + + + `, + 'src/renderer/settings/alternate-entry.tsx': `export const alternateEntry = true;`, + }), + (desktopRoot) => { + const config = generateArchitectureConfig(desktopRoot, rendererEntrySeedConfig()); + assertHasViolation( + checkRendererArchitecture({ config, desktopRoot }), + /^src\/renderer\/index\.html: renderer entry contract requires exactly one empty external module script for \/main\.tsx$/u, + ); + }, + ); + }); + + it('rejects changing the packaged main-window loader to an alternate renderer document', async () => { + await withDesktopFixture( + rendererEntryContractFiles({ + 'src/main/main-renderer-loader.ts': ` + import { join } from 'node:path'; + import { pathToFileURL } from 'node:url'; + interface MainRendererWindow { + loadFile(path: string): Promise; + loadURL(url: string): Promise; + } + interface MainRendererEntry { + readonly filePath: string; + readonly url: string; + readonly useDevServer: boolean; + } + export function resolveMainRendererEntry( + mainModuleDirectory: string, + viteDevServerUrl: string | undefined, + ): MainRendererEntry { + const rendererEntryPath = join(mainModuleDirectory, '..', '..', 'dist-renderer', 'alternate.html'); + const rendererEntryUrl = viteDevServerUrl ?? pathToFileURL(rendererEntryPath).href; + return Object.freeze({ + filePath: rendererEntryPath, + url: rendererEntryUrl, + useDevServer: !!viteDevServerUrl, + }); + } + export async function loadMainRenderer( + mainWindow: MainRendererWindow, + rendererEntry: MainRendererEntry, + ): Promise { + if (rendererEntry.useDevServer) { + await mainWindow.loadURL(rendererEntry.url); + } else { + await mainWindow.loadFile(rendererEntry.filePath); + } + } + `, + }), + (desktopRoot) => { + const config = generateArchitectureConfig(desktopRoot, rendererEntrySeedConfig()); + assertHasViolation( + checkRendererArchitecture({ config, desktopRoot }), + /^src\/main\/main-renderer-loader\.ts: renderer loader must load only the pinned dist-renderer\/index\.html entry$/u, + ); + }, + ); + }); + + it('rejects shadowing the pinned renderer path helpers behind local bindings', async () => { + const files = rendererEntryContractFiles(); + files['src/main/main-renderer-loader.ts'] = files['src/main/main-renderer-loader.ts'] + .replace("import { join } from 'node:path';", "import { join as pathJoin } from 'node:path';") + .replace( + "import { pathToFileURL } from 'node:url';", + ` + import { pathToFileURL as nodePathToFileURL } from 'node:url'; + function join(...parts) { + return pathJoin(...parts.slice(0, -1), 'alternate.html'); + } + function pathToFileURL(path) { + return nodePathToFileURL(path); + } + `, + ); + await withDesktopFixture(files, (desktopRoot) => { + const config = generateArchitectureConfig(desktopRoot, rendererEntrySeedConfig()); + assertHasViolation( + checkRendererArchitecture({ config, desktopRoot }), + /^src\/main\/main-renderer-loader\.ts: renderer loader must load only the pinned dist-renderer\/index\.html entry$/u, + ); + }); + }); + + it('rejects reassigning the resolved renderer entry before loading it', async () => { + const files = rendererEntryContractFiles(); + files['src/main/main-window.ts'] = files['src/main/main-window.ts'] + .replace('const rendererEntry =', 'let rendererEntry =') + .replace( + 'await loadMainRenderer(mainWindow, rendererEntry);', + ` + rendererEntry = { + filePath: '/alternate.html', + url: 'file:///alternate.html', + useDevServer: false, + }; + await loadMainRenderer(mainWindow, rendererEntry); + `, + ); + await withDesktopFixture(files, (desktopRoot) => { + const config = generateArchitectureConfig(desktopRoot, rendererEntrySeedConfig()); + assertHasViolation( + checkRendererArchitecture({ config, desktopRoot }), + /^src\/main\/main-window\.ts: main window must delegate exactly once to the pinned renderer loader$/u, + ); + }); + }); + + it('rejects mutating the frozen renderer entry before loading it', async () => { + const files = rendererEntryContractFiles(); + files['src/main/main-window.ts'] = files['src/main/main-window.ts'].replace( + 'await loadMainRenderer(mainWindow, rendererEntry);', + ` + rendererEntry.filePath = '/alternate.html'; + await loadMainRenderer(mainWindow, rendererEntry); + `, + ); + await withDesktopFixture(files, (desktopRoot) => { + const config = generateArchitectureConfig(desktopRoot, rendererEntrySeedConfig()); + assertHasViolation( + checkRendererArchitecture({ config, desktopRoot }), + /^src\/main\/main-window\.ts: main window must delegate exactly once to the pinned renderer loader$/u, + ); + }); + }); + + it('rejects aliasing the main window navigation API around the dedicated loader', async () => { + await withDesktopFixture( + rendererEntryContractFiles({ + 'src/main/main-window.ts': ` + import { loadMainRenderer, resolveMainRendererEntry } from './main-renderer-loader.js'; + async function createWindow() { + const rendererEntry = resolveMainRendererEntry(import.meta.dirname, process.env.VITE_DEV_SERVER_URL); + await loadMainRenderer(mainWindow, rendererEntry); + const alternateEntryPath = join(import.meta.dirname, '..', '..', 'dist-renderer', 'alternate.html'); + const loadAlternateEntry = mainWindow.loadFile.bind(mainWindow); + await loadAlternateEntry(alternateEntryPath); + } + `, + }), + (desktopRoot) => { + const config = generateArchitectureConfig(desktopRoot, rendererEntrySeedConfig()); + assertHasViolation( + checkRendererArchitecture({ config, desktopRoot }), + /^src\/main\/main-window\.ts: main window must delegate exactly once to the pinned renderer loader$/u, + ); + }, + ); + }); + + it('rejects a computed second navigation inside the dedicated loader branch', async () => { + const files = rendererEntryContractFiles(); + files['src/main/main-renderer-loader.ts'] = files['src/main/main-renderer-loader.ts'].replace( + 'await mainWindow.loadFile(rendererEntry.filePath);', + ` + await mainWindow.loadFile(rendererEntry.filePath); + await mainWindow['load' + 'File']( + rendererEntry.filePath.replace('index.html', 'alternate.html'), + ); + `, + ); + await withDesktopFixture(files, (desktopRoot) => { + const config = generateArchitectureConfig(desktopRoot, rendererEntrySeedConfig()); + assertHasViolation( + checkRendererArchitecture({ config, desktopRoot }), + /^src\/main\/main-renderer-loader\.ts: renderer loader must load only the pinned dist-renderer\/index\.html entry$/u, + ); + }); + }); + + it('rejects remapping the Vite renderer root to an alternate source tree', async () => { + await withDesktopFixture( + rendererEntryContractFiles({ + 'vite.config.ts': ` + export default defineConfig({ + root: 'src/renderer/settings/alternate-root', + build: { outDir: '../../../../dist-renderer' }, + }); + `, + }), + (desktopRoot) => { + const config = generateArchitectureConfig(desktopRoot, rendererEntrySeedConfig()); + assertHasViolation( + checkRendererArchitecture({ config, desktopRoot }), + /^vite\.config\.ts: Vite must build src\/renderer\/index\.html into dist-renderer without an input override$/u, + ); + }, + ); + }); + + it('rejects shadowing the pinned Vite config factory behind a local binding', async () => { + const files = rendererEntryContractFiles(); + files['vite.config.ts'] = files['vite.config.ts'] + .replace("import { defineConfig } from 'vite';", "import { defineConfig as viteDefineConfig } from 'vite';") + .replace( + "const REPO_ROOT = '/fixture';", + ` + const REPO_ROOT = '/fixture'; + function defineConfig(config) { + return viteDefineConfig(config); + } + `, + ); + await withDesktopFixture(files, (desktopRoot) => { + const config = generateArchitectureConfig(desktopRoot, rendererEntrySeedConfig()); + assertHasViolation( + checkRendererArchitecture({ config, desktopRoot }), + /^vite\.config\.ts: Vite must build src\/renderer\/index\.html into dist-renderer without an input override$/u, + ); + }); + }); + + it('rejects pointing the Vite entry guard at a different renderer root', async () => { + const files = rendererEntryContractFiles(); + files['vite.config.ts'] = files['vite.config.ts'].replace( + "rendererEntryContractPlugin(resolve(import.meta.dirname, 'src/renderer'))", + "rendererEntryContractPlugin(resolve(import.meta.dirname, 'src/renderer/settings/alternate-root'))", + ); + await withDesktopFixture(files, (desktopRoot) => { + const config = generateArchitectureConfig(desktopRoot, rendererEntrySeedConfig()); + assertHasViolation( + checkRendererArchitecture({ config, desktopRoot }), + /^vite\.config\.ts: Vite must build src\/renderer\/index\.html into dist-renderer without an input override$/u, + ); + }); + }); + + it('attests the canonical main source in the final Vite entry graph', () => { + assert.doesNotThrow(() => + runRendererEntryBundleContract([ + '\0vite/modulepreload-polyfill.js', + '/fixture/src/renderer/index.html?html-proxy&inline-css&index=0.css', + '/fixture/src/renderer/main.tsx', + ]), + ); + }); + + it('does not apply the renderer entry contract to Storybook builds', () => { + const plugin = rendererEntryContractPlugin('/fixture/src/renderer'); + plugin.configResolved({ root: '/fixture/storybook' }); + assert.doesNotThrow(() => + plugin.generateBundle.call( + { + emitFile() { + assert.fail('a non-renderer build must not emit a renderer attestation'); + }, + }, + {}, + {}, + ), + ); + }); + + it('rejects a Vite HTML transform that swaps the final entry graph', () => { + assert.throws( + () => + runRendererEntryBundleContract([ + '\0vite/modulepreload-polyfill.js', + '/fixture/src/renderer/settings/alternate-entry.tsx', + ]), + /renderer entry contract requires src\/renderer\/index\.html to import only src\/renderer\/main\.tsx/u, + ); + }); + + it('rejects executable HTML injected around the canonical module entry', () => { + assert.doesNotThrow(() => assertRendererEntryHtml(canonicalRendererEntryHtml())); + const emittedHtml = canonicalRendererEntryHtml().replace( + '/main.tsx', + './assets/index-canonical.js', + ); + assert.doesNotThrow(() => + assertRendererEntryHtml(emittedHtml, './assets/index-canonical.js'), + ); + assert.throws( + () => assertRendererEntryHtml(emittedHtml, './assets/index-other.js'), + /renderer entry HTML contract forbids transformed executable or navigation surfaces/u, + ); + assert.throws( + () => + assertRendererEntryHtml( + canonicalRendererEntryHtml( + '', + "script-src 'self' 'unsafe-inline'", + ), + ), + /renderer entry HTML contract forbids transformed executable or navigation surfaces/u, + ); + assert.throws( + () => + assertRendererEntryHtml( + canonicalRendererEntryHtml(''), + ), + /renderer entry HTML contract forbids transformed executable or navigation surfaces/u, + ); + }); + + it('rejects a newly allowlisted root debt file relative to the base ledger', async () => { + const debtPath = 'src/renderer/new-root-debt.ts'; + const currentConfig = architectureConfig({ + rootDebt: { [debtPath]: emptyDebt() }, + }); + const baseConfig = architectureConfig(); + + await withDesktopFixture( + { + [debtPath]: 'export const newRootDebt = true;', + }, + (desktopRoot) => { + const violations = violationsFor(desktopRoot, currentConfig, baseConfig); + assertHasViolation( + violations, + /^src\/renderer\/new-root-debt\.ts: new rootDebt debt entries are forbidden$/u, + ); + }, + ); + }); + + it('rejects deleting root debt while the existing root entry is still dirty', async () => { + const debtPath = 'src/renderer/main.tsx'; + const currentConfig = architectureConfig({ legacyRendererFiles: [debtPath] }); + const baseConfig = architectureConfig({ + legacyRendererFiles: [debtPath], + rootDebt: { + [debtPath]: emptyDebt({ + bridgePaths: { 'window.maka.sessions.list': 1 }, + hookCalls: { useEffect: 1 }, + }), + }, + }); + + await withDesktopFixture( + { + [debtPath]: ` + import { useEffect } from 'react'; + void window.maka.sessions.list(); + export function RendererEntry() { useEffect(() => undefined, []); return null; } + `, + }, + (desktopRoot) => { + const violations = violationsFor(desktopRoot, currentConfig, baseConfig); + assertHasViolation( + violations, + /^src\/renderer\/main\.tsx: permanent root entry guard cannot be removed while the source exists$/u, + ); + }, + ); + }); + + it('rejects removing the permanent root guard while a clean thin entry still exists', async () => { + const debtPath = 'src/renderer/main.tsx'; + const currentConfig = architectureConfig({ legacyRendererFiles: [debtPath] }); + const baseConfig = architectureConfig({ + legacyRendererFiles: [debtPath], + rootDebt: { + [debtPath]: emptyDebt({ + bridgePaths: { 'window.maka.onboarding.getSnapshot': 1 }, + hookCalls: { useEffect: 1 }, + }), + }, + }); + + await withDesktopFixture( + { + [debtPath]: ` + import { mountDesktopApplication } from './bootstrap/mount-desktop-application.js'; + mountDesktopApplication(); + `, + 'src/renderer/bootstrap/mount-desktop-application.ts': ` + export function mountDesktopApplication() {} + `, + }, + (desktopRoot) => { + assertHasViolation( + violationsFor(desktopRoot, currentConfig, baseConfig), + /^src\/renderer\/main\.tsx: permanent root entry guard cannot be removed while the source exists$/u, + ); + }, + ); + }); + + it('rejects bridge debt returning to a retained clean root entry', async () => { + const debtPath = 'src/renderer/main.tsx'; + const cleanSource = ` + import { mountDesktopApplication } from './bootstrap/mount-desktop-application.js'; + mountDesktopApplication(); + `; + const pollutedSource = ` + import { mountDesktopApplication } from './bootstrap/mount-desktop-application.js'; + void window.maka.sessions.list(); + mountDesktopApplication(); + `; + const baseConfig = architectureConfig({ + legacyRendererFiles: [debtPath], + rootDebt: { [debtPath]: debtForSource(cleanSource, debtPath) }, + }); + const currentConfig = architectureConfig({ + legacyRendererFiles: [debtPath], + rootDebt: { [debtPath]: debtForSource(pollutedSource, debtPath) }, + }); + + await withDesktopFixture( + { + [debtPath]: pollutedSource, + 'src/renderer/bootstrap/mount-desktop-application.ts': ` + export function mountDesktopApplication() {} + `, + }, + (desktopRoot) => { + const violations = violationsFor(desktopRoot, currentConfig, baseConfig); + assertHasViolation( + violations, + /^src\/renderer\/main\.tsx: bridgePaths debt increased from 0 to 1$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/main\.tsx: new or increased bridgePaths debt window\.maka\.sessions\.list$/u, + ); + }, + ); + }); + + it('allows removing the permanent root guard after its source is deleted', async () => { + const debtPath = 'src/renderer/main.tsx'; + const currentConfig = architectureConfig(); + const baseConfig = architectureConfig({ + legacyRendererFiles: [debtPath], + rootDebt: { [debtPath]: emptyDebt() }, + }); + + await withDesktopFixture( + { + 'src/renderer/bootstrap/mount-desktop-application.ts': ` + export function mountDesktopApplication() {} + `, + }, + (desktopRoot) => { + assert.deepEqual(violationsFor(desktopRoot, currentConfig, baseConfig), []); + }, + ); + }); + + it('rejects retiring root debt by moving state and storage ownership into bootstrap', async () => { + const debtPath = 'src/renderer/main.tsx'; + const currentConfig = architectureConfig({ legacyRendererFiles: [debtPath] }); + const baseConfig = architectureConfig({ + legacyRendererFiles: [debtPath], + rootDebt: { [debtPath]: emptyDebt() }, + }); + + await withDesktopFixture( + { + [debtPath]: ` + import { PollutedBootstrap } from './bootstrap/polluted-bootstrap.js'; + void PollutedBootstrap; + `, + 'src/renderer/bootstrap/polluted-bootstrap.tsx': ` + import { useState } from 'react'; + export function PollutedBootstrap() { + const [value] = useState(() => localStorage.getItem('root-state')); + return
{value}
; + } + `, + }, + (desktopRoot) => { + const violations = violationsFor(desktopRoot, currentConfig, baseConfig); + assertHasViolation( + violations, + /bootstrap\/polluted-bootstrap\.tsx: bootstrap code owns stateful React hooks/u, + ); + assertHasViolation( + violations, + /bootstrap\/polluted-bootstrap\.tsx: bootstrap code directly accesses browser environment capabilities/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/main\.tsx: permanent root entry guard cannot be removed while the source exists$/u, + ); + }, + ); + }); + + it('allows new legacy files inside an explicitly approved growth directory', async () => { + const growthDirectory = 'src/renderer/settings'; + const path = `${growthDirectory}/new-preference.ts`; + const currentConfig = architectureConfig({ + legacyGrowthDirectories: [growthDirectory], + legacyRendererFiles: [path], + }); + const baseConfig = architectureConfig({ + legacyGrowthDirectories: [growthDirectory], + }); + + await withDesktopFixture( + { + [path]: 'export const newPreference = true;', + }, + (desktopRoot) => { + assert.deepEqual(violationsFor(desktopRoot, currentConfig, baseConfig), []); + }, + ); + }); + + it('rejects arbitrary new unclassified renderer source names relative to the base ledger', async () => { + const path = 'src/renderer/session-coordinator.mts'; + const currentConfig = architectureConfig({ legacyRendererFiles: [path] }); + const baseConfig = architectureConfig(); + + await withDesktopFixture( + { + [path]: 'export const coordinator = true;', + }, + (desktopRoot) => { + const violations = violationsFor(desktopRoot, currentConfig, baseConfig); + assertHasViolation( + violations, + /^src\/renderer\/session-coordinator\.mts: new unclassified renderer source files are forbidden outside approved legacy directories$/u, + ); + }, + ); + }); + + it('rejects unbudgeted legacy imports from every strict ownership zone', async () => { + await withDesktopFixture( + { + 'src/renderer/legacy-helper.ts': 'export const legacy = true;', + 'src/renderer/features/alpha/controller.ts': ` + import { legacy } from '../../legacy-helper.js'; + export const featureValue = legacy; + `, + 'src/renderer/application/session/session-scope.ts': ` + import { legacy } from '../../legacy-helper.js'; + export const applicationValue = legacy; + `, + 'src/renderer/composition/desktop-application.ts': ` + import { legacy } from '../legacy-helper.js'; + export const compositionValue = legacy; + `, + 'src/renderer/platform/desktop/create-services.ts': ` + import { legacy } from '../../legacy-helper.js'; + export const platformValue = legacy; + `, + }, + (desktopRoot) => { + const violations = violationsFor(desktopRoot); + assertHasViolation(violations, /feature imports unbudgeted renderer legacy code/u); + assertHasViolation(violations, /application authority imports an outer implementation/u); + assertHasViolation(violations, /composition imports a forbidden implementation module/u); + assertHasViolation(violations, /Desktop adapter imports unbudgeted renderer legacy code/u); + }, + ); + }); + + it('detects bridge aliases, TypeScript wrappers, and aliased React hooks', async () => { + await withDesktopFixture( + { + 'src/renderer/application/bridge-aliases.ts': ` + const desktopWindow = window; + void desktopWindow.maka.sessions.list(); + void globalThis.maka.sessions.list(); + void self['maka'].sessions.list(); + void (window as Window).maka.sessions.list(); + void window!.maka.sessions.list(); + const { maka } = window; + void maka.sessions.list(); + `, + 'src/renderer/shell/aliased-hook.ts': ` + import { useState as state } from 'react'; + export function useFrameState() { return state(false); } + `, + }, + (desktopRoot) => { + const violations = violationsFor(desktopRoot); + assertHasViolation(violations, /bridge-aliases\.ts: application code accesses the Desktop global bridge/u); + assertHasViolation(violations, /aliased-hook\.ts: shell code owns stateful React hooks/u); + }, + ); + }); + + it('retains full bridge method paths after the Desktop bridge is aliased', () => { + const analysis = analyzeRendererSource( + ` + const bridge = window.maka; + void bridge.sessions.list(); + const { maka: desktop } = window; + void desktop.transcripts.open('session-a'); + `, + 'src/renderer/application/bridge-method-aliases.ts', + ); + + assert.equal(analysis.bridgePaths['window.maka.sessions.list'], 1); + assert.equal(analysis.bridgePaths['window.maka.transcripts.open'], 1); + }); + + it('retains stateful hook ownership through local aliases', () => { + const analysis = analyzeRendererSource( + ` + import { useState as state } from 'react'; + const localState = state; + localState(false); + `, + 'src/renderer/shell/hook-method-aliases.ts', + ); + + assert.equal(analysis.hookCalls.useState, 1); + + const reassigned = analyzeRendererSource( + ` + import { useEffect, useState } from 'react'; + let hook = useState; + hook = useEffect; + hook(false); + `, + 'src/renderer/shell/reassigned-hook-alias.ts', + ); + assert.equal(reassigned.hookCalls.useState, 1); + assert.equal(reassigned.hookCalls.useEffect, 1); + + const repeatedVar = analyzeRendererSource( + ` + import React, { useState } from 'react'; + function legacyAliases() { + var hook; + var hook = useState; + var R; + var R = React; + var NestedReact; + if (true) { + var NestedReact = React; + } + hook(false); + R.useState(false); + NestedReact.useState(false); + } + `, + 'src/renderer/shell/repeated-var-hook-alias.ts', + ); + assert.equal(repeatedVar.hookCalls.useState, 3); + }); + + it('recognizes React 19 use and namespace-destructured custom Hooks', async () => { + const analysis = analyzeRendererSource( + ` + import { use as read } from 'react'; + import * as React from 'react'; + import * as hooks from '@astryxdesign/core/hooks'; + const R = React; + const { use: readFromNamespace } = R; + const hookNamespace = hooks; + const { useHotkeys: hotkeys } = hookNamespace; + const { useHotkeys } = hooks as typeof hooks; + const { useHotkeys: defaultHotkeys = () => undefined } = hooks as typeof hooks; + read(Promise.resolve('direct')); + React.use(Promise.resolve('member')); + readFromNamespace(Promise.resolve('destructured')); + hotkeys([]); + useHotkeys([]); + defaultHotkeys([]); + function shadowedReact(React: { use(value: unknown): unknown }) { + return React.use(Promise.resolve('shadowed')); + } + function shadowedHook(useHotkeys: (bindings: unknown[]) => unknown) { + return useHotkeys([]); + } + function blockShadowedReact() { + const React = { use: (value: unknown) => value }; + return React.use(Promise.resolve('block-shadowed')); + } + function blockShadowedHook() { + const useHotkeys = (bindings: unknown[]) => bindings; + return useHotkeys([]); + } + function lateDestructuredAlias() { + const lateHotkeys = lateUseHotkeys; + return lateHotkeys([]); + } + const { useHotkeys: lateUseHotkeys } = hooks as typeof hooks; + `, + 'src/renderer/shell/react-use-aliases.tsx', + ); + assert.equal(analysis.hookCalls.use, 3); + assert.equal(analysis.hookCalls.useHotkeys, 4); + + const nonReactUse = analyzeRendererSource( + ` + import { use } from './plain-helper.js'; + use('not-react'); + `, + 'src/renderer/shell/plain-use.ts', + ); + assert.deepEqual(nonReactUse.hookCalls, {}); + + await withDesktopFixture( + { + 'src/renderer/shell/react-use.tsx': ` + import { use } from 'react'; + export function ShellRead() { return use(Promise.resolve('shell')); } + `, + 'src/renderer/composition/react-namespace-use.tsx': ` + import * as React from 'react'; + export function CompositionRead() { return React.use(Promise.resolve('composition')); } + `, + 'src/renderer/platform/desktop/destructured-hook.ts': ` + import * as hooks from '@astryxdesign/core/hooks'; + const { useHotkeys = () => undefined } = hooks as typeof hooks; + export function installHotkeys() { return useHotkeys([]); } + `, + }, + (desktopRoot) => { + const violations = violationsFor(desktopRoot); + assertHasViolation(violations, /shell\/react-use\.tsx: shell code owns stateful React hooks/u); + assertHasViolation( + violations, + /composition\/react-namespace-use\.tsx: composition code owns stateful React hooks/u, + ); + assertHasViolation( + violations, + /platform\/desktop\/destructured-hook\.ts: platform code owns stateful React hooks/u, + ); + }, + ); + }); + + it('parses TypeScript and static-template dependencies and fails closed on dynamic paths', async () => { + await withDesktopFixture( + { + 'src/renderer/features/alpha/import-equals.ts': ` + import beta = require('../beta/private.js'); + export const value = beta; + `, + 'src/renderer/features/alpha/dynamic.ts': ` + export function load(path: string) { return import(path); } + `, + 'src/renderer/features/beta/private.ts': 'export const beta = true;', + 'src/renderer/application/contracts/deep-type.ts': ` + export type Deep = import('../../features/alpha/private.js').Deep; + `, + 'src/renderer/composition/static-template.ts': ` + export const deep = import(\`../features/alpha/private.js\`); + `, + 'src/renderer/composition/import-meta-glob.ts': ` + export const privateFeatures = import.meta.glob('../features/alpha/private.ts'); + `, + }, + (desktopRoot) => { + const violations = violationsFor(desktopRoot); + assertHasViolation(violations, /import-equals\.ts: feature alpha imports feature beta/u); + assertHasViolation(violations, /dynamic\.ts: feature code contains a non-static import or require/u); + assertHasViolation(violations, /deep-type\.ts: feature imports must use index/u); + assertHasViolation(violations, /static-template\.ts: feature imports must use index/u); + assertHasViolation(violations, /import-meta-glob\.ts: composition code contains a non-static import or require/u); + }, + ); + }); + + it('forbids exposing a feature testing entry through its public index', async () => { + await withDesktopFixture( + { + 'src/renderer/features/alpha/index.ts': `export * from './testing.js';`, + 'src/renderer/features/alpha/testing.ts': 'export const fake = true;', + }, + (desktopRoot) => { + assertHasViolation( + violationsFor(desktopRoot), + /feature .*testing entry/u, + ); + }, + ); + }); + + it('allows legacy AppShell dependency replacement with feature public APIs and application contracts', async () => { + const appShellPath = 'src/renderer/app-shell.tsx'; + const appShellSource = ` + import { AlphaHost } from './features/alpha/index.js'; + import type { SessionScope } from './application/contracts/session-scope.js'; + export function AppShell(props: { readonly scope: SessionScope }) { + return ; + } + `; + const currentDebt = debtForSource(appShellSource, appShellPath); + const baseDebt = { + ...currentDebt, + dependencyPaths: { + './legacy-alpha-owner.js': 1, + './legacy-session-owner.js': 1, + }, + }; + const ownership = [ + { + capability: 'fixture-app-shell', + targetZone: 'shell', + legacyPaths: [appShellPath], + }, + ]; + const currentConfig = architectureConfig({ + legacyFiles: { [appShellPath]: currentDebt }, + legacyRendererFiles: [appShellPath], + ownership, + }); + const baseConfig = architectureConfig({ + legacyFiles: { [appShellPath]: baseDebt }, + legacyRendererFiles: [appShellPath], + ownership, + }); + + await withDesktopFixture( + { + [appShellPath]: appShellSource, + 'src/renderer/features/alpha/index.ts': ` + export function AlphaHost(_props: unknown) { return null; } + `, + 'src/renderer/application/contracts/session-scope.ts': ` + export interface SessionScope { readonly sessionId?: string } + `, + }, + (desktopRoot) => { + assert.deepEqual(violationsFor(desktopRoot, currentConfig, baseConfig), []); + }, + ); + }); + + it('rejects replacing legacy AppShell debt with a feature private import', async () => { + const appShellPath = 'src/renderer/app-shell.tsx'; + const appShellSource = ` + import { alphaController } from './features/alpha/controller.js'; + export const AppShell = alphaController; + `; + const currentDebt = debtForSource(appShellSource, appShellPath); + const baseDebt = { + ...currentDebt, + dependencyPaths: { './legacy-alpha-owner.js': 1 }, + }; + const ownership = [ + { + capability: 'fixture-app-shell', + targetZone: 'shell', + legacyPaths: [appShellPath], + }, + ]; + const currentConfig = architectureConfig({ + legacyFiles: { [appShellPath]: currentDebt }, + legacyRendererFiles: [appShellPath], + ownership, + }); + const baseConfig = architectureConfig({ + legacyFiles: { [appShellPath]: baseDebt }, + legacyRendererFiles: [appShellPath], + ownership, + }); + + await withDesktopFixture( + { + [appShellPath]: appShellSource, + 'src/renderer/features/alpha/controller.ts': 'export const alphaController = true;', + }, + (desktopRoot) => { + assertHasViolation( + violationsFor(desktopRoot, currentConfig, baseConfig), + /^src\/renderer\/app-shell\.tsx: new dependency debt \.\/features\/alpha\/controller\.js$/u, + ); + }, + ); + }); + + it('fails closed when the CLI base argument is missing or invalid', () => { + const checker = fileURLToPath(new URL('./check-renderer-architecture.mjs', import.meta.url)); + const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)); + const missing = spawnSync(process.execPath, [checker, '--base'], { + cwd: repoRoot, + encoding: 'utf8', + }); + const invalid = spawnSync(process.execPath, [checker, '--base', 'definitely-not-a-renderer-architecture-ref'], { + cwd: repoRoot, + encoding: 'utf8', + }); + + assert.notEqual(missing.status, 0); + assert.match(missing.stderr, /usage: check-renderer-architecture/u); + assert.notEqual(invalid.status, 0); + assert.match(invalid.stderr, /base ref does not resolve to a commit/u); + }); +}); diff --git a/apps/desktop/scripts/check-renderer-entry-output.mjs b/apps/desktop/scripts/check-renderer-entry-output.mjs new file mode 100644 index 0000000000..0163de93eb --- /dev/null +++ b/apps/desktop/scripts/check-renderer-entry-output.mjs @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { assertRendererEntryHtml } from './vite-renderer-entry-contract.ts'; + +export function checkRendererEntryOutput(desktopRoot = resolve(import.meta.dirname, '..')) { + const outputRoot = resolve(desktopRoot, 'dist-renderer'); + const attestation = JSON.parse( + readFileSync(resolve(outputRoot, 'renderer-entry-attestation.json'), 'utf8'), + ); + if ( + attestation?.htmlFile !== 'index.html' || + attestation?.sourceFile !== 'src/renderer/main.tsx' || + typeof attestation?.entryFile !== 'string' || + !/^assets\/[a-z0-9._-]+\.js$/iu.test(attestation.entryFile) + ) { + throw new Error('renderer entry build attestation is invalid'); + } + const html = readFileSync(resolve(outputRoot, attestation.htmlFile), 'utf8'); + assertRendererEntryHtml(html, `./${attestation.entryFile}`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + checkRendererEntryOutput(); + console.log('Renderer entry output check passed.'); + } catch (error) { + console.error( + `Renderer entry output check failed: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; + } +} diff --git a/apps/desktop/scripts/dev.mjs b/apps/desktop/scripts/dev.mjs index f73bdec843..56e7ec7e31 100644 --- a/apps/desktop/scripts/dev.mjs +++ b/apps/desktop/scripts/dev.mjs @@ -51,6 +51,7 @@ import { const DESKTOP_DIR = resolve(fileURLToPath(new URL('..', import.meta.url))); const REPO_ROOT = resolve(DESKTOP_DIR, '..', '..'); const TSC_CLI = join(REPO_ROOT, 'node_modules', 'typescript', 'bin', 'tsc'); +const MODEL_METADATA_SYNC = join(REPO_ROOT, 'scripts', 'sync-model-metadata.mjs'); const RUNTIME_WORKER_BUILD = join(REPO_ROOT, 'packages', 'runtime', 'scripts', 'build-filesystem-worker.mjs'); // ── helpers ────────────────────────────────────────────────────────────────── @@ -78,6 +79,12 @@ function runNodeTool(dir, script, args) { const TIMER_START = Date.now(); +// A clean or ignore-scripts install has no generated model modules yet, and +// `tsc --build` bypasses workspace prebuild hooks. Generate from the committed +// snapshot before starting the incremental library graph. +log('build', 'model metadata — generating from committed snapshot'); +await runNodeTool(REPO_ROOT, MODEL_METADATA_SYNC, []); + // Phase 1: all library packages via `tsc --build` (single process, shared // .tsbuildinfo, sub-project incremental detection). The preload bundle imports // workspace package dist files, so it starts only after that build is ready. diff --git a/apps/desktop/scripts/vite-renderer-entry-contract.ts b/apps/desktop/scripts/vite-renderer-entry-contract.ts new file mode 100644 index 0000000000..ca696b0809 --- /dev/null +++ b/apps/desktop/scripts/vite-renderer-entry-contract.ts @@ -0,0 +1,176 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { resolve, sep } from 'node:path'; +import type { Plugin } from 'vite'; + +const SOURCE_FILE = /\.(?:(?:c|m)?(?:js|ts)x?)$/u; +const ALLOWED_HTML_TAGS = new Set([ + 'body', + 'div', + 'head', + 'html', + 'link', + 'meta', + 'script', + 'style', + 'title', +]); +const CONTENT_SECURITY_POLICY = + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'"; + +function normalizePath(path: string): string { + return path.split(sep).join('/'); +} + +function htmlAttribute(attributes: string, name: string): string | undefined { + const match = attributes.match( + new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'iu'), + ); + return match?.[1] ?? match?.[2]; +} + +export function assertRendererEntryHtml(html: string, expectedScriptSource?: string): void { + const tagNames = [...html.matchAll(/<\/?([a-z][a-z0-9-]*)\b/giu)].map((match) => + match[1].toLowerCase(), + ); + const hasForbiddenTag = tagNames.some((tag) => !ALLOWED_HTML_TAGS.has(tag)); + const hasEventHandler = /\son[a-z0-9-]+\s*=/iu.test(html); + + const openingScriptCount = html.match(/]*)>([\s\S]*?)<\/script\s*>/giu)]; + const [script] = scripts; + const scriptSource = htmlAttribute(script?.[1] ?? '', 'src'); + const validScriptSource = expectedScriptSource + ? scriptSource === expectedScriptSource + : scriptSource === '/main.tsx' || /^\.\/assets\/[a-z0-9._-]+\.js$/iu.test(scriptSource ?? ''); + const validScript = + openingScriptCount === 1 && + scripts.length === 1 && + htmlAttribute(script?.[1] ?? '', 'type') === 'module' && + validScriptSource && + (script?.[2] ?? '').trim().length === 0; + + const metas = [...html.matchAll(/]*)>/giu)].map((match) => match[1]); + const charsetMetas = metas.filter( + (attributes) => htmlAttribute(attributes, 'charset')?.toLowerCase() === 'utf-8', + ); + const viewportMetas = metas.filter( + (attributes) => + htmlAttribute(attributes, 'name')?.toLowerCase() === 'viewport' && + htmlAttribute(attributes, 'content') === 'width=device-width, initial-scale=1.0', + ); + const policyMetas = metas.filter( + (attributes) => + htmlAttribute(attributes, 'http-equiv')?.toLowerCase() === 'content-security-policy' && + htmlAttribute(attributes, 'content')?.trim().replace(/\s+/gu, ' ') === CONTENT_SECURITY_POLICY, + ); + const validMetas = + metas.length === 3 && + charsetMetas.length === 1 && + viewportMetas.length === 1 && + policyMetas.length === 1; + + const links = [...html.matchAll(/]*)>/giu)].map((match) => match[1]); + const validLinks = links.every((attributes) => { + const relation = htmlAttribute(attributes, 'rel')?.toLowerCase(); + const href = htmlAttribute(attributes, 'href'); + return ( + ['modulepreload', 'stylesheet'].includes(relation ?? '') && + /^\.\/assets\/[a-z0-9._-]+\.(?:css|js)$/iu.test(href ?? '') + ); + }); + + if ( + hasForbiddenTag || + hasEventHandler || + !validScript || + !validMetas || + !validLinks + ) { + throw new Error( + 'renderer entry HTML contract forbids transformed executable or navigation surfaces', + ); + } +} + +/** + * Attests the final Vite module graph rather than trusting the source HTML. + * A transformIndexHtml hook may rewrite a valid index.html before bundling; + * the canonical main.tsx must therefore remain the sole executable source + * imported directly by the emitted HTML entry. + */ +export function rendererEntryContractPlugin(rendererRoot: string): Plugin { + const canonicalIndex = normalizePath(resolve(rendererRoot, 'index.html')); + const canonicalMain = normalizePath(resolve(rendererRoot, 'main.tsx')); + let isRendererBuild = false; + + return { + name: 'maka-renderer-entry-contract', + apply: 'build', + configResolved(config) { + isRendererBuild = normalizePath(resolve(config.root)) === normalizePath(resolve(rendererRoot)); + }, + transformIndexHtml: { + order: 'post', + handler(html) { + if (!isRendererBuild) return html; + assertRendererEntryHtml(html); + return html; + }, + }, + generateBundle(_options, bundle) { + if (!isRendererBuild) return; + const entryChunks = Object.values(bundle).filter( + (item) => item.type === 'chunk' && item.isEntry, + ); + const [entry] = entryChunks; + const facade = entry?.facadeModuleId ? normalizePath(entry.facadeModuleId) : undefined; + const moduleInfo = facade ? this.getModuleInfo(entry.facadeModuleId) : undefined; + const directSourceImports = (moduleInfo?.importedIds ?? []) + .filter((id) => !id.startsWith('\0')) + .map((id) => normalizePath(id.split(/[?#]/u, 1)[0])) + .filter((id) => SOURCE_FILE.test(id)); + + if ( + entryChunks.length !== 1 || + facade !== canonicalIndex || + directSourceImports.length !== 1 || + directSourceImports[0] !== canonicalMain + ) { + this.error( + 'renderer entry contract requires src/renderer/index.html to import only src/renderer/main.tsx', + ); + } + this.emitFile({ + type: 'asset', + fileName: 'renderer-entry-attestation.json', + source: `${JSON.stringify( + { + entryFile: entry.fileName, + htmlFile: 'index.html', + sourceFile: 'src/renderer/main.tsx', + }, + null, + 2, + )}\n`, + }); + }, + }; +} diff --git a/apps/desktop/src/main/__tests__/active-execution-boundary-read-model.test.ts b/apps/desktop/src/main/__tests__/active-execution-boundary-read-model.test.ts index f00a507cd6..c2fbe9867d 100644 --- a/apps/desktop/src/main/__tests__/active-execution-boundary-read-model.test.ts +++ b/apps/desktop/src/main/__tests__/active-execution-boundary-read-model.test.ts @@ -17,6 +17,7 @@ * under the License. */ +import { deferred } from '@maka/core/test-only/async-primitives'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { createReadOnlyPermissionProfile, createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; @@ -179,15 +180,6 @@ describe('A boundary read that fails (#1629)', () => { }); }); - -function deferred(): { promise: Promise; resolve(value: T): void } { - let resolve!: (value: T) => void; - const promise = new Promise((resolveValue) => { - resolve = resolveValue; - }); - return { promise, resolve }; -} - /** Let every pending microtask chain run to completion. */ async function settle(): Promise { await new Promise((resolve) => setImmediate(resolve)); diff --git a/apps/desktop/src/main/__tests__/agent-graph-refresh.test.ts b/apps/desktop/src/main/__tests__/agent-graph-refresh.test.ts index 061508e13e..3ba86b680a 100644 --- a/apps/desktop/src/main/__tests__/agent-graph-refresh.test.ts +++ b/apps/desktop/src/main/__tests__/agent-graph-refresh.test.ts @@ -17,18 +17,10 @@ * under the License. */ +import { deferred } from '@maka/core/test-only/async-primitives'; import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { createAgentGraphRefreshScheduler } from '../../renderer/agent-graph-refresh.js'; - -function deferred(): { promise: Promise; resolve: (value: T) => void } { - let resolve!: (value: T) => void; - const promise = new Promise((res) => { - resolve = res; - }); - return { promise, resolve }; -} - async function tick(): Promise { await new Promise((resolve) => setImmediate(resolve)); } diff --git a/apps/desktop/src/main/__tests__/app-icon-ipc.test.ts b/apps/desktop/src/main/__tests__/app-icon-ipc.test.ts index e5f5bce2f6..fabe47bd62 100644 --- a/apps/desktop/src/main/__tests__/app-icon-ipc.test.ts +++ b/apps/desktop/src/main/__tests__/app-icon-ipc.test.ts @@ -23,6 +23,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import type { IpcMainInvokeEvent } from 'electron'; +import { DEFAULT_APP_ICON } from '@maka/core/settings'; import type { AppSettings, UpdateAppSettingsInput } from '@maka/core/settings'; import { registerAppIconIpc } from '../app-icon-ipc.js'; import { customAppIconDirectory, resolveCustomAppIconPath } from '../custom-app-icon-store.js'; @@ -36,13 +37,23 @@ async function harness(selected: string, options: { onCompareAndSet?: () => void; onApply?: () => void; onShowOpenDialog?: () => Promise; + /** Seeds `appearance.appIconDark`; absent means the split is off. */ + dark?: string; + /** Makes the conditional write throw, standing in for a disk failure. */ + failWrite?: boolean; } = {}) { const root = await mkdtemp(join(tmpdir(), 'maka-icon-ipc-')); await mkdir(customAppIconDirectory(root), { recursive: true }); await writeFile(resolveCustomAppIconPath(root, ID), 'x'); const handlers = new Map(); - let settings = { appearance: { theme: 'auto', appIcon: selected } } as unknown as AppSettings; + let settings = { + appearance: { + theme: 'auto', + appIcon: selected, + ...(options.dark === undefined ? {} : { appIconDark: options.dark }), + }, + } as unknown as AppSettings; const applied: AppSettings[] = []; registerAppIconIpc({ @@ -65,15 +76,22 @@ async function harness(selected: string, options: { }, updateIf: async ( predicate: (current: AppSettings) => boolean, - patch: UpdateAppSettingsInput, + patch: UpdateAppSettingsInput | ((current: AppSettings) => UpdateAppSettingsInput), ) => { // The real store evaluates the predicate and writes on one queue. The // hook stands in for whatever else reached that queue first. options.onCompareAndSet?.(); if (!predicate(settings)) return { applied: false, settings }; + // Thrown after the predicate, where the real store would fail: the + // decision to write has been made and the write is what breaks. + if (options.failWrite) throw new Error('disk is full'); + // Spread, like the real `mergeSettings`: an explicit `undefined` in a + // patch overwrites rather than being skipped, which is how a slot is + // cleared. + const resolved = typeof patch === 'function' ? patch(settings) : patch; settings = { ...settings, - appearance: { ...settings.appearance, ...patch.appearance }, + appearance: { ...settings.appearance, ...resolved.appearance }, } as AppSettings; return { applied: true, settings }; }, @@ -91,6 +109,7 @@ async function harness(selected: string, options: { remove: (icon: unknown) => handlers.get('app:removeIcon')!(undefined as unknown as IpcMainInvokeEvent, icon), current: () => settings.appearance.appIcon, + currentDark: () => settings.appearance.appIconDark, select: (icon: unknown) => handlers.get('app:selectIcon')!(undefined as unknown as IpcMainInvokeEvent, icon), importIcon: () => @@ -111,9 +130,10 @@ test('removing the current icon resets the selection before the file goes away', const result = (await h.remove(ICON)) as { ok: boolean; selection?: string }; assert.equal(result.ok, true); - assert.equal(result.selection, 'default'); + // The shipped default, which is no longer the id literally named `default`. + assert.equal(result.selection, DEFAULT_APP_ICON); // Both halves moved, and the setting is the half that moved first. - assert.equal(h.current(), 'default'); + assert.equal(h.current(), DEFAULT_APP_ICON); assert.deepEqual(await readdir(customAppIconDirectory(h.root)), []); // The OS surface was told, so the dock is not still holding the deleted art. assert.equal(h.applied.length, 1); @@ -194,9 +214,11 @@ test('a selection issued mid-removal cannot land between reset, apply and delete // It ran after the removal, not inside it, and the artwork was already gone. assert.equal(selected.ok, false); assert.equal(selected.reason, 'missing_artwork'); - assert.equal(h.current(), 'default'); + assert.equal(h.current(), DEFAULT_APP_ICON); assert.deepEqual(await readdir(customAppIconDirectory(h.root)), []); - // Nothing ran between the reset and the delete. + // Nothing ran between the reset and the delete, and both slots move in a + // single compare-and-set: a second conditional write here would be a window + // where the light slot is already committed and the dark one is not. assert.deepEqual(observed, ['compare-and-set', 'apply']); }); @@ -221,3 +243,60 @@ test('an open file dialog does not hold the queue', async () => { releaseDialog(); await importing; }); + +test('removing an icon used only in dark mode clears the dark slot', async () => { + // The dangling-reference case: the light slot names something else, so the + // light-slot predicate does not match and an earlier version of this handler + // deleted the file while leaving `appIconDark` pointing at it. + const h = await harness('sky', { dark: ICON }); + + const result = (await h.remove(ICON)) as { ok: boolean; darkSelection?: string }; + + assert.equal(result.ok, true); + assert.equal(h.current(), 'sky', 'the light choice is untouched'); + assert.equal(h.currentDark(), undefined, 'the dark slot no longer names deleted artwork'); + assert.equal(result.darkSelection, undefined); + assert.equal(h.applied.length, 1, 'the dock was re-applied for the cleared slot'); +}); + +test('removing an icon used in both slots clears both', async () => { + const h = await harness(ICON, { dark: ICON }); + + const result = (await h.remove(ICON)) as { ok: boolean; selection: string }; + + assert.equal(result.ok, true); + assert.equal(h.current(), 'sky'); + assert.equal(h.currentDark(), undefined); + assert.equal(result.selection, 'sky'); +}); + +test('removing an unrelated icon leaves a dark choice alone', async () => { + const other = `custom:${'d'.repeat(32)}`; + const h = await harness('sky', { dark: 'ink' }); + + await h.remove(other); + + assert.equal(h.current(), 'sky'); + assert.equal(h.currentDark(), 'ink', 'an unrelated removal must not disturb the split'); +}); + +test('a failed reset commits nothing and leaves the dock alone', async () => { + // The partial-commit case: both slots name the icon, so a two-step reset + // would have written the light slot before the second write could fail. + // One queued write means a failure leaves the persisted state untouched, + // which is what makes `reset_failed` an honest answer. + const h = await harness(ICON, { dark: ICON, failWrite: true }); + + const result = (await h.remove(ICON)) as { ok: boolean; reason?: string }; + + assert.equal(result.ok, false); + assert.equal(result.reason, 'reset_failed'); + assert.equal(h.current(), ICON, 'the light slot was not half-reset'); + assert.equal(h.currentDark(), ICON, 'the dark slot was not half-reset'); + assert.equal(h.applied.length, 0, 'nothing was applied to the dock'); + assert.deepEqual( + await readdir(customAppIconDirectory(h.root)), + [`${ID}.png`], + 'the artwork survives a failed reset', + ); +}); diff --git a/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts b/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts index a5b249259f..e3bab13b5e 100644 --- a/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts +++ b/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts @@ -26,8 +26,10 @@ describe('app quit coordinator', () => { let resumeQuitCount = 0; let preventedCount = 0; const coordinator = createAppQuitCoordinator({ + prepareToQuit: async () => {}, cleanup: async () => {}, focusOrCreateWindow: () => {}, + onPreparationError: () => {}, onCleanupError: () => {}, onWindowCreationError: () => {}, resumeQuit: () => { @@ -48,7 +50,7 @@ describe('app quit coordinator', () => { assert.equal(resumeQuitCount, 0); assert.equal(preventedCount, 2); - await new Promise((resolve) => setImmediate(resolve)); + await flushQuitCoordinator(); assert.equal(resumeQuitCount, 1); }); @@ -62,6 +64,7 @@ describe('app quit coordinator', () => { releaseCleanup = resolve; }); const coordinator = createAppQuitCoordinator({ + prepareToQuit: async () => {}, cleanup: async () => { cleanupCount += 1; await cleanupPending; @@ -69,6 +72,7 @@ describe('app quit coordinator', () => { focusOrCreateWindow: () => { focusOrCreateCount += 1; }, + onPreparationError: () => {}, onCleanupError: () => {}, onWindowCreationError: () => {}, resumeQuit: () => { @@ -84,6 +88,7 @@ describe('app quit coordinator', () => { coordinator.handleBeforeQuit(event); coordinator.handleBeforeQuit(event); + await flushQuitCoordinator(); assert.equal(cleanupCount, 1); assert.equal(preventedCount, 2); assert.equal(resumeQuitCount, 0); @@ -91,7 +96,7 @@ describe('app quit coordinator', () => { releaseCleanup(); await cleanupPending; await Promise.resolve(); - await new Promise((resolve) => setImmediate(resolve)); + await flushQuitCoordinator(); assert.equal(resumeQuitCount, 1); @@ -108,11 +113,13 @@ describe('app quit coordinator', () => { let focusOrCreateCount = 0; let windowCreationSignal: AbortSignal | undefined; const coordinator = createAppQuitCoordinator({ + prepareToQuit: async () => {}, cleanup: () => new Promise(() => {}), focusOrCreateWindow: (signal) => { focusOrCreateCount += 1; windowCreationSignal = signal; }, + onPreparationError: () => {}, onCleanupError: () => {}, onWindowCreationError: () => {}, resumeQuit: () => {}, @@ -130,10 +137,12 @@ describe('app quit coordinator', () => { const failure = new Error('window load failed'); const reportedErrors: unknown[] = []; const coordinator = createAppQuitCoordinator({ + prepareToQuit: async () => {}, cleanup: async () => {}, focusOrCreateWindow: async () => { throw failure; }, + onPreparationError: () => {}, onCleanupError: () => {}, onWindowCreationError: (error) => reportedErrors.push(error), resumeQuit: () => {}, @@ -146,18 +155,62 @@ describe('app quit coordinator', () => { assert.deepEqual(reportedErrors, [failure]); }); + it('cancels quit without closing resources when Host retirement preparation fails', async () => { + const preparationError = new Error('retirement failed'); + const reportedErrors: unknown[] = []; + let preparationCount = 0; + let cleanupCount = 0; + let focusOrCreateCount = 0; + let resumeQuitCount = 0; + const coordinator = createAppQuitCoordinator({ + prepareToQuit: async () => { + preparationCount += 1; + if (preparationCount === 1) throw preparationError; + }, + cleanup: async () => { + cleanupCount += 1; + }, + focusOrCreateWindow: () => { + focusOrCreateCount += 1; + }, + onPreparationError: (error) => reportedErrors.push(error), + onCleanupError: () => {}, + onWindowCreationError: () => {}, + resumeQuit: () => { + resumeQuitCount += 1; + }, + }); + + coordinator.handleBeforeQuit({ preventDefault: () => {} }); + await flushQuitCoordinator(); + + assert.deepEqual(reportedErrors, [preparationError]); + assert.equal(cleanupCount, 0); + assert.equal(resumeQuitCount, 0); + assert.equal(focusOrCreateCount, 1); + + coordinator.handleBeforeQuit({ preventDefault: () => {} }); + await flushQuitCoordinator(); + + assert.equal(preparationCount, 2); + assert.equal(cleanupCount, 1); + assert.equal(resumeQuitCount, 1); + }); + it('reports cleanup failure without leaking an unhandled rejection', async () => { const cleanupError = new Error('close failed'); const reportedErrors: unknown[] = []; let focusOrCreateCount = 0; let resumeQuitCount = 0; const deps = { + prepareToQuit: async () => {}, cleanup: async () => { throw cleanupError; }, focusOrCreateWindow: () => { focusOrCreateCount += 1; }, + onPreparationError: () => {}, onCleanupError: (error: unknown) => { reportedErrors.push(error); }, @@ -169,8 +222,7 @@ describe('app quit coordinator', () => { const coordinator = createAppQuitCoordinator(deps); coordinator.handleBeforeQuit({ preventDefault: () => {} }); - await Promise.resolve(); - await new Promise((resolve) => setImmediate(resolve)); + await flushQuitCoordinator(); let secondQuitPrevented = false; coordinator.focusOrCreateWindow(); coordinator.handleBeforeQuit({ @@ -185,3 +237,8 @@ describe('app quit coordinator', () => { assert.equal(secondQuitPrevented, false); }); }); + +async function flushQuitCoordinator(): Promise { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); +} diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index 7b0edd27b9..e87d962eed 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -18,9 +18,9 @@ */ /** - * #1954 busy-race settlement: a sessions:send that raced a root turn another - * client opened can come back `steered` (the send owns no turn) or under a - * Host-chosen turnId. Both results must be interpreted identically by the + * #1954 busy-race settlement: a submitted Message that raced a root turn + * another client opened can come back `steered` (the send owns no turn) or + * under a Host-chosen turnId. Both results must be interpreted identically by the * new-chat and existing-session branches, and a rebind must never overwrite * an authoritative live projection that beat the IPC response. */ @@ -28,107 +28,311 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import type { StoredMessage } from '@maka/core/session'; -import type { LiveTurnProjection } from '@maka/ui'; +import type { LiveTurnProjection, TransientUserMessageProjection } from '@maka/ui'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; -function installWindow(maka: unknown): () => void { - const target = globalThis as unknown as { window?: unknown }; - const hadWindow = Object.prototype.hasOwnProperty.call(target, 'window'); - const previousWindow = target.window; - Object.defineProperty(target, 'window', { - configurable: true, - value: { maka }, - writable: true, +import { + createActionsDeps, + createTransientState, + createTurnState, + EMPTY_SKILL_INVOCATION, + installWindow, +} from './app-shell-chat-actions-fixture.js'; + +describe('busy-raced send settlement', () => { + it('shows a Follow Up immediately and keeps its caller-owned identity', async () => { + const activeIdRef = { current: 'session-a' as string | undefined }; + const transient = new Map(); + let submittedMessageId: string | undefined; + let releaseAdmission!: () => void; + const admission = new Promise((resolve) => { + releaseAdmission = resolve; + }); + let observeSubmit!: () => void; + const submitted = new Promise((resolve) => { + observeSubmit = resolve; + }); + const restoreWindow = installWindow({ + sessions: { + submitMessage: async ( + _sessionId: string, + _placement: string, + command: { messageId: string }, + ) => { + submittedMessageId = command.messageId; + observeSubmit(); + await admission; + return { + ok: true, + disposition: 'followup', + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }; + }, + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef, + addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + updateTransientMessage: (_sessionId, message) => transient.set(message.id, message), + removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), + }); + const sending = actions.enqueueMessage( + 'session-a', + 'do this next', + 'next_turn', + ); + await submitted; + + assert.ok(submittedMessageId); + assert.equal(transient.get(submittedMessageId)?.id, submittedMessageId); + releaseAdmission(); + await sending; + assert.deepEqual([...transient.keys()], [submittedMessageId]); + } finally { + restoreWindow(); + } }); - return () => { - if (hadWindow) { - Object.defineProperty(target, 'window', { - configurable: true, - value: previousWindow, - writable: true, + + it('keeps a Follow Up visible when Host admission outcome is unknown', async () => { + const transient = new Map(); + const restoreWindow = installWindow({ + sessions: { + submitMessage: async () => ({ ok: false, reason: 'outcome_unknown' as const }), + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + updateTransientMessage: (_sessionId, message) => transient.set(message.id, message), + removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), }); - } else { - delete target.window; + + await actions.enqueueMessage('session-a', 'do this next', 'next_turn'); + + assert.equal(transient.size, 1); + assert.equal([...transient.values()][0]?.text, 'do this next'); + } finally { + restoreWindow(); } - }; -} + }); -function createTurnState() { - const liveTurnBySession: Record = {}; - return { - liveTurnBySession, - setLiveTurnBySession( - updater: (c: Record) => Record, - ) { - const next = updater({ ...liveTurnBySession }); - for (const key of Object.keys(liveTurnBySession)) delete liveTurnBySession[key]; - Object.assign(liveTurnBySession, next); - }, - }; -} + it('retires a Follow Up the Host refused outright', async () => { + const transient = new Map(); + // A Follow Up submitted just as the running Turn settles is admitted as a + // fresh Turn, so an unresolvable Skill token in it is refused outright. + // No Turn opened and no canonical message will ever replace the row, so + // leaving it visible would strand it there for the life of the Session. + const restoreWindow = installWindow({ + sessions: { + submitMessage: async () => ({ + ok: false, + reason: 'skill_invocation_failed' as const, + skillInvocation: { + loaded: [], + failed: [{ request: 'typo', reason: 'not_found' as const }], + receipts: [], + }, + }), + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + updateTransientMessage: (_sessionId, message) => transient.set(message.id, message), + removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), + }); -function createMessageState() { - const messages: StoredMessage[] = []; - return { - messages, - setMessages(updater: StoredMessage[] | ((current: StoredMessage[]) => StoredMessage[])) { - const next = typeof updater === 'function' ? updater([...messages]) : updater; - messages.length = 0; - messages.push(...next); - }, - }; -} + await actions.enqueueMessage('session-a', '/skill:typo do this next', 'next_turn'); -function createActionsDeps() { - return { - uiLocale: 'en' as const, - activeIdRef: { current: undefined as string | undefined }, - addPendingSessionAction: () => true, - captureComposerImportOwner: () => ({ - sessionId: undefined, - navSection: 'sessions' as const, - }), - checkTaskSubmissionReadiness: async () => true, - clearPendingSessionAction: () => undefined, - isNewChatSendSurfaceActive: () => true, - isShellSurfaceOwnerActive: () => true, - markSessionReadLocally: () => undefined, - messageRetryPendingRef: { current: new Set() }, - refreshSessions: async () => [], - setActiveId: () => undefined, - setMessageLoadErrorBySession: () => undefined, - setMessageRetryPendingBySession: () => undefined, - setMessages: () => undefined, - transcriptRangeRef: { current: undefined }, - setNavSelection: () => undefined, - setLiveTurnBySession: () => undefined, - setInteractionBySession: () => undefined, - showModelSetupToast: () => undefined, - toastApi: { error: () => undefined, info: () => undefined }, - newChatModel: null, - pendingNewChatThinkingLevel: null, - newChatPermissionChoice: undefined, - clearNewChatPermissionChoice: () => {}, - newChatCollaborationMode: 'agent' as const, - newChatOrchestrationMode: 'default' as const, - newTaskTarget: { profileId: 'local', hostId: 'host-local', projectId: null }, - }; -} + assert.deepEqual([...transient.keys()], []); + } finally { + restoreWindow(); + } + }); -const EMPTY_SKILL_INVOCATION = { loaded: [], failed: [], receipts: [] }; + it('reports a refused Follow Up as not sent', async () => { + const restoreWindow = installWindow({ + sessions: { + submitMessage: async () => ({ + ok: false as const, + reason: 'skill_invocation_failed' as const, + skillInvocation: { + loaded: [], + failed: [{ request: 'typo', reason: 'not_found' }], + receipts: [], + }, + }), + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + }); -describe('busy-raced send settlement', () => { - it('a steered send on an existing session disarms its turn and shows no optimistic message', async () => { + // Refusal is not an exception, so a caller that only watches for a throw + // reads it as sent and clears the draft the user still needs. + assert.equal( + await actions.enqueueMessage('session-a', '/skill:typo do this next', 'next_turn'), + false, + ); + } finally { + restoreWindow(); + } + }); + + it('reports an unproven Follow Up as sent so its text is not offered twice', async () => { + const restoreWindow = installWindow({ + sessions: { + submitMessage: async () => ({ ok: false as const, reason: 'outcome_unknown' as const }), + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + }); + + assert.equal( + await actions.enqueueMessage('session-a', 'do this next', 'next_turn'), + true, + ); + } finally { + restoreWindow(); + } + }); + + it('does not resurrect a Follow Up retracted before its IPC reply settles', async () => { + const transient = new Map(); + let submittedMessageId: string | undefined; + let releaseAdmission!: () => void; + const admission = new Promise((resolve) => { + releaseAdmission = resolve; + }); + let observeSubmit!: () => void; + const submitted = new Promise((resolve) => { + observeSubmit = resolve; + }); + const restoreWindow = installWindow({ + sessions: { + submitMessage: async ( + _sessionId: string, + _placement: string, + command: { messageId: string }, + ) => { + submittedMessageId = command.messageId; + observeSubmit(); + await admission; + return { + ok: true as const, + disposition: 'followup' as const, + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }; + }, + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + updateTransientMessage: (_sessionId, message) => { + if (transient.has(message.id)) transient.set(message.id, message); + }, + removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), + }); + const sending = actions.enqueueMessage('session-a', 'do this next', 'next_turn'); + await submitted; + + assert.ok(submittedMessageId); + transient.delete(submittedMessageId); + releaseAdmission(); + await sending; + + assert.deepEqual([...transient.keys()], []); + } finally { + restoreWindow(); + } + }); + + it('shows one stable local message before Host admission settles', async () => { + const activeIdRef = { current: 'session-a' as string | undefined }; + const transient = new Map(); + let submittedMessageId: string | undefined; + let releaseAdmission!: () => void; + const admission = new Promise((resolve) => { + releaseAdmission = resolve; + }); + let observeSubmit!: () => void; + const submitted = new Promise((resolve) => { + observeSubmit = resolve; + }); + const restoreWindow = installWindow({ + sessions: { + submitMessage: async ( + _sessionId: string, + _placement: string, + command: { messageId: string }, + ) => { + submittedMessageId = command.messageId; + observeSubmit(); + await admission; + return { + ok: true, + disposition: 'turn_started', + messageId: command.messageId, + turnId: 'host-turn', + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }; + }, + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef, + addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + updateTransientMessage: (_sessionId, message) => transient.set(message.id, message), + removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), + }); + const sending = actions.send('also check the tests'); + await submitted; + + assert.ok(submittedMessageId); + assert.equal(transient.get(submittedMessageId)?.text, 'also check the tests'); + + releaseAdmission(); + assert.equal(await sending, true); + assert.equal(transient.size, 1); + assert.equal(transient.has(submittedMessageId), true); + assert.equal(transient.has('host-turn'), false); + } finally { + restoreWindow(); + } + }); + + it('keeps one local row when Host admits the message as steering', async () => { const activeIdRef = { current: 'session-a' as string | undefined }; const turnState = createTurnState(); - const messageState = createMessageState(); + const transientState = createTransientState(); const restoreWindow = installWindow({ sessions: { - send: async (_sessionId: string, command: { turnId: string }) => ({ + submitMessage: async (_sessionId: string, command: { messageId: string }) => ({ ok: true, - steered: true, - turnId: command.turnId, + disposition: 'steering', + messageId: command.messageId, attachments: [], inlineReferences: [], skillInvocation: EMPTY_SKILL_INVOCATION, @@ -140,24 +344,28 @@ describe('busy-raced send settlement', () => { ...createActionsDeps(), activeIdRef, setLiveTurnBySession: turnState.setLiveTurnBySession, - setMessages: messageState.setMessages, + ...transientState.deps, }); assert.equal(await actions.send('also check the tests'), true); assert.equal(turnState.liveTurnBySession['session-a'], undefined); - assert.deepEqual(messageState.messages, []); + // One row for one Message, still under the identity the client sent it + // with: steering admission names no Turn to re-key it to. + assert.equal(transientState.rows.size, 1); } finally { restoreWindow(); } }); - it('rebinds the unconfirmed arm onto a Host-chosen turn id', async () => { + it('does not turn a Host-started admission into a renderer-owned LiveTurn', async () => { const activeIdRef = { current: 'session-a' as string | undefined }; const turnState = createTurnState(); - const messageState = createMessageState(); + const transientState = createTransientState(); const restoreWindow = installWindow({ sessions: { - send: async () => ({ + submitMessage: async (_sessionId: string, command: { messageId: string }) => ({ ok: true, + disposition: 'turn_started', + messageId: command.messageId, turnId: 'host-turn', attachments: [], inlineReferences: [], @@ -170,15 +378,13 @@ describe('busy-raced send settlement', () => { ...createActionsDeps(), activeIdRef, setLiveTurnBySession: turnState.setLiveTurnBySession, - setMessages: messageState.setMessages, + ...transientState.deps, }); assert.equal(await actions.send('also check the tests'), true); - const live = turnState.liveTurnBySession['session-a']; - assert.equal(live?.turnId, 'host-turn'); - assert.equal(live?.unconfirmed, true); - const optimistic = messageState.messages.filter((message) => message.type === 'user'); - assert.equal(optimistic.length, 1); - assert.equal(optimistic[0]?.turnId, 'host-turn'); + assert.equal(turnState.liveTurnBySession['session-a'], undefined); + assert.equal(transientState.rows.size, 1); + assert.equal(transientState.rows.has('host-turn'), false); + assert.equal([...transientState.rows.values()][0]?.hostTurnId, 'host-turn'); } finally { restoreWindow(); } @@ -187,10 +393,10 @@ describe('busy-raced send settlement', () => { it('keeps an authoritative projection that arrived before the send response', async () => { const activeIdRef = { current: 'session-a' as string | undefined }; const turnState = createTurnState(); - const messageState = createMessageState(); + const transientState = createTransientState(); const restoreWindow = installWindow({ sessions: { - send: async () => { + submitMessage: async (_sessionId: string, command: { messageId: string }) => { // The Host streamed under its own turn id before the IPC response. turnState.setLiveTurnBySession((current) => ({ ...current, @@ -198,6 +404,8 @@ describe('busy-raced send settlement', () => { })); return { ok: true, + disposition: 'turn_started', + messageId: command.messageId, turnId: 'host-turn', attachments: [], inlineReferences: [], @@ -211,7 +419,7 @@ describe('busy-raced send settlement', () => { ...createActionsDeps(), activeIdRef, setLiveTurnBySession: turnState.setLiveTurnBySession, - setMessages: messageState.setMessages, + ...transientState.deps, }); assert.equal(await actions.send('also check the tests'), true); const live = turnState.liveTurnBySession['session-a']; @@ -223,10 +431,10 @@ describe('busy-raced send settlement', () => { } }); - it('a steered send on the new-chat path navigates without a ghost optimistic turn', async () => { + it('keeps the new-chat message through navigation when Host admits it as steering', async () => { const activeIdRef = { current: undefined as string | undefined }; const turnState = createTurnState(); - const messageState = createMessageState(); + const transientState = createTransientState(); const activated: string[] = []; const removed: string[] = []; const restoreWindow = installWindow({ @@ -237,10 +445,10 @@ describe('busy-raced send settlement', () => { remove: async (sessionId: string) => { removed.push(sessionId); }, - send: async (_sessionId: string, command: { turnId: string }) => ({ + submitMessage: async (_sessionId: string, command: { messageId: string }) => ({ ok: true, - steered: true, - turnId: command.turnId, + disposition: 'steering', + messageId: command.messageId, attachments: [], inlineReferences: [], skillInvocation: EMPTY_SKILL_INVOCATION, @@ -251,34 +459,39 @@ describe('busy-raced send settlement', () => { const actions = createAppShellChatActions({ ...createActionsDeps(), activeIdRef, + activateSessionForFirstSend: async (sessionId) => { + activated.push(sessionId); + activeIdRef.current = sessionId; + }, setActiveId: (sessionId: string | undefined) => { - if (sessionId !== undefined) activated.push(sessionId); activeIdRef.current = sessionId; }, setLiveTurnBySession: turnState.setLiveTurnBySession, - setMessages: messageState.setMessages, + ...transientState.deps, }); assert.equal(await actions.send('also check the tests'), true); assert.deepEqual(activated, ['session-new']); assert.equal(turnState.liveTurnBySession['session-new'], undefined); - assert.deepEqual(messageState.messages, []); + assert.equal(transientState.rows.size, 1); assert.deepEqual(removed, []); } finally { restoreWindow(); } }); - it('a Host-chosen turn id on the new-chat path keys the optimistic state to it', async () => { + it('keeps the new-chat messageId when Host chooses another turnId', async () => { const activeIdRef = { current: undefined as string | undefined }; const turnState = createTurnState(); - const messageState = createMessageState(); + const transientState = createTransientState(); const restoreWindow = installWindow({ newTasks: { create: async () => ({ id: 'session-new' }), }, sessions: { - send: async () => ({ + submitMessage: async (_sessionId: string, command: { messageId: string }) => ({ ok: true, + disposition: 'turn_started', + messageId: command.messageId, turnId: 'host-turn', attachments: [], inlineReferences: [], @@ -290,17 +503,19 @@ describe('busy-raced send settlement', () => { const actions = createAppShellChatActions({ ...createActionsDeps(), activeIdRef, + activateSessionForFirstSend: async (sessionId) => { + activeIdRef.current = sessionId; + }, setActiveId: (sessionId: string | undefined) => { activeIdRef.current = sessionId; }, setLiveTurnBySession: turnState.setLiveTurnBySession, - setMessages: messageState.setMessages, + ...transientState.deps, }); assert.equal(await actions.send('also check the tests'), true); - assert.equal(turnState.liveTurnBySession['session-new']?.turnId, 'host-turn'); - const optimistic = messageState.messages.filter((message) => message.type === 'user'); - assert.equal(optimistic.length, 1); - assert.equal(optimistic[0]?.turnId, 'host-turn'); + assert.equal(turnState.liveTurnBySession['session-new'], undefined); + assert.equal(transientState.rows.size, 1); + assert.equal(transientState.rows.has('host-turn'), false); } finally { restoreWindow(); } diff --git a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts new file mode 100644 index 0000000000..ad52d98382 --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts @@ -0,0 +1,133 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Shared scaffolding for the `createAppShellChatActions` suites. The dependency + * surface is wide and the suites only ever vary a handful of entries, so a + * second copy of it drifts silently and has to be edited twice whenever the + * actions gain a dependency. + */ + +import type { LiveTurnProjection, TransientUserMessageProjection } from '@maka/ui'; + +/** Installs a `window.maka` bridge double; the returned function restores it. */ +export function installWindow(maka: unknown): () => void { + const target = globalThis as unknown as { window?: unknown }; + const hadWindow = Object.prototype.hasOwnProperty.call(target, 'window'); + const previousWindow = target.window; + Object.defineProperty(target, 'window', { + configurable: true, + value: { maka }, + writable: true, + }); + return () => { + if (hadWindow) { + Object.defineProperty(target, 'window', { + configurable: true, + value: previousWindow, + writable: true, + }); + } else { + delete target.window; + } + }; +} + +/** + * The live-turn arm as a real map rather than a black-hole stub: a send that + * never lands must leave nothing behind, and that cannot be asserted against a + * no-op setter. + */ +export function createTurnState() { + const liveTurnBySession: Record = {}; + return { + liveTurnBySession, + setLiveTurnBySession( + updater: (c: Record) => Record, + ) { + const next = updater({ ...liveTurnBySession }); + for (const key of Object.keys(liveTurnBySession)) delete liveTurnBySession[key]; + Object.assign(liveTurnBySession, next); + }, + }; +} + +/** + * The transient arm as a real map. Transient rows are not `StoredMessage`s — + * they have no Turn to belong to yet — so they are held apart from the + * canonical transcript here, exactly as the shell holds them. + */ +export function createTransientState() { + const rows = new Map(); + return { + rows, + deps: { + addTransientMessage: (_sessionId: string, message: TransientUserMessageProjection) => { + rows.set(message.id, message); + }, + updateTransientMessage: (_sessionId: string, message: TransientUserMessageProjection) => { + rows.set(message.id, message); + }, + removeTransientMessage: (_sessionId: string, messageId: string) => { + rows.delete(messageId); + }, + }, + }; +} + +export function createActionsDeps() { + const activeIdRef = { current: undefined as string | undefined }; + return { + uiLocale: 'en' as const, + activeIdRef, + captureComposerImportOwner: () => ({ + sessionId: undefined, + navSection: 'sessions' as const, + }), + checkTaskSubmissionReadiness: async () => true, + isNewChatSendSurfaceActive: () => true, + isShellSurfaceOwnerActive: () => true, + markSessionReadLocally: () => undefined, + messageRetryPending: { claim: () => true, release: () => undefined }, + refreshSessions: async () => [], + activateSessionForFirstSend: async (sessionId: string) => { + activeIdRef.current = sessionId; + }, + setActiveId: () => undefined, + setMessageLoadErrorBySession: () => undefined, + setMessages: () => undefined, + addTransientMessage: () => undefined, + updateTransientMessage: () => undefined, + removeTransientMessage: () => undefined, + transcriptRangeRef: { current: undefined }, + setLiveTurnBySession: () => undefined, + setInteractionBySession: () => undefined, + showModelSetupToast: () => undefined, + toastApi: { error: () => undefined, info: () => undefined }, + newChatModel: null, + pendingNewChatThinkingLevel: null, + newChatPermissionChoice: undefined, + clearNewChatPermissionChoice: () => {}, + newChatCollaborationMode: 'agent' as const, + newChatOrchestrationMode: 'default' as const, + newTaskTarget: { profileId: 'local', hostId: 'host-local', projectId: null }, + }; +} + +export const EMPTY_SKILL_INVOCATION = { loaded: [], failed: [], receipts: [] }; diff --git a/apps/desktop/src/main/__tests__/app-shell-exact-turn-arm.test.ts b/apps/desktop/src/main/__tests__/app-shell-exact-turn-arm.test.ts new file mode 100644 index 0000000000..e9f7053009 --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-shell-exact-turn-arm.test.ts @@ -0,0 +1,112 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * An exact-Turn send arms the processing indicator before it knows the Turn's + * identity, because the model-wait window opens before any SessionEvent + * arrives. Runtime Host names the Turn, so the client-side arm has to adopt + * that name the moment it is answered: a Turn that ends before its first + * text/tool event has nothing else to retire the arm, and an arm nobody can + * retire holds "正在处理…" and Stop on forever. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { TransientUserMessageProjection } from '@maka/ui'; +import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; + +import { + createActionsDeps, + createTurnState, + EMPTY_SKILL_INVOCATION, + installWindow, +} from './app-shell-chat-actions-fixture.js'; + +const GRAPH_TURN = { mode: 'graph', source: 'slash_command' } as const; + +describe('exact-Turn arm identity', () => { + it('adopts the Host Turn identity the admission answered with', async () => { + const turnState = createTurnState(); + const restoreWindow = installWindow({ + sessions: { + submitMessage: async () => ({ + ok: true, + disposition: 'turn_started' as const, + turnId: 'host-turn-1', + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }), + }, + }); + + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + setLiveTurnBySession: turnState.setLiveTurnBySession, + }); + + assert.equal( + await actions.send('run the graph', undefined, { turnOrchestration: GRAPH_TURN }), + true, + ); + } finally { + restoreWindow(); + } + + const armed = turnState.liveTurnBySession['session-a']; + assert.equal(armed?.turnId, 'host-turn-1'); + // Still unconfirmed: the Host named the Turn, it has not yet said anything + // about running it. + assert.equal(armed?.unconfirmed, true); + }); + + it('releases the arm when Host admission opened no Turn under it', async () => { + const turnState = createTurnState(); + const transient = new Map(); + const restoreWindow = installWindow({ + sessions: { + submitMessage: async () => ({ ok: false, reason: 'outcome_unknown' as const }), + }, + }); + + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + setLiveTurnBySession: turnState.setLiveTurnBySession, + addTransientMessage: (_sessionId, message) => transient.set(message.id, message), + updateTransientMessage: (_sessionId, message) => transient.set(message.id, message), + removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId), + }); + + await actions.send('run the graph', undefined, { turnOrchestration: GRAPH_TURN }); + } finally { + restoreWindow(); + } + + // The Message may well have been admitted, so its row stays for canonical + // transcript to settle. The Turn arm is a different claim: nothing proves + // a Turn opened under this identity, and no event will ever retire it. + assert.equal(turnState.liveTurnBySession['session-a'], undefined); + assert.equal(transient.size, 1); + }); +}); diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 967d777dc9..495643628f 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -32,90 +32,19 @@ * no E2E can reach deterministically. */ +import { deferred } from '@maka/core/test-only/async-primitives'; import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import type { SessionSummary } from '@maka/core/session'; import type { LiveTurnProjection } from '@maka/ui'; import type { DesktopTranscriptRangeController } from '../../renderer/desktop-transcript-range-store.js'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; -import { createAppShellSessionUiStateController } from '../../renderer/app-shell-session-ui-state.js'; -import { settledSessionTransientIds } from '../../renderer/settled-session-transients.js'; - -function installWindow(maka: unknown): () => void { - const target = globalThis as unknown as { window?: unknown }; - const hadWindow = Object.prototype.hasOwnProperty.call(target, 'window'); - const previousWindow = target.window; - Object.defineProperty(target, 'window', { - configurable: true, - value: { maka }, - writable: true, - }); - return () => { - if (hadWindow) { - Object.defineProperty(target, 'window', { - configurable: true, - value: previousWindow, - writable: true, - }); - } else { - delete target.window; - } - }; -} -/** - * The live-turn arm as a real map rather than a black-hole stub: a send that - * never lands must leave nothing behind, and that cannot be asserted against a - * no-op setter. - */ -function createTurnState() { - const liveTurnBySession: Record = {}; - return { - liveTurnBySession, - setLiveTurnBySession(updater: (c: Record) => Record) { - const next = updater({ ...liveTurnBySession }); - for (const key of Object.keys(liveTurnBySession)) delete liveTurnBySession[key]; - Object.assign(liveTurnBySession, next); - }, - }; -} - -function createActionsDeps() { - return { - uiLocale: 'en' as const, - activeIdRef: { current: undefined as string | undefined }, - addPendingSessionAction: () => true, - captureComposerImportOwner: () => ({ - sessionId: undefined, - navSection: 'sessions' as const, - }), - checkTaskSubmissionReadiness: async () => true, - clearPendingSessionAction: () => undefined, - isNewChatSendSurfaceActive: () => true, - isShellSurfaceOwnerActive: () => true, - markSessionReadLocally: () => undefined, - messageRetryPendingRef: { current: new Set() }, - refreshSessions: async () => [], - setActiveId: () => undefined, - setMessageLoadErrorBySession: () => undefined, - setMessageRetryPendingBySession: () => undefined, - setMessages: () => undefined, - transcriptRangeRef: { current: undefined }, - setNavSelection: () => undefined, - setLiveTurnBySession: () => undefined, - setInteractionBySession: () => undefined, - showModelSetupToast: () => undefined, - toastApi: { error: () => undefined, info: () => undefined }, - newChatModel: null, - pendingNewChatThinkingLevel: null, - newChatPermissionChoice: undefined, - clearNewChatPermissionChoice: () => {}, - newChatCollaborationMode: 'agent' as const, - newChatOrchestrationMode: 'default' as const, - newTaskTarget: { profileId: 'local', hostId: 'host-local', projectId: null }, - }; -} +import { + createActionsDeps, + createTurnState, + installWindow, +} from './app-shell-chat-actions-fixture.js'; describe('composer first-send cleanup', () => { it('cancels when the composer owner changes during the readiness check', async () => { @@ -124,7 +53,7 @@ describe('composer first-send cleanup', () => { let sends = 0; const restoreWindow = installWindow({ sessions: { - send: async () => { + submitMessage: async () => { sends += 1; return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } }; }, @@ -163,7 +92,7 @@ describe('composer first-send cleanup', () => { }, }, sessions: { - send: async () => ({ + submitMessage: async () => ({ ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] }, @@ -175,6 +104,7 @@ describe('composer first-send cleanup', () => { const deps = { ...createActionsDeps(), newChatModel: { + llmConnectionId: 'connection-1', llmConnectionSlug: 'opencode-free', model: 'mimo-v2.5-free', }, @@ -213,7 +143,7 @@ describe('composer first-send cleanup', () => { }, }, sessions: { - send: async () => ({ + submitMessage: async () => ({ ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] }, @@ -249,7 +179,7 @@ describe('composer first-send cleanup', () => { }, }, sessions: { - send: async () => ({ + submitMessage: async () => ({ ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] }, @@ -294,7 +224,7 @@ describe('composer first-send cleanup', () => { }, }, sessions: { - send: async () => ({ + submitMessage: async () => ({ ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] }, @@ -330,7 +260,7 @@ describe('composer first-send cleanup', () => { newTasks: { create: async () => ({ id: 'session-1' }) }, sessions: { // What `prepareSkillInvocation` does when Skill discovery fails. - send: async () => Promise.reject(new Error('Skill discovery failed')), + submitMessage: async () => Promise.reject(new Error('Skill discovery failed')), remove: async (sessionId: string) => { removed.push(sessionId); }, @@ -351,7 +281,7 @@ describe('composer first-send cleanup', () => { const restoreWindow = installWindow({ newTasks: { create: async () => ({ id: 'session-1' }) }, sessions: { - send: async () => ({ + submitMessage: async () => ({ ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] }, @@ -373,13 +303,107 @@ describe('composer first-send cleanup', () => { assert.deepEqual(removed, []); }); + it('projects the first message before activation while waiting to submit until observation', async () => { + const observation = deferred(); + const order: string[] = []; + const activeIdRef = { current: undefined as string | undefined }; + const restoreWindow = installWindow({ + newTasks: { + create: async () => { + order.push('create'); + return { id: 'session-1' }; + }, + }, + sessions: { + submitMessage: async () => { + order.push('submit'); + return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } }; + }, + }, + }); + + try { + const sending = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef, + addTransientMessage: () => { + order.push('optimistic'); + }, + activateSessionForFirstSend: async (sessionId) => { + order.push('observe'); + activeIdRef.current = sessionId; + await observation.promise; + order.push('seeded'); + }, + }).send('hello'); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(order, ['create', 'optimistic', 'observe']); + + observation.resolve(); + assert.equal(await sending, true); + assert.deepEqual(order, ['create', 'optimistic', 'observe', 'seeded', 'submit']); + } finally { + restoreWindow(); + } + }); + + it('removes the unsent session and surfaces an observation barrier failure', async () => { + const activeIdRef = { current: undefined as string | undefined }; + const removed: string[] = []; + const errors: string[] = []; + let submissions = 0; + const restoreWindow = installWindow({ + newTasks: { create: async () => ({ id: 'session-1' }) }, + sessions: { + submitMessage: async () => { + submissions += 1; + return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } }; + }, + remove: async (sessionId: string) => { + removed.push(sessionId); + }, + }, + }); + + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef, + activateSessionForFirstSend: async (sessionId) => { + activeIdRef.current = sessionId; + throw new Error('Timed out while preparing the new Session event stream'); + }, + setActiveId: (sessionId) => { + activeIdRef.current = sessionId; + }, + isNewChatSendSurfaceActive: () => activeIdRef.current === undefined, + isShellSurfaceOwnerActive: (owner) => + owner.navSection === 'sessions' && owner.sessionId === activeIdRef.current, + toastApi: { + error: (_title, description) => { + if (description) errors.push(description); + }, + info: () => undefined, + }, + }); + assert.equal(await actions.send('hello'), false); + } finally { + restoreWindow(); + } + + assert.equal(submissions, 0); + assert.deepEqual(removed, ['session-1']); + assert.equal(activeIdRef.current, undefined); + assert.deepEqual(errors, ['The message could not be sent. Try again later.']); + }); + it('leaves an EXISTING session alone when its send rejects', async () => { // Only the session this send created is disposable. A send that fails in // an open conversation must not delete the conversation. const removed: string[] = []; const restoreWindow = installWindow({ sessions: { - send: async () => Promise.reject(new Error('Skill discovery failed')), + submitMessage: async () => Promise.reject(new Error('Skill discovery failed')), remove: async (sessionId: string) => { removed.push(sessionId); }, @@ -416,7 +440,7 @@ describe('composer first-send cleanup', () => { const transcriptRangeRef = { current: transcript as DesktopTranscriptRangeController | undefined }; const restoreWindow = installWindow({ sessions: { - send: async () => { + submitMessage: async () => { order.push('send'); return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } }; }, @@ -439,15 +463,6 @@ describe('composer first-send cleanup', () => { } }); }); - -function deferred() { - let resolve!: (value: T | PromiseLike) => void; - const promise = new Promise((settle) => { - resolve = settle; - }); - return { promise, resolve }; -} - /** * #1433 round 5: the failure feedback for a send is addressed to the surface * that sent, and `showModelSetupToast` is not just a toast — it ends in @@ -463,7 +478,7 @@ function deferred() { describe('composer send failure feedback', () => { const readinessFailure = () => ({ sessions: { - send: async () => + submitMessage: async () => Promise.reject(new Error('NO_REAL_CONNECTION:missing_api_key: no ready connection')), remove: async () => undefined, }, @@ -491,11 +506,7 @@ describe('composer send failure feedback', () => { assert.deepEqual(setupToasts, [], 'a stale surface must not be navigated to 设置 · 模型'); }); - // A send that never reaches the runtime must take its arm with it. A leftover - // arm still carries its `unconfirmed` claim, which would make - // `settledSessionTransientIds` protect a turn that does not exist — leaving a - // Stop button nothing can clear. - it('leaves no arm behind when the send never lands', async () => { + it('does not invent a live turn when the send never lands', async () => { const turnState = createTurnState(); const restoreWindow = installWindow(readinessFailure()); @@ -532,92 +543,3 @@ describe('composer send failure feedback', () => { assert.equal(setupToasts.length, 1, 'the user who is still looking must get the answer'); }); }); - -/** - * The bug this guards, as the sequence that actually produced it: send arms the - * turn, a session list that was already in flight lands still carrying the - * pre-send status, and the settle reconcile runs against it. - * - * Nothing in that list is wrong — the runtime writes `status: 'running'` only at - * the end of `AgentRun.begin` and announces it to nobody until `onRunStarted`. - * The list simply predates the answer. Reading it as a settle used to drop the - * arm, so the first content event rebuilt the projection as `'streamed'` and the - * prominent "正在处理…" silently became the calm "继续中…". - * - * Asserted through the real `send`, the real state controller, and the real - * settle rule, because the defect lived in how those three compose — each one is - * individually correct. - */ -describe('a send in flight versus a stale session list', () => { - const sessionId = 'session-a'; - - function sendingWindow() { - return { - sessions: { - send: async () => ({ - ok: true, - attachments: [], - skillInvocation: { loaded: [], failed: [] }, - }), - }, - }; - } - - // The list as it reads before the runtime's `running` write — identical to how - // it reads after the turn is over, which is exactly why the status alone - // cannot settle anything. - const preSendList = [{ id: sessionId, status: 'active', statusUpdatedAt: 100 }] as SessionSummary[]; - - async function armViaSend(controller: ReturnType) { - const restoreWindow = installWindow(sendingWindow()); - try { - const actions = createAppShellChatActions({ - ...createActionsDeps(), - activeIdRef: { current: sessionId }, - setLiveTurnBySession: controller.setLiveTurnBySession, - }); - assert.equal(await actions.send('hello'), true); - } finally { - restoreWindow(); - } - const armed = controller.getState().liveTurnBySession[sessionId]; - assert.equal(armed?.unconfirmed, true, 'the send must arm an unconfirmed turn'); - return armed!.turnId; - } - - function settle(controller: ReturnType) { - return settledSessionTransientIds({ - activeId: sessionId, - sessions: preSendList, - liveTurnBySession: controller.getState().liveTurnBySession, - }); - } - - it('keeps the armed turn, and settles it once the authority names that turn', async () => { - const controller = createAppShellSessionUiStateController(); - const turnId = await armViaSend(controller); - - assert.deepEqual(settle(controller), [], 'a list older than the answer must not settle the turn'); - assert.equal( - controller.getState().liveTurnBySession[sessionId]?.phase, - 'waiting', - 'the first-token wait must survive the stale refresh', - ); - - // `sessions:changed` naming this turn — what `onRunStarted` now emits once - // the run has begun. This is the same controller entry point the shell - // wires that subscription to. - controller.confirmLiveTurn(sessionId, turnId); - - assert.deepEqual(settle(controller), [sessionId], 'an answered turn settles under the plain status rules'); - }); - - it('ignores an answer about a turn other than the one in flight', async () => { - const controller = createAppShellSessionUiStateController(); - await armViaSend(controller); - - controller.confirmLiveTurn(sessionId, 'turn-from-another-client'); - - assert.deepEqual(settle(controller), [], 'only this send\'s own turn may release its claim'); - }); -}); diff --git a/apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts deleted file mode 100644 index 4e4057977f..0000000000 --- a/apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts +++ /dev/null @@ -1,404 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import type { SessionSummary } from '@maka/core/session'; -import { createAppShellSessionRowActions } from '../../renderer/app-shell-session-row-actions.js'; - -function summary(id: string, overrides: Partial = {}): SessionSummary { - return { - id, - name: id, - isFlagged: false, - isArchived: true, - labels: [], - hasUnread: false, - status: 'active', - backend: 'fake', - llmConnectionSlug: 'test', - connectionLocked: true, - model: 'test', - permissionMode: 'ask', - ...overrides, - }; -} - -/** A task that left the archive while retaining its independent execution status. */ -function restored(id: string): SessionSummary { - return summary(id, { isArchived: false }); -} - -type SweepHarness = { - removed: string[]; - /** Each `remove` call as `[sessionId, requireArchived]`. */ - removeOptions: Array<[string, boolean]>; - cleared: string[]; - selections: Array; - /** Titles of the success toasts a row action raised. */ - toasts: string[]; - listCalls: number; -}; - -/** - * Installs a `window.maka.sessions` whose `remove` fails for the named ids and - * whose `list` answers with `surviving` (or throws when it is `undefined`, - * standing in for a catalog that cannot be read back). - */ -function installWindow( - harness: SweepHarness, - options: { - rejectIds?: readonly string[]; - rejectWithUndefinedIds?: readonly string[]; - surviving?: readonly SessionSummary[]; - /** Runs after each accepted removal, to model what another client did meanwhile. */ - onRemove?: (sessionId: string) => void; - /** - * The catalog the fake Host decides against. It checks the archived premise - * here, where the real one checks it inside its compare-and-set — not - * against whatever the renderer last saw. - */ - catalog?: readonly SessionSummary[]; - } = {}, -): () => void { - const target = globalThis as unknown as { window?: unknown }; - const previous = target.window; - Object.defineProperty(target, 'window', { - configurable: true, - writable: true, - value: { - maka: { - sessions: { - remove: async (id: string, removeOptions?: { requireArchived?: boolean }) => { - harness.removeOptions.push([id, removeOptions?.requireArchived === true]); - if (options.rejectWithUndefinedIds?.includes(id)) { - return Promise.reject(undefined); - } - if (options.rejectIds?.includes(id)) throw new Error(`busy:${id}`); - const target = options.catalog?.find((session) => session.id === id); - if (removeOptions?.requireArchived && target && !target.isArchived) return 'restored'; - harness.removed.push(id); - options.onRemove?.(id); - return 'removed'; - }, - list: async () => { - harness.listCalls += 1; - if (!options.surviving) throw new Error('catalog unavailable'); - return [...options.surviving]; - }, - }, - }, - }, - }); - return () => { - if (previous === undefined) delete target.window; - else Object.defineProperty(target, 'window', { configurable: true, writable: true, value: previous }); - }; -} - -function createActions(input: { - harness: SweepHarness; - sessions: SessionSummary[]; - activeIdRef: { current: string | undefined }; - pending?: Set; - refreshed?: SessionSummary[]; -}) { - return createAppShellSessionRowActions({ - uiLocale: 'en', - activeIdRef: input.activeIdRef, - clearSessionRendererState: (id) => { - input.harness.cleared.push(id); - }, - pendingSessionRowActionsRef: { current: input.pending ?? new Set() }, - refreshSessions: async () => input.refreshed ?? [], - sessionsRef: { current: input.sessions }, - setActiveId: (id) => { - input.harness.selections.push(id); - input.activeIdRef.current = id; - }, - setMessages: () => undefined, - toastApi: { - success: (title: string) => { - input.harness.toasts.push(title); - }, - error: () => undefined, - confirm: async () => true, - }, - }); -} - -function harness(): SweepHarness { - return { - removed: [], - removeOptions: [], - cleared: [], - selections: [], - toasts: [], - listCalls: 0, - }; -} - -describe('purgeSessions', () => { - it('deletes the whole set, clears each family, and does not read the catalog back', async () => { - const h = harness(); - const sessions = [ - summary('a'), - summary('a-v2', { revisionRootSessionId: 'a', revisionParentSessionId: 'a' }), - summary('b'), - ]; - const activeIdRef = { current: 'a-v2' as string | undefined }; - const restore = installWindow(h); - const actions = createActions({ harness: h, sessions, activeIdRef }); - - const outcome = await actions.purgeSessions(['a-v2', 'b']).finally(restore); - - assert.deepEqual(h.removed, ['a-v2', 'b']); - assert.deepEqual(outcome, { - removed: 2, - remaining: [], - restored: [], - verified: true, - firstFailure: undefined, - }); - // Every delete in a sweep carries the archived premise the confirm named. - assert.deepEqual(h.removeOptions, [ - ['a-v2', true], - ['b', true], - ]); - // The family goes, not just the representative, and the open member of it - // stops being the active session. - assert.deepEqual(h.cleared.sort(), ['a', 'a-v2', 'b']); - assert.deepEqual(h.selections, [undefined]); - // Nothing rejected, so there is nothing to check back. - assert.equal(h.listCalls, 0); - }); - - it('reports a task restored before the sweep reached it, rather than dropping it', async () => { - // The confirm named a set. One restored from another surface while the - // dialog was up has left it, and a sweep that deleted it anyway would be - // acting outside what was agreed to. Reporting it is the other half: the - // person agreed to two and one went, which needs saying. - const h = harness(); - const catalog = [restored('kept'), summary('doomed')]; - const restore = installWindow(h, { catalog }); - const actions = createActions({ - harness: h, - sessions: [...catalog], - activeIdRef: { current: undefined }, - }); - - const outcome = await actions.purgeSessions(['kept', 'doomed']).finally(restore); - - assert.deepEqual(h.removed, ['doomed']); - assert.equal(outcome.removed, 1); - assert.deepEqual(outcome.restored, ['kept']); - assert.deepEqual(outcome.remaining, []); - // Kept tasks are settled by the delete itself; nothing to check back. - assert.equal(h.listCalls, 0); - }); - - it('reports a task restored while the sweep was already running', async () => { - // The page disables its own controls during a sweep, so the restore comes - // from a second window, landing after the sweep started and before it - // reached this task. - const h = harness(); - const catalog = [summary('first'), summary('second')]; - const restore = installWindow(h, { - catalog, - onRemove: (id) => { - if (id === 'first') catalog[1] = restored('second'); - }, - }); - const actions = createActions({ - harness: h, - sessions: [...catalog], - activeIdRef: { current: undefined }, - }); - - const outcome = await actions.purgeSessions(['first', 'second']).finally(restore); - - assert.deepEqual(h.removed, ['first']); - assert.equal(outcome.removed, 1); - assert.deepEqual(outcome.restored, ['second']); - assert.deepEqual(outcome.remaining, []); - }); - - it('keeps everything the renderer holds for a task the delete left alone', async () => { - const h = harness(); - const catalog = [summary('first'), restored('rescued')]; - const restore = installWindow(h, { catalog }); - const activeIdRef = { current: 'rescued' as string | undefined }; - const actions = createActions({ harness: h, sessions: [...catalog], activeIdRef }); - - const outcome = await actions.purgeSessions(['first', 'rescued']).finally(restore); - - assert.deepEqual(outcome.restored, ['rescued']); - assert.equal(outcome.firstFailure, undefined); - // A task that is still there keeps its renderer state, including being the - // open one. - assert.deepEqual(h.cleared, ['first']); - assert.deepEqual(h.selections, []); - assert.equal(activeIdRef.current, 'rescued'); - }); - - it('sends every id to the delete instead of deciding against its own snapshot', async () => { - // The renderer's list is one observer of a state the Host owns, and a - // serial sweep gives a second window plenty of room to outdate it — in - // either direction. Here the snapshot is stale in the direction that - // silently spares a task: it reads `stale` as no longer archived while the - // catalog the delete commits against still has it archived. Filtering here - // would drop the id with no outcome at all, which is how a confirmed count - // stops adding up. - const h = harness(); - const restore = installWindow(h, { catalog: [summary('stale'), summary('plain')] }); - const actions = createActions({ - harness: h, - sessions: [restored('stale'), summary('plain')], - activeIdRef: { current: undefined }, - }); - - const outcome = await actions.purgeSessions(['stale', 'plain']).finally(restore); - - assert.deepEqual(h.removeOptions, [ - ['stale', true], - ['plain', true], - ]); - assert.deepEqual(h.removed, ['stale', 'plain']); - assert.equal(outcome.removed, 2); - assert.deepEqual(outcome.restored, []); - }); - - it('skips an id whose row action is already in flight instead of racing it', async () => { - const h = harness(); - const sessions = [summary('busy'), summary('free')]; - const restore = installWindow(h, { surviving: [summary('busy')] }); - const actions = createActions({ - harness: h, - sessions, - activeIdRef: { current: undefined }, - pending: new Set(['busy:delete']), - }); - - const outcome = await actions.purgeSessions(['busy', 'free']).finally(restore); - - assert.deepEqual(h.removed, ['free']); - assert.deepEqual(outcome.remaining, ['busy']); - assert.equal(outcome.removed, 1); - }); - - it('counts a rejected delete that the catalog no longer lists as removed', async () => { - // The delete IPC commits the removal before it releases renderer - // resources, so a rejection is not evidence the task survived. Only the - // catalog can settle it. - const h = harness(); - const sessions = [summary('committed'), summary('survivor')]; - const restore = installWindow(h, { - rejectIds: ['committed', 'survivor'], - surviving: [summary('survivor')], - }); - const actions = createActions({ harness: h, sessions, activeIdRef: { current: undefined } }); - - const outcome = await actions.purgeSessions(['committed', 'survivor']).finally(restore); - - assert.equal(h.listCalls, 1); - assert.deepEqual(outcome.remaining, ['survivor']); - assert.equal(outcome.removed, 1); - assert.ok(outcome.firstFailure); - assert.equal((outcome.firstFailure.error as Error).message, 'busy:committed'); - assert.equal(outcome.firstFailure.sessionId, 'committed'); - }); - - it('retains the first failing Session even when the rejection value is undefined', async () => { - const h = harness(); - const sessions = [summary('first'), summary('second')]; - const restore = installWindow(h, { - rejectWithUndefinedIds: ['first'], - rejectIds: ['second'], - surviving: sessions, - }); - const actions = createActions({ harness: h, sessions, activeIdRef: { current: undefined } }); - - const outcome = await actions.purgeSessions(['first', 'second']).finally(restore); - - assert.ok(outcome.firstFailure); - assert.equal(outcome.firstFailure.sessionId, 'first'); - assert.equal(outcome.firstFailure.error, undefined); - }); - - it('claims nothing when the catalog cannot be read back', async () => { - // `refreshSessions` would have answered with the pre-delete list here, and - // reporting on that would have called a completed sweep a total failure. - const h = harness(); - const sessions = [summary('a')]; - const restore = installWindow(h, { rejectIds: ['a'], surviving: undefined }); - const actions = createActions({ - harness: h, - sessions, - activeIdRef: { current: undefined }, - refreshed: sessions, - }); - - const outcome = await actions.purgeSessions(['a']).finally(restore); - - assert.equal(outcome.verified, false); - assert.deepEqual(outcome.remaining, []); - assert.equal(outcome.removed, 0); - }); -}); - -describe('deleteSession', () => { - it('carries the archived premise of the row the confirm named', async () => { - // Deleting from 已归档任务 is the same decision a sweep makes, one row at a - // time, so a restore revokes it the same way. - const h = harness(); - const sessions = [summary('archived-row'), restored('active-row')]; - const restore = installWindow(h); - const actions = createActions({ harness: h, sessions, activeIdRef: { current: undefined } }); - - await actions.deleteSession('archived-row'); - await actions.deleteSession('active-row'); - restore(); - - // An active task never had an archived premise to lose, so requiring one - // would refuse every delete from the rail. - assert.deepEqual(h.removeOptions, [ - ['archived-row', true], - ['active-row', false], - ]); - }); - - it('keeps a task the Host reports as restored, and says so', async () => { - const h = harness(); - // The row was archived when the confirm named it; the catalog the delete - // commits against says otherwise by the time it lands. - const sessions = [summary('rescued')]; - const restore = installWindow(h, { catalog: [restored('rescued')] }); - const activeIdRef = { current: 'rescued' as string | undefined }; - const actions = createActions({ harness: h, sessions, activeIdRef }); - - await actions.deleteSession('rescued'); - restore(); - - assert.deepEqual(h.removed, []); - assert.deepEqual(h.cleared, []); - assert.equal(activeIdRef.current, 'rescued'); - // Not "Deleted rescued": nothing was. - assert.deepEqual(h.toasts, ['rescued was restored, so it was kept']); - }); -}); diff --git a/apps/desktop/src/main/__tests__/app-shell-session-row-actions-revisions.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-row-actions-revisions.test.ts deleted file mode 100644 index d3e37e4e69..0000000000 --- a/apps/desktop/src/main/__tests__/app-shell-session-row-actions-revisions.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import type { SessionSummary } from '@maka/core/session'; -import { createAppShellSessionRowActions } from '../../renderer/app-shell-session-row-actions.js'; - -function summary(id: string, overrides: Partial = {}): SessionSummary { - return { - id, - name: 'Conversation', - isFlagged: false, - isArchived: false, - labels: [], - hasUnread: false, - status: 'active', - backend: 'fake', - llmConnectionSlug: 'test', - connectionLocked: true, - model: 'test', - permissionMode: 'ask', - ...overrides, - }; -} - -function installWindow(calls: string[]): () => void { - const target = globalThis as unknown as { window?: unknown }; - const previous = target.window; - Object.defineProperty(target, 'window', { - configurable: true, - writable: true, - value: { - maka: { - sessions: { - setFlagged: async (id: string, value: boolean, options?: { revisionFamily?: boolean }) => { calls.push(`flag:${id}:${value}:${options?.revisionFamily === true}`); }, - archive: async (id: string, options?: { revisionFamily?: boolean }) => { calls.push(`archive:${id}:${options?.revisionFamily === true}`); }, - unarchive: async (id: string, options?: { revisionFamily?: boolean }) => { calls.push(`unarchive:${id}:${options?.revisionFamily === true}`); }, - rename: async (id: string, name: string, options?: { revisionFamily?: boolean }) => { calls.push(`rename:${id}:${name}:${options?.revisionFamily === true}`); }, - remove: async (id: string, options?: { revisionFamily?: boolean; requireArchived?: boolean }) => { calls.push(`remove:${id}:${options?.revisionFamily === true}:${options?.requireArchived === true}`); return 'removed' as const; }, - }, - }, - }, - }); - return () => { - if (previous === undefined) delete target.window; - else Object.defineProperty(target, 'window', { configurable: true, writable: true, value: previous }); - }; -} - -describe('revision-family session row actions', () => { - it('applies conversation metadata/lifecycle to versions but not ordinary branches', async () => { - const calls: string[] = []; - const cleared: string[] = []; - const selections: Array = []; - const root = summary('root'); - const version = summary('version', { - revisionRootSessionId: 'root', - revisionParentSessionId: 'root', - }); - const branch = summary('branch', { parentSessionId: 'root', branchOfTurnId: 'turn-1' }); - const activeIdRef = { current: 'root' as string | undefined }; - const restore = installWindow(calls); - const actions = createAppShellSessionRowActions({ - uiLocale: 'en', - activeIdRef, - clearSessionRendererState: (id) => { cleared.push(id); }, - pendingSessionRowActionsRef: { current: new Set() }, - refreshSessions: async () => [root, version, branch], - sessionsRef: { current: [root, version, branch] }, - setActiveId: (id) => { selections.push(id); activeIdRef.current = id; }, - setMessages: () => undefined, - toastApi: { - success: () => undefined, - error: () => undefined, - confirm: async () => true, - }, - }); - - try { - await actions.flagSession('version', true); - await actions.renameSession('branch', 'Independent branch'); - await actions.archiveSession('version'); - activeIdRef.current = 'version'; - await actions.deleteSession('root'); - } finally { - restore(); - } - - assert.deepEqual(calls, [ - 'flag:version:true:true', - 'rename:branch:Independent branch:true', - 'archive:version:true', - // `root` is not archived, so the delete states no archived premise — - // requiring one would refuse every delete from the rail. - 'remove:root:true:false', - ]); - assert.deepEqual(selections, [undefined, undefined]); - assert.deepEqual(cleared, ['root', 'version', 'root', 'version']); - }); -}); diff --git a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts index 3b980403cf..fd7fc82fbd 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts @@ -17,23 +17,14 @@ * under the License. */ +import { deferred } from '@maka/core/test-only/async-primitives'; import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import type { LlmConnection } from '@maka/core/llm-connections'; +import type { ProjectedLlmConnection } from '@maka/core/llm-connections'; import type { StoredMessage } from '@maka/core/session'; import type { DesktopSessionSummary } from '../../preload/bridge-contract.js'; import { createAppShellSessionSettingsActions } from '../../renderer/app-shell-session-settings-actions.js'; - -function deferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((next, fail) => { - resolve = next; - reject = fail; - }); - return { promise, reject, resolve }; -} - +import type { SessionPendingClaim } from '../../renderer/app-shell-session-ui-state.js'; function session(id: string): DesktopSessionSummary { return { id, @@ -44,6 +35,7 @@ function session(id: string): DesktopSessionSummary { hasUnread: false, status: 'active', backend: 'fake', + llmConnectionId: 'connection-1', llmConnectionSlug: 'e2e', connectionLocked: true, model: 'claude-sonnet', @@ -55,21 +47,36 @@ function session(id: string): DesktopSessionSummary { }; } +/** The store's claim semantics over a plain map the assertions can read. */ +function pendingClaimOver(state: Record): SessionPendingClaim { + return { + claim(key) { + if (state[key] === true) return false; + state[key] = true; + return true; + }, + release(key) { + delete state[key]; + }, + }; +} + function createHarness(options: { confirm?: () => Promise; - connections?: LlmConnection[]; + connections?: ProjectedLlmConnection[]; messages?: StoredMessage[]; permissionModeResult?: 'ask' | 'bypass'; } = {}) { const activeIdRef = { current: 'session-a' as string | undefined }; const sessions = [session('session-a'), session('session-b')]; const sessionsRef = { current: sessions }; - const pending = new Set(); - const pendingBySession: Record = {}; + const permissionModePending: Record = {}; + const sessionModelPending: Record = {}; const modelCalls: string[] = []; const permissionCalls: string[] = []; const thinkingCalls: string[] = []; const errors: string[] = []; + const errorDescriptions: Array = []; const errorTargets: Array<{ sessionId: string } | undefined> = []; const successes: Array<{ title: string; description?: string }> = []; const newTaskPermissionModes: string[] = []; @@ -104,24 +111,19 @@ function createHarness(options: { const actions = createAppShellSessionSettingsActions({ uiLocale: 'zh', activeIdRef, - connections: options.connections ?? ([{ slug: 'e2e', name: 'E2E' }] as LlmConnection[]), + connections: options.connections ?? ([{ slug: 'e2e', name: 'E2E', catalogEntries: [] }] as unknown as ProjectedLlmConnection[]), messages: options.messages ?? [], - pendingPermissionModeChangesRef: { current: new Set() }, - pendingSessionModelChangesRef: { current: pending }, + permissionModePending: pendingClaimOver(permissionModePending), + sessionModelPending: pendingClaimOver(sessionModelPending), refreshSessions: async () => sessions, saveComposerDefaults: () => undefined, sessionsRef, setNewTaskPermissionMode: (mode) => void newTaskPermissionModes.push(mode), - setPendingPermissionModeBySession: () => undefined, - setPendingSessionModelBySession: (update) => { - const next = update(pendingBySession); - for (const key of Object.keys(pendingBySession)) delete pendingBySession[key]; - Object.assign(pendingBySession, next); - }, toastApi: { success: (title, description) => successes.push({ title, description }), - error: (title, _description, _details, target) => { + error: (title, description, _details, target) => { errors.push(title); + errorDescriptions.push(description); errorTargets.push(target); }, confirm: options.confirm ?? (async () => true), @@ -132,12 +134,13 @@ function createHarness(options: { actions, activeIdRef, errors, + errorDescriptions, errorTargets, modelCalls, modelResult, newTaskPermissionModes, - pending, - pendingBySession, + permissionModePending, + sessionModelPending, permissionCalls, sessionsRef, thinkingCalls, @@ -216,6 +219,7 @@ describe('AppShell session settings actions', () => { const harness = createHarness(); const modelChange = harness.actions.setSessionModel({ + llmConnectionId: 'connection-1', llmConnectionSlug: 'e2e', model: 'claude-opus', }); @@ -223,7 +227,7 @@ describe('AppShell session settings actions', () => { assert.deepEqual(harness.modelCalls, ['session-a']); assert.deepEqual(harness.thinkingCalls, []); - assert.equal(harness.pendingBySession['session-a'], true); + assert.equal(harness.sessionModelPending['session-a'], true); harness.modelResult.resolve(session('session-a')); await modelChange; @@ -242,6 +246,7 @@ describe('AppShell session settings actions', () => { }); const modelChange = harness.actions.setSessionModel({ + llmConnectionId: 'connection-1', llmConnectionSlug: 'e2e', model: 'claude-opus', }); @@ -260,6 +265,7 @@ describe('AppShell session settings actions', () => { const harness = createHarness(); const modelChange = harness.actions.setSessionModel({ + llmConnectionId: 'connection-1', llmConnectionSlug: 'e2e', model: 'claude-opus', }); @@ -272,17 +278,19 @@ describe('AppShell session settings actions', () => { it('includes connection names when a switch rebinds the connection', async () => { const harness = createHarness({ connections: [ - { slug: 'e2e', name: 'Primary' }, - { slug: 'relay', name: 'Relay' }, - ] as LlmConnection[], + { slug: 'e2e', name: 'Primary', catalogEntries: [] }, + { slug: 'relay', name: 'Relay', catalogEntries: [] }, + ] as unknown as ProjectedLlmConnection[], }); const modelChange = harness.actions.setSessionModel({ + llmConnectionId: 'connection-1', llmConnectionSlug: 'relay', model: 'claude-sonnet', }); harness.modelResult.resolve({ ...session('session-a'), + llmConnectionId: 'connection-1', llmConnectionSlug: 'relay', }); await modelChange; @@ -297,6 +305,7 @@ describe('AppShell session settings actions', () => { const harness = createHarness(); const modelChange = harness.actions.setSessionModel({ + llmConnectionId: 'connection-1', llmConnectionSlug: 'e2e', model: 'claude-opus', }); @@ -305,7 +314,7 @@ describe('AppShell session settings actions', () => { assert.deepEqual(harness.modelCalls, ['session-a']); assert.deepEqual(harness.thinkingCalls, ['session-b']); - assert.deepEqual(harness.pending, new Set(['session-a', 'session-b'])); + assert.deepEqual(Object.keys(harness.sessionModelPending), ['session-a', 'session-b']); harness.thinkingResult.resolve(session('session-b')); await thinkingChange; @@ -318,17 +327,18 @@ describe('AppShell session settings actions', () => { const thinkingChange = harness.actions.setSessionThinkingLevel('high'); await harness.actions.setSessionModel({ + llmConnectionId: 'connection-1', llmConnectionSlug: 'e2e', model: 'claude-opus', }); assert.deepEqual(harness.thinkingCalls, ['session-a']); assert.deepEqual(harness.modelCalls, []); - assert.equal(harness.pendingBySession['session-a'], true); + assert.equal(harness.sessionModelPending['session-a'], true); harness.thinkingResult.resolve(session('session-a')); await thinkingChange; - assert.equal(harness.pendingBySession['session-a'], undefined); + assert.equal(harness.sessionModelPending['session-a'], undefined); }); it('releases the session owner after a failed mutation so the next action can run', async () => { @@ -338,12 +348,12 @@ describe('AppShell session settings actions', () => { harness.thinkingResult.reject(new Error('fixture failure')); await thinkingChange; - assert.equal(harness.pending.has('session-a'), false); - assert.equal(harness.pendingBySession['session-a'], undefined); + assert.equal(harness.sessionModelPending['session-a'], undefined); assert.equal(harness.errors.length, 1); assert.deepEqual(harness.errorTargets, [{ sessionId: 'session-a' }]); const modelChange = harness.actions.setSessionModel({ + llmConnectionId: 'connection-1', llmConnectionSlug: 'e2e', model: 'claude-opus', }); @@ -351,4 +361,19 @@ describe('AppShell session settings actions', () => { harness.modelResult.resolve(session('session-a')); await modelChange; }); + + it('points a failed account-and-model switch at credential recovery', async () => { + const harness = createHarness(); + + const modelChange = harness.actions.setSessionModel({ + llmConnectionId: 'connection-1', + llmConnectionSlug: 'e2e', + model: 'claude-opus', + }); + harness.modelResult.reject(new Error('fixture failure')); + await modelChange; + + assert.match(harness.errorDescriptions[0] ?? '', /设置 · 模型/); + assert.match(harness.errorDescriptions[0] ?? '', /登录或 API Key/); + }); }); diff --git a/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts b/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts new file mode 100644 index 0000000000..a16796aa15 --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createAppShellStopAction } from '../../renderer/app-shell-stop-action.js'; + +test('removes exactly the transient messages the Host retracts while stopping', async () => { + const removed: Array<{ sessionId: string; messageId: string }> = []; + const target = globalThis as unknown as { window?: unknown }; + const previousWindow = target.window; + target.window = { + maka: { + sessions: { + stop: async () => ({ + kind: 'interrupted', + retractedMessageIds: ['message-1', 'message-2'], + }), + }, + }, + }; + try { + const stop = createAppShellStopAction({ + uiLocale: 'en', + activeIdRef: { current: 'session-1' }, + stopPending: { claim: () => true, release: () => undefined }, + removeTransientMessage: (sessionId, messageId) => removed.push({ sessionId, messageId }), + toastApi: { error() {} }, + }); + + await stop(); + + assert.deepEqual(removed, [ + { sessionId: 'session-1', messageId: 'message-1' }, + { sessionId: 'session-1', messageId: 'message-2' }, + ]); + } finally { + target.window = previousWindow; + } +}); diff --git a/apps/desktop/src/main/__tests__/app-update-attestation.test.ts b/apps/desktop/src/main/__tests__/app-update-attestation.test.ts new file mode 100644 index 0000000000..cda5e24984 --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-update-attestation.test.ts @@ -0,0 +1,225 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Bundle } from '@sigstore/bundle'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { + desktopUpdateChannelFromManifest, + verifyDownloadedUpdateAttestation, +} from '../app-update-attestation.js'; + +const require = createRequire(import.meta.url); + +function assertElectronVerification(script: string, failureMessage: string): void { + const verification = spawnSync(require('electron') as string, ['-e', script], { + cwd: process.cwd(), + encoding: 'utf8', + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + }); + assert.equal( + verification.status, + 0, + `${failureMessage}:\n${verification.stderr || verification.stdout}`, + ); +} + +function provenanceBundle(name: string, sha256: string): Bundle { + const statement = { + _type: 'https://in-toto.io/Statement/v1', + subject: [{ name, digest: { sha256 } }], + predicateType: 'https://slsa.dev/provenance/v1', + predicate: {}, + }; + return { + mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json', + verificationMaterial: { + content: undefined, + tlogEntries: [], + timestampVerificationData: undefined, + }, + content: { + $case: 'dsseEnvelope', + dsseEnvelope: { + payloadType: 'application/vnd.in-toto+json', + payload: Buffer.from(JSON.stringify(statement)), + signatures: [], + }, + }, + } as unknown as Bundle; +} + +test('download verification accepts only a trusted exact artifact subject', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'maka-update-attestation-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const artifact = join(directory, 'cached-update.zip'); + const bytes = Buffer.from('attested update bytes'); + await writeFile(artifact, bytes); + const digest = createHash('sha256').update(bytes).digest('hex'); + const bundle = provenanceBundle('Maka-1.2.3-mac-arm64.zip', digest); + const bundleBytes = Buffer.from(JSON.stringify({ + mediaType: bundle.mediaType, + verificationMaterial: { + certificate: { rawBytes: Buffer.from('fixture certificate').toString('base64') }, + tlogEntries: [], + }, + dsseEnvelope: { + payloadType: bundle.content.$case === 'dsseEnvelope' + ? bundle.content.dsseEnvelope.payloadType + : '', + payload: bundle.content.$case === 'dsseEnvelope' + ? Buffer.from(bundle.content.dsseEnvelope.payload).toString('base64') + : '', + signatures: [{ sig: Buffer.from('fixture signature').toString('base64') }], + }, + })); + const options = { + downloadedFile: artifact, + version: '1.2.3', + platform: 'darwin' as const, + arch: 'arm64', + trustRootCacheDirectory: join(directory, 'trust'), + fetchBundle: async () => bundleBytes, + verifyBundle: async () => {}, + }; + + await verifyDownloadedUpdateAttestation(options); + await assert.rejects( + verifyDownloadedUpdateAttestation({ ...options, platform: 'win32', arch: 'x64' }), + /does not identify/u, + ); + + await writeFile(artifact, 'different update bytes'); + await assert.rejects(verifyDownloadedUpdateAttestation(options), /does not identify/u); + + await assert.rejects( + verifyDownloadedUpdateAttestation({ + ...options, + verifyBundle: () => { + throw new Error('untrusted workflow identity'); + }, + }), + /untrusted workflow identity/u, + ); +}); + +test('nightly verification fetches provenance from the versioned GitHub Release asset', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'maka-nightly-attestation-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const artifact = join(directory, 'cached-update.zip'); + const bytes = Buffer.from('nightly update bytes'); + await writeFile(artifact, bytes); + const version = '0.2.0-dev.20260829.42'; + const name = `Maka-${version}-mac-arm64.zip`; + const digest = createHash('sha256').update(bytes).digest('hex'); + const bundle = provenanceBundle(name, digest); + let fetchedUrl = ''; + + await verifyDownloadedUpdateAttestation({ + channel: 'nightly', + downloadedFile: artifact, + version, + platform: 'darwin', + arch: 'arm64', + trustRootCacheDirectory: join(directory, 'trust'), + fetchBundle: async (url) => { + fetchedUrl = url; + return Buffer.from(JSON.stringify({ + mediaType: bundle.mediaType, + verificationMaterial: { + certificate: { rawBytes: Buffer.from('fixture certificate').toString('base64') }, + tlogEntries: [], + }, + dsseEnvelope: { + payloadType: bundle.content.$case === 'dsseEnvelope' + ? bundle.content.dsseEnvelope.payloadType + : '', + payload: bundle.content.$case === 'dsseEnvelope' + ? Buffer.from(bundle.content.dsseEnvelope.payload).toString('base64') + : '', + signatures: [{ sig: Buffer.from('fixture signature').toString('base64') }], + }, + })); + }, + verifyBundle: async () => {}, + }); + + assert.equal( + fetchedUrl, + `https://github.com/apache/maka/releases/download/v${version}/Maka-${version}-attestation.sigstore.json`, + ); +}); + +test('packaged update trust accepts only an explicit release or nightly channel', () => { + assert.equal(desktopUpdateChannelFromManifest({ makaUpdateChannel: 'release' }), 'release'); + assert.equal(desktopUpdateChannelFromManifest({ makaUpdateChannel: 'nightly' }), 'nightly'); + assert.throws( + () => desktopUpdateChannelFromManifest({ makaUpdateChannel: 'preview' }), + /does not declare a trusted update channel/u, + ); +}); + +test('TUF verifies ECDSA without breaking Ed25519 in the packaged Electron runtime', () => { + assertElectronVerification( + String.raw` + const { generateKeyPairSync, sign } = require('node:crypto'); + const { dirname, join } = require('node:path'); + const modelsEntry = require.resolve('@tufjs/models'); + const { canonicalize } = require('@tufjs/canonical-json'); + const { verifySignature } = require(join(dirname(modelsEntry), 'utils', 'verify.js')); + const metadata = { _type: 'root', expires: '2030-01-01T00:00:00Z', version: 1 }; + const data = Buffer.from(canonicalize(metadata)); + for (const { type, options, algorithm } of [ + { type: 'ec', options: { namedCurve: 'prime256v1' }, algorithm: 'sha256' }, + { type: 'ed25519', options: {}, algorithm: null }, + ]) { + const { privateKey, publicKey } = generateKeyPairSync(type, options); + const signature = sign(algorithm, data, privateKey).toString('hex'); + if (!verifySignature(metadata, { key: publicKey }, signature)) process.exit(1); + } + `, + 'Electron TUF verification failed', + ); +}); + +test('Sigstore verifies ECDSA without breaking Ed25519 in the packaged Electron runtime', () => { + assertElectronVerification( + String.raw` + const { generateKeyPairSync, sign } = require('node:crypto'); + const { crypto } = require('@sigstore/core'); + const data = Buffer.from('signed update metadata'); + for (const { type, options, algorithm } of [ + { type: 'ec', options: { namedCurve: 'prime256v1' }, algorithm: 'sha256' }, + { type: 'ed25519', options: {}, algorithm: null }, + ]) { + const { privateKey, publicKey } = generateKeyPairSync(type, options); + const signature = sign(algorithm, data, privateKey); + if (!crypto.verify(data, publicKey, signature)) process.exit(1); + } + if (crypto.verify(data, 'not a public key', Buffer.alloc(0))) process.exit(1); + `, + 'Electron Sigstore verification failed', + ); +}); diff --git a/apps/desktop/src/main/__tests__/app-update-focus-check.test.ts b/apps/desktop/src/main/__tests__/app-update-focus-check.test.ts index d453f7b05c..2f2f3a2977 100644 --- a/apps/desktop/src/main/__tests__/app-update-focus-check.test.ts +++ b/apps/desktop/src/main/__tests__/app-update-focus-check.test.ts @@ -50,6 +50,7 @@ function createHarness(options: { start: number }) { currentVersion: '0.1.8', isPackaged: true, updater, + verifyDownloadedUpdate: async () => {}, prepareInstall: async () => ({ kind: 'prepared', rollback() {} }), clock: { // No scheduled checks in these tests: the timer is a separate trigger and diff --git a/apps/desktop/src/main/__tests__/app-update-service.test.ts b/apps/desktop/src/main/__tests__/app-update-service.test.ts index e42dce6e32..5f1d3f1686 100644 --- a/apps/desktop/src/main/__tests__/app-update-service.test.ts +++ b/apps/desktop/src/main/__tests__/app-update-service.test.ts @@ -21,12 +21,13 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { describe, test } from 'node:test'; import type { AppUpdater } from 'electron-updater'; +import { resolveUpdateFeedOverride } from '../app-update-test-context.js'; import { createAppUpdateService, - resolveUpdateFeedOverride, type AppUpdateInstallRequest, type AppUpdateStatus, } from '../app-update-service.js'; +import type { DownloadedUpdateAttestationVerifier } from '../app-update-attestation.js'; const FIRST_UPDATE_CHECK_DELAY_MS = 10_000; const UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; @@ -129,12 +130,15 @@ function createHarness(input: { mockLatestVersion?: string; mockState?: 'available' | 'downloading' | 'downloaded'; testFeedUrl?: string; + updateChannel?: 'release' | 'nightly'; + verifyDownloadedUpdate?: DownloadedUpdateAttestationVerifier; } = {}) { const updater = input.updater ?? new FakeUpdater(); const clock = input.clock ?? new FakeClock(); const service = createAppUpdateService({ currentVersion: '1.0.0', isPackaged: input.isPackaged ?? true, + updateChannel: input.updateChannel ?? 'release', updater: updater as unknown as AppUpdater, clock, onStatusChange: input.onStatusChange, @@ -145,10 +149,15 @@ function createHarness(input: { mockLatestVersion: input.mockLatestVersion, mockState: input.mockState, testFeedUrl: input.testFeedUrl, + verifyDownloadedUpdate: input.verifyDownloadedUpdate ?? (async () => {}), }); return { clock, service, updater }; } +async function settleUpdateVerification(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + describe('AppUpdateService', () => { test('owns one main-process schedule and configures background downloads', async () => { const { clock, service, updater } = createHarness(); @@ -169,13 +178,16 @@ describe('AppUpdateService', () => { assert.equal(clock.pending().length, 0); }); - test('preserves electron-updater channel policy derived from the app version', () => { - for (const allowPrerelease of [false, true]) { - const updater = new FakeUpdater(); - updater.allowPrerelease = allowPrerelease; - createHarness({ updater }); - assert.equal(updater.allowPrerelease, allowPrerelease); - } + test('accepts dev updates only in packaged Nightly builds', () => { + const releaseUpdater = new FakeUpdater(); + const nightlyUpdater = new FakeUpdater(); + + createHarness({ updater: releaseUpdater, updateChannel: 'release' }); + createHarness({ updater: nightlyUpdater, updateChannel: 'nightly' }); + + assert.equal(releaseUpdater.allowPrerelease, false); + assert.equal(nightlyUpdater.allowPrerelease, true); + assert.equal(Object.hasOwn(nightlyUpdater, 'channel'), false); }); test('routes the feed to a loopback generic provider when the test override is set', () => { @@ -295,11 +307,13 @@ describe('AppUpdateService', () => { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); + await settleUpdateVerification(); assert.deepEqual(statuses.map((status) => status.state), [ 'checking', 'available', 'downloading', + 'verifying', 'downloaded', ]); assert.deepEqual(service.getStatus(), { @@ -309,6 +323,28 @@ describe('AppUpdateService', () => { }); }); + test('fails closed when downloaded update provenance cannot be verified', async () => { + const { service, updater } = createHarness({ + verifyDownloadedUpdate: async () => { + throw new Error('release provenance did not match'); + }, + }); + updater.emit('update-downloaded', { + ...updateInfo('1.1.0'), + downloadedFile: '/tmp/maka-update.zip', + }); + await settleUpdateVerification(); + + assert.deepEqual(service.getStatus(), { + state: 'error', + currentVersion: '1.0.0', + latestVersion: '1.1.0', + operation: 'download', + message: 'release provenance did not match', + }); + assert.equal(updater.quitAndInstallCalls, 0); + }); + test('cancels a stalled auto-download before retrying it', async () => { const updater = new FakeUpdater(); const statuses: AppUpdateStatus[] = []; @@ -390,6 +426,7 @@ describe('AppUpdateService', () => { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); + await settleUpdateVerification(); assert.deepEqual( await service.installUpdate({ allowInterruptActiveTasks: false }), @@ -408,6 +445,7 @@ describe('AppUpdateService', () => { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); + await settleUpdateVerification(); assert.deepEqual( await idle.service.installUpdate({ allowInterruptActiveTasks: false }), { ok: true }, @@ -430,6 +468,7 @@ describe('AppUpdateService', () => { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); + await settleUpdateVerification(); assert.deepEqual(await service.installUpdate({ allowInterruptActiveTasks: false }), { ok: true, @@ -452,6 +491,7 @@ describe('AppUpdateService', () => { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); + await settleUpdateVerification(); assert.deepEqual( await synchronous.service.installUpdate({ allowInterruptActiveTasks: false }), { ok: false, reason: 'install_failed' }, @@ -479,6 +519,7 @@ describe('AppUpdateService', () => { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); + await settleUpdateVerification(); assert.deepEqual( await asynchronous.service.installUpdate({ allowInterruptActiveTasks: false }), { ok: true }, diff --git a/apps/desktop/src/main/__tests__/artifact-preview-registry.test.ts b/apps/desktop/src/main/__tests__/artifact-preview-registry.test.ts index 609376de4a..4ae9aa4b6e 100644 --- a/apps/desktop/src/main/__tests__/artifact-preview-registry.test.ts +++ b/apps/desktop/src/main/__tests__/artifact-preview-registry.test.ts @@ -19,9 +19,11 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import type { ArtifactBinaryReadResult } from '@maka/core/artifacts'; import { - IMAGE_PAYLOAD_MAX_BYTES, + ARTIFACT_IMAGE_PREVIEW_MAX_BYTES, + type ArtifactBinaryReadResult, +} from '@maka/core/artifacts'; +import { decideImageReadOutcome, resolvePreviewKind, } from '@maka/ui/artifact-preview-registry'; @@ -36,14 +38,17 @@ describe('artifact preview registry', () => { it('enforces the inclusive metadata size boundary before loading', () => { const base = { name: 'image.png', kind: 'image' as const, mimeType: 'image/png' }; - assert.deepEqual(resolvePreviewKind({ ...base, sizeBytes: IMAGE_PAYLOAD_MAX_BYTES }), { + assert.deepEqual(resolvePreviewKind({ ...base, sizeBytes: ARTIFACT_IMAGE_PREVIEW_MAX_BYTES }), { kind: 'image', reason: 'mime_match', }); - assert.deepEqual(resolvePreviewKind({ ...base, sizeBytes: IMAGE_PAYLOAD_MAX_BYTES + 1 }), { - kind: 'unsupported', - reason: 'oversize', - }); + assert.deepEqual( + resolvePreviewKind({ ...base, sizeBytes: ARTIFACT_IMAGE_PREVIEW_MAX_BYTES + 1 }), + { + kind: 'unsupported', + reason: 'oversize', + }, + ); }); it('routes IPC failures and malformed successful payloads without retaining base64', () => { diff --git a/apps/desktop/src/main/__tests__/attachment-ingest-resolve.test.ts b/apps/desktop/src/main/__tests__/attachment-ingest-resolve.test.ts index 8eb812f74c..5128176131 100644 --- a/apps/desktop/src/main/__tests__/attachment-ingest-resolve.test.ts +++ b/apps/desktop/src/main/__tests__/attachment-ingest-resolve.test.ts @@ -22,8 +22,15 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; -import { resolveAttachmentRefs, resolveIngestItems } from '../attachment-ingest.js'; +import { + type AttachmentSnapshotInput, + resolveAttachmentRefs, + resolveIngestItems, + resolvePickedAttachments, + sniffPickedAttachmentMimeType, +} from '../attachment-ingest.js'; import { createAttachmentApprovalRegistry } from '../attachment-approval.js'; +import { attachmentKindFromMimeType } from '@maka/core/attachments'; describe('resolveIngestItems (pre-read validation)', () => { test('rejects more than 8 items before touching approvals or stat', async () => { @@ -235,6 +242,169 @@ describe('resolveIngestItems (pre-read validation)', () => { }); describe('resolveAttachmentRefs', () => { + test('uses PDF magic bytes instead of a spoofed PNG extension', async () => { + const dir = await mkdtemp(join(tmpdir(), 'att-sniff-')); + const path = join(dir, 'report.png'); + await writeFile(path, Buffer.from('%PDF-1.4\nfixture')); + let resizeCalls = 0; + let captured: AttachmentSnapshotInput | undefined; + try { + await resolveAttachmentRefs({ + files: [{ path, size: 16 }], + resizeImage: async (bytes) => { + resizeCalls += 1; + return bytes; + }, + snapshot: async (input) => { + captured = input; + return { + kind: input.attachmentKind, + name: input.name, + mimeType: input.mimeType, + bytes: input.content.byteLength, + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'attachment-1' }, + }; + }, + }); + assert.equal(resizeCalls, 0); + assert.equal(captured?.mimeType, 'application/pdf'); + assert.equal(captured?.attachmentKind, 'pdf'); + assert.equal(captured?.artifactKind, 'pdf'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test('recognises a PDF whose header sits behind a preamble, not as an ordinary file', async () => { + // A PDF may carry bytes before %PDF-; readers scan the first ~1 KiB for it. + // Missing it here would route the file to `other` and let a downstream text + // read decode binary as UTF-8 — so it must still resolve as a PDF. + const content = Buffer.concat([Buffer.alloc(64), Buffer.from('%PDF-1.7\nfixture')]); + let captured: AttachmentSnapshotInput | undefined; + + await resolveAttachmentRefs({ + files: [{ name: 'notes.txt', size: content.byteLength, content }], + snapshot: async (input) => { + captured = input; + return { + kind: input.attachmentKind, + name: input.name, + mimeType: input.mimeType, + bytes: input.content.byteLength, + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'attachment-1' }, + }; + }, + }); + + assert.equal(captured?.mimeType, 'application/pdf'); + assert.equal(captured?.attachmentKind, 'pdf'); + assert.equal(captured?.artifactKind, 'pdf'); + }); + + test('uses PNG magic bytes instead of conflicting renderer metadata', async () => { + const content = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + let resizeCalls = 0; + let captured: AttachmentSnapshotInput | undefined; + + await resolveAttachmentRefs({ + files: [{ name: 'report.pdf', mimeType: 'application/pdf', size: content.byteLength, content }], + resizeImage: async (bytes) => { + resizeCalls += 1; + return bytes; + }, + snapshot: async (input) => { + captured = input; + return { + kind: input.attachmentKind, + name: input.name, + mimeType: input.mimeType, + bytes: input.content.byteLength, + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'attachment-1' }, + }; + }, + }); + + assert.equal(resizeCalls, 1); + assert.equal(captured?.mimeType, 'image/png'); + assert.equal(captured?.attachmentKind, 'image'); + assert.equal(captured?.artifactKind, 'image'); + }); + + test('recognises an image with no extension from its bytes', async () => { + const content = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); + let captured: AttachmentSnapshotInput | undefined; + + await resolveAttachmentRefs({ + files: [{ name: 'clipboard-image', size: content.byteLength, content }], + snapshot: async (input) => { + captured = input; + return { + kind: input.attachmentKind, + name: input.name, + mimeType: input.mimeType, + bytes: input.content.byteLength, + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'attachment-1' }, + }; + }, + }); + + assert.equal(captured?.mimeType, 'image/jpeg'); + assert.equal(captured?.attachmentKind, 'image'); + }); + + test('updates the MIME when image resizing changes the encoded format', async () => { + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); + const resizedPng = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + let captured: AttachmentSnapshotInput | undefined; + + await resolveAttachmentRefs({ + files: [{ name: 'photo.jpg', size: jpeg.byteLength, content: jpeg }], + resizeImage: async () => resizedPng, + snapshot: async (input) => { + captured = input; + return { + kind: input.attachmentKind, + name: input.name, + mimeType: input.mimeType, + bytes: input.content.byteLength, + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'attachment-1' }, + }; + }, + }); + + assert.equal(captured?.mimeType, 'image/png'); + assert.deepEqual(captured?.content, resizedPng); + }); + + test('does not decode unknown bytes merely because the renderer calls them an image', async () => { + const content = Buffer.from('not an image'); + let resizeCalls = 0; + let captured: AttachmentSnapshotInput | undefined; + + await resolveAttachmentRefs({ + files: [{ name: 'payload.svg', mimeType: 'image/svg+xml', size: content.byteLength, content }], + resizeImage: async (bytes) => { + resizeCalls += 1; + return bytes; + }, + snapshot: async (input) => { + captured = input; + return { + kind: input.attachmentKind, + name: input.name, + mimeType: input.mimeType, + bytes: input.content.byteLength, + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'attachment-1' }, + }; + }, + }); + + assert.equal(resizeCalls, 0); + assert.equal(captured?.mimeType, 'application/octet-stream'); + assert.equal(captured?.attachmentKind, 'other'); + assert.equal(captured?.artifactKind, 'file'); + }); + test('rejects a path grown beyond the cap before creating a Host artifact', async () => { const dir = await mkdtemp(join(tmpdir(), 'att-cap-')); const path = join(dir, 'grew.bin'); @@ -258,3 +428,81 @@ describe('resolveAttachmentRefs', () => { } }); }); + +describe('sniffPickedAttachmentMimeType (pick-time staging kind)', () => { + test('stages a real image named .pdf as an image so it previews and notices', async () => { + const dir = await mkdtemp(join(tmpdir(), 'att-pick-')); + const path = join(dir, 'report.pdf'); + await writeFile(path, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + try { + const mimeType = await sniffPickedAttachmentMimeType(path, 'report.pdf'); + assert.equal(mimeType, 'image/png'); + assert.equal(attachmentKindFromMimeType(mimeType, 'report.pdf'), 'image'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test('stages a real PDF named .png as a pdf, and unknown bytes as an ordinary file', async () => { + const dir = await mkdtemp(join(tmpdir(), 'att-pick-')); + const pdfPath = join(dir, 'report.png'); + const junkPath = join(dir, 'payload.png'); + await writeFile(pdfPath, Buffer.from('%PDF-1.4\nfixture')); + await writeFile(junkPath, Buffer.from('not an image')); + try { + assert.equal(await sniffPickedAttachmentMimeType(pdfPath, 'report.png'), 'application/pdf'); + const junk = await sniffPickedAttachmentMimeType(junkPath, 'payload.png'); + assert.equal(junk, 'application/octet-stream'); + assert.equal(attachmentKindFromMimeType(junk, 'payload.png'), 'other'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test('a read failure downgrades a claimed image/PDF name rather than trusting it', async () => { + // The prefix read failing must not reinstate the extension-based image/PDF + // claim resolveAttachmentMimeType exists to reject: staging stays unblocked + // but the spoofed kind is dropped (the send path re-reads and confirms). + const spoofed = await sniffPickedAttachmentMimeType('/no/such/file.pdf', 'file.pdf'); + assert.equal(spoofed, 'application/octet-stream'); + assert.equal(attachmentKindFromMimeType(spoofed, 'file.pdf'), 'other'); + // A non-image/PDF claim still resolves, so genuine document kinds still stage. + const docx = await sniffPickedAttachmentMimeType('/no/such/file.docx', 'file.docx'); + assert.equal( + docx, + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ); + }); +}); + +describe('resolvePickedAttachments (pick dialog owner: stage by content)', () => { + test('stamps each picked path with its sniffed MIME and main-side size, not its extension', async () => { + const dir = await mkdtemp(join(tmpdir(), 'att-pick-plan-')); + const imageAsPdf = join(dir, 'report.pdf'); // real PNG bytes behind a .pdf name + const junkAsPng = join(dir, 'payload.png'); // non-image bytes behind an image name + await writeFile(imageAsPdf, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + await writeFile(junkAsPng, Buffer.from('not an image')); + const statCalls: string[] = []; + try { + const plan = await resolvePickedAttachments([imageAsPdf, junkAsPng], async (path) => { + statCalls.push(path); + return { size: path.endsWith('report.pdf') ? 8 : 12 }; + }); + + // report.pdf carries image bytes → stages as an image (thumbnail + notice). + assert.equal(plan[0].name, 'report.pdf'); + assert.equal(plan[0].mimeType, 'image/png'); + assert.equal(attachmentKindFromMimeType(plan[0].mimeType, plan[0].name), 'image'); + assert.equal(plan[0].size, 8); + + // A disguised .png loses its image claim → stages as an ordinary file. + assert.equal(plan[1].mimeType, 'application/octet-stream'); + assert.equal(attachmentKindFromMimeType(plan[1].mimeType, plan[1].name), 'other'); + + // Sizes come from the injected (main-side) stat, one call per path. + assert.deepEqual(statCalls, [imageAsPdf, junkAsPng]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/__tests__/attachment-preview.test.ts b/apps/desktop/src/main/__tests__/attachment-preview.test.ts index 1fc5ecd4cf..221f5a9a55 100644 --- a/apps/desktop/src/main/__tests__/attachment-preview.test.ts +++ b/apps/desktop/src/main/__tests__/attachment-preview.test.ts @@ -26,7 +26,7 @@ import { type AttachmentPreviewResult, } from '../attachment-preview.js'; -const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); +const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); function fakeRenderPreview(bytes: Uint8Array): Promise<{ bytes: Uint8Array; mimeType: string } | null> { return Promise.resolve({ bytes, mimeType: 'image/png' }); @@ -82,7 +82,7 @@ describe('staged attachment preview (approval source)', () => { assert.equal(read, 0); }); - it('refuses non-image approvals without touching the file', async () => { + it('reads non-image approvals once to verify their content before refusing them', async () => { const approvals = createAttachmentApprovalRegistry(); const [issued] = approvals.issueApprovals(1, [{ path: '/tmp/notes.md', name: 'notes.md', size: 4 }]); let read = 0; @@ -92,12 +92,102 @@ describe('staged attachment preview (approval source)', () => { approvals, readFile: async () => { read += 1; - return PNG; + return Buffer.from('text'); }, renderPreview: fakeRenderPreview, }); assert.deepEqual(result, { ok: false, reason: 'not_image' }); - assert.equal(read, 0); + assert.equal(read, 1); + }); + + it('previews real image bytes even when the file name claims PDF', async () => { + const approvals = createAttachmentApprovalRegistry(); + const [issued] = approvals.issueApprovals(1, [ + { path: '/tmp/report.pdf', name: 'report.pdf', mimeType: 'application/pdf', size: PNG.byteLength }, + ]); + let renderCalls = 0; + const result = await loadApprovalPreview({ + senderId: 1, + approvalId: issued.approvalId, + approvals, + readFile: async () => PNG, + renderPreview: async (bytes) => { + renderCalls += 1; + return { bytes, mimeType: 'image/png' }; + }, + }); + + assert.deepEqual(result, { + ok: true, + base64: Buffer.from(PNG).toString('base64'), + mimeType: 'image/png', + }); + assert.equal(renderCalls, 1); + }); + + it('does not send PDF bytes with a spoofed PNG name to the image decoder', async () => { + const pdf = Buffer.from('%PDF-1.4\nfixture'); + const approvals = createAttachmentApprovalRegistry(); + const [issued] = approvals.issueApprovals(1, [ + { path: '/tmp/report.png', name: 'report.png', size: pdf.byteLength }, + ]); + let renderCalls = 0; + const result = await loadApprovalPreview({ + senderId: 1, + approvalId: issued.approvalId, + approvals, + readFile: async () => pdf, + renderPreview: async () => { + renderCalls += 1; + return null; + }, + }); + + assert.deepEqual(result, { ok: false, reason: 'not_image' }); + assert.equal(renderCalls, 0); + }); + + it('does not send unknown bytes with a spoofed PNG name to the image decoder', async () => { + const unknown = Buffer.from('not an image'); + const approvals = createAttachmentApprovalRegistry(); + const [issued] = approvals.issueApprovals(1, [ + { path: '/tmp/payload.png', name: 'payload.png', size: unknown.byteLength }, + ]); + let renderCalls = 0; + const result = await loadApprovalPreview({ + senderId: 1, + approvalId: issued.approvalId, + approvals, + readFile: async () => unknown, + renderPreview: async () => { + renderCalls += 1; + return null; + }, + }); + + assert.deepEqual(result, { ok: false, reason: 'not_image' }); + assert.equal(renderCalls, 0); + }); + + it('uses the sniffed MIME when returning original image bytes as the preview', async () => { + const webp = Buffer.from('RIFF0000WEBPVP8 ', 'ascii'); + const approvals = createAttachmentApprovalRegistry(); + const [issued] = approvals.issueApprovals(1, [ + { path: '/tmp/photo.png', name: 'photo.png', size: webp.byteLength }, + ]); + const result = await loadApprovalPreview({ + senderId: 1, + approvalId: issued.approvalId, + approvals, + readFile: async () => webp, + renderPreview: async () => null, + }); + + assert.deepEqual(result, { + ok: true, + base64: webp.toString('base64'), + mimeType: 'image/webp', + }); }); it('registers the client-owned attachment preview IPC boundary', async () => { diff --git a/apps/desktop/src/main/__tests__/bot-incoming-session-lifecycle.test.ts b/apps/desktop/src/main/__tests__/bot-incoming-session-lifecycle.test.ts index a9fc383cc2..2589971c8a 100644 --- a/apps/desktop/src/main/__tests__/bot-incoming-session-lifecycle.test.ts +++ b/apps/desktop/src/main/__tests__/bot-incoming-session-lifecycle.test.ts @@ -22,13 +22,13 @@ import { describe, test } from 'node:test'; import type { BotIncomingMessage, BotRegistry } from '@maka/runtime/bots'; import { createBotIncomingMainService } from '../bot-incoming-main.js'; import { BotSessionUnavailableError } from '../bot-session-adapter.js'; +import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; async function waitFor(predicate: () => boolean): Promise { - const deadline = Date.now() + 1_000; - while (!predicate()) { - if (Date.now() >= deadline) throw new Error('Timed out waiting for bot lifecycle test'); - await new Promise((resolve) => setImmediate(resolve)); - } + await pollFor(predicate, { + timeoutMs: 1_000, + message: 'Timed out waiting for bot lifecycle test', + }); } describe('bot session lifecycle bindings', () => { diff --git a/apps/desktop/src/main/__tests__/bot-incoming-typing.test.ts b/apps/desktop/src/main/__tests__/bot-incoming-typing.test.ts index c8ec9a9c77..0361067070 100644 --- a/apps/desktop/src/main/__tests__/bot-incoming-typing.test.ts +++ b/apps/desktop/src/main/__tests__/bot-incoming-typing.test.ts @@ -22,13 +22,10 @@ import { getEventListeners } from 'node:events'; import { test } from 'node:test'; import type { BotIncomingMessage, BotRegistry } from '@maka/runtime/bots'; import { createBotIncomingMainService } from '../bot-incoming-main.js'; +import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; async function waitFor(predicate: () => boolean, message: string): Promise { - for (let attempt = 0; attempt < 100; attempt += 1) { - if (predicate()) return; - await new Promise((resolve) => setImmediate(resolve)); - } - assert.fail(message); + await pollFor(predicate, { attempts: 100, message }); } test('the bot typing loop owns only its active abort listener', async (t) => { diff --git a/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts b/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts index ef77a151e3..83d5744dc2 100644 --- a/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts +++ b/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts @@ -17,6 +17,7 @@ * under the License. */ +import { deferred } from '@maka/core/test-only/async-primitives'; import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { @@ -26,7 +27,7 @@ import { type UpdateAppSettingsInput, } from '@maka/core/settings'; import type { BotRegistry } from '@maka/runtime/bots'; -import type { SettingsStore } from '@maka/storage'; +import type { SettingsStore } from '@maka/storage/settings-store'; import { BotOnboardingService, wecomTerminalPollStatus, @@ -34,13 +35,6 @@ import { } from '../bot-onboarding-main.js'; const QR_DATA = 'data:image/png;base64,ZmFrZQ=='; - -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((next) => { resolve = next; }); - return { promise, resolve }; -} - function harness( adapter: BotOnboardingProviderAdapter, applyEffect?: (settings: AppSettings, patch: UpdateAppSettingsInput) => Promise, diff --git a/apps/desktop/src/main/__tests__/browser-message-box.test.ts b/apps/desktop/src/main/__tests__/browser-message-box.test.ts new file mode 100644 index 0000000000..488c2c66f8 --- /dev/null +++ b/apps/desktop/src/main/__tests__/browser-message-box.test.ts @@ -0,0 +1,376 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { test } from 'node:test'; +import { runInNewContext } from 'node:vm'; +import type { + BrowserWindow, + BrowserWindowConstructorOptions, + MessageBoxReturnValue, +} from 'electron'; +import { parseHTML } from 'linkedom'; +import { + type BrowserMessageBoxRuntime, + buildBrowserMessageBoxHtml, + centeredBounds, + isBrowserMessageBoxPresentationActive, + parseBrowserMessageBoxResponse, + showBrowserMessageBoxWithRuntime, +} from '../browser-message-box.js'; + +test('falls back natively and never attaches to an inaccessible parent', async () => { + const options = { message: 'Recover Maka' }; + const nativeResult = { response: 0, checkboxChecked: false }; + const parentState = { visible: false, minimized: false, destroyed: false }; + const parent = { + isVisible: () => parentState.visible, + isMinimized: () => parentState.minimized, + isDestroyed: () => parentState.destroyed, + getBounds: () => ({ x: 100, y: 100, width: 900, height: 700 }), + } as BrowserWindow; + const nativeParents: Array = []; + const runtimeBase = { + shouldUseDarkColors: false, + resolveWorkArea: () => ({ x: 0, y: 0, width: 1_200, height: 800 }), + showNative: async ( + _options: typeof options, + actualParent: BrowserWindow | undefined, + ): Promise => { + nativeParents.push(actualParent); + return nativeResult; + }, + onBrowserError: () => assert.fail('native presentation must not report a browser error'), + }; + + parentState.visible = true; + const failure = new Error('renderer failed'); + const failedWindow = fakeBrowserWindow({ loadError: failure }); + let reported: unknown; + assert.equal( + await showBrowserMessageBoxWithRuntime(options, parent, { locale: 'en' }, { + ...runtimeBase, + createWindow: (windowOptions) => { + assert.equal(windowOptions.parent, parent); + assert.equal(windowOptions.modal, true); + parentState.minimized = true; + failedWindow.options = windowOptions; + return failedWindow.window; + }, + onBrowserError: (error) => { + reported = error; + }, + }), + nativeResult, + ); + assert.equal(reported, failure); + assert.deepEqual(nativeParents, [undefined]); + assert.equal(failedWindow.destroyed(), true); +}); + +test('drives the BrowserWindow lifecycle through a safe response URL', async () => { + const parent = { + isVisible: () => true, + isMinimized: () => true, + isDestroyed: () => false, + } as BrowserWindow; + const presented = fakeBrowserWindow(); + let createdOptions: BrowserWindowConstructorOptions | undefined; + + const presentation = showBrowserMessageBoxWithRuntime( + { message: 'Recover Maka', buttons: ['Recover', 'Cancel'], cancelId: 1 }, + parent, + { locale: 'en', dark: true }, + { + shouldUseDarkColors: false, + createWindow: (options) => { + createdOptions = options; + return presented.window; + }, + resolveWorkArea: (actualParent) => { + assert.equal(actualParent, undefined); + return { x: 0, y: 0, width: 1_200, height: 800 }; + }, + showNative: async () => assert.fail('successful browser presentation must not fall back'), + onBrowserError: () => assert.fail('successful browser presentation must not report error'), + }, + ); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(isBrowserMessageBoxPresentationActive(), true); + assert.equal(createdOptions?.parent, undefined); + assert.equal(createdOptions?.modal, undefined); + assert.equal(presented.shown(), true); + assert.equal(presented.focused(), true); + assert.match(presented.loadedUrl(), /^data:text\/html/u); + assert.equal(presented.deniesWindowOpen(), true); + + let prevented = false; + presented.webContents.emit( + 'will-navigate', + { + preventDefault: () => { + prevented = true; + }, + }, + 'maka-dialog://response/0', + ); + assert.deepEqual(await presentation, { response: 0, checkboxChecked: false }); + assert.equal(prevented, true); + assert.equal(presented.destroyed(), true); + assert.equal(isBrowserMessageBoxPresentationActive(), false); +}); + +test('maps close to cancel and falls back after each BrowserWindow presentation failure', async () => { + const closed = fakeBrowserWindow(); + const closeResult = showBrowserMessageBoxWithRuntime( + { message: 'Recover Maka', buttons: ['Recover', 'Cancel'], cancelId: 1 }, + undefined, + { locale: 'en' }, + runtimeForWindow(closed), + ); + closed.window.emit('closed'); + assert.equal(isBrowserMessageBoxPresentationActive(), true); + assert.deepEqual(await closeResult, { response: 1, checkboxChecked: false }); + assert.equal(closed.destroyed(), true); + + for (const scenario of ['unresponsive', 'renderer-gone', 'timeout'] as const) { + const presented = fakeBrowserWindow({ pendingLoad: scenario === 'timeout' }); + const errors: unknown[] = []; + const nativeResult = { response: 0, checkboxChecked: false }; + const result = showBrowserMessageBoxWithRuntime( + { message: 'Recover Maka' }, + undefined, + { locale: 'en' }, + runtimeForWindow(presented, { + presentationTimeoutMs: scenario === 'timeout' ? 1 : undefined, + onBrowserError: (error) => errors.push(error), + nativeResult, + }), + ); + if (scenario === 'unresponsive') presented.window.emit('unresponsive'); + if (scenario === 'renderer-gone') { + presented.webContents.emit('render-process-gone', {}, { reason: 'crashed' }); + } + + assert.equal(await result, nativeResult); + assert.equal(errors.length, 1, scenario); + assert.equal(presented.destroyed(), true, scenario); + assert.equal(isBrowserMessageBoxPresentationActive(), false, scenario); + } +}); + +test('accepts only an in-range response URL produced by the dialog', () => { + assert.equal(parseBrowserMessageBoxResponse('maka-dialog://response/1', 3), 1); + for (const value of [ + 'https://response/1', + 'maka-dialog://other/1', + 'maka-dialog://user@response/1', + 'maka-dialog://response:123/1', + 'maka-dialog://response/01', + 'maka-dialog://response/1?', + 'maka-dialog://response/1#', + 'maka-dialog://response/1?again=true', + 'maka-dialog://response/3', + 'not a url', + ]) { + assert.equal(parseBrowserMessageBoxResponse(value, 3), undefined, value); + } +}); + +test('centers against the parent while keeping the whole dialog on-screen', () => { + assert.deepEqual( + centeredBounds( + { x: 900, y: 700, width: 200, height: 100 }, + { x: 0, y: 0, width: 1_000, height: 800 }, + 520, + 300, + ), + { x: 480, y: 500, width: 520, height: 300 }, + ); + assert.deepEqual( + centeredBounds(undefined, { x: -1_000, y: 40, width: 800, height: 600 }, 400, 280), + { x: -800, y: 200, width: 400, height: 280 }, + ); +}); + +test('renders escaped content with Maka dialog tokens and safe action ordering', () => { + const html = buildBrowserMessageBoxHtml( + { + type: 'warning', + title: '', + message: 'Maka & Runtime Host · /Users/示例', + detail: '', + buttons: ['Replace ', 'Cancel', 'Copy & Diagnostics'], + defaultId: 0, + cancelId: 1, + }, + { dark: true, locale: 'en', palette: 'nord' }, + ); + + assert.match(html, /globalThis\.pwned/u); + + const cancelPosition = html.indexOf('>Cancel'); + const copyPosition = html.indexOf('>Copy & Diagnostics'); + const actionPosition = html.indexOf('>Replace <Host>'); + assert.ok(cancelPosition >= 0 && cancelPosition < copyPosition); + assert.ok(copyPosition < actionPosition); + assert.match(html, /data-response="0" autofocus/u); + assert.match(html, /default-src 'none'/u); +}); + +test('routes button and keyboard decisions through the rendered interaction bridge', () => { + const html = buildBrowserMessageBoxHtml( + { + message: 'Recover Maka', + buttons: ['Recover', 'Cancel'], + defaultId: 0, + cancelId: 1, + }, + { dark: false, locale: 'en' }, + ); + const { document, window } = parseHTML(html); + const script = document.querySelector('script')?.textContent; + assert.ok(script); + const navigations: string[] = []; + runInNewContext(script, { + window: { location: { assign: (url: string) => navigations.push(url) } }, + document, + Element: window.Element, + HTMLButtonElement: window.HTMLButtonElement, + }); + const dispatchKey = (key: string): void => { + const event = new window.Event('keydown', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'key', { value: key }); + document.body.dispatchEvent(event); + }; + + document + .querySelector('[data-response="0"]') + ?.dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true })); + dispatchKey('Escape'); + dispatchKey('Enter'); + + assert.deepEqual(navigations, [ + 'maka-dialog://response/0', + 'maka-dialog://response/1', + 'maka-dialog://response/0', + ]); +}); + +interface FakeBrowserWindow { + readonly window: BrowserWindow; + readonly webContents: EventEmitter; + options?: BrowserWindowConstructorOptions; + loadedUrl(): string; + shown(): boolean; + focused(): boolean; + destroyed(): boolean; + deniesWindowOpen(): boolean; +} + +function fakeBrowserWindow(input: { + readonly loadError?: Error; + readonly pendingLoad?: boolean; +} = {}): FakeBrowserWindow { + let loadedUrl = ''; + let shown = false; + let focused = false; + let destroyed = false; + let deniesWindowOpen = false; + const webContents = Object.assign(new EventEmitter(), { + setWindowOpenHandler(handler: () => { action: string }) { + deniesWindowOpen = handler().action === 'deny'; + }, + async executeJavaScript(script: string): Promise { + return script.includes('scrollHeight') ? 340 : undefined; + }, + }); + const window = Object.assign(new EventEmitter(), { + webContents, + setMenuBarVisibility() {}, + loadURL(url: string): Promise { + loadedUrl = url; + if (input.loadError) return Promise.reject(input.loadError); + if (input.pendingLoad) return new Promise(() => undefined); + return Promise.resolve(); + }, + isDestroyed: () => destroyed, + destroy: () => { + destroyed = true; + }, + setBounds() {}, + show: () => { + shown = true; + }, + focus: () => { + focused = true; + }, + }) as unknown as BrowserWindow; + return { + window, + webContents, + loadedUrl: () => loadedUrl, + shown: () => shown, + focused: () => focused, + destroyed: () => destroyed, + deniesWindowOpen: () => deniesWindowOpen, + }; +} + +function runtimeForWindow( + presented: FakeBrowserWindow, + options: { + readonly presentationTimeoutMs?: number; + readonly onBrowserError?: (error: unknown) => void; + readonly nativeResult?: MessageBoxReturnValue; + } = {}, +): BrowserMessageBoxRuntime { + return { + shouldUseDarkColors: false, + createWindow: (windowOptions) => { + presented.options = windowOptions; + return presented.window; + }, + resolveWorkArea: () => ({ x: 0, y: 0, width: 1_200, height: 800 }), + showNative: async () => + options.nativeResult ?? assert.fail('successful browser presentation must not fall back'), + onBrowserError: + options.onBrowserError ?? + (() => assert.fail('successful browser presentation must not report an error')), + ...(options.presentationTimeoutMs === undefined + ? {} + : { presentationTimeoutMs: options.presentationTimeoutMs }), + }; +} diff --git a/apps/desktop/src/main/__tests__/browser-origin-lease.test.ts b/apps/desktop/src/main/__tests__/browser-origin-lease.test.ts new file mode 100644 index 0000000000..ee9bf34651 --- /dev/null +++ b/apps/desktop/src/main/__tests__/browser-origin-lease.test.ts @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { BrowserOriginLeaseTracker } from '../browser/browser-origin-lease.js'; + +test('a navigate lease allows the old page but only arms for its approved target', () => { + let url = 'https://old.example/'; + const tracker = new BrowserOriginLeaseTracker(() => url); + const lease = tracker.open('https://approved.example/path', 'navigate'); + assert.equal(lease.snapshot().violatedUrl, undefined); + assert.throws( + () => lease.startNavigation('https://other.example/path'), + /outside the approved Origin/u, + ); + + lease.startNavigation('https://approved.example/next'); + url = 'https://approved.example/next'; + tracker.recordNavigation(url); + assert.deepEqual(lease.snapshot(), { + epoch: 1, + url: 'https://approved.example/next', + }); +}); diff --git a/apps/desktop/src/main/__tests__/browser-selection.test.ts b/apps/desktop/src/main/__tests__/browser-selection.test.ts index 2fdc3c5781..cabcea287b 100644 --- a/apps/desktop/src/main/__tests__/browser-selection.test.ts +++ b/apps/desktop/src/main/__tests__/browser-selection.test.ts @@ -17,20 +17,10 @@ * under the License. */ +import { deferred } from '@maka/core/test-only/async-primitives'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { createBrowserSelectionCoordinator } from '../../preload/browser-selection.js'; - -function deferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - const flush = () => new Promise((resolve) => queueMicrotask(resolve)); describe('browser selection ordering', () => { diff --git a/apps/desktop/src/main/__tests__/browser-session.test.ts b/apps/desktop/src/main/__tests__/browser-session.test.ts index 3b967fadd9..542172700c 100644 --- a/apps/desktop/src/main/__tests__/browser-session.test.ts +++ b/apps/desktop/src/main/__tests__/browser-session.test.ts @@ -24,6 +24,7 @@ * view Host and a fake CDP bridge), so no Electron or live CDP endpoint. */ +import { deferred } from '@maka/core/test-only/async-primitives'; import { strict as assert } from 'node:assert'; import { afterEach, describe, it } from 'node:test'; import type { IPage } from '@jackwener/opencli/types'; @@ -40,17 +41,6 @@ import { withBrowserPage, } from '../browser/session.js'; import { type BrowserViewHost, provideBrowserViewHost } from '../browser/browser-host.js'; - -function deferred(): { promise: Promise; resolve: (v: T) => void; reject: (e: unknown) => void } { - let resolve!: (v: T) => void; - let reject!: (e: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); function makeFakePage(url: string | null = null): IPage { @@ -102,6 +92,13 @@ type HostSpy = { function installHost(overrides: Partial = {}): HostSpy { const spy: HostSpy = { resolved: [], released: [], disposed: [], host: null as never }; const host: BrowserViewHost = { + currentUrl: () => "https://example.com/", + openOriginLease: () => ({ + approvedOrigin: 'https://example.com', + startNavigation: () => {}, + snapshot: () => ({ epoch: 0, url: 'https://example.com/' }), + release: () => {}, + }), canDrive: () => true, resolveEndpoint: async (id) => { spy.resolved.push(id); diff --git a/apps/desktop/src/main/__tests__/browser-tools.test.ts b/apps/desktop/src/main/__tests__/browser-tools.test.ts index d121388874..98edf823c8 100644 --- a/apps/desktop/src/main/__tests__/browser-tools.test.ts +++ b/apps/desktop/src/main/__tests__/browser-tools.test.ts @@ -26,7 +26,9 @@ import { strict as assert } from 'node:assert'; import { afterEach, describe, it } from 'node:test'; import type { IPage } from '@jackwener/opencli/types'; +import type { ComputerUseToolSet } from '@maka/runtime/computer-use-tools'; import type { MakaTool, MakaToolContext } from '@maka/runtime/tool-runtime'; +import { withBrowserOriginAdmission } from '../browser/browser-origin-admission.js'; import { buildBrowserClickTool, buildBrowserExtractTool, @@ -39,50 +41,100 @@ import { takeoverNote, } from '../browser/browser-tools.js'; import { type BridgeLike, resetBrowserSessionsForTest, setBridgeFactoryForTest } from '../browser/session.js'; -import { type BrowserViewHost, provideBrowserViewHost } from '../browser/browser-host.js'; +import { createDesktopNativeCapabilityProvider } from '../runtime-host-native-capabilities.js'; +import { + type BrowserViewHost, + provideBrowserViewHost, +} from '../browser/browser-host.js'; +import { BrowserOriginLeaseTracker } from '../browser/browser-origin-lease.js'; type FakePageConfig = { url?: string; + afterGotoUrl?: string; + afterClickUrl?: string; + afterFillUrl?: string; title?: string; click?: { matches_n: number; match_level: 'exact' | 'stable' | 'reidentified' }; fill?: { verified: boolean; actual: string; match_level: 'exact' | 'stable' | 'reidentified' }; snapshot?: unknown; + snapshotImpl?: (browser: FakeBrowser) => unknown | Promise; extractHtml?: string; - waitImpl?: (options: unknown) => Promise; + extractImpl?: (browser: FakeBrowser) => void; + waitImpl?: (options: unknown, browser: FakeBrowser) => Promise; + onLeaseOpened?: (browser: FakeBrowser) => void; + takeoverReloadImpl?: (browser: FakeBrowser) => void; +}; + +type FakeBrowser = { + url: string; + onNavigate?: (url: string) => void; + navigate(url: string): void; + clicks: number; + fills: number; + presses: number; }; -function makeFakePage(cfg: FakePageConfig): IPage { +function createFakeBrowser(url: string): FakeBrowser { + const browser: FakeBrowser = { + url, + navigate(nextUrl) { + browser.url = nextUrl; + browser.onNavigate?.(nextUrl); + }, + clicks: 0, + fills: 0, + presses: 0, + }; + return browser; +} + +function makeFakePage(cfg: FakePageConfig, browser: FakeBrowser): IPage { return { - getCurrentUrl: async () => cfg.url ?? null, - goto: async () => {}, + getCurrentUrl: async () => browser.url || null, + goto: async (url: string) => browser.navigate(cfg.afterGotoUrl ?? url), evaluate: async (js: string) => { - if (js.includes('location.href')) return (cfg.url ?? '') as never; + if (js.includes('location.href')) return browser.url as never; if (js.includes('document.title')) return (cfg.title ?? '') as never; if (js.includes('outerHTML')) { + cfg.extractImpl?.(browser); return (cfg.extractHtml === undefined ? null : { html: cfg.extractHtml, truncated: false }) as never; } return '' as never; }, - snapshot: async () => cfg.snapshot ?? '[1] link "Home"', - click: async () => cfg.click ?? { matches_n: 1, match_level: 'exact' }, - fillText: async () => - cfg.fill + snapshot: async () => + cfg.snapshotImpl ? cfg.snapshotImpl(browser) : (cfg.snapshot ?? '[1] link "Home"'), + click: async () => { + browser.clicks += 1; + if (cfg.afterClickUrl) browser.navigate(cfg.afterClickUrl); + return cfg.click ?? { matches_n: 1, match_level: 'exact' }; + }, + fillText: async () => { + browser.fills += 1; + if (cfg.afterFillUrl) browser.navigate(cfg.afterFillUrl); + return cfg.fill ? { filled: true, verified: cfg.fill.verified, expected: '', actual: cfg.fill.actual, length: 0, matches_n: 1, match_level: cfg.fill.match_level } - : { filled: true, verified: true, expected: '', actual: '', length: 0, matches_n: 1, match_level: 'exact' }, - pressKey: async () => {}, + : { filled: true, verified: true, expected: '', actual: '', length: 0, matches_n: 1, match_level: 'exact' }; + }, + pressKey: async () => { + browser.presses += 1; + }, wait: async (options: unknown) => { - if (cfg.waitImpl) return cfg.waitImpl(options); + if (cfg.waitImpl) return cfg.waitImpl(options, browser); }, } as unknown as IPage; } class FakeBridge implements BridgeLike { - constructor(private readonly page: IPage) {} + constructor( + private readonly page: IPage, + private readonly onReload?: () => void, + ) {} async connect(): Promise { return this.page; } async close(): Promise {} - async send(): Promise { + async send(method: string): Promise { + if (method === 'Page.reload') this.onReload?.(); return {}; } async waitForEvent(): Promise { @@ -90,15 +142,27 @@ class FakeBridge implements BridgeLike { } } -function install(cfg: FakePageConfig): void { +function install(cfg: FakePageConfig): FakeBrowser { + const browser = createFakeBrowser(cfg.url ?? 'https://example.com/'); + const originLeases = new BrowserOriginLeaseTracker(() => browser.url); + browser.onNavigate = (url) => originLeases.recordNavigation(url); const host: BrowserViewHost = { + currentUrl: () => browser.url, + openOriginLease: (_sessionId, approvedUrl, kind) => { + const lease = originLeases.open(approvedUrl, kind); + cfg.onLeaseOpened?.(browser); + return lease; + }, canDrive: () => true, resolveEndpoint: async (id) => ({ cdpEndpoint: `ws://127.0.0.1:1/${id}` }), releaseSession: async () => {}, disposeSession: async () => {}, }; provideBrowserViewHost(host); - setBridgeFactoryForTest(() => new FakeBridge(makeFakePage(cfg))); + setBridgeFactoryForTest( + () => new FakeBridge(makeFakePage(cfg, browser), () => cfg.takeoverReloadImpl?.(browser)), + ); + return browser; } function ctx(): MakaToolContext { @@ -113,7 +177,10 @@ function ctx(): MakaToolContext { } function run

(tool: MakaTool, args: P): Promise { - return Promise.resolve(tool.impl(args, ctx())) as Promise; + return withBrowserOriginAdmission( + { sessionId: 's1', url: 'https://example.com/approved?secret=grant' }, + () => Promise.resolve(tool.impl(args, ctx())) as Promise, + ); } afterEach(() => { @@ -135,16 +202,157 @@ describe('browser tool helpers', () => { assert.equal(takeoverNote({ takeoverReloaded: false }), ''); assert.match(takeoverNote({ takeoverReloaded: true }), /reloaded once/); }); - }); describe('browser tool execution', () => { - it('navigate rejects a non-web URL before connecting', async () => { install({}); await assert.rejects(run(buildBrowserNavigateTool(), { url: 'file:///etc/passwd' }), /Not a navigable URL/); }); + it('navigate returns only a sanitized destination after a cross-Origin redirect', async () => { + install({ + url: 'https://old.example/', + afterGotoUrl: 'https://other.example/reset/redirect-secret?token=private#account', + title: 'Private destination title', + }); + const out = await run(buildBrowserNavigateTool(), { url: 'https://example.com/start' }); + assert.equal( + out, + 'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.', + ); + assert.doesNotMatch(out, /Private destination title|reset|redirect-secret|token|account/); + }); + + it('click returns only the new URL after cross-Origin navigation', async () => { + install({ + url: 'https://example.com/start', + afterClickUrl: 'https://other.example/reset/click-secret?token=private#account', + }); + const out = await run(buildBrowserClickTool(), { ref: '[1]' }); + assert.equal( + out, + 'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.', + ); + assert.doesNotMatch(out, /Clicked|matched|reset|click-secret|token|account/); + + resetBrowserSessionsForTest(); + install({ + url: 'https://example.com/start', + afterClickUrl: 'file:///reset/local-secret', + }); + const nonWebOut = await run(buildBrowserClickTool(), { ref: '[1]' }); + assert.equal( + nonWebOut, + 'Navigated to an unapproved page. Access to the new site requires approval on the next Browser call.', + ); + assert.doesNotMatch(nonWebOut, /file|reset|local-secret/); + }); + + it('covers the Provider second-check → first page await gap end to end', async () => { + install({ + snapshot: 'private snapshot', + onLeaseOpened: (state) => state.navigate('https://other.example/private?token=secret'), + }); + const computerUseTools = [] as unknown as ComputerUseToolSet; + computerUseTools.clearSession = () => undefined; + computerUseTools.sessionEvents = {} as ComputerUseToolSet['sessionEvents']; + let resolved = 0; + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [buildBrowserSnapshotTool()], + resolveBrowserUrl: () => { + resolved += 1; + return 'https://example.com/approved'; + }, + releaseBrowserSession() {}, + computerUseTools, + releaseComputerUseSession() {}, + }); + assert.ok(provider.call); + if (!provider.call) return; + const result = await provider.call( + { + kind: 'client.capability.call', + invocationId: 'invocation-1', + registrationId: 'registration-1', + offerId: 'desktop_browser', + serverId: 'desktop_browser', + toolName: 'browser_snapshot', + arguments: {}, + sessionId: 's1', + turnId: 't1', + toolCallId: 'c1', + cwd: '/tmp', + }, + { + signal: new AbortController().signal, + accept: async () => undefined, + }, + ); + assert.equal(resolved, 2); + assert.deepEqual(result, { + content: [ + { + type: 'text', + text: 'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.', + }, + ], + }); + assert.doesNotMatch(JSON.stringify(result), /private snapshot|token/); + }); + + it('discards a snapshot when the page crosses Origin and returns to the approved site', async () => { + install({ + snapshotImpl: (browser) => { + browser.navigate('https://other.example/reset/first-violated-secret?token=secret'); + browser.navigate('https://example.com/back'); + return 'private snapshot'; + }, + }); + const out = await run(buildBrowserSnapshotTool(), {}); + assert.equal( + out, + 'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.', + ); + assert.doesNotMatch(out, /private snapshot|reset|first-violated-secret|token/); + }); + + it('covers an A→B→A takeover reload before the first mutating page call', async () => { + const browser = install({ + takeoverReloadImpl: (state) => { + state.navigate('https://other.example/reset/reload-secret?token=secret'); + state.navigate('https://example.com/back'); + }, + }); + const out = await run(buildBrowserClickTool(), { ref: '[1]' }); + assert.equal( + out, + 'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.', + ); + assert.equal(browser.clicks, 0); + assert.doesNotMatch(out, /reset|reload-secret|token/); + }); + + it('does not press Enter when filling navigates away from the approved Origin', async () => { + const browser = install({ + afterFillUrl: 'https://other.example/login?token=private#form', + }); + const out = await run(buildBrowserTypeTool(), { ref: '[2]', text: 'hello', submit: true }); + assert.equal( + out, + 'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.', + ); + assert.equal(browser.fills, 1); + assert.equal(browser.presses, 0); + assert.doesNotMatch(out, /hello|token|form/); + }); + + it('returns only a sanitized URL after same-Origin click navigation', async () => { + install({ afterClickUrl: 'https://example.com/next?token=private#section' }); + const out = await run(buildBrowserClickTool(), { ref: '[1]' }); + assert.equal(out, 'Navigated to https://example.com/next.'); + assert.doesNotMatch(out, /Clicked|matched|token|section/); + }); it('type reports verification failure with the actual content', async () => { @@ -162,8 +370,6 @@ describe('browser tool execution', () => { await assert.rejects(run(buildBrowserWaitTool(), { text: ' ' }), /non-empty/); }); - - it('extract fails clearly when a selector matches nothing', async () => { install({ url: 'https://example.com/' }); // extractHtml undefined => page returns null await assert.rejects(run(buildBrowserExtractTool(), { selector: '#missing' }), /No element matches selector/); diff --git a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts index 54626dbb65..a6f0302ac7 100644 --- a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts @@ -111,6 +111,8 @@ async function mountRegion(): Promise<{ children: createElement(AstryxLocaleProvider, { children: createElement(ChatComposerRegion, { composerRef: composer, + directoryComposerProps: {}, + directoryPickerEnabled: false, active: true, onboardingComposerHidden: false, activeInteraction: undefined, @@ -119,8 +121,7 @@ async function mountRegion(): Promise<{ newTaskSendPending, stopPendingBySession: {}, respondToSandboxBoundary: () => {}, - activeSandboxBoundary: undefined, - activeQuestion: undefined, + respondToClientCapability: () => {}, respondToUserQuestion: () => {}, stop: () => {}, onSend: () => {}, diff --git a/apps/desktop/src/main/__tests__/chat-recovery-notice.test.ts b/apps/desktop/src/main/__tests__/chat-recovery-notice.test.ts new file mode 100644 index 0000000000..50fb540aea --- /dev/null +++ b/apps/desktop/src/main/__tests__/chat-recovery-notice.test.ts @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { ChatRecoveryNotice } from '../../renderer/chat-recovery-notice.js'; +import { SessionHealthRecoveryNotice } from '../../renderer/chat-recovery-notice.js'; + +test('composer recovery notices share one wrapper and secondary disabled action', () => { + const markup = renderToStaticMarkup(createElement(ChatRecoveryNotice, { + status: 'error', + title: 'Choose a model connection', + description: 'Choose the connection and model to use.', + actionLabel: 'Choose connection and model', + actionDisabled: true, + onAction: () => undefined, + })); + + assert.match(markup, /class="maka-chat-recovery-notice"/); + assert.match(markup, /maka-chat-recovery-notice-alert/); + assert.match(markup, /Choose a model connection/); + assert.match(markup, /Choose the connection and model to use\./); + assert.match(markup, /Choose connection and model/); + assert.match(markup, /disabled=""/); + assert.match(markup, /secondary/); +}); + +test('composer recovery notices omit the action when no handler exists', () => { + const markup = renderToStaticMarkup(createElement(ChatRecoveryNotice, { + status: 'warning', + title: 'Workspace unavailable', + actionLabel: 'Open Settings', + })); + + assert.doesNotMatch(markup, /`; + const buttons = input.buttons + .map((label, index) => ({ label, index })) + .sort((left, right) => { + const rank = (index: number): number => + index === input.defaultId ? 2 : index === input.cancelId ? 0 : 1; + return rank(left.index) - rank(right.index); + }) + .map(({ label, index }) => { + const classes = [ + 'decision', + index === input.defaultId + ? 'primary' + : index === input.cancelId + ? 'ghost' + : 'secondary', + ] + .filter(Boolean) + .join(' '); + return ``; + }) + .join(''); + const detailBlock = input.detail + ? `

${escapeHtml(input.detail)}
` + : ''; + const statusIcon = + input.type === 'question' + ? '' + : input.type === 'info' || input.type === 'none' + ? '' + : ''; + + return ` + + + + + + ${escapeHtml(input.title)} + + + +
+
+ + ${closeButton} +
+
+
+ +
+

${escapeHtml(input.title)}

+
${escapeHtml(input.message)}
+
+
+ ${detailBlock} +
+
${buttons}
+
+ + +`; +} + +function messageBoxType(value: MessageBoxOptions['type']): BrowserMessageBoxPresentation['type'] { + return value === 'warning' || value === 'error' || value === 'question' || value === 'info' + ? value + : 'none'; +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/gu, (character) => { + const entities: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return entities[character] ?? character; + }); +} diff --git a/apps/desktop/src/main/browser/automation-host.ts b/apps/desktop/src/main/browser/automation-host.ts index ca296f1360..4561629d1d 100644 --- a/apps/desktop/src/main/browser/automation-host.ts +++ b/apps/desktop/src/main/browser/automation-host.ts @@ -47,6 +47,12 @@ export function createBrowserViewHost( shownSessionId: () => string | null, ): BrowserViewHost { return { + currentUrl(sessionId) { + return manager.get(sessionId)?.state().url ?? ''; + }, + openOriginLease(sessionId, approvedUrl, kind) { + return manager.getOrCreate(sessionId).openOriginLease(approvedUrl, kind); + }, canDrive(sessionId, kind, opts) { const shown = sessionId === shownSessionId(); const controller = manager.get(sessionId); diff --git a/apps/desktop/src/main/browser/browser-host.ts b/apps/desktop/src/main/browser/browser-host.ts index 3c48ae8a27..a5307849dc 100644 --- a/apps/desktop/src/main/browser/browser-host.ts +++ b/apps/desktop/src/main/browser/browser-host.ts @@ -33,7 +33,29 @@ import type { BrowserActionKind } from './logic.js'; +export interface BrowserOriginLeaseSnapshot { + readonly epoch: number; + readonly url: string; + readonly violatedUrl?: string; +} + +export interface BrowserOriginLease { + readonly approvedOrigin: string; + /** Arm a navigate lease immediately before its approved Page.goto call. */ + startNavigation(targetUrl: string): void; + snapshot(): BrowserOriginLeaseSnapshot; + release(): void; +} + export interface BrowserViewHost { + /** Read the current real URL without creating or attaching an automation endpoint. */ + currentUrl(sessionId: string): string; + /** + * Track every committed navigation while one admitted Browser call is live. + * The lease remembers the first cross-Origin URL, so A→B→A cannot regain the + * original approval by merely ending on A again. + */ + openOriginLease(sessionId: string, approvedUrl: string, kind: BrowserActionKind): BrowserOriginLease; /** * The visible-lease gate (see browserActionAllowed): may `sessionId` run a * `kind` action right now? EVERY kind — read, navigate, mutate — requires the diff --git a/apps/desktop/src/main/browser/browser-origin-admission.ts b/apps/desktop/src/main/browser/browser-origin-admission.ts new file mode 100644 index 0000000000..2b505623e5 --- /dev/null +++ b/apps/desktop/src/main/browser/browser-origin-admission.ts @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; + +export interface BrowserOriginAdmission { + readonly sessionId: string; + readonly url: string; +} + +const currentAdmission = new AsyncLocalStorage(); + +/** Bind the Host-approved Browser scope to exactly one provider invocation. */ +export function withBrowserOriginAdmission( + admission: BrowserOriginAdmission, + run: () => T, +): T { + return currentAdmission.run(admission, run); +} + +/** Browser tools fail closed when they are invoked outside the admitted call. */ +export function browserOriginAdmission(sessionId: string): BrowserOriginAdmission | undefined { + const admission = currentAdmission.getStore(); + return admission?.sessionId === sessionId ? admission : undefined; +} diff --git a/apps/desktop/src/main/browser/browser-origin-lease.ts b/apps/desktop/src/main/browser/browser-origin-lease.ts new file mode 100644 index 0000000000..866cb9658e --- /dev/null +++ b/apps/desktop/src/main/browser/browser-origin-lease.ts @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { BrowserOriginLease, BrowserOriginLeaseSnapshot } from './browser-host.js'; +import type { BrowserActionKind } from './logic.js'; + +type ActiveLease = { + readonly approvedOrigin: string; + monitoring: boolean; + violatedUrl?: string; +}; + +/** Monotonic, page-host-owned evidence for Browser Origin admission. */ +export class BrowserOriginLeaseTracker { + #epoch = 0; + readonly #active = new Set(); + + constructor(private readonly currentUrl: () => string) {} + + recordNavigation(url = this.currentUrl()): void { + this.#epoch += 1; + for (const lease of this.#active) { + if (lease.monitoring && !lease.violatedUrl && webOrigin(url) !== lease.approvedOrigin) { + lease.violatedUrl = url; + } + } + } + + open(approvedUrl: string, kind: BrowserActionKind): BrowserOriginLease { + const approvedOrigin = webOrigin(approvedUrl); + if (!approvedOrigin) throw new Error('Browser admission requires an HTTP origin'); + const state: ActiveLease = { + approvedOrigin, + // Navigate may start on another site. It becomes monitored immediately + // before the approved goto; every other action must start on its grant. + monitoring: kind !== 'navigate', + }; + this.#active.add(state); + + const snapshot = (): BrowserOriginLeaseSnapshot => { + const url = this.currentUrl(); + if (state.monitoring && !state.violatedUrl && webOrigin(url) !== approvedOrigin) { + state.violatedUrl = url; + } + return { + epoch: this.#epoch, + url, + ...(state.violatedUrl === undefined ? {} : { violatedUrl: state.violatedUrl }), + }; + }; + + if (state.monitoring) snapshot(); + let released = false; + return { + approvedOrigin, + startNavigation: (targetUrl) => { + if (released) throw new Error('Browser Origin lease is no longer active'); + if (webOrigin(targetUrl) !== approvedOrigin) { + throw new Error('Browser navigation target is outside the approved Origin'); + } + state.monitoring = true; + }, + snapshot, + release: () => { + if (released) return; + released = true; + this.#active.delete(state); + }, + }; + } +} + +function webOrigin(value: string): string | undefined { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:' ? url.origin : undefined; + } catch { + return undefined; + } +} diff --git a/apps/desktop/src/main/browser/browser-tools.ts b/apps/desktop/src/main/browser/browser-tools.ts index bf6947b65b..1997ba8bdc 100644 --- a/apps/desktop/src/main/browser/browser-tools.ts +++ b/apps/desktop/src/main/browser/browser-tools.ts @@ -19,7 +19,10 @@ import { z } from 'zod'; import { htmlToMarkdown } from '@jackwener/opencli/utils'; +import type { IPage } from '@jackwener/opencli/types'; import type { MakaTool } from '@maka/runtime/tool-runtime'; +import { browserOriginAdmission } from './browser-origin-admission.js'; +import type { BrowserOriginLease } from './browser-host.js'; import { type BrowserPageRun, type TakeoverMode, @@ -34,11 +37,10 @@ import { parseNavigable } from './logic.js'; * (click / type) take a ref and self-verify the match. All six drive the * conversation's OWN embedded-browser view through BrowserSession. * - * Permission: every tool is the dedicated `browser` category — Maka's mode - * gates it (block in `explore`, prompt in `ask` AND `execute`), so a browser - * action on the user's logged-in sessions is never silently auto-allowed; the - * visible view plus the user's "allow for this turn" is the safety net. A - * finer origin-keyed grant (remember "allow this site") is a later refinement. + * Permission: the Runtime Host admits these Client Capability calls against an + * Origin-keyed Session Grant. The Desktop provider binds that approved Origin + * to this exact invocation; the page-host lease below then keeps it valid for + * the entire action, in addition to the visible-view safety boundary. */ const BROWSER_TOOL_CATEGORY = 'browser' as const; @@ -76,11 +78,20 @@ export function takeoverNote(info: { takeoverReloaded: boolean }): string { } /** - * Shared run path for the browser_* tools. Permission is decided by the engine - * BEFORE impl runs (via the tool's category), so this only guards availability - * and runs the action through BrowserSession with its tool-level timeout and - * the user's stop signal. + * Shared run path for the browser_* tools. Host admission happens before impl; + * this layer carries its approved Origin across BrowserSession and checks it + * before and after every page operation, alongside timeout and stop handling. */ +type BrowserActionResult = + | { readonly kind: 'completed'; readonly value: T } + | { readonly kind: 'navigated'; readonly url: string; readonly requiresApproval: boolean }; + +class BrowserOriginLeaseLostError extends Error { + constructor(readonly url: string) { + super('Browser Origin lease was lost'); + } +} + async function runBrowserAction(input: { sessionId: string; label: string; @@ -90,24 +101,74 @@ async function runBrowserAction(input: { // omit for the pure-observe default, which never reloads. takeover?: TakeoverMode; run: BrowserPageRun; -}): Promise { +}): Promise> { if (!browserAutomationAvailable()) { throw new Error('Browser automation is only available inside the desktop app.'); } - return withBrowserPage(input.sessionId, input.label, input.run, { + const admission = browserOriginAdmission(input.sessionId); + if (!admission) { + throw new Error('Browser action is missing its admitted Origin scope.'); + } + return withBrowserPage(input.sessionId, input.label, async (page, info) => { + const lease = info.originLease; + if (!lease) throw new Error('Browser action is missing its page-host Origin lease.'); + const started = lease.snapshot(); + try { + assertOriginLease(lease); + const value = await input.run(guardBrowserPage(page, lease), info); + const finished = assertOriginLease(lease); + if ((input.takeover ?? 'observe') === 'mutate' && finished.epoch !== started.epoch) { + return { kind: 'navigated', url: finished.url, requiresApproval: false }; + } + return { kind: 'completed', value }; + } catch (error) { + if (error instanceof BrowserOriginLeaseLostError) { + return { kind: 'navigated', url: error.url, requiresApproval: true }; + } + throw error; + } + }, { abort: input.abortSignal, + originAdmission: { approvedUrl: admission.url }, ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), ...(input.takeover ? { takeover: input.takeover } : {}), }); } +function assertOriginLease(lease: BrowserOriginLease) { + const snapshot = lease.snapshot(); + if (snapshot.violatedUrl !== undefined) { + throw new BrowserOriginLeaseLostError(snapshot.violatedUrl); + } + return snapshot; +} + +function guardBrowserPage(page: IPage, lease: BrowserOriginLease): IPage { + return new Proxy(page, { + get(target, key, receiver) { + const member = Reflect.get(target, key, receiver); + if (typeof member !== 'function') return member; + return async (...args: unknown[]) => { + if (key === 'goto') { + lease.startNavigation(String(args[0] ?? '')); + } else { + assertOriginLease(lease); + } + const value = await Reflect.apply(member, target, args); + assertOriginLease(lease); + return value; + }; + }, + }) as IPage; +} + export function buildBrowserNavigateTool(): MakaTool<{ url: string }, string> { return { name: 'browser_navigate', displayName: '浏览器导航', description: 'Open a URL in the conversation\'s embedded browser. Pass a full http:// or https:// URL; other schemes are rejected. ' + - 'Returns the URL actually landed on (after redirects) and the page title. Follow with browser_snapshot to see what is on the page.', + 'Returns the sanitized URL actually landed on (after redirects). Follow with browser_snapshot to see what is on the page.', parameters: z.object({ url: z.string().min(1).max(4000).describe('Full http:// or https:// URL to open. Other schemes are rejected.'), }), @@ -133,14 +194,11 @@ export function buildBrowserNavigateTool(): MakaTool<{ url: string }, string> { // getCurrentUrl() would just echo it back and a redirect would never // be visible. const landed = await page.evaluate('window.location.href').catch(() => url); - const title = await page.evaluate('document.title').catch(() => ''); - return { landed: typeof landed === 'string' && landed ? landed : url, title, info }; + return { landed: typeof landed === 'string' && landed ? landed : url, info }; }, }); - return ( - [`Loaded ${result.landed}`, result.title ? `Title: ${result.title}` : undefined].filter(Boolean).join('\n') + - takeoverNote(result.info) - ); + if (result.kind === 'navigated') return navigationResult(result.url, result.requiresApproval); + return `Loaded ${sanitizedPageUrl(result.value.landed)}` + takeoverNote(result.value.info); }, }; } @@ -165,9 +223,12 @@ export function buildBrowserSnapshotTool(): MakaTool, stri return { snapshot, url, info }; }, }); + if (result.kind === 'navigated') return navigationResult(result.url, result.requiresApproval); const text = - typeof result.snapshot === 'string' ? result.snapshot : JSON.stringify(result.snapshot, null, 2); - return (result.url ? `${result.url}\n\n${text}` : text) + takeoverNote(result.info); + typeof result.value.snapshot === 'string' + ? result.value.snapshot + : JSON.stringify(result.value.snapshot, null, 2); + return (result.value.url ? `${result.value.url}\n\n${text}` : text) + takeoverNote(result.value.info); }, }; } @@ -190,20 +251,54 @@ export function buildBrowserClickTool(): MakaTool<{ ref: string }, string> { abortSignal, // A mutating action: harden a taken-over page (reload once) before clicking. takeover: 'mutate', - run: async (page, info) => ({ outcome: await page.click(normalizeElementRef(ref)), info }), + run: async (page, info) => { + const outcome = await page.click(normalizeElementRef(ref)); + return { outcome, info }; + }, }); - const { matches_n, match_level } = result.outcome; + if (result.kind === 'navigated') return navigationResult(result.url, result.requiresApproval); + const { matches_n, match_level } = result.value.outcome; return ( `Clicked ${ref} (matched ${matches_n} element${matches_n === 1 ? '' : 's'}, ${match_level} match).` + (matches_n > 1 ? ' Multiple matches — verify the right element reacted, or re-snapshot for a tighter ref.' : '') + - takeoverNote(result.info) + takeoverNote(result.value.info) ); }, }; } +function sanitizedPageUrl(value: string): string { + try { + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'an unapproved page'; + return `${url.origin}${url.pathname}`; + } catch { + return 'an unapproved page'; + } +} + +function unapprovedPageOrigin(value: string): string { + try { + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'an unapproved page'; + // The pathname may itself contain credentials or reset tokens; disclose it only after approval. + return url.origin; + } catch { + return 'an unapproved page'; + } +} + +function navigationResult(url: string, requiresApproval: boolean): string { + if (requiresApproval) { + return `Navigated to ${unapprovedPageOrigin(url)}. Access to the new site requires approval on the next Browser call.`; + } + return ( + `Navigated to ${sanitizedPageUrl(url)}.` + ); +} + export function buildBrowserTypeTool(): MakaTool<{ ref: string; text: string; submit?: boolean }, string> { return { name: 'browser_type', @@ -233,14 +328,15 @@ export function buildBrowserTypeTool(): MakaTool<{ ref: string; text: string; su return { outcome, info }; }, }); - const { verified, actual, match_level } = result.outcome; + if (result.kind === 'navigated') return navigationResult(result.url, result.requiresApproval); + const { verified, actual, match_level } = result.value.outcome; const lines = [ `Filled ${ref} (${match_level} match)${submit ? ', then pressed Enter' : ''}.`, verified ? 'Verified: the field contains the requested text.' : `Not verified — the field now contains: ${JSON.stringify(actual)}`, ]; - return lines.join('\n') + takeoverNote(result.info); + return lines.join('\n') + takeoverNote(result.value.info); }, }; } @@ -292,7 +388,7 @@ export function buildBrowserWaitTool(): MakaTool< : selector ? `selector ${JSON.stringify(selector)}` : `${requested}s pause`; - const info = await runBrowserAction({ + const result = await runBrowserAction({ sessionId, label: 'wait', abortSignal, @@ -322,7 +418,8 @@ export function buildBrowserWaitTool(): MakaTool< return pageInfo; }, }); - return `Done: ${condition}.` + takeoverNote(info); + if (result.kind === 'navigated') return navigationResult(result.url, result.requiresApproval); + return `Done: ${condition}.` + takeoverNote(result.value); }, }; } @@ -358,27 +455,28 @@ export function buildBrowserExtractTool(): MakaTool<{ selector?: string; start?: return { read, url, info }; }, }); - if (typeof result.read?.html !== 'string') { + if (result.kind === 'navigated') return navigationResult(result.url, result.requiresApproval); + if (typeof result.value.read?.html !== 'string') { throw new Error( selector ? `No element matches selector ${JSON.stringify(selector)}.` : 'The page has no readable body yet — navigate somewhere first.', ); } - const markdown = htmlToMarkdown(result.read.html); + const markdown = htmlToMarkdown(result.value.read.html); const chunk = markdown.slice(start, start + EXTRACT_CHAR_LIMIT); const nextStart = start + chunk.length; const hasMore = nextStart < markdown.length; return ( - (result.url ? `${result.url}\n\n` : '') + + (result.value.url ? `${result.value.url}\n\n` : '') + chunk + (hasMore ? `\n\n(Content continues — call browser_extract again with start=${nextStart}. next_start_char: ${nextStart})` : '') + - (result.read.truncated + (result.value.read.truncated ? "\n\n(The page's HTML was larger than the extraction ceiling; trailing content was dropped before conversion. Use `selector` to target the part you need.)" : '') + - takeoverNote(result.info) + takeoverNote(result.value.info) ); }, }; diff --git a/apps/desktop/src/main/browser/controller.ts b/apps/desktop/src/main/browser/controller.ts index a214fcdd6b..1cac244301 100644 --- a/apps/desktop/src/main/browser/controller.ts +++ b/apps/desktop/src/main/browser/controller.ts @@ -19,8 +19,11 @@ import { type BrowserWindow, type Session, shell, WebContentsView } from 'electron'; import { CdpBridge, type AutomationEndpoint } from './cdp-bridge.js'; +import type { BrowserOriginLease } from './browser-host.js'; +import { BrowserOriginLeaseTracker } from './browser-origin-lease.js'; import { browserViewWebPreferences } from './options.js'; import { + type BrowserActionKind, type BrowserState, type BrowserViewRect, deriveBrowserState, @@ -57,6 +60,9 @@ export class BrowserViewController { /** True while the view holds real on-screen bounds (last setViewport painted it). */ private shownWithBounds = false; private automation: CdpBridge | null = null; + private readonly originLeases = new BrowserOriginLeaseTracker(() => + this.destroyed || this.wc.isDestroyed() ? '' : this.wc.getURL(), + ); constructor( private readonly window: BrowserWindow, @@ -78,8 +84,8 @@ export class BrowserViewController { const wc = this.wc; wc.on('did-start-loading', () => this.emitState()); wc.on('did-stop-loading', () => this.emitState()); - wc.on('did-navigate', () => this.emitState()); - wc.on('did-navigate-in-page', () => this.emitState()); + wc.on('did-navigate', () => this.recordNavigation()); + wc.on('did-navigate-in-page', () => this.recordNavigation()); wc.on('page-title-updated', () => this.emitState()); wc.on('did-fail-load', () => this.emitState()); @@ -151,6 +157,16 @@ export class BrowserViewController { this.onState(this.sessionId, this.state()); } + private recordNavigation(): void { + if (this.destroyed) return; + this.originLeases.recordNavigation(this.wc.getURL()); + this.emitState(); + } + + openOriginLease(approvedUrl: string, kind: BrowserActionKind): BrowserOriginLease { + return this.originLeases.open(approvedUrl, kind); + } + async navigate(input: string): Promise { const url = parseNavigable(input); if (!url) return; diff --git a/apps/desktop/src/main/browser/session.ts b/apps/desktop/src/main/browser/session.ts index 05e2d5532e..e0a9b5a7ad 100644 --- a/apps/desktop/src/main/browser/session.ts +++ b/apps/desktop/src/main/browser/session.ts @@ -19,7 +19,11 @@ import { CDPBridge } from '@jackwener/opencli/browser/cdp'; import type { IPage } from '@jackwener/opencli/types'; -import { browserAutomationAvailable, browserViewHost } from './browser-host.js'; +import { + type BrowserOriginLease, + browserAutomationAvailable, + browserViewHost, +} from './browser-host.js'; import { type BrowserActionKind, parseNavigable } from './logic.js'; /** @@ -273,7 +277,10 @@ async function acquire(sessionId: string): Promise { return promise; } -export type BrowserPageRun = (page: IPage, info: { takeoverReloaded: boolean }) => Promise; +export type BrowserPageRun = ( + page: IPage, + info: { takeoverReloaded: boolean; originLease?: BrowserOriginLease }, +) => Promise; /** * Run one tool action against the session's embedded-browser page: lazy connect @@ -285,7 +292,12 @@ export async function withBrowserPage( sessionId: string, label: string, run: BrowserPageRun, - opts?: { timeoutMs?: number; abort?: AbortSignal; takeover?: TakeoverMode }, + opts?: { + timeoutMs?: number; + abort?: AbortSignal; + takeover?: TakeoverMode; + originAdmission?: { approvedUrl: string }; + }, ): Promise { if (opts?.abort?.aborted) throw new BrowserActionCanceledError(label); const kind: TakeoverMode = opts?.takeover ?? 'observe'; @@ -306,6 +318,13 @@ export async function withBrowserPage( let timer: ReturnType | undefined; let onAbort: (() => void) | undefined; let onRevoke: (() => void) | undefined; + // Establish the page-host Origin lease before endpoint acquisition and a + // possible takeover reload. This closes the Provider-check → first-page-await + // gap, while still preserving canDrive's rule that a blocked action creates + // no view. + const originLease = opts?.originAdmission + ? browserViewHost().openOriginLease(sessionId, opts.originAdmission.approvedUrl, kind) + : undefined; // Track this action so a switch away from the conversation can revoke it (the // visible lease is continuous, not just the preflight canDrive above). // Registered AFTER canDrive resolved true, with no await between, so the @@ -362,7 +381,7 @@ export async function withBrowserPage( conn.pendingTakeover = false; } } - return await Promise.race([run(conn.page, { takeoverReloaded }), interrupted]); + return await Promise.race([run(conn.page, { takeoverReloaded, ...(originLease ? { originLease } : {}) }), interrupted]); } catch (err) { if (conn && isConnectionLoss(err)) { invalidate(conn); @@ -378,6 +397,7 @@ export async function withBrowserPage( } finally { clearTimeout(timer); if (onAbort) opts?.abort?.removeEventListener('abort', onAbort); + originLease?.release(); untrackInFlight(sessionId, revoke); } } diff --git a/apps/desktop/src/main/client-settings-effects.ts b/apps/desktop/src/main/client-settings-effects.ts index 138f3a2c4e..ea5c65e090 100644 --- a/apps/desktop/src/main/client-settings-effects.ts +++ b/apps/desktop/src/main/client-settings-effects.ts @@ -16,8 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -import { toAppIconChoice, type AppIconChoice, type AppSettings } from '@maka/core/settings'; -import type { SettingsStore } from '@maka/storage'; +import { + appIconForTheme, + startupAppIcon, + type AppIconChoice, + type AppSettings, +} from '@maka/core/settings'; +import { isDarkAppearance } from './theme-source.js'; +import type { SettingsStore } from '@maka/storage/settings-store'; export interface ClientSettingsEffects { apply(settings: AppSettings, notifyRenderer: boolean): Promise; @@ -29,6 +35,12 @@ interface ClientSettingsEffectDependencies { readonly applyKeepSystemAwake: (enabled: boolean) => Promise; readonly applyBotSettings: (settings: AppSettings['botChat']) => Promise; readonly applyAppIcon: (icon: AppIconChoice) => Promise; + /** + * What the OS currently reports, read fresh on every pass. Injected + * rather than imported so this module stays free of electron and keeps + * its unit tests runnable outside a desktop session. + */ + readonly systemPrefersDark: () => boolean; readonly observeLocale: (settings: AppSettings) => void; readonly emitExternalChanged: () => void; } @@ -39,11 +51,11 @@ export function createClientSettingsEffects( let rendererFingerprint: string | undefined; let botFingerprint: string | undefined; let keepSystemAwake: boolean | undefined; - // Seeded rather than left undefined: the shipped default is already on - // screen before the first snapshot arrives — the dock gets it synchronously - // at startup and a new window gets it from its `icon` option — so treating - // "default" as unapplied would cost a 1024px PNG decode on every launch. - let appIcon: AppIconChoice = 'default'; + // Seeded rather than left undefined: the startup path has already put an + // icon on the dock synchronously, so treating it as unapplied would cost a + // 1024px PNG decode on every launch. Seeded lazily because the value depends + // on the OS appearance, which is read fresh rather than captured here. + let appIcon: AppIconChoice | undefined; let tail = Promise.resolve(); const schedule = ( @@ -62,7 +74,15 @@ export function createClientSettingsEffects( // partial patch rather than from a store read. // Same reason as `appIconPath`: this snapshot did not come through // `normalizeSettings`, so the value is untrusted until coerced. - const nextAppIcon = toAppIconChoice(settings.appearance.appIcon); + // Resolved against the current appearance, so this also re-runs when the + // OS flips light/dark under an `auto` preference — the settings object + // is unchanged then, and the icon is the only thing that moves. + const systemPrefersDark = dependencies.systemPrefersDark(); + appIcon ??= startupAppIcon(systemPrefersDark); + const nextAppIcon = appIconForTheme( + settings.appearance, + isDarkAppearance(settings.appearance.theme, systemPrefersDark), + ); const appIconChanged = nextAppIcon !== appIcon; dependencies.observeLocale(settings); if (keepAwakeChanged) { diff --git a/apps/desktop/src/main/client-settings-ipc-main.ts b/apps/desktop/src/main/client-settings-ipc-main.ts index 84150086cd..a5e0e21e29 100644 --- a/apps/desktop/src/main/client-settings-ipc-main.ts +++ b/apps/desktop/src/main/client-settings-ipc-main.ts @@ -22,7 +22,7 @@ import type { UpdateAppSettingsInput, UpdateAppSettingsResult, } from "@maka/core/settings"; -import type { SettingsStore } from "@maka/storage"; +import type { SettingsStore } from "@maka/storage/settings-store"; import type { IpcMain } from "electron"; import { clientOwnedSettingsPatch, diff --git a/apps/desktop/src/main/computer-use/pip-window.ts b/apps/desktop/src/main/computer-use/pip-window.ts index 0f5bbf9b8a..f148acf3d3 100644 --- a/apps/desktop/src/main/computer-use/pip-window.ts +++ b/apps/desktop/src/main/computer-use/pip-window.ts @@ -761,12 +761,3 @@ export function createComputerUsePipController( }, }; } - - - -// Re-exported so callers keep their type import path while the code moves. -export type { ParentWindowLike, PipWindowLike } from './pip-electron.js'; - -// Re-exported so callers and tests keep their import path while the code -// moves. Comes out in a later cut. -export { withComputerUsePip } from './pip-feed.js'; diff --git a/apps/desktop/src/main/config-transfer-service.ts b/apps/desktop/src/main/config-transfer-service.ts index c916b77847..a66cc53701 100644 --- a/apps/desktop/src/main/config-transfer-service.ts +++ b/apps/desktop/src/main/config-transfer-service.ts @@ -25,9 +25,9 @@ import { import { type ConfigBundle, type ConnectionConflictStrategy, - type CredentialKind, planConnectionMerge, -} from '@maka/storage'; +} from '@maka/storage/config-transfer'; +import { type CredentialKind } from '@maka/storage/credential-store'; /** * Electron-free config import orchestration. Runtime Host owns export because diff --git a/apps/desktop/src/main/connections-ipc-validation.ts b/apps/desktop/src/main/connections-ipc-validation.ts index 603ee5a65a..3c0b7a3e71 100644 --- a/apps/desktop/src/main/connections-ipc-validation.ts +++ b/apps/desktop/src/main/connections-ipc-validation.ts @@ -23,7 +23,7 @@ import { type UpdateConnectionInput, } from '@maka/core/llm-connections'; import { normalizeOptionalRequestBodyOverlay, normalizeRequestHeaders } from '@maka/core/runtime-policy'; -import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY, providerDefaultsOf } from '@maka/core/llm-connections'; import { normalizeRelayModelProfiles } from '@maka/core/model-thinking'; const IPC_CONNECTION_SLUG_MAX_LENGTH = 64; @@ -64,7 +64,9 @@ export function normalizeCreateConnectionInputForIpc(value: unknown): CreateConn typeof input.name !== 'string' || input.name.length === 0 || typeof input.providerType !== 'string' || - !(input.providerType in PROVIDER_DEFAULTS) + // `in` traverses the prototype chain, so it admitted `__proto__`, + // `toString` and `constructor` as provider types across the IPC boundary. + providerDefaultsOf(input.providerType) === undefined ) { throw new Error('Invalid Connection input'); } @@ -110,8 +112,8 @@ export function normalizeConnectionPatchSecretsForIpc(value: unknown): UpdateCon } export function normalizeConnectionBaseUrlForIpc(input: T): T { - if (PROVIDER_DEFAULTS[input.providerType].authKind === 'oauth_token') { - return { ...input, baseUrl: PROVIDER_DEFAULTS[input.providerType].baseUrl }; + if (PROVIDER_REGISTRY[input.providerType].authKind === 'oauth_token') { + return { ...input, baseUrl: PROVIDER_REGISTRY[input.providerType].baseUrl }; } if (input.baseUrl === undefined) return input; return { @@ -124,7 +126,7 @@ export function normalizeConnectionBaseUrlValueForIpc( providerType: CreateConnectionInput['providerType'], value: string, ): string { - const defaults = PROVIDER_DEFAULTS[providerType]; + const defaults = PROVIDER_REGISTRY[providerType]; if (defaults.authKind === 'oauth_token') return defaults.baseUrl; const result = normalizeConnectionBaseUrl(value); if (!result.ok) throw new Error(result.error); diff --git a/apps/desktop/src/main/create-session-input.ts b/apps/desktop/src/main/create-session-input.ts index c06de46351..d098cf9614 100644 --- a/apps/desktop/src/main/create-session-input.ts +++ b/apps/desktop/src/main/create-session-input.ts @@ -43,7 +43,7 @@ import type { OrchestrationMode } from '@maka/core/orchestration'; import type { PermissionMode } from '@maka/core/permission'; -import type { SessionStartMode } from '@maka/core/explore-agent'; +import type { SessionStartMode } from '@maka/core/session-start-mode'; import { DEFAULT_SESSION_NAME } from '@maka/core/session-name'; import { isChatDefaultPermissionMode } from '@maka/core/settings'; @@ -52,7 +52,7 @@ import { isCollaborationMode } from '@maka/core/collaboration'; import { isOrchestrationMode } from '@maka/core/orchestration'; -import { isSessionStartMode } from '@maka/core/explore-agent'; +import { isSessionStartMode } from '@maka/core/session-start-mode'; /** * `unknown`, because this is an IPC boundary and the renderer's type is a diff --git a/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts b/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts index 048f033a7c..9e52418045 100644 --- a/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts +++ b/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts @@ -20,9 +20,11 @@ import type { IpcMain } from 'electron'; import { copyDesktopDiagnosticReport, + createDesktopPreviousMainProcessDiagnosticInput, parseDesktopDiagnosticInput, type DesktopDiagnosticsDeps, } from './main-process-diagnostics.js'; +import type { MainProcessRecoveryEvidence } from './main-process-recovery-journal.js'; export interface DesktopDiagnosticsIpcDeps extends DesktopDiagnosticsDeps { readonly ipcMain: Pick; @@ -37,3 +39,27 @@ export function registerDesktopDiagnosticsIpc(deps: DesktopDiagnosticsIpcDeps): }, ); } + +export interface PreviousMainProcessDiagnosticsIpcDeps extends DesktopDiagnosticsIpcDeps { + readonly evidence: MainProcessRecoveryEvidence | undefined; + readonly acknowledge: () => void; +} + +export function registerPreviousMainProcessDiagnosticsIpc( + deps: PreviousMainProcessDiagnosticsIpcDeps, +): void { + let noticeAvailable = deps.evidence !== undefined; + deps.ipcMain.handle('diagnostics:takePreviousMainProcessInterruption', (): boolean => { + if (!noticeAvailable) return false; + deps.acknowledge(); + noticeAvailable = false; + return true; + }); + deps.ipcMain.handle('diagnostics:copyPreviousMainProcessInterruption', async (): Promise => { + if (!deps.evidence) throw new Error('Previous-session diagnostics are unavailable'); + await copyDesktopDiagnosticReport( + deps, + createDesktopPreviousMainProcessDiagnosticInput(deps.evidence), + ); + }); +} diff --git a/apps/desktop/src/main/desktop-native-capability-assembly.ts b/apps/desktop/src/main/desktop-native-capability-assembly.ts index 0fcef1e123..4182384793 100644 --- a/apps/desktop/src/main/desktop-native-capability-assembly.ts +++ b/apps/desktop/src/main/desktop-native-capability-assembly.ts @@ -25,10 +25,8 @@ import { createDesktopPhysicalInputGuard, } from './computer-use-host.js'; import { createCursorOverlayController } from './computer-use/cursor-overlay-window.js'; -import { - createComputerUsePipController, - withComputerUsePip, -} from './computer-use/pip-window.js'; +import { withComputerUsePip } from './computer-use/pip-feed.js'; +import { createComputerUsePipController } from './computer-use/pip-window.js'; import { createComputerUseStatusItem, withComputerUseStatusItem, diff --git a/apps/desktop/src/main/desktop-shell-presentation.ts b/apps/desktop/src/main/desktop-shell-presentation.ts index 614160db51..2a6d96daa5 100644 --- a/apps/desktop/src/main/desktop-shell-presentation.ts +++ b/apps/desktop/src/main/desktop-shell-presentation.ts @@ -16,7 +16,8 @@ * specific language governing permissions and limitations * under the License. */ -import { app } from 'electron'; +import { app, nativeTheme } from 'electron'; +import { startupAppIcon } from '@maka/core/settings'; import { applyAppIcon } from './app-icon-surface.js'; import { installApplicationMenu } from './application-menu.js'; import { resolveDockPresentation } from './dock-presentation.js'; @@ -41,13 +42,18 @@ export function installDesktopShellPresentation( if (dockPresentation === 'hide') { app.dock.hide(); } else if (dockPresentation === 'icon') { - // The DEFAULT mark, synchronously, even when the user picked another - // one: reading the persisted choice means awaiting the settings store, - // and a dock that shows the generic Electron rocket until that resolves - // is the exact regression PR-GRAY-CARD-LIFT-0 fixed. The persisted - // choice lands a tick later, from the same client-settings effect that - // applies it when the user switches (see client-settings-effects.ts). - applyAppIcon('default', deps.onIconError); + // A DEFAULT, synchronously, even when the user picked another one: + // reading the persisted choice means awaiting the settings store, and a + // dock that shows the generic Electron rocket until that resolves is the + // exact regression PR-GRAY-CARD-LIFT-0 fixed. The persisted choice lands + // a tick later, from the same client-settings effect that applies it + // when the user switches (see client-settings-effects.ts). + // + // Which default depends on the appearance, and only the OS half of that + // is readable synchronously — the stored `theme` preference is not. So a + // user whose in-app theme disagrees with the OS still sees one swap, the + // same as before; what this avoids is every default install swapping. + applyAppIcon(startupAppIcon(nativeTheme.shouldUseDarkColors), deps.onIconError); } } diff --git a/apps/desktop/src/main/desktop-transcript-replica.ts b/apps/desktop/src/main/desktop-transcript-replica.ts index b7408ca4ac..0730da8906 100644 --- a/apps/desktop/src/main/desktop-transcript-replica.ts +++ b/apps/desktop/src/main/desktop-transcript-replica.ts @@ -24,11 +24,14 @@ import { type RuntimeHostSessionProjectionSeed, } from '@maka/runtime-host/adapter'; import { RuntimeHostSubscriptionError } from '@maka/runtime-host/client'; -import type { SessionTranscriptPage } from '@maka/runtime-host/protocol'; import { - DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES, + SESSION_TRANSCRIPT_RANGE_MAX_BYTES, + type SessionTranscriptPage, +} from '@maka/runtime-host/protocol'; +import { + DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES, - DESKTOP_TRANSCRIPT_SESSION_CACHE_MAX_BYTES, + DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, } from '../preload/transcript-contract.js'; import type { DesktopRuntimeHostSession } from './runtime-host-client.js'; @@ -36,6 +39,7 @@ export interface DesktopTranscriptReplicaOptions { readonly generation?: string; readonly maxMessageBytes?: number; readonly maxResidentBytes?: number; + readonly maxResidentTurns?: number; readonly maxOverlayBytes?: number; readonly accountPreparationBytes?: (deltaBytes: number) => void; readonly onChange?: ( @@ -79,6 +83,7 @@ export class DesktopTranscriptReplica { readonly hostEpoch: string; readonly #handle: DesktopRuntimeHostSession; readonly #maxResidentBytes: number; + readonly #maxResidentTurns: number; readonly #maxOverlayBytes: number; readonly #maxMessageBytes: number; readonly #accountPreparationBytes: (deltaBytes: number) => void; @@ -109,10 +114,12 @@ export class DesktopTranscriptReplica { this.generation = options.generation ?? randomUUID(); this.hostEpoch = handle.hostEpoch; this.#maxResidentBytes = - options.maxResidentBytes ?? DESKTOP_TRANSCRIPT_SESSION_CACHE_MAX_BYTES; + options.maxResidentBytes ?? DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES; + this.#maxResidentTurns = + options.maxResidentTurns ?? DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS; this.#maxOverlayBytes = options.maxOverlayBytes ?? DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES; - this.#maxMessageBytes = options.maxMessageBytes ?? DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES; + this.#maxMessageBytes = options.maxMessageBytes ?? SESSION_TRANSCRIPT_RANGE_MAX_BYTES; this.#accountPreparationBytes = options.accountPreparationBytes ?? (() => undefined); this.#onChange = options.onChange ?? (() => undefined); this.#durableThrough = handle.transcriptBootstrap.throughSequence; @@ -135,7 +142,13 @@ export class DesktopTranscriptReplica { replica.#installDurable(durable.messages); replica.#hasOlder = durable.nextCursor !== null; }); - replica.#evictToBudget(); + replica.#evictToBudget( + undefined, + 'oldest', + handle.transcriptBootstrap.durable.protectedTurnSequence ?? + replica.#durableThrough ?? + undefined, + ); if (replica.#overlayBytes > replica.#maxOverlayBytes) { throw new RangeError('Desktop transcript overlay exceeds the session cache limit'); } @@ -233,6 +246,11 @@ export class DesktopTranscriptReplica { }); await this.#withDecodedPage(page, (decoded) => { this.#assertOpen(); + // Same post-await `#resident` invariant as `#replaceWithRange` and the + // paged catch-up: a concurrent `discard()` may have reclaimed this + // replica while the older page was in flight. Installing the page here + // would repopulate durable state and undo the eviction. + if (!this.#resident) return; this.#acceptRange(decoded.messages); if ( anchor !== null && @@ -243,7 +261,11 @@ export class DesktopTranscriptReplica { } const completedOverlayMessageIds = this.#installDurable(decoded.messages); this.#hasOlder = decoded.nextCursor !== null; - const evictedDurableSequences = this.#evictToBudget(undefined, 'newest'); + const evictedDurableSequences = this.#evictToBudget( + undefined, + 'newest', + anchor ?? undefined, + ); this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); }); } @@ -275,6 +297,13 @@ export class DesktopTranscriptReplica { }); await this.#withDecodedPage(page, (decoded) => { this.#assertOpen(); + // `#resident` can flip to false across the `await` above (a concurrent + // `discard()` reclaims memory for a non-visible session while the page is + // in flight). Re-anchoring here would repopulate durable state and undo + // the eviction, resurrecting a deliberately discarded replica past its + // memory budget. The paged catch-up guards its own post-await callback + // the same way; mirror it before mutating or publishing. + if (!this.#resident) return; this.#acceptRange(decoded.messages); if ( decoded.messages.length > 0 && @@ -291,7 +320,11 @@ export class DesktopTranscriptReplica { this.#hasOlder = loadTail ? decoded.nextCursor !== null : sequence > 0; this.#hasNewer = loadTail ? false : decoded.nextCursor !== null; evictedDurableSequences.push( - ...this.#evictToBudget(undefined, loadTail ? 'oldest' : 'newest'), + ...this.#evictToBudget( + undefined, + loadTail ? 'oldest' : 'newest', + loadTail ? (page.protectedTurnSequence ?? sequence) : sequence, + ), ); this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); }); @@ -359,8 +392,16 @@ export class DesktopTranscriptReplica { return; } if (this.#hasNewer) { - this.#durableThrough = target; - this.#publish([], [], []); + // The resident window was trimmed off the tail (e.g. after loading + // older history, `#evictToBudget(..., 'newest')` set `#hasNewer`), so + // `target` cannot be appended contiguously to what is resident. Bumping + // the watermark and publishing an empty change here silently dropped + // the freshly persisted message: an already-open consumer never learned + // about it and only a fresh subscription (the user switching sessions + // and back) re-read it. Re-anchor to the newest window instead — the + // same recovery a fresh subscription performs — so the append reaches + // open consumers live. + await this.#replaceWithRange(target, target, 512 * 1024); return; } let cursor: string | null = null; @@ -393,11 +434,23 @@ export class DesktopTranscriptReplica { expectedSequence = decoded.messages.at(-1)!.identity + 1; } const completedOverlayMessageIds = this.#installDurable(decoded.messages); - const evictedDurableSequences = this.#evictToBudget(); + const evictedDurableSequences = this.#evictToBudget( + undefined, + 'oldest', + page.protectedTurnSequence ?? decoded.messages.at(-1)?.identity, + ); this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); cursor = decoded.nextCursor; }); } while (cursor !== null); + // A concurrent `discard()` (LRU reclaim for another observed session) can + // flip `#resident` to false across any page `await` above. The per-page + // callback already returns early in that case, so `expectedSequence` is + // left short of the watermark. Without this guard the check below would + // turn a benign memory reclaim into a fatal `correlation_changed` that + // drives the session terminal. A discarded replica has no watermark to + // meet, so return cleanly and let a later resume re-catch-up. + if (!this.#resident) return; if (expectedSequence !== target + 1) { throw correlationError('Desktop transcript catch-up ended before its watermark'); } @@ -497,19 +550,71 @@ export class DesktopTranscriptReplica { #evictToBudget( budget: number | undefined = undefined, edge: 'oldest' | 'newest' = 'oldest', + protectedSequence?: number, ): number[] { const residentBudget = budget ?? this.#maxResidentBytes + this.#overlayBytes; const evicted: number[] = []; - const direction = edge === 'oldest' ? 1 : -1; - for (const sequence of [...this.#durable.keys()].sort((left, right) => direction * (left - right))) { - if (this.#residentBytes <= residentBudget) break; + const sequences = [...this.#durable.keys()].sort((left, right) => left - right); + const turnGroups = new Map(); + for (const sequence of sequences) { const entry = this.#durable.get(sequence); if (!entry) continue; - this.#durable.delete(sequence); - this.#adjustResidentBytes(-entry.encodedBytes); - if (edge === 'oldest') this.#hasOlder = true; + const turnKey = residentTurnKey(entry); + const group = turnGroups.get(turnKey); + if (group) group.push(sequence); + else turnGroups.set(turnKey, [sequence]); + } + const orderedTurns = [...turnGroups.entries()]; + let oldestIndex = 0; + let newestIndex = orderedTurns.length - 1; + let residentTurns = orderedTurns.length; + const protectedEntry = protectedSequence === undefined + ? undefined + : this.#durable.get(protectedSequence); + const protectedTurnKey = protectedEntry === undefined + ? undefined + : residentTurnKey(protectedEntry); + const protectedIndex = protectedTurnKey === undefined + ? -1 + : orderedTurns.findIndex(([turnKey]) => turnKey === protectedTurnKey); + const take = ( + candidateEdge: 'oldest' | 'newest', + ): readonly [string, number[]] | undefined => { + const index = candidateEdge === 'oldest' ? oldestIndex : newestIndex; + if (oldestIndex > newestIndex) return undefined; + const turn = orderedTurns[index]; + if (!turn || turn[0] === protectedTurnKey) return undefined; + if (candidateEdge === 'oldest') oldestIndex += 1; + else newestIndex -= 1; + return turn; + }; + while ( + this.#residentBytes > residentBudget + || residentTurns > this.#maxResidentTurns + ) { + let evictionEdge = protectedIndex < 0 + ? edge + : protectedIndex - oldestIndex > newestIndex - protectedIndex + ? 'oldest' + : protectedIndex - oldestIndex < newestIndex - protectedIndex + ? 'newest' + : edge; + let turn = take(evictionEdge); + if (turn === undefined) { + evictionEdge = edge === 'oldest' ? 'newest' : 'oldest'; + turn = take(evictionEdge); + } + if (turn === undefined) break; + for (const sequence of turn[1]) { + const entry = this.#durable.get(sequence); + if (!entry) continue; + this.#durable.delete(sequence); + this.#adjustResidentBytes(-entry.encodedBytes); + evicted.push(sequence); + } + residentTurns -= 1; + if (evictionEdge === 'oldest') this.#hasOlder = true; else this.#hasNewer = true; - evicted.push(sequence); } return evicted; } @@ -606,6 +711,16 @@ function encodedMessageBytes(message: StoredMessage): number { return Buffer.byteLength(JSON.stringify(message), 'utf8'); } +function residentTurnKey(entry: ResidentMessage): string { + const turnId = messageTurnId(entry.message); + return turnId === undefined ? `sequence:${entry.sequence}` : `turn:${turnId}`; +} + +function messageTurnId(message: StoredMessage): string | undefined { + const turnId = 'turnId' in message ? message.turnId : undefined; + return typeof turnId === 'string' ? turnId : undefined; +} + function correlationError(message: string): RuntimeHostSubscriptionError { return new RuntimeHostSubscriptionError('correlation_changed', message); } diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index 9e5dcb56c9..e61e89f1c7 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -20,19 +20,29 @@ import { mkdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; import type { E2eFixtureScenario, E2eFixtureState } from '@maka/core/e2e-fixture'; +import { MODEL_CALL_ATTEMPT_EVENT_TYPE } from '@maka/core/model-call-attempt'; import type { UiLocale } from '@maka/core/ui-locale'; -import { createProjectCatalog } from '@maka/storage'; -import { resolveStorageRoot } from '@maka/storage/root-authority'; +import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; +import { createProjectCatalog } from '@maka/storage/project-catalog'; +import { + resolveStorageRoot, + tryAcquireInteractiveRootOwner, +} from '@maka/storage/root-authority'; +import { openInteractiveSessionTodoStoreForWrite } from '@maka/storage/session-todo-authority'; +import { openInteractiveUsageStoresForWrite } from '@maka/storage/usage-stores'; import { E2E_FIXTURE_NOW, LONG_SIDEBAR_PROJECT_ID, LONG_SIDEBAR_PROJECT_NAME, LONG_SIDEBAR_SESSION_PREFIX, + PARTIAL_HISTORY_SESSION_ID, PROMPT_RAIL_SESSION_ID, TURN_SESSION_ID, writeSession, } from './e2e-fixture/seed-helpers.js'; import { + partialHistoryMessages, + partialHistorySession, promptRailMessages, promptRailSession, turnMessages, @@ -46,13 +56,14 @@ import { writeScheduledTasks, writeSettings, } from './e2e-fixture/scenarios-settings.js'; -import { usageStatsSessions } from './e2e-fixture/scenarios-usage.js'; +import { usageStatsRecords, usageStatsSessions } from './e2e-fixture/scenarios-usage.js'; const E2E_FIXTURE_SCENARIOS = new Set([ 'settings-models', 'turn-narrative', 'turn-narrative-browser', 'chat-prompt-rail', + 'chat-partial-history', 'settings-data', 'settings-bots-onboarding', 'settings-general', @@ -168,6 +179,8 @@ export function getE2eFixtureState(fixture: E2eFixture | null): E2eFixtureState // this window scrolls smoothly is a per-launch choice (`scrollMotion`), // because only the jump case needs it and it costs seconds of settling. return { ...state, activeSessionId: PROMPT_RAIL_SESSION_ID, workbarCollapsed: true }; + case 'chat-partial-history': + return { ...state, activeSessionId: PARTIAL_HISTORY_SESSION_ID, workbarCollapsed: true }; case 'settings-data': return { ...state, activeSessionId: TURN_SESSION_ID, openSettingsSection: 'data' }; case 'settings-bots-onboarding': @@ -211,14 +224,40 @@ export async function seedE2eFixture(input: { const scenario = input.fixture.scenario; await rm(input.workspaceRoot, { recursive: true, force: true }); await mkdir(input.workspaceRoot, { recursive: true }); - await resolveStorageRoot({ path: input.workspaceRoot, kind: 'interactive' }); + const storageRoot = await resolveStorageRoot({ path: input.workspaceRoot, kind: 'interactive' }); await writeSettings(input.workspaceRoot, scenario); await writeConnections(input.workspaceRoot, now, scenario); await writeSession(input.workspaceRoot, turnSession(now), turnMessages(now)); + if (scenario === 'turn-narrative' || scenario === 'turn-narrative-browser') { + const owner = await tryAcquireInteractiveRootOwner(storageRoot); + if (!owner) throw new Error('Unable to acquire the E2E fixture SessionTodo root'); + try { + const todos = await openInteractiveSessionTodoStoreForWrite(owner.lease); + try { + await todos.replaceAll(TURN_SESSION_ID, [ + { content: '补齐桌面端无障碍覆盖', status: 'in_progress' }, + { content: '核对模型选择器的键盘路径', status: 'pending' }, + { content: '确认工具结果可以展开阅读', status: 'completed' }, + ]); + } finally { + todos.close(); + } + } finally { + await owner.close(); + } + } + if (scenario === 'chat-prompt-rail') { await writeSession(input.workspaceRoot, promptRailSession(now), promptRailMessages(now)); } + if (scenario === 'chat-partial-history') { + await writeSession( + input.workspaceRoot, + partialHistorySession(now), + partialHistoryMessages(now), + ); + } if (scenario === 'sidebar-search-modal-open') { for (const seed of longSidebarSessions(now)) { await writeSession(input.workspaceRoot, seed.header, seed.messages); @@ -242,5 +281,41 @@ export async function seedE2eFixture(input: { for (const seed of usageStatsSessions(now)) { await writeSession(input.workspaceRoot, seed.header, seed.messages); } + const owner = await tryAcquireInteractiveRootOwner(storageRoot); + if (!owner) throw new Error('Unable to acquire the E2E fixture storage root'); + const usage = await openInteractiveUsageStoresForWrite(owner.lease); + // The AgentRun store and the interactive usage stores share one refcounted + // operational-state DB keyed by the lease's resolved path, so the canonical + // model-call events written here are visible to `catchUpModelCallProjection` + // below. It MUST be the lease's canonicalPath, not the raw workspaceRoot — + // a /var vs /private/var realpath difference would open a different DB. + const runStore = createSqliteAgentRunStore(owner.lease.canonicalPath); + try { + const records = usageStatsRecords(now); + // Model calls seed the CANONICAL ledger through the AgentRun event stream; + // tools stay on the legacy telemetry table (there is no canonical tool + // ledger). This is what actually exercises the canonical merge branch. + for (const { header: runHeader, attempt } of records.modelCalls) { + await runStore.createRun(runHeader); + await runStore.appendEvent(attempt.sessionId, attempt.runId, { + id: attempt.attemptId, + type: MODEL_CALL_ATTEMPT_EVENT_TYPE, + ts: attempt.completedAt, + sessionId: attempt.sessionId, + runId: attempt.runId, + turnId: attempt.turnId, + data: { ...attempt }, + }); + } + for (const record of records.tools) await usage.telemetry.recordToolInvocation(record); + await runStore.close?.(); + // Fold the appended attempts into the read model so the page's first read + // sees canonical usage (production's readCanonicalUsage also repairs). + await usage.modelCalls.catchUpModelCallProjection(); + await usage.flush(); + } finally { + await usage.close(); + await owner.close(); + } } } diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts index 5df48fd8f7..760d9d5887 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts @@ -20,6 +20,7 @@ import type { SessionHeader, StoredMessage } from '@maka/core/session'; import { header, + PARTIAL_HISTORY_SESSION_ID, PROMPT_RAIL_PROMPT_COUNT, PROMPT_RAIL_SESSION_ID, TURN_SESSION_ID, @@ -86,7 +87,14 @@ export function turnMessages(now: number): StoredMessage[] { turnId, ts: now - 9 * 60_000, text: '当前需要重点观察截图基线是否稳定、启用模型名单是否清晰,以及完整目录是否只在搜索时出现。', - thinking: { text: '确认关键 UI 状态和工具执行结果。' }, + thinking: { + text: [ + '**Calculating CRT Solution**', + '', + 'First combine \\( n \\equiv 3 \\pmod 7 \\) with \\( n \\equiv 5 \\pmod {11} \\).', + 'Then solve \\( 7a \\equiv 2 \\pmod {11} \\), giving \\( a \\equiv 5 \\pmod {11} \\).', + ].join('\n'), + }, modelId: 'glm-5.1', }, { @@ -141,3 +149,45 @@ export function promptRailMessages(now: number): StoredMessage[] { } return messages; } + +export function partialHistorySession(now: number): SessionHeader { + return header({ + id: PARTIAL_HISTORY_SESSION_ID, + name: '超长对话历史范围示例', + connection: 'zai-live', + model: 'glm-5.1', + now, + lastMessageAt: now - 60_000, + }); +} + +/** + * Eight turns whose durable transcript is well over the Desktop range budget. + * The whitespace is stored but collapses when rendered, keeping this a useful + * visual fixture while forcing the initial open to contain only the latest + * contiguous range. + */ +export function partialHistoryMessages(now: number): StoredMessage[] { + const messages: StoredMessage[] = []; + const rangePadding = ' '.repeat(180 * 1024); + for (let index = 1; index <= 8; index += 1) { + const turnId = `turn-partial-history-${index}`; + const ts = now - (9 - index) * 60_000; + messages.push({ + type: 'user', + id: `msg-partial-history-user-${index}`, + turnId, + ts, + text: `第 ${index} 个问题:请概括这一阶段的实现进展。`, + }); + messages.push({ + type: 'assistant', + id: `msg-partial-history-assistant-${index}`, + turnId, + ts: ts + 1_000, + text: `第 ${index} 阶段已经完成关键实现,并通过了对应验证。${rangePadding}`, + modelId: 'glm-5.1', + }); + } + return messages; +} diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts b/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts index 7ed957643e..14af6b6249 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts @@ -17,21 +17,33 @@ * under the License. */ +import type { AgentRunHeader } from '@maka/core/agent-run'; +import { + MODEL_CALL_ATTEMPT_SCHEMA_VERSION, + type ModelCallAttempt, +} from '@maka/core/model-call-attempt'; import type { SessionHeader, StoredMessage } from '@maka/core/session'; +import type { TelemetryIndexWriter } from '@maka/storage/usage-stores'; import { header } from './seed-helpers.js'; -// Settings → 使用统计 fixture. `usageStats` aggregates `token_usage` + tool -// messages across ALL sessions in the workspace, so the settings-usage capture -// only shows real tables if the seed contains enough varied traffic. These -// sessions are gated to the `settings-usage` scenario so no other capture is -// disturbed; every value is a literal keyed off the fixed `now`, so the tables -// render deterministically. +type PersistedToolInvocationRecord = Parameters< + TelemetryIndexWriter['recordToolInvocation'] +>[0]; + +// Settings → 使用统计 fixture. Session messages keep the task links realistic, +// while `usageStatsRecords` seeds the two Host-owned surfaces the page reads: +// model calls go through the CANONICAL model-call ledger (AgentRun +// `model_call_attempt_recorded` events, projected by +// `catchUpModelCallProjection`), and tool invocations stay on the legacy +// telemetry table (tools have no canonical ledger). These records are gated to +// `settings-usage` so no other capture is disturbed; every value is derived +// from the fixed fixture clock for deterministic tables. // // The shape below intentionally spreads across: -// - 3 providers (zai-live / relay-fallback / needs-reauth) → 供应商统计 +// - 3 connections (zai-live / relay-fallback / needs-reauth) → 供应商统计 // - 5 models (glm / claude / gpt families) → 模型统计 // - 6 tools with 2 failures → 工具统计 (exercises the error column) -// - a dozen request-log rows mixing model + tool + success/error → 请求日志 +// - a dozen activity rows mixing model + tool + success/error → 活动记录 interface UsageTurnSpec { turnId: string; @@ -215,3 +227,101 @@ export function usageStatsSessions( }, ]; } + +export function usageStatsRecords(now: number): { + modelCalls: Array<{ header: AgentRunHeader; attempt: ModelCallAttempt }>; + tools: PersistedToolInvocationRecord[]; +} { + const sessions = usageStatsSessions(now); + const modelCalls: Array<{ header: AgentRunHeader; attempt: ModelCallAttempt }> = []; + const tools: PersistedToolInvocationRecord[] = []; + for (const { header: session, messages } of sessions) { + const modelByTurn = new Map( + messages + .filter((message) => message.type === 'assistant') + .map((message) => [message.turnId, message.modelId]), + ); + const toolResults = new Map( + messages + .filter((message) => message.type === 'tool_result') + .map((message) => [message.toolUseId, message]), + ); + for (const message of messages) { + if (message.type === 'token_usage') { + const inputTokens = message.input; + const outputTokens = message.output; + const cacheRead = message.cacheRead ?? 0; + const cacheMiss = message.cacheMissInput ?? Math.max(0, inputTokens - cacheRead); + const cacheWrite = message.cacheCreation ?? 0; + const modelId = modelByTurn.get(message.turnId) ?? session.model; + // Run/attempt ids must match SAFE_ID_PATTERN ([A-Za-z0-9_-]); no colons. + const runId = `run-${message.id}`; + modelCalls.push({ + header: { + runId, + sessionId: session.id, + turnId: message.turnId, + status: 'created', + backendKind: 'fake', + llmConnectionSlug: session.llmConnectionSlug, + modelId, + cwd: '/tmp/e2e-usage', + permissionMode: 'ask', + createdAt: message.ts - 2_000, + updatedAt: message.ts, + }, + attempt: { + schemaVersion: MODEL_CALL_ATTEMPT_SCHEMA_VERSION, + logicalCallId: message.id, + attemptId: message.id, + traceId: message.turnId, + sessionId: session.id, + runId, + turnId: message.turnId, + step: 0, + attempt: 0, + callKind: 'main', + connectionSlug: session.llmConnectionSlug, + providerId: session.llmConnectionSlug, + modelId, + startedAt: message.ts - 2_000, + completedAt: message.ts, + latencyMs: 2_000, + status: 'completed', + usageBasis: 'reported', + inputTokens, + outputTokens, + cacheReadInputTokens: cacheRead, + cacheMissInputTokens: cacheMiss, + cacheWriteInputTokens: cacheWrite, + reasoningTokens: message.reasoning ?? 0, + costBasis: 'priced', + costUsd: message.costUsd ?? 0, + }, + }); + } + if (message.type === 'tool_call') { + const result = toolResults.get(message.id); + const durationMs = result?.durationMs ?? 0; + const ts = result?.ts ?? message.ts; + tools.push({ + id: `tool:${message.id}`, + sessionId: session.id, + turnId: message.turnId, + toolCallId: message.id, + toolName: message.displayName ?? message.toolName, + providerId: session.llmConnectionSlug, + modelId: modelByTurn.get(message.turnId) ?? session.model, + durationMs, + status: result?.isError ? 'error' : 'success', + bytesIn: 0, + bytesOut: 0, + startedAt: message.ts, + date: new Date(ts).toISOString().slice(0, 10), + ts, + }); + } + } + } + return { modelCalls, tools }; +} diff --git a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts index c2d27c4a12..d38ecfaf78 100644 --- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts +++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts @@ -22,10 +22,10 @@ import { dirname, join } from 'node:path'; import type { SessionHeader, StoredMessage } from '@maka/core/session'; import { acquireOperationalStateDatabase, - createSqliteSessionMetadataStore, OPERATIONAL_STATE_DATABASE_NAME, - projectSessionCatalogMessages, -} from '@maka/storage'; +} from '@maka/storage/operational-state-store'; +import { projectSessionCatalogMessages } from '@maka/storage/session-store'; +import { createSqliteSessionMetadataStore } from '@maka/storage/sqlite-session-metadata-store'; // Fixed clock for the e2e-fixture. All seeded timestamps and // transient fixture state derive from this value unless tests explicitly @@ -34,8 +34,11 @@ export const E2E_FIXTURE_NOW = Date.UTC(2026, 4, 22, 3, 0, 0); export const TURN_SESSION_ID = 'e2e-fixture-turn'; export const PROMPT_RAIL_SESSION_ID = 'e2e-fixture-prompt-rail'; -/** Exceeds both the 64-tick rail and 100-turn mounted-window bounds. */ -export const PROMPT_RAIL_PROMPT_COUNT = 120; +export const PARTIAL_HISTORY_SESSION_ID = 'e2e-fixture-partial-history'; +/** Exceeds both the 64-tick rail and the bounded active transcript range. */ +export const PROMPT_RAIL_PROMPT_COUNT = process.env.MAKA_TRANSCRIPT_STRESS === '1' + ? 640 + : 120; export const LONG_SIDEBAR_SESSION_PREFIX = 'e2e-fixture-sidebar-long-'; export const LONG_SIDEBAR_SESSION_COUNT = 60; export const LONG_SIDEBAR_PROJECT_ID = 'e2e-fixture-project'; diff --git a/apps/desktop/src/main/main-process-recovery-journal.ts b/apps/desktop/src/main/main-process-recovery-journal.ts index 9c27ab4cf0..bfe91e627b 100644 --- a/apps/desktop/src/main/main-process-recovery-journal.ts +++ b/apps/desktop/src/main/main-process-recovery-journal.ts @@ -239,21 +239,6 @@ export function createMainProcessRecoveryJournal( }; } -export async function presentPendingMainProcessRecovery( - journal: Pick, - present: (evidence: MainProcessRecoveryEvidence) => Promise, - onError: (error: unknown) => void, -): Promise { - const evidence = journal.pending; - if (!evidence) return; - try { - await present(evidence); - journal.discardPending(); - } catch (error) { - onError(error); - } -} - export function appendUncaughtMainProcessError( logs: { append(level: 'error', message: string, capturedAt?: Date): void }, journal: Pick, diff --git a/apps/desktop/src/main/main-renderer-loader.ts b/apps/desktop/src/main/main-renderer-loader.ts new file mode 100644 index 0000000000..5b97a43564 --- /dev/null +++ b/apps/desktop/src/main/main-renderer-loader.ts @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export interface MainRendererWindow { + loadFile(path: string): Promise; + loadURL(url: string): Promise; +} + +export interface MainRendererEntry { + readonly filePath: string; + readonly url: string; + readonly useDevServer: boolean; +} + +export function resolveMainRendererEntry( + mainModuleDirectory: string, + viteDevServerUrl: string | undefined, +): MainRendererEntry { + const rendererEntryPath = join( + mainModuleDirectory, + '..', + '..', + 'dist-renderer', + 'index.html', + ); + const rendererEntryUrl = viteDevServerUrl ?? pathToFileURL(rendererEntryPath).href; + return Object.freeze({ + filePath: rendererEntryPath, + url: rendererEntryUrl, + useDevServer: !!viteDevServerUrl, + }); +} + +export async function loadMainRenderer( + mainWindow: MainRendererWindow, + rendererEntry: MainRendererEntry, +): Promise { + if (rendererEntry.useDevServer) { + await mainWindow.loadURL(rendererEntry.url); + } else { + await mainWindow.loadFile(rendererEntry.filePath); + } +} diff --git a/apps/desktop/src/main/main-renderer-process-gone.ts b/apps/desktop/src/main/main-renderer-process-gone.ts index 4ee6904a5b..a2e410dae0 100644 --- a/apps/desktop/src/main/main-renderer-process-gone.ts +++ b/apps/desktop/src/main/main-renderer-process-gone.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { RenderProcessGoneDetails } from 'electron'; +import type { Event, RenderProcessGoneDetails } from 'electron'; interface RenderProcessGoneSource { once( @@ -26,6 +26,52 @@ interface RenderProcessGoneSource { ): void; } +interface MainRendererReloadSource { + once(event: 'unresponsive', listener: () => void): void; + once( + event: 'render-process-gone', + listener: (event: Event, details: RenderProcessGoneDetails) => void, + ): void; + once(event: 'destroyed', listener: () => void): void; + on( + event: 'did-fail-load', + listener: ( + event: Event, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean, + frameProcessId: number, + frameRoutingId: number, + ) => void, + ): void; + off(event: 'unresponsive', listener: () => void): void; + off( + event: 'render-process-gone', + listener: (event: Event, details: RenderProcessGoneDetails) => void, + ): void; + off(event: 'destroyed', listener: () => void): void; + off( + event: 'did-fail-load', + listener: ( + event: Event, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean, + frameProcessId: number, + frameRoutingId: number, + ) => void, + ): void; + isDestroyed(): boolean; + reload(): void; +} + +export interface MainRendererFrameIdentity { + readonly processId: number; + readonly frameToken: string; +} + export function observeMainRendererProcessGone(deps: { readonly source: RenderProcessGoneSource; readonly shutdownSignal: AbortSignal; @@ -36,3 +82,99 @@ export function observeMainRendererProcessGone(deps: { deps.onUnexpectedExit(details); }); } + +/** + * Waits for a crashed main Renderer to report its first committed paint before + * recovery is successful. The ordinary one-shot crash observer is re-armed + * synchronously by `onReady`, leaving no successful-recovery gap unobserved. + */ +export function reloadMainRendererProcess(deps: { + readonly source: MainRendererReloadSource; + readonly shutdownSignal: AbortSignal; + readonly subscribeMainFrameCommitted: ( + listener: (frame: MainRendererFrameIdentity) => void, + ) => () => void; + readonly subscribeRendererReady: ( + listener: (frame: MainRendererFrameIdentity) => boolean, + ) => () => void; + readonly onReady: () => void; + readonly timeoutMs?: number; +}): Promise { + if (deps.shutdownSignal.aborted || deps.source.isDestroyed()) { + return Promise.resolve(false); + } + return new Promise((resolve) => { + let settled = false; + let timeout: ReturnType | undefined; + let committedFrame: MainRendererFrameIdentity | undefined; + let unsubscribeMainFrameCommitted = (): void => {}; + let unsubscribeRendererReady = (): void => {}; + const cleanup = (): void => { + if (timeout) clearTimeout(timeout); + unsubscribeMainFrameCommitted(); + unsubscribeRendererReady(); + deps.source.off('did-fail-load', onFailed); + deps.source.off('unresponsive', onUnresponsive); + deps.source.off('render-process-gone', onGone); + deps.source.off('destroyed', onDestroyed); + deps.shutdownSignal.removeEventListener('abort', onAborted); + }; + const settle = (loaded: boolean): void => { + if (settled) return; + settled = true; + cleanup(); + resolve(loaded); + }; + const onRendererReady = (frame: MainRendererFrameIdentity): boolean => { + if (!sameFrame(frame, committedFrame)) return false; + try { + deps.onReady(); + settle(true); + return true; + } catch { + settle(false); + return false; + } + }; + const onFailed = ( + _event: Event, + _errorCode: number, + _errorDescription: string, + _validatedURL: string, + isMainFrame: boolean, + ): void => { + if (isMainFrame) settle(false); + }; + const onUnresponsive = (): void => settle(false); + const onGone = (_event: Event, _details: RenderProcessGoneDetails): void => settle(false); + const onDestroyed = (): void => settle(false); + const onAborted = (): void => settle(false); + + unsubscribeMainFrameCommitted = deps.subscribeMainFrameCommitted((frame) => { + committedFrame = frame; + }); + unsubscribeRendererReady = deps.subscribeRendererReady(onRendererReady); + deps.source.on('did-fail-load', onFailed); + deps.source.once('unresponsive', onUnresponsive); + deps.source.once('render-process-gone', onGone); + deps.source.once('destroyed', onDestroyed); + deps.shutdownSignal.addEventListener('abort', onAborted, { once: true }); + timeout = setTimeout(() => settle(false), deps.timeoutMs ?? 30_000); + try { + deps.source.reload(); + } catch { + settle(false); + } + }); +} + +function sameFrame( + actual: MainRendererFrameIdentity, + expected: MainRendererFrameIdentity | undefined, +): boolean { + if (!expected) return false; + return ( + actual.processId === expected.processId && + actual.frameToken === expected.frameToken + ); +} diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index fd21973268..a2c61a5473 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -17,11 +17,10 @@ * under the License. */ -import { app, BrowserWindow, dialog, nativeTheme, screen, shell } from 'electron'; +import { app, BrowserWindow, dialog, nativeTheme, screen, shell, webFrameMain } from 'electron'; import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import type { AppSettings } from '@maka/core/settings'; +import { appIconForTheme, type AppSettings } from '@maka/core/settings'; import { readableAppIconPath } from './app-icon-surface.js'; import { isExternalUrl } from './external-link-guard.js'; import { readSavedBounds, writeSavedBounds, SAFE_MIN_HEIGHT, SAFE_MIN_WIDTH, type SavedBounds } from './window-state.js'; @@ -29,8 +28,13 @@ import { BrowserViewController } from './browser/controller.js'; import { BrowserViewManager } from './browser/view-manager.js'; import type { E2eFixture } from './e2e-fixture.js'; import { installMainWindowPermissionPolicy } from './main-window-permission-policy.js'; -import { observeMainRendererProcessGone } from './main-renderer-process-gone.js'; -import { isThemePreference, toNativeThemeSource } from './theme-source.js'; +import { loadMainRenderer, resolveMainRendererEntry } from './main-renderer-loader.js'; +import { + type MainRendererFrameIdentity, + observeMainRendererProcessGone, + reloadMainRendererProcess, +} from './main-renderer-process-gone.js'; +import { isDarkAppearance, isThemePreference, toNativeThemeSource } from './theme-source.js'; import { createWindowRevealGate } from './window-reveal.js'; import { createWindowsMaximizeRendererSync } from './windows-maximize-renderer-sync.js'; import { @@ -43,10 +47,18 @@ type SettingsReader = { export interface MainWindowController { createWindow(signal: AbortSignal): Promise; + /** + * Reload only the crashed main Renderer, preserving the Desktop process, + * Runtime Host, background services, and current BrowserWindow. + */ + reloadMainRenderer(): Promise; send(channel: string, ...args: unknown[]): void; // PR-SHOW-AFTER-FIRST-COMMIT: reveal the hidden window after the renderer's // first React commit. Idempotent + e2e-fixture-safe (see notifyRendererReady). - notifyRendererReady(): void; + notifyRendererReady( + sender: Electron.WebContents, + senderFrame: Electron.WebFrameMain | null, + ): void; setTitlebarControlsVisible(sender: Electron.WebContents, visible: unknown): void; setThemeSource(sender: Electron.WebContents, themePref: unknown): void; setTitleBarOverlayTheme(sender: Electron.WebContents, theme: unknown): void; @@ -170,12 +182,45 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // skeleton anyway. The gate defers those focus requests until markReady. const revealGate = createWindowRevealGate(keepHiddenForE2eFixture); let showFallbackTimer: NodeJS.Timeout | undefined; + let rendererRecoveryReadiness: + | { + readonly contents: Electron.WebContents; + listener?: (frame: MainRendererFrameIdentity) => boolean; + } + | undefined; + let mainWindowShutdownSignal: AbortSignal | undefined; const clearShowFallbackTimer = (): void => { if (showFallbackTimer) { clearTimeout(showFallbackTimer); showFallbackTimer = undefined; } }; + const armShowFallbackTimer = (target: BrowserWindow): void => { + clearShowFallbackTimer(); + if (keepHiddenForE2eFixture || target.isDestroyed() || target.isVisible()) return; + showFallbackTimer = setTimeout(() => { + showFallbackTimer = undefined; + if (!target.isDestroyed()) revealGate.markReady(target); + }, SHOW_FALLBACK_TIMEOUT_MS); + }; + + const observeRendererProcess = (target: BrowserWindow, signal: AbortSignal): void => { + observeMainRendererProcessGone({ + source: target.webContents, + shutdownSignal: signal, + onUnexpectedExit: (details) => { + console.error( + `[renderer] main Renderer process exited unexpectedly: reason=${details.reason} exitCode=${details.exitCode}`, + ); + void Promise.resolve() + .then(() => deps.onRendererProcessGone(details)) + .catch((error) => { + console.error('[renderer] failed to handle main Renderer process exit:', error); + app.quit(); + }); + }, + }); + }; function getBrowserViews(): BrowserViewManager { if (!browserViews) { @@ -256,9 +301,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // renderer to resources that teardown has already started closing. if (signal.aborted) return; const themePref = e2eFixture?.theme ?? persistedTheme; - const isDark = - themePref === 'dark' || - (themePref === 'auto' && nativeTheme.shouldUseDarkColors); + const isDark = isDarkAppearance(themePref, nativeTheme.shouldUseDarkColors); const initialBg = isDark ? '#1c1d21' : '#ffffff'; // Astro-Han review (#493): sync nativeTheme here too, not only via the // renderer's later setThemeSource() IPC call -- otherwise the vibrancy @@ -267,15 +310,10 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // disagrees with the persisted in-app preference. nativeTheme.themeSource = toNativeThemeSource(themePref); - const rendererEntryPath = join( + const rendererEntry = resolveMainRendererEntry( import.meta.dirname, - '..', - '..', - 'dist-renderer', - 'index.html', + process.env.VITE_DEV_SERVER_URL, ); - const rendererEntryUrl = process.env.VITE_DEV_SERVER_URL - ?? pathToFileURL(rendererEntryPath).href; // Re-arm the reveal gate for this window's lifecycle (macOS keeps the app // alive after close-all; the next createWindow starts hidden again and a @@ -296,7 +334,13 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // Windows and Linux draw this icon per window, so a window opened after // the user switched icons must be born with the chosen one — waiting for // the client-settings effect to catch up would show the default first. - icon: readableAppIconPath(persistedAppearance?.appIcon), + // Resolved for this window's appearance, not just the stored choice: + // Windows and Linux draw the icon per window, so a window opened while + // dark is in effect must be born with the dark tile — otherwise it keeps + // the light one until something else triggers a re-apply. + icon: readableAppIconPath( + appIconForTheme(persistedAppearance ?? {}, isDark), + ), // PR-WINDOW-TITLEBAR-0: hide the native title bar so the renderer // chrome can extend to the top edge on every platform. macOS keeps // `hiddenInset` + traffic-light buttons (top-left); Windows uses @@ -375,22 +419,9 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main allowRunningInsecureContent: false, }, }); - observeMainRendererProcessGone({ - source: mainWindow.webContents, - shutdownSignal: signal, - onUnexpectedExit: (details) => { - console.error( - `[renderer] main Renderer process exited unexpectedly: reason=${details.reason} exitCode=${details.exitCode}`, - ); - void Promise.resolve() - .then(() => deps.onRendererProcessGone(details)) - .catch((error) => { - console.error('[renderer] failed to handle main Renderer process exit:', error); - app.quit(); - }); - }, - }); - installMainWindowPermissionPolicy(mainWindow.webContents, rendererEntryUrl); + mainWindowShutdownSignal = signal; + observeRendererProcess(mainWindow, signal); + installMainWindowPermissionPolicy(mainWindow.webContents, rendererEntry.url); // Two-layer external-link hygiene: assistant markdown often emits `
` // links to docs / GitHub / provider sign-up pages. Without these guards @@ -492,11 +523,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main void writeSavedBounds(workspaceRoot, final); }); - if (process.env.VITE_DEV_SERVER_URL) { - await mainWindow.loadURL(rendererEntryUrl); - } else { - await mainWindow.loadFile(rendererEntryPath); - } + await loadMainRenderer(mainWindow, rendererEntry); // PR-SHOW-AFTER-FIRST-COMMIT: reveal fallback. Start this budget only once // the renderer document has loaded. Starting it before loadURL/loadFile @@ -505,12 +532,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // If renderer-ready arrived while loadURL/loadFile was resolving, the // window is already visible and no timer is needed. E2e-fixture windows // remain hidden for their whole lifecycle. - if (!keepHiddenForE2eFixture && !mainWindow.isVisible()) { - showFallbackTimer = setTimeout(() => { - showFallbackTimer = undefined; - revealGate.markReady(mainWindow); - }, SHOW_FALLBACK_TIMEOUT_MS); - } + armShowFallbackTimer(mainWindow); if (process.env.MAKA_REAL_WINDOW_SMOKE === '1') { emitRealWindowSmokeDiagnostic('after-load'); setTimeout(() => emitRealWindowSmokeDiagnostic('settled-1000ms'), 1000); @@ -519,8 +541,77 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main return { createWindow, + async reloadMainRenderer() { + const target = mainWindow; + const signal = mainWindowShutdownSignal; + if ( + !target || + target.isDestroyed() || + target.webContents.isDestroyed() || + !signal || + signal.aborted + ) return false; + clearShowFallbackTimer(); + revealGate.reset(); + target.hide(); + const contents = target.webContents; + const readiness: { + readonly contents: Electron.WebContents; + listener?: (frame: MainRendererFrameIdentity) => boolean; + } = { contents }; + rendererRecoveryReadiness = readiness; + let loaded = false; + try { + loaded = await reloadMainRendererProcess({ + source: contents, + shutdownSignal: signal, + subscribeMainFrameCommitted: (listener) => { + const onFrameNavigated = ( + _event: Electron.Event, + _url: string, + _httpResponseCode: number, + _httpStatusText: string, + isMainFrame: boolean, + frameProcessId: number, + frameRoutingId: number, + ): void => { + if (!isMainFrame) return; + const frame = webFrameMain.fromId(frameProcessId, frameRoutingId); + if (frame) listener(rendererFrameIdentity(frame)); + }; + contents.on('did-frame-navigate', onFrameNavigated); + return () => contents.off('did-frame-navigate', onFrameNavigated); + }, + subscribeRendererReady: (listener) => { + readiness.listener = listener; + return () => { + if (readiness.listener === listener) readiness.listener = undefined; + }; + }, + onReady: () => { + // The previous one-shot observer was consumed by the crash. Re-arm + // it before successful recovery is exposed to the caller. + observeRendererProcess(target, signal); + }, + }); + if (!loaded) console.error('[renderer] main Renderer reload did not become ready'); + return loaded; + } finally { + if (rendererRecoveryReadiness === readiness) { + if (loaded) rendererRecoveryReadiness = undefined; + else readiness.listener = undefined; + } + } + }, send: safeSendToRenderer, - notifyRendererReady() { + notifyRendererReady(sender, senderFrame) { + if (!mainWindow || mainWindow.isDestroyed() || sender !== mainWindow.webContents) return; + const recovery = rendererRecoveryReadiness; + if (recovery?.contents === sender) { + // A failed attempt stays as a tombstone, and a retry accepts ready only + // from the main frame committed by that exact reload navigation. + if (!senderFrame || !recovery.listener?.(rendererFrameIdentity(senderFrame))) return; + } // PR-SHOW-AFTER-FIRST-COMMIT: the renderer finished its first React // commit. Cancel the fallback timer and reveal the window through the // shared gate — idempotent, so an HMR reload re-firing this signal (or a @@ -610,6 +701,13 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main }; } +function rendererFrameIdentity(frame: Electron.WebFrameMain): MainRendererFrameIdentity { + return { + processId: frame.processId, + frameToken: frame.frameToken, + }; +} + function isTitleBarOverlayTheme(value: unknown): value is { isDark: boolean; backgroundColor: string } { if (!value || typeof value !== 'object') return false; const candidate = value as { isDark?: unknown; backgroundColor?: unknown }; diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 3068460b46..8cea0b9d1d 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -23,9 +23,10 @@ import { developmentLaunchResultFile, shouldShowLoserDialog, } from '@maka/core/dev-single-instance'; -import { app, clipboard, dialog } from 'electron'; +import { app, clipboard, dialog, ipcMain } from 'electron'; import { join } from 'node:path'; import { resolveBuildInfo } from './build-info.js'; +import { resolveUpdateTestUserDataDirectory } from './app-update-test-context.js'; import { captureDesktopDiagnosticEnvironment, copyDesktopDiagnosticReport, @@ -36,15 +37,13 @@ import { import { appendUncaughtMainProcessError, createMainProcessRecoveryJournal, - presentPendingMainProcessRecovery, type MainProcessRecoveryJournal, } from './main-process-recovery-journal.js'; -import { - showFatalStartupError, - showPreviousMainProcessInterruptionDialog, -} from './native-diagnostic-dialog.js'; +import { showFatalStartupError } from './native-diagnostic-dialog.js'; import { isIsolatedE2e } from './startup-context.js'; import { reportDevelopmentLaunchResult } from './dev-single-instance-result.js'; +import { registerPreviousMainProcessDiagnosticsIpc } from './desktop-diagnostics-ipc-main.js'; +import { showBrowserMessageBox } from './browser-message-box.js'; let recoveryJournal: MainProcessRecoveryJournal | undefined; installMainProcessLogCapture(mainProcessLogBuffer, () => recoveryJournal?.markDirty()); @@ -59,6 +58,21 @@ installMainProcessLogCapture(mainProcessLogBuffer, () => recoveryJournal?.markDi // path logic. See https://github.com/maka-agent/maka-agent/issues/2252. app.setName(app.isPackaged ? 'Maka' : 'Maka Dev'); +// Electron otherwise quits implicitly when the last BrowserWindow closes. +// Startup and fatal-recovery surfaces can be the only window, so keep process +// lifetime explicit; runtime-host-boot installs the normal platform policy +// after startup, and every early terminal path calls app.exit itself. +app.on('window-all-closed', () => {}); + +const updateTestUserData = resolveUpdateTestUserDataDirectory({ + feedUrl: process.env.MAKA_UPDATE_TEST_FEED, + explicitDirectory: process.env.MAKA_UPDATE_TEST_USER_DATA_DIR, + isPackaged: app.isPackaged, + appPath: app.getAppPath(), + executablePath: process.execPath, +}); +if (updateTestUserData) app.setPath('userData', updateTestUserData); + // E2E isolation: redirect userData BEFORE the single-instance lock so the // lock judges the throwaway dir, not the real user data — otherwise a // developer with Maka open makes the E2E process exit as a "second instance". @@ -69,9 +83,9 @@ if (isIsolatedE2e && process.env.MAKA_E2E_USER_DATA_DIR) { } // Electron does not enforce single-instance by default. Must run before any -// workspace/store setup below -- a losing second process exits immediately, -// before touching shared state. See the 'second-instance' listener in -// runtime-host-boot.ts for what the surviving process does about it. +// workspace/store setup below -- a losing second process never touches shared +// state. See the 'second-instance' listener in runtime-host-boot.ts for what +// the surviving process does about it. if (!app.requestSingleInstanceLock()) { if (!app.isPackaged) { // Dev: losing the lock must NOT pretend to have started (exit 0 would be @@ -80,18 +94,46 @@ if (!app.requestSingleInstanceLock()) { // one-shot result file. A direct launcher explicitly promises to consume // the exit code; a TCC launcher proves it has a consumer only when the // result write succeeds. Any other entry (Dock, Spotlight, Quit & Reopen) - // gets a native box — fail toward the dialog. Linux pre-ready showErrorBox - // degrades to stderr (no GUI); documented in electron.d.ts. Packaged builds - // keep the existing UX (double-click focuses the first window) — the gate - // is a semantic boundary. + // waits for ready and gets the same product-styled temporary window as + // startup recovery. Packaged builds keep the existing UX (double-click + // focuses the first window) — the gate is a semantic boundary. const resultReported = reportDevelopmentLaunchResult(process.argv, { status: 'loser' }); if (!resultReported && shouldShowLoserDialog(process.argv)) { - dialog.showErrorBox( - 'Maka Dev', - `Another instance holds the Maka Dev profile (${app.getPath('userData')}). Quit it and retry.`, - ); - } - app.exit(DEV_LOSER_EXIT_CODE); + const profilePath = app.getPath('userData'); + void app + .whenReady() + .then(() => { + const locale = resolveSystemUiLocale(app.getPreferredSystemLanguages()); + const isChinese = locale === 'zh'; + return showBrowserMessageBox( + { + type: 'warning', + title: isChinese ? 'Maka Dev 已在运行' : 'Maka Dev is already running', + message: isChinese + ? '另一个 Maka Dev 实例正在使用此开发配置。' + : 'Another Maka Dev instance is using this development profile.', + detail: isChinese + ? `开发配置:${profilePath}\n\n请先退出正在运行的实例,然后重试。` + : `Development profile: ${profilePath}\n\nQuit the running instance, then retry.`, + buttons: [isChinese ? '退出' : 'Exit'], + defaultId: 0, + cancelId: 0, + }, + undefined, + { locale }, + ); + }) + .catch((error) => { + console.error('[dev] styled single-instance dialog failed:', error); + dialog.showErrorBox( + 'Maka Dev', + `Another instance holds the Maka Dev profile (${profilePath}). Quit it and retry.`, + ); + }) + .finally(() => { + app.exit(DEV_LOSER_EXIT_CODE); + }); + } else app.exit(DEV_LOSER_EXIT_CODE); } else { app.exit(0); } @@ -125,6 +167,24 @@ if (!app.requestSingleInstanceLock()) { } catch (error) { console.error('[diagnostics] main-process recovery unavailable:', error); } + if (isIsolatedE2e) recoveryJournal?.discardPending(); + registerPreviousMainProcessDiagnosticsIpc({ + ipcMain, + evidence: isIsolatedE2e ? undefined : recoveryJournal?.pending, + acknowledge: () => recoveryJournal?.discardPending(), + environment: () => + captureDesktopDiagnosticEnvironment({ + appVersion: app.getVersion(), + buildMode: buildInfo.mode, + buildCommit: buildInfo.commit, + locale: app.getLocale(), + workspacePath: join(app.getPath('userData'), 'workspaces', 'default'), + }), + mainLogs: () => mainProcessLogBuffer.snapshot(), + resolveActiveRuntimeHost: () => undefined, + resolveRuntimeHost: () => undefined, + writeClipboard: (report) => clipboard.writeText(report), + }); // The full boot must not run in the top-level module-evaluation chain: // Electron ESM emits `ready` only after the entry module finishes // evaluating, so a top-level `await app.whenReady()` (which the @@ -134,47 +194,8 @@ if (!app.requestSingleInstanceLock()) { // store/db write". app .whenReady() - .then(async () => { + .then(() => { console.log('[startup] app ready'); - const journal = recoveryJournal; - if (journal?.pending) { - if (isIsolatedE2e) { - journal.discardPending(); - } else { - await presentPendingMainProcessRecovery( - journal, - (pending) => - showPreviousMainProcessInterruptionDialog({ - locale: resolveSystemUiLocale(app.getPreferredSystemLanguages()), - copyDiagnostics: () => - copyDesktopDiagnosticReport( - { - environment: () => - captureDesktopDiagnosticEnvironment({ - appVersion: app.getVersion(), - buildMode: buildInfo.mode, - buildCommit: buildInfo.commit, - locale: app.getLocale(), - workspacePath: join( - app.getPath('userData'), - 'workspaces', - 'default', - ), - }), - mainLogs: () => mainProcessLogBuffer.snapshot(), - resolveActiveRuntimeHost: () => undefined, - resolveRuntimeHost: () => undefined, - writeClipboard: (report) => clipboard.writeText(report), - }, - createDesktopPreviousMainProcessDiagnosticInput(pending), - ), - showMessageBox: (options) => dialog.showMessageBox(options), - }), - (error) => - console.error('[diagnostics] previous-session recovery dialog failed:', error), - ); - } - } return import('./runtime-host-boot.js'); }) .catch(async (error: unknown) => { @@ -184,8 +205,9 @@ if (!app.requestSingleInstanceLock()) { // fixture-fatal path in runtime-host-boot.ts: print a parseable line and exit fast). if (!isIsolatedE2e) { const buildInfo = resolveBuildInfo(app.isPackaged, app.getAppPath()); + const locale = resolveSystemUiLocale(app.getPreferredSystemLanguages()); await showFatalStartupError(error, { - locale: resolveSystemUiLocale(app.getPreferredSystemLanguages()), + locale, environment: () => captureDesktopDiagnosticEnvironment({ appVersion: app.getVersion(), @@ -196,7 +218,8 @@ if (!app.requestSingleInstanceLock()) { }), mainLogs: () => mainProcessLogBuffer.snapshot(), writeClipboard: (report) => clipboard.writeText(report), - showMessageBox: (options) => dialog.showMessageBox(options), + showMessageBox: (options) => + showBrowserMessageBox(options, undefined, { locale }), }); } } finally { diff --git a/apps/desktop/src/main/mcp-ipc-main.ts b/apps/desktop/src/main/mcp-ipc-main.ts index b958ad1961..0cb59d44fe 100644 --- a/apps/desktop/src/main/mcp-ipc-main.ts +++ b/apps/desktop/src/main/mcp-ipc-main.ts @@ -20,14 +20,22 @@ import type { IpcMain } from 'electron'; import { MCP_CONFIG_VERSION, - isMcpStdioConfig, + mcpConfigChangeRetiresCredentials, type McpConfigAddResult, type McpConfigFile, + type McpConfigImportResult, type McpServerConfig, type McpServerStatus, } from '@maka/core/mcp'; import type { McpClientManager } from '@maka/mcp'; -import { McpServerExistsError, normalizeMcpConfig, type McpConfigStore } from '@maka/storage'; +import { + assertMcpEndpointPolicyOnChanges, + McpServerExistsError, + McpConfigSourceError, + normalizeMcpConfig, + normalizeMcpImport, + type McpConfigStore, +} from '@maka/storage/mcp-config-store'; import type { McpOAuthController } from './mcp-oauth-controller.js'; import { redactMcpConfigSecrets, @@ -86,53 +94,45 @@ export function registerMcpIpcMain(deps: McpIpcMainDeps): void { ); } }; - // Every config mutation is one transaction on one lane: + // Every config mutation is one transaction on one lane and one shared file + // lock: // read the authoritative snapshot → restore sentinels and apply the // active-login gate against it → erase the credentials this commit // orphans (removed servers, repointed endpoints) → persist. - // The credential erasure is asynchronous, so it cannot live inside the - // store's synchronous transform; the lane serializes the whole sequence - // instead, and the final transform still fails closed if the snapshot - // drifted under an out-of-band writer. Credentials go first so a failed - // erase aborts the commit while everything is still configured and - // retryable — never a persisted removal whose token a same-id re-add - // could inherit after a restart. + // The lane also excludes Desktop OAuth claims; the store transaction makes + // the same sequence linearizable against TUI and other process writers. + // Credentials go first so a failed erase aborts the commit while everything + // is still configured and retryable — never a persisted removal whose token + // a same-id re-add could inherit after a restart. const inMutationLane = deps.exclusiveLane ?? createMcpExclusiveLane(); const commitConfig = async ( mutate: (current: McpConfigFile) => McpConfigFile, - ): Promise => { - const current = await deps.store.get(); - const next = mutate(current); - // The authoritative gate: every server this commit semantically touches - // is re-checked INSIDE the lane. The handler-entry checks are advisory - // fast-fails; this one cannot race a login claim, because claims travel - // the same lane. - for (const serverId of new Set([ - ...Object.keys(current.mcpServers), - ...Object.keys(next.mcpServers), - ])) { - const before = current.mcpServers[serverId]; - const after = next.mcpServers[serverId]; - if (JSON.stringify(before) !== JSON.stringify(after)) assertNoActiveLogin(serverId); - } - // Erases are per-server and not transactional as a set: if one fails - // partway, the commit aborts with the EARLIER servers already logged - // out. That partial effect is deliberately in the fail-closed direction - // — a re-login is recoverable, a credential outliving its removed or - // repointed config is not. - for (const serverId of credentialRetirements(current, next)) { - await deps.manager.forgetServerCredentials(serverId); - } - const snapshot = JSON.stringify(current); - return deps.store.transform((actual) => { - if (JSON.stringify(actual) !== snapshot) { - throw new Error( - 'MCP configuration changed while this update was being prepared — retry the operation', - ); + ): Promise => + deps.store.transform(async (current) => { + const next = mutate(current); + assertMcpEndpointPolicyOnChanges(current, next); + // The authoritative gate: every server this commit semantically touches + // is re-checked INSIDE the lane. The handler-entry checks are advisory + // fast-fails; this one cannot race a login claim, because claims travel + // the same lane. + for (const serverId of new Set([ + ...Object.keys(current.mcpServers), + ...Object.keys(next.mcpServers), + ])) { + const before = current.mcpServers[serverId]; + const after = next.mcpServers[serverId]; + if (JSON.stringify(before) !== JSON.stringify(after)) assertNoActiveLogin(serverId); + } + // Erases are per-server and not transactional as a set: if one fails + // partway, the commit aborts with the EARLIER servers already logged + // out. That partial effect is deliberately in the fail-closed direction + // — a re-login is recoverable, a credential outliving its removed or + // repointed config is not. + for (const serverId of credentialRetirements(current, next)) { + await deps.manager.forgetServerCredentials(serverId); } return next; }); - }; // The renderer is semi-trusted (SECURITY.md §3): every config that crosses // toward it leaves with clientSecret replaced by the sentinel, and every // config it sends back has sentinels restored from disk before the store @@ -145,19 +145,38 @@ export function registerMcpIpcMain(deps: McpIpcMainDeps): void { await deps.ensureReady(); return deps.manager.statuses(); }); - deps.ipcMain.handle('mcp:setConfig', async (_event, config: McpConfigFile) => - inMutationLane(async () => { - // Sentinels restore BEFORE the gate compares anything: the renderer's - // copy of an untouched secret-bearing server still carries sentinels, - // and comparing those against the stored real values would make every - // such server look modified — vetoing a bulk edit that only touches - // an unrelated server while some other login runs. commitConfig's - // in-lane gate does the semantic comparison on the restored config. - const next = await commitConfig((current) => restoreMcpConfigSecrets(config, current)); - await deps.manager.sync(next); - changed(deps); - return redactMcpConfigSecrets(next); - }), + deps.ipcMain.handle( + 'mcp:importConfig', + async (_event, source: string): Promise => { + let imported: McpConfigFile; + try { + imported = normalizeMcpImport(source); + } catch (error) { + if (error instanceof McpConfigSourceError) { + return { + status: 'invalid', + reason: error.reason, + ...(error.version === undefined ? {} : { version: error.version }), + }; + } + throw error; + } + const importedCount = Object.keys(imported.mcpServers).length; + return inMutationLane(async () => { + const next = await commitConfig((current) => + restoreMcpConfigSecrets( + { + version: MCP_CONFIG_VERSION, + mcpServers: { ...current.mcpServers, ...imported.mcpServers }, + }, + current, + ), + ); + await deps.manager.sync(next); + changed(deps); + return { status: 'imported', config: redactMcpConfigSecrets(next), importedCount }; + }); + }, ); deps.ipcMain.handle( 'mcp:add', @@ -343,12 +362,7 @@ function credentialRetirements(current: McpConfigFile, next: McpConfigFile): str const incoming = Object.hasOwn(next.mcpServers, serverId) ? next.mcpServers[serverId] : undefined; - if (!incoming) { - retired.push(serverId); - continue; - } - if (isMcpStdioConfig(server)) continue; - if (isMcpStdioConfig(incoming) || incoming.url !== server.url) retired.push(serverId); + if (mcpConfigChangeRetiresCredentials(server, incoming)) retired.push(serverId); } return retired; } diff --git a/apps/desktop/src/main/native-diagnostic-dialog-copy.ts b/apps/desktop/src/main/native-diagnostic-dialog-copy.ts new file mode 100644 index 0000000000..c671249561 --- /dev/null +++ b/apps/desktop/src/main/native-diagnostic-dialog-copy.ts @@ -0,0 +1,174 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; + +interface NativeDiagnosticDialogCopy { + readonly dialog: { + readonly copy: string; + readonly copyAgain: string; + readonly copied: string; + readonly copyFailed: string; + }; + readonly fatalStartup: { + readonly title: string; + readonly message: string; + readonly detail: string; + readonly exit: string; + }; + readonly rendererGone: { + readonly title: string; + readonly message: string; + readonly detail: string; + readonly recover: string; + readonly exit: string; + }; + readonly runtimeHostRecovery: { + readonly title: string; + readonly message: string; + readonly detail: string; + readonly activeTasks: string; + readonly repairFailed: string; + readonly repair: string; + readonly repairAndRestart: string; + readonly exit: string; + }; + readonly defaultRuntimeHostRecovery: { + readonly title: string; + connectFailed(profileName: string): string; + readonly detail: string; + readonly retry: string; + readonly useLocal: string; + readonly keepOffline: string; + }; + readonly storageRootRepair: { + readonly title: string; + readonly message: string; + detail(workspaceRoot: string): string; + readonly repair: string; + readonly exit: string; + }; +} + +const COPY = { + en: { + dialog: { + copy: 'Copy Diagnostics', + copyAgain: 'Copy Again', + copied: 'Diagnostics copied. You can paste them into an issue report.', + copyFailed: 'Could not copy diagnostics.', + }, + fatalStartup: { + title: 'Maka failed to start', + message: 'Maka could not finish starting.', + detail: 'An unexpected startup error occurred. Copy diagnostics to inspect the details.', + exit: 'Exit', + }, + rendererGone: { + title: 'Maka needs to recover', + message: "Maka's interface stopped unexpectedly.", + detail: + 'Recover the interface without restarting Maka. Runtime Host, running work, and background services will stay in place.', + recover: 'Recover Interface', + exit: 'Exit', + }, + runtimeHostRecovery: { + title: 'Maka needs to repair Runtime Host', + message: 'The Runtime Host for this workspace could not start.', + detail: + 'Maka can repair the managed Runtime Host selected by this Desktop. Your workspace, Host identity, credentials, and settings will be preserved. Repair may replace the installed Host with the version selected for this Desktop even when automatic update compatibility cannot be confirmed.', + activeTasks: + 'The Host may still own active work. Continuing can interrupt that work before the Host restarts.', + repairFailed: 'The previous repair attempt did not finish. Copy diagnostics to inspect the details.', + repair: 'Repair Runtime Host', + repairAndRestart: 'Repair and Restart Host', + exit: 'Exit', + }, + defaultRuntimeHostRecovery: { + title: 'Default Runtime Host is unavailable', + connectFailed: (profileName) => `Could not connect to ${profileName}`, + detail: + 'Retry, use Local as the default Host, or keep the current selection and resolve it later in Settings. Copy diagnostics to inspect the connection failure.', + retry: 'Retry', + useLocal: 'Use Local', + keepOffline: 'Keep Offline', + }, + storageRootRepair: { + title: 'Maka workspace needs repair', + message: 'Maka cannot verify this workspace.', + detail: (workspaceRoot) => + `The disk identity may have changed. Repair only if this is the original Maka workspace on this computer, not a copied workspace.\n\n${workspaceRoot}`, + repair: 'Repair Workspace', + exit: 'Exit', + }, + }, + zh: { + dialog: { + copy: '复制诊断信息', + copyAgain: '再次复制', + copied: '诊断信息已复制,可直接粘贴到问题报告中。', + copyFailed: '无法复制诊断信息。', + }, + fatalStartup: { + title: 'Maka 启动失败', + message: 'Maka 无法完成启动。', + detail: '启动时发生意外错误。复制诊断信息可查看详情。', + exit: '退出', + }, + rendererGone: { + title: 'Maka 需要恢复', + message: 'Maka 界面意外停止运行。', + detail: '只恢复界面,不重启 Maka。Runtime Host、正在运行的工作和后台服务都会保留。', + recover: '恢复界面', + exit: '退出', + }, + runtimeHostRecovery: { + title: 'Maka 需要修复 Runtime Host', + message: '管理此工作区的 Runtime Host 无法启动。', + detail: + 'Maka 可以修复此 Desktop 选择的托管 Runtime Host。工作区、Host 身份、凭证和设置都会保留。即使无法确认自动更新兼容性,修复也可能使用此 Desktop 选择的版本替换当前 Host。', + activeTasks: 'Host 可能仍有正在运行的任务。继续会先中断这些任务,再重启 Host。', + repairFailed: '上一次修复未能完成。复制诊断信息可查看详情。', + repair: '修复 Runtime Host', + repairAndRestart: '修复并重启 Host', + exit: '退出', + }, + defaultRuntimeHostRecovery: { + title: '默认 Runtime Host 无法连接', + connectFailed: (profileName) => `无法连接 ${profileName}`, + detail: + '你可以重试、改用 Local 作为默认 Host,或保持当前选择并稍后在设置中处理。复制诊断信息可查看连接失败详情。', + retry: '重试', + useLocal: '改用 Local', + keepOffline: '保持离线', + }, + storageRootRepair: { + title: 'Maka 工作区需要修复', + message: 'Maka 无法验证这个工作区。', + detail: (workspaceRoot) => + `系统中的磁盘标识可能发生了变化。仅当这是本机原来的 Maka 工作区、而不是复制出的工作区时,才选择修复。\n\n${workspaceRoot}`, + repair: '修复工作区', + exit: '退出', + }, + }, +} satisfies UiCatalog; + +export function getNativeDiagnosticDialogCopy(locale: UiLocale): NativeDiagnosticDialogCopy { + return COPY[locale]; +} diff --git a/apps/desktop/src/main/native-diagnostic-dialog.ts b/apps/desktop/src/main/native-diagnostic-dialog.ts index 2491326731..231c30a2e1 100644 --- a/apps/desktop/src/main/native-diagnostic-dialog.ts +++ b/apps/desktop/src/main/native-diagnostic-dialog.ts @@ -27,6 +27,7 @@ import { formatDesktopDiagnosticReport, type DesktopDiagnosticEnvironment, } from './main-process-diagnostics.js'; +import { getNativeDiagnosticDialogCopy } from './native-diagnostic-dialog-copy.js'; import { whileAwaitingPerson } from './startup-step.js'; interface DiagnosticDialogDeps { @@ -43,6 +44,33 @@ interface FatalStartupDiagnosticDialogDeps { readonly showMessageBox: (options: MessageBoxOptions) => Promise; } +export interface RuntimeHostStartupRecoveryDialogInput { + readonly startupError: Error; + readonly repairError?: Error; + readonly activeTasks: boolean; +} + +export function defaultRuntimeHostRecoveryDialog(input: { + readonly locale: UiLocale; + readonly profileName: string; + readonly error: Error; +}): { readonly options: MessageBoxOptions; readonly diagnosticDetails: string } { + const copy = getNativeDiagnosticDialogCopy(input.locale).defaultRuntimeHostRecovery; + return { + options: { + type: 'warning', + title: copy.title, + message: copy.connectFailed(input.profileName), + detail: copy.detail, + buttons: [copy.retry, copy.useLocal, copy.keepOffline], + defaultId: 0, + cancelId: 2, + noLink: true, + }, + diagnosticDetails: input.error.stack ?? `${input.error.name}: ${input.error.message}`, + }; +} + export async function showMessageBoxWithDiagnostics( options: MessageBoxOptions, deps: DiagnosticDialogDeps, @@ -60,9 +88,10 @@ export async function showFatalStartupError( error: unknown, deps: FatalStartupDiagnosticDialogDeps, ): Promise { - const copy = FATAL_STARTUP_COPY[deps.locale]; + const copy = getNativeDiagnosticDialogCopy(deps.locale).fatalStartup; const message = error instanceof Error ? error.message : String(error); const input = createDesktopStartupDiagnosticInput({ + // The diagnostic report is machine-facing and stays English regardless of locale. title: 'Maka failed to start', description: message || 'Unknown startup error', ...(error instanceof Error && error.stack ? { details: error.stack } : {}), @@ -72,7 +101,7 @@ export async function showFatalStartupError( type: 'error', title: copy.title, message: copy.message, - detail: message || copy.unknownError, + detail: copy.detail, buttons: [copy.exit], defaultId: 0, cancelId: 0, @@ -96,41 +125,50 @@ export async function showFatalStartupError( export async function showMainRendererProcessGoneDialog( deps: DiagnosticDialogDeps, -): Promise<'relaunch' | 'exit'> { - const copy = MAIN_RENDERER_GONE_COPY[deps.locale]; +): Promise<'recover' | 'exit'> { + const copy = getNativeDiagnosticDialogCopy(deps.locale).rendererGone; const result = await showMessageBoxWithDiagnostics( { type: 'error', title: copy.title, message: copy.message, detail: copy.detail, - buttons: [copy.relaunch, copy.exit], + buttons: [copy.recover, copy.exit], defaultId: 0, cancelId: 1, noLink: true, }, deps, ); - return result.response === 0 ? 'relaunch' : 'exit'; + return result.response === 0 ? 'recover' : 'exit'; } -export async function showPreviousMainProcessInterruptionDialog( +export async function showRuntimeHostStartupRecoveryDialog( + input: RuntimeHostStartupRecoveryDialogInput, deps: DiagnosticDialogDeps, -): Promise { - const copy = PREVIOUS_MAIN_PROCESS_INTERRUPTION_COPY[deps.locale]; - await showMessageBoxWithDiagnostics( +): Promise<'repair' | 'exit'> { + const copy = getNativeDiagnosticDialogCopy(deps.locale).runtimeHostRecovery; + const detail = [ + copy.detail, + input.activeTasks ? copy.activeTasks : undefined, + input.repairError ? copy.repairFailed : undefined, + ] + .filter(Boolean) + .join('\n\n'); + const result = await showMessageBoxWithDiagnostics( { type: 'warning', title: copy.title, message: copy.message, - detail: copy.detail, - buttons: [copy.continue], - defaultId: 0, - cancelId: 0, + detail, + buttons: [input.activeTasks ? copy.repairAndRestart : copy.repair, copy.exit], + defaultId: input.activeTasks || input.repairError ? 1 : 0, + cancelId: 1, noLink: true, }, deps, ); + return result.response === 0 ? 'repair' : 'exit'; } async function copyDiagnostics( @@ -139,10 +177,10 @@ async function copyDiagnostics( ): Promise { try { await copy(); - return DIALOG_COPY[locale].copied; + return getNativeDiagnosticDialogCopy(locale).dialog.copied; } catch (error) { console.error('[diagnostics] native clipboard write failed:', error); - return DIALOG_COPY[locale].copyFailed; + return getNativeDiagnosticDialogCopy(locale).dialog.copyFailed; } } @@ -155,7 +193,7 @@ function diagnosticDialogOptions( if (!buttons || buttons.length === 0) { throw new TypeError('A diagnostic dialog requires at least one decision button'); } - const copy = DIALOG_COPY[locale]; + const copy = getNativeDiagnosticDialogCopy(locale).dialog; return { options: { ...options, @@ -167,65 +205,3 @@ function diagnosticDialogOptions( copyId: buttons.length, }; } - -const DIALOG_COPY = { - en: { - copy: 'Copy Diagnostics', - copyAgain: 'Copy Again', - copied: 'Diagnostics copied. You can paste them into an issue report.', - copyFailed: 'Could not copy diagnostics.', - }, - zh: { - copy: '复制诊断信息', - copyAgain: '再次复制', - copied: '诊断信息已复制,可直接粘贴到问题报告中。', - copyFailed: '无法复制诊断信息。', - }, -} as const; - -const FATAL_STARTUP_COPY = { - en: { - title: 'Maka failed to start', - message: 'Maka could not finish starting.', - unknownError: 'An unknown startup error occurred.', - exit: 'Exit', - }, - zh: { - title: 'Maka 启动失败', - message: 'Maka 无法完成启动。', - unknownError: '启动时发生未知错误。', - exit: '退出', - }, -} as const; - -const MAIN_RENDERER_GONE_COPY = { - en: { - title: 'Maka needs to recover', - message: "Maka's interface stopped unexpectedly.", - detail: 'Relaunch Maka to continue, or exit and reopen it later.', - relaunch: 'Relaunch', - exit: 'Exit', - }, - zh: { - title: 'Maka 需要恢复', - message: 'Maka 界面意外停止运行。', - detail: '重新启动 Maka 以继续,或退出后稍后再打开。', - relaunch: '重新启动', - exit: '退出', - }, -} as const; - -const PREVIOUS_MAIN_PROCESS_INTERRUPTION_COPY = { - en: { - title: 'Previous session ended unexpectedly', - message: 'Maka did not finish shutting down during its previous session.', - detail: 'You can copy the available diagnostics before continuing.', - continue: 'Continue', - }, - zh: { - title: '上一次会话意外结束', - message: 'Maka 上一次运行时未能完成退出流程。', - detail: '继续前,你可以复制当前可用的诊断信息。', - continue: '继续', - }, -} as const; diff --git a/apps/desktop/src/main/new-session-project.ts b/apps/desktop/src/main/new-session-project.ts index 097dcd280d..78b6275638 100644 --- a/apps/desktop/src/main/new-session-project.ts +++ b/apps/desktop/src/main/new-session-project.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { ProjectCatalog } from '@maka/storage'; +import type { ProjectCatalog } from '@maka/storage/project-catalog'; import type { WorkspaceTarget } from '@maka/runtime-host/protocol'; export interface DesktopSessionWorkspaceInput { diff --git a/apps/desktop/src/main/oauth/github-copilot-subscription-service.ts b/apps/desktop/src/main/oauth/github-copilot-subscription-service.ts index 5a62fa6b5d..1d6930eed2 100644 --- a/apps/desktop/src/main/oauth/github-copilot-subscription-service.ts +++ b/apps/desktop/src/main/oauth/github-copilot-subscription-service.ts @@ -33,7 +33,7 @@ import { type OAuthSubscriptionTokens, } from '@maka/runtime/subscription-credentials'; import { fetchGitHubCopilotModels } from '@maka/runtime/model-fetcher'; -import type { CredentialStore } from '@maka/storage'; +import type { CredentialStore } from '@maka/storage/credential-store'; const GITHUB_COPILOT_CONNECTION_SLUG = 'github-copilot'; const execFileAsync = promisify(execFile); diff --git a/apps/desktop/src/main/onboarding-service.ts b/apps/desktop/src/main/onboarding-service.ts index db98878289..24e396ff0f 100644 --- a/apps/desktop/src/main/onboarding-service.ts +++ b/apps/desktop/src/main/onboarding-service.ts @@ -60,6 +60,7 @@ import { projectSessionSendOutcome, type SessionSendProjection } from '@maka/cor import { type SessionSummary } from '@maka/core/session'; import { buildChatModelChoices, type ChatModelChoice } from '@maka/core/chat-model-choice'; +import type { ProjectedLlmConnection } from '@maka/core/llm-connections'; import type { LlmConnection } from '@maka/core/llm-connections'; export interface OnboardingSnapshot { @@ -71,14 +72,14 @@ export interface OnboardingSnapshot { */ sessions: SessionSummary[]; /** Default Host connection projection used to seed the shell. */ - connections: LlmConnection[]; + connections: ProjectedLlmConnection[]; defaultSlug: string | null; chatModelChoices: ChatModelChoice[]; sessionSendOutcomes: Record; } export interface OnboardingServiceDeps { - listConnections(): Promise; + listConnections(): Promise; getDefaultSlug(): Promise; listSessions(): Promise; getMilestones(): Promise; @@ -196,7 +197,7 @@ function buildSnapshot( state: OnboardingState, milestones: OnboardingMilestone[], sessions: SessionSummary[], - connections: LlmConnection[], + connections: ProjectedLlmConnection[], defaultSlug: string | null, secrets: Readonly>, ): OnboardingSnapshot { @@ -213,7 +214,6 @@ function buildSnapshot( projectSessionSendOutcome({ session, connections, - defaultSlug, hasSecret: (slug) => secrets[slug] ?? false, }), ]), diff --git a/apps/desktop/src/main/permission-response-guard.ts b/apps/desktop/src/main/permission-response-guard.ts index 1dd307674a..e29fe05aab 100644 --- a/apps/desktop/src/main/permission-response-guard.ts +++ b/apps/desktop/src/main/permission-response-guard.ts @@ -23,9 +23,15 @@ import type { ReviseBeforeTurnInput, TurnOrchestration, } from '@maka/core/runtime-inputs'; -import type { QuoteRef } from '@maka/core/events'; +import { + isDirectoryReference, + DIRECTORY_REFERENCE_MAX_COUNT, + type DirectoryReference, + type QuoteRef, +} from '@maka/core/events'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; +import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant'; import { MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; import { isAttachmentRef, isCanonicalStorageRef, type AttachmentRef } from '@maka/core/events'; @@ -52,6 +58,7 @@ export type RuntimeHostReviseBeforeTurnInput = ReviseBeforeTurnInput & { copyId: interface NormalizedSendSessionCommand { type: 'send'; + messageId?: string; turnId?: string; text: string; displayText?: string; @@ -59,12 +66,14 @@ interface NormalizedSendSessionCommand { attachmentItems?: unknown; retainedAttachments?: AttachmentRef[]; turnOrchestration?: TurnOrchestration; + directoryReferences?: DirectoryReference[]; quotes?: QuoteRef[]; workspaceFileReferences?: WorkspaceFileReferencePosition[]; } type NormalizedStopSessionInput = { source?: 'stop_button'; expectedTurnId?: string; + expectedAdmissionId?: string; }; export function normalizeSandboxBoundaryResponse(input: unknown): SandboxBoundaryResponse { @@ -88,6 +97,24 @@ export function normalizeSandboxBoundaryResponse(input: unknown): SandboxBoundar }; } +export function normalizeClientCapabilityResponse(input: unknown): ClientCapabilityResponse { + if (!input || typeof input !== 'object') { + throw new Error('Invalid Client Capability response'); + } + const value = input as Record; + if ( + typeof value.requestId !== 'string' || + value.requestId.length === 0 || + value.requestId.length > MAX_PERMISSION_REQUEST_ID_LENGTH + ) { + throw new Error('Invalid Client Capability response requestId'); + } + if (value.decision !== 'allow' && value.decision !== 'deny') { + throw new Error('Invalid Client Capability response decision'); + } + return { requestId: value.requestId, decision: value.decision }; +} + export function normalizeUserQuestionResponse(input: unknown): UserQuestionResponse { const value = requireObject(input, 'Invalid user question response'); const requestId = normalizeRequiredString( @@ -177,6 +204,7 @@ export function normalizeSessionSendCommand(input: unknown): NormalizedSendSessi } return { type: 'send', + ...normalizeOptionalSendMessageId(value.messageId), ...normalizeOptionalSendTurnId(value.turnId), text, ...(displayText !== undefined ? { displayText } : {}), @@ -186,6 +214,7 @@ export function normalizeSessionSendCommand(input: unknown): NormalizedSendSessi ...(value.turnOrchestration !== undefined ? { turnOrchestration: normalizeTurnOrchestration(value.turnOrchestration) } : {}), + ...normalizeOptionalDirectoryReferences(value.directoryReferences), ...normalizeOptionalQuotes(value.quotes), ...normalizeOptionalWorkspaceFileReferences( value.workspaceFileReferences, @@ -194,6 +223,13 @@ export function normalizeSessionSendCommand(input: unknown): NormalizedSendSessi }; } +function normalizeOptionalSendMessageId(input: unknown): { messageId?: string } { + if (input === undefined) return {}; + return { + messageId: normalizeRequiredString(input, 'Invalid send messageId', MAX_TURN_ID_LENGTH), + }; +} + function normalizeOptionalRetainedAttachments( input: unknown, ): { retainedAttachments?: AttachmentRef[] } { @@ -324,9 +360,17 @@ export function normalizeStopSessionInput(input: unknown): NormalizedStopSession 'Invalid stop session expectedTurnId', MAX_TURN_ID_LENGTH, ); + const expectedAdmissionId = value.expectedAdmissionId === undefined + ? undefined + : normalizeRequiredString( + value.expectedAdmissionId, + 'Invalid stop session expectedAdmissionId', + MAX_TURN_ID_LENGTH, + ); return { ...(value.source ? { source: 'stop_button' as const } : {}), ...(expectedTurnId ? { expectedTurnId } : {}), + ...(expectedAdmissionId ? { expectedAdmissionId } : {}), }; } @@ -369,3 +413,17 @@ function normalizeOptionalSendTurnId(input: unknown): { turnId?: string } { turnId: normalizeRequiredString(input, 'Invalid send turnId', MAX_TURN_ID_LENGTH), }; } + +function normalizeOptionalDirectoryReferences( + input: unknown, +): { directoryReferences?: DirectoryReference[] } { + if (input === undefined) return {}; + if ( + !Array.isArray(input) || + input.length > DIRECTORY_REFERENCE_MAX_COUNT || + !input.every(isDirectoryReference) + ) { + throw new Error('Invalid directory references'); + } + return input.length ? { directoryReferences: input.map((ref) => ({ ...ref })) } : {}; +} diff --git a/apps/desktop/src/main/pet-pack-import.ts b/apps/desktop/src/main/pet-pack-import.ts index b6a4b2dfbb..73de5c4ee8 100644 --- a/apps/desktop/src/main/pet-pack-import.ts +++ b/apps/desktop/src/main/pet-pack-import.ts @@ -30,7 +30,7 @@ import { PetPackStoreError, type PetPackStore, } from '@maka/storage/pet-pack-store'; -import type { SettingsStore } from '@maka/storage'; +import type { SettingsStore } from '@maka/storage/settings-store'; import type { createMainWindowController } from './main-window.js'; type MainWindowController = Pick< diff --git a/apps/desktop/src/main/qq-bot-scan-login.ts b/apps/desktop/src/main/qq-bot-scan-login.ts index c27c1fe18d..37b459a5c2 100644 --- a/apps/desktop/src/main/qq-bot-scan-login.ts +++ b/apps/desktop/src/main/qq-bot-scan-login.ts @@ -93,7 +93,7 @@ export function decryptQQBotSecret(encryptedSecret: string, decryptionKey: strin const iv = payload.subarray(0, 12); const authTag = payload.subarray(payload.length - 16); const ciphertext = payload.subarray(12, payload.length - 16); - const decipher = createDecipheriv('aes-256-gcm', key, iv); + const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }); decipher.setAuthTag(authTag); return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); } diff --git a/apps/desktop/src/main/runtime-host-account-connection.ts b/apps/desktop/src/main/runtime-host-account-connection.ts index ddb1d428f8..e98892fd37 100644 --- a/apps/desktop/src/main/runtime-host-account-connection.ts +++ b/apps/desktop/src/main/runtime-host-account-connection.ts @@ -18,7 +18,8 @@ */ import { - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, + providerFallbackModelIds, type ProviderType, } from '@maka/core/llm-connections'; import type { @@ -56,13 +57,13 @@ export async function ensureRuntimeHostAccountConnection( ? enabledModelIds : existing?.enabledModelIds.length ? existing.enabledModelIds - : PROVIDER_DEFAULTS[identity.providerType].fallbackModels; + : providerFallbackModelIds(PROVIDER_REGISTRY[identity.providerType]); if (!existing) { const slugOwner = catalog.connections.find(({ slug }) => slug === identity.slug); if (slugOwner) { throw new Error(`Connection slug belongs to ${slugOwner.providerType}`); } - const defaults = PROVIDER_DEFAULTS[identity.providerType]; + const defaults = PROVIDER_REGISTRY[identity.providerType]; const created = await client.createConnection(catalog.revision, { slug: identity.slug, name: defaults.label, @@ -109,15 +110,27 @@ export async function synchronizeRuntimeHostAccountConnection( providerType, ); if (!connection) throw new Error('Account Connection is missing'); + return synchronizeRuntimeHostAccountConnectionById(client, connection.connectionId); +} + +export async function synchronizeRuntimeHostAccountConnectionById( + client: RuntimeHostAccountConnectionClient, + connectionId: string, +): Promise { + const connection = findRuntimeHostAccountConnectionById( + await client.loadConnectionCatalog(), + connectionId, + ); + if (!connection) throw new Error('Account Connection is missing'); // Discovery is best effort. Selecting a default must not depend on it: a - // connection whose inventory came from the curated fallback still has usable + // connection whose inventory came from the shipped baseline still has usable // models, and leaving `defaultTarget` empty makes every later operation that // needs a default — new Session, send, external Session import — fail with a // reason the user cannot see from the error it produces. await client.fetchConnectionModels(connection.connectionId).catch(() => undefined); const catalog = await client.loadConnectionCatalog(); if (catalog.defaultTarget !== null) return; - const updated = findRuntimeHostAccountConnection(catalog, providerType); + const updated = findRuntimeHostAccountConnectionById(catalog, connectionId); const modelId = updated?.enabledModelIds[0]; if (!updated || !modelId) return; const selected = await client.setDefaultConnectionTarget(catalog.revision, { @@ -158,6 +171,18 @@ export async function disableRuntimeHostAccountConnection( providerType, ); if (!connection) return; + return disableRuntimeHostAccountConnectionById(client, connection.connectionId); +} + +export async function disableRuntimeHostAccountConnectionById( + client: RuntimeHostAccountConnectionClient, + connectionId: string, +): Promise { + const connection = findRuntimeHostAccountConnectionById( + await client.loadConnectionCatalog(), + connectionId, + ); + if (!connection) return; const credential = await client.queryCredential(runtimeHostAccountCredential(connection)); if (credential?.configured) { const removed = await client.deleteCredential({ @@ -171,9 +196,9 @@ export async function disableRuntimeHostAccountConnection( throw new Error(`Unable to remove account credential: ${removed.kind}`); } } - const latest = findRuntimeHostAccountConnection( + const latest = findRuntimeHostAccountConnectionById( await client.loadConnectionCatalog(), - providerType, + connectionId, ); if (!latest?.enabled) return; const disabled = await client.updateConnection( @@ -192,10 +217,17 @@ export function findRuntimeHostAccountConnection( return catalog.connections.find((connection) => connection.providerType === providerType); } +export function findRuntimeHostAccountConnectionById( + catalog: ConnectionCatalogSnapshot, + connectionId: string, +): ConnectionCatalogEntry | undefined { + return catalog.connections.find((connection) => connection.connectionId === connectionId); +} + export function runtimeHostAccountCredential( connection: ConnectionCatalogEntry, ): CredentialLocator { - if (PROVIDER_DEFAULTS[connection.providerType].authKind !== 'oauth_token') { + if (PROVIDER_REGISTRY[connection.providerType].authKind !== 'oauth_token') { throw new Error('Account Connection does not use an OAuth credential'); } return { diff --git a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts index 5ba6f5ed74..4032293ef4 100644 --- a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts @@ -21,8 +21,12 @@ import { randomUUID } from "node:crypto"; import { open, mkdir, rename, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { MAX_ATTACHMENT_BYTES } from '@maka/core/attachments'; -import { type ArtifactSaveResult } from '@maka/core/artifacts'; +import { + ARTIFACT_IMAGE_PREVIEW_MAX_BYTES, + normalizeArtifactImagePreviewMime, + resolveArtifactImagePreview, + type ArtifactSaveResult, +} from '@maka/core/artifacts'; import { sanitizeArtifactName } from "@maka/storage/artifact-stores"; import { handleReconnectableRead, @@ -40,6 +44,13 @@ interface RuntimeHostArtifactsIpcDeps { readonly presentationRoot?: string; } +type RuntimeHostAttachmentPreviewIpcDeps = Pick< + RuntimeHostArtifactsIpcDeps, + 'ipcMain' | 'client' +>; + +const ATTACHMENT_PREVIEW_LIMIT_EXCEEDED = Symbol("attachment-preview-limit-exceeded"); + export function registerRuntimeHostArtifactsIpc( deps: RuntimeHostArtifactsIpcDeps, ): void { @@ -86,37 +97,7 @@ export function registerRuntimeHostArtifactsIpc( return result; }, ); - handleReconnectableRead( - deps.ipcMain, - "attachments:readBytes", - async (_event, sessionId: string, artifactId: string) => { - const artifact = await deps.client.getArtifact(sessionId, artifactId); - if ( - !artifact || - artifact.status === "deleted" || - artifact.sizeBytes > MAX_ATTACHMENT_BYTES - ) { - return { ok: false as const, reason: "not_found" }; - } - const chunks: Buffer[] = []; - let received = 0; - await deps.client.streamArtifact(sessionId, artifactId, async (chunk) => { - received += chunk.byteLength; - if (received > MAX_ATTACHMENT_BYTES) { - throw new Error("Attachment exceeds the renderer byte limit"); - } - chunks.push(Buffer.from(chunk)); - }); - if (received !== artifact.sizeBytes) { - return { ok: false as const, reason: "read_failed" }; - } - return { - ok: true as const, - base64: Buffer.concat(chunks, received).toString("base64"), - mimeType: artifact.mimeType ?? "application/octet-stream", - }; - }, - ); + registerRuntimeHostAttachmentPreviewIpc(deps); deps.ipcMain.handle( "app:openArtifactPath", async (_event, sessionId: string, artifactId: string) => { @@ -171,6 +152,58 @@ export function registerRuntimeHostArtifactsIpc( ); } +/** Guest-safe projection used by transcript attachment thumbnails. */ +export function registerRuntimeHostAttachmentPreviewIpc( + deps: RuntimeHostAttachmentPreviewIpcDeps, +): void { + handleReconnectableRead( + deps.ipcMain, + "attachments:readBytes", + async (_event, sessionId: string, artifactId: string) => { + const artifact = await deps.client.getArtifact(sessionId, artifactId); + if ( + !artifact || + artifact.status === "deleted" + ) { + return { ok: false as const, reason: "not_found" }; + } + const preview = resolveArtifactImagePreview(artifact); + if (preview.kind === "unsupported") { + return { + ok: false as const, + reason: preview.reason === "oversize" ? "too_large" : "unsupported_mime", + }; + } + const mimeType = normalizeArtifactImagePreviewMime(artifact.mimeType, artifact.name); + if (!mimeType) return { ok: false as const, reason: "unsupported_mime" }; + const chunks: Buffer[] = []; + let received = 0; + try { + await deps.client.streamArtifact(sessionId, artifactId, async (chunk) => { + received += chunk.byteLength; + if (received > ARTIFACT_IMAGE_PREVIEW_MAX_BYTES) { + throw ATTACHMENT_PREVIEW_LIMIT_EXCEEDED; + } + chunks.push(Buffer.from(chunk)); + }); + } catch (error) { + if (error === ATTACHMENT_PREVIEW_LIMIT_EXCEEDED) { + return { ok: false as const, reason: "too_large" }; + } + throw error; + } + if (received !== artifact.sizeBytes) { + return { ok: false as const, reason: "read_failed" }; + } + return { + ok: true as const, + base64: Buffer.concat(chunks, received).toString("base64"), + mimeType, + }; + }, + ); +} + async function materializeArtifact( client: DesktopRuntimeHostClient, sessionId: string, diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 9d67cf7ac5..41122c911a 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -19,9 +19,10 @@ import { app, + type BrowserWindow, clipboard, - dialog, ipcMain, + nativeTheme, powerSaveBlocker, shell, type MessageBoxOptions, @@ -29,14 +30,13 @@ import { } from "electron"; import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; -import { basename, join } from "node:path"; +import { join } from "node:path"; import { type ConnectionEvent } from '@maka/core/connections'; -import type { UsageRange } from '@maka/core/settings'; import { type SessionChangedEvent, type SessionChangedReason } from '@maka/core/session'; import { isBotDeliveryProvider } from '@maka/core/bot-chat-settings'; import { resolveSystemUiLocale } from '@maka/core/ui-locale'; import { - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, providerAuthRequiresSecret, } from "@maka/core/llm-connections"; import { BotRegistry, type BotIncomingMessage } from '@maka/runtime/bots'; @@ -49,28 +49,43 @@ import { createClientRuntimeHostCredentialStore, createClientRuntimeHostProfileCatalog, createRuntimeHostCandidateLaunchBarrier, + createRuntimeHostPeerClientFromEnvironment, LOCAL_RUNTIME_HOST_PROFILE, loadOrCreateRuntimeHostClientInstanceId, + listRuntimeHostWslDistributions, + runtimeHostProfileAccess, + type ResolvedRuntimeHostProfile, } from "@maka/runtime-host/client"; +import { openRuntimeHostPeerMeshOwner } from '@maka/runtime-host/peer-mesh'; import type { WorkspaceTarget } from "@maka/runtime-host/protocol"; +import { runtimeHostProfileUsesHostWorkspace } from "@maka/runtime-host/profile-kind"; import { createCredentialMcpOAuthStorage, McpClientManager } from "@maka/mcp"; -import { - createSettingsStore, - createMcpConfigStore, - createFileCredentialStore, -} from "@maka/storage"; +import { createWorkBoardStore } from "@maka/storage/work-board-store"; +import { createFileCredentialStore } from "@maka/storage/credential-store"; +import { createMcpConfigStore } from "@maka/storage/mcp-config-store"; +import { createSettingsStore } from "@maka/storage/settings-store"; import { resolveStorageRoot } from "@maka/storage/root-authority"; import { createMcpOAuthController } from "./mcp-oauth-controller.js"; import { registerAppClientIpc, registerAppIpc } from "./app-ipc-main.js"; import { createAppQuitCoordinator } from "./app-quit-coordinator.js"; +import { + desktopUpdateChannelFromManifest, + verifyDownloadedUpdateAttestation, +} from "./app-update-attestation.js"; import { createAppUpdateService } from "./app-update-service.js"; import { createAttachmentApprovalRegistry } from "./attachment-approval.js"; import { renderAttachmentPreview, resizeImageForAttachment } from "./attachment-resize-native.js"; import { registerAttachmentPreviewIpc } from "./attachment-preview.js"; -import { readFileCapped } from "./attachment-ingest.js"; +import { readFileCapped, resolvePickedAttachments } from "./attachment-ingest.js"; import { registerBrowserIpc } from "./browser-ipc-main.js"; +import { browserViewHost } from "./browser/browser-host.js"; import { releaseBrowserSession } from "./browser/session.js"; +import { + isBrowserMessageBoxPresentationActive, + showBrowserMessageBox, + type BrowserMessageBoxAppearance, +} from "./browser-message-box.js"; import { createE2eFixtureBotOnboardingAdapters } from "./bot-onboarding-e2e-fixture.js"; import { resolveBuildInfo } from "./build-info.js"; import { computerUseServiceHealth } from "./computer-use-host.js"; @@ -89,11 +104,13 @@ import { seedE2eFixture, } from "./e2e-fixture.js"; import { createKeepSystemAwakeController } from "./keep-system-awake.js"; +import { isDarkAppearance } from "./theme-source.js"; import { readWithFallback, type ReconnectableReadIpcMain, } from "./ipc-reconnect-policy.js"; import { createMainWindowController } from "./main-window.js"; +import type { DesktopRuntimeHostIdentity } from "../preload/bridge-contract.js"; import { captureDesktopDiagnosticEnvironment, copyDesktopDiagnosticReport, @@ -104,9 +121,12 @@ import { type DesktopDiagnosticsDeps, } from "./main-process-diagnostics.js"; import { + defaultRuntimeHostRecoveryDialog, showMainRendererProcessGoneDialog, showMessageBoxWithDiagnostics, + showRuntimeHostStartupRecoveryDialog, } from "./native-diagnostic-dialog.js"; +import { getNativeDiagnosticDialogCopy } from "./native-diagnostic-dialog-copy.js"; import { resolveDesktopSessionWorkspace, } from "./new-session-project.js"; @@ -121,6 +141,7 @@ import { import { registerNotificationsIpc } from "./notifications-ipc-main.js"; import { registerMarkdownSaveIpc } from "./markdown-save-ipc-main.js"; import { registerPetPackIpc } from "./pet-pack-import.js"; +import { registerWorkBoardIpc } from "./work-board-ipc-main.js"; import { createPermissionOverlayMain, registerPermissionOverlayIpc, @@ -134,7 +155,7 @@ import { createProjectRootController, type ProjectRootController, } from "./project-root-controller.js"; -import { createSessionCopyCleanupAuthority } from "./quote-companion-cleanup.js"; +import { createSessionCopyCleanupAuthority } from "@maka/storage/session-copy-cleanup"; import { projectHostConnections, registerRuntimeHostConnectionsIpc, @@ -157,6 +178,12 @@ import { startRuntimeHostDesktopManager, type RuntimeHostDesktopManager, } from "./runtime-host-desktop-manager.js"; +import { + canRepairManagedRuntimeHostStartup, + DesktopRuntimeHostStartupRecoveryCancelledError, + startDesktopRuntimeHostWithRecovery, +} from "./runtime-host-startup-recovery.js"; +import { buildRuntimeHostQuitFailureDialog } from "./runtime-host-quit-copy.js"; import { createRuntimeHostUpgradePrompts } from "./runtime-host-upgrade-dialog.js"; import { registerRuntimeHostMemoryIpc } from "./runtime-host-memory-ipc-main.js"; import { @@ -164,13 +191,33 @@ import { registerDesktopRuntimeHostProfileIpc, resolveDesktopRuntimeHostStartup, } from "./runtime-host-profile-service.js"; +import { + createDesktopGuestSessionMountService, + createGuestSessionMountStore, + registerDesktopGuestSessionMountIpc, +} from './runtime-host-guest-session-mounts.js'; import { createDesktopRuntimeHostSshTerminal, - isExactRuntimeHostSetupPackageSpecifier, - type DesktopRuntimeHostSetupPackage, } from "./runtime-host-ssh-terminal.js"; +import { + runDesktopRuntimeHostWslManagement, + runDesktopRuntimeHostWslSetup, +} from './runtime-host-wsl-controller.js'; +import { + createRuntimeHostSetupPackageResolver, + desktopRuntimeHostDevelopmentPeerTarget, +} from "./runtime-host-setup-package.js"; +import { + configureDesktopRuntimeHostPeerClient, + readDesktopRuntimeHostWebRtcStunPolicy, + writeDesktopRuntimeHostWebRtcStunPolicy, +} from './runtime-host-peer-client.js'; +import { createDesktopRuntimeHostLocalOperator } from './runtime-host-local-operator.js'; +import { createDesktopLocalRuntimeHostRemoteAccess } from './runtime-host-local-remote-access.js'; import { createDesktopRuntimeHostOnboarding } from "./runtime-host-onboarding.js"; import { createDesktopRuntimeHostManagement } from "./runtime-host-management.js"; +import { createDesktopRuntimeHostLocalManagement } from './runtime-host-local-management.js'; +import { createDesktopRuntimeHostPeerMeshManagement } from './runtime-host-peer-mesh-management.js'; import { registerRuntimeHostOAuthIpc } from "./runtime-host-oauth-ipc-main.js"; import { RuntimeHostOAuthPresentation } from "./runtime-host-oauth-presentation.js"; import { registerRuntimeHostPermissionsIpc } from "./runtime-host-permissions-ipc-main.js"; @@ -200,9 +247,6 @@ import { import { resolveDesktopStorageRoot } from "./storage-root-startup.js"; import { startupStep } from "./startup-step.js"; import { registerWorkspaceSearchIpc } from "./workspace-search-ipc-main.js"; -import { - projectDesktopUsageStats, -} from "../shared/desktop-session-projection.js"; import { parseDesktopSessionResourceKey, requireDesktopTargetScope, @@ -214,6 +258,43 @@ await resolveShellEnv(); const MANAGED_UPDATE_RECONNECT_TIMEOUT_MS = 10_000; const buildInfo = resolveBuildInfo(app.isPackaged, app.getAppPath()); const userDataDir = app.getPath("userData"); +const runtimeHostPeerConfiguration = await configureDesktopRuntimeHostPeerClient({ + isPackaged: app.isPackaged, + enableDevelopmentPeer: process.argv.includes('--runtime-host-peer'), + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + clientDataRoot: userDataDir, +}); +let runtimeHostPeerOwner: Awaited> | undefined; +let runtimeHostPeerMesh: Awaited>['mesh'] | undefined; +let runtimeHostPeerClient: + | ReturnType + | undefined; +if (runtimeHostPeerConfiguration) { + try { + runtimeHostPeerOwner = await openRuntimeHostPeerMeshOwner({ + ...runtimeHostPeerConfiguration, + dataRoot: join(userDataDir, 'peer-mesh'), + endpointKind: 'client', + onBackgroundReconcileError: (error) => { + console.error('[runtime-host] Peer Mesh background synchronization failed:', error); + }, + }); + runtimeHostPeerClient = runtimeHostPeerOwner.client; + runtimeHostPeerMesh = runtimeHostPeerOwner.mesh; + void runtimeHostPeerOwner.closed.catch((error) => { + runtimeHostPeerMesh = undefined; + console.error('[runtime-host] Peer Mesh stopped; Direct peer remains available:', error); + }); + } catch (error) { + console.error('[runtime-host] Peer Mesh is unavailable; continuing with Direct peer:', error); + runtimeHostPeerClient = createRuntimeHostPeerClientFromEnvironment(process.env, { + automaticRelayDiscovery: runtimeHostPeerConfiguration.automaticRelayDiscovery, + webRtcStunUrls: runtimeHostPeerConfiguration.webRtcStunUrls, + }); + } +} +const runtimeHostDirectPeerAvailable = runtimeHostPeerClient !== undefined; const runtimeHostClientInstanceId = await loadOrCreateRuntimeHostClientInstanceId( join(userDataDir, "runtime-host-client.json"), ); @@ -260,21 +341,35 @@ const desktopDiagnostics: DesktopDiagnosticsDeps = { resolveRuntimeHost: resolveRuntimeHostDiagnostics, writeClipboard: (report) => clipboard.writeText(report), }; +let resolveBrowserDialogParent = (): BrowserWindow | undefined => undefined; +let resolveBrowserDialogAppearance = async (): Promise => ({ + locale: resolveSystemUiLocale(app.getPreferredSystemLanguages()), + palette: "default", +}); + +async function showDesktopMessageBox( + options: MessageBoxOptions, + override?: Partial, +): Promise { + const appearance = { ...(await resolveBrowserDialogAppearance()), ...override }; + return showBrowserMessageBox(options, resolveBrowserDialogParent(), appearance); +} function showStartupDiagnosticDialog( options: MessageBoxOptions, locale: ReturnType, + diagnosticDetails = options.detail, ): Promise { return showMessageBoxWithDiagnostics(options, { locale, - showMessageBox: (next) => dialog.showMessageBox(next), + showMessageBox: (nextOptions) => showDesktopMessageBox(nextOptions, { locale }), copyDiagnostics: () => copyDesktopDiagnosticReport( desktopDiagnostics, createDesktopStartupDiagnosticInput({ title: options.title || options.message, description: options.message, - ...(options.detail ? { details: options.detail } : {}), + ...(diagnosticDetails ? { details: diagnosticDetails } : {}), }), ), }); @@ -301,13 +396,28 @@ if (!startupLocalStorageRoot) { await new Promise(() => {}); throw new Error("Desktop storage root resolution did not complete"); } -const localRuntimeHostId = startupLocalStorageRoot.rootId; const settingsStore = createSettingsStore(workspaceRoot); const desktopLocale = createDesktopLocaleAuthority({ readSettings: () => settingsStore.get(), preferredSystemLanguages: () => app.getPreferredSystemLanguages(), }); +resolveBrowserDialogAppearance = async () => { + try { + const settings = await settingsStore.get(); + return { + locale: desktopLocale.observe(settings), + palette: settings.appearance.palette, + dark: isDarkAppearance( + e2eFixture?.theme ?? settings.appearance.theme, + nativeTheme.shouldUseDarkColors, + ), + }; + } catch { + return { locale: desktopLocale.current(), palette: "default" }; + } +}; const mcpConfigStore = createMcpConfigStore(workspaceRoot); +const workBoardStore = createWorkBoardStore(workspaceRoot); const mcpManager = new McpClientManager({ clientName: "maka-desktop", clientVersion: app.getVersion(), @@ -359,20 +469,47 @@ const mainWindowController = createMainWindowController({ description: `Reason: ${details.reason}`, details: `Exit code: ${details.exitCode}`, }); - const decision = await showMainRendererProcessGoneDialog({ - locale: desktopLocale.current(), - copyDiagnostics: () => - copyDesktopDiagnosticReport(desktopDiagnostics, diagnosticInput), - showMessageBox: (options) => dialog.showMessageBox(options), - }); - if (decision === "relaunch") app.relaunch(); + for (;;) { + const locale = await desktopLocale.resolve(); + const decision = await showMainRendererProcessGoneDialog({ + locale, + copyDiagnostics: () => + copyDesktopDiagnosticReport(desktopDiagnostics, diagnosticInput), + // showBrowserMessageBox attaches only to a visible, non-minimized + // parent. A pre-first-paint crash therefore gets a standalone window. + showMessageBox: (options) => showDesktopMessageBox(options, { locale }), + }); + if (decision !== "recover") break; + if (await mainWindowController.reloadMainRenderer()) return; + if (!mainWindowController.browserWindow()) break; + } app.quit(); }, }); +resolveBrowserDialogParent = () => mainWindowController.browserWindow(); const runtimeHostSshTerminal = createDesktopRuntimeHostSshTerminal({ ipcMain, send: (channel, event) => mainWindowController.send(channel, event), }); +const runtimeHostSetupPackage = createRuntimeHostSetupPackageResolver({ + isPackaged: app.isPackaged, + appPath: app.getAppPath(), + environment: process.env, +}); +const localRuntimeHostOperator = createDesktopRuntimeHostLocalOperator(); +const localRuntimeHostRemoteAccess = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain, + clientDataRoot: userDataDir, + rootPath: startupLocalStorageRoot.canonicalPath, + rootId: startupLocalStorageRoot.rootId, + directPeerAvailable: runtimeHostDirectPeerAvailable, + manager: () => runtimeHostManager, + resolveSetupPackage: (signal) => runtimeHostSetupPackage.resolve( + desktopRuntimeHostDevelopmentPeerTarget(), + signal, + ), + operator: localRuntimeHostOperator, +}); const native = assembleDesktopNativeCapabilities({ isComputerUseRealModelE2e, locale: desktopLocale, @@ -410,14 +547,19 @@ const runtimeHostProfileService = createDesktopRuntimeHostProfileService({ credentialStore: runtimeHostCredentialStore, states: () => runtimeHostManager?.entries() ?? [], enable: async (target, sshInteraction) => { - if (target.profile.kind !== "remote" || !target.credential) { - throw new Error("A resolved remote Runtime Host profile is required"); + if (target.profile.kind === 'local') { + throw new Error('A resolved non-local Runtime Host profile is required'); + } + if (target.profile.kind === 'remote' && !target.credential) { + throw new Error('A remote Runtime Host profile requires an access credential'); } if (!runtimeHostManager) throw new Error("Runtime Host manager is unavailable"); await runtimeHostManager.enable({ profile: target.profile, - credential: target.credential, - ...(target.profile.transport.kind === "ssh" ? { sshInteraction } : {}), + ...(target.credential ? { credential: target.credential } : {}), + ...(target.profile.kind === 'remote' && target.profile.transport.kind === "ssh" + ? { sshInteraction } + : {}), }); }, disable: async (profileId) => { @@ -433,21 +575,75 @@ const runtimeHostProfileService = createDesktopRuntimeHostProfileService({ runtimeHostManager.setDefaultProfile(profileId); }, }); +const guestSessionMountService = createDesktopGuestSessionMountService({ + store: createGuestSessionMountStore(runtimeHostCredentialStore), + mount: async (target, signal, onConnectionPhase, onPeerEndpoint) => { + if (target.profile.kind !== 'remote' || !target.credential) { + throw new Error('A shared Session requires a remote Guest target'); + } + if (!runtimeHostManager) throw new Error('Runtime Host manager is unavailable'); + await runtimeHostManager.mountGuest( + { profile: target.profile, credential: target.credential }, + signal, + onConnectionPhase, + (status) => { + if (status.peerEndpoint) onPeerEndpoint?.(status.peerEndpoint); + }, + ); + }, + finalizeAccess: async (mountId, signal) => { + if (!runtimeHostManager) throw new Error('Runtime Host manager is unavailable'); + await runtimeHostManager.finalizeGuestAccess(mountId, signal); + }, + unmount: async (mountId) => { + if (!runtimeHostManager) return; + await runtimeHostManager.unmountGuest(mountId); + }, +}); const runtimeHostOnboarding = createDesktopRuntimeHostOnboarding({ ipcMain, clientInstanceId: runtimeHostClientInstanceId, profiles: runtimeHostProfileService, runSetup: runtimeHostSshTerminal.runSetup, - resolveSetupPackage: runtimeHostSetupPackage, + runWslSetup: runDesktopRuntimeHostWslSetup, + listWslDistributions: listRuntimeHostWslDistributions, + setupPackageMode: runtimeHostSetupPackage.mode, + resolveSshDevelopmentPeerTarget: runtimeHostSshTerminal.resolveDevelopmentPeerTarget, + resolveSetupPackage: runtimeHostSetupPackage.resolve, send: (snapshot) => mainWindowController.send("runtime-host-onboarding:changed", snapshot), }); +const localRuntimeHostManagement = createDesktopRuntimeHostLocalManagement({ + remoteAccess: localRuntimeHostRemoteAccess, + operator: localRuntimeHostOperator, + rootPath: startupLocalStorageRoot.canonicalPath, + resolveUpdatePackage: () => runtimeHostSetupPackage.resolve( + desktopRuntimeHostDevelopmentPeerTarget(), + ), + currentHostEpoch: () => + runtimeHostManager?.current('local')?.candidate?.client.hostEpoch, + awaitUpdatedConnection: async (previousHostEpoch, replacementExpected) => { + if (!runtimeHostManager) throw new Error('Runtime Host manager is unavailable'); + await runtimeHostManager.waitUntilReady( + 'local', + replacementExpected ? previousHostEpoch : undefined, + AbortSignal.timeout(MANAGED_UPDATE_RECONNECT_TIMEOUT_MS), + ); + }, +}); const runtimeHostManagement = createDesktopRuntimeHostManagement({ ipcMain, profiles: runtimeHostProfileService, runServiceManagement: runtimeHostSshTerminal.runServiceManagement, + runWslManagement: runDesktopRuntimeHostWslManagement, + runPeerManagement: runtimeHostSshTerminal.runPeerManagement, + directPeerClientAvailable: runtimeHostDirectPeerAvailable, runUpdate: runtimeHostSshTerminal.runUpdate, - resolveUpdatePackage: runtimeHostSetupPackage, + runUpdatePolicy: runtimeHostSshTerminal.runUpdatePolicy, + runUpdateReconciliation: runtimeHostSshTerminal.runUpdateReconciliation, + setupPackageMode: runtimeHostSetupPackage.mode, + resolveSshDevelopmentPeerTarget: runtimeHostSshTerminal.resolveDevelopmentPeerTarget, + resolveUpdatePackage: runtimeHostSetupPackage.resolve, currentHostEpoch: (profileId) => runtimeHostManager?.current(profileId)?.candidate?.client.hostEpoch, awaitUpdatedConnection: async ( @@ -491,30 +687,20 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ mainWindowController.send("runtime-host-management:progress", progress), runAccessManagement: runtimeHostSshTerminal.runAccessManagement, cleanupManagedDeployment: runtimeHostSshTerminal.cleanupManagedDeployment, + providers: [localRuntimeHostManagement], +}); +const runtimeHostPeerMeshManagement = createDesktopRuntimeHostPeerMeshManagement({ + ipcMain, + localMesh: () => runtimeHostPeerMesh, + localHost: localRuntimeHostRemoteAccess, + runLocal: localRuntimeHostOperator.runPeerMesh, + liveHost: (profileId) => runtimeHostManager?.current(profileId)?.candidate?.client, + profiles: runtimeHostProfileService, + runRemote: runtimeHostSshTerminal.runPeerMeshManagement, + readConnectivityPolicy: () => readDesktopRuntimeHostWebRtcStunPolicy(userDataDir), + writeConnectivityPolicy: (policy) => + writeDesktopRuntimeHostWebRtcStunPolicy(userDataDir, policy), }); - -function runtimeHostSetupPackage(): DesktopRuntimeHostSetupPackage { - if (!app.isPackaged && process.env.MAKA_RUNTIME_HOST_SETUP_ARCHIVE) { - return { kind: "development_archive", path: process.env.MAKA_RUNTIME_HOST_SETUP_ARCHIVE }; - } - const manifestPath = app.isPackaged - ? join(app.getAppPath(), "package.json") - : join(app.getAppPath(), "..", "..", "packages", "cli", "package.json"); - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { - name?: unknown; - version?: unknown; - runtimeHostSetupPackage?: unknown; - }; - const specifier = app.isPackaged - ? manifest.runtimeHostSetupPackage - : manifest.name === "maka-agent" && typeof manifest.version === "string" - ? `maka-agent@${manifest.version}` - : undefined; - if (!isExactRuntimeHostSetupPackageSpecifier(specifier)) { - throw new Error("Desktop does not declare an exact Runtime Host setup package"); - } - return { kind: "npm", specifier }; -} const defaultRuntimeHostRecovery = createRuntimeHostDefaultRecovery({ defaultProfileId: () => runtimeHostManager?.defaultProfileId() ?? @@ -559,7 +745,7 @@ const selectedDesktopWorkspaceTarget = async ( if (typeof current.projectId === "string") { return { kind: "project", projectId: current.projectId }; } - if (target.kind === "remote") return undefined; + if (runtimeHostProfileUsesHostWorkspace(target.kind)) return undefined; return { kind: "host_path", path: current.path }; }; const currentDesktopWorkspaceTarget = async ( @@ -567,7 +753,7 @@ const currentDesktopWorkspaceTarget = async ( ): Promise => { const workspace = await selectedDesktopWorkspaceTarget(target); if (!workspace) { - throw new Error("Select a project from the remote Runtime Host first"); + throw new Error("Select a project from the Runtime Host first"); } return workspace; }; @@ -598,12 +784,23 @@ const clientSettingsEffects = createClientSettingsEffects({ console.error("[icon] failed to apply the app icon:", error), ); }, + systemPrefersDark: () => nativeTheme.shouldUseDarkColors, observeLocale: (settings) => desktopLocale.observe(settings), emitExternalChanged: () => { mainWindowController.send("settings:clientChanged"); sendActiveRuntimeHostEvent("settings:externalChanged", { ts: Date.now() }); }, }); +// An OS appearance flip changes no setting, so nothing else would notice it. +// Only the icon depends on the answer, and `refresh` re-resolves it and +// no-ops when the resolved tile is the one already applied — which is the +// case for every user who has not set a separate dark icon. +nativeTheme.on("updated", () => { + void clientSettingsEffects.refresh(false).catch((error) => { + console.error("[icon] failed to re-apply the app icon after a theme change:", error); + }); +}); + const clientSettingsTools = buildClientSettingsTools({ read: () => settingsStore.get(), update: async (patch) => { @@ -612,16 +809,20 @@ const clientSettingsTools = buildClientSettingsTools({ return settings; }, confirm: async (changes) => { - const copy = clientSettingsConfirmation(changes, await desktopLocale.resolve()); - const result = await dialog.showMessageBox({ - type: "question", - message: copy.message, - detail: copy.detail, - buttons: copy.buttons, - defaultId: 0, - cancelId: 1, - noLink: true, - }); + const locale = await desktopLocale.resolve(); + const copy = clientSettingsConfirmation(changes, locale); + const result = await showDesktopMessageBox( + { + type: "question", + message: copy.message, + detail: copy.detail, + buttons: copy.buttons, + defaultId: 0, + cancelId: 1, + noLink: true, + }, + { locale }, + ); return result.response === 0; }, }); @@ -643,17 +844,44 @@ const updateMockState = process.env.MAKA_UPDATE_MOCK_STATE === "downloaded" ? process.env.MAKA_UPDATE_MOCK_STATE : undefined; +const updateTestFeed = process.env.MAKA_UPDATE_TEST_FEED; +const desktopUpdateChannel = app.isPackaged + ? desktopUpdateChannelFromManifest( + JSON.parse(readFileSync(join(app.getAppPath(), "package.json"), "utf8")), + ) + : "release"; const updateService = createAppUpdateService({ currentVersion: app.getVersion(), isPackaged: app.isPackaged, - testFeedUrl: process.env.MAKA_UPDATE_TEST_FEED, + updateChannel: desktopUpdateChannel, + testFeedUrl: updateTestFeed, mockLatestVersion: process.env.MAKA_UPDATE_MOCK_VERSION, mockState: updateMockState, onStatusChange: (status) => mainWindowController.send("app:updateStatusChanged", status), + // The loopback-only upgrade harness owns synthetic bytes that cannot carry + // a GitHub Actions identity, so it tests updater mechanics rather than + // provenance. Ordinary packaged launches have no override and always reach + // the Sigstore verifier below. + verifyDownloadedUpdate: updateTestFeed + ? async () => {} + : ({ downloadedFile, version }) => + verifyDownloadedUpdateAttestation({ + channel: desktopUpdateChannel, + downloadedFile, + version, + trustRootCacheDirectory: join(userDataDir, "update-trust", "sigstore"), + }), prepareInstall: async (input) => { if (!runtimeHostManager) throw new Error("Runtime Host manager is unavailable"); - return runtimeHostManager.prepareForUpdate(input.allowInterruptActiveTasks); + const retirement = await runtimeHostManager.retireOwnedLocalHost( + input.allowInterruptActiveTasks ? "interrupt_active_work" : "refuse_active_work", + ); + if (retirement.kind === "active_tasks") return retirement; + return { + kind: "prepared", + rollback: retirement.kind === "retired" ? retirement.resume : () => {}, + }; }, }); mcpManager.onChange(() => { @@ -665,6 +893,12 @@ mcpManager.onChange(() => { registerPersistentClientIpc(); registerPetPackIpc({ ipcMain, workspaceRoot, mainWindowController, settingsStore }); +const workBoardIpc = registerWorkBoardIpc({ + ipcMain, + workspaceRoot, + mainWindowController, + store: workBoardStore, +}); const browserIpc = registerBrowserIpc({ mainWindowController, isHostActive: (scope) => runtimeHostManager?.ownsScope(scope) === true, @@ -678,12 +912,13 @@ registerNotificationsIpc({ }); const sessionCopyOwnerProcessId = randomUUID(); -runtimeHostManager = await startRuntimeHostDesktopManager( +const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( { rootPath: workspaceRoot, clientInstanceId: runtimeHostClientInstanceId, generation: runtimeHostGeneration, candidateLaunchBarrier: runtimeHostCandidateLaunchBarrier, + ...(runtimeHostPeerClient ? { peerClient: runtimeHostPeerClient } : {}), // The Desktop E2E composition lives behind its own entry module, which // release packaging drops: picking it here is what keeps FakeBackend and // the E2E bootstrap out of the shipped Runtime Host. @@ -701,6 +936,17 @@ runtimeHostManager = await startRuntimeHostDesktopManager( resizeImage: resizeImageForAttachment, nativeCapabilities: { browserTools: native.browserTools, + resolveBrowserUrl: ({ sessionId, toolName, arguments: args }) => { + if (toolName === "browser_navigate") { + if (typeof args.url !== "string") { + throw new Error("Browser navigation URL is unavailable"); + } + return args.url; + } + const url = browserViewHost().currentUrl(sessionId); + if (!url) throw new Error("Browser session has no current URL"); + return url; + }, releaseBrowserSession, computerUseTools: native.computerUseTools, additionalGroups: () => { @@ -777,7 +1023,7 @@ runtimeHostManager = await startRuntimeHostDesktopManager( input, { ...currentTarget.projectManagement, - ...(target.kind === "local" + ...(!runtimeHostProfileUsesHostWorkspace(target.kind) ? { defaultProjectId: async () => (await settingsStore.get()).projects.defaultProjectId, @@ -785,7 +1031,7 @@ runtimeHostManager = await startRuntimeHostDesktopManager( : {}), }, currentTarget.projectCatalog, - { allowHostPath: target.kind === "local" }, + { allowHostPath: !runtimeHostProfileUsesHostWorkspace(target.kind) }, ); }, emitSessionsChanged, @@ -802,6 +1048,11 @@ runtimeHostManager = await startRuntimeHostDesktopManager( console.error("[runtime-host] projection refresh failed:", error), registerClientIpc: registerHostClientIpc, openSshTunnel: runtimeHostSshTerminal.openSshTunnel, + activateSshOperator: runtimeHostSshTerminal.activateSshOperator, + resolveLocalCollaborationConnectionTarget: () => + localRuntimeHostRemoteAccess.createCollaborationConnectionTarget(), + resolveProfileCollaborationConnectionTarget: (profile) => + runtimeHostProfileService.resolveCollaborationConnectionTarget(profile), }, { upgradePrompts: createRuntimeHostUpgradePrompts( @@ -817,6 +1068,10 @@ runtimeHostManager = await startRuntimeHostDesktopManager( profileId: state.target.profile.id, profileName: state.target.profile.name, profileKind: state.target.profile.kind, + profileAccess: runtimeHostProfileAccess(state.target.profile), + ...(state.collaborationAuthority === undefined + ? {} + : { collaborationAuthority: state.collaborationAuthority }), ...(hostId ? { hostId } : {}), readiness: state.readiness, isDefault: @@ -831,7 +1086,10 @@ runtimeHostManager = await startRuntimeHostDesktopManager( console.error("[runtime-host] Browser target retirement failed:", error), ); } - if (state.readiness === "unavailable") { + if ( + state.readiness === 'unavailable' && + state.target.profile.id === runtimeHostManager?.defaultProfileId() + ) { defaultRuntimeHostRecovery.offer({ profileId: state.target.profile.id, profileName: state.target.profile.name, @@ -853,6 +1111,10 @@ runtimeHostManager = await startRuntimeHostDesktopManager( profileId: state.target.profile.id, profileName: state.target.profile.name, profileKind: state.target.profile.kind, + profileAccess: runtimeHostProfileAccess(state.target.profile), + ...(state.collaborationAuthority === undefined + ? {} + : { collaborationAuthority: state.collaborationAuthority }), ...(hostId ? { hostId } : {}), readiness: "unavailable", isDefault: @@ -876,6 +1138,10 @@ runtimeHostManager = await startRuntimeHostDesktopManager( profileId, profileName: state?.target.profile.name ?? profileId, profileKind: state?.target.profile.kind ?? "remote", + profileAccess: state ? runtimeHostProfileAccess(state.target.profile) : "owner", + ...(state?.collaborationAuthority === undefined + ? {} + : { collaborationAuthority: state.collaborationAuthority }), ...(state?.readiness === "ready" ? { hostId: state.candidate.client.hostId } : state?.readiness !== "unavailable" && state && "hostId" in state && state.hostId @@ -885,7 +1151,15 @@ runtimeHostManager = await startRuntimeHostDesktopManager( isDefault: true, }); }, + recoverLocalHost: (signal) => localRuntimeHostRemoteAccess.recoverBeforeLocalHostStart(signal), + resolveLocalHostReplacement: (registration, signal) => + localRuntimeHostRemoteAccess.resolveConflictingHostReplacement(registration, signal), onFatalError: (error, target) => { + if ( + !runtimeHostManager && + target.profile.kind === "local" && + canRepairManagedRuntimeHostStartup(error) + ) return; if (error instanceof RuntimeHostUpgradeCancelledError) { if (target.profile.kind === "local") app.quit(); return; @@ -894,8 +1168,49 @@ runtimeHostManager = await startRuntimeHostDesktopManager( if (target.profile.kind === "local") app.quit(); }, }, -).catch((error: unknown) => { - if (error instanceof RuntimeHostUpgradeCancelledError) { +); +runtimeHostManager = await startDesktopRuntimeHostWithRecovery({ + start: async () => { + await localRuntimeHostRemoteAccess.recoverBeforeLocalHostStart(); + return startLocalRuntimeHostManager(); + }, + repair: async ({ allowManualUpdate, allowInterruptActiveTasks }) => { + console.warn('[runtime-host] repairing the managed Local Host before startup'); + const result = await localRuntimeHostRemoteAccess.repairManagedStartup({ + allowManualUpdate, + allowInterruptActiveTasks, + }); + console.log(`[runtime-host] managed Local Host repair result: ${result.kind}`); + return result; + }, + prompt: async (input) => { + console.error('[runtime-host] managed Local Host startup recovery requires attention:', { + startupError: input.startupError, + repairError: input.repairError, + activeTasks: input.activeTasks, + }); + const locale = await desktopLocale.resolve(); + return showRuntimeHostStartupRecoveryDialog(input, { + locale, + showMessageBox: (options) => showDesktopMessageBox(options, { locale }), + copyDiagnostics: () => + copyDesktopDiagnosticReport( + desktopDiagnostics, + createDesktopStartupDiagnosticInput({ + title: 'Runtime Host startup recovery', + description: input.startupError.message, + details: [input.startupError.stack, input.repairError?.stack] + .filter(Boolean) + .join('\n\n'), + }), + ), + }); + }, +}).catch((error: unknown) => { + if ( + error instanceof RuntimeHostUpgradeCancelledError || + error instanceof DesktopRuntimeHostStartupRecoveryCancelledError + ) { app.quit(); return new Promise(() => undefined); } @@ -903,6 +1218,12 @@ runtimeHostManager = await startRuntimeHostDesktopManager( }); wireLifecycle(); runtimeHostManager.setDefaultProfile(runtimeHostStartup.preferences.defaultProfileId); +await guestSessionMountService.start().catch((error: unknown) => { + console.error('[runtime-host] shared Sessions could not be restored:', error); +}); +await localRuntimeHostRemoteAccess.recover().catch((error: unknown) => { + console.error('[runtime-host] interrupted Local Host setup could not be recovered:', error); +}); void runtimeHostProfileService.startEnabledProfiles(); const unavailableDefault = runtimeHostStartup.unavailable.get( runtimeHostStartup.preferences.defaultProfileId, @@ -978,6 +1299,7 @@ function registerHostClientIpc( scope: DesktopTargetScope, isTargetActive: () => boolean, ): () => Promise { + const usesHostWorkspace = runtimeHostProfileUsesHostWorkspace(target.kind); const sendToRenderer = (channel: string, ...args: unknown[]): void => { if (isTargetActive()) mainWindowController.send(channel, scope, ...args); }; @@ -998,7 +1320,7 @@ function registerHostClientIpc( }); const targetProjectCatalog = createRuntimeHostProjectCatalog(() => ({ client, - includeHostPaths: target.kind === "local", + includeHostPaths: !usesHostWorkspace, })); const targetProjectManagement = createProjectManagementService({ catalog: targetProjectCatalog, @@ -1011,7 +1333,7 @@ function registerHostClientIpc( return result.canceled ? undefined : result.filePaths[0]; }, selection: targetProjectRoot, - capabilities: target.kind === "local" + capabilities: !usesHostWorkspace ? { chooseClientDirectory: true, chooseHostDirectory: false, @@ -1041,6 +1363,11 @@ function registerHostClientIpc( emitTargetConnectionListChanged(); sendToRenderer("settings:externalChanged", { ts: Date.now() }); }); + // No `settings:externalChanged` here: the user's settings did not move, the + // Host just resolved the same connections against a newer model catalog. + const unsubscribeConnectionCatalogChanges = client.subscribeConnectionCatalogChanges(() => { + emitTargetConnectionListChanged(); + }); const unsubscribeSessionCatalogChanges = client.subscribeSessionCatalogChanges( ({ sessionId }) => emitTargetSessionsChanged("updated", sessionId), ); @@ -1115,7 +1442,7 @@ function registerHostClientIpc( client, workspaceRoot, openPath: (path) => shell.openPath(path), - allowLocalPaths: target.kind === "local", + allowLocalPaths: !usesHostWorkspace, }); const settingsIpcDeps = { ipcMain: scopedIpc, @@ -1169,7 +1496,7 @@ function registerHostClientIpc( return { kind: "project", projectId }; } if (projectId === null) { - if (target.kind === "remote") return undefined; + if (usesHostWorkspace) return undefined; return { kind: "host_path", path: (await requireRuntimePolicyTarget(target).projectManagement.current()).path, @@ -1180,7 +1507,7 @@ function registerHostClientIpc( getDefaultPermissionMode: () => resolveDefaultPermissionMode(() => loadRuntimeHostSettings(settingsIpcDeps)), openPath: (path) => shell.openPath(path), - allowLocalPaths: target.kind === "local", + allowLocalPaths: !usesHostWorkspace, }); registerRuntimeHostSearchIpc({ ipcMain: scopedIpc, client }); registerRuntimeHostUsageIpc({ @@ -1191,7 +1518,7 @@ function registerHostClientIpc( registerRuntimeHostWorkspaceIpc({ ipcMain: scopedIpc, client, - allowLocalWorkspace: target.kind === "local", + allowLocalWorkspace: !usesHostWorkspace, }); const resolveProjectRootForContext = (sessionId: unknown): Promise => resolveProjectContextRoot(sessionId, { @@ -1212,7 +1539,7 @@ function registerHostClientIpc( buildInfo, e2eFixture, projectManagement: targetProjectManagement, - allowLocalProjectPaths: target.kind === "local", + allowLocalProjectPaths: !usesHostWorkspace, }, scopedIpc, ); @@ -1226,7 +1553,7 @@ function registerHostClientIpc( if (!path) throw new Error(`Project is unavailable: ${projectId}`); return path; }, - allowLocalWorkspace: target.kind === "local", + allowLocalWorkspace: !usesHostWorkspace, }); const onboardingService = createOnboardingService({ listConnections: async () => @@ -1254,7 +1581,7 @@ function registerHostClientIpc( ({ slug }) => slug === connection.slug, ); if (!entry) return false; - const authKind = PROVIDER_DEFAULTS[entry.providerType].authKind; + const authKind = PROVIDER_REGISTRY[entry.providerType].authKind; const status = await client.queryCredential({ scope: "connection", connectionId: entry.connectionId, @@ -1266,7 +1593,7 @@ function registerHostClientIpc( const taskSubmissionReadinessService = createDesktopTaskSubmissionReadinessService({ workspaceRoot, runtimeState: () => ({ state: client.lifecycleState, checkedAt: Date.now() }), - ...(target.kind === "remote" + ...(usesHostWorkspace ? { inspectWorkspace: async () => "ready" as const } : {}), resolveModelTarget: (requestedSlug) => @@ -1286,7 +1613,7 @@ function registerHostClientIpc( } const entry = catalog.connections.find(({ slug }) => slug === connection.slug); if (!entry) return { kind: "connection_missing", connectionSlug } as const; - const authKind = PROVIDER_DEFAULTS[entry.providerType].authKind; + const authKind = PROVIDER_REGISTRY[entry.providerType].authKind; const hasSecret = await client.queryCredential({ scope: "connection", connectionId: entry.connectionId, @@ -1299,6 +1626,7 @@ function registerHostClientIpc( registerTaskSubmissionReadinessIpc(taskSubmissionReadinessService, scopedIpc); return async () => { unsubscribeConfigurationChanges(); + unsubscribeConnectionCatalogChanges(); unsubscribeSessionCatalogChanges(); unsubscribeProjectCatalogChanges(); unsubscribeScheduledTaskChanges(); @@ -1337,6 +1665,11 @@ function registerPersistentClientIpc(): void { }); registerMarkdownSaveIpc({ ipcMain, mainWindowController }); registerDesktopRuntimeHostProfileIpc(ipcMain, runtimeHostProfileService); + registerDesktopGuestSessionMountIpc( + ipcMain, + guestSessionMountService, + () => clipboard.readText(), + ); registerClientSettingsIpc({ ipcMain, settingsStore, @@ -1360,12 +1693,6 @@ function registerPersistentClientIpc(): void { } : {}), }); - ipcMain.handle("settings:usageStats", async (_event, range?: UsageRange) => - projectDesktopUsageStats( - { hostId: localRuntimeHostId }, - await settingsStore.usageStats(range), - ), - ); ipcMain.handle("sessions:unobserve", async (_event, observerId: unknown) => { if (typeof observerId !== "string" || observerId.length === 0 || observerId.length > 256) { throw new Error("Invalid Session observer identity"); @@ -1400,39 +1727,65 @@ function registerPersistentClientIpc(): void { ); }, ); + const projectRuntimeHostIdentity = ( + epoch: string, + target: ResolvedRuntimeHostProfile, + readiness: 'ready' | 'reconnecting', + hostId: string, + collaborationAuthority?: boolean, + ): DesktopRuntimeHostIdentity => ({ + hostId, + targetEpoch: epoch, + profileId: target.profile.id, + profileName: target.profile.name, + profileKind: target.profile.kind, + profileAccess: runtimeHostProfileAccess(target.profile), + ...(collaborationAuthority === undefined ? {} : { collaborationAuthority }), + readiness, + }); ipcMain.handle("runtime-host:activeIdentity", () => { const current = runtimeHostManager?.current(); if (!current?.hostId) { throw new Error("Desktop Runtime Host identity is unavailable"); } - return { - hostId: current.hostId, - targetEpoch: current.epoch, - profileId: current.target.profile.id, - profileName: current.target.profile.name, - profileKind: current.target.profile.kind, - readiness: current.readiness, - }; + return projectRuntimeHostIdentity( + current.epoch, + current.target, + current.readiness, + current.hostId, + current.collaborationAuthority, + ); }); ipcMain.handle("runtime-host:identities", () => (runtimeHostManager?.entries() ?? []).flatMap((state) => { - const hostId = state.readiness === "ready" - ? state.candidate.client.hostId - : state.readiness === "reconnecting" - ? state.hostId - : undefined; + if (state.readiness !== "ready" && state.readiness !== "reconnecting") return []; + const hostId = state.readiness === "ready" ? state.candidate.client.hostId : state.hostId; if (!hostId) return []; - return [{ - hostId, - targetEpoch: state.epoch, - profileId: state.target.profile.id, - profileName: state.target.profile.name, - profileKind: state.target.profile.kind, - readiness: state.readiness, - }]; + return [ + projectRuntimeHostIdentity( + state.epoch, + state.target, + state.readiness, + hostId, + state.collaborationAuthority, + ), + ]; }), ); registerDesktopDiagnosticsIpc({ ipcMain, ...desktopDiagnostics }); + ipcMain.handle('directories:pick', async () => { + const local = runtimeHostManager?.entries().find( + (state) => state.target.profile.kind === 'local', + ); + if (!local || local.readiness !== 'ready') throw new Error('Local Runtime Host is unavailable'); + const hostId = local.candidate.client.hostId; + const result = await mainWindowController.showOpenDialog({ + title: 'Reference folder', + properties: ['openDirectory'], + }); + if (result.canceled || !result.filePaths[0]) return { ok: false, reason: 'cancelled' }; + return { ok: true, reference: { hostId, path: result.filePaths[0] } }; + }); ipcMain.handle("attachments:pickFiles", async (event) => { const result = await mainWindowController.showOpenDialog({ title: "Add attachments", @@ -1441,13 +1794,10 @@ function registerPersistentClientIpc(): void { if (result.canceled || !result.filePaths[0]) return { ok: false, reason: "cancelled" }; const { stat } = await import("node:fs/promises"); - const chosen = await Promise.all( - result.filePaths.map(async (path) => ({ - path, - name: basename(path), - size: (await stat(path)).size, - })), - ); + // Route by content, not the extension: each picked path is staged under the + // kind its sniffed MIME implies, so a real image named `report.pdf` previews + // and triggers the vision notice, and a disguised file does neither. + const chosen = await resolvePickedAttachments(result.filePaths, (path) => stat(path)); return { ok: true, files: attachmentApprovals.issueApprovals(event.sender.id, chosen), @@ -1521,11 +1871,18 @@ function emitSessionsChanged( function wireLifecycle(): void { const quitCoordinator = createAppQuitCoordinator({ + prepareToQuit: prepareRuntimeHostDesktopQuit, cleanup: closeRuntimeHostDesktop, focusOrCreateWindow: (signal) => { if (mainWindowController.hasOpenWindows()) mainWindowController.focus(); else return mainWindowController.createWindow(signal); }, + onPreparationError: (error) => { + console.error("[runtime-host] quit retirement failed:", error); + void showRuntimeHostQuitFailure(error).catch((dialogError) => + console.error("[runtime-host] quit failure dialog failed:", dialogError), + ); + }, onCleanupError: (error) => console.error("[runtime-host] shutdown failed:", error), onWindowCreationError: (error) => @@ -1547,21 +1904,49 @@ function wireLifecycle(): void { app.on("window-all-closed", () => { native.computerUseOverlay.destroyAll(); native.computerUsePip.destroyAll(); - if (process.platform !== "darwin") app.quit(); + if (process.platform !== "darwin" && !isBrowserMessageBoxPresentationActive()) app.quit(); }); app.on("before-quit", quitCoordinator.handleBeforeQuit); quitCoordinator.focusOrCreateWindow(); } +async function prepareRuntimeHostDesktopQuit(): Promise { + mainWindowController.browserWindow()?.destroy(); + const retirement = await runtimeHostManager?.retireOwnedLocalHost( + "interrupt_active_work", + ); + if (retirement?.kind === "active_tasks") { + throw new Error("Runtime Host refused authorized quit retirement"); + } +} + +async function showRuntimeHostQuitFailure(error: unknown): Promise { + const locale = await desktopLocale.resolve(); + await showDesktopMessageBox(buildRuntimeHostQuitFailureDialog(error, locale), { locale }); +} + async function closeRuntimeHostDesktop(): Promise { clientSettingsWatcher.stop(); updateService.dispose(); settingsBotsIpc?.dispose(); permissionOverlay.dismiss(); + const guestMountShutdown = Promise.resolve().then(() => guestSessionMountService.close()); + const runtimeHostManagerShutdown = guestMountShutdown + .catch(() => undefined) + .then(() => runtimeHostManager?.close()); + const runtimeHostPeerShutdown = runtimeHostManagerShutdown + .catch(() => undefined) + .then(() => runtimeHostPeerOwner?.close() ?? runtimeHostPeerClient?.close()); const results = await Promise.allSettled([ Promise.resolve().then(() => runtimeHostManagement.close()), - runtimeHostManager?.close(), + Promise.resolve().then(() => runtimeHostPeerMeshManagement.close()), + guestMountShutdown, + runtimeHostManagerShutdown, + runtimeHostPeerShutdown, runtimeHostOnboarding.close(), + localRuntimeHostRemoteAccess.close(), + runtimeHostSetupPackage.close(), + Promise.resolve().then(() => workBoardIpc.close()), runtimeHostSshTerminal.close(), botRegistry.stopAll(), mcpManager.close(), @@ -1605,26 +1990,20 @@ async function confirmDesktopStorageRootRepair( console.log( "[storage-root] root-identity conflict; parking at repair dialog", ); - const isChinese = - resolveSystemUiLocale(app.getPreferredSystemLanguages()) === "zh"; + const locale = resolveSystemUiLocale(app.getPreferredSystemLanguages()); + const copy = getNativeDiagnosticDialogCopy(locale).storageRootRepair; const { response } = await showStartupDiagnosticDialog( { type: "warning", - title: isChinese ? "Maka 工作区需要修复" : "Maka workspace needs repair", - message: isChinese - ? "Maka 无法验证这个工作区。" - : "Maka cannot verify this workspace.", - detail: isChinese - ? `系统中的磁盘标识可能发生了变化。仅当这是本机原来的 Maka 工作区、而不是复制出的工作区时,才选择修复。\n\n${workspaceRoot}` - : `The disk identity may have changed. Repair only if this is the original Maka workspace on this computer, not a copied workspace.\n\n${workspaceRoot}`, - buttons: isChinese - ? ["修复工作区", "退出"] - : ["Repair Workspace", "Exit"], + title: copy.title, + message: copy.message, + detail: copy.detail(workspaceRoot), + buttons: [copy.repair, copy.exit], defaultId: 1, cancelId: 1, noLink: true, }, - isChinese ? "zh" : "en", + locale, ); return response === 0; } @@ -1633,28 +2012,12 @@ async function promptForDefaultRuntimeHostRecovery(input: { readonly profileName: string; readonly error: Error; }): Promise<"retry" | "use_local" | "keep_offline"> { - const isChinese = - resolveSystemUiLocale(app.getPreferredSystemLanguages()) === "zh"; + const locale = await desktopLocale.resolve(); + const dialogInput = defaultRuntimeHostRecoveryDialog({ ...input, locale }); const { response } = await showStartupDiagnosticDialog( - { - type: "warning", - title: isChinese - ? "默认 Runtime Host 无法连接" - : "Default Runtime Host is unavailable", - message: isChinese - ? `无法连接 ${input.profileName}` - : `Could not connect to ${input.profileName}`, - detail: isChinese - ? `${input.error.message}\n\n你可以重试、改用 Local 作为默认 Host,或保持当前选择并稍后在设置中处理。` - : `${input.error.message}\n\nRetry, use Local as the default Host, or keep the current selection and resolve it later in Settings.`, - buttons: isChinese - ? ["重试", "改用 Local", "保持离线"] - : ["Retry", "Use Local", "Keep Offline"], - defaultId: 0, - cancelId: 2, - noLink: true, - }, - isChinese ? "zh" : "en", + dialogInput.options, + locale, + dialogInput.diagnosticDetails, ); return response === 0 ? "retry" : response === 1 ? "use_local" : "keep_offline"; } diff --git a/apps/desktop/src/main/runtime-host-bot-session-adapter.ts b/apps/desktop/src/main/runtime-host-bot-session-adapter.ts index e0d0f31a90..9f7fdfc275 100644 --- a/apps/desktop/src/main/runtime-host-bot-session-adapter.ts +++ b/apps/desktop/src/main/runtime-host-bot-session-adapter.ts @@ -76,7 +76,7 @@ export function createRuntimeHostBotSessionAdapter( name: input.name, labels: [...input.labels], modelTarget: { kind: 'default' }, - permissionMode: 'explore', + mode: 'bot', }); } catch (error) { if ( diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 7fd5c647d0..e1d731f274 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -26,10 +26,12 @@ import { type TurnRecord, } from "@maka/core/session"; import { markPersisted } from "@maka/core/persisted-value"; -import type { Task } from "@maka/core/task-ledger"; +import { + projectSessionTodoItemsForDisplay, + type SessionTodoItem, +} from "@maka/core/session-todo"; import type { - ConnectionCatalogSnapshot, ConnectionVersionBasis, CredentialLocator, CredentialStatus, @@ -46,11 +48,16 @@ import { type DecodedSessionTranscriptPage, type DirectRequestOperationKey, type RuntimeHostConnection, + type RuntimeHostPeerConnectionPath, + type RuntimeHostRetirementMode, + type RuntimeHostRetirementPreparation, type RuntimeHostSessionSubscription, RuntimeHostCatalogReadError, RuntimeHostOperationError, + prepareConnectedRuntimeHostRetirement, readRuntimeHostAgentGraphEpochs, readRuntimeHostConnectionCatalog, + type RuntimeHostConnectionCatalogSnapshot, readRuntimeHostInvocableSkills, readRuntimeHostResources, readRuntimeHostProjectDetails, @@ -77,6 +84,7 @@ import { type MemoryQueryInput, type MemoryQueryResult, type GoalControlAction, + type HostStatusResult, type GoalProjection, type OperationInput, type OperationOutput, @@ -101,7 +109,18 @@ import { type ScheduledTaskChangedFrame, type SessionCatalogItem, type SessionCatalogProjection, - type SessionConfiguration, + type SharedSessionCatalogProjection, + type CollaborationAccessQueryResult, + type CollaborationGrantRevokeResult, + type CollaborationInvitationPrepareResult, + type CollaborationPrincipalRevokeResult, + type CollaborationTurnRequestAcknowledgeResult, + type CollaborationTurnRequestDecideResult, + type CollaborationTurnRequestQueryResult, + type SessionCollaborationGrantKind, + type SessionTurnAccessRequest, + type SessionTurnRequestIntent, + type SessionConfigurationPatch, type SessionAssistantStreamIdentity, type SessionContinuitySnapshot, type SessionTranscriptBootstrap, @@ -140,7 +159,7 @@ const MAX_OPTIMISTIC_ATTEMPTS = 3; const MAX_SESSION_REVISION_ATTEMPTS = 8; const MAX_PRICING_SNAPSHOT_ATTEMPTS = 3; -export type DesktopSessionConfigurationPatch = Partial; +export type DesktopSessionConfigurationPatch = SessionConfigurationPatch; /** * How a remove settled. `restored` is not a failure: the task left the state @@ -148,6 +167,17 @@ export type DesktopSessionConfigurationPatch = Partial; */ export type SessionRemoveDisposition = "removed" | "restored"; +/** + * How a remove settled together with what it archived. `archivedSubtaskCount` + * is the Host's executed count of ordinary linked subtasks moved to the archive + * — 0 when the delete was called off (`restored`) or archived nothing — so the + * renderer's toast reports a fact rather than a renderer-side estimate. + */ +export interface SessionRemoveOutcome { + readonly disposition: SessionRemoveDisposition; + readonly archivedSubtaskCount: number; +} + export type DesktopRuntimeHostClientErrorCode = | "catalog_unstable" | "client_closed" @@ -258,21 +288,87 @@ export class DesktopRuntimeHostClient { return this.connection.rootId; } + get peerPath(): RuntimeHostPeerConnectionPath | undefined { + return this.connection.peerPath; + } + get lifecycleState(): 'ready' | 'unavailable' { return this.#connectionClosed || this.#closeTask ? 'unavailable' : 'ready'; } + status(timeoutMs?: number): Promise { + return this.connection.status(timeoutMs); + } + finalizeAccessCredential( timeoutMs?: number, ): Promise> { return this.request('access.credential.finalize', {}, timeoutMs); } + prepareCollaborationInvitation( + sessionId: string, + grantKinds: readonly SessionCollaborationGrantKind[], + ): Promise { + return this.request('collaboration.invitation.prepare', { sessionId, grantKinds }); + } + + queryCollaborationAccess(sessionId?: string): Promise { + return this.request( + 'collaboration.access.query', + sessionId === undefined ? {} : { sessionId }, + ); + } + + revokeCollaborationGrant(grantId: string): Promise { + return this.request('collaboration.grant.revoke', { grantId }); + } + + revokeCollaborationPrincipal( + principalId: string, + ): Promise { + return this.request('collaboration.principal.revoke', { principalId }); + } + + createCollaborationTurnRequest( + intent: SessionTurnRequestIntent, + ): Promise { + return this.request('collaboration.turn-request.create', { intent }); + } + + queryCollaborationTurnRequests(sessionId?: string): Promise { + return this.request( + 'collaboration.turn-request.query', + sessionId === undefined ? {} : { sessionId }, + ); + } + + acknowledgeCollaborationTurnRequest( + requestId: string, + ): Promise { + return this.request('collaboration.turn-request.acknowledge', { requestId }); + } + + decideCollaborationTurnRequest( + requestId: string, + decision: 'approve' | 'reject', + ): Promise { + return this.request('collaboration.turn-request.decide', { + requestId, + decision, + }); + } + subscribeConfigurationChanges(listener: (revision: number) => void): () => void { this.#assertOpen(); return this.connection.subscribeConfigurationChanges(listener); } + subscribeConnectionCatalogChanges(listener: (revision: number) => void): () => void { + this.#assertOpen(); + return this.connection.subscribeConnectionCatalogChanges(listener); + } + subscribeProjectCatalogChanges(listener: (revision: number) => void): () => void { this.#assertOpen(); return this.connection.subscribeProjectCatalogChanges(listener); @@ -292,7 +388,7 @@ export class DesktopRuntimeHostClient { return this.connection.subscribeScheduledTaskChanges(listener); } - async loadConnectionCatalog(): Promise { + async loadConnectionCatalog(): Promise { this.#assertOpen(); try { return await readRuntimeHostConnectionCatalog(this.connection); @@ -413,11 +509,23 @@ export class DesktopRuntimeHostClient { }); } + verifyConnectionOnboarding( + input: OperationInput<"connection.onboarding.verify">, + ): Promise> { + return this.request("connection.onboarding.verify", input); + } + + saveConnectionOnboarding( + input: OperationInput<"connection.onboarding.save">, + ): Promise> { + return this.request("connection.onboarding.save", input); + } + startOAuthLogin( attemptId: string, - connectionId: string, + target: OperationInput<"oauth.login.start">["target"], ): Promise> { - return this.request("oauth.login.start", { attemptId, connectionId }); + return this.request("oauth.login.start", { attemptId, target }); } queryOAuthLogin( @@ -569,6 +677,11 @@ export class DesktopRuntimeHostClient { } } + async getSharedSession(): Promise { + this.#assertOpen(); + return (await this.request('session.shared.query', {})).session; + } + async listProjects( includeLocations = true, ): Promise<(ProjectCatalogProject | ProjectCatalogProjectDetails)[]> { @@ -827,6 +940,32 @@ export class DesktopRuntimeHostClient { ); } + resolveWorkHubCoordinationSession() { + return this.request("workhub.coordination.resolve", {}); + } + + listWorkHubCoordinationCandidates() { + return this.request("workhub.coordination.candidates", {}); + } + + actWorkHubCoordination( + input: OperationInput<"workhub.coordination.act">, + ): Promise> { + return this.request("workhub.coordination.act", input); + } + + answerWorkHubCoordination( + input: OperationInput<"workhub.coordination.answer">, + ): Promise> { + return this.request("workhub.coordination.answer", input); + } + + recordWorkHubCoordination( + input: OperationInput<"workhub.coordination.record">, + ): Promise> { + return this.request("workhub.coordination.record", input); + } + listExternalSessionSources(): Promise { return this.request("external-session.source.query", {}); } @@ -871,23 +1010,7 @@ export class DesktopRuntimeHostClient { this.request("session.configuration.update", { sessionId, expectedRevision: current.revision, - configuration: { - // An unlocked Session still follows the Host-owned default route. - // Once execution or an explicit model change locks it, the resolved - // catalog route is the explicit target that must survive this patch. - modelTarget: current.connectionLocked - ? { - kind: "explicit", - connectionSlug: current.llmConnectionSlug, - model: current.model, - } - : { kind: "default" }, - thinkingLevel: current.thinkingLevel ?? null, - permissionMode: current.permissionMode, - collaborationMode: current.collaborationMode, - orchestrationMode: current.orchestrationMode, - ...definedPatch, - }, + patch: definedPatch, }), ); } @@ -938,19 +1061,34 @@ export class DesktopRuntimeHostClient { async removeSession( sessionId: string, options: { requireArchived?: boolean } = {}, - ): Promise { + ): Promise { for (let attempt = 0; attempt < MAX_SESSION_REVISION_ATTEMPTS; attempt += 1) { const current = await this.#requireSession(sessionId); - if (options.requireArchived && !current.isArchived) return "restored"; + if (options.requireArchived && !current.isArchived) { + return { disposition: "restored", archivedSubtaskCount: 0 }; + } const result = await this.request("session.remove", { sessionId, expectedRevision: current.revision, }); - if (result.kind === "removed") return "removed"; + if (result.kind === "removed") { + return { disposition: "removed", archivedSubtaskCount: result.archivedSubtaskCount ?? 0 }; + } } throw revisionConflict("remove", sessionId); } + /** + * How many linked subtasks a delete of this parent would move to the archive, + * per the Host's own removal plan. The delete confirm warns off this so the + * renderer never re-derives the plan from a catalog projection that omits the + * operator marker and copy state. + */ + async previewSessionRemoval(sessionId: string): Promise { + const result = await this.request("session.remove.preview", { sessionId }); + return result.archivableSubtaskCount; + } + async removeSessionCopy(sessionId: string): Promise<'removed' | 'retained'> { try { const current = await this.#requireSession(sessionId); @@ -1065,6 +1203,18 @@ export class DesktopRuntimeHostClient { }); } + queryMessages( + input: OperationInput<'turn.message.query'>, + ): Promise> { + return this.request('turn.message.query', input); + } + + queryMessageExecutions( + input: OperationInput<'turn.message.execution.query'>, + ): Promise> { + return this.request('turn.message.execution.query', input); + } + retractQueueEntry( input: Omit, ): Promise { @@ -1147,16 +1297,13 @@ export class DesktopRuntimeHostClient { } queryHostDiagnostics(): Promise> { - return this.connection.queryHostDiagnostics(2_000); + return this.connection.request('host.diagnostics.query', {}, 2_000); } - prepareHostUpgrade( - allowInterruptActiveTasks: boolean, - ): Promise> { - return this.request("host.upgrade.prepare", { - expectedHostEpoch: this.connection.hostEpoch, - allowInterruptActiveTasks, - }); + prepareHostRetirement( + mode: RuntimeHostRetirementMode, + ): Promise { + return prepareConnectedRuntimeHostRetirement(this.connection, mode); } stopTurn( @@ -1195,35 +1342,10 @@ export class DesktopRuntimeHostClient { return this.request("context.compact", input); } - async listTasks(sessionId: string): Promise { - const projection = await collectStableProjection({ - name: "Task ledger", - sessionId, - start: () => - this.request("task.ledger.query", { kind: "list_start", sessionId }), - continue: (first, cursor) => - this.request("task.ledger.query", { - kind: "list_continue", - sessionId, - revision: first.revision, - cursor, - }), - page(result, first) { - if ( - result.kind !== "page" || - result.sessionId !== sessionId || - (first !== undefined && result.revision !== first.revision) - ) { - throw invalidProjection("Task ledger"); - } - return { - source: result, - items: result.tasks, - nextCursor: result.nextCursor, - }; - }, - }); - return projection.items; + async querySessionTodo(sessionId: string): Promise { + const result = await this.request("session.todo.query", { sessionId }); + if (result.sessionId !== sessionId) throw invalidProjection("SessionTodo"); + return projectSessionTodoItemsForDisplay(result.items); } queryUsage( diff --git a/apps/desktop/src/main/runtime-host-collaboration-invitation.ts b/apps/desktop/src/main/runtime-host-collaboration-invitation.ts new file mode 100644 index 0000000000..3137dc2d74 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-collaboration-invitation.ts @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + decodeRemoteRuntimeHostProfile, + type RuntimeHostRemoteTransport, +} from '@maka/runtime-host/client'; +import { decodeCollaborationInvitationCode } from '@maka/runtime-host/protocol'; + +const SCHEMA_VERSION = 1; +export const DESKTOP_COLLABORATION_INVITATION_CODE_MAX_BYTES = 32 * 1024; + +export interface DesktopCollaborationConnectionTarget { + readonly name: string; + readonly transport: RuntimeHostRemoteTransport; +} + +export interface DesktopCollaborationInvitation { + readonly invitationCode: string; + readonly target: DesktopCollaborationConnectionTarget; +} + +export function encodeDesktopCollaborationInvitation( + value: DesktopCollaborationInvitation, +): string { + const invitation = decodeCollaborationInvitationCode(value.invitationCode); + const target = decodeTarget(value.target, invitation.rootId); + return Buffer.from( + JSON.stringify({ + schemaVersion: SCHEMA_VERSION, + invitationCode: value.invitationCode, + target, + }), + 'utf8', + ).toString('base64url'); +} + +export function decodeDesktopCollaborationInvitation( + code: string, +): DesktopCollaborationInvitation { + if ( + !code || + Buffer.byteLength(code, 'utf8') > DESKTOP_COLLABORATION_INVITATION_CODE_MAX_BYTES + ) { + throw new Error('Invalid Desktop collaboration invitation'); + } + let value: unknown; + try { + value = JSON.parse(Buffer.from(code, 'base64url').toString('utf8')) as unknown; + } catch { + throw new Error('Invalid Desktop collaboration invitation'); + } + if (!isRecord(value) || !hasExactKeys(value, ['schemaVersion', 'invitationCode', 'target'])) { + throw new Error('Invalid Desktop collaboration invitation'); + } + if (value.schemaVersion !== SCHEMA_VERSION || typeof value.invitationCode !== 'string') { + throw new Error('Unsupported Desktop collaboration invitation'); + } + const invitation = decodeCollaborationInvitationCode(value.invitationCode); + return { + invitationCode: value.invitationCode, + target: decodeTarget(value.target, invitation.rootId), + }; +} + +function decodeTarget(value: unknown, rootId: string): DesktopCollaborationConnectionTarget { + if (!isRecord(value) || !hasExactKeys(value, ['name', 'transport'])) { + throw new Error('Invalid Desktop collaboration connection target'); + } + const profile = decodeRemoteRuntimeHostProfile({ + id: 'collaboration-target', + name: value.name, + kind: 'remote', + rootId, + transport: value.transport, + }); + return { + name: profile.name, + transport: profile.transport, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, expected: readonly string[]): boolean { + const keys = Object.keys(value).sort(); + return keys.length === expected.length && expected.slice().sort().every((key, index) => key === keys[index]); +} diff --git a/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts b/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts new file mode 100644 index 0000000000..0044747b06 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts @@ -0,0 +1,167 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; +import { RuntimeHostOperationError } from '@maka/runtime-host/client'; +import type { CollaborationTurnRequestQueryResult } from '@maka/runtime-host/protocol'; +import { + encodeDesktopCollaborationInvitation, + type DesktopCollaborationConnectionTarget, +} from './runtime-host-collaboration-invitation.js'; +import { + handleReconnectableRead, + type ReconnectableReadIpcMain, +} from './ipc-reconnect-policy.js'; + +export function registerRuntimeHostCollaborationIpc( + client: Pick< + DesktopRuntimeHostClient, + | 'prepareCollaborationInvitation' + | 'acknowledgeCollaborationTurnRequest' + | 'createCollaborationTurnRequest' + | 'decideCollaborationTurnRequest' + | 'queryCollaborationTurnRequests' + | 'queryCollaborationAccess' + | 'revokeCollaborationGrant' + | 'revokeCollaborationPrincipal' + >, + ipcMain: ReconnectableReadIpcMain, + resolveConnectionTarget: () => + | DesktopCollaborationConnectionTarget + | Promise, +): void { + ipcMain.handle( + 'session-collaboration:prepare', + async ( + _event, + sessionId: unknown, + preset: unknown, + allowInsecure: unknown, + ) => { + const target = await resolveConnectionTarget(); + if (target.transport.kind === 'plaintext' && allowInsecure !== true) { + return { kind: 'insecure_confirmation_required' } as const; + } + const prepared = await client.prepareCollaborationInvitation( + requiredId(sessionId, 'Session'), + requiredPreset(preset) === 'request_turn' + ? ['session_observation', 'session_turn_request'] + : ['session_observation'], + ); + return { + kind: 'prepared', + invitation: { + ...prepared, + invitationCode: encodeDesktopCollaborationInvitation({ + invitationCode: prepared.invitationCode, + target, + }), + connectivity: + target.transport.kind === 'libp2p-direct' + ? { + kind: 'peer' as const, + coordinationRelayCount: target.transport.coordinationRelays.length, + } + : { kind: 'configured' as const }, + }, + }; + }, + ); + ipcMain.handle( + 'session-collaboration:turn-request:create', + (_event, intent: unknown) => + client.createCollaborationTurnRequest(intent as Parameters< + DesktopRuntimeHostClient['createCollaborationTurnRequest'] + >[0]), + ); + handleReconnectableRead( + ipcMain, + 'session-collaboration:turn-request:query', + async (_event, sessionId: unknown) => { + try { + return await client.queryCollaborationTurnRequests( + sessionId === undefined ? undefined : requiredId(sessionId, 'Session'), + ); + } catch (error) { + if ( + error instanceof RuntimeHostOperationError && + error.code === 'operation_unavailable' + ) { + return { canRequestTurns: false, requests: [] } satisfies CollaborationTurnRequestQueryResult; + } + throw error; + } + }, + ); + ipcMain.handle( + 'session-collaboration:turn-request:acknowledge', + (_event, requestId: unknown) => + client.acknowledgeCollaborationTurnRequest(requiredId(requestId, 'Turn request')), + ); + ipcMain.handle( + 'session-collaboration:turn-request:decide', + (_event, requestId: unknown, decision: unknown) => + client.decideCollaborationTurnRequest( + requiredId(requestId, 'Turn request'), + requiredDecision(decision), + ), + ); + handleReconnectableRead( + ipcMain, + 'session-collaboration:getAccess', + (_event, sessionId: unknown) => + client.queryCollaborationAccess(requiredId(sessionId, 'Session')), + ); + ipcMain.handle( + 'session-collaboration:revokeGrant', + (_event, grantId: unknown) => + client.revokeCollaborationGrant(requiredId(grantId, 'Grant')), + ); + ipcMain.handle( + 'session-collaboration:revokePrincipal', + (_event, principalId: unknown) => + client.revokeCollaborationPrincipal(requiredId(principalId, 'Principal')), + ); +} + +function requiredId(value: unknown, label: string): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 512 || + /[\u0000-\u001f\u007f]/u.test(value) + ) { + throw new Error(`Invalid ${label} identity`); + } + return value; +} + +function requiredPreset(value: unknown): 'observe' | 'request_turn' { + if (value !== 'observe' && value !== 'request_turn') { + throw new Error('Invalid collaboration invitation preset'); + } + return value; +} + +function requiredDecision(value: unknown): 'approve' | 'reject' { + if (value !== 'approve' && value !== 'reject') { + throw new Error('Invalid collaboration Turn request decision'); + } + return value; +} diff --git a/apps/desktop/src/main/runtime-host-config-ipc-main.ts b/apps/desktop/src/main/runtime-host-config-ipc-main.ts index 7edc25d629..51d002c19b 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -21,7 +21,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import type { IpcMain } from 'electron'; import type { AppSettings, UpdateAppSettingsInput } from '@maka/core/settings'; import type { LlmConnection } from '@maka/core/llm-connections'; -import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import type { ConnectionCatalogEntry, CredentialLocator, @@ -51,7 +51,7 @@ import { type ConfigCategory, type ConfigData, type ConnectionConflictStrategy, -} from '@maka/storage'; +} from '@maka/storage/config-transfer'; interface RuntimeHostConfigIpcDeps { readonly ipcMain: Pick; @@ -365,7 +365,7 @@ function restoreHostSettingsSecrets( function connectionCredentialLocator( connection: ConnectionCatalogEntry, ): Extract | null { - const kind = PROVIDER_DEFAULTS[connection.providerType].authKind; + const kind = PROVIDER_REGISTRY[connection.providerType].authKind; if (kind === 'none') return null; return { scope: 'connection', diff --git a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts index 64716cf624..deeb731da8 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -20,25 +20,34 @@ import type { ConnectionTestResult, CreateConnectionInput, + IdentifiedLlmConnection, LlmConnection, SavedRequestHeaders, UpdateConnectionInput, } from '@maka/core/llm-connections'; import { buildChatModelChoices } from '@maka/core/chat-model-choice'; +import type { ProjectedLlmConnection } from '@maka/core/llm-connections'; import { connectionEnabledModelIds, defaultEnabledModelIdsWhenOmitted, - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, providerAuthRequiresSecret, } from '@maka/core/llm-connections'; import { normalizeRelayModelProfiles } from '@maka/core/model-thinking'; +import type { CredentialLocator } from '@maka/core/runtime-policy'; import type { - ConnectionCatalogEntry, - ConnectionCatalogSnapshot, - CredentialLocator, -} from '@maka/core/runtime-policy'; + RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry, + RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot, +} from '@maka/runtime-host/client'; import { normalizeRequestHeaderUpdates } from '@maka/core/runtime-policy'; -import type { ConnectionTestRunResult } from '@maka/runtime-host/protocol'; +import { + CONNECTION_EFFECT_OPERATION_SPECS, + type ConnectionTestRunResult, +} from '@maka/runtime-host/protocol'; +import { + RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, +} from '@maka/runtime-host/client'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; import { handleReconnectableRead, @@ -50,7 +59,11 @@ import { normalizeConnectionSlugForIpc, normalizeCreateConnectionInputForIpc, } from './connections-ipc-validation.js'; -import type { DesktopConnectionSnapshot } from '../shared/desktop-connection-snapshot.js'; +import type { + DesktopConnectionIdentity, + DesktopConnectionSnapshot, +} from '../shared/desktop-connection-snapshot.js'; +import type { DesktopConnectionOnboardingSaveOutcome } from '../preload/bridge-contract.js'; type HostConnectionsClient = Pick< DesktopRuntimeHostClient, @@ -66,6 +79,8 @@ type HostConnectionsClient = Pick< | 'setDefaultConnectionTarget' | 'testConnection' | 'updateConnection' + | 'verifyConnectionOnboarding' + | 'saveConnectionOnboarding' >; export interface RuntimeHostConnectionsIpcDeps { @@ -88,9 +103,9 @@ export function registerRuntimeHostConnectionsIpc( chatModelChoices: buildChatModelChoices(connections), } satisfies DesktopConnectionSnapshot; }); - handleReconnectableRead(deps.ipcMain, 'connections:hasSecret', async (_event, slug: unknown) => { + handleReconnectableRead(deps.ipcMain, 'connections:hasSecret', async (_event, identity: unknown) => { const catalog = await snapshot(); - const connection = requireConnection(catalog, slug); + const connection = requireConnectionIdentity(catalog, identity); if (!providerAuthRequiresSecret(connection.providerType)) return true; return ( (await deps.client.queryCredential(connectionCredential(connection))) @@ -100,8 +115,8 @@ export function registerRuntimeHostConnectionsIpc( handleReconnectableRead( deps.ipcMain, 'connections:getRequestHeaders', - async (_event, slug: unknown) => { - const connection = requireConnection(await snapshot(), slug); + async (_event, identity: unknown) => { + const connection = requireConnectionIdentity(await snapshot(), identity); const result = await deps.client.getConnectionRequestHeaders(connection.connectionId); if (result.kind !== 'found') throw new Error('Connection no longer exists'); return { names: result.names } satisfies SavedRequestHeaders; @@ -109,8 +124,8 @@ export function registerRuntimeHostConnectionsIpc( ); deps.ipcMain.handle( 'connections:setRequestHeaders', - async (_event, slug: unknown, rawUpdates: unknown) => { - const connection = requireConnection(await snapshot(), slug); + async (_event, identity: unknown, rawUpdates: unknown) => { + const connection = requireConnectionIdentity(await snapshot(), identity); const result = await deps.client.replaceConnectionRequestHeaders( connection.connectionId, normalizeRequestHeaderUpdates(rawUpdates), @@ -120,17 +135,28 @@ export function registerRuntimeHostConnectionsIpc( return { names: result.names } satisfies SavedRequestHeaders; }, ); - deps.ipcMain.handle('connections:setDefault', async (_event, slug: unknown) => { + deps.ipcMain.handle('connections:setDefault', async (_event, identity: unknown) => { const catalog = await snapshot(); - const target = slug === null + const target = identity === null ? null - : defaultTargetForConnection(requireConnection(catalog, slug)); + : defaultTargetForConnection(requireConnectionIdentity(catalog, identity)); requireCommitted( await deps.client.setDefaultConnectionTarget(catalog.revision, target), 'set default Connection', ); deps.emitConnectionListChanged(); }); + deps.ipcMain.handle('connections:setDefaultBySlug', async (_event, slug: unknown) => { + const catalog = await snapshot(); + requireCommitted( + await deps.client.setDefaultConnectionTarget( + catalog.revision, + defaultTargetForConnection(requireConnection(catalog, slug)), + ), + 'set default Connection', + ); + deps.emitConnectionListChanged(); + }); deps.ipcMain.handle('connections:setDefaultModel', async (_event, input: unknown) => { const catalog = await snapshot(); const target = input === null ? null : explicitDefaultTarget(catalog, input); @@ -140,6 +166,37 @@ export function registerRuntimeHostConnectionsIpc( ); deps.emitConnectionListChanged(); }); + deps.ipcMain.handle('connections:onboardingVerify', async (_event, raw: unknown) => { + const input = CONNECTION_EFFECT_OPERATION_SPECS[ + 'connection.onboarding.verify' + ].decodeInput(raw); + return deps.client.verifyConnectionOnboarding(input); + }); + deps.ipcMain.handle('connections:onboardingSave', async (_event, raw: unknown) => { + const input = CONNECTION_EFFECT_OPERATION_SPECS[ + 'connection.onboarding.save' + ].decodeInput(raw); + try { + const result = await deps.client.saveConnectionOnboarding(input); + if (result.kind === 'saved') deps.emitConnectionListChanged(); + return { kind: 'result', result } satisfies DesktopConnectionOnboardingSaveOutcome; + } catch (error) { + if (error instanceof RuntimeHostOperationError) { + return { + kind: error.code === 'commit_outcome_unknown' ? 'outcome_unknown' : 'not_saved', + } satisfies DesktopConnectionOnboardingSaveOutcome; + } + if (error instanceof RuntimeHostRequestInterruptedError) { + return { + kind: error.dispatch === 'not_dispatched' ? 'not_saved' : 'outcome_unknown', + } satisfies DesktopConnectionOnboardingSaveOutcome; + } + // A protocol/decode failure can arrive only after the command response + // has started coming back. Without affirmative evidence that the Host + // did not commit, allowing another create risks duplicating the account. + return { kind: 'outcome_unknown' } satisfies DesktopConnectionOnboardingSaveOutcome; + } + }); deps.ipcMain.handle('connections:create', async (_event, raw: unknown) => { const input = normalizeCreateInput(raw); const catalog = await snapshot(); @@ -192,9 +249,9 @@ export function registerRuntimeHostConnectionsIpc( deps.emitConnectionListChanged(); return requireProjectedConnection(await snapshot(), input.slug); }); - deps.ipcMain.handle('connections:update', async (_event, rawSlug: unknown, rawPatch: unknown) => { + deps.ipcMain.handle('connections:update', async (_event, rawIdentity: unknown, rawPatch: unknown) => { const catalog = await snapshot(); - const current = requireConnection(catalog, rawSlug); + const current = requireConnectionIdentity(catalog, rawIdentity); const patch = normalizeUpdateInput(current, rawPatch); const updated = await deps.client.updateConnection( { connectionId: current.connectionId, revision: current.revision }, @@ -226,7 +283,7 @@ export function registerRuntimeHostConnectionsIpc( if (patch.apiKey !== undefined) await updateCredential(deps.client, current, patch.apiKey); if (patch.defaultModel !== undefined) { const latest = await snapshot(); - const entry = requireConnection(latest, current.slug); + const entry = requireConnectionIdentity(latest, connectionIdentity(current)); const target = patch.defaultModel ? { connectionId: entry.connectionId, modelId: patch.defaultModel } : latest.defaultTarget?.connectionId === entry.connectionId @@ -238,9 +295,10 @@ export function registerRuntimeHostConnectionsIpc( ); } deps.emitConnectionListChanged(); - return requireProjectedConnection(await snapshot(), current.slug); + return requireProjectedConnectionIdentity(await snapshot(), connectionIdentity(current)); }); - deps.ipcMain.handle('connections:delete', async (_event, slug: unknown) => { + deps.ipcMain.handle('connections:delete', async (_event, rawIdentity: unknown) => { + const identity = normalizeConnectionIdentity(rawIdentity); // OAuth/model-fetch can bump the connection revision under the UI. Retry // on connection_stale with a fresh snapshot so delete does not fail with a // opaque "service unavailable" after the user already confirmed. @@ -249,10 +307,11 @@ export function registerRuntimeHostConnectionsIpc( const catalog = await snapshot(); let current: ReturnType; try { - current = requireConnection(catalog, slug); + current = requireConnectionIdentity(catalog, identity); } catch (error) { - // Only treat a missing slug as success. Invalid input must still fail. - if (error instanceof Error && error.message.startsWith('No such Connection:')) { + // The exact entity is already gone. A new entity may reuse its slug; + // deleting that replacement would violate the detail route's binding. + if (error instanceof Error && error.message.startsWith('No such Connection identity:')) { deps.emitConnectionListChanged(); return; } @@ -274,22 +333,31 @@ export function registerRuntimeHostConnectionsIpc( throw new Error('Unable to delete Connection: connection_stale'); } }); - deps.ipcMain.handle('connections:fetchModels', async (_event, slug: unknown) => { - const current = requireConnection(await snapshot(), slug); + deps.ipcMain.handle('connections:fetchModels', async (_event, identity: unknown) => { + const current = requireConnectionIdentity(await snapshot(), identity); const result = await deps.client.fetchConnectionModels(current.connectionId); if (result.kind !== 'committed') { throw new Error(`Unable to fetch Connection models: ${result.kind}`); } deps.emitConnectionListChanged(); - const latest = requireConnection(await snapshot(), current.slug); - return { - models: [...latest.models], - source: result.source, - fetchedAt: result.fetchedAt, - }; + const latest = requireConnectionIdentity(await snapshot(), connectionIdentity(current)); + return { models: [...latest.models], source: result.source }; }); deps.ipcMain.handle( 'connections:test', + async (_event, identity: unknown, options?: { model?: unknown }) => { + const current = requireConnectionIdentity(await snapshot(), identity); + const model = options?.model; + if (model !== undefined && (typeof model !== 'string' || model.length === 0)) { + throw new Error('Invalid Connection test model'); + } + const result = await deps.client.testConnection(current.connectionId, model); + deps.emitConnectionListChanged(); + return projectHostConnectionTest(result); + }, + ); + deps.ipcMain.handle( + 'connections:testBySlug', async (_event, slug: unknown, options?: { model?: unknown }) => { const current = requireConnection(await snapshot(), slug); const model = options?.model; @@ -325,13 +393,16 @@ export function projectHostConnectionTest(result: ConnectionTestRunResult): Conn }; } -export function projectHostConnections(catalog: ConnectionCatalogSnapshot): LlmConnection[] { +export function projectHostConnections( + catalog: ConnectionCatalogSnapshot, +): ProjectedLlmConnection[] { return catalog.connections.map((connection) => { const defaultModel = catalog.defaultTarget?.connectionId === connection.connectionId ? catalog.defaultTarget.modelId : ''; return { + connectionId: connection.connectionId, slug: connection.slug, name: connection.name, providerType: connection.providerType, @@ -340,6 +411,7 @@ export function projectHostConnections(catalog: ConnectionCatalogSnapshot): LlmC defaultModel, enabledModelIds: [...connection.enabledModelIds], models: [...connection.models], + catalogEntries: connection.catalogEntries, ...(connection.relayModelProfiles === undefined ? {} : { relayModelProfiles: connection.relayModelProfiles }), @@ -347,9 +419,6 @@ export function projectHostConnections(catalog: ConnectionCatalogSnapshot): LlmC ? {} : { requestBodyOverlay: connection.requestBodyOverlay }), ...(connection.modelSource === undefined ? {} : { modelSource: connection.modelSource }), - ...(connection.modelsFetchedAt === undefined - ? {} - : { modelsFetchedAt: connection.modelsFetchedAt }), ...(connection.lastTest === undefined ? {} : { @@ -398,7 +467,7 @@ async function updateCredential( } function connectionCredential(connection: ConnectionCatalogEntry): CredentialLocator { - const authKind = PROVIDER_DEFAULTS[connection.providerType].authKind; + const authKind = PROVIDER_REGISTRY[connection.providerType].authKind; return { scope: 'connection', connectionId: connection.connectionId, @@ -422,6 +491,43 @@ function requireConnection( return connection; } +function normalizeConnectionIdentity(value: unknown): DesktopConnectionIdentity { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('Invalid Connection identity'); + } + const record = value as Record; + const keys = Object.keys(record).sort(); + if (keys.length !== 2 || keys[0] !== 'connectionId' || keys[1] !== 'slug') { + throw new Error('Invalid Connection identity'); + } + if (typeof record.connectionId !== 'string' || record.connectionId.length === 0) { + throw new Error('Connection identity id is required'); + } + return { + connectionId: record.connectionId, + slug: normalizeConnectionSlugForIpc(record.slug, 'connection identity slug'), + }; +} + +function requireConnectionIdentity( + catalog: ConnectionCatalogSnapshot, + value: unknown, +): ConnectionCatalogEntry { + const identity = normalizeConnectionIdentity(value); + const connection = catalog.connections.find( + (candidate) => candidate.connectionId === identity.connectionId, + ); + if (!connection) throw new Error(`No such Connection identity: ${identity.connectionId}`); + if (connection.slug !== identity.slug) { + throw new Error('Connection identity no longer matches its slug'); + } + return connection; +} + +function connectionIdentity(connection: ConnectionCatalogEntry): DesktopConnectionIdentity { + return { connectionId: connection.connectionId, slug: connection.slug }; +} + function requireProjectedConnection( catalog: ConnectionCatalogSnapshot, slug: string, @@ -431,6 +537,18 @@ function requireProjectedConnection( return connection; } +function requireProjectedConnectionIdentity( + catalog: ConnectionCatalogSnapshot, + identity: DesktopConnectionIdentity, +): ProjectedLlmConnection { + const connection = requireConnectionIdentity(catalog, identity); + const projected = projectHostConnections(catalog).find( + (candidate) => candidate.connectionId === connection.connectionId, + ); + if (!projected) throw new Error(`No such Connection identity: ${identity.connectionId}`); + return projected; +} + function defaultTargetForConnection(connection: ConnectionCatalogEntry) { const modelId = connection.enabledModelIds[0]; if (!modelId) throw new Error(`Connection has no enabled model: ${connection.slug}`); diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 37396022b1..8f2d460c44 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -22,11 +22,15 @@ import type { IpcMain } from "electron"; import type { ActiveInteractionRequestEvent } from '@maka/core/events'; import { redactSecrets } from '@maka/core/redaction'; import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs'; +import { isSideConversationSession } from '@maka/core/side-conversation'; import type { SessionChangedEvent, SessionChangedReason } from '@maka/core/session'; import type { BotRegistry } from '@maka/runtime/bots'; import { + type RuntimeHostSshOperatorActivationInput, connectOrSpawnRuntimeHost, - connectRemoteRuntimeHostProfile, + connectRuntimeHostProfile, + type RuntimeHostPeerClient, + type RuntimeHostConnectionPhase, type RuntimeHostSshInteraction, type RuntimeHostSshTunnel, type RuntimeHostSshTunnelInput, @@ -35,12 +39,20 @@ import { type RuntimeHostConnection, type RuntimeHostCandidateLaunchBarrier, type RuntimeHostSpawnedProcess, - type RemoteRuntimeHostProfile, + type PersistedRuntimeHostProfile, + runtimeHostProfileAccess, + type RuntimeHostProfileAccess, + type CandidateExitDetails, } from "@maka/runtime-host/client"; +import type { RuntimeHostActivationResult } from "@maka/runtime-host/operator"; +import { + runtimeHostProfileUsesHostWorkspace, + type RuntimeHostProfileKind, +} from "@maka/runtime-host/profile-kind"; import { INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, RUNTIME_HOST_PROTOCOL_VERSION, - type HostRegistration, + type HostStatusResult, type WorkspaceTarget, } from "@maka/runtime-host/protocol"; import type { AttachmentApprovalRegistry } from "./attachment-approval.js"; @@ -53,21 +65,34 @@ import { DesktopRuntimeHostClient } from "./runtime-host-client.js"; import type { SessionCopyCleanupAuthority, SessionCopyCleanupDisposition, -} from "./quote-companion-cleanup.js"; +} from "@maka/storage/session-copy-cleanup"; import { createDesktopNativeCapabilityProvider, type DesktopNativeCapabilityProvider, type DesktopNativeCapabilityProviderInput, } from "./runtime-host-native-capabilities.js"; -import { registerRuntimeHostSessionCatalogIpc } from "./runtime-host-session-catalog-ipc-main.js"; +import { + registerRuntimeHostSharedSessionCatalogIpc, + registerRuntimeHostSessionCatalogIpc, + toDesktopHostSharedSessionSummary, +} from "./runtime-host-session-catalog-ipc-main.js"; +import { registerRuntimeHostWorkHubIpc } from "./runtime-host-workhub-ipc-main.js"; import { registerRuntimeHostExternalSessionsIpc } from "./runtime-host-external-sessions-ipc-main.js"; +import { registerRuntimeHostCollaborationIpc } from './runtime-host-collaboration-ipc-main.js'; +import type { DesktopCollaborationConnectionTarget } from './runtime-host-collaboration-invitation.js'; +import { registerRuntimeHostAttachmentPreviewIpc } from './runtime-host-artifacts-ipc-main.js'; import { registerRuntimeHostSessionDomainsIpc, type RuntimeHostSessionDomainsIpcDeps, type RuntimeHostSessionDomainsIpcHandle, } from "./runtime-host-session-domains-ipc-main.js"; +import { + registerRuntimeHostShellRunQueriesIpc, + type RuntimeHostShellRunQueriesIpcHandle, +} from './runtime-host-shell-runs-ipc-main.js'; import { registerRuntimeHostSessionExecutionIpc, + registerRuntimeHostSessionObservationIpc, type RuntimeHostSessionExecutionIpcDeps, } from "./runtime-host-session-execution-ipc-main.js"; import { RuntimeHostSessionObservationRegistry } from "./runtime-host-session-observation-registry.js"; @@ -123,6 +148,14 @@ export interface DesktopRuntimeHostCandidateDeps { readonly openSshTunnel?: ( input: RuntimeHostSshTunnelInput, ) => Promise; + readonly activateSshOperator?: ( + input: RuntimeHostSshOperatorActivationInput, + ) => Promise; + readonly resolveLocalCollaborationConnectionTarget?: () => + Promise; + readonly resolveProfileCollaborationConnectionTarget?: ( + profile: PersistedRuntimeHostProfile, + ) => Promise; readonly createSessionCopyCleanup: (input: { removeSession: (sessionId: string) => Promise; resumeSessionCopy: (input: { @@ -130,6 +163,7 @@ export interface DesktopRuntimeHostCandidateDeps { kind: 'branch' | 'revision'; sourceSessionId: string; sourceTurnId: string; + intent?: 'side_conversation'; }) => Promise; }) => SessionCopyCleanupAuthority; readonly registerClientIpc?: ( @@ -142,17 +176,18 @@ export interface DesktopRuntimeHostCandidateDeps { ) => void | (() => void | Promise); } -export type DesktopRuntimeHostTargetPolicy = - | { readonly kind: "local"; readonly rootId: string } - | { - readonly kind: "remote"; - readonly rootId: string; - }; +export interface DesktopRuntimeHostTargetPolicy { + readonly kind: RuntimeHostProfileKind; + readonly rootId: string; + readonly access: RuntimeHostProfileAccess; +} export interface DesktopRuntimeHostCandidateControls { refreshClientCapabilities(): Promise; } +export type DesktopRuntimeHostOwnership = 'owned_ephemeral' | 'supervised' | 'external'; + export interface DesktopRuntimeHostCandidateStartInput extends Omit { readonly ipcMain: CandidateIpcMain; @@ -165,10 +200,15 @@ export interface DesktopRuntimeHostCandidateStartInput readonly generation?: string; readonly takeoverHostEpoch?: string; readonly signal?: AbortSignal; + /** Candidate-exit sink forwarded to the launcher; the Desktop owns the sink. */ + readonly onExit?: (details: CandidateExitDetails) => void; readonly candidateLaunchBarrier?: RuntimeHostCandidateLaunchBarrier; - readonly remote?: { - readonly profile: RemoteRuntimeHostProfile; - readonly credential: string; + readonly peerClient?: RuntimeHostPeerClient; + readonly onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void; + readonly onHostStatus?: (status: HostStatusResult) => void; + readonly profileTarget?: { + readonly profile: PersistedRuntimeHostProfile; + readonly credential?: string; readonly sshInteraction?: RuntimeHostSshInteraction; }; } @@ -184,7 +224,9 @@ export interface DesktopRuntimeHostCandidate { readonly botIncoming: BotIncomingMainService; readonly client: DesktopRuntimeHostClient; readonly closed: Promise; - readonly hostLifecycleMode: HostRegistration["lifecycleMode"] | "remote"; + readonly hostOwnership: DesktopRuntimeHostOwnership; + readonly hostPid?: number; + readonly ownedProcess?: RuntimeHostSpawnedProcess; stopSession(sessionId: string): Promise; close(): Promise; } @@ -193,7 +235,9 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { readonly botIncoming: BotIncomingMainService; readonly client: DesktopRuntimeHostClient; readonly closed: Promise; - readonly hostLifecycleMode: HostRegistration["lifecycleMode"] | "remote"; + readonly hostOwnership: DesktopRuntimeHostOwnership; + readonly hostPid: number | undefined; + readonly ownedProcess: RuntimeHostSpawnedProcess | undefined; readonly #client: DesktopRuntimeHostClient; readonly #observer: RuntimeHostSessionObserver; readonly #ipc: ScopedIpcMain; @@ -218,7 +262,9 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { detachSessionObservations: () => void; closeSessionObservations: () => Promise; connectionClosed: Promise; - hostLifecycleMode: HostRegistration["lifecycleMode"] | "remote"; + hostOwnership: DesktopRuntimeHostOwnership; + hostPid?: number; + ownedProcess?: RuntimeHostSpawnedProcess; hasRegisteredCapabilities: () => boolean; stopSession: (sessionId: string) => Promise; }) { @@ -235,7 +281,9 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { this.#hasRegisteredCapabilities = input.hasRegisteredCapabilities; this.#stopSession = input.stopSession; this.botIncoming = input.botIncoming; - this.hostLifecycleMode = input.hostLifecycleMode; + this.hostOwnership = input.hostOwnership; + this.hostPid = input.hostPid; + this.ownedProcess = input.ownedProcess; this.closed = input.connectionClosed.then(() => this.close()); } @@ -279,10 +327,10 @@ export async function startDesktopRuntimeHostCandidate( observationRegistry?: RuntimeHostSessionObservationRegistry, ): Promise { const ipcMain = requireTargetIpcMain(input.ipcMain); - if (input.remote) { - return startRemoteDesktopRuntimeHostCandidate( + if (input.profileTarget) { + return startProfileDesktopRuntimeHostCandidate( input, - input.remote, + input.profileTarget, observationRegistry, ipcMain, ); @@ -299,8 +347,13 @@ export async function startDesktopRuntimeHostCandidate( connection.connection, { ...input, ipcMain }, observationRegistry, - connection.registration.lifecycleMode, + connection.registration.lifecycleMode === 'ephemeral' + ? 'owned_ephemeral' + : 'supervised', "local", + 'owner', + connection.registration.pid, + connection.spawnedProcess, ), }; } catch (error) { @@ -309,6 +362,16 @@ export async function startDesktopRuntimeHostCandidate( } } +function noGuestBotService(): BotIncomingMainService { + return { + handleBotIncomingMessage: async () => { + throw new Error('Session Guest profiles cannot receive bot messages'); + }, + invalidateSessionBindings: () => undefined, + close: async () => undefined, + }; +} + function observeLocalRuntimeHostProcess(spawnedProcess: RuntimeHostSpawnedProcess | undefined): void { if (!spawnedProcess) return; void spawnedProcess.exited.then( @@ -355,15 +418,17 @@ function redactRuntimeHostStderr(stderr: string): string { return redacted.replace(/\S+/gu, (token) => redactSecrets(token)); } -async function startRemoteDesktopRuntimeHostCandidate( +async function startProfileDesktopRuntimeHostCandidate( input: DesktopRuntimeHostCandidateStartInput, - remote: NonNullable, + profileTarget: NonNullable, observationRegistry: RuntimeHostSessionObservationRegistry | undefined, ipcMain: RuntimeHostTargetIpcMain, ): Promise { - const connection = await connectRemoteRuntimeHostProfile({ - profile: remote.profile, - credential: remote.credential, + const connection = await connectRuntimeHostProfile({ + profile: profileTarget.profile, + ...(profileTarget.credential === undefined + ? {} + : { credential: profileTarget.credential }), clientInstanceId: input.clientInstanceId ?? randomUUID(), ...(input.signal === undefined ? {} : { signal: input.signal }), ...(input.connectTimeoutMs === undefined @@ -373,12 +438,22 @@ async function startRemoteDesktopRuntimeHostCandidate( ? {} : { handshakeTimeoutMs: input.handshakeTimeoutMs }), readyTimeoutMs: input.electionDeadlineMs ?? 45_000, - ...(remote.sshInteraction === undefined + ...(input.peerClient === undefined ? {} : { peerClient: input.peerClient }), + ...(input.onConnectionPhase === undefined ? {} - : { sshInteraction: remote.sshInteraction }), + : { onConnectionPhase: input.onConnectionPhase }), + ...(input.onHostStatus === undefined + ? {} + : { onHostStatus: input.onHostStatus }), + ...(profileTarget.sshInteraction === undefined + ? {} + : { sshInteraction: profileTarget.sshInteraction }), }, { ...(input.openSshTunnel ? { openSshTunnel: input.openSshTunnel } : {}), + ...(input.activateSshOperator + ? { activateSshOperator: input.activateSshOperator } + : {}), }); try { return { @@ -387,8 +462,14 @@ async function startRemoteDesktopRuntimeHostCandidate( connection, { ...input, ipcMain }, observationRegistry, - "remote", - "remote", + 'external', + profileTarget.profile.kind, + runtimeHostProfileAccess(profileTarget.profile), + undefined, + undefined, + profileTarget.profile.kind === 'remote' && input.resolveProfileCollaborationConnectionTarget + ? () => input.resolveProfileCollaborationConnectionTarget!(profileTarget.profile) + : undefined, ), }; } catch (error) { @@ -401,12 +482,19 @@ export async function createDesktopRuntimeHostCandidate( connection: RuntimeHostConnection, deps: DesktopRuntimeHostCandidateDeps, observationRegistry: RuntimeHostSessionObservationRegistry | undefined, - hostLifecycleMode: HostRegistration["lifecycleMode"] | "remote", + hostOwnership: DesktopRuntimeHostOwnership, targetKind: DesktopRuntimeHostTargetPolicy["kind"], + targetAccess: RuntimeHostProfileAccess = 'owner', + hostPid?: number, + ownedProcess?: RuntimeHostSpawnedProcess, + resolveCollaborationConnectionTarget?: () => + | DesktopCollaborationConnectionTarget + | Promise, ): Promise { const target: DesktopRuntimeHostTargetPolicy = { kind: targetKind, rootId: connection.rootId, + access: targetAccess, }; const ipcMain = deps.ipcMain; const scope = { hostId: connection.rootId, targetEpoch: ipcMain.epoch }; @@ -492,6 +580,7 @@ export async function createDesktopRuntimeHostCandidate( }; let observer: RuntimeHostSessionObserver | undefined; let closeSessionDomains: (() => Promise) | undefined; + let sharedShellRuns: RuntimeHostShellRunQueriesIpcHandle | undefined; let disposeClientIpc: (() => void | Promise) | undefined; let observationsAttached = false; let capabilitiesRegistered = false; @@ -510,39 +599,78 @@ export async function createDesktopRuntimeHostCandidate( client, emitSessionsChanged: (reason, sessionId, extra) => emitSessionsChanged(reason, sessionId, extra), - emitSessionDomainChanged: (change) => domains?.sessionDomainChanged(change), + emitSessionDomainChanged: (change) => + target.access === 'session_guest' + ? sharedShellRuns?.sessionDomainChanged(change) + : domains?.sessionDomainChanged(change), emitRuntimeResourcePtyData: (event) => domains?.runtimeResourcePtyData(event), emitAgentGraphChanged: (event) => domains?.agentGraphChanged(event), emitActiveInteractionsChanged, emitSubscriptionRecovered: (sessionId) => - domains?.sessionSubscriptionRecovered(sessionId), + target.access === 'session_guest' + ? sharedShellRuns?.sessionSubscriptionRecovered(sessionId) + : domains?.sessionSubscriptionRecovered(sessionId), emitObservationSeed: (sessionId, phase) => sendToRenderer?.('sessions:observation-seed', { sessionId, phase }), - onWatchedTurnFinished: (sessionId, outcome) => - outcome === "completed" - ? deps.completeComputerUseTurn( - desktopSessionResourceKey({ ...scope, sessionId }), - ) - : deps.nativeCapabilities.releaseComputerUseSession( - desktopSessionResourceKey({ ...scope, sessionId }), - ), + ...(target.access === 'owner' + ? { + onWatchedTurnFinished: (sessionId: string, outcome: 'completed' | 'abandoned') => + outcome === 'completed' + ? deps.completeComputerUseTurn( + desktopSessionResourceKey({ ...scope, sessionId }), + ) + : deps.nativeCapabilities.releaseComputerUseSession( + desktopSessionResourceKey({ ...scope, sessionId }), + ), + } + : {}), recoverConnectionClosed: observationRegistry !== undefined, ...(deps.now ? { now: deps.now } : {}), }); observer = sessionObserver; - domains = registerRuntimeHostSessionDomainsIpc( + if (target.access === 'owner') { + domains = registerRuntimeHostSessionDomainsIpc( + { + client, + sessionObserver, + emitModeChanged, + ...(deps.renderer ? { sendToRenderer } : {}), + ...(deps.onError ? { onError: reportError } : {}), + ...(deps.newId ? { newId: deps.newId } : {}), + ...(deps.now ? { now: deps.now } : {}), + }, + ipc, + ); + closeSessionDomains = domains.close; + } else { + sharedShellRuns = registerRuntimeHostShellRunQueriesIpc( + { client, sendToRenderer, onError: reportError }, + ipc, + ); + } + registerRuntimeHostSessionObservationIpc( { - client, - sessionObserver, - emitModeChanged, - ...(deps.renderer ? { sendToRenderer } : {}), - ...(deps.onError ? { onError: reportError } : {}), - ...(deps.newId ? { newId: deps.newId } : {}), - ...(deps.now ? { now: deps.now } : {}), + observations: sessionObservations, + resolveSideConversation: async (sessionId) => { + if (target.access === 'session_guest') return false; + const session = await client.getSession(sessionId); + if (!session) throw new Error(`Runtime Host Session not found: ${sessionId}`); + return isSideConversationSession(session.labels); + }, }, ipc, ); - closeSessionDomains = domains.close; + if (target.access === 'session_guest') { + const trackedSessionIds = sessionObservations.trackedSessionIds(); + if (trackedSessionIds.length > 0) { + const sharedSessionId = (await client.getSharedSession())?.id; + for (const sessionId of trackedSessionIds) { + if (sessionId === sharedSessionId) continue; + await sessionObservations.forgetSession(sessionId); + emitSessionsChanged('deleted', sessionId); + } + } + } const observedSessionIds = sessionObservations.observedSessionIds(); for (const sessionId of observedSessionIds) { sendToRenderer('sessions:observation-seed', { sessionId, phase: 'pending' }); @@ -575,7 +703,7 @@ export async function createDesktopRuntimeHostCandidate( sendToRenderer('sessions:observation-seed', { sessionId, phase: 'ready' }); emitSessionsChanged("message-appended", sessionId); emitSessionsChanged("goal-change", sessionId); - domains.sessionSubscriptionRecovered(sessionId); + domains?.sessionSubscriptionRecovered(sessionId); emitActiveInteractionsChanged( sessionId, sessionObserver.listActiveInteractions(sessionId) ?? [], @@ -588,11 +716,12 @@ export async function createDesktopRuntimeHostCandidate( }; const createNativeProvider = (): DesktopNativeCapabilityProvider => { let provider: DesktopNativeCapabilityProvider; + const usesHostWorkspace = runtimeHostProfileUsesHostWorkspace(target.kind); provider = createDesktopNativeCapabilityProvider( deps.nativeCapabilities, { - hostPathAccess: target.kind === "local" ? "cwd" : "none", - ...(target.kind === "remote" ? { clientCwd: deps.workspaceRoot } : {}), + hostPathAccess: usesHostWorkspace ? "none" : "cwd", + ...(usesHostWorkspace ? { clientCwd: deps.workspaceRoot } : {}), releaseResourcesOnClose: false, targetScope: scope, nativeSessionId: (sessionId) => @@ -606,13 +735,15 @@ export async function createDesktopRuntimeHostCandidate( providers.add(provider); return provider; }; - const nativeCapabilities = createNativeProvider(); - if ( - nativeCapabilities.offers().length > 0 || - (nativeCapabilities.services?.().length ?? 0) > 0 - ) { - await client.replaceClientCapabilities(nativeCapabilities); - capabilitiesRegistered = true; + if (target.access === 'owner') { + const nativeCapabilities = createNativeProvider(); + if ( + nativeCapabilities.offers().length > 0 || + (nativeCapabilities.services?.().length ?? 0) > 0 + ) { + await client.replaceClientCapabilities(nativeCapabilities); + capabilitiesRegistered = true; + } } let capabilityRefresh = Promise.resolve(); const refreshClientCapabilities = (): Promise => { @@ -630,91 +761,131 @@ export async function createDesktopRuntimeHostCandidate( }); return capabilityRefresh; }; - const sessionCopyCleanup = deps.createSessionCopyCleanup({ - removeSession: async (sessionId) => { - const disposition = await client.removeSessionCopy(sessionId); - if (disposition === "retained") return disposition; - await releaseNativeSession(sessionId).catch(reportError); - emitSessionsChanged("deleted", sessionId); - return disposition; - }, - resumeSessionCopy: async ({ sessionId, kind, sourceSessionId, sourceTurnId }) => { - await client.copySession(kind, { - sourceSessionId, - targetSessionId: sessionId, - sourceTurnId, - }); - }, - }); - const registeredClientIpc = deps.registerClientIpc?.( - client, - ipc, - { refreshClientCapabilities }, - target, - scope, - isTargetActive, - ); - disposeClientIpc = - typeof registeredClientIpc === "function" + const sessionCopyCleanup = target.access === 'owner' + ? deps.createSessionCopyCleanup({ + removeSession: async (sessionId) => { + const disposition = await client.removeSessionCopy(sessionId); + if (disposition === "retained") return disposition; + await releaseNativeSession(sessionId).catch(reportError); + emitSessionsChanged("deleted", sessionId); + return disposition; + }, + resumeSessionCopy: async ({ sessionId, kind, sourceSessionId, sourceTurnId, intent }) => { + await client.copySession(kind, { + sourceSessionId, + targetSessionId: sessionId, + sourceTurnId, + ...(intent ? { intent } : {}), + }); + }, + }) + : undefined; + const registeredClientIpc = target.access === 'owner' + ? deps.registerClientIpc?.( + client, + ipc, + { refreshClientCapabilities }, + target, + scope, + isTargetActive, + ) + : undefined; + disposeClientIpc = target.access === 'session_guest' + ? client.subscribeSessionCatalogChanges(({ sessionId }) => + emitSessionsChanged('updated', sessionId), + ) + : typeof registeredClientIpc === 'function' ? registeredClientIpc : undefined; - registerRuntimeHostSessionCatalogIpc( - { - client, - runningTurnIds: (sessionId) => sessionObserver.observedRunningTurnIds(sessionId), - resolveCreateProject: (input) => deps.resolveSessionCreateProject(input, target), - emitSessionsChanged, - releaseSessionResources: releaseNativeSession, - sessionCopyCleanup, - ...(deps.newId ? { newId: deps.newId } : {}), - }, - ipc, - ); - registerRuntimeHostExternalSessionsIpc( - { - client, - emitSessionsChanged, - }, - ipc, - ); - const stopSession = registerRuntimeHostSessionExecutionIpc( - { - client, - observer: sessionObserver, - observations: sessionObservations, - attachmentApprovals: deps.attachmentApprovals, - emitSessionsChanged, - stat: deps.stat, - resizeImage: deps.resizeImage, - beforeStop: (sessionId) => - deps.nativeCapabilities.releaseComputerUseSession( - desktopSessionResourceKey({ ...scope, sessionId }), - ), - sessionCopyCleanup, - onBackgroundError: reportError, - ...(deps.e2eInteractions - ? { e2eInteractions: deps.e2eInteractions } - : {}), - ...(deps.newId ? { newId: deps.newId } : {}), - }, - ipc, - ); - const botIncoming = createBotIncomingMainService({ - botRegistry: deps.botRegistry, - sessions: createRuntimeHostBotSessionAdapter({ - client, - resolveCreateTarget: () => deps.resolveBotCreateTarget(target), - emitSessionsChanged, - ...(deps.newId ? { newId: deps.newId } : {}), - }), + if (target.access === 'session_guest') { + registerRuntimeHostAttachmentPreviewIpc({ ipcMain: ipc, client }); + registerRuntimeHostSharedSessionCatalogIpc( + { + getSession: async () => { + const session = await client.getSharedSession(); + return session ? toDesktopHostSharedSessionSummary(session) : null; + }, + }, + ipc, + ); + } else { + if (!sessionCopyCleanup) throw new Error('Owner Session copy authority is unavailable'); + registerRuntimeHostSessionCatalogIpc( + { + client, + runningTurnIds: (sessionId) => sessionObserver.observedRunningTurnIds(sessionId), + resolveCreateProject: (input) => deps.resolveSessionCreateProject(input, target), + emitSessionsChanged, + releaseSessionResources: releaseNativeSession, + sessionCopyCleanup, + ...(deps.newId ? { newId: deps.newId } : {}), + }, + ipc, + ); + } + registerRuntimeHostCollaborationIpc(client, ipc, async () => { + if (resolveCollaborationConnectionTarget) return resolveCollaborationConnectionTarget(); + if (target.kind === 'local' && deps.resolveLocalCollaborationConnectionTarget) { + return deps.resolveLocalCollaborationConnectionTarget(); + } + throw new Error('This Runtime Host does not have a shareable connection target'); }); + if (target.access === 'owner') { + registerRuntimeHostWorkHubIpc(client, ipc, { + resolveCreateProject: () => deps.resolveSessionCreateProject({}, target), + emitSessionsChanged, + }); + registerRuntimeHostExternalSessionsIpc( + { + client, + emitSessionsChanged, + }, + ipc, + ); + } + const stopSession = sessionCopyCleanup + ? registerRuntimeHostSessionExecutionIpc( + { + client, + observer: sessionObserver, + attachmentApprovals: deps.attachmentApprovals, + emitSessionsChanged, + stat: deps.stat, + resizeImage: deps.resizeImage, + beforeStop: (sessionId) => + deps.nativeCapabilities.releaseComputerUseSession( + desktopSessionResourceKey({ ...scope, sessionId }), + ), + sessionCopyCleanup, + onBackgroundError: reportError, + ...(deps.e2eInteractions + ? { e2eInteractions: deps.e2eInteractions } + : {}), + ...(deps.newId ? { newId: deps.newId } : {}), + }, + ipc, + ) + : async () => { + throw new Error('Shared Sessions cannot be stopped'); + }; + const botIncoming = target.access === 'owner' + ? createBotIncomingMainService({ + botRegistry: deps.botRegistry, + sessions: createRuntimeHostBotSessionAdapter({ + client, + resolveCreateTarget: () => deps.resolveBotCreateTarget(target), + emitSessionsChanged, + ...(deps.newId ? { newId: deps.newId } : {}), + }), + }) + : noGuestBotService(); return new DesktopRuntimeHostCandidateImpl({ client, observer: sessionObserver, ipc, botIncoming, closeNativeCapabilities, - closeSessionDomains: domains.close, + closeSessionDomains: domains?.close ?? (() => Promise.resolve()), disposeClientIpc, detachSessionObservations: () => sessionObservations.detach(sessionObserver), @@ -723,7 +894,9 @@ export async function createDesktopRuntimeHostCandidate( ? sessionObservations.close() : Promise.resolve(), connectionClosed: connection.closed, - hostLifecycleMode, + hostOwnership, + ...(hostPid === undefined ? {} : { hostPid }), + ...(ownedProcess === undefined ? {} : { ownedProcess }), hasRegisteredCapabilities: () => capabilitiesRegistered, stopSession, }); @@ -770,6 +943,8 @@ function connectInput( ? {} : { handshakeTimeoutMs: input.handshakeTimeoutMs }), ...(input.signal === undefined ? {} : { signal: input.signal }), + ...(input.onExit === undefined ? {} : { onExit: input.onExit }), + closeOnLauncherExit: true, }; } diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index eec13f75f0..3e70e08256 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -20,6 +20,7 @@ import { randomUUID } from 'node:crypto'; import type { BotIncomingMessage } from '@maka/runtime/bots'; import { + abortable, RuntimeHostOperationError, RuntimeHostPermanentReconnectError, RuntimeHostRequestInterruptedError, @@ -27,18 +28,26 @@ import { LOCAL_RUNTIME_HOST_PROFILE, sameResolvedRuntimeHostProfileTarget, startRuntimeHostReconnectLifecycle, + type CandidateExitDetails, type ResolvedRuntimeHostProfile, type RuntimeHostReconnectBackoff, type RuntimeHostReconnectLifecycle, + type RuntimeHostConnectionPhase, + type RuntimeHostRetirementMode, type RuntimeHostSshInteraction, } from '@maka/runtime-host/client'; -import type { HostRegistration } from '@maka/runtime-host/protocol'; +import { + isHostActivityIdle, + type HostRegistration, + type HostStatusResult, +} from '@maka/runtime-host/protocol'; import type { DesktopTargetSessionRef } from '../shared/runtime-host-identity.js'; import { startDesktopRuntimeHostCandidate, type DesktopRuntimeHostCandidate, type DesktopRuntimeHostCandidateStartInput, type DesktopRuntimeHostCandidateStartResult, + type DesktopRuntimeHostOwnership, } from './runtime-host-desktop-candidate.js'; import { RuntimeHostReconnectingIpcMain } from './runtime-host-reconnecting-ipc-main.js'; import { RuntimeHostSessionObservationRegistry } from './runtime-host-session-observation-registry.js'; @@ -61,18 +70,25 @@ export interface RuntimeHostDesktopManager { ): void; unobserveSession(observerId: string): Promise; enable( - remote: DesktopRuntimeHostCandidateStartInput['remote'], + profileTarget: DesktopRuntimeHostCandidateStartInput['profileTarget'], + ): Promise; + mountGuest( + profileTarget: NonNullable, + signal?: AbortSignal, + onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void, + onHostStatus?: (status: HostStatusResult) => void, ): Promise; + finalizeGuestAccess(mountId: string, signal?: AbortSignal): Promise; + unmountGuest(mountId: string): Promise; disable(profileId: string): Promise; waitUntilReady( profileId: string, previousHostEpoch?: string, signal?: AbortSignal, ): Promise; + runManagedLocalHostChange(change: () => Promise): Promise; setDefaultProfile(profileId: string): void; - prepareForUpdate( - allowInterruptActiveTasks: boolean, - ): Promise; + retireOwnedLocalHost(mode: RuntimeHostRetirementMode): Promise; close(): Promise; } @@ -82,6 +98,7 @@ export interface RuntimeHostDesktopTargetSnapshot { readonly target: ResolvedRuntimeHostProfile; readonly readiness: 'ready' | 'reconnecting'; readonly candidate?: DesktopRuntimeHostCandidate; + readonly collaborationAuthority?: boolean; } export type RuntimeHostDesktopTargetState = @@ -90,27 +107,62 @@ export type RuntimeHostDesktopTargetState = readonly target: ResolvedRuntimeHostProfile; readonly readiness: 'connecting' | 'reconnecting'; readonly hostId?: string; + readonly collaborationAuthority?: boolean; } | { readonly epoch: string; readonly target: ResolvedRuntimeHostProfile; readonly readiness: 'ready'; readonly candidate: DesktopRuntimeHostCandidate; + readonly collaborationAuthority?: boolean; } | { readonly epoch: string; readonly target: ResolvedRuntimeHostProfile; readonly readiness: 'unavailable'; readonly hostId?: string; + readonly collaborationAuthority?: boolean; readonly error: Error; }; -export type RuntimeHostUpdatePreparation = +export type DesktopLocalHostRetirement = | { readonly kind: 'active_tasks' } - | { readonly kind: 'prepared'; rollback(): void }; + | { readonly kind: 'not_owned' } + | { readonly kind: 'retired'; resume(): void }; + +interface DesktopLocalHostRetirementTask { + readonly mode: RuntimeHostRetirementMode; + readonly result: Promise; +} + +export interface DesktopLocalHostRetirementFacts { + readonly hostId: string; + readonly hostEpoch: string; + readonly lifecycleMode: 'ephemeral'; + readonly rootPath: string; + readonly pid?: number; +} + +export class DesktopLocalHostRetirementError extends Error { + constructor( + readonly facts: DesktopLocalHostRetirementFacts, + options: ErrorOptions, + ) { + super('Unable to retire the Desktop-owned local Runtime Host', options); + this.name = 'DesktopLocalHostRetirementError'; + } +} export type RuntimeHostRestartDecision = 'restart' | 'wait' | 'cancel'; -export type RuntimeHostWaitDecision = 'wait' | 'cancel'; +export type RuntimeHostNonRestartableDecision = 'replace' | 'wait' | 'cancel'; +export type RuntimeHostNonRestartableAction = + | 'replace_may_interrupt_work' + | 'wait' + | 'cancel_only'; + +export interface RuntimeHostLocalReplacement { + replace(activeWorkPolicy: RuntimeHostRetirementMode): Promise<'replaced' | 'active_tasks'>; +} export class RuntimeHostUpgradeCancelledError extends RuntimeHostPermanentReconnectError { constructor() { @@ -144,7 +196,10 @@ export interface RuntimeHostUpgradePrompts { restartable( conflict: RuntimeHostRestartableConflict, ): Promise; - waitOnly(conflict: RuntimeHostWaitConflict): Promise; + nonRestartable( + conflict: RuntimeHostWaitConflict, + action: RuntimeHostNonRestartableAction, + ): Promise; } interface DesktopRuntimeHostTargetGeneration { @@ -154,11 +209,23 @@ interface DesktopRuntimeHostTargetGeneration { readonly observations: RuntimeHostSessionObservationRegistry; state: RuntimeHostDesktopTargetState; hostId?: string; + collaborationAuthority?: boolean; lifecycle?: RuntimeHostReconnectLifecycle; unsubscribeLifecycle?: () => void; + lastCandidate?: { + readonly hostId: string; + readonly hostEpoch: string; + readonly ownership: DesktopRuntimeHostOwnership; + readonly ownedProcess?: DesktopOwnedProcessEvidence; + }; valid: boolean; } +interface DesktopOwnedProcessEvidence { + readonly pid: number; + state: 'running' | 'exited' | 'unknown'; +} + export async function startRuntimeHostDesktopManager( input: DesktopRuntimeHostCandidateStartInput, options: { @@ -173,6 +240,11 @@ export async function startRuntimeHostDesktopManager( registration: HostRegistration, signal: AbortSignal, ) => Promise; + resolveLocalHostReplacement?: ( + registration: HostRegistration, + signal: AbortSignal, + ) => Promise; + recoverLocalHost?: (signal: AbortSignal) => Promise; reconnectBackoff?: RuntimeHostReconnectBackoff; pairingFinalizationTimeoutMs?: number; onTargetStateChanged?: (state: RuntimeHostDesktopTargetState) => void; @@ -180,7 +252,7 @@ export async function startRuntimeHostDesktopManager( onDefaultProfileChanged?: (profileId: string) => void; } = {}, ): Promise { - if (input.remote) throw new Error('Desktop Runtime Host manager must start with Local'); + if (input.profileTarget) throw new Error('Desktop Runtime Host manager must start with Local'); const manager = new RuntimeHostDesktopManagerImpl( input, options.startCandidate ?? startDesktopRuntimeHostCandidate, @@ -188,6 +260,8 @@ export async function startRuntimeHostDesktopManager( options.upgradePrompts, options.waitForHostExit ?? waitForProcessExit, options.waitForHostRetirement ?? waitForProcessRetirement, + options.resolveLocalHostReplacement, + options.recoverLocalHost, options.reconnectBackoff, options.pairingFinalizationTimeoutMs ?? DEFAULT_PAIRING_FINALIZATION_TIMEOUT_MS, options.onTargetStateChanged, @@ -206,6 +280,8 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { readonly #baseInput: DesktopRuntimeHostCandidateStartInput; readonly #pairingFinalizationShutdown = new AbortController(); #defaultProfileId: string = LOCAL_RUNTIME_HOST_PROFILE.id; + #localHostRetirement: Extract | undefined; + #localHostRetirementTask: DesktopLocalHostRetirementTask | undefined; #closed = false; #closeTask: Promise | undefined; @@ -225,6 +301,15 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { registration: HostRegistration, signal: AbortSignal, ) => Promise, + private readonly resolveLocalHostReplacement: + | (( + registration: HostRegistration, + signal: AbortSignal, + ) => Promise) + | undefined, + private readonly recoverLocalHost: + | ((signal: AbortSignal) => Promise) + | undefined, private readonly reconnectBackoff: RuntimeHostReconnectBackoff | undefined, private readonly pairingFinalizationTimeoutMs: number, private readonly onTargetStateChanged: @@ -271,10 +356,14 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { } finalizePairing(profileId: string): Promise { - return this.#mutateTarget(profileId, () => this.#finalizePairing(profileId)); + return this.#mutateTarget(profileId, () => this.#finalizeAccessCredential(profileId)); + } + + finalizeGuestAccess(mountId: string, signal?: AbortSignal): Promise { + return this.#mutateTarget(mountId, () => this.#finalizeAccessCredential(mountId, signal)); } - async #finalizePairing(profileId: string): Promise { + async #finalizeAccessCredential(profileId: string, externalSignal?: AbortSignal): Promise { const target = this.#requireTarget(profileId); if (target.target.profile.kind !== 'remote') { throw new Error('Only remote Runtime Host profiles can finalize pairing'); @@ -289,6 +378,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { const signal = AbortSignal.any([ this.#pairingFinalizationShutdown.signal, timeout.signal, + ...(externalSignal ? [externalSignal] : []), ]); try { let candidate = await this.#waitForReadyCandidate(lifecycle, undefined, signal); @@ -300,7 +390,15 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { try { const remainingMs = deadline - Date.now(); if (remainingMs <= 0) throw new RuntimeHostPairingFinalizationInterruptedError(); - await candidate.client.finalizeAccessCredential(remainingMs); + const finalized = await abortable( + () => candidate.client.finalizeAccessCredential(remainingMs), + signal, + ); + if (finalized.reconnectRequired) { + await candidate.close(); + await this.#waitForReadyCandidate(lifecycle, candidate, signal); + } + signal.throwIfAborted(); return; } catch (error) { if (pairingFinalizeTimedOut(error)) { @@ -333,12 +431,20 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { ...(target.hostId ? { hostId: target.hostId } : {}), target: target.target, readiness: candidate ? 'ready' : 'reconnecting', + ...(target.collaborationAuthority === undefined + ? {} + : { collaborationAuthority: target.collaborationAuthority }), ...(candidate ? { candidate } : {}), }; } entries(): readonly RuntimeHostDesktopTargetState[] { - return [...this.#targets.values()].map((target) => target.state); + return [...this.#targets.values()].map((target) => ({ + ...target.state, + ...(target.collaborationAuthority === undefined + ? {} + : { collaborationAuthority: target.collaborationAuthority }), + })); } ownsScope(scope: { readonly hostId: string; readonly targetEpoch: string }): boolean { @@ -399,37 +505,69 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { } async enable( - remote: DesktopRuntimeHostCandidateStartInput['remote'], + profileTarget: DesktopRuntimeHostCandidateStartInput['profileTarget'], + ): Promise { + if (!profileTarget) throw new Error('A non-local Runtime Host profile is required'); + if (isSessionGuestProfile(profileTarget.profile)) { + throw new Error('Session Guest targets must be mounted instead of enabled as profiles'); + } + return this.#mutateTarget(profileTarget.profile.id, () => + this.#enable(profileTarget, false), + ); + } + + mountGuest( + profileTarget: NonNullable, + signal?: AbortSignal, + onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void, + onHostStatus?: (status: HostStatusResult) => void, ): Promise { - if (!remote) throw new Error('A remote Runtime Host profile is required'); - return this.#mutateTarget(remote.profile.id, () => this.#enable(remote)); + if (!isSessionGuestProfile(profileTarget.profile)) { + return Promise.reject(new Error('A Session Guest target is required')); + } + return this.#mutateTarget(profileTarget.profile.id, () => + this.#enable(profileTarget, true, signal, onConnectionPhase, onHostStatus), + ); } async #enable( - remote: NonNullable, + profileTarget: NonNullable, + allowSameRoot: boolean, + signal?: AbortSignal, + onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void, + onHostStatus?: (status: HostStatusResult) => void, ): Promise { + signal?.throwIfAborted(); if (this.#closed) throw new Error('Desktop Runtime Host manager is closed'); - const profileId = remote.profile.id; + const profileId = profileTarget.profile.id; if (profileId === LOCAL_RUNTIME_HOST_PROFILE.id) { throw new Error('Local Runtime Host is already enabled'); } for (const target of this.#targets.values()) { if (target.target.profile.id === profileId) continue; - const rootId = target.target.profile.kind === 'remote' + const rootId = target.target.profile.kind !== 'local' ? target.target.profile.rootId : target.hostId; - if (rootId === remote.profile.rootId) { - throw new Error(`Runtime Host ${remote.profile.rootId} is already enabled`); + if ( + rootId === profileTarget.profile.rootId && + !allowSameRoot && + !isSessionGuestProfile(target.target.profile) + ) { + throw new Error(`Runtime Host ${profileTarget.profile.rootId} is already enabled`); } } const existing = this.#targets.get(profileId); if ( existing?.valid && - sameResolvedRuntimeHostProfileTarget(existing.target, remote) + sameResolvedRuntimeHostProfileTarget(existing.target, profileTarget) ) return; if (existing) await this.#removeTarget(existing); - const target = this.#createTarget(withRuntimeHostTarget(this.#baseInput, remote)); + const target = this.#createTarget({ + ...withRuntimeHostTarget(this.#baseInput, profileTarget), + ...(onConnectionPhase ? { onConnectionPhase } : {}), + ...(onHostStatus ? { onHostStatus } : {}), + }); this.#targets.set(profileId, target); this.#publishState(target, { epoch: target.epoch, @@ -437,7 +575,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { readiness: 'connecting', }); try { - target.lifecycle = await this.#startLifecycle(target, false); + target.lifecycle = await this.#startLifecycle(target, false, signal); if (this.#closed) { await target.lifecycle.close(); throw new Error('Desktop Runtime Host manager is closed'); @@ -462,6 +600,16 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { return this.#mutateTarget(profileId, () => this.#disable(profileId)); } + unmountGuest(mountId: string): Promise { + return this.#mutateTarget(mountId, async () => { + const target = this.#targets.get(mountId); + if (target && !isSessionGuestProfile(target.target.profile)) { + throw new Error('Runtime Host target is not a Session Guest mount'); + } + await this.#disable(mountId); + }); + } + async waitUntilReady( profileId: string, previousHostEpoch?: string, @@ -491,18 +639,75 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { await this.#removeTarget(target); } + runManagedLocalHostChange(change: () => Promise): Promise { + return this.#mutateTarget(LOCAL_RUNTIME_HOST_PROFILE.id, async () => { + const lifecycle = this.#requireLifecycle( + this.#requireTarget(LOCAL_RUNTIME_HOST_PROFILE.id), + ); + const suspension = await lifecycle.suspend(); + try { + if (suspension.current?.hostOwnership === 'owned_ephemeral') { + throw new Error('The Local Runtime Host is not managed by a background service'); + } + return await change(); + } finally { + suspension.resume(); + } + }); + } + setDefaultProfile(profileId: string): void { this.#defaultProfileId = profileId; this.onDefaultProfileChanged?.(profileId); } - async prepareForUpdate( - allowInterruptActiveTasks: boolean, - ): Promise { - const lifecycle = this.#requireLifecycle( - this.#requireTarget(LOCAL_RUNTIME_HOST_PROFILE.id), - ); - const quiescence = lifecycle.quiesce(); + retireOwnedLocalHost( + mode: RuntimeHostRetirementMode, + ): Promise { + if (this.#localHostRetirement) return Promise.resolve(this.#localHostRetirement); + + const activeTask = this.#localHostRetirementTask; + if (activeTask) { + if ( + activeTask.mode === 'refuse_active_work' && + mode === 'interrupt_active_work' + ) { + return activeTask.result.then((result) => + result.kind === 'active_tasks' + ? this.retireOwnedLocalHost(mode) + : result, + ); + } + return activeTask.result; + } + + const result = this.#retireOwnedLocalHost(mode).finally(() => { + if (this.#localHostRetirementTask?.result === result) { + this.#localHostRetirementTask = undefined; + } + }); + this.#localHostRetirementTask = { mode, result }; + return result; + } + + async #retireOwnedLocalHost( + mode: RuntimeHostRetirementMode, + ): Promise { + const target = this.#requireTarget(LOCAL_RUNTIME_HOST_PROFILE.id); + const lifecycle = this.#requireLifecycle(target); + const unavailable = this.#unavailableLocalHostRetirement(target); + if (unavailable) return unavailable; + let quiescence: Awaited< + ReturnType['quiesce']> + >; + try { + quiescence = await lifecycle.quiesce(); + } catch (error) { + const terminal = this.#unavailableLocalHostRetirement(target, error); + if (terminal) return terminal; + throw error; + } + let hostPid = quiescence.current.hostPid; let launchBarrierPaused = false; const resume = () => { if (launchBarrierPaused) { @@ -512,33 +717,84 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { quiescence.resume(); }; try { - if ( - quiescence.current.hostLifecycleMode === 'service' || - quiescence.current.hostLifecycleMode === 'remote' - ) { - return { kind: 'prepared', rollback: resume }; + if (quiescence.current.hostOwnership !== 'owned_ephemeral') { + resume(); + return { kind: 'not_owned' }; } this.#baseInput.candidateLaunchBarrier?.pause(); launchBarrierPaused = this.#baseInput.candidateLaunchBarrier !== undefined; const diagnostics = await quiescence.current.client.queryHostDiagnostics(); + hostPid = diagnostics.pid; // The adopted Host still owns the root here, so every other owned launch // can be settled without allowing it to become a late election winner. await this.#baseInput.candidateLaunchBarrier?.retireExcept(diagnostics.pid); - const result = await quiescence.current.client.prepareHostUpgrade( - allowInterruptActiveTasks, - ); + const result = await quiescence.current.client.prepareHostRetirement(mode); if (result.kind === 'active_tasks') { + if (mode === 'interrupt_active_work') { + throw new Error('Runtime Host refused authorized retirement'); + } resume(); return result; } await this.waitForHostExit(result.pid); - return { kind: 'prepared', rollback: resume }; + target.lastCandidate = undefined; + return this.#completeLocalHostRetirement(resume); } catch (error) { resume(); - throw error; + throw new DesktopLocalHostRetirementError( + { + hostId: quiescence.current.client.hostId, + hostEpoch: quiescence.current.client.hostEpoch, + lifecycleMode: 'ephemeral', + rootPath: this.#baseInput.rootPath, + ...(hostPid === undefined ? {} : { pid: hostPid }), + }, + { cause: error }, + ); } } + #unavailableLocalHostRetirement( + target: DesktopRuntimeHostTargetGeneration, + cause: unknown = target.state.readiness === 'unavailable' ? target.state.error : undefined, + ): DesktopLocalHostRetirement | undefined { + if (target.state.readiness !== 'unavailable') return undefined; + const last = target.lastCandidate; + if (!last || last.ownership !== 'owned_ephemeral') return { kind: 'not_owned' }; + if (last.ownedProcess?.state === 'exited') return { kind: 'not_owned' }; + throw new DesktopLocalHostRetirementError( + { + hostId: last.hostId, + hostEpoch: last.hostEpoch, + lifecycleMode: 'ephemeral', + rootPath: this.#baseInput.rootPath, + ...(last.ownedProcess?.state === 'running' + ? { pid: last.ownedProcess.pid } + : {}), + }, + { cause: cause instanceof Error ? cause : new Error(String(cause)) }, + ); + } + + #completeLocalHostRetirement( + resume: () => void, + ): Extract { + let active = true; + const retirement = { + kind: 'retired' as const, + resume: () => { + if (!active) return; + active = false; + if (this.#localHostRetirement !== retirement) return; + this.#localHostRetirement = undefined; + if (this.#closed) return; + resume(); + }, + }; + this.#localHostRetirement = retirement; + return retirement; + } + close(): Promise { this.#closeTask ??= this.#close(); return this.#closeTask; @@ -553,9 +809,12 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { const results = await Promise.allSettled( [...this.#targets.values()].map((target) => this.#removeTarget(target)), ); + const peerResults = await Promise.allSettled( + this.#baseInput.peerClient ? [this.#baseInput.peerClient.close()] : [], + ); this.#baseInput.candidateLaunchBarrier?.release(); this.#ipcMain.close(); - const failures = results.filter( + const failures = [...results, ...peerResults].filter( (result): result is PromiseRejectedResult => result.status === 'rejected', ); if (failures.length > 0) { @@ -569,6 +828,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { async #startLifecycle( target: DesktopRuntimeHostTargetGeneration, reportInitialFailure: boolean, + initialSignal?: AbortSignal, ): Promise> { let starting = true; try { @@ -576,9 +836,14 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { connect: (signal) => this.connect( target, - signal, - starting ? target.input.remote?.sshInteraction : 'batch', + starting && initialSignal + ? AbortSignal.any([signal, initialSignal]) + : signal, + starting ? target.input.profileTarget?.sshInteraction : 'batch', ), + onReconnectError: (error) => { + console.warn('[runtime-host] reconnect attempt failed:', error); + }, onFatalError: (error) => { if (!starting && target.valid) { target.valid = false; @@ -607,37 +872,86 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { sshInteraction: RuntimeHostSshInteraction | undefined, ): Promise { let takeoverHostEpoch: string | undefined; + let localRecoveryAttempted = false; + const inheritedExit = target.input.onExit; + const tryRecoverLocalHost = async (): Promise => { + if (target.input.profileTarget || localRecoveryAttempted || !this.recoverLocalHost) { + return false; + } + localRecoveryAttempted = true; + return this.recoverLocalHost(signal); + }; while (true) { - const result = await this.startCandidate( - { - ...target.input, - ...(target.input.remote - ? { - remote: { - ...target.input.remote, - ...(sshInteraction === undefined ? {} : { sshInteraction }), - }, + let result: DesktopRuntimeHostCandidateStartResult; + try { + result = await this.startCandidate( + { + ...target.input, + onExit: (details) => this.#reportCandidateExit(inheritedExit, details), + ...(target.input.profileTarget + ? { + profileTarget: { + ...target.input.profileTarget, + ...(sshInteraction === undefined ? {} : { sshInteraction }), + }, + } + : {}), + ipcMain: this.#ipcMain.createTarget(target.epoch), + isTargetActive: () => this.#ipcMain.isActive(target.epoch), + isTargetValid: () => target.valid, + onConnectionPhase: (phase) => { + target.input.onConnectionPhase?.(phase); + }, + onHostStatus: (status) => { + if (status.collaborationAuthority !== undefined) { + target.collaborationAuthority = status.collaborationAuthority; } - : {}), - ipcMain: this.#ipcMain.createTarget(target.epoch), - isTargetActive: () => this.#ipcMain.isActive(target.epoch), - isTargetValid: () => target.valid, - signal, - ...(takeoverHostEpoch === undefined ? {} : { takeoverHostEpoch }), - }, - target.observations, - ); + target.input.onHostStatus?.(status); + }, + signal, + ...(takeoverHostEpoch === undefined ? {} : { takeoverHostEpoch }), + }, + target.observations, + ); + } catch (error) { + signal.throwIfAborted(); + if (await tryRecoverLocalHost()) continue; + throw error; + } if (result.kind === 'ready') { + const status = result.candidate.client.status; + if (typeof status === 'function') { + const observed = await status.call(result.candidate.client, 5_000).catch(() => undefined); + if (observed?.collaborationAuthority !== undefined) { + target.collaborationAuthority = observed.collaborationAuthority; + } + } target.hostId = result.candidate.client.hostId; + const previous = target.lastCandidate; + const retainedOwnedProcess = + previous?.hostId === result.candidate.client.hostId && + previous.hostEpoch === result.candidate.client.hostEpoch && + previous.ownership === 'owned_ephemeral' && + result.candidate.hostOwnership === 'owned_ephemeral' && + previous.ownedProcess?.pid === result.candidate.hostPid + ? previous.ownedProcess + : undefined; + target.lastCandidate = { + hostId: result.candidate.client.hostId, + hostEpoch: result.candidate.client.hostEpoch, + ownership: result.candidate.hostOwnership, + ...(result.candidate.ownedProcess + ? { ownedProcess: trackOwnedProcess(result.candidate.ownedProcess) } + : retainedOwnedProcess + ? { ownedProcess: retainedOwnedProcess } + : {}), + }; return result.candidate; } if (result.kind === 'upgrade_required' && result.restartable) { const activity = result.handshake?.activity; const decision = - activity && - activity.connections === 0 && - activity.activeOperations === 0 && - activity.residencies.length === 0 + activity && isHostActivityIdle(activity) ? 'restart' : await this.#resolveRestartable(result); if (decision === 'cancel') { @@ -655,12 +969,50 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { result.kind === 'incompatible' || (result.kind === 'upgrade_required' && !result.restartable) ) { - const decision = await this.#resolveWaitOnly(result); + const replacement = target.input.profileTarget + ? undefined + : await this.resolveLocalHostReplacement?.(result.registration, signal); + const activity = result.handshake?.activity; + if (replacement && activity && isHostActivityIdle(activity)) { + // Only a complete, observed snapshot can authorize silent + // replacement. The replacement transaction closes races with new + // operations; an absent snapshot requires explicit consent because + // it cannot prove that other clients are disconnected. + const attempt = await replacement.replace('refuse_active_work'); + if (attempt === 'replaced') { + takeoverHostEpoch = undefined; + continue; + } + } + // The retirement waiter observes a local PID. Remote targets cannot + // use it to prove that a Host on another machine has exited. + const action: RuntimeHostNonRestartableAction = replacement + ? 'replace_may_interrupt_work' + : target.input.profileTarget + ? 'cancel_only' + : 'wait'; + const decision = await this.#resolveNonRestartable(result, action); if (decision === 'cancel') throw new RuntimeHostUpgradeCancelledError(); + if (decision === 'replace') { + if (!replacement) { + throw new RuntimeHostPermanentReconnectError( + 'This Runtime Host cannot be replaced from the current target', + ); + } + const replaced = await replacement.replace('interrupt_active_work'); + if (replaced === 'active_tasks') { + throw new RuntimeHostPermanentReconnectError( + 'This Runtime Host still owns work that cannot be interrupted safely', + ); + } + takeoverHostEpoch = undefined; + continue; + } takeoverHostEpoch = undefined; await this.waitForHostRetirement(result.registration, signal); continue; } + if (await tryRecoverLocalHost()) continue; throw runtimeHostStartupError(result.reason, result.diagnostic); } } @@ -672,8 +1024,11 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { return this.#missingUpgradePrompt(); } - #resolveWaitOnly(conflict: RuntimeHostWaitConflict): Promise { - if (this.upgradePrompts) return this.upgradePrompts.waitOnly(conflict); + #resolveNonRestartable( + conflict: RuntimeHostWaitConflict, + action: RuntimeHostNonRestartableAction, + ): Promise { + if (this.upgradePrompts) return this.upgradePrompts.nonRestartable(conflict, action); return this.#missingUpgradePrompt(); } @@ -690,6 +1045,19 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { return target.lifecycle; } + /** Desktop-owned candidate-exit diagnostics; honors an embedder-supplied sink. */ + #reportCandidateExit( + inherited: ((details: CandidateExitDetails) => void) | undefined, + details: CandidateExitDetails, + ): void { + inherited?.(details); + if (details.code === 0 && details.signal === null) { + console.info('[runtime-host] candidate exited cleanly', details); + return; + } + console.error('[runtime-host] candidate exited unexpectedly', details); + } + async #waitForReadyCandidate( lifecycle: RuntimeHostReconnectLifecycle, previous?: DesktopRuntimeHostCandidate, @@ -708,10 +1076,12 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { observations = new RuntimeHostSessionObservationRegistry((error) => input.onError?.(error)), ): DesktopRuntimeHostTargetGeneration { this.#observationRegistries.add(observations); - const target = input.remote + const target = input.profileTarget ? { - profile: input.remote.profile, - credential: input.remote.credential, + profile: input.profileTarget.profile, + ...(input.profileTarget.credential === undefined + ? {} + : { credential: input.profileTarget.credential }), } : { profile: LOCAL_RUNTIME_HOST_PROFILE }; const epoch = randomUUID(); @@ -737,19 +1107,22 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { } } - #mutateTarget(profileId: string, operation: () => Promise): Promise { + #mutateTarget(profileId: string, operation: () => Promise): Promise { if (this.#closed) { return Promise.reject(new Error('Desktop Runtime Host manager is closed')); } const previous = this.#targetMutations.get(profileId) ?? Promise.resolve(); const pending = previous.catch(() => undefined).then(operation); - const settled = pending.finally(() => { + const settled = pending.then( + () => undefined, + () => undefined, + ).finally(() => { if (this.#targetMutations.get(profileId) === settled) { this.#targetMutations.delete(profileId); } }); this.#targetMutations.set(profileId, settled); - return settled; + return pending; } #activate(target: DesktopRuntimeHostTargetGeneration): void { @@ -764,12 +1137,18 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { target: target.target, readiness: 'ready', candidate, + ...(target.collaborationAuthority === undefined + ? {} + : { collaborationAuthority: target.collaborationAuthority }), } : { epoch: target.epoch, target: target.target, readiness: 'reconnecting', ...(target.hostId ? { hostId: target.hostId } : {}), + ...(target.collaborationAuthority === undefined + ? {} + : { collaborationAuthority: target.collaborationAuthority }), }, ); }); @@ -782,12 +1161,18 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { target: target.target, readiness: 'ready', candidate, + ...(target.collaborationAuthority === undefined + ? {} + : { collaborationAuthority: target.collaborationAuthority }), } : { epoch: target.epoch, target: target.target, readiness: 'reconnecting', ...(target.hostId ? { hostId: target.hostId } : {}), + ...(target.collaborationAuthority === undefined + ? {} + : { collaborationAuthority: target.collaborationAuthority }), }, ); } @@ -841,6 +1226,22 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { ); } } + +} + +function trackOwnedProcess( + process: NonNullable, +): DesktopOwnedProcessEvidence { + const evidence: DesktopOwnedProcessEvidence = { pid: process.pid, state: 'running' }; + void process.exited.then( + () => { + evidence.state = 'exited'; + }, + () => { + evidence.state = 'unknown'; + }, + ); + return evidence; } function pairingFinalizeRetry(error: unknown): boolean { @@ -869,10 +1270,16 @@ function pairingFinalizeTimedOut(error: unknown): boolean { function withRuntimeHostTarget( input: DesktopRuntimeHostCandidateStartInput, - remote: DesktopRuntimeHostCandidateStartInput['remote'], + profileTarget: DesktopRuntimeHostCandidateStartInput['profileTarget'], ): DesktopRuntimeHostCandidateStartInput { - const { remote: _previousRemote, ...base } = input; - return remote ? { ...base, remote } : base; + const { profileTarget: _previousProfileTarget, ...base } = input; + return profileTarget ? { ...base, profileTarget } : base; +} + +function isSessionGuestProfile( + profile: ResolvedRuntimeHostProfile['profile'], +): boolean { + return profile.kind === 'remote' && profile.access === 'session_guest'; } function waitForAbortableDelay(ms: number, signal: AbortSignal): Promise { @@ -902,7 +1309,7 @@ async function waitForProcessRetirement( async function waitForProcessExit(pid: number): Promise { const deadline = Date.now() + 10_000; while (isProcessAlive(pid)) { - if (Date.now() >= deadline) throw new Error('Runtime Host did not exit before update'); + if (Date.now() >= deadline) throw new Error('Runtime Host did not exit before retirement'); await new Promise((resolve) => setTimeout(resolve, 50)); } } diff --git a/apps/desktop/src/main/runtime-host-framed-output.ts b/apps/desktop/src/main/runtime-host-framed-output.ts new file mode 100644 index 0000000000..5a24062815 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-framed-output.ts @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export function createRuntimeHostFramedOutputFilter(input: { + readonly prefix: string; + readonly pendingMaxBytes: number; + readonly decode: (line: string) => Frame | undefined; + readonly label: string; + readonly onFrame: (frame: Frame) => void; + readonly onError: (error: Error) => void; +}): { push(data: string): string; finish(): string } { + let pending = ''; + let discardReservedLine = false; + const drain = (finished: boolean): string => { + let visible = ''; + while (pending) { + if (discardReservedLine) { + const newline = pending.indexOf('\n'); + if (newline < 0) { + pending = ''; + break; + } + pending = pending.slice(newline + 1); + discardReservedLine = false; + continue; + } + const marker = pending.indexOf(input.prefix); + if (marker >= 0) { + visible += pending.slice(0, marker); + pending = pending.slice(marker); + const newline = pending.indexOf('\n'); + if (newline < 0) { + if (finished) { + input.onError(new Error(`${input.label} returned an incomplete result`)); + pending = ''; + } else if (pending.length > input.pendingMaxBytes) { + input.onError(new Error(`${input.label} returned an oversized result`)); + pending = ''; + discardReservedLine = true; + } + break; + } + const line = pending.slice(0, newline + 1); + pending = pending.slice(newline + 1); + const frame = input.decode(line); + if (frame) input.onFrame(frame); + else input.onError(new Error(`${input.label} returned an invalid result`)); + continue; + } + if (finished) { + visible += pending; + pending = ''; + break; + } + const retained = markerSuffixLength(pending, input.prefix); + visible += pending.slice(0, pending.length - retained); + pending = pending.slice(pending.length - retained); + break; + } + return visible; + }; + return { + push(data) { + pending += data; + return drain(false); + }, + finish() { + return drain(true); + }, + }; +} + +function markerSuffixLength(value: string, prefix: string): number { + const limit = Math.min(value.length, prefix.length - 1); + for (let length = limit; length > 0; length -= 1) { + if (prefix.startsWith(value.slice(-length))) return length; + } + return 0; +} diff --git a/apps/desktop/src/main/runtime-host-guest-session-mounts.ts b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts new file mode 100644 index 0000000000..4b9e5a97e6 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts @@ -0,0 +1,633 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { + decodeRemoteRuntimeHostProfile, + RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES, + type ResolvedRuntimeHostProfile, + type RuntimeHostConnectionPhase, + type RuntimeHostRemoteTransport, +} from '@maka/runtime-host/client'; +import { + decodeCollaborationInvitationCode, + type HostPeerEndpoint, +} from '@maka/runtime-host/protocol'; +import type { CredentialStore } from '@maka/storage/credential-store'; +import type { + SessionCollaborationCancelResult, + SessionCollaborationImportPhase, + SessionCollaborationImportResult, + SessionCollaborationMountSummary, +} from '../shared/session-collaboration.js'; +import { + decodeDesktopCollaborationInvitation, + DESKTOP_COLLABORATION_INVITATION_CODE_MAX_BYTES, +} from './runtime-host-collaboration-invitation.js'; +import { RuntimeHostPairingFinalizationInterruptedError } from './runtime-host-desktop-manager.js'; + +const STORE_SCHEMA_VERSION = 1; +const STORE_SLOT = 'desktop-guest-session-mounts'; +const MAX_MOUNTS = 128; +const STARTUP_RETRY_MAX_MS = 30_000; + +export interface GuestSessionMount { + readonly mountId: string; + readonly name: string; + readonly rootId: string; + readonly transport: RuntimeHostRemoteTransport; + readonly credential: string; +} + +interface GuestSessionMountDocument { + readonly schemaVersion: typeof STORE_SCHEMA_VERSION; + readonly mounts: readonly GuestSessionMount[]; +} + +interface LiveGuestActivationBase { + readonly controller: AbortController; + stage: 'connecting' | 'finalizing'; + finalization?: Promise; + task: Promise; +} + +interface LiveGuestImportActivation extends LiveGuestActivationBase { + readonly kind: 'import'; + readonly operationId: string; + readonly onProgress?: (phase: SessionCollaborationImportPhase) => void; + mountId?: string; +} + +interface LiveGuestStartupActivation extends LiveGuestActivationBase { + readonly kind: 'startup'; + readonly mountId: string; +} + +type LiveGuestActivation = LiveGuestImportActivation | LiveGuestStartupActivation; + +export interface GuestSessionMountStore { + read(): Promise; + write(mounts: readonly GuestSessionMount[]): Promise; +} + +export interface DesktopGuestSessionMountService { + start(): Promise; + list(): Promise; + importInvitation( + code: string, + allowInsecure: boolean, + operationId: string, + onProgress?: (phase: SessionCollaborationImportPhase) => void, + ): Promise; + cancelImport(operationId: string): SessionCollaborationCancelResult; + remove(mountId: string): Promise; + close(): Promise; +} + +export function createGuestSessionMountStore( + credentials: Pick, +): GuestSessionMountStore { + return { + async read() { + const raw = await credentials.getSecret(STORE_SLOT, 'runtime_host_access'); + if (raw === null) return []; + return decodeDocument(JSON.parse(raw) as unknown).mounts; + }, + async write(mounts) { + if (mounts.length > MAX_MOUNTS) { + throw new Error(`At most ${MAX_MOUNTS} shared Sessions can be retained`); + } + const document: GuestSessionMountDocument = { + schemaVersion: STORE_SCHEMA_VERSION, + mounts: [...mounts].sort((left, right) => left.mountId.localeCompare(right.mountId)), + }; + decodeDocument(document); + await credentials.setSecret( + STORE_SLOT, + 'runtime_host_access', + `${JSON.stringify(document)}\n`, + ); + }, + }; +} + +export function createDesktopGuestSessionMountService(input: { + readonly store: GuestSessionMountStore; + readonly mount: ( + target: ResolvedRuntimeHostProfile, + signal: AbortSignal, + onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void, + onPeerEndpoint?: (endpoint: HostPeerEndpoint) => void, + ) => Promise; + readonly finalizeAccess: (mountId: string, signal: AbortSignal) => Promise; + readonly unmount: (mountId: string) => Promise; + readonly wait?: (delayMs: number, signal: AbortSignal) => Promise; + readonly onError?: (error: Error, mount: GuestSessionMount) => void; +}): DesktopGuestSessionMountService { + const wait = input.wait ?? waitForDelay; + const onError = input.onError ?? ((error, mount) => { + console.warn(`[runtime-host] shared Session ${mount.mountId} is unavailable:`, error); + }); + const activations = new Set(); + const removingMounts = new Set(); + let mounts: Map | undefined; + let mutationTail = Promise.resolve(); + let closed = false; + + const mutate = (operation: () => Promise): Promise => { + const pending = mutationTail.then(operation); + mutationTail = pending.then( + () => undefined, + () => undefined, + ); + return pending; + }; + + const load = async (): Promise> => { + if (!mounts) mounts = new Map((await input.store.read()).map((mount) => [mount.mountId, mount])); + return mounts; + }; + + const persist = async (next: Map): Promise => { + await input.store.write([...next.values()]); + mounts = next; + }; + + const recordPeerEndpoint = (mount: GuestSessionMount, endpoint: HostPeerEndpoint): void => { + if ( + closed || + mount.transport.kind !== 'libp2p-direct' || + endpoint.peerId !== mount.transport.peerId || + (endpoint.routeHints.length === 0 && endpoint.coordinationRelays.length === 0) + ) return; + void mutate(async () => { + if (removingMounts.has(mount.mountId)) return; + const current = await load(); + const retained = current.get(mount.mountId); + if ( + retained?.transport.kind !== 'libp2p-direct' || + retained.transport.peerId !== endpoint.peerId || + ( + sameStrings(retained.transport.routeHints, endpoint.routeHints) && + sameStrings(retained.transport.coordinationRelays, endpoint.coordinationRelays) + ) + ) return; + const updated = decodeMount({ + ...retained, + transport: { + kind: 'libp2p-direct', + peerId: endpoint.peerId, + routeHints: endpoint.routeHints, + coordinationRelays: endpoint.coordinationRelays, + }, + }); + await persist(new Map(current).set(mount.mountId, updated)); + }).catch((error: unknown) => onError(asError(error), mount)); + }; + + const activate = async ( + activation: LiveGuestActivation, + mount: GuestSessionMount, + ): Promise => { + activation.stage = 'connecting'; + await input.mount(resolveMountTarget(mount), activation.controller.signal, (phase) => { + if (activation.kind === 'import') { + reportImportProgress( + activation.onProgress, + collaborationProgressForConnectionPhase(phase), + ); + } + }, (endpoint) => recordPeerEndpoint(mount, endpoint)); + // waitForReady observes host.status before mount resolves. Commit that + // authenticated route snapshot before declaring the durable mount ready. + await mutationTail; + activation.controller.signal.throwIfAborted(); + if (removingMounts.has(mount.mountId)) { + throw new Error('Shared Session mount was removed while connecting'); + } + activation.stage = 'finalizing'; + if (activation.kind === 'import') { + reportImportProgress(activation.onProgress, 'finalizing_access'); + } + const finalization = input.finalizeAccess(mount.mountId, activation.controller.signal); + activation.finalization = finalization; + try { + await finalization; + activation.controller.signal.throwIfAborted(); + } finally { + if (activation.finalization === finalization) activation.finalization = undefined; + } + }; + + const beginStartupReconciliation = (mount: GuestSessionMount): void => { + if ( + closed || + removingMounts.has(mount.mountId) || + [...activations].some((activation) => activation.mountId === mount.mountId) + ) return; + const activation: LiveGuestActivation = { + kind: 'startup', + controller: new AbortController(), + mountId: mount.mountId, + stage: 'connecting', + task: Promise.resolve(), + }; + activations.add(activation); + activation.task = (async () => { + let delayMs = 1_000; + while (!closed && !activation.controller.signal.aborted) { + if (!(await load()).has(mount.mountId)) return; + try { + await activate(activation, mount); + return; + } catch (error) { + if ( + closed || + activation.controller.signal.aborted || + removingMounts.has(mount.mountId) + ) return; + activation.stage = 'connecting'; + onError(asError(error), mount); + await wait(delayMs, activation.controller.signal); + delayMs = Math.min(delayMs * 2, STARTUP_RETRY_MAX_MS); + } + } + })().finally(() => { + activations.delete(activation); + }); + void activation.task.catch((error) => { + if (!activation.controller.signal.aborted) onError(asError(error), mount); + }); + }; + + const remove = async (mountId: string): Promise => { + removingMounts.add(mountId); + try { + const matching = [...activations].filter((activation) => activation.mountId === mountId); + for (const activation of matching) { + if (activation.stage === 'connecting') { + activation.controller.abort(new Error('Shared Session mount was removed')); + } + } + await Promise.allSettled( + matching.flatMap((activation) => + activation.finalization ? [activation.finalization] : [], + ), + ); + const removed = await mutate(async () => { + const current = await load(); + const mount = current.get(mountId); + if (!mount) return undefined; + const next = new Map(current); + next.delete(mountId); + await persist(next); + return mount; + }); + if (!removed) return; + for (const activation of activations) { + if (activation.mountId === mountId) { + activation.controller.abort(new Error('Shared Session mount was removed')); + } + } + void input.unmount(mountId).catch((error) => onError(asError(error), removed)); + } finally { + removingMounts.delete(mountId); + } + }; + + const runImport = async ( + code: string, + allowInsecure: boolean, + activation: LiveGuestImportActivation, + ): Promise => { + reportImportProgress(activation.onProgress, 'validating_invitation'); + activation.controller.signal.throwIfAborted(); + let bundle; + let invitation; + try { + bundle = decodeDesktopCollaborationInvitation(code); + invitation = decodeCollaborationInvitationCode(bundle.invitationCode); + } catch { + return { kind: 'error', reason: 'invalid_code' }; + } + if (bundle.target.transport.kind === 'plaintext' && !allowInsecure) { + return { kind: 'error', reason: 'insecure_confirmation_required' }; + } + const mount = decodeMount({ + mountId: `shared-${randomUUID()}`, + name: `${bundle.target.name} · Shared`, + rootId: invitation.rootId, + transport: bundle.target.transport, + credential: invitation.credential, + }); + activation.mountId = mount.mountId; + const retained = await mutate(async () => { + activation.controller.signal.throwIfAborted(); + const current = await load(); + if (current.size >= MAX_MOUNTS) return false; + await persist(new Map(current).set(mount.mountId, mount)); + return true; + }); + if (!retained) { + return { + kind: 'error', + reason: 'connection_failed', + message: `At most ${MAX_MOUNTS} shared Sessions can be retained`, + }; + } + let reconcile = false; + try { + reportImportProgress(activation.onProgress, 'discovering_host'); + await activate(activation, mount); + activation.controller.signal.throwIfAborted(); + if (!(await load()).has(mount.mountId)) { + throw new Error('Shared Session mount was removed while connecting'); + } + return { kind: 'connected', mountId: mount.mountId }; + } catch (error) { + if ( + activation.stage === 'finalizing' && + error instanceof RuntimeHostPairingFinalizationInterruptedError + ) { + reconcile = true; + } else { + await mutate(async () => { + const next = new Map(await load()); + next.delete(mount.mountId); + await persist(next); + }); + activation.controller.abort(new Error('Shared Session mount activation failed')); + await input.unmount(mount.mountId).catch(() => undefined); + } + return { + kind: 'error', + reason: isPeerPathUnavailable(error) ? 'peer_path_unavailable' : 'connection_failed', + message: asError(error).message, + }; + } finally { + activations.delete(activation); + if (reconcile) beginStartupReconciliation(mount); + } + }; + + const importInvitation = ( + code: string, + allowInsecure: boolean, + operationId: string, + onProgress?: (phase: SessionCollaborationImportPhase) => void, + ): Promise => { + if (closed) return Promise.reject(new Error('Shared Session mount service is closed')); + if ( + [...activations].some( + (activation) => activation.kind === 'import' && activation.operationId === operationId, + ) + ) { + return Promise.reject(new Error('Shared Session import operation is already active')); + } + const activation: LiveGuestImportActivation = { + kind: 'import', + operationId, + ...(onProgress ? { onProgress } : {}), + controller: new AbortController(), + stage: 'connecting', + task: Promise.resolve(), + }; + activations.add(activation); + const task = runImport(code, allowInsecure, activation).finally(() => { + activations.delete(activation); + }); + activation.task = task; + return task; + }; + + return { + async start() { + if (closed) return; + const current = await mutate(load); + for (const mount of current.values()) beginStartupReconciliation(mount); + }, + + async list() { + return [...(await mutate(load)).values()] + .map(({ mountId, name }) => ({ mountId, name })) + .sort((left, right) => left.name.localeCompare(right.name)); + }, + + importInvitation, + + cancelImport(operationId) { + const operation = [...activations].find( + (activation): activation is LiveGuestImportActivation => + activation.kind === 'import' && activation.operationId === operationId, + ); + if (!operation) return 'settling'; + if (operation.stage === 'finalizing') return 'settling'; + operation.controller.abort(new Error('Shared Session import was cancelled')); + return 'cancelled'; + }, + + remove, + + async close() { + closed = true; + for (const activation of activations) { + if (activation.stage === 'connecting') { + activation.controller.abort(new Error('Shared Session mount service is closed')); + } + } + await Promise.allSettled([...activations].map((activation) => activation.task)); + await mutationTail; + activations.clear(); + }, + }; +} + +export function registerDesktopGuestSessionMountIpc( + ipcMain: Pick, + service: DesktopGuestSessionMountService, + readClipboardText: () => string, +): () => void { + const channels = [ + 'session-collaboration:import', + 'session-collaboration:import:cancel', + 'session-collaboration:mount:list', + 'session-collaboration:mount:remove', + 'session-collaboration:invitation:read-clipboard', + ] as const; + ipcMain.handle( + channels[0], + (event, code: string, allowInsecure: boolean, operationIdValue: unknown) => { + const operationId = requireOperationId(operationIdValue); + return service.importInvitation(code, allowInsecure, operationId, (phase) => { + if (!event.sender.isDestroyed()) { + event.sender.send('session-collaboration:import:progress', operationId, phase); + } + }); + }, + ); + ipcMain.handle(channels[1], (_event, operationIdValue: unknown) => + service.cancelImport(requireOperationId(operationIdValue))); + ipcMain.handle(channels[2], () => service.list()); + ipcMain.handle(channels[3], (_event, mountId: string) => service.remove(mountId)); + ipcMain.handle(channels[4], () => { + const value = readClipboardText().trim(); + if (Buffer.byteLength(value, 'utf8') > DESKTOP_COLLABORATION_INVITATION_CODE_MAX_BYTES) { + throw new Error('Clipboard content is too large to be a shared Session invitation'); + } + if (!value) return value; + decodeDesktopCollaborationInvitation(value); + return value; + }); + return () => { + for (const channel of channels) ipcMain.removeHandler(channel); + }; +} + +function resolveMountTarget(mount: GuestSessionMount): ResolvedRuntimeHostProfile { + return { + profile: decodeRemoteRuntimeHostProfile({ + id: mount.mountId, + name: mount.name, + kind: 'remote', + rootId: mount.rootId, + transport: mount.transport, + access: 'session_guest', + }), + credential: mount.credential, + }; +} + +function decodeDocument(value: unknown): GuestSessionMountDocument { + if (!isRecord(value) || !hasExactKeys(value, ['schemaVersion', 'mounts'])) { + throw new Error('Shared Session mount store is invalid'); + } + if (value.schemaVersion !== STORE_SCHEMA_VERSION || !Array.isArray(value.mounts)) { + throw new Error('Shared Session mount store version is unsupported'); + } + if (value.mounts.length > MAX_MOUNTS) { + throw new Error(`Shared Session mount store exceeds ${MAX_MOUNTS} entries`); + } + const mounts = value.mounts.map(decodeMount); + if (new Set(mounts.map((mount) => mount.mountId)).size !== mounts.length) { + throw new Error('Shared Session mount identities must be unique'); + } + return { schemaVersion: STORE_SCHEMA_VERSION, mounts }; +} + +function decodeMount(value: unknown): GuestSessionMount { + if ( + !isRecord(value) || + !hasExactKeys(value, ['mountId', 'name', 'rootId', 'transport', 'credential']) || + typeof value.credential !== 'string' || + !value.credential || + /\s/u.test(value.credential) || + Buffer.byteLength(value.credential, 'utf8') > RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES + ) { + throw new Error('Shared Session mount is invalid'); + } + const target = decodeRemoteRuntimeHostProfile({ + id: value.mountId, + name: value.name, + kind: 'remote', + rootId: value.rootId, + transport: value.transport, + access: 'session_guest', + }); + return { + mountId: target.id, + name: target.name, + rootId: target.rootId, + transport: target.transport, + credential: value.credential, + }; +} + +function isPeerPathUnavailable(error: unknown): boolean { + if (!isRecord(error) || typeof error.code !== 'string') return false; + return error.code === 'direct_path_unavailable' || error.code === 'transit_unavailable'; +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function collaborationProgressForConnectionPhase( + phase: RuntimeHostConnectionPhase, +): SessionCollaborationImportPhase { + switch (phase) { + case 'discovering': + return 'preparing_route'; + case 'connecting': + return 'connecting'; + case 'authenticating': + return 'authenticating'; + case 'handshaking': + case 'waiting_for_ready': + return 'loading_session'; + } +} + +function reportImportProgress( + observer: ((phase: SessionCollaborationImportPhase) => void) | undefined, + phase: SessionCollaborationImportPhase, +): void { + try { + observer?.(phase); + } catch { + // Presentation progress cannot control the import lifecycle. + } +} + +function requireOperationId(value: unknown): string { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/u.test(value)) { + throw new Error('Shared Session import operation ID is invalid'); + } + return value; +} + +function waitForDelay(delayMs: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const timer = setTimeout(done, delayMs); + function done(): void { + signal.removeEventListener('abort', aborted); + resolve(); + } + function aborted(): void { + clearTimeout(timer); + signal.removeEventListener('abort', aborted); + reject(signal.reason); + } + signal.addEventListener('abort', aborted, { once: true }); + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, expected: readonly string[]): boolean { + const keys = Object.keys(value).sort(); + const sorted = [...expected].sort(); + return keys.length === sorted.length && keys.every((key, index) => key === sorted[index]); +} + +function asError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} diff --git a/apps/desktop/src/main/runtime-host-local-management.ts b/apps/desktop/src/main/runtime-host-local-management.ts new file mode 100644 index 0000000000..933f7bd6e8 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-local-management.ts @@ -0,0 +1,125 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { LOCAL_RUNTIME_HOST_PROFILE } from '@maka/runtime-host/client'; +import type { RuntimeHostServiceManagementFrame } from '@maka/runtime-host/operator'; +import type { + DesktopLocalRuntimeHostRemoteAccess, + DesktopRuntimeHostLocalManagementTarget, +} from './runtime-host-local-remote-access.js'; +import type { createDesktopRuntimeHostLocalOperator } from './runtime-host-local-operator.js'; +import type { DesktopRuntimeHostManagementProvider } from './runtime-host-management-provider.js'; +import type { DesktopRuntimeHostSetupPackage } from './runtime-host-setup-package.js'; + +type LocalOperator = ReturnType; + +export function createDesktopRuntimeHostLocalManagement(input: { + readonly remoteAccess: DesktopLocalRuntimeHostRemoteAccess; + readonly operator: LocalOperator; + readonly rootPath: string; + readonly resolveUpdatePackage: () => + | DesktopRuntimeHostSetupPackage + | Promise; + readonly currentHostEpoch: () => string | undefined; + readonly awaitUpdatedConnection: ( + previousHostEpoch: string | undefined, + replacementExpected: boolean, + ) => Promise; +}): DesktopRuntimeHostManagementProvider { + return { + profileId: LOCAL_RUNTIME_HOST_PROFILE.id, + accessManagementAvailable: false, + run: (action, allowInterruptActiveTasks) => { + const execute = (target: DesktopRuntimeHostLocalManagementTarget) => + input.operator.runService({ + operatorPath: target.operatorPath, + action, + target, + ...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), + }).then((frame) => requireLocalFrame(frame, action)); + return action === 'status' || action === 'logs' + ? input.remoteAccess.inspectManaged(execute) + : input.remoteAccess.changeManaged(execute); + }, + uninstall: async (allowInterruptActiveTasks) => { + const result = await input.remoteAccess.uninstall({ allowInterruptActiveTasks }); + return { kind: result.kind, retainedStateRoot: input.rootPath }; + }, + update: async (allowInterruptActiveTasks, onProgress) => { + const setupPackage = await input.resolveUpdatePackage(); + return input.remoteAccess.changeManaged( + (target) => + input.operator.runUpdate( + { + setupPackage, + target, + ...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), + }, + onProgress, + ).then((frame) => requireLocalFrame(frame, 'update')), + ); + }, + configureProjectDirectories: ( + roots, + expectedConfigFingerprint, + allowInterruptActiveTasks, + ) => + input.remoteAccess.changeManaged( + (target) => + input.operator.runService({ + operatorPath: target.operatorPath, + action: 'configure', + target, + projectDirectoryRoots: roots, + expectedConfigFingerprint, + ...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), + }).then((frame) => requireLocalFrame(frame, 'configure')), + ), + updatePolicy: (policy) => + input.remoteAccess.inspectManaged((target) => + input.operator.runUpdatePolicy({ + operatorPath: target.operatorPath, + target, + ...(policy ? { policy } : {}), + }).then((frame) => requireLocalFrame(frame, 'update_policy'))), + reconcileUpdate: (onProgress) => + input.remoteAccess.changeManaged((target) => + input.operator.runUpdateReconciliation( + { operatorPath: target.operatorPath, target }, + onProgress, + ).then((frame) => requireLocalFrame(frame, 'reconcile_update'))), + currentHostEpoch: input.currentHostEpoch, + awaitUpdatedConnection: input.awaitUpdatedConnection, + }; +} + +function requireLocalFrame( + frame: RuntimeHostServiceManagementFrame, + action: Action, +): Exclude & { + readonly action: Action; +} { + if (frame.kind === 'progress' || frame.action !== action) { + throw new Error('Local Runtime Host returned an unrelated management result'); + } + return frame as Exclude< + RuntimeHostServiceManagementFrame, + { readonly kind: 'progress' } + > & { readonly action: Action }; +} diff --git a/apps/desktop/src/main/runtime-host-local-operator.ts b/apps/desktop/src/main/runtime-host-local-operator.ts new file mode 100644 index 0000000000..e8179f9195 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-local-operator.ts @@ -0,0 +1,913 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import { mkdtemp, rm, rmdir, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { redactSecrets } from '@maka/core/redaction'; +import { + DEFAULT_PROCESS_TERMINATION_GRACE_MS, + terminateChildProcessTree, +} from '@maka/runtime/process-tree-terminator'; +import { + decodeRuntimeHostAccessManagementFrame, + decodeRuntimeHostPeerManagementFrame, + decodeRuntimeHostPeerMeshManagementFrame, + decodeRuntimeHostServiceManagementFrame, + decodeRuntimeHostSetupFrame, + RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV, + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, + RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, + RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX, + RUNTIME_HOST_PEER_MANAGEMENT_FRAME_PREFIX, + RUNTIME_HOST_PEER_MESH_MANAGEMENT_FRAME_MAX_BYTES, + RUNTIME_HOST_PEER_MESH_MANAGEMENT_FRAME_PREFIX, + RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, + RUNTIME_HOST_SETUP_FRAME_PREFIX, + RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV, + type RuntimeHostAccessManagementFrame, + type RuntimeHostManagedUpdatePolicy, + type RuntimeHostPeerManagementFrame, + type RuntimeHostPeerMeshManagementAction, + type RuntimeHostPeerMeshManagementFrame, + type RuntimeHostServiceManagementFrame, + type RuntimeHostServiceUpdatePhase, + type RuntimeHostSetupFrame, +} from '@maka/runtime-host/operator'; +import { createRuntimeHostFramedOutputFilter } from './runtime-host-framed-output.js'; +import { + runtimeHostSetupPackageVersion, + type DesktopRuntimeHostSetupPackage, +} from './runtime-host-setup-package.js'; + +const SETUP_TIMEOUT_MS = 10 * 60_000; +const SETUP_FRAME_PENDING_MAX = 20 * 1024; +const STDERR_MAX_BYTES = 64 * 1024; + +type RuntimeHostSetupCompleteFrame = Extract; + +export interface DesktopRuntimeHostLocalServiceTarget { + readonly serviceId: string; + readonly rootPath: string; + readonly rootId: string; + readonly deploymentId?: string; +} + +export interface DesktopRuntimeHostLocalSetupInput { + readonly setupPackage: DesktopRuntimeHostSetupPackage; + readonly clientDataRoot: string; + readonly rootPath: string; + readonly principalId: string; + readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[]; + readonly coordinationRelays?: readonly string[]; + readonly expectedTarget: DesktopRuntimeHostLocalServiceTarget; + readonly signal?: AbortSignal; +} + +export interface DesktopRuntimeHostLocalSetupCommand { + readonly executable: string; + readonly args: readonly string[]; +} + +export interface DesktopRuntimeHostLocalServiceManagementInput { + readonly operatorPath: string; + readonly action: + | 'status' + | 'start' + | 'restart' + | 'logs' + | 'install' + | 'configure' + | 'retire' + | 'uninstall'; + readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[]; + readonly expectedConfigFingerprint?: string; + readonly allowInterruptActiveTasks?: boolean; + readonly retainManagedDeployment?: boolean; + readonly signal?: AbortSignal; +} + +export function runtimeHostLocalSetupCommand(input: { + readonly packageSpecifier: string; + readonly clientDataRoot: string; + readonly rootPath: string; + readonly principalId: string; + readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[]; + readonly coordinationRelays?: readonly string[]; + readonly expectedTarget: DesktopRuntimeHostLocalServiceTarget; +}): DesktopRuntimeHostLocalSetupCommand { + if (!/^[A-Za-z0-9_.:-]{1,128}$/u.test(input.principalId)) { + throw new Error('Runtime Host setup principal is invalid'); + } + return { + executable: 'npm', + args: [ + 'exec', + '--yes', + '--package', + input.packageSpecifier, + '--', + 'maka', + 'runtime-host', + 'setup', + '--client-data-root', + input.clientDataRoot, + '--root', + input.rootPath, + '--principal', + input.principalId, + '--preset', + 'desktop-client', + '--defer-pairing-commit', + '--bind-pairing-to-client', + '--enable-direct-peer', + ...managedTargetArgs(input.expectedTarget), + ...(input.coordinationRelays ?? []).flatMap((relay) => [ + '--coordination-relay', + relay, + ]), + ...(input.projectDirectoryRoots === undefined + ? [] + : input.projectDirectoryRoots.length === 0 + ? ['--no-project-roots'] + : input.projectDirectoryRoots.flatMap(({ label, path }) => [ + '--project-root-json', + JSON.stringify({ label, path }), + ])), + '--json', + ], + }; +} + +export function createDesktopRuntimeHostLocalOperator(input: { + readonly environment?: NodeJS.ProcessEnv; + readonly spawnProcess?: typeof spawn; + readonly setupTimeoutMs?: number; + readonly terminateProcess?: typeof terminateChildProcessTree; +} = {}): { + runSetup( + setup: DesktopRuntimeHostLocalSetupInput, + onProgress: (frame: Extract) => void, + ): Promise; + runPeer(input: { + readonly operatorPath: string; + readonly action: 'enable' | 'disable' | 'status'; + readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly coordinationRelays?: readonly string[]; + readonly allowInterruptActiveTasks?: boolean; + readonly signal?: AbortSignal; + }): Promise; + runPeerMesh(input: { + readonly operatorPath: string; + readonly action: RuntimeHostPeerMeshManagementAction; + readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly meshId?: string | null; + readonly peerId?: string; + readonly displayName?: string | null; + readonly invitation?: string; + readonly signal?: AbortSignal; + }): Promise>; + runAccess(input: { + readonly operatorPath: string; + readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly signal?: AbortSignal; + }): Promise; + runService( + input: DesktopRuntimeHostLocalServiceManagementInput, + ): Promise; + runUpdate( + input: { + readonly setupPackage: DesktopRuntimeHostSetupPackage; + readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly expectedHost?: { readonly hostEpoch: string; readonly pid: number }; + readonly allowManualUpdate?: boolean; + readonly allowInterruptActiveTasks?: boolean; + readonly signal?: AbortSignal; + }, + onProgress: (phase: RuntimeHostServiceUpdatePhase) => void, + ): Promise; + runUpdatePolicy(input: { + readonly operatorPath: string; + readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly policy?: RuntimeHostManagedUpdatePolicy; + readonly signal?: AbortSignal; + }): Promise; + runUpdateReconciliation( + input: { + readonly operatorPath: string; + readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly signal?: AbortSignal; + }, + onProgress: (phase: RuntimeHostServiceUpdatePhase) => void, + ): Promise; + cleanupManagedDeployment(input: { + readonly operatorPath: string; + readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly finalize?: boolean; + readonly signal?: AbortSignal; + }): Promise; + close(): Promise; +} { + const active = new Set(); + let closed = false; + const closing = new AbortController(); + const terminate = input.terminateProcess ?? terminateChildProcessTree; + + return { + async runSetup(setup, onProgress) { + if (closed) throw new Error('Local Runtime Host operator is closed'); + setup.signal?.throwIfAborted(); + const setupPackage = await resolveLocalSetupPackage(setup.setupPackage); + const command = runtimeHostLocalSetupCommand({ + ...setup, + packageSpecifier: setupPackage.specifier, + }); + const workingDirectory = await mkdtemp(join(tmpdir(), 'maka-runtime-host-local-setup-')); + try { + if (closed) throw new Error('Local Runtime Host operator is closed'); + const signal = combinedSignal(setup.signal, closing.signal); + signal.throwIfAborted(); + return await runSetupProcess({ + command, + cwd: workingDirectory, + environment: { + ...(input.environment ?? process.env), + ...(setupPackage.integrity + ? { + [RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV]: + setupPackage.integrity, + } + : {}), + }, + spawnProcess: input.spawnProcess ?? spawn, + timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, + terminate, + signal, + onProgress, + active, + }); + } finally { + await rm(workingDirectory, { recursive: true, force: true }); + } + }, + runPeer(command) { + if (closed) throw new Error('Local Runtime Host operator is closed'); + return runSingleFrameProcess({ + command: { + executable: command.operatorPath, + args: [ + 'peer', + command.action, + '--framed', + ...(command.action === 'enable' + ? command.coordinationRelays?.length + ? command.coordinationRelays.flatMap((relay) => [ + '--coordination-relay', + relay, + ]) + : ['--clear-coordination-relays'] + : []), + ...(command.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), + ...managedTargetArgs(command.target), + ], + }, + prefix: RUNTIME_HOST_PEER_MANAGEMENT_FRAME_PREFIX, + decode: decodeRuntimeHostPeerManagementFrame, + label: 'Local Runtime Host peer management', + environment: input.environment ?? process.env, + spawnProcess: input.spawnProcess ?? spawn, + timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, + terminate, + signal: combinedSignal(command.signal, closing.signal), + active, + }).then((frame) => requirePeerFrame(frame, command.action, command.target)); + }, + runPeerMesh(command) { + if (closed) throw new Error('Local Runtime Host operator is closed'); + return runPeerMeshFrameProcess({ + command: { + executable: command.operatorPath, + args: [ + 'mesh', + command.action, + '--framed', + ...(typeof command.meshId === 'string' + ? ['--mesh', command.meshId] + : command.meshId === null + ? ['--off'] + : []), + ...(command.peerId ? ['--peer', command.peerId] : []), + ...(command.displayName === null + ? ['--clear-name'] + : command.displayName + ? ['--name', command.displayName] + : []), + ...managedTargetArgs(command.target), + ], + }, + environment: input.environment ?? process.env, + spawnProcess: input.spawnProcess ?? spawn, + timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, + terminate, + signal: combinedSignal(command.signal, closing.signal), + active, + ...(command.invitation ? { inputLine: command.invitation } : {}), + action: command.action, + }); + }, + runAccess(command) { + if (closed) throw new Error('Local Runtime Host operator is closed'); + return runSingleFrameProcess({ + command: { + executable: command.operatorPath, + args: [ + 'access', + 'list', + '--framed', + '--root', + command.target.rootPath, + '--expected-root', + command.target.rootId, + ], + }, + prefix: RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX, + decode: decodeRuntimeHostAccessManagementFrame, + label: 'Local Runtime Host access management', + environment: input.environment ?? process.env, + spawnProcess: input.spawnProcess ?? spawn, + timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, + terminate, + signal: combinedSignal(command.signal, closing.signal), + active, + }).then(requireAccessListFrame); + }, + runService(command) { + if (closed) throw new Error('Local Runtime Host operator is closed'); + return runSingleFrameProcess({ + command: { + executable: command.operatorPath, + args: [ + command.action, + '--framed', + ...(command.projectDirectoryRoots === undefined + ? [] + : command.projectDirectoryRoots.length === 0 + ? ['--no-project-roots'] + : command.projectDirectoryRoots.flatMap(({ label, path }) => [ + '--project-root-json', + JSON.stringify({ label, path }), + ])), + ...(command.expectedConfigFingerprint + ? ['--expected-config-fingerprint', command.expectedConfigFingerprint] + : []), + ...(command.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), + ...(command.retainManagedDeployment ? ['--retain-managed-deployment'] : []), + ...managedTargetArgs(command.target), + ], + }, + prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, + decode: decodeRuntimeHostServiceManagementFrame, + label: 'Local Runtime Host service management', + environment: { + ...(input.environment ?? process.env), + [RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV]: '1', + }, + spawnProcess: input.spawnProcess ?? spawn, + timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, + terminate, + signal: combinedSignal(command.signal, closing.signal), + active, + }).then((frame) => requireServiceFrame(frame, command.action)); + }, + runUpdate(command, onProgress) { + if (closed) throw new Error('Local Runtime Host operator is closed'); + if (!command.target.deploymentId) { + return Promise.reject(new Error('Runtime Host update requires a deployment generation')); + } + const setupPackage = resolveLocalSetupPackage(command.setupPackage); + const targetVersion = runtimeHostSetupPackageVersion(command.setupPackage); + return runServiceFrameProcess({ + command: { + executable: 'npm', + args: [ + 'exec', + '--yes', + '--package', + setupPackage.specifier, + '--', + 'maka', + 'runtime-host', + 'service', + 'update', + '--framed', + ...(targetVersion ? ['--target', targetVersion] : []), + '--managed-root-id', + command.target.rootId, + ...(command.expectedHost + ? ['--expected-host-json', JSON.stringify(command.expectedHost)] + : []), + ...managedTargetArgs(command.target), + ...(command.allowManualUpdate ? ['--allow-manual-update'] : []), + ...(command.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), + ], + }, + environment: { + ...(input.environment ?? process.env), + ...(setupPackage.integrity + ? { [RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV]: setupPackage.integrity } + : {}), + [RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV]: '1', + }, + spawnProcess: input.spawnProcess ?? spawn, + timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, + terminate, + signal: combinedSignal(command.signal, closing.signal), + active, + action: 'update', + onProgress, + }); + }, + runUpdatePolicy(command) { + if (closed) throw new Error('Local Runtime Host operator is closed'); + const policy = command.policy; + return runServiceFrameProcess({ + command: { + executable: command.operatorPath, + args: [ + 'update-policy', + '--framed', + ...(policy + ? [ + '--target', + policy.kind === 'channel' + ? policy.channel + : policy.kind === 'fixed' + ? policy.version + : 'manual', + ] + : []), + ...managedTargetArgs(command.target), + ], + }, + environment: { + ...(input.environment ?? process.env), + [RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]: + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, + }, + spawnProcess: input.spawnProcess ?? spawn, + timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, + terminate, + signal: combinedSignal(command.signal, closing.signal), + active, + action: 'update_policy', + }); + }, + runUpdateReconciliation(command, onProgress) { + if (closed) throw new Error('Local Runtime Host operator is closed'); + return runServiceFrameProcess({ + command: { + executable: command.operatorPath, + args: ['reconcile-update', '--framed', ...managedTargetArgs(command.target)], + }, + environment: { + ...(input.environment ?? process.env), + [RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV]: '1', + [RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]: + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, + }, + spawnProcess: input.spawnProcess ?? spawn, + timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, + terminate, + signal: combinedSignal(command.signal, closing.signal), + active, + action: 'reconcile_update', + onProgress, + }); + }, + async cleanupManagedDeployment(command) { + if (closed) throw new Error('Local Runtime Host operator is closed'); + try { + await stat(command.operatorPath); + } catch (error) { + if (!isNodeError(error, 'ENOENT')) throw error; + try { + await rmdir(dirname(command.operatorPath)); + } catch (directoryError) { + if (!isNodeError(directoryError, 'ENOENT')) throw directoryError; + } + return; + } + await runExitProcess({ + command: { + executable: command.operatorPath, + args: [ + '__cleanup-managed-deployment', + ...(command.finalize ? ['--finalize'] : []), + ...managedTargetArgs(command.target), + ], + }, + label: 'Local Runtime Host deployment cleanup', + environment: input.environment ?? process.env, + spawnProcess: input.spawnProcess ?? spawn, + timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, + terminate, + signal: combinedSignal(command.signal, closing.signal), + active, + }); + }, + async close() { + if (closed) return; + closed = true; + closing.abort(new Error('Local Runtime Host operator is closed')); + await Promise.allSettled([...active].map((child) => stopProcess(child, terminate))); + }, + }; +} + +function requirePeerFrame( + frame: RuntimeHostPeerManagementFrame, + action: 'enable' | 'disable' | 'status', + target: DesktopRuntimeHostLocalServiceTarget, +): RuntimeHostPeerManagementFrame { + if (frame.action !== action) { + throw new Error('Local Runtime Host peer management returned an unrelated result'); + } + if ( + frame.kind === 'result' && + frame.status.state === 'enabled' && + frame.status.rootId !== target.rootId + ) { + throw new Error('Local Runtime Host peer management returned an unrelated root'); + } + return frame; +} + +function requireAccessListFrame( + frame: RuntimeHostAccessManagementFrame, +): RuntimeHostAccessManagementFrame { + if (frame.action !== 'list') { + throw new Error('Local Runtime Host access management returned an unrelated result'); + } + return frame; +} + +function requireServiceFrame( + frame: RuntimeHostServiceManagementFrame, + action: DesktopRuntimeHostLocalServiceManagementInput['action'], +): RuntimeHostServiceManagementFrame { + if (frame.action !== action) { + throw new Error('Local Runtime Host service management returned an unrelated result'); + } + return frame; +} + +function runServiceFrameProcess(input: { + readonly command: DesktopRuntimeHostLocalSetupCommand; + readonly environment: NodeJS.ProcessEnv; + readonly spawnProcess: typeof spawn; + readonly timeoutMs: number; + readonly terminate: typeof terminateChildProcessTree; + readonly signal?: AbortSignal; + readonly active: Set; + readonly action: RuntimeHostServiceManagementFrame['action']; + readonly onProgress?: (phase: RuntimeHostServiceUpdatePhase) => void; +}): Promise { + let result: RuntimeHostServiceManagementFrame | undefined; + let failure: Error | undefined; + return runFramedProcess({ + ...input, + prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, + decode: decodeRuntimeHostServiceManagementFrame, + label: 'Local Runtime Host service management', + onFrame(frame) { + if (frame.action !== input.action) { + failure = new Error('Local Runtime Host service management returned an unrelated result'); + return; + } + if (frame.kind === 'progress') { + if (!input.onProgress) { + failure = new Error('Local Runtime Host service management returned unexpected progress'); + } else { + input.onProgress(frame.phase); + } + return; + } + if (result) { + failure = new Error('Local Runtime Host service management returned multiple results'); + } else { + result = frame; + } + }, + result: () => result, + failure: () => failure, + acceptNonzeroResult: true, + }); +} + +function runPeerMeshFrameProcess(input: { + readonly command: DesktopRuntimeHostLocalSetupCommand; + readonly environment: NodeJS.ProcessEnv; + readonly spawnProcess: typeof spawn; + readonly timeoutMs: number; + readonly terminate: typeof terminateChildProcessTree; + readonly signal?: AbortSignal; + readonly active: Set; + readonly action: RuntimeHostPeerMeshManagementAction; + readonly inputLine?: string; +}): Promise> { + let result: Exclude | undefined; + let failure: Error | undefined; + return runFramedProcess({ + ...input, + prefix: RUNTIME_HOST_PEER_MESH_MANAGEMENT_FRAME_PREFIX, + decode: decodeRuntimeHostPeerMeshManagementFrame, + label: 'Local Runtime Host Peer Mesh management', + onFrame(frame) { + if (frame.action !== input.action) { + failure = new Error('Local Runtime Host Peer Mesh management returned an unrelated result'); + } else if (frame.kind !== 'input') { + if (result) { + failure = new Error('Local Runtime Host Peer Mesh management returned multiple results'); + } else { + result = frame; + } + } + }, + result: () => result, + failure: () => failure, + acceptNonzeroResult: true, + pendingMaxBytes: RUNTIME_HOST_PEER_MESH_MANAGEMENT_FRAME_MAX_BYTES, + }); +} + +function combinedSignal( + operation: AbortSignal | undefined, + closing: AbortSignal, +): AbortSignal { + return operation ? AbortSignal.any([operation, closing]) : closing; +} + +function resolveLocalSetupPackage( + setupPackage: DesktopRuntimeHostSetupPackage, +): { readonly specifier: string; readonly integrity?: string } { + if (setupPackage.kind === 'npm') { + return { specifier: setupPackage.specifier }; + } + return { specifier: setupPackage.path, integrity: setupPackage.integrity }; +} + +function managedTargetArgs(target: DesktopRuntimeHostLocalServiceTarget): string[] { + return [ + '--expected-service-id', + target.serviceId, + '--expected-root-path', + target.rootPath, + '--expected-root-id', + target.rootId, + ...(target.deploymentId + ? ['--expected-deployment-id', target.deploymentId] + : []), + ]; +} + +function runSingleFrameProcess(input: { + readonly command: DesktopRuntimeHostLocalSetupCommand; + readonly prefix: string; + readonly decode: (line: string) => Frame | undefined; + readonly label: string; + readonly environment: NodeJS.ProcessEnv; + readonly spawnProcess: typeof spawn; + readonly timeoutMs: number; + readonly terminate: typeof terminateChildProcessTree; + readonly signal?: AbortSignal; + readonly active: Set; + readonly inputLine?: string; +}): Promise { + let result: Frame | undefined; + let failure: Error | undefined; + return runFramedProcess({ + ...input, + onFrame(frame) { + if (result) failure = new Error(`${input.label} returned multiple results`); + else result = frame; + }, + result: () => result, + failure: () => failure, + acceptNonzeroResult: true, + }); +} + +function runExitProcess(input: { + readonly command: DesktopRuntimeHostLocalSetupCommand; + readonly label: string; + readonly environment: NodeJS.ProcessEnv; + readonly spawnProcess: typeof spawn; + readonly timeoutMs: number; + readonly terminate: typeof terminateChildProcessTree; + readonly signal?: AbortSignal; + readonly active: Set; +}): Promise { + return runFramedProcess({ + ...input, + prefix: 'MAKA_UNUSED_FRAME ', + decode: () => undefined, + onFrame: () => undefined, + result: () => true, + failure: () => undefined, + }).then(() => undefined); +} + +function runSetupProcess(input: { + readonly command: DesktopRuntimeHostLocalSetupCommand; + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; + readonly spawnProcess: typeof spawn; + readonly timeoutMs: number; + readonly terminate: typeof terminateChildProcessTree; + readonly signal?: AbortSignal; + readonly onProgress: (frame: Extract) => void; + readonly active: Set; +}): Promise { + let complete: RuntimeHostSetupCompleteFrame | undefined; + let failure: Error | undefined; + return runFramedProcess({ + ...input, + prefix: RUNTIME_HOST_SETUP_FRAME_PREFIX, + decode: decodeRuntimeHostSetupFrame, + label: 'Local Maka setup', + onFrame(frame) { + if (frame.kind === 'progress') input.onProgress(frame); + else if (frame.kind === 'error') failure = new Error(frame.error.message); + else if (complete) failure = new Error('Local Maka setup returned multiple results'); + else complete = frame; + }, + result: () => complete, + failure: () => failure, + }); +} + +function runFramedProcess(input: { + readonly command: DesktopRuntimeHostLocalSetupCommand; + readonly cwd?: string; + readonly prefix: string; + readonly decode: (line: string) => Frame | undefined; + readonly label: string; + readonly environment: NodeJS.ProcessEnv; + readonly spawnProcess: typeof spawn; + readonly timeoutMs: number; + readonly terminate: typeof terminateChildProcessTree; + readonly signal?: AbortSignal; + readonly active: Set; + readonly onFrame: (frame: Frame) => void; + readonly result: () => Result | undefined; + readonly failure: () => Error | undefined; + readonly acceptNonzeroResult?: boolean; + readonly inputLine?: string; + readonly pendingMaxBytes?: number; +}): Promise { + input.signal?.throwIfAborted(); + return new Promise((resolve, reject) => { + const child = input.spawnProcess(input.command.executable, [...input.command.args], { + ...(input.cwd ? { cwd: input.cwd } : {}), + detached: process.platform !== 'win32', + env: input.environment, + stdio: [input.inputLine === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + input.active.add(child); + if (input.inputLine !== undefined) child.stdin?.end(`${input.inputLine}\n`); + let filterFailure: Error | undefined; + let stopFailure: Error | undefined; + let stderr = ''; + let settled = false; + const filter = createRuntimeHostFramedOutputFilter({ + prefix: input.prefix, + pendingMaxBytes: input.pendingMaxBytes ?? SETUP_FRAME_PENDING_MAX, + decode: input.decode, + label: input.label, + onFrame: (frame) => { + try { + input.onFrame(frame); + } catch (error) { + filterFailure = error instanceof Error ? error : new Error(String(error)); + } + }, + onError: (error) => { + filterFailure = error; + }, + }); + const cleanup = () => { + clearTimeout(timeout); + input.signal?.removeEventListener('abort', onAbort); + input.active.delete(child); + }; + const finish = (result: Result | undefined, error?: Error) => { + if (settled) return; + settled = true; + cleanup(); + if (error) reject(error); + else resolve(result!); + }; + const stop = (error: Error) => { + if (stopFailure) return; + stopFailure = error; + void stopProcess(child, input.terminate).then( + () => finish(undefined, error), + (stopError) => finish(undefined, new AggregateError([error, stopError])), + ); + }; + const onAbort = () => stop(abortError(input.signal)); + const timeout = setTimeout(() => stop(new Error(`${input.label} timed out`)), input.timeoutMs); + input.signal?.addEventListener('abort', onAbort, { once: true }); + child.stdout?.on('data', (chunk: Buffer) => filter.push(chunk.toString('utf8'))); + child.stderr?.on('data', (chunk: Buffer) => { + stderr = appendBounded(stderr, chunk.toString('utf8'), STDERR_MAX_BYTES); + }); + child.once('error', (error) => { + if (!stopFailure) finish(undefined, error); + }); + child.once('close', (code, signal) => { + if (stopFailure) return; + filter.finish(); + const failure = filterFailure ?? input.failure(); + if (failure) return finish(undefined, failure); + const result = input.result(); + if (result && (code === 0 || input.acceptNonzeroResult)) return finish(result); + const status = code === null ? signal ?? 'an unknown status' : `code ${code}`; + const detail = redactSecrets(stderr.trim()).slice(-2_000); + finish( + undefined, + new Error( + detail + ? `${input.label} exited with ${status}: ${detail}` + : result + ? `${input.label} exited with ${status}` + : `${input.label} ended without a result (${status})`, + ), + ); + }); + if (input.signal?.aborted) onAbort(); + }); +} + +async function stopProcess( + child: ChildProcess, + terminate: typeof terminateChildProcessTree, +): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await terminate(child, 'SIGTERM'); + if (await exitsWithin(child, DEFAULT_PROCESS_TERMINATION_GRACE_MS)) return; + await terminate(child, 'SIGKILL'); + if (!(await exitsWithin(child, DEFAULT_PROCESS_TERMINATION_GRACE_MS))) { + throw new Error('Local Runtime Host operator did not exit after forced termination'); + } +} + +function exitsWithin(child: ChildProcess, timeoutMs: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true); + return new Promise((resolve) => { + const timeout = setTimeout(() => { + child.removeListener('close', onClose); + resolve(false); + }, timeoutMs); + const onClose = () => { + clearTimeout(timeout); + resolve(true); + }; + child.once('close', onClose); + }); +} + +function appendBounded(current: string, chunk: string, maxBytes: number): string { + const next = current + chunk; + const encoded = Buffer.from(next); + return encoded.byteLength <= maxBytes + ? next + : encoded.subarray(encoded.byteLength - maxBytes).toString('utf8'); +} + +function abortError(signal: AbortSignal | undefined): Error { + return signal?.reason instanceof Error ? signal.reason : new Error('Local Maka setup was cancelled'); +} + +function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error && error.code === code; +} diff --git a/apps/desktop/src/main/runtime-host-local-remote-access.ts b/apps/desktop/src/main/runtime-host-local-remote-access.ts new file mode 100644 index 0000000000..942e245c82 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -0,0 +1,1335 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { open, readFile, rename, rm } from 'node:fs/promises'; +import { hostname } from 'node:os'; +import { dirname, isAbsolute, join } from 'node:path'; +import type { IpcMain } from 'electron'; +import { + consumeAccessCredentialDelivery, + encodeRuntimeHostOwnerConnectionCode, +} from '@maka/runtime-host/client'; +import { resolveRuntimeHostManagedDeploymentAuthority } from '@maka/runtime-host/operator'; +import { REMOTE_OWNER_OPERATION_GRANTS, type HostRegistration } from '@maka/runtime-host/protocol'; +import type { + DesktopLocalRuntimeHostRemoteAccessEnableResult, + DesktopLocalRuntimeHostRemoteAccessSnapshot, +} from '../preload/bridge-contract.js'; +import type { + RuntimeHostDesktopManager, + RuntimeHostLocalReplacement, +} from './runtime-host-desktop-manager.js'; +import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; +import type { + createDesktopRuntimeHostLocalOperator, + DesktopRuntimeHostLocalServiceTarget, +} from './runtime-host-local-operator.js'; +import type { DesktopRuntimeHostSetupPackage } from './runtime-host-setup-package.js'; + +const LIFECYCLE_FILE = 'runtime-host-local-service.json'; +const SERVICE_ID_PATTERN = /^[a-f0-9]{64}$/u; +const ROOT_ID_PATTERN = /^[a-f0-9]{64}$/u; +const DEPLOYMENT_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; +const ADDRESS_MAX_BYTES = 2 * 1024; +const ADDRESS_MAX_COUNT = 16; +const LOCAL_REMOTE_ACCESS_PRINCIPAL_ID = 'desktop-owner:local-runtime-host-sharing'; + +interface LocalServiceTarget extends DesktopRuntimeHostLocalServiceTarget { + readonly schemaVersion: 1; + readonly operatorPath: string; + readonly deploymentId: string; +} + +interface LocalServiceSetupPending { + readonly schemaVersion: 1; + /** Persisted only after the Desktop-owned Host has retired. */ + readonly state: 'setupPending'; + readonly rootPath: string; + readonly rootId: string; + readonly coordinationRelays: readonly string[]; + readonly allowInterruptActiveTasks: boolean; +} + +/** Schema-v1 setup intent written by Desktop releases before ownership was established. */ +interface LocalServiceLegacyHandoff { + readonly schemaVersion: 1; + readonly state: 'handoff'; + readonly rootPath: string; + readonly rootId: string; + readonly coordinationRelays: readonly string[]; + readonly allowInterruptActiveTasks: boolean; +} + +interface LocalServiceManaged extends LocalServiceTarget { + readonly state: 'managed'; +} + +type LocalManagedDeploymentAuthority = + | { + readonly kind: 'active'; + readonly lifecycleMode: 'on_demand' | 'supervised'; + readonly target: LocalServiceTarget; + } + | { readonly kind: 'transition' }; + +export interface DesktopRuntimeHostLocalManagementTarget + extends DesktopRuntimeHostLocalServiceTarget { + readonly operatorPath: string; + readonly deploymentId: string; +} + +export type DesktopRuntimeHostManagedStartupRepairResult = + | { readonly kind: 'repaired' } + | { readonly kind: 'active_tasks' } + | { readonly kind: 'unavailable' }; + +export interface DesktopLocalRuntimeHostRemoteAccess { + getSnapshot(): Promise; + createCollaborationConnectionTarget(): Promise<{ + readonly name: string; + readonly transport: { + readonly kind: 'libp2p-direct'; + readonly peerId: string; + readonly routeHints: readonly string[]; + readonly coordinationRelays: readonly string[]; + }; + }>; + enable(value: unknown): Promise; + disable(): Promise; + uninstall(value: unknown): Promise<{ readonly kind: 'active_tasks' | 'uninstalled' }>; + inspectManaged( + operation: (target: DesktopRuntimeHostLocalManagementTarget) => Promise, + ): Promise; + changeManaged( + operation: (target: DesktopRuntimeHostLocalManagementTarget) => Promise, + ): Promise; + resolveConflictingHostReplacement( + registration: HostRegistration, + signal: AbortSignal, + ): Promise; + repairManagedStartup(input?: { + readonly allowManualUpdate?: boolean; + readonly allowInterruptActiveTasks?: boolean; + readonly signal?: AbortSignal; + }): Promise; + recoverBeforeLocalHostStart(signal?: AbortSignal): Promise; + recover(): Promise; + close(): Promise; +} + +interface LocalServicePeerChanging extends LocalServiceTarget { + readonly state: 'peerChanging'; + readonly peerEnabled: boolean; + readonly coordinationRelays: readonly string[]; + readonly allowInterruptActiveTasks: boolean; +} + +interface LocalServiceUninstalling extends LocalServiceTarget { + readonly state: 'uninstalling' | 'cleanupPending'; + readonly allowInterruptActiveTasks: boolean; +} + +type LocalServiceLifecycle = + | LocalServiceLegacyHandoff + | LocalServiceSetupPending + | LocalServiceManaged + | LocalServicePeerChanging + | LocalServiceUninstalling; + +interface LocalPeerDescriptor { + readonly peerId: string; + readonly routeHints: readonly string[]; + readonly coordinationRelays: readonly string[]; +} + +type DesktopRuntimeHostLocalOperator = ReturnType< + typeof createDesktopRuntimeHostLocalOperator +>; +type LocalPeerResultFrame = Extract< + Awaited>, + { kind: 'result'; action: 'enable' | 'disable' } +>; + +export function createDesktopLocalRuntimeHostRemoteAccess(input: { + readonly ipcMain: Pick; + readonly clientDataRoot: string; + readonly rootPath: string; + readonly rootId: string; + readonly directPeerAvailable: boolean; + readonly manager: () => RuntimeHostDesktopManager | undefined; + readonly resolveSetupPackage: ( + signal?: AbortSignal, + ) => DesktopRuntimeHostSetupPackage | Promise; + readonly operator: DesktopRuntimeHostLocalOperator; + readonly resolveManagedDeploymentAuthority?: ( + rootId: string, + ) => Promise; +}): DesktopLocalRuntimeHostRemoteAccess { + const lifecyclePath = join(input.clientDataRoot, LIFECYCLE_FILE); + const closing = new AbortController(); + let mutation = Promise.resolve(); + const serialize = (operation: () => Promise): Promise => { + const result = mutation.then(operation); + mutation = result.then( + () => undefined, + () => undefined, + ); + return result; + }; + const resolveManagedDeploymentAuthority = + input.resolveManagedDeploymentAuthority ?? + (async (rootId: string): Promise => { + const authority = await resolveRuntimeHostManagedDeploymentAuthority(rootId); + if (!authority) return undefined; + if (authority.record.state !== 'active') return { kind: 'transition' }; + return { + kind: 'active', + lifecycleMode: authority.record.lifecycle.mode, + target: requireServiceTarget( + { + schemaVersion: 1, + serviceId: authority.record.root.id, + operatorPath: join(authority.record.deploymentRoot, 'operator'), + rootPath: authority.record.root.path, + rootId: authority.record.root.id, + deploymentId: authority.record.deploymentId, + }, + input.rootPath, + ), + }; + }); + + const adoptCommittedSetup = async ( + setup: LocalServiceSetupPending, + ): Promise< + | { readonly kind: 'absent' | 'transition' } + | { readonly kind: 'managed'; readonly managed: LocalServiceManaged } + > => { + const authority = await resolveManagedDeploymentAuthority(setup.rootId); + if (!authority) return { kind: 'absent' }; + if (authority.kind === 'transition') return authority; + const managed = managedLifecycle(authority.target); + await writeDocument(lifecyclePath, managed); + return { kind: 'managed', managed }; + }; + + const getSnapshot = (): Promise => + serialize(async () => { + let managedService = false; + try { + const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + managedService = lifecycle !== undefined && hasManagedServiceTarget(lifecycle); + if (!supported(input.directPeerAvailable)) { + return { + ...unsupportedSnapshot(), + ...(managedService ? { managedService: true as const } : {}), + }; + } + if (!lifecycle) return { state: 'off' }; + if (lifecycle.state !== 'managed') { + return { + state: 'unavailable', + message: + lifecycle.state === 'setupPending' || lifecycle.state === 'handoff' + ? 'Local Runtime Host setup is being recovered' + : lifecycle.state === 'peerChanging' + ? 'Local Runtime Host remote access is being recovered' + : 'Local Runtime Host uninstall is being recovered', + ...(managedService ? { managedService: true } : {}), + }; + } + const sharedAccess = await hasSharedAccess(input.operator, lifecycle); + const peer = await readPeer(input.operator, lifecycle); + return peer + ? onSnapshot(sharedAccess) + : { state: 'off', managedService: true, ...(sharedAccess ? { sharedAccess: true } : {}) }; + } catch (error) { + return { + state: 'unavailable', + message: errorMessage(error), + ...(managedService ? { managedService: true } : {}), + }; + } + }); + + const enable = (value: unknown): Promise => + serialize(async () => { + const request = requireEnableInput(value); + if (!supported(input.directPeerAvailable)) throw new Error(unsupportedSnapshot().message); + let lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (lifecycle?.state === 'uninstalling') { + const recovered = await finishUninstall(lifecycle); + if (recovered.kind === 'active_tasks') return recovered; + lifecycle = undefined; + } + if (lifecycle?.state === 'cleanupPending') { + await finishUninstall(lifecycle); + lifecycle = undefined; + } + if (lifecycle?.state === 'peerChanging') { + const recovered = await finishPeerChange(lifecycle); + if (recovered.kind === 'active_tasks') return recovered; + lifecycle = managedLifecycle(lifecycle); + } + if (lifecycle?.state === 'handoff') { + const recovered = await recoverLegacyHandoff(lifecycle); + if (recovered.kind === 'active_tasks') return recovered; + if (recovered.kind === 'external') { + throw new Error('The Local Runtime Host is already managed outside this Desktop'); + } + lifecycle = recovered.managed; + } + if (lifecycle?.state === 'setupPending') { + const committed = await adoptCommittedSetup(lifecycle); + if (committed.kind === 'managed') { + lifecycle = committed.managed; + } else { + const recovered = await finishSetup(lifecycle, 'recovery'); + if (recovered.kind === 'active_tasks') return recovered; + lifecycle = recovered.managed; + } + } + if (lifecycle?.state === 'managed') { + const manager = requireManager(input.manager); + const previousHostEpoch = manager.current('local')?.candidate?.client.hostEpoch; + const desired: LocalServicePeerChanging = { + ...lifecycle, + state: 'peerChanging', + peerEnabled: true, + coordinationRelays: request.coordinationRelays, + allowInterruptActiveTasks: request.allowInterruptActiveTasks, + }; + await writeDocument(lifecyclePath, desired); + const changed = await finishPeerChange(desired); + if (changed.kind === 'active_tasks') { + return changed; + } + const peer = requireEnabledPeer(changed.response.status); + await manager.waitUntilReady( + 'local', + changed.response.restarted ? previousHostEpoch : undefined, + ); + return enabledResult( + await issueConnectionCode(input.rootPath, desired.rootId, peer, localClient(input.manager)), + ); + } + + const setup: LocalServiceSetupPending = { + schemaVersion: 1, + state: 'setupPending', + rootPath: input.rootPath, + rootId: input.rootId, + coordinationRelays: request.coordinationRelays, + allowInterruptActiveTasks: request.allowInterruptActiveTasks, + }; + const completed = await finishSetup(setup, 'request'); + if (completed.kind === 'active_tasks') return completed; + const manager = requireManager(input.manager); + await manager.waitUntilReady('local'); + const livePeer = await readLivePeer(localClient(input.manager), completed.peer); + return enabledResult( + encodeRuntimeHostOwnerConnectionCode({ + name: hostName(), + rootId: completed.managed.rootId, + transport: { kind: 'libp2p-direct', ...livePeer }, + credential: completed.credential, + }), + ); + }); + + const finishSetup = async ( + setup: LocalServiceSetupPending, + origin: 'request' | 'recovery', + ): Promise< + | { readonly kind: 'active_tasks' } + | { + readonly kind: 'complete'; + readonly managed: LocalServiceManaged; + readonly peer: LocalPeerDescriptor; + readonly credential: string; + } + > => { + const setupPackage = await input.resolveSetupPackage(closing.signal); + const manager = requireManager(input.manager); + const retirement = await manager.retireOwnedLocalHost( + setup.allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work', + ); + if (retirement.kind === 'active_tasks') { + return { kind: 'active_tasks' }; + } + if (retirement.kind === 'not_owned' && origin === 'request') { + throw new Error('The Local Runtime Host is already managed outside this Desktop'); + } + try { + if (origin === 'request') await writeDocument(lifecyclePath, setup); + const reconcile = () => reconcileSetup(setup, setupPackage); + // Recovery may find that the operator already owns the root. Its setup + // can still restart that Host, so keep Desktop reconnect quiesced across + // the entire reconciliation just like every other managed-service change. + return retirement.kind === 'not_owned' + ? await manager.runManagedLocalHostChange(reconcile) + : await reconcile(); + } finally { + if (retirement.kind === 'retired') retirement.resume(); + } + }; + + const reconcileSetup = async ( + setup: LocalServiceSetupPending, + setupPackage: DesktopRuntimeHostSetupPackage, + signal: AbortSignal = closing.signal, + ): Promise<{ + readonly kind: 'complete'; + readonly managed: LocalServiceManaged; + readonly peer: LocalPeerDescriptor; + readonly credential: string; + }> => { + let target: LocalServiceTarget | undefined; + try { + const complete = await input.operator.runSetup( + { + setupPackage, + clientDataRoot: input.clientDataRoot, + rootPath: setup.rootPath, + principalId: LOCAL_REMOTE_ACCESS_PRINCIPAL_ID, + coordinationRelays: setup.coordinationRelays, + expectedTarget: { + serviceId: setup.rootId, + rootPath: setup.rootPath, + rootId: setup.rootId, + }, + signal, + }, + () => undefined, + ); + if ( + complete.serviceId !== setup.rootId || + complete.rootPath !== setup.rootPath || + complete.rootId !== setup.rootId || + !DEPLOYMENT_ID_PATTERN.test(complete.deploymentId) || + !complete.directPeer + ) { + throw new Error('Local Runtime Host setup returned an unrelated service'); + } + target = requireServiceTarget( + { + schemaVersion: 1, + serviceId: complete.serviceId, + operatorPath: complete.operatorPath, + rootPath: complete.rootPath, + rootId: complete.rootId, + deploymentId: complete.deploymentId, + }, + setup.rootPath, + ); + const peer = requireEnabledPeer({ state: 'enabled', ...complete.directPeer }); + const managed: LocalServiceManaged = { ...target, state: 'managed' }; + await writeDocument(lifecyclePath, managed); + return { kind: 'complete', managed, peer, credential: complete.credential }; + } catch (error) { + if (!target) throw error; + try { + await uninstallExactService(input.operator, target); + await removeDocument(lifecyclePath); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + 'Local Runtime Host setup rollback failed', + ); + } + throw error; + } + }; + + const recoverLegacyHandoff = async ( + legacy: LocalServiceLegacyHandoff, + ): Promise< + | { readonly kind: 'active_tasks' } + | { readonly kind: 'external' } + | { readonly kind: 'complete'; readonly managed: LocalServiceManaged } + > => { + const pending = pendingSetup(legacy); + const authority = await adoptCommittedSetup(pending); + if (authority.kind === 'managed') { + return { kind: 'complete', managed: authority.managed }; + } + if (authority.kind === 'transition') { + await writeDocument(lifecyclePath, pending); + return finishSetup(pending, 'recovery'); + } + + const manager = requireManager(input.manager); + const retirement = await manager.retireOwnedLocalHost( + legacy.allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work', + ); + if (retirement.kind === 'active_tasks') return { kind: 'active_tasks' }; + if (retirement.kind === 'not_owned') { + const raced = await adoptCommittedSetup(pending); + if (raced.kind === 'managed') { + return { kind: 'complete', managed: raced.managed }; + } + if (raced.kind === 'absent') { + await removeDocument(lifecyclePath); + return { kind: 'external' }; + } + } + + try { + await writeDocument(lifecyclePath, pending); + const setupPackage = await input.resolveSetupPackage(closing.signal); + const reconcile = () => reconcileSetup(pending, setupPackage); + return retirement.kind === 'not_owned' + ? await manager.runManagedLocalHostChange(reconcile) + : await reconcile(); + } finally { + if (retirement.kind === 'retired') retirement.resume(); + } + }; + + const createConnectionCode = (): Promise => + serialize(async () => { + const managed = requireManaged( + await readLifecycle(lifecyclePath, input.rootPath, input.rootId), + ); + const peer = await readPeer(input.operator, managed); + if (!peer) throw new Error('Remote access is not enabled on this computer'); + return issueConnectionCode(input.rootPath, managed.rootId, peer, localClient(input.manager)); + }); + + const createCollaborationConnectionTarget = () => + serialize(async () => { + const managed = requireManaged( + await readLifecycle(lifecyclePath, input.rootPath, input.rootId), + ); + const peer = await readPeer(input.operator, managed); + if (!peer) throw new Error('Remote access is not enabled on this computer'); + const livePeer = await readLivePeer(localClient(input.manager), peer); + return { + name: hostName(), + transport: { kind: 'libp2p-direct' as const, ...livePeer }, + }; + }); + + const revokeSharedAccess = (): Promise => + serialize(async () => { + const managed = requireManaged( + await readLifecycle(lifecyclePath, input.rootPath, input.rootId), + ); + await localClient(input.manager).request('access.principal.revoke', { + principalKind: 'remote_owner', + principalId: LOCAL_REMOTE_ACCESS_PRINCIPAL_ID, + }); + const peer = await readPeer(input.operator, managed); + return peer ? onSnapshot(false) : { state: 'off', managedService: true }; + }); + + const disable = (): Promise => + serialize(async () => { + const managed = requireManaged( + await readLifecycle(lifecyclePath, input.rootPath, input.rootId), + ); + const desired: LocalServicePeerChanging = { + ...managed, + state: 'peerChanging', + peerEnabled: false, + coordinationRelays: [], + allowInterruptActiveTasks: false, + }; + await writeDocument(lifecyclePath, desired); + const changed = await finishPeerChange(desired); + if (changed.kind === 'active_tasks') { + throw new Error('Runtime Host still owns active work; remote access was not disabled'); + } + if (changed.response.status.state === 'enabled') { + throw new Error('Local Runtime Host Direct peer did not disable'); + } + return { state: 'off', managedService: true, ...(await sharedAccessFlag(input.operator, managed)) }; + }); + + const uninstall = ( + value: unknown, + ): Promise<{ readonly kind: 'active_tasks' | 'uninstalled' }> => + serialize(async () => { + if (!isRecord(value) || typeof value.allowInterruptActiveTasks !== 'boolean') { + throw new Error('Local Runtime Host uninstall request is invalid'); + } + const allowInterruptActiveTasks = value.allowInterruptActiveTasks; + const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (lifecycle?.state === 'uninstalling') { + const intent = allowInterruptActiveTasks && !lifecycle.allowInterruptActiveTasks + ? { ...lifecycle, allowInterruptActiveTasks: true } + : lifecycle; + if (intent !== lifecycle) await writeDocument(lifecyclePath, intent); + return finishUninstall(intent); + } + if (lifecycle?.state === 'cleanupPending') { + return finishUninstall(lifecycle); + } + const managed = requireManagementTarget(lifecycle); + const intent: LocalServiceUninstalling = { + schemaVersion: 1, + ...managed, + state: 'uninstalling', + allowInterruptActiveTasks, + }; + await writeDocument(lifecyclePath, intent); + return finishUninstall(intent); + }); + + const finishPeerChange = async ( + intent: LocalServicePeerChanging, + coordination: 'manager' | 'direct' = 'manager', + ): Promise< + | { readonly kind: 'active_tasks' } + | { readonly kind: 'complete'; readonly response: LocalPeerResultFrame } + > => { + const change = () => + input.operator.runPeer({ + operatorPath: intent.operatorPath, + action: intent.peerEnabled ? 'enable' : 'disable', + target: intent, + coordinationRelays: intent.coordinationRelays, + allowInterruptActiveTasks: intent.allowInterruptActiveTasks, + }); + const response = coordination === 'manager' + ? await runManagedServiceChange(change) + : await change(); + if (response.kind === 'error') { + if (response.error.code === 'active_tasks') { + await writeDocument(lifecyclePath, managedLifecycle(intent)); + return { kind: 'active_tasks' }; + } + throw new Error(response.error.message); + } + if (response.action === 'status') { + throw new Error('Local Runtime Host returned an unrelated peer result'); + } + await writeDocument(lifecyclePath, managedLifecycle(intent)); + return { kind: 'complete', response }; + }; + + const finishUninstall = async ( + intent: LocalServiceUninstalling, + coordination: 'manager' | 'direct' = 'manager', + signal: AbortSignal = closing.signal, + ): Promise<{ readonly kind: 'active_tasks' } | { readonly kind: 'uninstalled' }> => { + if (intent.state === 'uninstalling') { + const authority = await resolveManagedDeploymentAuthority(intent.rootId); + if (authority) { + const change = () => + input.operator.runService({ + operatorPath: intent.operatorPath, + action: 'uninstall', + target: intent, + allowInterruptActiveTasks: intent.allowInterruptActiveTasks, + retainManagedDeployment: true, + }); + const response = coordination === 'manager' + ? await runManagedServiceChange(change) + : await change(); + if (response.kind === 'error') throw new Error(response.error.message); + if (response.action !== 'uninstall') { + throw new Error('Local Runtime Host returned an unrelated service result'); + } + if (response.retirement.kind === 'active_tasks') { + await writeDocument(lifecyclePath, managedLifecycle(intent)); + return { kind: 'active_tasks' }; + } + } + intent = { ...intent, state: 'cleanupPending' }; + await writeDocument(lifecyclePath, intent); + } + await input.operator.cleanupManagedDeployment({ + operatorPath: intent.operatorPath, + target: intent, + signal, + }); + await input.operator.cleanupManagedDeployment({ + operatorPath: intent.operatorPath, + target: intent, + finalize: true, + signal, + }); + await removeDocument(lifecyclePath); + return { kind: 'uninstalled' }; + }; + + const runManagedServiceChange = (change: () => Promise): Promise => { + const manager = requireManager(input.manager); + return manager.runManagedLocalHostChange(change); + }; + + const channels = [ + 'local-runtime-host-remote-access:get-snapshot', + 'local-runtime-host-remote-access:enable', + 'local-runtime-host-remote-access:create-connection-code', + 'local-runtime-host-remote-access:revoke-shared-access', + 'local-runtime-host-remote-access:disable', + ] as const; + input.ipcMain.handle(channels[0], getSnapshot); + input.ipcMain.handle(channels[1], (_event, value: unknown) => enable(value)); + input.ipcMain.handle(channels[2], createConnectionCode); + input.ipcMain.handle(channels[3], revokeSharedAccess); + input.ipcMain.handle(channels[4], disable); + + return { + getSnapshot, + createCollaborationConnectionTarget, + enable, + disable, + uninstall, + inspectManaged: (operation) => + serialize(async () => { + const managed = requireManagementTarget( + await readLifecycle(lifecyclePath, input.rootPath, input.rootId), + ); + return operation(managed); + }), + changeManaged: (operation) => + serialize(async () => { + const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (lifecycle?.state === 'uninstalling' || lifecycle?.state === 'cleanupPending') { + throw new Error('Finish uninstalling the Local Runtime Host before changing it'); + } + const managed = requireManagementTarget(lifecycle); + return requireManager(input.manager).runManagedLocalHostChange(() => operation(managed)); + }), + resolveConflictingHostReplacement: async (registration, signal) => { + signal.throwIfAborted(); + if (registration.rootId !== input.rootId) { + throw conflictReplacementError(registration.pid, 'the workspace identity changed'); + } + if (registration.lifecycleMode === 'ephemeral') return undefined; + const authority = await resolveManagedDeploymentAuthority(registration.rootId); + if ( + !authority || + authority.kind !== 'active' || + authority.lifecycleMode !== 'supervised' + ) { + return undefined; + } + const target = authority.target; + return { + replace: (activeWorkPolicy) => + serialize(async () => { + signal.throwIfAborted(); + const setupPackage = await input.resolveSetupPackage(signal); + const frame = await input.operator.runUpdate( + { + setupPackage, + target, + expectedHost: { + hostEpoch: registration.hostEpoch, + pid: registration.pid, + }, + ...(activeWorkPolicy === 'interrupt_active_work' + ? { allowInterruptActiveTasks: true } + : {}), + signal, + }, + () => undefined, + ); + if (frame.kind === 'error') { + if (frame.error.code === 'active_tasks') return 'active_tasks'; + if (frame.error.code === 'target_mismatch') return 'replaced'; + throw conflictReplacementError(registration.pid, frame.error.message); + } + if (frame.kind === 'progress' || frame.action !== 'update') { + throw conflictReplacementError( + registration.pid, + 'the managed service returned an unrelated result', + ); + } + if (frame.update.kind === 'active_tasks') { + return 'active_tasks'; + } + if (frame.update.kind === 'already_current') { + throw conflictReplacementError( + registration.pid, + 'the managed service did not replace the observed Host', + ); + } + return 'replaced'; + }), + }; + }, + repairManagedStartup: (options = {}) => + serialize(async () => { + const signal = options.signal + ? AbortSignal.any([options.signal, closing.signal]) + : closing.signal; + signal.throwIfAborted(); + const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (lifecycle?.state !== 'managed') return { kind: 'unavailable' }; + + const setupPackage = await input.resolveSetupPackage(signal); + const frame = await input.operator.runUpdate( + { + setupPackage, + target: lifecycle, + ...(options.allowManualUpdate ? { allowManualUpdate: true } : {}), + ...(options.allowInterruptActiveTasks + ? { allowInterruptActiveTasks: true } + : {}), + signal, + }, + () => undefined, + ); + if (frame.kind === 'error') { + if (frame.error.code === 'active_tasks') return { kind: 'active_tasks' }; + throw new Error(`Runtime Host repair failed: ${frame.error.message}`); + } + if (frame.kind === 'progress' || frame.action !== 'update') { + throw new Error('Runtime Host repair returned an unrelated result'); + } + if (frame.update.kind === 'active_tasks') return { kind: 'active_tasks' }; + + if (frame.update.kind === 'already_current') { + const restarted = await input.operator.runService({ + operatorPath: lifecycle.operatorPath, + action: 'restart', + target: lifecycle, + ...(options.allowInterruptActiveTasks + ? { allowInterruptActiveTasks: true } + : {}), + signal, + }); + if (restarted.kind === 'error') { + if (restarted.error.code === 'active_tasks') return { kind: 'active_tasks' }; + throw new Error(`Runtime Host restart failed: ${restarted.error.message}`); + } + if (restarted.kind === 'progress' || restarted.action !== 'restart') { + throw new Error('Runtime Host restart returned an unrelated result'); + } + } + return { kind: 'repaired' }; + }), + recoverBeforeLocalHostStart: async (signal) => { + const operationSignal = signal ? AbortSignal.any([signal, closing.signal]) : closing.signal; + operationSignal.throwIfAborted(); + const observed = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (observed?.state === 'peerChanging') { + return serialize(async () => { + operationSignal.throwIfAborted(); + const pending = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (pending?.state !== 'peerChanging') return false; + await finishPeerChange(pending, 'direct'); + return true; + }); + } + if (observed?.state === 'uninstalling' || observed?.state === 'cleanupPending') { + return serialize(async () => { + operationSignal.throwIfAborted(); + const pending = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (pending?.state !== 'uninstalling' && pending?.state !== 'cleanupPending') { + return false; + } + await finishUninstall(pending, 'direct', operationSignal); + return true; + }); + } + if (observed?.state !== 'setupPending' && observed?.state !== 'handoff') return false; + return serialize(async () => { + operationSignal.throwIfAborted(); + const pending = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (pending?.state !== 'setupPending' && pending?.state !== 'handoff') return false; + const committed = pendingSetup(pending); + const authority = await adoptCommittedSetup(committed); + if (authority.kind === 'absent') return false; + if (authority.kind === 'managed') return true; + if (pending.state === 'handoff') await writeDocument(lifecyclePath, committed); + const setupPackage = await input.resolveSetupPackage(operationSignal); + await reconcileSetup(committed, setupPackage, operationSignal); + return true; + }); + }, + recover: () => + serialize(async () => { + const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (!lifecycle) return; + if (lifecycle.state === 'uninstalling' || lifecycle.state === 'cleanupPending') { + await finishUninstall(lifecycle); + return; + } + if (lifecycle.state === 'peerChanging') { + const recovered = await finishPeerChange(lifecycle); + if (recovered.kind === 'active_tasks') { + throw new Error('Local Runtime Host peer recovery was blocked by active work'); + } + return; + } + if (lifecycle.state === 'handoff' || lifecycle.state === 'setupPending') { + const committed = await adoptCommittedSetup(pendingSetup(lifecycle)); + if (committed.kind === 'managed') return; + if (!supported(input.directPeerAvailable)) return; + if (lifecycle.state === 'handoff') await recoverLegacyHandoff(lifecycle); + else await finishSetup(lifecycle, 'recovery'); + return; + } + }), + async close() { + for (const channel of channels) input.ipcMain.removeHandler(channel); + closing.abort(new Error('Maka is shutting down')); + await input.operator.close(); + await mutation; + }, + }; +} + +function conflictReplacementError(pid: number, reason: string): Error { + return new Error(`Maka could not replace Runtime Host process ${pid}: ${reason}`); +} + +function supported(directPeerAvailable: boolean): boolean { + return directPeerAvailable && (process.platform === 'darwin' || process.platform === 'linux'); +} + +function unsupportedSnapshot(): Extract< + DesktopLocalRuntimeHostRemoteAccessSnapshot, + { state: 'unsupported' } +> { + return { + state: 'unsupported', + message: + process.platform === 'darwin' || process.platform === 'linux' + ? 'This Desktop build does not include Direct peer support' + : 'Remote access to this computer currently requires macOS or Linux', + }; +} + +function requireEnableInput(value: unknown): { + readonly allowInterruptActiveTasks: boolean; + readonly coordinationRelays: readonly string[]; +} { + if (!isRecord(value) || typeof value.allowInterruptActiveTasks !== 'boolean') { + throw new Error('Local Runtime Host remote-access request is invalid'); + } + return { + allowInterruptActiveTasks: value.allowInterruptActiveTasks, + coordinationRelays: requireAddresses(value.coordinationRelays), + }; +} + +async function readPeer( + operator: DesktopRuntimeHostLocalOperator, + receipt: LocalServiceTarget, +): Promise { + const response = await operator.runPeer({ + operatorPath: receipt.operatorPath, + action: 'status', + target: receipt, + }); + if (response.kind === 'error') throw new Error(response.error.message); + return response.status.state === 'enabled' ? requireEnabledPeer(response.status) : undefined; +} + +function requireEnabledPeer(value: unknown): LocalPeerDescriptor { + if (!isRecord(value) || value.state !== 'enabled') { + throw new Error('Runtime Host Direct peer is not enabled'); + } + if (typeof value.peerId !== 'string' || value.peerId.length === 0 || value.peerId.length > 160) { + throw new Error('Runtime Host returned an invalid peer identity'); + } + const peer = { + peerId: value.peerId, + routeHints: requireAddresses(value.routeHints), + coordinationRelays: requireAddresses(value.coordinationRelays), + }; + if (peer.routeHints.length === 0 && peer.coordinationRelays.length === 0) { + throw new Error('Runtime Host Direct peer has no reachable route'); + } + return peer; +} + +function onSnapshot(sharedAccess: boolean): Extract< + DesktopLocalRuntimeHostRemoteAccessSnapshot, + { state: 'on' } +> { + return { + state: 'on', + managedService: true, + ...(sharedAccess ? { sharedAccess: true } : {}), + }; +} + +function enabledResult( + connectionCode: string, +): Extract { + return { kind: 'enabled', connectionCode, snapshot: onSnapshot(true) }; +} + +async function issueConnectionCode( + rootPath: string, + rootId: string, + peer: LocalPeerDescriptor, + client: DesktopRuntimeHostClient, +): Promise { + const livePeer = await readLivePeer(client, peer); + const prepared = await client.request('access.credential.prepare', { + principalKind: 'remote_owner', + principalId: LOCAL_REMOTE_ACCESS_PRINCIPAL_ID, + operationGrants: REMOTE_OWNER_OPERATION_GRANTS, + canPublishClientCapabilities: true, + canUseHostPaths: false, + bindClientInstance: true, + }); + const credential = await consumeAccessCredentialDelivery( + rootPath, + prepared.deliveryId, + prepared.credentialId, + ); + return encodeRuntimeHostOwnerConnectionCode({ + name: hostName(), + rootId, + transport: { kind: 'libp2p-direct', ...livePeer }, + credential, + }); +} + +async function readLivePeer( + client: DesktopRuntimeHostClient, + configured: LocalPeerDescriptor, +): Promise { + const endpoint = (await client.status()).peerEndpoint; + if (!endpoint) { + throw new Error('Runtime Host Direct peer is not available'); + } + if (endpoint.peerId !== configured.peerId) { + throw new Error('Runtime Host Direct peer identity changed'); + } + return requireEnabledPeer({ + state: 'enabled', + peerId: endpoint.peerId, + routeHints: endpoint.routeHints, + coordinationRelays: endpoint.coordinationRelays, + }); +} + +async function hasSharedAccess( + operator: DesktopRuntimeHostLocalOperator, + target: LocalServiceTarget, +): Promise { + const response = await operator.runAccess({ + operatorPath: target.operatorPath, + target, + }); + if (response.kind === 'error') throw new Error(response.error.message); + return response.credentials.some( + (credential) => + credential.principalKind === 'remote_owner' && + credential.principalId === LOCAL_REMOTE_ACCESS_PRINCIPAL_ID, + ); +} + +async function sharedAccessFlag( + operator: DesktopRuntimeHostLocalOperator, + target: LocalServiceTarget, +): Promise<{ readonly sharedAccess: true } | Record> { + return (await hasSharedAccess(operator, target)) ? { sharedAccess: true } : {}; +} + +function localClient(manager: () => RuntimeHostDesktopManager | undefined): DesktopRuntimeHostClient { + const snapshot = requireManager(manager).current('local'); + if (!snapshot?.candidate) throw new Error('The Local Runtime Host is reconnecting'); + return snapshot.candidate.client; +} + +function requireManager( + manager: () => RuntimeHostDesktopManager | undefined, +): RuntimeHostDesktopManager { + const current = manager(); + if (!current) throw new Error('Runtime Host manager is unavailable'); + return current; +} + +function hostName(): string { + return hostname().trim().slice(0, 128) || 'Remote computer'; +} + +function requireServiceTarget(value: unknown, rootPath: string): LocalServiceTarget { + if ( + !isRecord(value) || + value.schemaVersion !== 1 || + typeof value.serviceId !== 'string' || + !SERVICE_ID_PATTERN.test(value.serviceId) || + typeof value.rootId !== 'string' || + !ROOT_ID_PATTERN.test(value.rootId) || + typeof value.deploymentId !== 'string' || + !DEPLOYMENT_ID_PATTERN.test(value.deploymentId) || + value.rootPath !== rootPath || + typeof value.operatorPath !== 'string' || + !isAbsolute(value.operatorPath) + ) { + throw new Error('Local Runtime Host service receipt is invalid'); + } + return { + schemaVersion: 1, + serviceId: value.serviceId, + rootPath, + rootId: value.rootId, + operatorPath: value.operatorPath, + deploymentId: value.deploymentId, + }; +} + +function requireManaged(lifecycle: LocalServiceLifecycle | undefined): LocalServiceManaged { + if (lifecycle?.state !== 'managed') { + throw new Error('Remote access has not been set up on this computer'); + } + return lifecycle; +} + +function requireManagementTarget( + lifecycle: LocalServiceLifecycle | undefined, +): DesktopRuntimeHostLocalManagementTarget { + if (!lifecycle || !hasManagedServiceTarget(lifecycle)) { + throw new Error('This computer does not have a managed Local Runtime Host'); + } + return lifecycle; +} + +function pendingSetup( + intent: LocalServiceSetupPending | LocalServiceLegacyHandoff, +): LocalServiceSetupPending { + return { + schemaVersion: 1, + state: 'setupPending', + rootPath: intent.rootPath, + rootId: intent.rootId, + coordinationRelays: intent.coordinationRelays, + allowInterruptActiveTasks: intent.allowInterruptActiveTasks, + }; +} + +function managedLifecycle(intent: LocalServiceTarget): LocalServiceManaged { + return { + schemaVersion: 1, + state: 'managed', + serviceId: intent.serviceId, + operatorPath: intent.operatorPath, + rootPath: intent.rootPath, + rootId: intent.rootId, + deploymentId: intent.deploymentId, + }; +} + +function hasManagedServiceTarget(lifecycle: LocalServiceLifecycle): lifecycle is + | LocalServiceManaged + | LocalServicePeerChanging + | LocalServiceUninstalling { + return lifecycle.state !== 'handoff' && lifecycle.state !== 'setupPending'; +} + +async function readLifecycle( + path: string, + rootPath: string, + rootId: string, +): Promise { + let value: unknown; + try { + value = JSON.parse(await readFile(path, 'utf8')) as unknown; + } catch (error) { + if (isNodeError(error, 'ENOENT')) return undefined; + throw error; + } + if ( + !isRecord(value) || + value.schemaVersion !== 1 || + value.rootPath !== rootPath || + value.rootId !== rootId + ) { + throw new Error('Local Runtime Host service lifecycle is invalid'); + } + if (value.state === 'setupPending' || value.state === 'handoff') { + assertExactKeys(value, [ + 'schemaVersion', + 'state', + 'rootPath', + 'rootId', + 'coordinationRelays', + 'allowInterruptActiveTasks', + ]); + if ( + typeof value.allowInterruptActiveTasks !== 'boolean' + ) { + throw new Error('Local Runtime Host setup intent is invalid'); + } + return { + schemaVersion: 1, + state: value.state, + rootPath, + rootId, + coordinationRelays: requireAddresses(value.coordinationRelays), + allowInterruptActiveTasks: value.allowInterruptActiveTasks, + }; + } + const target = requireServiceTarget(value, rootPath); + const targetKeys = [ + 'schemaVersion', + 'state', + 'serviceId', + 'operatorPath', + 'rootPath', + 'rootId', + 'deploymentId', + ]; + assertExactKeys( + value, + value.state === 'managed' + ? targetKeys + : value.state === 'peerChanging' + ? [ + ...targetKeys, + 'peerEnabled', + 'coordinationRelays', + 'allowInterruptActiveTasks', + ] + : [ + ...targetKeys, + 'allowInterruptActiveTasks', + ], + ); + if ( + value.state !== 'managed' && + value.state !== 'peerChanging' && + value.state !== 'uninstalling' && + value.state !== 'cleanupPending' + ) { + throw new Error('Local Runtime Host service lifecycle is invalid'); + } + if (value.state === 'managed') return { ...target, state: 'managed' }; + if (typeof value.allowInterruptActiveTasks !== 'boolean') { + throw new Error('Local Runtime Host service intent is invalid'); + } + if (value.state === 'peerChanging') { + if (typeof value.peerEnabled !== 'boolean') { + throw new Error('Local Runtime Host peer intent is invalid'); + } + return { + ...target, + state: 'peerChanging', + peerEnabled: value.peerEnabled, + coordinationRelays: requireAddresses(value.coordinationRelays), + allowInterruptActiveTasks: value.allowInterruptActiveTasks, + }; + } + return { + ...target, + state: value.state, + allowInterruptActiveTasks: value.allowInterruptActiveTasks, + }; +} + +function assertExactKeys(value: Record, keys: readonly string[]): void { + if ( + Object.keys(value).some((key) => !keys.includes(key)) || + Object.keys(value).length !== keys.length + ) { + throw new Error('Local Runtime Host service lifecycle is invalid'); + } +} + +async function writeDocument(path: string, value: object): Promise { + const temporaryPath = join(dirname(path), `.runtime-host-local-service-${randomUUID()}.tmp`); + const handle = await open(temporaryPath, 'wx', 0o600); + try { + await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await rename(temporaryPath, path); + await syncDirectory(dirname(path)); + } finally { + await rm(temporaryPath, { force: true }); + } +} + +async function syncDirectory(path: string): Promise { + if (process.platform === 'win32') return; + const handle = await open(path, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function removeDocument(path: string): Promise { + await rm(path, { force: true }); + await syncDirectory(dirname(path)); +} + +async function uninstallExactService( + operator: DesktopRuntimeHostLocalOperator, + receipt: LocalServiceTarget, +): Promise { + const response = await operator.runService({ + operatorPath: receipt.operatorPath, + action: 'uninstall', + target: receipt, + }); + if ( + response.kind === 'error' || + response.action !== 'uninstall' || + response.service.state !== 'not_installed' + ) { + throw new Error( + response.kind === 'error' + ? response.error.message + : 'Local Runtime Host service was not cleanly uninstalled', + ); + } +} + +function requireAddresses(value: unknown): readonly string[] { + if (!Array.isArray(value) || value.length > ADDRESS_MAX_COUNT) { + throw new Error('Runtime Host peer routes are invalid'); + } + return value.map((entry) => { + if ( + typeof entry !== 'string' || + !entry.startsWith('/') || + Buffer.byteLength(entry, 'utf8') > ADDRESS_MAX_BYTES || + /[\s\u0000-\u001f\u007f]/u.test(entry) + ) { + throw new Error('Runtime Host peer route is invalid'); + } + return entry; + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNodeError(error: unknown, code: string): boolean { + return error instanceof Error && 'code' in error && error.code === code; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} diff --git a/apps/desktop/src/main/runtime-host-managed-services.ts b/apps/desktop/src/main/runtime-host-managed-services.ts index 33b24de6fa..665c52724b 100644 --- a/apps/desktop/src/main/runtime-host-managed-services.ts +++ b/apps/desktop/src/main/runtime-host-managed-services.ts @@ -21,9 +21,12 @@ import { randomUUID } from "node:crypto"; import { mkdir, open, readFile, rename, rm } from "node:fs/promises"; import { dirname, join } from "node:path"; import { - decodeRemoteRuntimeHostProfile, - sameRemoteRuntimeHostProfileTarget, + decodePersistedRuntimeHostProfile, + sameResolvedRuntimeHostProfileTarget, + type EnvironmentRuntimeHostProfile, + type PersistedRuntimeHostProfile, type RemoteRuntimeHostProfile, + type RuntimeHostRemoteTransport, } from "@maka/runtime-host/client"; import { requireHostRootId } from "@maka/runtime-host/protocol"; import { withFileUpdateLock } from "@maka/storage/file-update-lock"; @@ -34,18 +37,78 @@ const DOCUMENT_MAX_BYTES = 256 * 1024; const BINDING_COUNT_MAX = 32; const PATH_MAX_BYTES = 4 * 1024; -export interface DesktopRuntimeHostManagedService { +export interface DesktopRuntimeHostDeploymentBinding { readonly id: string; readonly rootPath: string; + readonly deploymentId?: string; +} + +type ManagedSshRuntimeHostProfile = RemoteRuntimeHostProfile & { + readonly transport: Extract; +}; + +interface DesktopRuntimeHostSshControlRoute { + readonly kind: "ssh_operator"; readonly operatorPath: string; } -export interface DesktopRuntimeHostManagedServiceBinding { - readonly profile: RemoteRuntimeHostProfile; - readonly service: DesktopRuntimeHostManagedService; - readonly state: "active" | "uninstalling" | "cleanup_pending"; +interface DesktopRuntimeHostManagedServiceTargetBase { + readonly deployment: DesktopRuntimeHostDeploymentBinding; +} + +export interface DesktopRuntimeHostManagedSshServiceTarget + extends DesktopRuntimeHostManagedServiceTargetBase { + readonly control: DesktopRuntimeHostSshControlRoute; +} + +export type DesktopRuntimeHostManagedWslServiceTarget = + DesktopRuntimeHostManagedServiceTargetBase; + +type DesktopRuntimeHostManagedServiceTarget = + | DesktopRuntimeHostManagedSshServiceTarget + | DesktopRuntimeHostManagedWslServiceTarget; + +interface DesktopRuntimeHostManagedServiceBindingBase { + readonly deployment: DesktopRuntimeHostDeploymentBinding; +} + +export type DesktopRuntimeHostManagedSshServiceBinding = + DesktopRuntimeHostManagedServiceBindingBase & { + readonly profile: ManagedSshRuntimeHostProfile; + readonly control: DesktopRuntimeHostSshControlRoute; + readonly state: "active" | "uninstalling" | "cleanup_pending"; + }; + +export type DesktopRuntimeHostManagedServiceBinding = + | DesktopRuntimeHostManagedSshServiceBinding + | (DesktopRuntimeHostManagedServiceBindingBase & { + readonly profile: EnvironmentRuntimeHostProfile; + readonly state: "active"; + }); + +export function isDesktopRuntimeHostManagedSshProfile( + profile: PersistedRuntimeHostProfile, +): profile is ManagedSshRuntimeHostProfile { + return profile.kind === "remote" && profile.transport.kind === "ssh"; +} + +export function isDesktopRuntimeHostManagedSshServiceBinding( + binding: DesktopRuntimeHostManagedServiceBinding, +): binding is DesktopRuntimeHostManagedSshServiceBinding { + return binding.profile.kind === "remote"; } +type DesktopRuntimeHostManagedServiceBindingInput = + | { + readonly profile: ManagedSshRuntimeHostProfile; + readonly deployment: DesktopRuntimeHostDeploymentBinding; + readonly control: DesktopRuntimeHostSshControlRoute; + } + | { + readonly profile: EnvironmentRuntimeHostProfile; + readonly deployment: DesktopRuntimeHostDeploymentBinding; + }; + export interface DesktopRuntimeHostManagedServiceDocument { readonly schemaVersion: typeof SCHEMA_VERSION; readonly bindings: readonly DesktopRuntimeHostManagedServiceBinding[]; @@ -54,25 +117,27 @@ export interface DesktopRuntimeHostManagedServiceDocument { export interface DesktopRuntimeHostManagedServiceStore { read(): Promise; save( - profile: RemoteRuntimeHostProfile, - service: DesktopRuntimeHostManagedService, + profile: ManagedSshRuntimeHostProfile, + target: DesktopRuntimeHostManagedSshServiceTarget, + ): Promise; + save( + profile: EnvironmentRuntimeHostProfile, + target: DesktopRuntimeHostManagedWslServiceTarget, ): Promise; removeIfCurrent( - profile: RemoteRuntimeHostProfile, - service: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedServiceBinding, + ): Promise; + removeForProfileIfCurrent( + profile: PersistedRuntimeHostProfile, ): Promise; - removeForProfileIfCurrent(profile: RemoteRuntimeHostProfile): Promise; markUninstallingIfCurrent( - profile: RemoteRuntimeHostProfile, - service: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedSshServiceBinding, ): Promise; markCleanupPendingIfCurrent( - profile: RemoteRuntimeHostProfile, - service: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedSshServiceBinding, ): Promise; removeCleanupPendingIfCurrent( - profile: RemoteRuntimeHostProfile, - service: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedSshServiceBinding, ): Promise; } @@ -80,16 +145,19 @@ export function createDesktopRuntimeHostManagedServiceStore( clientDataRoot: string, ): DesktopRuntimeHostManagedServiceStore { return new FileDesktopRuntimeHostManagedServiceStore( + join(clientDataRoot, "runtime-host-deployments.json"), join(clientDataRoot, "runtime-host-managed-services.json"), ); } export function findDesktopRuntimeHostManagedServiceBinding( document: DesktopRuntimeHostManagedServiceDocument, - profile: RemoteRuntimeHostProfile, + profile: PersistedRuntimeHostProfile, ): DesktopRuntimeHostManagedServiceBinding | undefined { - const binding = document.bindings.find((candidate) => candidate.profile.id === profile.id); - return binding && sameRemoteRuntimeHostProfileTarget(binding.profile, profile) + const binding = document.bindings.find( + (candidate) => candidate.profile.id === profile.id, + ); + return binding && sameManagedProfileTarget(binding.profile, profile) ? binding : undefined; } @@ -101,120 +169,151 @@ export function sameDesktopRuntimeHostManagedServiceBinding( return ( left.state === right.state && left.profile.id === right.profile.id && - sameRemoteRuntimeHostProfileTarget(left.profile, right.profile) && - sameService(left.service, right.service) + sameManagedProfileTarget(left.profile, right.profile) && + sameBindingTarget(left, right) ); } -class FileDesktopRuntimeHostManagedServiceStore - implements DesktopRuntimeHostManagedServiceStore -{ +class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostManagedServiceStore { readonly #path: string; + readonly #legacyPath: string; - constructor(path: string) { + constructor(path: string, legacyPath: string) { this.#path = path; + this.#legacyPath = legacyPath; } async read(): Promise { + return this.#exclusive(() => this.#readUnlocked()); + } + + async #readUnlocked(): Promise { let contents: string; try { contents = await readFile(this.#path, "utf8"); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return emptyDocument(); - throw error; + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + try { + contents = await readFile(this.#legacyPath, "utf8"); + } catch (legacyError) { + if ((legacyError as NodeJS.ErrnoException).code === "ENOENT") + return emptyDocument(); + throw legacyError; + } + const migrated = decodeLegacyDocument(JSON.parse(contents)); + await writeDocument(this.#path, migrated); + await removeLegacyDocument(this.#legacyPath); + return migrated; } if (Buffer.byteLength(contents, "utf8") > DOCUMENT_MAX_BYTES) { throw new Error("Runtime Host managed service document is too large"); } - return decodeDocument(JSON.parse(contents)); + const document = decodeDocument(JSON.parse(contents)); + await removeLegacyDocument(this.#legacyPath); + return document; } save( - value: RemoteRuntimeHostProfile, - managedService: DesktopRuntimeHostManagedService, + profile: ManagedSshRuntimeHostProfile, + target: DesktopRuntimeHostManagedSshServiceTarget, + ): Promise; + save( + profile: EnvironmentRuntimeHostProfile, + target: DesktopRuntimeHostManagedWslServiceTarget, + ): Promise; + save( + profile: ManagedSshRuntimeHostProfile | EnvironmentRuntimeHostProfile, + target: DesktopRuntimeHostManagedServiceTarget, ): Promise { - const profile = decodeRemoteRuntimeHostProfile(value); - if (profile.transport.kind !== "ssh") { - return Promise.reject(new Error("A managed Runtime Host service requires SSH")); - } - const service = decodeService(managedService); + const binding = decodeBinding( + { profile, ...target }, + "Runtime Host managed service binding", + ); + const bindingProfile = binding.profile; return this.#exclusive(async () => { - const current = await this.read(); + const current = await this.#readUnlocked(); const bindings = current.bindings.filter( - (binding) => binding.profile.id !== profile.id, + (binding) => binding.profile.id !== bindingProfile.id, ); + if ( + bindings.some( + (binding) => + binding.profile.rootId === bindingProfile.rootId && + (bindingProfile.kind === "environment" || + binding.profile.kind === "environment"), + ) + ) { + throw new Error( + "A managed Runtime Host deployment is already bound to another profile", + ); + } if (bindings.length >= BINDING_COUNT_MAX) { - throw new Error("Too many managed Runtime Host services are configured"); + throw new Error( + "Too many managed Runtime Host services are configured", + ); } await writeDocument(this.#path, { schemaVersion: SCHEMA_VERSION, - bindings: [...bindings, { profile, service, state: "active" }], + bindings: [ + ...bindings, + { + ...binding, + state: "active", + }, + ], }); }); } markUninstallingIfCurrent( - value: RemoteRuntimeHostProfile, - managedService: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedSshServiceBinding, ): Promise { return this.#setStateIfCurrent( - value, - managedService, + binding, ["active", "uninstalling"], "uninstalling", ); } markCleanupPendingIfCurrent( - value: RemoteRuntimeHostProfile, - managedService: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedSshServiceBinding, ): Promise { return this.#setStateIfCurrent( - value, - managedService, + binding, ["uninstalling", "cleanup_pending"], "cleanup_pending", ); } removeCleanupPendingIfCurrent( - value: RemoteRuntimeHostProfile, - managedService: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedSshServiceBinding, ): Promise { - return this.#remove( - decodeRemoteRuntimeHostProfile(value), - decodeService(managedService), - "cleanup_pending", - ); + return this.#remove(binding, "cleanup_pending"); } - removeIfCurrent( - value: RemoteRuntimeHostProfile, - managedService: DesktopRuntimeHostManagedService, - ): Promise { - const profile = decodeRemoteRuntimeHostProfile(value); - const service = decodeService(managedService); - return this.#remove(profile, service); + removeIfCurrent(binding: DesktopRuntimeHostManagedServiceBinding): Promise { + return this.#remove(binding); } - removeForProfileIfCurrent(value: RemoteRuntimeHostProfile): Promise { - return this.#remove(decodeRemoteRuntimeHostProfile(value)); + removeForProfileIfCurrent(value: PersistedRuntimeHostProfile): Promise { + return this.#remove(undefined, undefined, decodePersistedRuntimeHostProfile(value)); } #remove( - profile: RemoteRuntimeHostProfile, - service?: DesktopRuntimeHostManagedService, + expected?: DesktopRuntimeHostManagedServiceBinding, state?: DesktopRuntimeHostManagedServiceBinding["state"], + profileOverride?: PersistedRuntimeHostProfile, ): Promise { + const profile = expected?.profile ?? profileOverride!; return this.#exclusive(async () => { - const current = await this.read(); + const current = await this.#readUnlocked(); const binding = current.bindings.find( (candidate) => candidate.profile.id === profile.id, ); if ( !binding || - !sameRemoteRuntimeHostProfileTarget(binding.profile, profile) || - (service && !sameService(binding.service, service)) || + !sameManagedProfileTarget(binding.profile, profile) || + (expected && !sameBindingTarget(binding, expected)) || (state && binding.state !== state) ) { return false; @@ -230,22 +329,21 @@ class FileDesktopRuntimeHostManagedServiceStore } #setStateIfCurrent( - value: RemoteRuntimeHostProfile, - managedService: DesktopRuntimeHostManagedService, - allowedStates: readonly DesktopRuntimeHostManagedServiceBinding["state"][], - state: DesktopRuntimeHostManagedServiceBinding["state"], + expected: DesktopRuntimeHostManagedSshServiceBinding, + allowedStates: readonly DesktopRuntimeHostManagedSshServiceBinding["state"][], + state: DesktopRuntimeHostManagedSshServiceBinding["state"], ): Promise { - const profile = decodeRemoteRuntimeHostProfile(value); - const service = decodeService(managedService); + const profile = expected.profile; return this.#exclusive(async () => { - const current = await this.read(); + const current = await this.#readUnlocked(); const binding = current.bindings.find( (candidate) => candidate.profile.id === profile.id, ); if ( !binding || - !sameRemoteRuntimeHostProfileTarget(binding.profile, profile) || - !sameService(binding.service, service) || + !isDesktopRuntimeHostManagedSshServiceBinding(binding) || + !sameManagedProfileTarget(binding.profile, profile) || + !sameBindingTarget(binding, expected) || !allowedStates.includes(binding.state) ) { return false; @@ -267,27 +365,36 @@ class FileDesktopRuntimeHostManagedServiceStore } } -function decodeDocument(value: unknown): DesktopRuntimeHostManagedServiceDocument { - const record = requireExactRecord(value, "Runtime Host managed service document", [ - "schemaVersion", - "bindings", - ]); - if (record.schemaVersion !== SCHEMA_VERSION || !Array.isArray(record.bindings)) { +function decodeDocument( + value: unknown, +): DesktopRuntimeHostManagedServiceDocument { + const record = requireExactRecord( + value, + "Runtime Host managed service document", + ["schemaVersion", "bindings"], + ); + if ( + record.schemaVersion !== SCHEMA_VERSION || + !Array.isArray(record.bindings) + ) { throw new Error("Runtime Host managed service document is invalid"); } if (record.bindings.length > BINDING_COUNT_MAX) { - throw new Error("Runtime Host managed service document has too many bindings"); + throw new Error( + "Runtime Host managed service document has too many bindings", + ); } const bindings = record.bindings.map((candidate) => { - const binding = requireExactRecord(candidate, "Runtime Host managed service binding", [ - "profile", - "service", - "state", - ]); - const profile = decodeRemoteRuntimeHostProfile(binding.profile); - if (profile.transport.kind !== "ssh") { - throw new Error("A managed Runtime Host service requires SSH"); - } + const candidateProfile = decodePersistedRuntimeHostProfile( + (candidate as { readonly profile?: unknown } | null)?.profile, + ); + const binding = requireExactRecord( + candidate, + "Runtime Host managed service binding", + candidateProfile.kind === "environment" + ? ["deployment", "profile", "state"] + : ["control", "deployment", "profile", "state"], + ); if ( binding.state !== "active" && binding.state !== "uninstalling" && @@ -295,26 +402,166 @@ function decodeDocument(value: unknown): DesktopRuntimeHostManagedServiceDocumen ) { throw new Error("Runtime Host managed service state is invalid"); } + const decoded = decodeBinding( + binding, + "Runtime Host managed service binding", + ); + if (!("control" in decoded)) { + if (binding.state !== "active") { + throw new Error("Managed WSL Runtime Host binding state is invalid"); + } + return Object.freeze({ + profile: decoded.profile, + deployment: decoded.deployment, + state: "active" as const, + }); + } return Object.freeze({ - profile, - service: decodeService(binding.service), + profile: decoded.profile, + deployment: decoded.deployment, + control: decoded.control, state: binding.state, }); }); - if (new Set(bindings.map((binding) => binding.profile.id)).size !== bindings.length) { - throw new Error("Runtime Host managed service bindings must have unique profile IDs"); + if ( + new Set(bindings.map((binding) => binding.profile.id)).size !== + bindings.length + ) { + throw new Error( + "Runtime Host managed service bindings must have unique profile IDs", + ); } - return Object.freeze({ schemaVersion: SCHEMA_VERSION, bindings: Object.freeze(bindings) }); + const bindingCountByRootId = new Map(); + for (const binding of bindings) { + bindingCountByRootId.set( + binding.profile.rootId, + (bindingCountByRootId.get(binding.profile.rootId) ?? 0) + 1, + ); + } + if ( + bindings.some( + (binding) => + binding.profile.kind === "environment" && + bindingCountByRootId.get(binding.profile.rootId)! > 1, + ) + ) { + throw new Error( + "Managed WSL Runtime Host State Roots cannot have another deployment binding", + ); + } + return Object.freeze({ + schemaVersion: SCHEMA_VERSION, + bindings: Object.freeze(bindings), + }); +} + +function decodeLegacyDocument( + value: unknown, +): DesktopRuntimeHostManagedServiceDocument { + const record = requireExactRecord( + value, + "Legacy Runtime Host managed service document", + ["schemaVersion", "bindings"], + ); + if (record.schemaVersion !== 1 || !Array.isArray(record.bindings)) { + throw new Error("Legacy Runtime Host managed service document is invalid"); + } + return decodeDocument({ + schemaVersion: SCHEMA_VERSION, + bindings: record.bindings.map((candidate) => { + const binding = requireExactRecord( + candidate, + "Legacy Runtime Host service binding", + ["profile", "service", "state"], + ); + const service = decodeLegacyService(binding.service); + return { + profile: binding.profile, + deployment: { id: service.id, rootPath: service.rootPath }, + control: { kind: "ssh_operator", operatorPath: service.operatorPath }, + state: binding.state, + }; + }), + }); } -function decodeService(value: unknown): DesktopRuntimeHostManagedService { - const record = requireExactRecord(value, "Managed Runtime Host service", [ - "id", - "rootPath", - "operatorPath", - ]); - const rootPath = requirePath(record.rootPath, "Managed Runtime Host State Root"); - const operatorPath = requirePath(record.operatorPath, "Managed Runtime Host operator path"); +function decodeDeployment(value: unknown): DesktopRuntimeHostDeploymentBinding { + const hasDeploymentId = + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.hasOwn(value, "deploymentId"); + const record = requireExactRecord( + value, + "Managed Runtime Host deployment", + hasDeploymentId ? ["deploymentId", "id", "rootPath"] : ["id", "rootPath"], + ); + return Object.freeze({ + id: requireHostRootId(record.id), + rootPath: requirePath(record.rootPath, "Managed Runtime Host State Root"), + ...(record.deploymentId === undefined + ? {} + : { deploymentId: requireDeploymentId(record.deploymentId) }), + }); +} + +function decodeSshControlRoute(value: unknown): DesktopRuntimeHostSshControlRoute { + const record = requireExactRecord( + value, + "Managed Runtime Host control route", + ["kind", "operatorPath"], + ); + if (record.kind !== "ssh_operator") { + throw new Error("Managed Runtime Host control route is invalid"); + } + const operatorPath = requirePath( + record.operatorPath, + "Managed Runtime Host operator path", + ); + if (!operatorPath.startsWith("/")) { + throw new Error("Managed Runtime Host operator path must be absolute"); + } + return Object.freeze({ kind: "ssh_operator", operatorPath }); +} + +function decodeBinding( + value: { + readonly profile?: unknown; + readonly deployment?: unknown; + readonly control?: unknown; + }, + label: string, +): DesktopRuntimeHostManagedServiceBindingInput { + const profile = decodePersistedRuntimeHostProfile(value.profile); + const deployment = decodeDeployment(value.deployment); + if (profile.kind === "environment") { + return Object.freeze({ profile, deployment }); + } + if (!isDesktopRuntimeHostManagedSshProfile(profile)) { + throw new Error(`${label} has no supported control route`); + } + const control = decodeSshControlRoute(value.control); + return Object.freeze({ profile, deployment, control }); +} + +function decodeLegacyService(value: unknown): { + readonly id: string; + readonly rootPath: string; + readonly operatorPath: string; +} { + const record = requireExactRecord( + value, + "Managed Runtime Host service", + ["id", "operatorPath", "rootPath"], + ); + const rootPath = requirePath( + record.rootPath, + "Managed Runtime Host State Root", + ); + const operatorPath = requirePath( + record.operatorPath, + "Managed Runtime Host operator path", + ); if (!operatorPath.startsWith("/")) { throw new Error("Managed Runtime Host operator path must be absolute"); } @@ -325,6 +572,18 @@ function decodeService(value: unknown): DesktopRuntimeHostManagedService { }); } +function requireDeploymentId(value: unknown): string { + if ( + typeof value !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test( + value, + ) + ) { + throw new Error("Managed Runtime Host deployment identity is invalid"); + } + return value; +} + function requirePath(value: unknown, label: string): string { if ( typeof value !== "string" || @@ -348,25 +607,50 @@ function requireExactRecord( const record = value as Record; const actual = Object.keys(record).sort(); const expected = [...keys].sort(); - if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { throw new Error(`${label} has unexpected fields`); } return record; } -function sameService( - left: DesktopRuntimeHostManagedService, - right: DesktopRuntimeHostManagedService, +function sameBindingTarget( + left: DesktopRuntimeHostManagedServiceBinding, + right: DesktopRuntimeHostManagedServiceBinding, +): boolean { + if ( + left.deployment.id !== right.deployment.id || + left.deployment.rootPath !== right.deployment.rootPath || + left.deployment.deploymentId !== right.deployment.deploymentId + ) { + return false; + } + if (!isDesktopRuntimeHostManagedSshServiceBinding(left)) { + return !isDesktopRuntimeHostManagedSshServiceBinding(right); + } + return ( + isDesktopRuntimeHostManagedSshServiceBinding(right) && + left.control.operatorPath === right.control.operatorPath + ); +} + +function sameManagedProfileTarget( + left: PersistedRuntimeHostProfile, + right: PersistedRuntimeHostProfile, ): boolean { return ( left.id === right.id && - left.rootPath === right.rootPath && - left.operatorPath === right.operatorPath + sameResolvedRuntimeHostProfileTarget({ profile: left }, { profile: right }) ); } function emptyDocument(): DesktopRuntimeHostManagedServiceDocument { - return Object.freeze({ schemaVersion: SCHEMA_VERSION, bindings: Object.freeze([]) }); + return Object.freeze({ + schemaVersion: SCHEMA_VERSION, + bindings: Object.freeze([]), + }); } async function writeDocument( @@ -374,7 +658,10 @@ async function writeDocument( document: DesktopRuntimeHostManagedServiceDocument, ): Promise { const validated = decodeDocument(document); - const temporaryPath = join(dirname(path), `.runtime-host-managed-services-${randomUUID()}.tmp`); + const temporaryPath = join( + dirname(path), + `.runtime-host-deployments-${randomUUID()}.tmp`, + ); const handle = await open(temporaryPath, "wx", 0o600); try { try { @@ -389,3 +676,13 @@ async function writeDocument( await rm(temporaryPath, { force: true }); } } + +async function removeLegacyDocument(path: string): Promise { + try { + await rm(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + await syncDirectory(dirname(path)); +} diff --git a/apps/desktop/src/main/runtime-host-management-provider.ts b/apps/desktop/src/main/runtime-host-management-provider.ts new file mode 100644 index 0000000000..67bf941bc5 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-management-provider.ts @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { + RuntimeHostManagedUpdatePolicy, + RuntimeHostServiceManagementFrame, + RuntimeHostServiceUpdatePhase, +} from '@maka/runtime-host/operator'; +import type { + DesktopRuntimeHostManagementAction, +} from '../preload/bridge-contract.js'; + +export type DesktopRuntimeHostManagementTerminalFrame = Exclude< + RuntimeHostServiceManagementFrame, + { readonly kind: 'progress' } +>; + +export interface DesktopRuntimeHostManagementProvider { + readonly profileId: string; + readonly accessManagementAvailable: boolean; + run( + action: Exclude, + allowInterruptActiveTasks: boolean, + ): Promise; + uninstall( + allowInterruptActiveTasks: boolean, + ): Promise<{ readonly kind: 'active_tasks' | 'uninstalled'; readonly retainedStateRoot: string }>; + update( + allowInterruptActiveTasks: boolean, + onProgress: (phase: RuntimeHostServiceUpdatePhase) => void, + ): Promise; + configureProjectDirectories( + roots: readonly { readonly label: string; readonly path: string }[], + expectedConfigFingerprint: string, + allowInterruptActiveTasks: boolean, + ): Promise; + updatePolicy( + policy?: RuntimeHostManagedUpdatePolicy, + ): Promise; + reconcileUpdate( + onProgress: (phase: RuntimeHostServiceUpdatePhase) => void, + ): Promise; + currentHostEpoch(): string | undefined; + awaitUpdatedConnection( + previousHostEpoch: string | undefined, + replacementExpected: boolean, + ): Promise; +} diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index 66e209ffef..f97b0253e7 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -20,26 +20,55 @@ import type { IpcMain } from 'electron'; import { RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY, + RUNTIME_HOST_OPERATOR_PEER_WEBRTC_STUN_CAPABILITY, + decodeRuntimeHostWebRtcStunPolicy, + isProductReleaseVersion, runtimeHostAccessCredentialFingerprint, + type RuntimeHostManagedUpdatePolicy, type RuntimeHostAccessManagementFrame, + type RuntimeHostPeerManagementFrame, + type RuntimeHostPeerStatus, type RuntimeHostServiceManagementFrame, } from '@maka/runtime-host/operator'; import type { DesktopRuntimeHostAccessSnapshot, + DesktopRuntimeHostDirectPeerSnapshot, DesktopRuntimeHostManagementAction, DesktopRuntimeHostManagementResponse, DesktopRuntimeHostManagementProgress, + DesktopRuntimeHostUpdatePolicySnapshot, + DesktopRuntimeHostUpdateReconciliationResponse, } from '../preload/bridge-contract.js'; import type { DesktopRuntimeHostProfileService } from './runtime-host-profile-service.js'; -import { sameDesktopRuntimeHostManagedServiceBinding } from './runtime-host-managed-services.js'; +import { + isDesktopRuntimeHostManagedSshServiceBinding, + sameDesktopRuntimeHostManagedServiceBinding, + type DesktopRuntimeHostManagedServiceBinding, + type DesktopRuntimeHostManagedSshServiceBinding, +} from './runtime-host-managed-services.js'; +import { requireProjectDirectoryRoots } from '../shared/runtime-host-project-directory-policy.js'; import type { DesktopRuntimeHostSshCleanupInput, DesktopRuntimeHostSshAccessInput, DesktopRuntimeHostSshManagementInput, + DesktopRuntimeHostSshPeerManagementInput, DesktopRuntimeHostSshUpdateInput, - DesktopRuntimeHostSetupPackage, + DesktopRuntimeHostSshUpdatePolicyInput, + DesktopRuntimeHostSshUpdateReconciliationInput, + RuntimeHostServiceUpdatePolicyTerminalFrame, + RuntimeHostServiceUpdateReconciliationTerminalFrame, RuntimeHostServiceUpdateTerminalFrame, } from './runtime-host-ssh-terminal.js'; +import type { + DesktopRuntimeHostDevelopmentPeerTarget, + DesktopRuntimeHostSetupPackage, +} from './runtime-host-setup-package.js'; +import type { + DesktopRuntimeHostManagementProvider, + DesktopRuntimeHostManagementTerminalFrame, +} from './runtime-host-management-provider.js'; +import type { DesktopRuntimeHostWslManagementInput } from './runtime-host-wsl-controller.js'; const MANAGEMENT_ACTIONS = new Set([ 'status', @@ -60,23 +89,51 @@ export function createDesktopRuntimeHostManagement(input: { readonly profiles: Pick< DesktopRuntimeHostProfileService, | 'resolveManagedService' + | 'assertPairingComplete' | 'resolveManagedAccess' | 'rotateManagedCredential' | 'markManagedServiceUninstalling' | 'markManagedServiceCleanupPending' | 'clearManagedServiceBinding' + | 'resolveManagedDirectPeerProfile' + | 'upsertManagedDirectPeerProfile' + | 'removeManagedDirectPeerProfile' >; readonly runServiceManagement: ( input: DesktopRuntimeHostSshManagementInput, ) => Promise>; + readonly runWslManagement: ( + input: DesktopRuntimeHostWslManagementInput, + ) => Promise>; readonly runAccessManagement: ( input: DesktopRuntimeHostSshAccessInput, ) => Promise; + readonly runPeerManagement: ( + input: DesktopRuntimeHostSshPeerManagementInput, + ) => Promise; + readonly directPeerClientAvailable: boolean; readonly runUpdate: ( input: DesktopRuntimeHostSshUpdateInput, onProgress: (phase: DesktopRuntimeHostManagementProgress['phase']) => void, ) => Promise; - readonly resolveUpdatePackage: () => DesktopRuntimeHostSetupPackage; + readonly runUpdatePolicy: ( + input: DesktopRuntimeHostSshUpdatePolicyInput, + ) => Promise; + readonly runUpdateReconciliation: ( + input: DesktopRuntimeHostSshUpdateReconciliationInput, + onProgress: (phase: DesktopRuntimeHostManagementProgress['phase']) => void, + ) => Promise; + readonly setupPackageMode: 'published' | 'development'; + readonly resolveSshDevelopmentPeerTarget: (input: { + readonly destination: string; + readonly sshPort?: number; + readonly signal?: AbortSignal; + }) => Promise>; + readonly resolveUpdatePackage: ( + peerTarget: DesktopRuntimeHostDevelopmentPeerTarget, + ) => + | DesktopRuntimeHostSetupPackage + | Promise; readonly currentHostEpoch: (profileId: string) => string | undefined; readonly awaitUpdatedConnection: ( profileId: string, @@ -88,7 +145,11 @@ export function createDesktopRuntimeHostManagement(input: { readonly cleanupManagedDeployment: ( input: DesktopRuntimeHostSshCleanupInput, ) => Promise; + readonly providers?: readonly DesktopRuntimeHostManagementProvider[]; }): { close(): void } { + const providers = new Map( + (input.providers ?? []).map((provider) => [provider.profileId, provider] as const), + ); const requireProfileId = (value: unknown): string => { if (typeof value !== 'string' || value.length === 0 || value.length > 128) { throw new Error('Runtime Host profile ID is invalid'); @@ -101,55 +162,97 @@ export function createDesktopRuntimeHostManagement(input: { return managed; }; + const activeTasks = ( + action: DesktopRuntimeHostManagementAction, + ): DesktopRuntimeHostManagementResponse => ({ + schemaVersion: 1, + kind: 'error', + action, + error: { code: 'active_tasks', message: 'Runtime Host still owns active work' }, + }); + + const projectManagementFrame = ( + frame: Exclude, + accessManagementAvailable: boolean, + ): DesktopRuntimeHostManagementResponse => + (frame.kind === 'result' + ? { ...frame, accessManagementAvailable } + : frame) as DesktopRuntimeHostManagementResponse; + const statusRequests = new Map>(); const runManagedAction = async ( profileId: string, managementAction: DesktopRuntimeHostManagementAction, + allowInterruptActiveTasks = false, ): Promise => { const managed = await resolveManagedService(profileId); - const { profile, service } = managed; - if (profile.transport.kind !== 'ssh') { - throw new Error('This Runtime Host profile is not bound to a managed service'); - } + const { deployment } = managed; if (managed.state !== 'active' && managementAction !== 'uninstall') { throw new Error('Finish uninstalling this Runtime Host service before managing it'); } + if ( + managementAction !== 'status' && + managementAction !== 'logs' && + !deployment.deploymentId && + !(managementAction === 'uninstall' && managed.state !== 'active') + ) { + throw new Error( + 'Re-onboard this Runtime Host before changing it; its legacy binding has no deployment generation', + ); + } + const expectedTarget = { + serviceId: deployment.id, + rootPath: deployment.rootPath, + rootId: managed.profile.rootId, + ...(deployment.deploymentId ? { deploymentId: deployment.deploymentId } : {}), + }; + if (!isDesktopRuntimeHostManagedSshServiceBinding(managed)) { + if (managementAction !== 'status') { + throw new Error('This WSL Runtime Host management action is not available'); + } + const response = await input.runWslManagement({ + distribution: managed.profile.provider.distribution, + operatorPath: managed.profile.operatorPath, + action: managementAction, + expectedTarget, + }); + return projectManagementFrame(response, false); + } const managementInput: DesktopRuntimeHostSshManagementInput = { - destination: profile.transport.destination, - ...(profile.transport.sshPort === undefined ? {} : { sshPort: profile.transport.sshPort }), - operatorPath: service.operatorPath, + destination: managed.profile.transport.destination, + ...(managed.profile.transport.sshPort === undefined + ? {} + : { sshPort: managed.profile.transport.sshPort }), + operatorPath: managed.control.operatorPath, action: managementAction, - expectedTarget: { - serviceId: service.id, - rootPath: service.rootPath, - rootId: profile.rootId, - }, + expectedTarget, ...(managementAction === 'install' ? { - rootPath: service.rootPath, - websocketPort: profile.transport.remotePort, - websocketPath: profile.transport.websocketPath, + rootPath: deployment.rootPath, + websocketPort: managed.profile.transport.remotePort, + websocketPath: managed.profile.transport.websocketPath, } : {}), + ...((managementAction === 'uninstall' || managementAction === 'restart') && + allowInterruptActiveTasks + ? { allowInterruptActiveTasks: true } + : {}), }; if (managementAction !== 'uninstall') { const response = await input.runServiceManagement(managementInput); if (response.action !== managementAction) { throw new Error('Remote Runtime Host returned a different management action'); } - return response.kind === 'result' - ? { - ...response, - action: managementAction, - accessManagementAvailable: - response.operatorCapabilities?.includes( - RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, - ) ?? false, - } - : { ...response, action: managementAction }; + return projectManagementFrame( + response, + response.kind === 'result' && + (response.operatorCapabilities?.includes( + RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + ) ?? false), + ); } - let pending = managed; + let pending: DesktopRuntimeHostManagedSshServiceBinding = managed; if (pending.state !== 'cleanup_pending') { pending = await input.profiles.markManagedServiceUninstalling(pending); const response = await input.runServiceManagement({ @@ -171,22 +274,62 @@ export function createDesktopRuntimeHostManagement(input: { operatorPath: managementInput.operatorPath, expectedTarget: managementInput.expectedTarget, }); + await input.cleanupManagedDeployment({ + destination: managementInput.destination, + ...(managementInput.sshPort === undefined + ? {} + : { sshPort: managementInput.sshPort }), + operatorPath: managementInput.operatorPath, + expectedTarget: managementInput.expectedTarget, + finalize: true, + }); await input.profiles.clearManagedServiceBinding(pending); - return { kind: 'uninstalled', retainedStateRoot: service.rootPath }; + return { kind: 'uninstalled', retainedStateRoot: deployment.rootPath }; }; const run = ( profileIdValue: unknown, action: unknown, + allowInterruptActiveTasksValue: unknown = false, ): Promise => { if (!MANAGEMENT_ACTIONS.has(action as DesktopRuntimeHostManagementAction)) { throw new Error('Runtime Host service management action is invalid'); } const profileId = requireProfileId(profileIdValue); const managementAction = action as DesktopRuntimeHostManagementAction; - if (managementAction !== 'status') return runManagedAction(profileId, managementAction); + if (typeof allowInterruptActiveTasksValue !== 'boolean') { + throw new Error('Runtime Host interruption authority is invalid'); + } + if ( + managementAction !== 'uninstall' && + managementAction !== 'restart' && + allowInterruptActiveTasksValue + ) { + throw new Error('Runtime Host interruption authority is not valid for this action'); + } + const provider = providers.get(profileId); + const execute = async (): Promise => { + if (managementAction !== 'status') { + input.profiles.assertPairingComplete(profileId); + } + if (!provider) { + return runManagedAction(profileId, managementAction, allowInterruptActiveTasksValue); + } + if (managementAction === 'uninstall') { + const response = await provider.uninstall(allowInterruptActiveTasksValue); + return response.kind === 'active_tasks' + ? activeTasks(managementAction) + : { kind: 'uninstalled', retainedStateRoot: response.retainedStateRoot }; + } + const frame = requireManagementFrame( + await provider.run(managementAction, allowInterruptActiveTasksValue), + managementAction, + ); + return projectManagementFrame(frame, provider.accessManagementAvailable); + }; + if (managementAction !== 'status') return execute(); const existing = statusRequests.get(profileId); if (existing) return existing; - const request = runManagedAction(profileId, managementAction); + const request = execute(); statusRequests.set(profileId, request); const forget = () => { if (statusRequests.get(profileId) === request) statusRequests.delete(profileId); @@ -204,9 +347,6 @@ export function createDesktopRuntimeHostManagement(input: { if (managed.state !== 'active') { throw new Error('Finish uninstalling this Runtime Host service before managing access'); } - if (managed.profile.transport.kind !== 'ssh') { - throw new Error('This Runtime Host profile does not have an SSH management channel'); - } return { managed, canRotate: managed.enabled, @@ -216,13 +356,268 @@ export function createDesktopRuntimeHostManagement(input: { ...(managed.profile.transport.sshPort === undefined ? {} : { sshPort: managed.profile.transport.sshPort }), - operatorPath: managed.service.operatorPath, - rootPath: managed.service.rootPath, + operatorPath: managed.control.operatorPath, + rootPath: managed.deployment.rootPath, expectedRootId: managed.profile.rootId, }, }; }; + const activeManagedTarget = async (profileId: string) => { + const managed = await resolveManagedService(profileId); + if (managed.state !== 'active') { + throw new Error('This Runtime Host profile is not available for managed service changes'); + } + if (!managed.deployment.deploymentId) { + throw new Error( + 'Re-onboard this Runtime Host before changing it; its legacy binding has no deployment generation', + ); + } + return { + profileId, + managed, + expectedTarget: { + serviceId: managed.deployment.id, + rootPath: managed.deployment.rootPath, + rootId: managed.profile.rootId, + deploymentId: managed.deployment.deploymentId, + }, + }; + }; + + const managedMutationTarget = async (profileIdValue: unknown) => { + const profileId = requireProfileId(profileIdValue); + input.profiles.assertPairingComplete(profileId); + const target = await activeManagedTarget(profileId); + if (!isDesktopRuntimeHostManagedSshServiceBinding(target.managed)) { + throw new Error('This Runtime Host profile is not available for managed service changes'); + } + return { + ...target, + managed: target.managed, + transport: target.managed.profile.transport, + }; + }; + + const reconnectManagedTarget = ( + profileId: string, + managed: Awaited>, + previousHostEpoch: string | undefined, + ): (() => Promise) => async () => { + const current = await input.profiles.resolveManagedService(profileId); + if (!current || !sameDesktopRuntimeHostManagedServiceBinding(current, managed)) { + throw new Error('Runtime Host profile changed while its service was updating'); + } + await input.awaitUpdatedConnection( + profileId, + managed.profile.rootId, + previousHostEpoch, + true, + ); + }; + + const peerSnapshot = async ( + profileId: string, + status: RuntimeHostPeerStatus, + ): Promise => { + const profile = await input.profiles.resolveManagedDirectPeerProfile(profileId); + return { + state: status.state, + ...(status.peerId ? { peerId: status.peerId } : {}), + routeHints: status.routeHints, + coordinationRelays: status.coordinationRelays, + automaticRelayDiscovery: status.automaticRelayDiscovery ?? false, + ...(status.webRtcStunPolicy ? { webRtcStunPolicy: status.webRtcStunPolicy } : {}), + profilePresent: profile.exists, + profileEnabled: profile.enabled, + clientAvailable: input.directPeerClientAvailable, + managementAvailable: true, + }; + }; + + const peerManagementTarget = async (profileIdValue: unknown) => { + const target = await managedMutationTarget(profileIdValue); + const adaptiveCapability = await input.runServiceManagement({ + destination: target.transport.destination, + ...(target.transport.sshPort === undefined + ? {} + : { sshPort: target.transport.sshPort }), + operatorPath: target.managed.control.operatorPath, + action: 'status', + expectedTarget: target.expectedTarget, + capabilityRequest: RUNTIME_HOST_OPERATOR_PEER_WEBRTC_STUN_CAPABILITY, + }); + if (adaptiveCapability.kind === 'error') throw new Error(adaptiveCapability.error.message); + if (adaptiveCapability.action !== 'status') { + throw new Error('Runtime Host returned an unrelated capability result'); + } + const webRtcStunAvailable = adaptiveCapability.operatorCapabilities?.includes( + RUNTIME_HOST_OPERATOR_PEER_WEBRTC_STUN_CAPABILITY, + ) === true; + const legacyCapability = webRtcStunAvailable + ? adaptiveCapability + : await input.runServiceManagement({ + destination: target.transport.destination, + ...(target.transport.sshPort === undefined + ? {} + : { sshPort: target.transport.sshPort }), + operatorPath: target.managed.control.operatorPath, + action: 'status', + expectedTarget: target.expectedTarget, + capabilityRequest: RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY, + }); + if (legacyCapability.kind === 'error') throw new Error(legacyCapability.error.message); + if (legacyCapability.action !== 'status') { + throw new Error('Runtime Host returned an unrelated capability result'); + } + return { + ...target, + available: webRtcStunAvailable || legacyCapability.operatorCapabilities?.includes( + RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY, + ) === true, + webRtcStunAvailable, + }; + }; + + const unavailablePeerSnapshot = async ( + profileId: string, + ): Promise => { + const profile = await input.profiles.resolveManagedDirectPeerProfile(profileId); + return { + state: 'unsupported', + routeHints: [], + coordinationRelays: [], + automaticRelayDiscovery: false, + profilePresent: profile.exists, + profileEnabled: profile.enabled, + clientAvailable: input.directPeerClientAvailable, + managementAvailable: false, + }; + }; + + const getDirectPeer = async ( + profileIdValue: unknown, + ): Promise => { + const { profileId, managed, transport, expectedTarget, available, webRtcStunAvailable } = + await peerManagementTarget(profileIdValue); + if (!available) return unavailablePeerSnapshot(profileId); + const response = await input.runPeerManagement({ + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), + operatorPath: managed.control.operatorPath, + action: 'status', + ...(webRtcStunAvailable ? { webRtcStunStatus: true } : {}), + expectedTarget, + }); + if (response.kind !== 'result') { + throw new Error( + response.kind === 'error' + ? response.error.message + : 'Runtime Host returned an unrelated direct-peer result', + ); + } + return peerSnapshot(profileId, response.status); + }; + + const configureDirectPeer = async ( + profileIdValue: unknown, + enabledValue: unknown, + coordinationRelaysValue: unknown, + automaticRelayDiscoveryValue: unknown, + webRtcStunPolicyValue: unknown, + ): Promise => { + if (typeof enabledValue !== 'boolean') { + throw new Error('Runtime Host direct-peer state is invalid'); + } + const coordinationRelays = requireCoordinationRelays(coordinationRelaysValue); + if (typeof automaticRelayDiscoveryValue !== 'boolean') { + throw new Error('Runtime Host relay discovery state is invalid'); + } + const webRtcStunPolicy = webRtcStunPolicyValue === undefined + ? undefined + : decodeRuntimeHostWebRtcStunPolicy(webRtcStunPolicyValue); + const { + profileId, + managed, + transport, + expectedTarget, + available, + webRtcStunAvailable, + } = + await peerManagementTarget(profileIdValue); + if (!available) { + throw new Error('Update this Runtime Host before managing Direct peer access'); + } + if (webRtcStunPolicy && !webRtcStunAvailable) { + throw new Error('Update this Runtime Host before changing its STUN policy'); + } + const peerProfile = await input.profiles.resolveManagedDirectPeerProfile(profileId); + if (peerProfile.enabled) { + throw new Error('Disable the Direct peer profile before changing its listener'); + } + const response = await input.runPeerManagement({ + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), + operatorPath: managed.control.operatorPath, + action: enabledValue ? 'enable' : 'disable', + ...(enabledValue ? { coordinationRelays } : {}), + ...(enabledValue ? { automaticRelayDiscovery: automaticRelayDiscoveryValue } : {}), + ...(enabledValue && webRtcStunPolicy ? { webRtcStunPolicy } : {}), + ...(webRtcStunAvailable ? { webRtcStunStatus: true } : {}), + expectedTarget, + }); + if (response.kind !== 'result') { + throw new Error( + response.kind === 'error' + ? response.error.message + : 'Runtime Host returned an unrelated direct-peer result', + ); + } + const status = response.status; + if (enabledValue) { + try { + if ( + status.state !== 'enabled' || + !status.peerId || + status.routeHints.length === 0 && status.coordinationRelays.length === 0 + ) { + throw new Error('Runtime Host did not return a usable direct-peer descriptor'); + } + await input.profiles.upsertManagedDirectPeerProfile(profileId, { + peerId: status.peerId, + routeHints: status.routeHints, + coordinationRelays: status.coordinationRelays, + }); + } catch (failure) { + try { + const rollback = await input.runPeerManagement({ + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), + operatorPath: managed.control.operatorPath, + action: 'disable', + expectedTarget, + }); + if (rollback.kind !== 'result' || rollback.status.state === 'enabled') { + throw new Error( + rollback.kind === 'error' + ? rollback.error.message + : 'Runtime Host did not confirm that Direct peer access was disabled', + ); + } + } catch (rollbackFailure) { + throw new AggregateError( + [asError(failure), asError(rollbackFailure)], + 'Direct peer setup failed and its listener may still be enabled', + ); + } + throw failure; + } + } else { + await input.profiles.removeManagedDirectPeerProfile(profileId); + } + return peerSnapshot(profileId, status); + }; + const update = async ( profileIdValue: unknown, allowInterruptActiveTasksValue: unknown, @@ -231,65 +626,239 @@ export function createDesktopRuntimeHostManagement(input: { throw new Error('Runtime Host update interruption authority is invalid'); } const profileId = requireProfileId(profileIdValue); - const managed = await resolveManagedService(profileId); - if (managed.state !== 'active' || managed.profile.transport.kind !== 'ssh') { - throw new Error('This Runtime Host profile is not available for managed updates'); - } - const previousHostEpoch = input.currentHostEpoch(profileId); - const response = await input.runUpdate( - { - destination: managed.profile.transport.destination, - ...(managed.profile.transport.sshPort === undefined - ? {} - : { sshPort: managed.profile.transport.sshPort }), - setupPackage: input.resolveUpdatePackage(), - expectedTarget: { - serviceId: managed.service.id, - rootPath: managed.service.rootPath, - rootId: managed.profile.rootId, + input.profiles.assertPairingComplete(profileId); + const provider = providers.get(profileId); + let execute: () => Promise; + let reconnect: () => Promise; + if (provider) { + const previousHostEpoch = provider.currentHostEpoch(); + input.sendProgress({ profileId, phase: 'preparing_cli' }); + execute = () => provider.update( + allowInterruptActiveTasksValue, + (phase) => input.sendProgress({ profileId, phase }), + ); + reconnect = () => provider.awaitUpdatedConnection(previousHostEpoch, true); + } else { + const { managed, transport, expectedTarget } = await managedMutationTarget(profileId); + const previousHostEpoch = input.currentHostEpoch(profileId); + input.sendProgress({ profileId, phase: 'preparing_cli' }); + const peerTarget = input.setupPackageMode === 'development' + ? await input.resolveSshDevelopmentPeerTarget({ + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), + }) + : 'none'; + const setupPackage = await input.resolveUpdatePackage(peerTarget); + execute = () => input.runUpdate( + { + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), + setupPackage, + expectedTarget, + ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}), }, - ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}), - }, - (phase) => input.sendProgress({ profileId, phase }), + (phase) => input.sendProgress({ profileId, phase }), + ); + reconnect = reconnectManagedTarget(profileId, managed, previousHostEpoch); + } + const response = requireManagementFrame(await execute(), 'update'); + const reconnectError = + response.kind === 'result' && + response.update.kind !== 'active_tasks' && + response.update.kind !== 'already_current' + ? await reconnectChangedTarget(reconnect) + : undefined; + const projected = projectManagementFrame( + response, + provider?.accessManagementAvailable ?? + (response.kind === 'result' && + (response.operatorCapabilities?.includes( + RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + ) ?? false)), ); + return projected.kind === 'result' && reconnectError + ? { ...projected, reconnectError } + : projected; + }; + + const configureProjectDirectories = async ( + profileIdValue: unknown, + rootsValue: unknown, + expectedConfigFingerprintValue: unknown, + allowInterruptActiveTasksValue: unknown, + ): Promise => { + const roots = requireProjectDirectoryRoots(rootsValue); if ( - response.kind === 'result' && - response.update.kind !== 'active_tasks' + typeof expectedConfigFingerprintValue !== 'string' || + !/^sha256:[a-f0-9]{64}$/u.test(expectedConfigFingerprintValue) ) { - try { - const current = await input.profiles.resolveManagedService(profileId); - if (!current || !sameDesktopRuntimeHostManagedServiceBinding(current, managed)) { - throw new Error('Runtime Host profile changed while its service was updating'); - } - await input.awaitUpdatedConnection( - profileId, - managed.profile.rootId, - previousHostEpoch, - response.update.kind !== 'already_current', + throw new Error('Runtime Host service configuration fingerprint is invalid'); + } + if (typeof allowInterruptActiveTasksValue !== 'boolean') { + throw new Error('Runtime Host configuration interruption authority is invalid'); + } + const profileId = requireProfileId(profileIdValue); + input.profiles.assertPairingComplete(profileId); + const provider = providers.get(profileId); + let execute: () => Promise; + let reconnect: () => Promise; + if (provider) { + const previousHostEpoch = provider.currentHostEpoch(); + execute = () => provider.configureProjectDirectories( + roots, + expectedConfigFingerprintValue, + allowInterruptActiveTasksValue, ); - } catch (error) { - return { - schemaVersion: 1, - kind: 'error', - action: 'update', - error: { - code: 'desktop_reconnect_failed', - message: - 'The Runtime Host update completed, but Desktop could not reconnect: ' + - (error instanceof Error ? error.message : String(error)), - }, - }; + reconnect = () => provider.awaitUpdatedConnection(previousHostEpoch, true); + } else { + const { managed, expectedTarget } = await activeManagedTarget(profileId); + const previousHostEpoch = input.currentHostEpoch(profileId); + if (!isDesktopRuntimeHostManagedSshServiceBinding(managed)) { + execute = () => input.runWslManagement({ + distribution: managed.profile.provider.distribution, + operatorPath: managed.profile.operatorPath, + action: 'configure', + expectedTarget, + projectDirectoryRoots: roots, + expectedConfigFingerprint: expectedConfigFingerprintValue, + ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}), + }); + } else { + const transport = managed.profile.transport; + execute = () => input.runServiceManagement({ + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), + operatorPath: managed.control.operatorPath, + action: 'configure', + expectedTarget, + projectDirectoryRoots: roots, + expectedConfigFingerprint: expectedConfigFingerprintValue, + ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}), + }); } + reconnect = reconnectManagedTarget(profileId, managed, previousHostEpoch); } + const response = requireManagementFrame(await execute(), 'configure'); + const reconnectError = + response.kind === 'result' && response.configuration.kind === 'configured' + ? await reconnectChangedTarget(reconnect) + : undefined; + const projected = projectManagementFrame( + response, + provider?.accessManagementAvailable ?? + (response.kind === 'result' && + (response.operatorCapabilities?.includes( + RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + ) ?? false)), + ); + return projected.kind === 'result' && reconnectError + ? { ...projected, reconnectError } + : projected; + }; + + const reconnectChangedTarget = async ( + reconnect: () => Promise, + ): Promise<{ readonly code: string; readonly message: string } | undefined> => { + try { + await reconnect(); + return undefined; + } catch (error) { + return { + code: 'desktop_reconnect_failed', + message: + 'The Runtime Host change was applied, but Desktop could not reconnect: ' + + (error instanceof Error ? error.message : String(error)), + }; + } + }; + + const updatePolicy = async ( + profileIdValue: unknown, + policyValue?: unknown, + ): Promise => { + const policy = policyValue === undefined ? undefined : requireUpdatePolicy(policyValue); + const providerProfileId = requireProfileId(profileIdValue); + if (policy !== undefined) { + input.profiles.assertPairingComplete(providerProfileId); + } + const provider = providers.get(providerProfileId); + const execute = provider + ? async (next?: RuntimeHostManagedUpdatePolicy) => + requireManagementFrame(await provider.updatePolicy(next), 'update_policy') + : await (async () => { + const { managed, transport, expectedTarget } = + await managedMutationTarget(profileIdValue); + const common = { + destination: transport.destination, + ...(transport.sshPort === undefined + ? {} + : { sshPort: transport.sshPort }), + operatorPath: managed.control.operatorPath, + expectedTarget, + }; + return async (next?: RuntimeHostManagedUpdatePolicy) => + input.runUpdatePolicy({ + ...common, + ...(next ? { policy: next } : {}), + }); + })(); + if (policy && policy.kind !== 'manual') { + const current = await execute(); + if (current.kind === 'error') throw new Error(current.error.message); + if (current.updateSchedulerState === undefined) { + throw new Error( + 'Update or repair this Runtime Host before enabling automatic updates', + ); + } + } + const response = await execute(policy); + if (response.kind === 'error') throw new Error(response.error.message); + return projectUpdatePolicy(response); + }; + + const reconcileUpdate = async ( + profileIdValue: unknown, + ): Promise => { + const profileId = requireProfileId(profileIdValue); + input.profiles.assertPairingComplete(profileId); + const provider = providers.get(profileId); + let execute: () => Promise; + let reconnect: () => Promise; + if (provider) { + const previousHostEpoch = provider.currentHostEpoch(); + execute = () => provider.reconcileUpdate((phase) => + input.sendProgress({ profileId, phase })); + reconnect = () => provider.awaitUpdatedConnection(previousHostEpoch, true); + } else { + const { managed, transport, expectedTarget } = await managedMutationTarget(profileId); + const previousHostEpoch = input.currentHostEpoch(profileId); + execute = () => input.runUpdateReconciliation( + { + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), + operatorPath: managed.control.operatorPath, + expectedTarget, + }, + (phase) => input.sendProgress({ profileId, phase }), + ); + reconnect = reconnectManagedTarget(profileId, managed, previousHostEpoch); + } + const response = requireManagementFrame(await execute(), 'reconcile_update'); + const reconnectError = + response.kind === 'result' && + (response.reconciliation.kind === 'updated' || + response.reconciliation.kind === 'repaired') + ? await reconnectChangedTarget(reconnect) + : undefined; return response.kind === 'result' ? { - ...response, - accessManagementAvailable: - response.operatorCapabilities?.includes( - RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, - ) ?? false, + kind: 'result', + updatePolicy: projectUpdatePolicy(response), + reconciliation: response.reconciliation, + ...(response.service ? { service: response.service } : {}), + ...(reconnectError ? { reconnectError } : {}), } - : response; + : { kind: 'error', error: response.error }; }; const accessSnapshot = ( @@ -407,37 +976,168 @@ export function createDesktopRuntimeHostManagement(input: { ); }; - const channels = [ - 'runtime-host-management:run', - 'runtime-host-management:update', - 'runtime-host-management:list-credentials', - 'runtime-host-management:rotate-credential', - 'runtime-host-management:revoke-credential', - ] as const; - input.ipcMain.handle(channels[0], (_event, profileId: unknown, action: unknown) => - run(profileId, action)); + const channels = { + run: 'runtime-host-management:run', + update: 'runtime-host-management:update', + configureProjectDirectories: 'runtime-host-management:configure-project-directories', + listCredentials: 'runtime-host-management:list-credentials', + rotateCredential: 'runtime-host-management:rotate-credential', + revokeCredential: 'runtime-host-management:revoke-credential', + getUpdatePolicy: 'runtime-host-management:get-update-policy', + setUpdatePolicy: 'runtime-host-management:set-update-policy', + reconcileUpdate: 'runtime-host-management:reconcile-update', + getDirectPeer: 'runtime-host-management:get-direct-peer', + configureDirectPeer: 'runtime-host-management:configure-direct-peer', + } as const; + input.ipcMain.handle( + channels.run, + ( + _event, + profileId: unknown, + action: unknown, + allowInterruptActiveTasks: unknown, + ) => run(profileId, action, allowInterruptActiveTasks), + ); input.ipcMain.handle( - channels[1], + channels.update, (_event, profileId: unknown, allowInterruptActiveTasks: unknown) => update(profileId, allowInterruptActiveTasks), ); - input.ipcMain.handle(channels[2], (_event, profileId: unknown) => + input.ipcMain.handle( + channels.configureProjectDirectories, + ( + _event, + profileId: unknown, + roots: unknown, + expectedConfigFingerprint: unknown, + allowInterruptActiveTasks: unknown, + ) => + configureProjectDirectories( + profileId, + roots, + expectedConfigFingerprint, + allowInterruptActiveTasks, + ), + ); + input.ipcMain.handle(channels.listCredentials, (_event, profileId: unknown) => listCredentials(profileId)); - input.ipcMain.handle(channels[3], (_event, profileId: unknown) => + input.ipcMain.handle(channels.rotateCredential, (_event, profileId: unknown) => rotateCredential(profileId)); input.ipcMain.handle( - channels[4], + channels.revokeCredential, (_event, profileId: unknown, credentialId: unknown) => revokeCredential(profileId, credentialId), ); + input.ipcMain.handle(channels.getUpdatePolicy, (_event, profileId: unknown) => + updatePolicy(profileId)); + input.ipcMain.handle(channels.setUpdatePolicy, (_event, profileId: unknown, policy: unknown) => + updatePolicy(profileId, policy)); + input.ipcMain.handle(channels.reconcileUpdate, (_event, profileId: unknown) => + reconcileUpdate(profileId)); + input.ipcMain.handle(channels.getDirectPeer, (_event, profileId: unknown) => + getDirectPeer(profileId)); + input.ipcMain.handle( + channels.configureDirectPeer, + ( + _event, + profileId: unknown, + enabled: unknown, + coordinationRelays: unknown, + automaticRelayDiscovery: unknown, + webRtcStunPolicy: unknown, + ) => configureDirectPeer( + profileId, + enabled, + coordinationRelays, + automaticRelayDiscovery, + webRtcStunPolicy, + ), + ); return { close() { - for (const channel of channels) input.ipcMain.removeHandler(channel); + for (const channel of Object.values(channels)) input.ipcMain.removeHandler(channel); }, }; } +function asError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + +function requireCoordinationRelays(value: unknown): readonly string[] { + if ( + !Array.isArray(value) || + value.length > 16 || + value.some( + (relay) => + typeof relay !== 'string' || + relay.length === 0 || + Buffer.byteLength(relay, 'utf8') > 2 * 1024 || + /[\s\u0000-\u001f\u007f]/u.test(relay), + ) || + new Set(value).size !== value.length + ) { + throw new Error('Runtime Host coordination relay list is invalid'); + } + return value; +} + +function requireManagementFrame< + Action extends RuntimeHostServiceManagementFrame['action'], +>( + frame: Exclude, + action: Action, +): Exclude & { + readonly action: Action; +} { + if (frame.action !== action) { + throw new Error('Runtime Host returned an unrelated management result'); + } + return frame as Exclude< + RuntimeHostServiceManagementFrame, + { readonly kind: 'progress' } + > & { readonly action: Action }; +} + +function projectUpdatePolicy( + frame: Extract, +): DesktopRuntimeHostUpdatePolicySnapshot { + if (frame.updateSchedulerState === undefined) { + return { ...frame.updatePolicy, schedulingState: 'unsupported' }; + } + return { ...frame.updatePolicy, schedulingState: frame.updateSchedulerState }; +} + +function requireUpdatePolicy(value: unknown): RuntimeHostManagedUpdatePolicy { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Runtime Host update policy is invalid'); + } + const policy = value as Record; + if (policy.kind === 'manual' && Object.keys(policy).length === 1) { + return { kind: 'manual' }; + } + if ( + policy.kind === 'fixed' && + Object.keys(policy).length === 2 && + typeof policy.version === 'string' && + isProductReleaseVersion(policy.version) + ) { + return { kind: 'fixed', version: policy.version }; + } + if ( + policy.kind === 'channel' && + Object.keys(policy).length === 2 && + (policy.channel === 'latest' || policy.channel === 'next') + ) { + return { kind: 'channel', channel: policy.channel }; + } + throw new Error('Runtime Host update policy is invalid'); +} + function sameCredentialAuthority( current: RuntimeHostAccessCredentialMetadata, replacement: RuntimeHostAccessCredentialMetadata, diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index e812492c62..bebe81db1c 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -35,6 +35,7 @@ import type { ClientCapabilityServiceOffer, } from "@maka/runtime-host/protocol"; import { toJSONSchema, z } from "zod"; +import { withBrowserOriginAdmission } from './browser/browser-origin-admission.js'; import type { DesktopTargetScope } from '../shared/runtime-host-identity.js'; const CAPABILITY_VERSION = "0"; @@ -62,6 +63,12 @@ type DesktopToolContentPart = Extract< export interface DesktopNativeCapabilityProviderInput { readonly browserTools: readonly MakaTool[]; + readonly resolveBrowserUrl: (input: { + readonly sessionId: string; + readonly toolName: string; + readonly arguments: Record; + readonly signal: AbortSignal; + }) => string | Promise; readonly releaseBrowserSession: (sessionId: string) => void | Promise; readonly computerUseTools: ComputerUseToolSet; readonly releaseComputerUseSession: ( @@ -156,6 +163,7 @@ export function createDesktopNativeCapabilityProvider( const invocation = new AbortController(); const task = invokeNativeTool( + input, binding, frame, options, @@ -238,7 +246,7 @@ async function invokeAdditionalService( options: Parameters>[1], ): Promise> { options.signal.throwIfAborted(); - await options.accept(); + await options.accept({ kind: "none" }); options.signal.throwIfAborted(); return service.call(frame.method, frame.input, { signal: options.signal }); } @@ -309,6 +317,7 @@ function capabilityGroups( } async function invokeNativeTool( + input: DesktopNativeCapabilityProviderInput, binding: NativeToolBinding, frame: ClientCapabilityCallFrame, options: Parameters>[1], @@ -331,29 +340,69 @@ async function invokeNativeTool( const parameters = requireZodSchema(binding.tool); const args = await parameters.parseAsync(frame.arguments); signal.throwIfAborted(); - await options.accept(); + const sessionId = + frame.offerId === BROWSER_OFFER_ID || frame.offerId === COMPUTER_USE_OFFER_ID + ? providerOptions.nativeSessionId?.(frame.sessionId) ?? frame.sessionId + : frame.sessionId; + const admissionEvidence = + frame.offerId === BROWSER_OFFER_ID + ? { + kind: "browser_url" as const, + url: await input.resolveBrowserUrl({ + sessionId, + toolName: frame.toolName, + arguments: frame.arguments, + signal, + }), + } + : { kind: "none" as const }; + signal.throwIfAborted(); + await options.accept(admissionEvidence); + signal.throwIfAborted(); + if (admissionEvidence.kind === "browser_url") { + const currentUrl = await input.resolveBrowserUrl({ + sessionId, + toolName: frame.toolName, + arguments: frame.arguments, + signal, + }); + if (browserOrigin(currentUrl) !== browserOrigin(admissionEvidence.url)) { + throw new Error("Browser origin changed while admission was pending"); + } + } signal.throwIfAborted(); usedSessionIds.add(frame.sessionId); providerOptions.onSessionUsed?.(frame.sessionId); if (frame.offerId === COMPUTER_USE_OFFER_ID) { providerOptions.onComputerUseTurnUsed?.(frame.sessionId, frame.turnId); } - const sessionId = - frame.offerId === BROWSER_OFFER_ID || frame.offerId === COMPUTER_USE_OFFER_ID - ? providerOptions.nativeSessionId?.(frame.sessionId) ?? frame.sessionId - : frame.sessionId; - const output = await binding.tool.impl(args, { - sessionId, - turnId: frame.turnId, - cwd, - toolCallId: frame.toolCallId, - abortSignal: signal, - emitOutput() {}, - ...(options.progress ? { emitProgress: options.progress } : {}), - }); + const execute = () => + binding.tool.impl(args, { + sessionId, + turnId: frame.turnId, + cwd, + toolCallId: frame.toolCallId, + abortSignal: signal, + emitOutput() {}, + ...(options.progress ? { emitProgress: options.progress } : {}), + }); + const output = await (admissionEvidence.kind === "browser_url" + ? withBrowserOriginAdmission( + { sessionId, url: admissionEvidence.url }, + execute, + ) + : execute()); return projectToolResult(binding.tool, frame.toolCallId, args, output); } +function browserOrigin(value: string): string { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Browser admission requires an HTTP origin"); + } + return url.origin; +} + function abortInvocations( activeInvocations: ReadonlyMap< AbortController, diff --git a/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts b/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts index 4a2f7d755b..6fd139b3b3 100644 --- a/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts @@ -18,19 +18,22 @@ */ import { randomUUID } from 'node:crypto'; +import { + decodeRuntimePolicyEntityId, + type ConnectionCatalogEntry, +} from '@maka/core/runtime-policy'; import { isOAuthEnrollmentProviderEnabled } from '@maka/runtime/oauth-provider-contracts'; import { OAUTH_LOGIN_PROVIDERS, + type OAuthConnectionIdentity, type OAuthLoginProjection, type OAuthLoginProvider, } from '@maka/runtime-host/protocol'; -import { INTERACTIVE_OAUTH_CONNECTION_SLUGS } from './oauth-connection-identities.js'; import { - disableRuntimeHostAccountConnection, - ensureRuntimeHostAccountConnection, - findRuntimeHostAccountConnection, + disableRuntimeHostAccountConnectionById, + findRuntimeHostAccountConnectionById, runtimeHostAccountCredential, - synchronizeRuntimeHostAccountConnection, + synchronizeRuntimeHostAccountConnectionById, type RuntimeHostAccountConnectionClient, } from './runtime-host-account-connection.js'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; @@ -40,6 +43,7 @@ import { } from './ipc-reconnect-policy.js'; import type { OAuthExternalPresentation, + OAuthPresentationExpectation, RuntimeHostOAuthPresentation, } from './runtime-host-oauth-presentation.js'; @@ -77,6 +81,7 @@ export interface RuntimeHostOAuthIpcDeps { interface ActiveOAuthAttempt { readonly provider: OAuthLoginProvider; + readonly connection: OAuthConnectionIdentity; } /** Adapts the existing Desktop OAuth UI to the Host's provider-neutral OAuth operations. */ @@ -89,28 +94,51 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void if (provider !== 'xai-oauth') { deps.ipcMain.handle(channel('is-experimental-enabled'), () => providerEnabled(provider)); } - deps.ipcMain.handle(channel('get-auth-url'), async () => { + deps.ipcMain.handle(channel('get-auth-url'), async (_event, rawTarget: unknown) => { if (!providerEnabled(provider)) return providerDisabled(); - // Drop any Desktop-tracked attempt for this provider so a re-click does - // not race a stale completeAuthorization waiter against a new start. - await cancelProviderAttempts(deps, activeAttempts, provider); - const connection = await ensureRuntimeHostAccountConnection(deps.client, { - providerType: provider, - slug: INTERACTIVE_OAUTH_CONNECTION_SLUGS[provider], - }); + const selection = decodeOAuthLoginSelection(rawTarget); + if (selection.kind === 'invalid') return invalidConnectionIdentity(); + const connectionId = selection.kind === 'exact' ? selection.connectionId : undefined; + if (connectionId) { + const existing = findRuntimeHostAccountConnectionById( + await deps.client.loadConnectionCatalog(), + connectionId, + ); + if (existing?.providerType !== provider) { + return actionFailure('OAuth account does not match this provider'); + } + } const attemptId = randomUUID(); - const expectation = deps.presentation.expect(attemptId); + let expectation: OAuthPresentationExpectation | undefined; + let startedOnHost = false; try { - const started = await deps.client.startOAuthLogin(attemptId, connection.connectionId); + expectation = deps.presentation.expect(attemptId); + const started = await deps.client.startOAuthLogin( + attemptId, + connectionId + ? { kind: 'existing', connectionId } + : { kind: 'create', providerType: provider }, + ); + startedOnHost = true; + if (started.connection.providerType !== provider) { + throw new Error('OAuth Connection does not match this provider'); + } if (isTerminal(started)) throw new Error(describeTerminal(started)); const presented = await waitForPresentation(deps.client, attemptId, expectation.presented); activeAttempts.set(attemptId, { provider, + connection: started.connection, }); - return { authRequestId: attemptId, stateHint: presented.stateHint }; + return { + authRequestId: attemptId, + stateHint: presented.stateHint, + connection: started.connection, + }; } catch (error) { - expectation.cancel(error); - await deps.client.cancelOAuthLogin(attemptId).catch(() => undefined); + expectation?.cancel(error); + if (startedOnHost) { + await deps.client.cancelOAuthLogin(attemptId).catch(() => undefined); + } // Prefer the host's message when present so "already in progress" is not // flattened into a generic 鉴权失败 for the toast classifier. const detail = @@ -131,7 +159,8 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void if (typeof attemptId !== 'string') { return actionFailure('OAuth authorization is not active', 'authorization_pending'); } - if (!providerAttempt(activeAttempts, attemptId, provider)) { + const activeAttempt = providerAttempt(activeAttempts, attemptId, provider); + if (!activeAttempt) { return actionFailure('OAuth authorization is not active', 'authorization_pending'); } try { @@ -140,14 +169,18 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void if (terminal.phase !== 'authenticated') { return actionFailure(describeTerminal(terminal), terminalFailureReason(terminal)); } + if (!sameOAuthConnectionIdentity(activeAttempt.connection, terminal.connection)) { + return actionFailure('OAuth authorization changed Connection identity'); + } // Authentication is authoritative once the Host commits the credential. // Catalog discovery is useful follow-up work, but a transient discovery // failure must not turn a committed login into a false UI failure. - await synchronizeRuntimeHostAccountConnection(deps.client, provider).catch( - () => undefined, - ); + await synchronizeRuntimeHostAccountConnectionById( + deps.client, + terminal.connection.connectionId, + ).catch(() => undefined); deps.emitConnectionListChanged(); - return { ok: true as const }; + return { ok: true as const, connection: terminal.connection }; } catch { activeAttempts.delete(attemptId); return actionFailure('Unable to complete OAuth authorization'); @@ -162,44 +195,71 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void } return { ok: true as const }; }); - handleReconnectableRead(deps.ipcMain, channel('get-account-state'), async () => { - const connection = findRuntimeHostAccountConnection( + handleReconnectableRead(deps.ipcMain, channel('get-account-state'), async (_event, rawConnectionId: unknown) => { + const connectionId = decodeExactOAuthConnectionId(rawConnectionId); + if (!connectionId) return invalidConnectionIdentity(); + const candidates = oauthAccountCandidates( await deps.client.loadConnectionCatalog(), provider, + connectionId, ); - if (!connection) return accountState(provider, 'not_logged_in'); - const credential = await deps.client.queryCredential( - runtimeHostAccountCredential(connection), - ); - if (credential?.configured) { - return accountState(provider, 'authenticated'); + if (candidates.length === 0) { + return actionFailure('OAuth account does not match this provider'); } const authorizing = [...activeAttempts.values()].some( - (attempt) => attempt.provider === provider, + (attempt) => + attempt.provider === provider && + attempt.connection.connectionId === connectionId, ); + if ((await configuredOAuthAccountConnections(deps.client, candidates)).length > 0) { + return accountState(provider, 'authenticated'); + } return accountState(provider, authorizing ? 'authorizing' : 'not_logged_in'); }); - deps.ipcMain.handle(channel('refresh-tokens'), async () => { - const connection = findRuntimeHostAccountConnection( + deps.ipcMain.handle(channel('refresh-tokens'), async (_event, rawConnectionId: unknown) => { + const connectionId = decodeExactOAuthConnectionId(rawConnectionId); + if (!connectionId) return invalidConnectionIdentity('refresh_failed'); + const candidates = oauthAccountCandidates( await deps.client.loadConnectionCatalog(), provider, + connectionId, ); - if (!connection) return actionFailure('OAuth account is not connected', 'refresh_failed'); - const credential = await deps.client.queryCredential( - runtimeHostAccountCredential(connection), + if (candidates.length === 0) { + return actionFailure('OAuth account does not match this provider', 'refresh_failed'); + } + const connections = await configuredOAuthAccountConnections( + deps.client, + candidates, ); - if (!credential?.configured) { + if (connections.length === 0) { return actionFailure('OAuth account is not connected', 'refresh_failed'); } + const connection = connections[0]!; const refreshed = await deps.client.fetchConnectionModels(connection.connectionId); return refreshed.kind === 'committed' ? { ok: true as const } : actionFailure('Unable to refresh OAuth account', 'refresh_failed'); }); - deps.ipcMain.handle(channel('logout'), async () => { - await cancelProviderAttempts(deps, activeAttempts, provider); + deps.ipcMain.handle(channel('logout'), async (_event, rawConnectionId: unknown) => { + const connectionId = decodeExactOAuthConnectionId(rawConnectionId); + if (!connectionId) return invalidConnectionIdentity(); try { - await disableRuntimeHostAccountConnection(deps.client, provider); + const candidates = oauthAccountCandidates( + await deps.client.loadConnectionCatalog(), + provider, + connectionId, + ); + if (candidates.length === 0) { + return actionFailure('OAuth account does not match this provider'); + } + const connection = candidates[0]!; + await cancelProviderAttempts( + deps, + activeAttempts, + provider, + connection.connectionId, + ); + await disableRuntimeHostAccountConnectionById(deps.client, connection.connectionId); } catch { return actionFailure('Unable to remove OAuth account'); } @@ -237,9 +297,14 @@ async function cancelProviderAttempts( deps: RuntimeHostOAuthIpcDeps, activeAttempts: Map, provider: OAuthLoginProvider, + connectionId: string | undefined, ): Promise { const attemptIds = [...activeAttempts] - .filter(([, attempt]) => attempt.provider === provider) + .filter( + ([, attempt]) => + attempt.provider === provider && + (connectionId === undefined || attempt.connection.connectionId === connectionId), + ) .map(([attemptId]) => attemptId); await Promise.all( attemptIds.map(async (attemptId) => { @@ -296,6 +361,77 @@ function accountState( return { provider, runtimeState }; } +function oauthAccountCandidates( + catalog: Awaited>, + provider: OAuthLoginProvider, + connectionId: string, +): ConnectionCatalogEntry[] { + const connection = findRuntimeHostAccountConnectionById(catalog, connectionId); + return connection?.providerType === provider ? [connection] : []; +} + +type OAuthLoginSelection = + | { readonly kind: 'create' } + | { readonly kind: 'exact'; readonly connectionId: string } + | { readonly kind: 'invalid' }; + +function decodeOAuthLoginSelection(value: unknown): OAuthLoginSelection { + if (!value || typeof value !== 'object' || Array.isArray(value)) return { kind: 'invalid' }; + const candidate = value as { readonly kind?: unknown; readonly connectionId?: unknown }; + const keys = Object.keys(value).sort(); + if (candidate.kind === 'create') { + return keys.length === 1 && keys[0] === 'kind' ? { kind: 'create' } : { kind: 'invalid' }; + } + if (candidate.kind !== 'existing' || typeof candidate.connectionId !== 'string') { + return { kind: 'invalid' }; + } + if (keys.length !== 2 || keys[0] !== 'connectionId' || keys[1] !== 'kind') { + return { kind: 'invalid' }; + } + try { + return { kind: 'exact', connectionId: decodeRuntimePolicyEntityId(candidate.connectionId) }; + } catch { + return { kind: 'invalid' }; + } +} + +function decodeExactOAuthConnectionId(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + try { + return decodeRuntimePolicyEntityId(value); + } catch { + return undefined; + } +} + +function sameOAuthConnectionIdentity( + left: OAuthConnectionIdentity, + right: OAuthConnectionIdentity, +): boolean { + return ( + left.connectionId === right.connectionId && + left.slug === right.slug && + left.providerType === right.providerType + ); +} + +function invalidConnectionIdentity(reason: 'refresh_failed' | 'unknown' = 'unknown') { + return actionFailure('Invalid OAuth Connection identity', reason); +} + +async function configuredOAuthAccountConnections( + client: OAuthClient, + candidates: readonly ConnectionCatalogEntry[], +): Promise { + const configured = await Promise.all( + candidates.map(async (connection) => ({ + connection, + status: await client.queryCredential(runtimeHostAccountCredential(connection)), + })), + ); + return configured.filter(({ status }) => status?.configured).map(({ connection }) => connection); +} + function providerDisabled() { return actionFailure('OAuth enrollment is disabled for this provider', 'experimental_disabled'); } diff --git a/apps/desktop/src/main/runtime-host-oauth-presentation.ts b/apps/desktop/src/main/runtime-host-oauth-presentation.ts index 8f9743b9b6..779e8e3a0e 100644 --- a/apps/desktop/src/main/runtime-host-oauth-presentation.ts +++ b/apps/desktop/src/main/runtime-host-oauth-presentation.ts @@ -37,7 +37,7 @@ export class RuntimeHostOAuthPresentation implements OAuthPresentationBackend { constructor(private readonly openSystemBrowser: (url: string) => Promise) {} expect(attemptId: string): OAuthPresentationExpectation { - this.#pending?.reject(new Error('Another OAuth presentation replaced this attempt')); + if (this.#pending) throw new Error('Another OAuth login is already in progress'); let resolvePresented!: (presentation: OAuthExternalPresentation) => void; let rejectPresented!: (reason?: unknown) => void; let presentedSettled = false; @@ -45,9 +45,8 @@ export class RuntimeHostOAuthPresentation implements OAuthPresentationBackend { resolvePresented = accept; rejectPresented = decline; }); - // If a later expect() supersedes this one before waitForPresentation - // attaches, Node would log UnhandledPromiseRejection. Keep a no-op - // handler; real waiters still observe the same rejection. + // The timeout can fire before waitForPresentation attaches. Keep a no-op + // handler; the real waiter still observes the same rejection. void presented.catch(() => undefined); const timer = setTimeout(() => { if (this.#pending?.attemptId !== attemptId) return; diff --git a/apps/desktop/src/main/runtime-host-onboarding.ts b/apps/desktop/src/main/runtime-host-onboarding.ts index c443c18ef4..68d59f5adb 100644 --- a/apps/desktop/src/main/runtime-host-onboarding.ts +++ b/apps/desktop/src/main/runtime-host-onboarding.ts @@ -28,10 +28,13 @@ import type { DesktopRuntimeHostOnboardingSnapshot, } from '../preload/bridge-contract.js'; import type { DesktopRuntimeHostProfileService } from './runtime-host-profile-service.js'; +import type { DesktopRuntimeHostSshSetupInput } from './runtime-host-ssh-terminal.js'; +import type { DesktopRuntimeHostWslSetupInput } from './runtime-host-wsl-controller.js'; import type { + DesktopRuntimeHostDevelopmentPeerTarget, DesktopRuntimeHostSetupPackage, - DesktopRuntimeHostSshSetupInput, -} from './runtime-host-ssh-terminal.js'; +} from './runtime-host-setup-package.js'; +import { requireProjectDirectoryRoots } from '../shared/runtime-host-project-directory-policy.js'; type OnboardingState = DesktopRuntimeHostOnboardingSnapshot extends infer Snapshot ? Snapshot extends DesktopRuntimeHostOnboardingSnapshot @@ -42,7 +45,10 @@ type OnboardingState = DesktopRuntimeHostOnboardingSnapshot extends infer Snapsh export function createDesktopRuntimeHostOnboarding(input: { readonly ipcMain: Pick; readonly clientInstanceId: string; - readonly profiles: Pick; + readonly profiles: Pick< + DesktopRuntimeHostProfileService, + 'addManagedEnvironmentAndEnable' | 'addAndEnableVerified' + >; readonly runSetup: ( input: DesktopRuntimeHostSshSetupInput, onProgress: (frame: { readonly phase: RuntimeHostSetupPhase }) => void, @@ -51,12 +57,34 @@ export function createDesktopRuntimeHostOnboarding(input: { readonly rootId: string; readonly rootPath: string; readonly serviceId: string; + readonly deploymentId: string; readonly operatorPath: string; readonly endpoint: string; readonly credential: string; }>; + readonly runWslSetup: ( + input: DesktopRuntimeHostWslSetupInput, + onProgress: (frame: { readonly phase: RuntimeHostSetupPhase }) => void, + onComplete: () => void, + ) => Promise<{ + readonly rootId: string; + readonly rootPath: string; + readonly serviceId: string; + readonly deploymentId: string; + readonly operatorPath: string; + }>; + readonly listWslDistributions: () => Promise; readonly send: (snapshot: DesktopRuntimeHostOnboardingSnapshot) => void; - readonly resolveSetupPackage: () => DesktopRuntimeHostSetupPackage; + readonly setupPackageMode: 'published' | 'development'; + readonly resolveSshDevelopmentPeerTarget: (input: { + readonly destination: string; + readonly sshPort?: number; + readonly signal?: AbortSignal; + }) => Promise>; + readonly resolveSetupPackage: ( + peerTarget: DesktopRuntimeHostDevelopmentPeerTarget, + signal?: AbortSignal, + ) => DesktopRuntimeHostSetupPackage | Promise; }): { close(): Promise } { let revision = 0; let snapshot: DesktopRuntimeHostOnboardingSnapshot = { kind: 'idle', revision }; @@ -89,7 +117,7 @@ export function createDesktopRuntimeHostOnboarding(input: { })); } const abort = new AbortController(); - publish({ kind: 'running', phase: 'connecting_ssh' }); + publish({ kind: 'running', phase: 'preparing_cli' }); const task = Promise.resolve().then(() => run(request, abort.signal)).finally(() => { if (active?.task === task) active = undefined; }); @@ -102,6 +130,17 @@ export function createDesktopRuntimeHostOnboarding(input: { signal: AbortSignal, ): Promise => { try { + if (request.kind === 'wsl') { + const setupPackage = await input.resolveSetupPackage('none', signal); + return await runWsl(request, setupPackage, signal); + } + const peerTarget = input.setupPackageMode === 'development' + ? await resolveSshDevelopmentPeerTarget(request, signal) + : 'none'; + const setupPackage = await input.resolveSetupPackage(peerTarget, signal); + const lifecycle = setupPackage.kind === 'npm' ? 'on_demand' : 'supervised'; + signal.throwIfAborted(); + publish({ kind: 'running', phase: 'connecting_ssh' }); let commitStarted = false; const beginCommit = () => { if (commitStarted) return; @@ -116,8 +155,12 @@ export function createDesktopRuntimeHostOnboarding(input: { { destination: request.destination, ...(request.sshPort === undefined ? {} : { sshPort: request.sshPort }), - setupPackage: input.resolveSetupPackage(), + setupPackage, + lifecycle, principalId: `desktop:${input.clientInstanceId}`, + ...(request.projectDirectoryRoots + ? { projectDirectoryRoots: request.projectDirectoryRoots } + : {}), signal, }, (progress) => { @@ -144,15 +187,30 @@ export function createDesktopRuntimeHostOnboarding(input: { kind: 'ssh', destination: request.destination, ...(request.sshPort === undefined ? {} : { sshPort: request.sshPort }), - remotePort: endpoint.port, - websocketPath: endpoint.websocketPath, + ...(lifecycle === 'on_demand' + ? { + activation: { + kind: 'ssh_operator' as const, + operatorPath: complete.operatorPath, + }, + } + : { + remotePort: endpoint.port, + websocketPath: endpoint.websocketPath, + }), }, }, credential: complete.credential, managedService: { - id: complete.serviceId, - rootPath: complete.rootPath, - operatorPath: complete.operatorPath, + deployment: { + id: complete.serviceId, + rootPath: complete.rootPath, + deploymentId: complete.deploymentId, + }, + control: { + kind: 'ssh_operator', + operatorPath: complete.operatorPath, + }, }, }); return publish({ @@ -168,11 +226,77 @@ export function createDesktopRuntimeHostOnboarding(input: { } }; + const resolveSshDevelopmentPeerTarget = async ( + request: Extract, + signal: AbortSignal, + ): Promise> => { + publish({ kind: 'running', phase: 'connecting_ssh' }); + const target = await input.resolveSshDevelopmentPeerTarget({ + destination: request.destination, + ...(request.sshPort === undefined ? {} : { sshPort: request.sshPort }), + signal, + }); + publish({ kind: 'running', phase: 'preparing_cli' }); + return target; + }; + + const runWsl = async ( + request: Extract, + setupPackage: DesktopRuntimeHostSetupPackage, + signal: AbortSignal, + ): Promise => { + publish({ kind: 'running', phase: 'connecting_wsl' }); + let commitStarted = false; + const beginCommit = () => { + if (commitStarted) return; + commitStarted = true; + if (active) active.cancellable = false; + publish({ kind: 'running', phase: 'connecting_host' }); + }; + const complete = await input.runWslSetup( + { + distribution: request.distribution, + setupPackage, + principalId: `desktop:${input.clientInstanceId}`, + ...(request.projectDirectoryRoots + ? { projectDirectoryRoots: request.projectDirectoryRoots } + : {}), + signal, + }, + (progress) => { + if (!commitStarted) publish({ kind: 'running', phase: progress.phase }); + }, + beginCommit, + ); + beginCommit(); + const profileId = `environment-${randomUUID()}`; + const profile = { + id: profileId, + name: request.name?.trim() || request.distribution, + kind: 'environment' as const, + provider: { kind: 'wsl' as const, distribution: request.distribution }, + rootId: complete.rootId, + operatorPath: complete.operatorPath, + }; + const connected = await input.profiles.addManagedEnvironmentAndEnable({ + profile, + managedService: { + deployment: { + id: complete.serviceId, + rootPath: complete.rootPath, + deploymentId: complete.deploymentId, + }, + }, + }); + return publish({ kind: 'complete', profileId: connected.profileId }); + }; + const channels = [ 'runtime-host-onboarding:getSnapshot', 'runtime-host-onboarding:start', 'runtime-host-onboarding:cancel', 'runtime-host-onboarding:reset', + 'runtime-host-onboarding:listWslDistributions', ] as const; input.ipcMain.handle(channels[0], () => snapshot); input.ipcMain.handle(channels[1], (_event, value: unknown) => start(value)); @@ -189,6 +313,7 @@ export function createDesktopRuntimeHostOnboarding(input: { await active?.task; publish({ kind: 'idle' }); }); + input.ipcMain.handle(channels[4], () => input.listWslDistributions()); return { close: async () => { @@ -205,22 +330,42 @@ function requireOnboardingInput(value: unknown): DesktopRuntimeHostOnboardingInp if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Remote Runtime Host setup input is invalid'); } - const input = value as Partial; + const input = value as Record; + if (input.name !== undefined && + (typeof input.name !== 'string' || input.name.trim().length > 128)) { + throw new Error('Remote Runtime Host setup input is invalid'); + } + const roots = input.projectDirectoryRoots === undefined + ? undefined + : requireProjectDirectoryRoots(input.projectDirectoryRoots); + if (input.kind === 'wsl') { + if ( + typeof input.distribution !== 'string' || + input.distribution.trim() !== input.distribution || + input.distribution.length === 0 || + input.distribution.length > 128 + ) throw new Error('WSL Runtime Host setup input is invalid'); + return { + kind: 'wsl', + distribution: input.distribution, + ...(typeof input.name === 'string' && input.name.trim() ? { name: input.name.trim() } : {}), + ...(roots ? { projectDirectoryRoots: roots } : {}), + }; + } if ( + input.kind !== 'ssh' || typeof input.destination !== 'string' || input.destination.trim() !== input.destination || input.destination.length === 0 || input.destination.length > 512 || - (input.name !== undefined && - (typeof input.name !== 'string' || input.name.trim().length > 128)) || (input.sshPort !== undefined && - (!Number.isInteger(input.sshPort) || input.sshPort < 1 || input.sshPort > 65_535)) - ) { - throw new Error('Remote Runtime Host setup input is invalid'); - } + (!Number.isInteger(input.sshPort) || Number(input.sshPort) < 1 || Number(input.sshPort) > 65_535)) + ) throw new Error('Remote Runtime Host setup input is invalid'); return { + kind: 'ssh', destination: input.destination, - ...(input.name?.trim() ? { name: input.name.trim() } : {}), - ...(input.sshPort === undefined ? {} : { sshPort: input.sshPort }), + ...(typeof input.name === 'string' && input.name.trim() ? { name: input.name.trim() } : {}), + ...(input.sshPort === undefined ? {} : { sshPort: Number(input.sshPort) }), + ...(roots ? { projectDirectoryRoots: roots } : {}), }; } diff --git a/apps/desktop/src/main/runtime-host-pairing-journal.ts b/apps/desktop/src/main/runtime-host-pairing-journal.ts index 42bd64ace4..38169122e3 100644 --- a/apps/desktop/src/main/runtime-host-pairing-journal.ts +++ b/apps/desktop/src/main/runtime-host-pairing-journal.ts @@ -99,16 +99,31 @@ export async function writeDesktopRuntimeHostPairingIntents( throw new Error('Runtime Host pairing recovery journal has too many entries'); } if (intents.length === 0) { - await credentials.deleteSecret( - PAIRING_JOURNAL_CREDENTIAL_SLOT, - 'runtime_host_access', - ); + try { + await credentials.deleteSecret( + PAIRING_JOURNAL_CREDENTIAL_SLOT, + 'runtime_host_access', + ); + } catch (deleteError) { + // Some credential backends can update an existing item while deletion is + // temporarily unavailable. Persisting an empty, valid journal is the + // terminal record; physical deletion is only garbage collection. + try { + await credentials.setSecret( + PAIRING_JOURNAL_CREDENTIAL_SLOT, + 'runtime_host_access', + encodePairingJournal([]), + ); + } catch (writeError) { + throw new AggregateError( + [deleteError, writeError], + 'Runtime Host pairing recovery journal could not be cleared', + ); + } + } return; } - const contents = JSON.stringify({ - schemaVersion: PAIRING_JOURNAL_SCHEMA_VERSION, - intents, - }); + const contents = encodePairingJournal(intents); if (Buffer.byteLength(contents) > PAIRING_JOURNAL_MAX_BYTES) { throw new Error('Runtime Host pairing recovery journal exceeds its size limit'); } @@ -119,6 +134,13 @@ export async function writeDesktopRuntimeHostPairingIntents( ); } +function encodePairingJournal(intents: readonly DesktopRuntimeHostPairingIntent[]): string { + return JSON.stringify({ + schemaVersion: PAIRING_JOURNAL_SCHEMA_VERSION, + intents, + }); +} + function decodePairingJournal(value: unknown): readonly DesktopRuntimeHostPairingIntent[] { const journal = requireExactRecord(value, ['schemaVersion', 'intents']); if ( diff --git a/apps/desktop/src/main/runtime-host-peer-client.ts b/apps/desktop/src/main/runtime-host-peer-client.ts new file mode 100644 index 0000000000..30500fc6a3 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-peer-client.ts @@ -0,0 +1,151 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { access, open, readFile, rename, rm } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { + decodeRuntimeHostWebRtcStunPolicy, + resolveRuntimeHostWebRtcStunUrls, + type RuntimeHostWebRtcStunPolicy, +} from '@maka/runtime-host/client'; +import { syncDirectory } from '@maka/storage/stable-storage'; + +const NATIVE_FILE = 'maka_runtime_host_peer.node'; +const CONNECTIVITY_POLICY_FILE = 'runtime-host-peer-connectivity.json'; +const CONNECTIVITY_POLICY_MAX_BYTES = 16 * 1024; + +export async function configureDesktopRuntimeHostPeerClient(input: { + readonly isPackaged: boolean; + readonly enableDevelopmentPeer?: boolean; + readonly appPath: string; + readonly resourcesPath: string; + readonly clientDataRoot: string; + readonly environment?: NodeJS.ProcessEnv; +}): Promise<{ + readonly nativePath: string; + readonly keyPath: string; + readonly automaticRelayDiscovery: true; + readonly webRtcStunUrls: readonly string[]; + readonly webRtcStunPolicy: RuntimeHostWebRtcStunPolicy; +} | undefined> { + const environment = input.environment ?? process.env; + const explicitNativePath = environment.MAKA_RUNTIME_HOST_PEER_NATIVE_PATH?.trim(); + const explicitKeyPath = environment.MAKA_RUNTIME_HOST_PEER_KEY_PATH?.trim(); + const webRtcStunPolicy = await readDesktopRuntimeHostWebRtcStunPolicy(input.clientDataRoot) + .catch(() => ({ kind: 'disabled' }) as const); + const webRtcStunUrls = resolveRuntimeHostWebRtcStunUrls(webRtcStunPolicy); + if (explicitNativePath || explicitKeyPath) { + return explicitNativePath && explicitKeyPath + ? { + nativePath: explicitNativePath, + keyPath: explicitKeyPath, + automaticRelayDiscovery: true, + webRtcStunUrls, + webRtcStunPolicy, + } + : undefined; + } + if (!input.isPackaged && !input.enableDevelopmentPeer) return undefined; + const nativePath = input.isPackaged + ? join(input.resourcesPath, 'runtime-host-peer', NATIVE_FILE) + : join( + input.appPath, + '..', + '..', + 'native', + 'runtime-host-peer', + 'target', + 'release', + NATIVE_FILE, + ); + try { + await access(nativePath); + } catch { + return undefined; + } + environment.MAKA_RUNTIME_HOST_PEER_NATIVE_PATH = nativePath; + const keyPath = join(input.clientDataRoot, 'runtime-host-client.peer.key'); + environment.MAKA_RUNTIME_HOST_PEER_KEY_PATH = keyPath; + return { + nativePath, + keyPath, + automaticRelayDiscovery: true, + webRtcStunUrls, + webRtcStunPolicy, + }; +} + +export async function readDesktopRuntimeHostWebRtcStunPolicy( + clientDataRoot: string, +): Promise { + const path = join(clientDataRoot, CONNECTIVITY_POLICY_FILE); + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'default' }; + throw error; + } + if (Buffer.byteLength(text, 'utf8') > CONNECTIVITY_POLICY_MAX_BYTES) { + throw new RangeError('Desktop peer connectivity policy exceeds its size limit'); + } + const document: unknown = JSON.parse(text); + if ( + document === null || + typeof document !== 'object' || + Array.isArray(document) || + Object.keys(document).length !== 2 || + !('schemaVersion' in document) || + document.schemaVersion !== 1 || + !('webRtcStunPolicy' in document) + ) { + throw new TypeError('Desktop peer connectivity policy is invalid'); + } + return decodeRuntimeHostWebRtcStunPolicy(document.webRtcStunPolicy); +} + +export async function writeDesktopRuntimeHostWebRtcStunPolicy( + clientDataRoot: string, + value: unknown, +): Promise { + const webRtcStunPolicy = decodeRuntimeHostWebRtcStunPolicy(value); + const path = join(clientDataRoot, CONNECTIVITY_POLICY_FILE); + const temporaryPath = join( + dirname(path), + `.runtime-host-peer-connectivity-${randomUUID()}.tmp`, + ); + const handle = await open(temporaryPath, 'wx', 0o600); + try { + try { + await handle.writeFile( + `${JSON.stringify({ schemaVersion: 1, webRtcStunPolicy }, null, 2)}\n`, + 'utf8', + ); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temporaryPath, path); + await syncDirectory(dirname(path)); + } finally { + await rm(temporaryPath, { force: true }); + } + return webRtcStunPolicy; +} diff --git a/apps/desktop/src/main/runtime-host-peer-mesh-management.ts b/apps/desktop/src/main/runtime-host-peer-mesh-management.ts new file mode 100644 index 0000000000..e321230229 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-peer-mesh-management.ts @@ -0,0 +1,661 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { IpcMain } from 'electron'; +import { + abortable, + LOCAL_RUNTIME_HOST_PROFILE, + RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, + type RuntimeHostWebRtcStunPolicy, +} from '@maka/runtime-host/client'; +import { PeerMeshPostCommitError, type PeerMeshNode } from '@maka/runtime-host/peer-mesh'; +import { + decodePeerMeshInvitation, + type PeerMeshInvitationV1, + type PeerMeshInvitationResult, + type PeerMeshQueryResult, +} from '@maka/runtime-host/protocol'; +import { projectPeerMeshQuery } from '@maka/runtime-host/server'; +import type { + DesktopRuntimeHostPeerMeshTarget, +} from '../preload/bridge-contract.js'; +import type { DesktopRuntimeHostProfileService } from './runtime-host-profile-service.js'; +import { isDesktopRuntimeHostManagedSshServiceBinding } from './runtime-host-managed-services.js'; +import type { + DesktopRuntimeHostSshPeerMeshManagementInput, + createDesktopRuntimeHostSshTerminal, +} from './runtime-host-ssh-terminal.js'; +import type { createDesktopRuntimeHostLocalOperator } from './runtime-host-local-operator.js'; +import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; +import type { + DesktopRuntimeHostLocalManagementTarget, + DesktopLocalRuntimeHostRemoteAccess, +} from './runtime-host-local-remote-access.js'; + +type SshTerminal = ReturnType; +type LocalOperator = ReturnType; +type PeerMeshAction = DesktopRuntimeHostSshPeerMeshManagementInput['action']; +type PeerMeshResult = PeerMeshQueryResult | PeerMeshInvitationResult; +type PeerMeshManagementResultFrame = Awaited>; + +interface ManagedPeerMeshCommand { + readonly action: PeerMeshAction; + readonly meshId?: string | null; + readonly peerId?: string; + readonly invitation?: PeerMeshInvitationV1; + readonly displayName?: string | null; + readonly signal?: AbortSignal; +} + +interface ActivePeerMeshOperation { + readonly controller: AbortController; + readonly cancellable: boolean; + readonly abortOnClose: boolean; +} + +type RunManagedPeerMeshCommand = (command: ManagedPeerMeshCommand) => Promise; + +class PeerMeshMutationOutcomeUnknownError extends Error { + constructor(options: ErrorOptions = {}) { + super('Peer Mesh mutation outcome is unknown', options); + this.name = 'PeerMeshMutationOutcomeUnknownError'; + } +} + +export function createDesktopRuntimeHostPeerMeshManagement(input: { + readonly ipcMain: Pick; + readonly localMesh?: () => PeerMeshNode | undefined; + readonly localHost: Pick; + readonly runLocal: LocalOperator['runPeerMesh']; + readonly liveHost: ( + profileId: string, + ) => Pick | undefined; + readonly profiles: Pick; + readonly runRemote: SshTerminal['runPeerMeshManagement']; + readonly readConnectivityPolicy: () => Promise; + readonly writeConnectivityPolicy: (value: unknown) => Promise; +}): { close(): void } { + const activeOperations = new Map(); + const execute = async ( + targetValue: unknown, + actionValue: unknown, + meshIdValue?: unknown, + peerIdValue?: unknown, + invitationValue?: unknown, + displayNameValue?: unknown, + signal?: AbortSignal, + ): Promise => { + const target = requireTarget(targetValue); + const action = requireAction(actionValue); + const meshId = + action === 'transit' && meshIdValue === null + ? null + : actionNeedsMesh(action) + ? requireIdentifier(meshIdValue, 'Mesh ID') + : undefined; + const peerId = action === 'remove' ? requireIdentifier(peerIdValue, 'Peer ID') : undefined; + const invitation = action === 'join' ? requireInvitation(invitationValue) : undefined; + const displayName = + action === 'rename' || action === 'rename-mesh' + ? requireDisplayName(displayNameValue) + : undefined; + if (target.kind === 'desktop') { + if (action === 'reconcile') { + return reconcileDesktopTarget( + input.localMesh?.(), + input.localHost, + input.runLocal, + input.liveHost(LOCAL_RUNTIME_HOST_PROFILE.id), + signal, + ); + } + return executeLocal( + input.localMesh?.(), + action, + meshId, + peerId, + invitation, + displayName, + signal, + ); + } + if (target.kind === 'local_host') { + const live = input.liveHost(LOCAL_RUNTIME_HOST_PROFILE.id); + if (live) { + return executeManagedTarget( + input.localMesh?.(), + (command) => runLivePeerMeshCommand(live, command), + action, + meshId, + peerId, + invitation, + displayName, + signal, + ); + } + return input.localHost.inspectManaged(async (managed) => { + const run: RunManagedPeerMeshCommand = async (command) => { + const { invitation, ...rest } = command; + return runFramedPeerMeshCommand(command.action, () => + input.runLocal({ + operatorPath: managed.operatorPath, + target: managedTarget(managed), + ...rest, + ...(invitation ? { invitation: JSON.stringify(invitation) } : {}), + signal: command.signal, + })); + }; + return executeManagedTarget( + input.localMesh?.(), + run, + action, + meshId, + peerId, + invitation, + displayName, + signal, + ); + }); + } + const live = input.liveHost(target.profileId); + if (live) { + return executeManagedTarget( + input.localMesh?.(), + (command) => runLivePeerMeshCommand(live, command), + action, + meshId, + peerId, + invitation, + displayName, + signal, + ); + } + const managed = await input.profiles.resolveManagedService(target.profileId); + if ( + !managed || + managed.state !== 'active' || + !isDesktopRuntimeHostManagedSshServiceBinding(managed) || + !managed.deployment.deploymentId + ) { + throw new Error('This Runtime Host does not have an active SSH management channel'); + } + const transport = managed.profile.transport; + const run: RunManagedPeerMeshCommand = async (command) => { + const { invitation, ...rest } = command; + return runFramedPeerMeshCommand(command.action, () => + input.runRemote({ + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), + operatorPath: managed.control.operatorPath, + expectedTarget: { + serviceId: managed.deployment.id, + rootPath: managed.deployment.rootPath, + rootId: managed.profile.rootId, + deploymentId: managed.deployment.deploymentId, + }, + ...rest, + ...(invitation ? { invitation: JSON.stringify(invitation) } : {}), + signal: command.signal, + })); + }; + return executeManagedTarget( + input.localMesh?.(), + run, + action, + meshId, + peerId, + invitation, + displayName, + signal, + ); + }; + + const channel = 'runtime-host-peer-mesh:execute'; + input.ipcMain.handle( + channel, + async (_event, target, action, meshId, peerId, invitation, displayName, operationIdValue) => { + const operationId = requireOperationId(operationIdValue); + const parsedTarget = requireTarget(target); + const parsedAction = requireAction(action); + const controller = new AbortController(); + if (activeOperations.has(operationId)) throw new Error('Peer Mesh operation is already active'); + activeOperations.set(operationId, { + controller, + cancellable: canCancelOperation(parsedTarget, parsedAction), + abortOnClose: parsedAction === 'status', + }); + try { + try { + return { + kind: 'completed' as const, + result: await execute( + parsedTarget, + parsedAction, + meshId, + peerId, + invitation, + displayName, + controller.signal, + ), + }; + } catch (error) { + if (mutationOutcomeIsUnknown(error)) return { kind: 'outcome_unknown' as const }; + throw error; + } + } finally { + activeOperations.delete(operationId); + } + }, + ); + const cancelChannel = 'runtime-host-peer-mesh:cancel'; + input.ipcMain.handle(cancelChannel, (_event, operationIdValue) => { + const operationId = requireOperationId(operationIdValue); + const operation = activeOperations.get(operationId); + if (operation?.cancellable) { + operation.controller.abort(new Error('Peer Mesh operation was cancelled')); + } + }); + const getConnectivityPolicyChannel = 'runtime-host-peer-mesh:get-connectivity-policy'; + input.ipcMain.handle(getConnectivityPolicyChannel, () => input.readConnectivityPolicy()); + const setConnectivityPolicyChannel = 'runtime-host-peer-mesh:set-connectivity-policy'; + input.ipcMain.handle(setConnectivityPolicyChannel, (_event, value: unknown) => + input.writeConnectivityPolicy(value)); + return { + close: () => { + for (const { abortOnClose, controller } of activeOperations.values()) { + if (abortOnClose) controller.abort(new Error('Peer Mesh management closed')); + } + activeOperations.clear(); + input.ipcMain.removeHandler(channel); + input.ipcMain.removeHandler(cancelChannel); + input.ipcMain.removeHandler(getConnectivityPolicyChannel); + input.ipcMain.removeHandler(setConnectivityPolicyChannel); + }, + }; +} + +async function executeManagedTarget( + desktopMesh: PeerMeshNode | undefined, + run: RunManagedPeerMeshCommand, + action: PeerMeshAction, + meshId: string | null | undefined, + peerId: string | undefined, + invitation: PeerMeshInvitationV1 | undefined, + displayName: string | null | undefined, + signal?: AbortSignal, +): Promise { + if (action === 'reconcile') return reconcileManagedTarget(desktopMesh, run, signal); + return run({ + action, + ...(meshId !== undefined ? { meshId } : {}), + ...(peerId ? { peerId } : {}), + ...(invitation ? { invitation } : {}), + ...(displayName !== undefined ? { displayName } : {}), + signal, + }); +} + +function runLivePeerMeshCommand( + client: Pick, + command: ManagedPeerMeshCommand, +): Promise { + switch (command.action) { + case 'status': + return abortable( + () => client.request('peer.mesh.query', {}, LIVE_HOST_QUERY_TIMEOUT_MS), + command.signal, + ); + case 'create': + return client.request('peer.mesh.create', {}); + case 'invite': + return client.request('peer.mesh.invite', { + meshId: requiredValue(command.meshId, 'Mesh ID'), + }); + case 'join': + return client.request('peer.mesh.join', { + invitation: requiredValue(command.invitation, 'Peer Mesh invitation'), + }); + case 'remove': + return client.request('peer.mesh.remove', { + meshId: requiredValue(command.meshId, 'Mesh ID'), + peerId: requiredValue(command.peerId, 'Peer ID'), + }); + case 'leave': + return client.request('peer.mesh.leave', { + meshId: requiredValue(command.meshId, 'Mesh ID'), + }); + case 'close': + return client.request('peer.mesh.close', { + meshId: requiredValue(command.meshId, 'Mesh ID'), + }); + case 'reconcile': + return client.request('peer.mesh.reconcile', {}); + case 'transit': + return client.request('peer.mesh.transit.set', { meshId: command.meshId ?? null }); + case 'rename': + return client.request('peer.mesh.display-name.set', { + displayName: requiredDisplayName(command.displayName), + }); + case 'rename-mesh': + return client.request('peer.mesh.rename', { + meshId: requiredValue(command.meshId, 'Mesh ID'), + displayName: requiredDisplayName(command.displayName), + }); + } +} + +async function runFramedPeerMeshCommand( + action: PeerMeshAction, + run: () => Promise, +): Promise { + let response: PeerMeshManagementResultFrame; + try { + response = await run(); + } catch (error) { + if (action !== 'status') throw new PeerMeshMutationOutcomeUnknownError({ cause: error }); + throw error; + } + if (response.action !== action) { + throw new Error('Runtime Host returned an unrelated Mesh result'); + } + if (response.kind === 'error') { + if (response.error.code === 'commit_outcome_unknown') { + throw new PeerMeshMutationOutcomeUnknownError({ cause: new Error(response.error.message) }); + } + throw new Error(response.error.message); + } + return response.result; +} + +function mutationOutcomeIsUnknown(error: unknown): boolean { + if ( + error instanceof PeerMeshMutationOutcomeUnknownError || + error instanceof PeerMeshPostCommitError || + (error instanceof RuntimeHostOperationError && error.code === 'commit_outcome_unknown') || + (error instanceof RuntimeHostRequestInterruptedError && + error.mode === 'command' && + error.dispatch === 'dispatched') + ) { + return true; + } + return error instanceof AggregateError && error.errors.some(mutationOutcomeIsUnknown); +} + +const LIVE_HOST_QUERY_TIMEOUT_MS = 10_000; + +function canCancelOperation( + target: DesktopRuntimeHostPeerMeshTarget, + action: PeerMeshAction, +): boolean { + return action === 'status' || (target.kind === 'desktop' && action === 'join'); +} + +async function reconcileDesktopTarget( + desktopMesh: PeerMeshNode | undefined, + localHost: Pick, + runLocal: LocalOperator['runPeerMesh'], + liveHost: Pick | undefined, + signal?: AbortSignal, +): Promise { + if (!desktopMesh) throw new Error('This Desktop build does not include Direct peer support'); + const failures: unknown[] = []; + const localSnapshot = await localHost.getSnapshot(); + if (localSnapshot.state === 'on') { + const reconciliation = liveHost + ? reconcileManagedTarget( + desktopMesh, + (command) => runLivePeerMeshCommand(liveHost, command), + signal, + ) + : localHost.inspectManaged(async (managed) => { + const run: RunManagedPeerMeshCommand = async (command) => { + const { invitation, ...rest } = command; + const response = await runLocal({ + operatorPath: managed.operatorPath, + target: managedTarget(managed), + ...rest, + ...(invitation ? { invitation: JSON.stringify(invitation) } : {}), + signal: command.signal, + }); + if (response.kind === 'error') throw new Error(response.error.message); + return response.result; + }; + return reconcileManagedTarget(desktopMesh, run, signal); + }); + await reconciliation.catch((error) => failures.push(error)); + } + await desktopMesh.reconcile(signal).catch((error) => failures.push(error)); + if (failures.length > 0) { + throw failures.length === 1 + ? failures[0] + : new AggregateError(failures, 'Peer Mesh synchronization failed'); + } + return projectPeerMeshQuery(desktopMesh); +} + +async function reconcileManagedTarget( + desktopMesh: PeerMeshNode | undefined, + run: RunManagedPeerMeshCommand, + signal?: AbortSignal, +): Promise { + if (!desktopMesh) return requireQueryResult(await run({ action: 'reconcile', signal })); + + const desktop = projectPeerMeshQuery(desktopMesh); + const managed = requireQueryResult(await run({ action: 'status', signal })); + const managedById = new Map(managed.meshes.map((mesh) => [mesh.meshId, mesh])); + let recovered = false; + for (const desktopMembership of desktop.meshes) { + const managedMembership = managedById.get(desktopMembership.meshId); + if (!managedMembership) continue; + if ( + desktopMembership.role === 'authority' && + managedMembership.role === 'member' && + authorityRouteNeedsRecovery(managedMembership) + ) { + const invitation = await desktopMesh.invite(desktopMembership.meshId); + await run({ action: 'join', invitation, signal }); + recovered = true; + continue; + } + if ( + desktopMembership.role === 'member' && + managedMembership.role === 'authority' && + authorityRouteNeedsRecovery(desktopMembership) + ) { + const invited = await run({ action: 'invite', meshId: managedMembership.meshId, signal }); + const invitation = requireInvitationResult(invited).invitation; + await desktopMesh.join(invitation, signal); + recovered = true; + } + } + return requireQueryResult(await run({ action: recovered ? 'status' : 'reconcile', signal })); +} + +function authorityRouteNeedsRecovery(mesh: PeerMeshQueryResult['meshes'][number]): boolean { + const authority = mesh.members.find(({ peerId }) => peerId === mesh.authorityPeerId); + return authority === undefined || authority.state === 'unknown' || authority.state === 'stale'; +} + +function requireQueryResult(result: PeerMeshResult): PeerMeshQueryResult { + if ('available' in result) return result; + throw new Error('Runtime Host returned an unrelated Peer Mesh result'); +} + +function requireInvitationResult(result: PeerMeshResult): PeerMeshInvitationResult { + if ('invitation' in result) return result; + throw new Error('Runtime Host returned an unrelated Peer Mesh result'); +} + +async function executeLocal( + mesh: PeerMeshNode | undefined, + action: PeerMeshAction, + meshId: string | null | undefined, + peerId: string | undefined, + invitation: ReturnType | undefined, + displayName: string | null | undefined, + signal?: AbortSignal, +): Promise { + if (!mesh) { + if (action === 'status') return { available: false, meshes: [] }; + throw new Error('This Desktop build does not include Direct peer support'); + } + const snapshot = (): PeerMeshQueryResult => projectPeerMeshQuery(mesh); + switch (action) { + case 'status': + return snapshot(); + case 'create': + await mesh.create(); + return snapshot(); + case 'invite': { + const created = await mesh.invite(requiredValue(meshId, 'Mesh ID')); + return { invitation: created, snapshot: snapshot() }; + } + case 'join': + await mesh.join(requiredValue(invitation, 'Peer Mesh invitation'), signal); + return snapshot(); + case 'remove': + await mesh.remove(requiredValue(meshId, 'Mesh ID'), requiredValue(peerId, 'Peer ID')); + return snapshot(); + case 'leave': + await mesh.leave(requiredValue(meshId, 'Mesh ID'), signal); + return snapshot(); + case 'close': + await mesh.closeMesh(requiredValue(meshId, 'Mesh ID')); + return snapshot(); + case 'reconcile': + await mesh.reconcile(signal); + return snapshot(); + case 'transit': + await mesh.setTransitMesh(meshId ?? null); + return snapshot(); + case 'rename': + await mesh.setDisplayName(requiredDisplayName(displayName)); + return snapshot(); + case 'rename-mesh': + await mesh.setMeshDisplayName( + requiredValue(meshId, 'Mesh ID'), + requiredDisplayName(displayName), + ); + return snapshot(); + } +} + +function requiredDisplayName(value: string | null | undefined): string | null { + if (value === undefined) throw new Error('Display name is required'); + return value; +} + +function requireDisplayName(value: unknown): string | null { + if (value === null) return null; + if (typeof value !== 'string') throw new Error('Peer Mesh display name is invalid'); + return value; +} + +function requiredValue(value: T | null | undefined, label: string): NonNullable { + if (value === undefined || value === null) throw new Error(`${label} is required`); + return value; +} + +function requireTarget(value: unknown): DesktopRuntimeHostPeerMeshTarget { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Peer Mesh target is invalid'); + } + const record = value as Record; + if (record.kind === 'desktop' && Object.keys(record).length === 1) return { kind: 'desktop' }; + if (record.kind === 'local_host' && Object.keys(record).length === 1) { + return { kind: 'local_host' }; + } + if ( + record.kind === 'managed_host' && + Object.keys(record).length === 2 && + typeof record.profileId === 'string' && + record.profileId.length > 0 && + record.profileId.length <= 128 + ) { + return { kind: 'managed_host', profileId: record.profileId }; + } + throw new Error('Peer Mesh target is invalid'); +} + +function managedTarget( + target: DesktopRuntimeHostLocalManagementTarget, +): Omit { + return { + serviceId: target.serviceId, + rootPath: target.rootPath, + rootId: target.rootId, + deploymentId: target.deploymentId, + }; +} + +function requireAction(value: unknown): PeerMeshAction { + if ( + value === 'status' || value === 'create' || value === 'invite' || value === 'join' || + value === 'remove' || + value === 'leave' || + value === 'close' || + value === 'reconcile' || + value === 'transit' || + value === 'rename' || + value === 'rename-mesh' + ) return value; + throw new Error('Peer Mesh action is invalid'); +} + +function actionNeedsMesh(action: PeerMeshAction): boolean { + return ( + action === 'invite' || + action === 'remove' || + action === 'leave' || + action === 'close' || + action === 'transit' || + action === 'rename-mesh' + ); +} + +function requireIdentifier(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 256) { + throw new Error(`${label} is invalid`); + } + return value; +} + +function requireOperationId(value: unknown): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 128 || + /[\u0000-\u001f\u007f]/u.test(value) + ) { + throw new Error('Peer Mesh operation ID is invalid'); + } + return value; +} + +function requireInvitation(value: unknown): ReturnType { + if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > 128 * 1024) { + throw new Error('Peer Mesh invitation is invalid'); + } + try { + return decodePeerMeshInvitation(JSON.parse(value) as unknown); + } catch { + throw new Error('Peer Mesh invitation is invalid'); + } +} diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts index 92c9aff152..a310141439 100644 --- a/apps/desktop/src/main/runtime-host-profile-service.ts +++ b/apps/desktop/src/main/runtime-host-profile-service.ts @@ -17,18 +17,24 @@ * under the License. */ -import { randomUUID } from "node:crypto"; -import { open, readFile, rename, rm } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import { lstat, open, readFile, rename, rm, rmdir } from "node:fs/promises"; import { dirname, join } from "node:path"; import { createClientRuntimeHostCredentialStore, createClientRuntimeHostProfileCatalog, + decodeEnvironmentRuntimeHostProfile, + decodeRemoteRuntimeHostProfile, + decodeRuntimeHostOwnerConnectionCode, LOCAL_RUNTIME_HOST_PROFILE, RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES, RuntimeHostOperationError, RuntimeHostPermanentReconnectError, + RuntimeHostRemoteCompatibilityError, sameRemoteRuntimeHostProfileTarget, sameResolvedRuntimeHostProfileTarget, + type EnvironmentRuntimeHostProfile, + type PersistedRuntimeHostProfile, type RemoteRuntimeHostProfile, type ResolvedRuntimeHostProfile, type RuntimeHostProfileCatalog, @@ -41,6 +47,7 @@ import type { DesktopRuntimeHostProfileAddResult, DesktopRuntimeHostProfileEntry, DesktopRuntimeHostProfileSnapshot, + DesktopRuntimeHostConnectionCodeImportResult, } from "../preload/bridge-contract.js"; import { RuntimeHostPairingFinalizationInterruptedError, @@ -57,11 +64,16 @@ import { import { createDesktopRuntimeHostManagedServiceStore, findDesktopRuntimeHostManagedServiceBinding, + isDesktopRuntimeHostManagedSshProfile, + isDesktopRuntimeHostManagedSshServiceBinding, sameDesktopRuntimeHostManagedServiceBinding, - type DesktopRuntimeHostManagedService, type DesktopRuntimeHostManagedServiceBinding, + type DesktopRuntimeHostManagedSshServiceBinding, + type DesktopRuntimeHostManagedSshServiceTarget, + type DesktopRuntimeHostManagedWslServiceTarget, type DesktopRuntimeHostManagedServiceStore, } from "./runtime-host-managed-services.js"; +import type { DesktopCollaborationConnectionTarget } from './runtime-host-collaboration-invitation.js'; const PREFERENCES_SCHEMA_VERSION = 2; const PREFERENCES_FILE = "runtime-host-profile-selection.json"; @@ -87,42 +99,68 @@ export interface DesktopRuntimeHostProfileService { addAndEnable( input: DesktopRuntimeHostProfileAddInput, ): Promise; + addManagedEnvironmentAndEnable(input: { + readonly profile: EnvironmentRuntimeHostProfile; + readonly managedService: DesktopRuntimeHostManagedWslServiceTarget; + }): Promise<{ readonly profileId: string }>; addAndEnableVerified( - input: DesktopRuntimeHostProfileAddInput & { + input: { + readonly profile: RemoteRuntimeHostProfile; readonly credential: string; - readonly managedService?: DesktopRuntimeHostManagedService; + readonly managedService?: DesktopRuntimeHostManagedSshServiceTarget; }, ): Promise<{ readonly profileId: string }>; + importConnectionCode(code: string): Promise; resolveManagedService( profileId: string, ): Promise; + resolveCollaborationConnectionTarget( + profile: PersistedRuntimeHostProfile, + ): Promise; + assertPairingComplete(profileId: string): void; resolveManagedAccess( profileId: string, ): Promise; - clearManagedServiceBinding(expected: DesktopRuntimeHostManagedServiceBinding): Promise; + resolveManagedDirectPeerProfile(profileId: string): Promise<{ + readonly exists: boolean; + readonly enabled: boolean; + }>; + upsertManagedDirectPeerProfile( + profileId: string, + peer: { + readonly peerId: string; + readonly routeHints: readonly string[]; + readonly coordinationRelays: readonly string[]; + }, + ): Promise; + removeManagedDirectPeerProfile(profileId: string): Promise; + clearManagedServiceBinding(expected: DesktopRuntimeHostManagedSshServiceBinding): Promise; markManagedServiceUninstalling( - expected: DesktopRuntimeHostManagedServiceBinding, - ): Promise; + expected: DesktopRuntimeHostManagedSshServiceBinding, + ): Promise; markManagedServiceCleanupPending( - expected: DesktopRuntimeHostManagedServiceBinding, - ): Promise; + expected: DesktopRuntimeHostManagedSshServiceBinding, + ): Promise; rotateManagedCredential( expected: DesktopRuntimeHostManagedAccess, credential: string, ): Promise; startEnabledProfiles(): Promise; - resolvePairingRecovery(): Promise; + resolvePairingRecovery(profileId?: string): Promise; + discardPairing(profileId: string): Promise; setEnabled(profileId: string, enabled: boolean): Promise; reconnect(profileId: string, expectedRootId: string): Promise; setDefault(profileId: string): Promise; remove(profileId: string): Promise; } -export interface DesktopRuntimeHostManagedAccess - extends DesktopRuntimeHostManagedServiceBinding { +export type DesktopRuntimeHostManagedAccess = Extract< + DesktopRuntimeHostManagedServiceBinding, + { readonly control: { readonly kind: 'ssh_operator' } } +> & { readonly credentialFingerprint: string; readonly enabled: boolean; -} +}; export async function resolveDesktopRuntimeHostStartup( clientDataRoot: string, @@ -183,10 +221,33 @@ export async function resolveDesktopRuntimeHostStartup( unavailable, }; } + const obsoleteProfileIds = new Set( + document.profiles.flatMap((profile) => + profile.kind === 'remote' && profile.access === 'session_guest' ? [profile.id] : [], + ), + ); + if (obsoleteProfileIds.size > 0) { + await recoverAbandonedProfileLock(join(clientDataRoot, PROFILE_FILE)); + } + for (const profileId of obsoleteProfileIds) await catalog.remove(profileId); + if (obsoleteProfileIds.size > 0) document = await catalog.read(); + if (!pairingReadFailure) { + const retained = pairingIntents.filter( + (intent) => intent.target.profile.access !== 'session_guest', + ); + if (retained.length !== pairingIntents.length) { + await writeDesktopRuntimeHostPairingIntents(credentialStore, retained); + pairingIntents = retained; + } + } const profileIds = new Set(document.profiles.map((profile) => profile.id)); + const defaultProfile = document.profiles.find( + (profile) => profile.id === preferences.defaultProfileId, + ); const defaultProfileId = preferences.defaultProfileId === LOCAL_RUNTIME_HOST_PROFILE.id || - profileIds.has(preferences.defaultProfileId) + (profileIds.has(preferences.defaultProfileId) && + !(defaultProfile?.kind === 'remote' && defaultProfile.access === 'session_guest')) ? preferences.defaultProfileId : LOCAL_RUNTIME_HOST_PROFILE.id; const enabledRemoteProfileIds = new Set( @@ -280,6 +341,7 @@ export function createDesktopRuntimeHostProfileService(input: { const mutateProfiles = (operation: () => Promise): Promise => mutate(async () => { + await recoverAbandonedProfileLock(profilePath); assertPreferencesWritable(); if (pairingReadFailure) { throw new Error( @@ -305,7 +367,7 @@ export function createDesktopRuntimeHostProfileService(input: { await persistPairingIntents(new Map(pairingIntents).set(profileId, intent)); }; - const assertPairingComplete = (profileId: string): void => { + const requirePairingComplete = (profileId: string): void => { if (pairingIntents.has(profileId)) { throw new Error('Resolve this Runtime Host\'s unfinished pairing before changing it'); } @@ -329,15 +391,21 @@ export function createDesktopRuntimeHostProfileService(input: { : unavailable.get(profile.id); return { profile, - ...(profile.kind === "remote" && + ...(profile.kind !== "local" && findDesktopRuntimeHostManagedServiceBinding(managedDocument, profile) ? { managedService: true as const } : {}), + ...(pairingIntents.has(profile.id) ? { pairingPending: true as const } : {}), enabled: isEnabled, isDefault: preferences.defaultProfileId === profile.id, readiness: isEnabled ? (state?.readiness ?? "unavailable") : "disabled", ...(state?.readiness === "ready" - ? { hostId: state.candidate.client.hostId } + ? { + hostId: state.candidate.client.hostId, + ...(state.candidate.client.peerPath + ? { peerPath: state.candidate.client.peerPath } + : {}), + } : state && "hostId" in state && state.hostId ? { hostId: state.hostId } : {}), @@ -354,12 +422,17 @@ export function createDesktopRuntimeHostProfileService(input: { preferences = next; }; - const clearPairingIntentBestEffort = async (profileId: string): Promise => { + const clearPairingIntent = async (profileId: string): Promise => { const next = new Map(pairingIntents); next.delete(profileId); - await persistPairingIntents(next).catch((error) => - console.error("[runtime-host] completed pairing recovery could not be cleared:", error), - ); + try { + await persistPairingIntents(next); + } catch (error) { + // Keep the in-memory recovery lock when neither deletion nor an empty + // journal can be persisted. Retrying is safe and prevents a stale intent + // from becoming ambiguous after restart. + throw new RuntimeHostPairingFinalizationInterruptedError({ cause: error }); + } }; const ensureEnabled = async (target: ResolvedRuntimeHostProfile): Promise => { @@ -370,25 +443,65 @@ export function createDesktopRuntimeHostProfileService(input: { preferences = next; }; + const resolveActivationTarget = async ( + target: ResolvedRuntimeHostProfile, + ): Promise => { + if ( + target.profile.kind !== 'remote' || + target.profile.transport.kind !== 'libp2p-direct' + ) { + return target; + } + const directProfile = target.profile; + const document = await catalog.read(); + const sourceProfile = document.profiles.find( + (profile) => + profile.kind === 'remote' && + profile.transport.kind === 'ssh' && + profile.rootId === directProfile.rootId && + managedDirectPeerProfileId(profile.id) === directProfile.id, + ); + if (!sourceProfile || sourceProfile.kind !== 'remote') return target; + const managed = findDesktopRuntimeHostManagedServiceBinding( + await managedServices.read(), + sourceProfile, + ); + if (!managed) return target; + if (managed.state !== 'active') { + throw new Error('The managed SSH recovery profile is not available'); + } + const source = await catalog.resolve(sourceProfile.id); + if (source.profile.kind !== 'remote' || !source.credential) { + throw new Error('The managed SSH recovery profile credential is not available'); + } + if (source.credential === target.credential) return target; + const rebound = await catalog.rebindIfCurrent(target, directProfile, source.credential); + if (!rebound.rebound) { + throw new Error('The Direct peer profile changed before its credential could be refreshed'); + } + return catalog.resolve(target.profile.id); + }; + const activateTarget = async ( target: ResolvedRuntimeHostProfile, sshInteraction: "terminal" | "batch", ): Promise => { try { - await input.enable(target, sshInteraction); - const current = await catalog.resolve(target.profile.id).catch(() => undefined); - if (!current || !sameResolvedRuntimeHostProfileTarget(current, target)) { - await input.disable(target.profile.id); + const activationTarget = await resolveActivationTarget(target); + await input.enable(activationTarget, sshInteraction); + const current = await catalog.resolve(activationTarget.profile.id).catch(() => undefined); + if (!current || !sameResolvedRuntimeHostProfileTarget(current, activationTarget)) { + await input.disable(activationTarget.profile.id); throw new Error("Runtime Host profile changed while it was connecting"); } const remainsEnabled = - target.profile.id === preferences.defaultProfileId || - preferences.enabledRemoteProfileIds.includes(target.profile.id); + activationTarget.profile.id === preferences.defaultProfileId || + preferences.enabledRemoteProfileIds.includes(activationTarget.profile.id); if (!remainsEnabled) { - await input.disable(target.profile.id); + await input.disable(activationTarget.profile.id); return; } - unavailable.delete(target.profile.id); + unavailable.delete(activationTarget.profile.id); } catch (error) { const failure = asError(error); unavailable.set(target.profile.id, failure); @@ -424,7 +537,7 @@ export function createDesktopRuntimeHostProfileService(input: { await ensureEnabled(target); await activateTarget(target, "terminal"); await input.finalizePairing(target.profile.id); - await clearPairingIntentBestEffort(target.profile.id); + await clearPairingIntent(target.profile.id); }; const rollbackPairingIntent = async ( @@ -444,10 +557,11 @@ export function createDesktopRuntimeHostProfileService(input: { if (!intent.previous) { await managedServices.removeForProfileIfCurrent(intent.target.profile); } - await clearPairingIntentBestEffort(intent.target.profile.id); + await clearPairingIntent(intent.target.profile.id); return; } const rollbackFailures: unknown[] = []; + let reactivationFailure: Error | undefined; await input.disable(current.profile.id).catch((error) => rollbackFailures.push(error)); if (intent.previous) { const restored = await catalog @@ -479,9 +593,9 @@ export function createDesktopRuntimeHostProfileService(input: { (error) => rollbackFailures.push(error), ); if (intent.wasEnabled) { - await activateTarget(previousTarget, "terminal").catch((error) => - rollbackFailures.push(error), - ); + await activateTarget(previousTarget, "terminal").catch((error) => { + reactivationFailure = asError(error); + }); } } } @@ -518,8 +632,9 @@ export function createDesktopRuntimeHostProfileService(input: { "Runtime Host pairing failed and its previous profile could not be restored", ); } - unavailable.delete(current.profile.id); - await clearPairingIntentBestEffort(intent.target.profile.id); + if (reactivationFailure) unavailable.set(current.profile.id, reactivationFailure); + else unavailable.delete(current.profile.id); + await clearPairingIntent(intent.target.profile.id); }; const recoverPairingIntent = async ( @@ -537,7 +652,7 @@ export function createDesktopRuntimeHostProfileService(input: { if (!intent.previous) { await managedServices.removeForProfileIfCurrent(intent.target.profile); } - await clearPairingIntentBestEffort(intent.target.profile.id); + await clearPairingIntent(intent.target.profile.id); if ( current && (preferences.defaultProfileId === current.profile.id || @@ -599,18 +714,81 @@ export function createDesktopRuntimeHostProfileService(input: { } }); + const addAndEnableVerified = ( + value: { + readonly profile: RemoteRuntimeHostProfile; + readonly credential: string; + readonly managedService?: DesktopRuntimeHostManagedSshServiceTarget; + }, + ): Promise<{ readonly profileId: string }> => { + requireSaveInput(value); + if (value.profile.access === 'session_guest') { + return Promise.reject( + new Error('Session Guest access must be retained as a shared Session mount'), + ); + } + return mutateProfiles(async () => { + const currentDocument = await catalog.read(); + const existing = currentDocument.profiles.find((profile) => + profile.kind === 'remote' && + profile.rootId === value.profile.rootId && + sameRemoteRuntimeHostProfileTarget(profile, value.profile), + ); + const previousTarget = existing ? await catalog.resolve(existing.id) : undefined; + const profile = existing ? { ...value.profile, id: existing.id } : value.profile; + const target = { profile, credential: value.credential } as const; + const intent = createDesktopRuntimeHostPairingIntent({ + target, + ...(previousTarget ? { previous: previousTarget } : {}), + wasEnabled: + previousTarget !== undefined && + preferences.enabledRemoteProfileIds.includes(previousTarget.profile.id), + }); + await beginPairingIntent(intent); + try { + if (value.managedService) { + if (!isDesktopRuntimeHostManagedSshProfile(profile)) { + throw new Error('A managed Runtime Host service requires SSH'); + } + await managedServices.save(profile, value.managedService); + } + if (previousTarget) { + const rebound = await catalog.rebindIfCurrent( + previousTarget, + profile, + value.credential, + ); + if (!rebound.rebound) { + throw new Error("Runtime Host profile changed before it could be updated"); + } + } else { + await catalog.create(profile, value.credential); + } + await finishPairingIntent(intent); + return { profileId: profile.id }; + } catch (failure) { + if (failure instanceof RuntimeHostPairingFinalizationInterruptedError) throw failure; + await rollbackPairingIntent(intent, failure); + throw failure; + } + }); + }; + return { getSnapshot: () => mutate(snapshot), addAndEnable(value) { requireSaveInput(value); return mutateProfiles(async () => { - if (value.credential === undefined) { - throw new Error("A Runtime Host access credential is required"); - } - const document = await catalog.create(value.profile, value.credential); - const profile = document.profiles.find((candidate) => candidate.id === value.profile.id); + const requestedProfile = decodeRemoteRuntimeHostProfile(value.profile); + const document = await catalog.create(requestedProfile, value.credential); + const profile = document.profiles.find( + (candidate) => candidate.id === requestedProfile.id, + ); if (!profile) throw new Error("Runtime Host profile creation did not persist"); - const target = { profile, credential: value.credential } as const; + const target: ResolvedRuntimeHostProfile = { + profile, + ...(value.credential ? { credential: value.credential } : {}), + }; let error: Error | undefined; try { error = await enable(profile.id); @@ -623,50 +801,58 @@ export function createDesktopRuntimeHostProfileService(input: { : { kind: "connected", snapshot: await snapshot() }; }); }, - addAndEnableVerified(value) { - requireSaveInput(value); + addManagedEnvironmentAndEnable(value) { + const requestedProfile = decodeEnvironmentRuntimeHostProfile(value.profile); return mutateProfiles(async () => { const currentDocument = await catalog.read(); - const existing = currentDocument.profiles.find((profile) => - profile.rootId === value.profile.rootId && - sameRemoteRuntimeHostProfileTarget(profile, value.profile), + const existing = currentDocument.profiles.find( + (candidate): candidate is EnvironmentRuntimeHostProfile => + candidate.kind === "environment" && + sameResolvedRuntimeHostProfileTarget( + { profile: candidate }, + { profile: requestedProfile }, + ), ); - const previousTarget = existing ? await catalog.resolve(existing.id) : undefined; - const profile = existing ? { ...value.profile, id: existing.id } : value.profile; - const target = { profile, credential: value.credential } as const; - const intent = createDesktopRuntimeHostPairingIntent({ - target, - ...(previousTarget ? { previous: previousTarget } : {}), - wasEnabled: - previousTarget !== undefined && - preferences.enabledRemoteProfileIds.includes(previousTarget.profile.id), - }); - await beginPairingIntent(intent); - try { - if (value.managedService) { - await managedServices.save(profile, value.managedService); - } - if (previousTarget) { - const rebound = await catalog.rebindIfCurrent( - previousTarget, - profile, - value.credential, - ); - if (!rebound.rebound) { - throw new Error("Runtime Host profile changed before it could be updated"); - } - } else { - await catalog.create(profile, value.credential); + const profile = existing ?? requestedProfile; + if (!existing) { + const document = await catalog.create(profile); + const persisted = document.profiles.find( + (candidate) => candidate.id === profile.id, + ); + if (!persisted || persisted.kind !== "environment") { + throw new Error("Runtime Host profile creation did not persist"); } - await finishPairingIntent(intent); - return { profileId: profile.id }; - } catch (failure) { - if (failure instanceof RuntimeHostPairingFinalizationInterruptedError) throw failure; - await rollbackPairingIntent(intent, failure); - throw failure; } + await managedServices.save(profile, value.managedService); + const error = await enable(profile.id); + if (error) throw error; + return { profileId: profile.id }; }); }, + addAndEnableVerified, + async importConnectionCode(code) { + let decoded; + try { + decoded = decodeRuntimeHostOwnerConnectionCode(code); + } catch { + return { kind: 'error', reason: 'invalid_code' }; + } + try { + const result = await addAndEnableVerified({ + profile: { + id: `remote-${randomUUID()}`, + name: decoded.name, + kind: 'remote', + rootId: decoded.rootId, + transport: decoded.transport, + }, + credential: decoded.credential, + }); + return { kind: 'connected', profileId: result.profileId }; + } catch (error) { + return { kind: 'error', reason: connectionCodeImportFailure(error) }; + } + }, rotateManagedCredential(expected, credential) { return mutateProfiles(async () => { const profileId = expected.profile.id; @@ -729,6 +915,56 @@ export function createDesktopRuntimeHostProfileService(input: { return binding; }); }, + resolveCollaborationConnectionTarget(profile) { + return mutate(async () => { + if (profile.kind !== 'remote') { + throw new Error('This Runtime Host does not expose a shareable peer endpoint'); + } + if (profile.transport.kind !== 'ssh' && profile.transport.kind !== 'libp2p-direct') { + return { name: profile.name, transport: profile.transport }; + } + let configuredPeerId: string | undefined; + if (profile.transport.kind === 'libp2p-direct') { + configuredPeerId = profile.transport.peerId; + } else { + const direct = (await catalog.read()).profiles.find( + (candidate) => candidate.id === managedDirectPeerProfileId(profile.id), + ); + if ( + direct?.kind === 'remote' && + direct.rootId === profile.rootId && + direct.transport.kind === 'libp2p-direct' + ) { + configuredPeerId = direct.transport.peerId; + } + } + if (!configuredPeerId) { + throw new Error( + 'Enable Direct peer access for this Runtime Host before sharing its Sessions', + ); + } + const active = input.states().find((state) => state.target.profile.id === profile.id); + if (!active || active.readiness !== 'ready') { + throw new Error('Connect this Runtime Host before sharing its Sessions'); + } + const endpoint = (await active.candidate.client.status()).peerEndpoint; + if (!endpoint) { + throw new Error('Runtime Host Direct peer is not available'); + } + if (configuredPeerId !== endpoint.peerId) { + throw new Error('Runtime Host Direct peer identity changed'); + } + return { + name: profile.name, + transport: { + kind: 'libp2p-direct' as const, + peerId: endpoint.peerId, + routeHints: endpoint.routeHints, + coordinationRelays: endpoint.coordinationRelays, + }, + }; + }); + }, resolveManagedAccess(profileId) { return mutate(async () => { if (pairingReadFailure || pairingIntents.has(profileId)) { @@ -743,28 +979,138 @@ export function createDesktopRuntimeHostProfileService(input: { await managedServices.read(), resolved.profile, ); - return binding - ? { - ...binding, - credentialFingerprint: runtimeHostAccessCredentialFingerprint(resolved.credential), - enabled: preferences.enabledRemoteProfileIds.includes(profileId), - } - : undefined; + if (!binding || !isDesktopRuntimeHostManagedSshServiceBinding(binding)) return undefined; + return { + ...binding, + credentialFingerprint: runtimeHostAccessCredentialFingerprint(resolved.credential), + enabled: preferences.enabledRemoteProfileIds.includes(profileId), + }; + }); + }, + assertPairingComplete(profileId) { + requirePairingComplete(profileId); + }, + resolveManagedDirectPeerProfile(profileId) { + return mutate(async () => { + const peerProfileId = managedDirectPeerProfileId(profileId); + return { + exists: (await catalog.read()).profiles.some((profile) => profile.id === peerProfileId), + enabled: preferences.enabledRemoteProfileIds.includes(peerProfileId), + }; + }); + }, + upsertManagedDirectPeerProfile(profileId, peer) { + return mutateProfiles(async () => { + requirePairingComplete(profileId); + const source = await catalog.resolve(profileId); + if ( + source.profile.kind !== 'remote' || + source.profile.transport.kind !== 'ssh' || + !source.credential + ) { + throw new Error('Direct peer can only be added through a managed SSH Runtime Host'); + } + const managed = findDesktopRuntimeHostManagedServiceBinding( + await managedServices.read(), + source.profile, + ); + if (!managed || managed.state !== 'active') { + throw new Error('This Runtime Host profile is not bound to an active managed service'); + } + if (peer.routeHints.length === 0 && peer.coordinationRelays.length === 0) { + throw new Error('Runtime Host returned an invalid direct-peer descriptor'); + } + const peerProfileId = managedDirectPeerProfileId(profileId); + if (preferences.enabledRemoteProfileIds.includes(peerProfileId)) { + throw new Error('Disable the Direct peer profile before changing its listener'); + } + const profile: RemoteRuntimeHostProfile = { + id: peerProfileId, + name: directPeerProfileName(source.profile.name), + kind: 'remote', + rootId: source.profile.rootId, + transport: { + kind: 'libp2p-direct', + peerId: peer.peerId, + routeHints: peer.routeHints, + coordinationRelays: peer.coordinationRelays, + }, + }; + const existing = (await catalog.read()).profiles.find( + (candidate) => candidate.id === peerProfileId, + ); + if (!existing) { + await catalog.create(profile, source.credential); + return; + } + const previous = await catalog.resolve(peerProfileId); + if (previous.profile.kind !== 'remote' || previous.profile.rootId !== source.profile.rootId) { + throw new Error('The Direct peer profile identity is already in use'); + } + const rebound = await catalog.rebindIfCurrent(previous, profile, source.credential); + if (!rebound.rebound) { + throw new Error('The Direct peer profile changed before it could be updated'); + } + }); + }, + removeManagedDirectPeerProfile(profileId) { + return mutateProfiles(async () => { + requirePairingComplete(profileId); + const peerProfileId = managedDirectPeerProfileId(profileId); + if (preferences.enabledRemoteProfileIds.includes(peerProfileId)) { + throw new Error('Disable the Direct peer profile before changing its listener'); + } + const current = await catalog.resolve(peerProfileId).catch(() => undefined); + if (!current) return; + const source = await catalog.resolve(profileId); + if ( + source.profile.kind !== 'remote' || + source.profile.transport.kind !== 'ssh' || + current.profile.kind !== 'remote' || + current.profile.transport.kind !== 'libp2p-direct' || + current.profile.rootId !== source.profile.rootId + ) { + throw new Error('The Direct peer profile identity is already in use'); + } + const removed = await catalog.removeIfCurrent(current); + if (!removed.removed) { + throw new Error('The Direct peer profile changed before it could be removed'); + } }); }, markManagedServiceUninstalling(expected) { return mutateProfiles(async () => { - assertPairingComplete(expected.profile.id); - const current = (await catalog.read()).profiles.find( + if ( + !expected.deployment.deploymentId && + expected.state !== 'uninstalling' + ) { + throw new Error( + 'Re-onboard this Runtime Host before uninstalling it; its legacy binding has no deployment generation', + ); + } + requirePairingComplete(expected.profile.id); + const document = await catalog.read(); + const current = document.profiles.find( (profile) => profile.id === expected.profile.id, ); if ( !current || - !sameRemoteRuntimeHostProfileTarget(current, expected.profile) || - !(await managedServices.markUninstallingIfCurrent( - expected.profile, - expected.service, - )) + !sameResolvedRuntimeHostProfileTarget( + { profile: current }, + { profile: expected.profile }, + ) + ) { + throw new Error('Runtime Host managed service binding changed during uninstall'); + } + if ( + document.profiles.some( + (profile) => profile.id === managedDirectPeerProfileId(expected.profile.id), + ) + ) { + throw new Error('Disable and remove the Direct peer profile before uninstalling this service'); + } + if ( + !(await managedServices.markUninstallingIfCurrent(expected)) ) { throw new Error('Runtime Host managed service binding changed during uninstall'); } @@ -773,17 +1119,17 @@ export function createDesktopRuntimeHostProfileService(input: { }, markManagedServiceCleanupPending(expected) { return mutateProfiles(async () => { - assertPairingComplete(expected.profile.id); + requirePairingComplete(expected.profile.id); const current = (await catalog.read()).profiles.find( (profile) => profile.id === expected.profile.id, ); if ( !current || - !sameRemoteRuntimeHostProfileTarget(current, expected.profile) || - !(await managedServices.markCleanupPendingIfCurrent( - expected.profile, - expected.service, - )) + !sameResolvedRuntimeHostProfileTarget( + { profile: current }, + { profile: expected.profile }, + ) || + !(await managedServices.markCleanupPendingIfCurrent(expected)) ) { throw new Error('Runtime Host managed service binding changed during uninstall'); } @@ -792,17 +1138,17 @@ export function createDesktopRuntimeHostProfileService(input: { }, clearManagedServiceBinding(expected) { return mutateProfiles(async () => { - assertPairingComplete(expected.profile.id); + requirePairingComplete(expected.profile.id); const current = (await catalog.read()).profiles.find( (profile) => profile.id === expected.profile.id, ); if ( !current || - !sameRemoteRuntimeHostProfileTarget(current, expected.profile) || - !(await managedServices.removeCleanupPendingIfCurrent( - expected.profile, - expected.service, - )) + !sameResolvedRuntimeHostProfileTarget( + { profile: current }, + { profile: expected.profile }, + ) || + !(await managedServices.removeCleanupPendingIfCurrent(expected)) ) { throw new Error('Runtime Host managed service binding changed during uninstall'); } @@ -818,7 +1164,8 @@ export function createDesktopRuntimeHostProfileService(input: { tasks.push(interactiveStartup); } for (const target of input.startup.remotes) { - if (target.profile.kind !== "remote" || !target.credential) continue; + if (target.profile.kind === 'local' || + (target.profile.kind === 'remote' && !target.credential)) continue; if (pairingIntents.has(target.profile.id)) continue; const sshInteraction = startupSshInteraction(target); const activation = () => activateTarget(target, sshInteraction); @@ -830,7 +1177,7 @@ export function createDesktopRuntimeHostProfileService(input: { } return Promise.all(tasks).then(() => undefined); }, - resolvePairingRecovery() { + resolvePairingRecovery(profileId) { return mutate(async () => { assertPreferencesWritable(); if (pairingReadFailure) { @@ -849,12 +1196,35 @@ export function createDesktopRuntimeHostProfileService(input: { pairingReadFailure = undefined; } } - for (const intent of [...pairingIntents.values()]) { - await recoverPairingIntent(intent); + const intents = profileId === undefined + ? [...pairingIntents.values()] + : [pairingIntents.get(profileId)].filter( + (intent): intent is DesktopRuntimeHostPairingIntent => intent !== undefined, + ); + const failures: Error[] = []; + for (const intent of intents) { + const failure = await recoverPairingIntent(intent); + if (failure) failures.push(failure); + } + if (failures.length > 0) { + throw failures.length === 1 + ? failures[0] + : new AggregateError(failures, 'Some Runtime Hosts are still unreachable'); } return snapshot(); }); }, + discardPairing(profileId) { + return mutateProfiles(async () => { + const intent = pairingIntents.get(profileId); + if (!intent) return snapshot(); + await rollbackPairingIntent( + intent, + new Error('Runtime Host pairing was discarded'), + ); + return snapshot(); + }); + }, setEnabled(profileId, isEnabled) { return mutateProfiles(async () => { if (profileId === LOCAL_RUNTIME_HOST_PROFILE.id) { @@ -865,7 +1235,7 @@ export function createDesktopRuntimeHostProfileService(input: { await enable(profileId); return snapshot(); } - assertPairingComplete(profileId); + requirePairingComplete(profileId); if (preferences.defaultProfileId === profileId) { throw new Error("Choose another default Runtime Host before disabling this one"); } @@ -878,12 +1248,15 @@ export function createDesktopRuntimeHostProfileService(input: { }, reconnect(profileId, expectedRootId) { return mutateProfiles(async () => { - assertPairingComplete(profileId); + requirePairingComplete(profileId); if (!preferences.enabledRemoteProfileIds.includes(profileId)) { throw new Error('Enable this Runtime Host before reconnecting it'); } const target = await catalog.resolve(profileId); - if (target.profile.kind !== 'remote' || target.profile.rootId !== expectedRootId) { + if ( + target.profile.kind === 'local' || + target.profile.rootId !== expectedRootId + ) { throw new Error('Runtime Host profile changed before it could reconnect'); } await input.disable(profileId); @@ -909,17 +1282,27 @@ export function createDesktopRuntimeHostProfileService(input: { if (profileId === LOCAL_RUNTIME_HOST_PROFILE.id) { throw new Error("Local Runtime Host cannot be removed"); } - assertPairingComplete(profileId); + requirePairingComplete(profileId); if (preferences.enabledRemoteProfileIds.includes(profileId)) { throw new Error("Disable a Runtime Host before removing it"); } if (preferences.defaultProfileId === profileId) { throw new Error("Choose another default Runtime Host before removing this one"); } - const profile = (await catalog.read()).profiles.find( + const document = await catalog.read(); + const profile = document.profiles.find( (candidate) => candidate.id === profileId, ); if (!profile) throw new Error("Runtime Host profile was not found"); + if ( + profile.kind === 'remote' && + profile.transport.kind === 'ssh' && + document.profiles.some( + (candidate) => candidate.id === managedDirectPeerProfileId(profileId), + ) + ) { + throw new Error('Disable and remove the Direct peer profile before removing its SSH profile'); + } const managedBinding = findDesktopRuntimeHostManagedServiceBinding( await managedServices.read(), profile, @@ -927,14 +1310,14 @@ export function createDesktopRuntimeHostProfileService(input: { if (managedBinding && managedBinding.state !== 'active') { throw new Error('Finish uninstalling this Runtime Host service before removing it'); } - await catalog.remove(profileId); if (managedBinding) { - await managedServices - .removeIfCurrent(profile, managedBinding.service) - .catch((error) => - console.error("[runtime-host] removed Profile left stale service metadata:", error), + if (!(await managedServices.removeIfCurrent(managedBinding))) { + throw new Error( + "Runtime Host managed service binding changed before its profile could be removed", ); + } } + await catalog.remove(profileId); unavailable.delete(profileId); return snapshot(); }); @@ -942,6 +1325,18 @@ export function createDesktopRuntimeHostProfileService(input: { }; } +function managedDirectPeerProfileId(sourceProfileId: string): string { + const digest = createHash('sha256').update(sourceProfileId).digest('hex').slice(0, 32); + return `direct-${digest}`; +} + +function directPeerProfileName(sourceName: string): string { + const suffix = ' · Direct'; + let name = sourceName; + while (Buffer.byteLength(name + suffix, 'utf8') > 128) name = name.slice(0, -1); + return `${name}${suffix}`; +} + async function rollbackCreatedProfile( catalog: RuntimeHostProfileCatalog, target: ResolvedRuntimeHostProfile, @@ -963,18 +1358,23 @@ function assertRootIsNotEnabled( document: Awaited>, states: readonly RuntimeHostDesktopTargetState[], ): void { - if (target.profile.kind !== "remote") return; + if (target.profile.kind === 'local') return; const rootId = target.profile.rootId; const duplicateProfile = document.profiles.find( (profile) => - profile.kind === "remote" && profile.id !== target.profile.id && preferences.enabledRemoteProfileIds.includes(profile.id) && profile.rootId === rootId, ); const duplicateState = states.find((state) => { if (state.target.profile.id === target.profile.id) return false; - const stateRootId = state.target.profile.kind === "remote" + if ( + state.target.profile.kind === 'remote' && + state.target.profile.access === 'session_guest' + ) { + return false; + } + const stateRootId = state.target.profile.kind !== 'local' ? state.target.profile.rootId : state.readiness === "ready" ? state.candidate.client.hostId @@ -991,6 +1391,27 @@ function assertRootIsNotEnabled( } } +async function recoverAbandonedProfileLock(profilePath: string): Promise { + const lockPath = `${profilePath}.lock`; + const lock = await lstat(lockPath).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + }); + if ( + !lock || + !lock.isDirectory() || + lock.isSymbolicLink() + ) { + return; + } + // Electron's single-instance authority excludes another Desktop writer for + // this client data root. Legacy directory locks contain no owner identity, + // so only reclaim an old, empty marker; unexpected contents still fail loud. + await rmdir(lockPath).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + }); +} + async function persistIfCurrentTarget( catalog: RuntimeHostProfileCatalog, profilePath: string, @@ -1025,29 +1446,72 @@ export function registerDesktopRuntimeHostProfileIpc( const channels = [ "runtime-host-profiles:getSnapshot", "runtime-host-profiles:add-and-enable", + "runtime-host-profiles:import-connection-code", "runtime-host-profiles:set-enabled", "runtime-host-profiles:set-default", "runtime-host-profiles:remove", "runtime-host-profiles:resolve-pairing-recovery", + "runtime-host-profiles:discard-pairing", ] as const; ipcMain.handle(channels[0], () => service.getSnapshot()); ipcMain.handle(channels[1], (_event, value: DesktopRuntimeHostProfileAddInput) => service.addAndEnable(value), ); - ipcMain.handle(channels[2], (_event, profileId: string, enabled: boolean) => + ipcMain.handle(channels[2], (_event, code: string) => service.importConnectionCode(code)); + ipcMain.handle(channels[3], (_event, profileId: string, enabled: boolean) => service.setEnabled(profileId, enabled), ); - ipcMain.handle(channels[3], (_event, profileId: string) => service.setDefault(profileId)); - ipcMain.handle(channels[4], (_event, profileId: string) => service.remove(profileId)); - ipcMain.handle(channels[5], () => service.resolvePairingRecovery()); + ipcMain.handle(channels[4], (_event, profileId: string) => service.setDefault(profileId)); + ipcMain.handle(channels[5], (_event, profileId: string) => service.remove(profileId)); + ipcMain.handle(channels[6], (_event, profileId?: string) => + service.resolvePairingRecovery(profileId), + ); + ipcMain.handle(channels[7], (_event, profileId: string) => + service.discardPairing(profileId), + ); return () => { for (const channel of channels) ipcMain.removeHandler(channel); }; } +function connectionCodeImportFailure( + error: unknown, +): Extract['reason'] { + if (error instanceof RuntimeHostRemoteCompatibilityError) return 'host_mismatch'; + if ( + error instanceof RuntimeHostOperationError && + error.operation === 'access.credential.finalize' && + error.code === 'invalid_request' + ) { + return 'code_unavailable'; + } + if (error instanceof RuntimeHostPermanentReconnectError) { + if (/rejected its access credential/u.test(error.message)) return 'code_unavailable'; + if (/unexpected State Root|incompatible Host composition/u.test(error.message)) { + return 'host_mismatch'; + } + } + if (error !== null && typeof error === 'object' && 'code' in error && typeof error.code === 'string') { + if (error.code === 'peer_identity_mismatch') return 'host_mismatch'; + if ( + error.code === 'direct_path_unavailable' || + error.code === 'coordination_unavailable' || + error.code === 'peer_connect_in_progress' + ) { + return 'host_unreachable'; + } + } + return 'unknown'; +} + +function errorCode(error: unknown): string | undefined { + if (!error || typeof error !== 'object' || !('code' in error)) return undefined; + return typeof error.code === 'string' ? error.code : undefined; +} + function requireSaveInput(value: unknown): asserts value is { readonly profile: RemoteRuntimeHostProfile; - readonly credential?: string; + readonly credential: string; } { if (typeof value !== "object" || value === null || !("profile" in value)) { throw new Error("Runtime Host profile input is invalid"); @@ -1061,10 +1525,9 @@ function requireSaveInput(value: unknown): asserts value is { throw new Error("Runtime Host profile input is invalid"); } if ( - "credential" in value && - value.credential !== undefined && - (typeof value.credential !== "string" || - Buffer.byteLength(value.credential, "utf8") > RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES) + !("credential" in value) || + typeof value.credential !== "string" || + Buffer.byteLength(value.credential, "utf8") > RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES ) { throw new Error("Runtime Host credential input is invalid"); } diff --git a/apps/desktop/src/main/runtime-host-quit-copy.ts b/apps/desktop/src/main/runtime-host-quit-copy.ts new file mode 100644 index 0000000000..f9bfff703d --- /dev/null +++ b/apps/desktop/src/main/runtime-host-quit-copy.ts @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiLocale } from '@maka/core/ui-locale'; +import type { MessageBoxOptions } from 'electron'; +import { DesktopLocalHostRetirementError } from './runtime-host-desktop-manager.js'; + +export function buildRuntimeHostQuitFailureDialog( + error: unknown, + locale: UiLocale, +): MessageBoxOptions { + const retirement = error instanceof DesktopLocalHostRetirementError ? error : undefined; + const copy = COPY[locale]; + const details: string[] = [copy.detail]; + if (retirement) { + details.push(`State Root: ${retirement.facts.rootPath}`); + details.push(`Host epoch: ${retirement.facts.hostEpoch}`); + if (retirement.facts.pid !== undefined) { + details.push(copy.process(retirement.facts.pid), copy.manual); + } + } + const cause = error instanceof Error && error.cause instanceof Error + ? error.cause.message + : error instanceof Error + ? error.message + : String(error); + details.push(`${copy.cause}: ${cause}`); + return { + type: 'error', + title: copy.title, + message: copy.message, + detail: details.join('\n'), + buttons: [copy.button], + defaultId: 0, + noLink: true, + }; +} + +const COPY = { + en: { + title: 'Unable to quit Maka safely', + message: 'The local Runtime Host could not stop safely. Maka is still running.', + detail: 'Quit was cancelled. Try again, or inspect diagnostics if the problem persists.', + process: (pid: number) => `Runtime Host process PID: ${pid}`, + manual: + "If retry still fails, confirm that no execution must be preserved before stopping this PID with the operating system's process-management tool.", + cause: 'Cause', + button: 'OK', + }, + zh: { + title: '无法安全退出 Maka', + message: '本地 Runtime Host 未能安全停止,Maka 仍在运行。', + detail: '退出已取消。请重试;如果问题持续存在,请查看诊断信息。', + process: (pid: number) => `Runtime Host 进程 PID:${pid}`, + manual: '如果重试仍然失败,请先确认没有需要保留的执行,再通过操作系统的进程管理工具停止该 PID。', + cause: '原因', + button: '好', + }, +} as const; diff --git a/apps/desktop/src/main/runtime-host-reconnecting-ipc-main.ts b/apps/desktop/src/main/runtime-host-reconnecting-ipc-main.ts index 3875fb5f4d..22a5390b32 100644 --- a/apps/desktop/src/main/runtime-host-reconnecting-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-reconnecting-ipc-main.ts @@ -33,16 +33,16 @@ type ReconcileIpcHandler = ( type ReconciliationUnavailableIpcHandler = ReconcileIpcHandler; -const DEFAULT_RECONCILIATION_WAIT_TIMEOUT_MS = 15_000; +const DEFAULT_REPLACEMENT_WAIT_TIMEOUT_MS = 15_000; export interface RuntimeHostReconnectingIpcMainOptions { - readonly reconciliationWaitTimeoutMs?: number; + readonly replacementWaitTimeoutMs?: number; } -class ReconciliationWaitExpiredError extends Error { +class HandlerWaitExpiredError extends Error { constructor() { - super("Runtime Host reconciliation replacement wait expired"); - this.name = "ReconciliationWaitExpiredError"; + super("Runtime Host replacement wait expired"); + this.name = "HandlerWaitExpiredError"; } } @@ -81,6 +81,13 @@ export class RuntimeHostTargetChangedError extends Error { } } +export class RuntimeHostHandlerUnavailableError extends Error { + constructor() { + super("Runtime Host handler remained unavailable after the reconnection window"); + this.name = "RuntimeHostHandlerUnavailableError"; + } +} + /** * Keeps Electron IPC registration stable across reconnects while fencing each * target generation. Reconnectable reads may move to a replacement candidate, @@ -90,7 +97,7 @@ export class RuntimeHostReconnectingIpcMain { readonly #ipcMain: Pick; readonly #slots = new Map(); readonly #activeEpochs = new Set(); - readonly #reconciliationWaitTimeoutMs: number; + readonly #replacementWaitTimeoutMs: number; #closed = false; constructor( @@ -98,12 +105,12 @@ export class RuntimeHostReconnectingIpcMain { options: RuntimeHostReconnectingIpcMainOptions = {}, ) { this.#ipcMain = ipcMain; - const reconciliationWaitTimeoutMs = - options.reconciliationWaitTimeoutMs ?? DEFAULT_RECONCILIATION_WAIT_TIMEOUT_MS; - if (!Number.isSafeInteger(reconciliationWaitTimeoutMs) || reconciliationWaitTimeoutMs <= 0) { - throw new TypeError("Runtime Host reconciliation wait timeout must be positive"); + const replacementWaitTimeoutMs = + options.replacementWaitTimeoutMs ?? DEFAULT_REPLACEMENT_WAIT_TIMEOUT_MS; + if (!Number.isSafeInteger(replacementWaitTimeoutMs) || replacementWaitTimeoutMs <= 0) { + throw new TypeError("Runtime Host replacement wait timeout must be positive"); } - this.#reconciliationWaitTimeoutMs = reconciliationWaitTimeoutMs; + this.#replacementWaitTimeoutMs = replacementWaitTimeoutMs; } createTarget(epoch: string): RuntimeHostTargetIpcMain { @@ -232,26 +239,28 @@ export class RuntimeHostReconnectingIpcMain { args: readonly unknown[], ): Promise { const epoch = this.#requireTargetEpoch(args[0]); - let handler: BoundHandler = - slot.handlers.get(epoch) ?? await this.#waitForHandler(slot, epoch); - let reconciliationContext: unknown; let reconciling = false; - let reconciliationDeadline: number | undefined; + let replacementDeadline: number | undefined; const waitForReplacement = async ( - previous: BoundHandler, + previous?: BoundHandler, ): Promise => { - if (!reconciling) return this.#waitForHandler(slot, epoch, previous); - const remainingMs = Math.max( - 0, - (reconciliationDeadline ?? Date.now()) - Date.now(), - ); + // One invocation gets one monotonic replacement window across every + // candidate it visits; flapping must not restart its lifetime. + replacementDeadline ??= performance.now() + this.#replacementWaitTimeoutMs; + const remainingMs = Math.max(0, replacementDeadline - performance.now()); try { + if (remainingMs <= 0) throw new HandlerWaitExpiredError(); return await this.#waitForHandler(slot, epoch, previous, remainingMs); } catch (error) { - if (error instanceof ReconciliationWaitExpiredError) return undefined; - throw error; + if (!(error instanceof HandlerWaitExpiredError)) throw error; + if (reconciling) return undefined; + throw new RuntimeHostHandlerUnavailableError(); } }; + const initialHandler = slot.handlers.get(epoch) ?? await waitForReplacement(); + if (!initialHandler) throw new RuntimeHostHandlerUnavailableError(); + let handler: BoundHandler = initialHandler; + let reconciliationContext: unknown; const unavailable = (): Promise => requireReconciliationUnavailableHandler(handler)( reconciliationContext, @@ -278,7 +287,6 @@ export class RuntimeHostReconnectingIpcMain { if (step.kind === "completed") return step.value; reconciliationContext = step.context; reconciling = true; - reconciliationDeadline = Date.now() + this.#reconciliationWaitTimeoutMs; const replacement = await waitForReplacement(handler); if (!replacement) return unavailable(); handler = replacement; @@ -325,7 +333,7 @@ export class RuntimeHostReconnectingIpcMain { return Promise.resolve(current); } if (timeoutMs !== undefined && timeoutMs <= 0) { - return Promise.reject(new ReconciliationWaitExpiredError()); + return Promise.reject(new HandlerWaitExpiredError()); } return new Promise((resolve, reject) => { let timeout: ReturnType | undefined; @@ -344,7 +352,7 @@ export class RuntimeHostReconnectingIpcMain { if (timeoutMs !== undefined) { timeout = setTimeout(() => { if (!slot.waiters.delete(waiter)) return; - waiter.reject(new ReconciliationWaitExpiredError()); + waiter.reject(new HandlerWaitExpiredError()); }, timeoutMs); } }); diff --git a/apps/desktop/src/main/runtime-host-search-ipc-main.ts b/apps/desktop/src/main/runtime-host-search-ipc-main.ts index 8323baca9d..194188998a 100644 --- a/apps/desktop/src/main/runtime-host-search-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-search-ipc-main.ts @@ -17,7 +17,8 @@ * under the License. */ -import { runThreadSearch } from './search/thread-search.js'; +import type { SearchResult } from '@maka/core/search'; +import { runThreadSearch } from '@maka/core/thread-search'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; import { toDesktopHostSessionSummary } from './runtime-host-session-catalog-ipc-main.js'; import { @@ -37,8 +38,8 @@ interface RuntimeHostSearchIpcDeps { export function registerRuntimeHostSearchIpc( deps: RuntimeHostSearchIpcDeps, ): void { - handleReconnectableRead(deps.ipcMain, 'search:thread', (_event, request: unknown) => - runThreadSearch(request, { + handleReconnectableRead(deps.ipcMain, 'search:thread', async (_event, request: unknown) => { + const result = await runThreadSearch(request, { listSessions: async () => (await deps.client.listSessions()).map(toDesktopHostSessionSummary), readMessages: (sessionId) => @@ -54,6 +55,20 @@ export function registerRuntimeHostSearchIpc( incognitoActive: (await deps.client.queryRuntimePolicy()).policy.privacy .incognitoActive, }), - }), - ); + }); + return result.ok ? result.results.map(projectDesktopSearchResult) : result; + }); +} + +function projectDesktopSearchResult(result: SearchResult): SearchResult { + if (!result.target) return result; + return { + ...result, + target: { + kind: result.target.kind, + sessionId: result.target.sessionId, + ...(result.target.turnId !== undefined ? { turnId: result.target.turnId } : {}), + ...(result.target.sequence !== undefined ? { sequence: result.target.sequence } : {}), + }, + }; } diff --git a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts index ce933fcf5b..f4c2586e5e 100644 --- a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts @@ -27,6 +27,7 @@ import { type SessionChangedEvent, type SessionChangedReason, type SessionCatalo import { projectSessionCatalogSummary } from '@maka/runtime-host/client'; import type { SessionCatalogProjection, + SharedSessionCatalogProjection, SessionCreateInput, WorkspaceTarget, SessionModelTarget, @@ -41,7 +42,7 @@ import { resolveSessionActionIds, } from './session-family-action.js'; import { normalizeSessionModelSelection } from './session-model-input.js'; -import type { SessionCopyCleanupAuthority } from './quote-companion-cleanup.js'; +import type { SessionCopyCleanupAuthority } from '@maka/storage/session-copy-cleanup'; import { handleReconnectableRead, type ReconnectableReadIpcMain, @@ -51,6 +52,7 @@ type RuntimeHostSessionCatalogClient = Pick< DesktopRuntimeHostClient, | 'createSession' | 'listSessions' + | 'previewSessionRemoval' | 'removeSession' | 'setSessionLifecycle' | 'updateSessionConfiguration' @@ -59,6 +61,7 @@ type RuntimeHostSessionCatalogClient = Pick< export interface DesktopHostSessionSummary extends SessionCatalogSummary { labelsTruncated: boolean; + shared?: true; } export interface RuntimeHostSessionCatalogIpcDeps { @@ -78,6 +81,10 @@ export interface RuntimeHostSessionCatalogIpcDeps { newId?: () => string; } +export interface RuntimeHostSharedSessionCatalogIpcDeps { + getSession(): Promise; +} + export function registerRuntimeHostSessionCatalogIpc( deps: RuntimeHostSessionCatalogIpcDeps, ipcMain: ReconnectableReadIpcMain, @@ -125,7 +132,8 @@ export function registerRuntimeHostSessionCatalogIpc( sessionId: newId(), workspace, ...(request.mode === undefined ? {} : { mode: request.mode }), - ...(request.mode === undefined ? { name: request.name } : {}), + // A nameless mode (`bot`) keeps the caller's name, so always forward it. + name: request.name, ...(request.labels === undefined ? {} : { labels: request.labels }), modelTarget: normalizeModelTarget(input), ...normalizeCreateThinkingLevel(input?.thinkingLevel), @@ -213,14 +221,62 @@ export function registerRuntimeHostSessionCatalogIpc( const ids = await actionIds(sessionId, { revisionFamily: true }); // A task restored under the caller's decision is left alone, and nothing // downstream of the deletion runs for it. - const disposition = await deps.client.removeSession(sessionId, { + const outcome = await deps.client.removeSession(sessionId, { requireArchived: requiresArchivedSession(options), }); - if (disposition === 'removed') await finishSessionRetirement(deps, ids, 'deleted'); - return disposition; + if (outcome.disposition === 'removed') await finishSessionRetirement(deps, ids, 'deleted'); + return outcome; + }); + ipcMain.handle('sessions:removePreview', async (_event, sessionId: string) => { + // Read-only: how many subtasks the delete would archive, for the confirm. + return deps.client.previewSessionRemoval(sessionId); }); } +export function registerRuntimeHostSharedSessionCatalogIpc( + deps: RuntimeHostSharedSessionCatalogIpcDeps, + ipcMain: ReconnectableReadIpcMain, +): void { + handleReconnectableRead(ipcMain, 'sessions:list', async (_event, filter?: unknown) => { + if (normalizeSessionListFilter(filter)?.subagentParentSessionId) return []; + const session = await deps.getSession(); + return session ? [session] : []; + }); +} + +export function toDesktopHostSharedSessionSummary( + session: SharedSessionCatalogProjection, +): DesktopHostSessionSummary { + return { + id: session.id, + name: session.name, + activityAt: session.activityAt, + isFlagged: false, + isArchived: false, + labels: [], + labelsTruncated: false, + hasUnread: false, + ...(session.lastMessageAt === undefined ? {} : { lastMessageAt: session.lastMessageAt }), + ...(session.lastMessagePreview === undefined + ? {} + : { lastMessagePreview: session.lastMessagePreview }), + status: session.status, + ...(session.liveRunState === undefined + ? {} + : { runningTurnIds: [...session.liveRunState.runningTurnIds] }), + ...(session.blockedReason === undefined ? {} : { blockedReason: session.blockedReason }), + ...(session.statusUpdatedAt === undefined + ? {} + : { statusUpdatedAt: session.statusUpdatedAt }), + backend: 'ai-sdk', + llmConnectionSlug: '', + connectionLocked: true, + model: '', + permissionMode: 'ask', + shared: true, + }; +} + /** * Reads the archived premise off the remove options. * @@ -294,19 +350,23 @@ function normalizeSessionListFilter(value: unknown): SessionListFilter | undefin } function normalizeModelTarget(input: CreateSessionRequestInput | undefined): SessionModelTarget { + const connectionId = normalizeOptionalString(input?.llmConnectionId, 'model connection id'); const slug = normalizeOptionalString(input?.llmConnectionSlug, 'model connection'); const model = normalizeOptionalString(input?.model, 'model'); - if (slug === undefined && model === undefined) return { kind: 'default' }; - if (slug === undefined || model === undefined) { - throw new Error('Explicit model selection requires both connection and model'); + if (connectionId === undefined && slug === undefined && model === undefined) { + return { kind: 'default' }; + } + if (connectionId === undefined || slug === undefined || model === undefined) { + throw new Error('Explicit model selection requires connection id, connection, and model'); } - return { kind: 'explicit', connectionSlug: slug, model }; + return { kind: 'explicit', connectionId, connectionSlug: slug, model }; } function normalizeExplicitModel(input: unknown): Extract { const selection = normalizeSessionModelSelection(input); return { kind: 'explicit', + connectionId: selection.llmConnectionId, connectionSlug: selection.llmConnectionSlug, model: selection.model, }; diff --git a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts index c0fd5aaa38..a68664aac7 100644 --- a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts @@ -46,6 +46,7 @@ import { type ReconnectableReadIpcMain, } from './ipc-reconnect-policy.js'; import { + registerRuntimeHostShellRunQueriesIpc, registerRuntimeHostShellRunsIpc, type RuntimeHostShellRunsClient, } from './runtime-host-shell-runs-ipc-main.js'; @@ -62,7 +63,7 @@ type RuntimeHostSessionDomainClient = RuntimeHostShellRunsClient & | 'listRuntimeResources' | 'listAgentGraphEpochs' | 'listCurrentAgentGraphEpochs' - | 'listTasks' + | 'querySessionTodo' | 'queryAgentGraph' | 'queryAgentGraphOperator' | 'queryDeepResearch' @@ -104,9 +105,17 @@ export function registerRuntimeHostSessionDomainsIpc( { client: deps.client, newId, sessionObserver: deps.sessionObserver }, ipcMain, ); + const shellRunQueries = registerRuntimeHostShellRunQueriesIpc( + { + client: deps.client, + sendToRenderer: deps.sendToRenderer, + onError: deps.onError, + }, + ipcMain, + ); - handleReconnectableRead(ipcMain, 'tasks:list', (_event, sessionId: unknown) => - deps.client.listTasks(requiredId(sessionId, 'Session')), + handleReconnectableRead(ipcMain, 'todo:read', (_event, sessionId: unknown) => + deps.client.querySessionTodo(requiredId(sessionId, 'Session')), ); handleReconnectableRead(ipcMain, 'deepResearch:get', async (_event, sessionId: unknown) => projectHostedDeepResearch( @@ -320,10 +329,9 @@ export function registerRuntimeHostSessionDomainsIpc( const sessionDomainChanged = (change: SessionDomainChange): void => { switch (change.domain) { - case 'task': - deps.sendToRenderer?.('tasks:changed', { + case 'todo': + deps.sendToRenderer?.('todo:changed', { sessionId: change.sessionId, - taskIds: [], at: now(), }); break; @@ -340,7 +348,7 @@ export function registerRuntimeHostSessionDomainsIpc( deps.sendToRenderer?.('usage:changed', { sessionId: change.sessionId }); break; case 'runtime_resource': - void refreshRuntimeResources(deps, change.sessionId, change.resources); + shellRunQueries.sessionDomainChanged(change); break; } }; @@ -354,32 +362,17 @@ export function registerRuntimeHostSessionDomainsIpc( deps.sendToRenderer?.('graphs:changed', event); }, sessionSubscriptionRecovered(sessionId) { - sessionDomainChanged({ sessionId, domain: 'task' }); + sessionDomainChanged({ sessionId, domain: 'todo' }); sessionDomainChanged({ sessionId, domain: 'deep_research' }); sessionDomainChanged({ sessionId, domain: 'plan' }); sessionDomainChanged({ sessionId, domain: 'usage' }); deps.sendToRenderer?.('graphs:resync', { rootSessionId: sessionId }); - deps.sendToRenderer?.('shell-runs:resync', { sessionId }); + shellRunQueries.sessionSubscriptionRecovered(sessionId); }, close: () => shellRuns.close(), }; } -async function refreshRuntimeResources( - deps: RuntimeHostSessionDomainsIpcDeps, - sessionId: string, - resources: readonly { ref: string }[], -): Promise { - for (const resource of resources) { - try { - const update = await deps.client.getRuntimeResource(sessionId, resource.ref); - if (update) deps.sendToRenderer?.('shell-runs:update', update); - } catch (error) { - deps.onError?.(error); - } - } -} - interface CanonicalGoalArmRequest { readonly sessionId: string; readonly condition: string; diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index a6653fedb0..5699b58242 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -20,13 +20,15 @@ import { randomUUID } from "node:crypto"; import type { IpcMainInvokeEvent } from "electron"; import { MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; -import { RuntimeHostOperationError } from '@maka/runtime-host/client'; -import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token'; +import { isSideConversationSession } from '@maka/core/side-conversation'; +import { + RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, +} from '@maka/runtime-host/client'; import { type SessionChangedEvent, type SessionChangedReason, } from '@maka/core/session'; -import { SIDE_CONVERSATION_SESSION_LABEL } from '@maka/core/side-conversation'; import { type ActiveInteractionRequestEvent, type AttachmentRef } from '@maka/core/events'; import { type PermissionMode } from '@maka/core/permission'; import { type SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; @@ -40,6 +42,7 @@ import { normalizeRegenerateTurnInput, normalizeRuntimeHostReviseBeforeTurnInput, normalizeSandboxBoundaryResponse, + normalizeClientCapabilityResponse, normalizeSessionSendCommand, normalizeStopSessionInput, normalizeUserQuestionResponse, @@ -49,7 +52,7 @@ import { type ReconnectableReadIpcMain, } from "./ipc-reconnect-policy.js"; import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; -import type { SessionCopyCleanupAuthority } from './quote-companion-cleanup.js'; +import type { SessionCopyCleanupAuthority } from '@maka/storage/session-copy-cleanup'; import type { RuntimeHostSessionObservationRegistry } from "./runtime-host-session-observation-registry.js"; import { RuntimeHostSessionObserver, @@ -57,9 +60,32 @@ import { type RuntimeHostTranscriptTarget, } from "./runtime-host-session-observer.js"; import type { DesktopTranscriptRangeRequest } from '../preload/transcript-contract.js'; +import type { DesktopSessionStopResult } from '../preload/bridge-contract.js'; import { toDesktopHostSessionSummary } from "./runtime-host-session-catalog-ipc-main.js"; import { mergeWorkspaceFileInlineReferences } from "./session-workspace-inline-references.js"; +type SideConversationBranchResult = + | { readonly ok: true; readonly session: ReturnType } + | { readonly ok: false; readonly reason: 'session_busy' | 'operation_unavailable' }; + +async function retryDispatchedCommand( + command: () => Promise, + waitForReconnect: () => Promise, +): Promise { + try { + return await command(); + } catch (error) { + if ( + !(error instanceof RuntimeHostRequestInterruptedError) || + error.dispatch !== 'dispatched' + ) { + throw error; + } + await waitForReconnect(); + return command(); + } +} + type RuntimeHostSessionExecutionClient = Pick< DesktopRuntimeHostClient, | "answerInteraction" @@ -70,6 +96,8 @@ type RuntimeHostSessionExecutionClient = Pick< | "interruptTurn" | 'listSessionTurns' | 'listSessionTurnLandmarks' + | 'queryMessageExecutions' + | 'queryMessages' | "queryTurnResume" | "readExecutionBoundary" | "regenerateTurn" @@ -78,23 +106,46 @@ type RuntimeHostSessionExecutionClient = Pick< | "updateQueueEntry" | "reorderQueueEntries" | "setSessionReadMarker" - | "startTurn" | "startTurnResume" | "submitMessage" | "updateSessionMetadata" | "updateSessionConfiguration" >; +/** No Skill was named, so the Host resolved none. */ +const EMPTY_SKILL_INVOCATION = { loaded: [], failed: [], receipts: [] } as const; + +async function submitMessageWithReconnect( + client: Pick, + input: Parameters[0], +): Promise> | undefined> { + try { + return await retryDispatchedCommand( + () => client.submitMessage(input), + () => client.getSession(input.sessionId), + ); + } catch (error) { + if (error instanceof RuntimeHostOperationError && error.code === 'outcome_unknown') { + return undefined; + } + // `dispatched` means the request reached Runtime Host and the answer was + // lost, which is the same thing `outcome_unknown` says. The retry above + // covers one interruption; a second one is still an unknown outcome, and + // raising it would have the renderer delete a Message the Host may hold + // — and, on a first send, the Session created for it. + if ( + error instanceof RuntimeHostRequestInterruptedError && + error.dispatch === 'dispatched' + ) { + return undefined; + } + throw error; + } +} + export interface RuntimeHostSessionExecutionIpcDeps { client: RuntimeHostSessionExecutionClient; observer: RuntimeHostSessionObserver; - observations: Pick< - RuntimeHostSessionObservationRegistry, - | 'loadTranscriptAround' - | 'loadTranscriptBefore' - | 'observe' - | 'openTranscript' - >; attachmentApprovals: AttachmentApprovalRegistry; emitSessionsChanged: ( reason: SessionChangedReason, @@ -119,6 +170,58 @@ export interface RuntimeHostSessionExecutionIpcDeps { newId?: () => string; } +export interface RuntimeHostSessionObservationIpcDeps { + observations: Pick< + RuntimeHostSessionObservationRegistry, + | 'loadTranscriptAround' + | 'loadTranscriptBefore' + | 'observe' + | 'openTranscript' + >; + resolveSideConversation(sessionId: string): Promise; +} + +/** Register the complete Desktop surface available to an observation-only Session. */ +export function registerRuntimeHostSessionObservationIpc( + deps: RuntimeHostSessionObservationIpcDeps, + ipcMain: ReconnectableReadIpcMain, +): void { + handleReconnectableRead( + ipcMain, + 'sessions:observe', + async (event, sessionId: unknown, observerId: unknown) => { + const normalizedSessionId = requiredId(sessionId, 'Session'); + await deps.observations.observe( + normalizedSessionId, + requiredId(observerId, 'Session observer'), + event.sender as RuntimeHostSessionObserverTarget, + await deps.resolveSideConversation(normalizedSessionId), + ); + }, + ); + ipcMain.handle( + 'sessions:transcript:open', + async (event, sessionId: unknown, consumerId: unknown) => + deps.observations.openTranscript( + requiredId(sessionId, 'Session'), + requiredId(consumerId, 'Transcript consumer'), + event.sender as RuntimeHostTranscriptTarget, + ), + ); + ipcMain.handle('sessions:transcript:load-before', async (event, input: unknown) => { + await deps.observations.loadTranscriptBefore( + normalizeTranscriptRangeRequest(input), + event.sender.id, + ); + }); + ipcMain.handle('sessions:transcript:load-around', async (event, input: unknown) => { + await deps.observations.loadTranscriptAround( + normalizeTranscriptRangeRequest(input), + event.sender.id, + ); + }); +} + /** * Project Host-owned Session execution onto the Desktop renderer IPC contract. * The adapter owns client validation and presentation events, never Runtime @@ -148,40 +251,21 @@ export function registerRuntimeHostSessionExecutionIpc( const stopSession = createRuntimeHostSessionStop(deps, newId); ipcMain.handle( - "sessions:observe", - async (event, sessionId: unknown, observerId: unknown) => { - const normalizedSessionId = requiredId(sessionId, "Session"); - const normalizedObserverId = requiredId(observerId, "Session observer"); - await deps.observations.observe( - normalizedSessionId, - normalizedObserverId, - event.sender as RuntimeHostSessionObserverTarget, - ); + 'sessions:queryCancelledMessages', + async (_event, sessionId: string, messageIds: unknown) => { + if (!Array.isArray(messageIds)) throw new Error('Invalid Message identities'); + return deps.client.queryMessages({ sessionId, messageIds }); }, ); + ipcMain.handle( - 'sessions:transcript:open', - async (event, sessionId: unknown, consumerId: unknown) => { - const result = await deps.observations.openTranscript( - requiredId(sessionId, 'Session'), - requiredId(consumerId, 'Transcript consumer'), - event.sender as RuntimeHostTranscriptTarget, - ); - return result; + 'sessions:queryMessageExecutions', + async (_event, sessionId: string, messageIds: unknown) => { + if (!Array.isArray(messageIds)) throw new Error('Invalid Message identities'); + return deps.client.queryMessageExecutions({ sessionId, messageIds }); }, ); - ipcMain.handle('sessions:transcript:load-before', async (event, input: unknown) => { - await deps.observations.loadTranscriptBefore( - normalizeTranscriptRangeRequest(input), - event.sender.id, - ); - }); - ipcMain.handle('sessions:transcript:load-around', async (event, input: unknown) => { - await deps.observations.loadTranscriptAround( - normalizeTranscriptRangeRequest(input), - event.sender.id, - ); - }); + handleReconnectableRead(ipcMain, 'sessions:listTurns', async (_event, sessionId: unknown) => deps.client.listSessionTurns(requiredId(sessionId, 'Session')), ); @@ -213,6 +297,7 @@ export function registerRuntimeHostSessionExecutionIpc( const session = await deps.client.getSession(sessionId); if (!session) throw new Error(`Runtime Host Session not found: ${sessionId}`); + const sideConversation = isSideConversationSession(session.labels); const turnId = command.turnId ?? newId(); let attachments = retainedAttachmentsForSession( sessionId, @@ -252,113 +337,76 @@ export function registerRuntimeHostSessionExecutionIpc( displayText, workspaceFileReferences: command.workspaceFileReferences, }); - const startInput = { + // Runtime Host is the sole admission authority: one submit answers + // whether the words opened a Turn or joined the running one, and the + // Desktop never routes on content — an explicit Skill or orchestration + // still fails closed on a busy Session, in the Host. The Message identity + // is the Turn id the caller reserved: one submit, one durable Message, + // and a retry the Host recognizes as the same one. + const messageId = turnId; + const submitted = await submitMessageWithReconnect(deps.client, { sessionId, - turnId, + messageId, + placement: "current_turn" as const, content: { text: command.text, ...(command.displayText !== undefined ? { displayText: command.displayText } : {}), ...(attachments.length > 0 ? { attachments } : {}), + ...(command.directoryReferences ? { directoryReferences: command.directoryReferences } : {}), ...(command.quotes ? { quotes: command.quotes } : {}), inlineReferences, }, - ...((command.skillIds?.length ?? 0) > 0 - ? { skillIds: command.skillIds } - : {}), + ...((command.skillIds?.length ?? 0) > 0 ? { skillIds: command.skillIds } : {}), ...(command.turnOrchestration ? { turnOrchestration: command.turnOrchestration } : {}), - }; - let startResult; - try { - startResult = await deps.client.startTurn(startInput); - } catch (error) { - // The renderer routes text at a session it sees as running to - // `sessions:steer`, but its view can lag the Host: another window, a - // Bot, or a Goal continuation may have opened the root Turn first, and - // that race surfaced here as a session_busy send failure that dropped - // the user's message (#1954). `turn.message.submit` resolves the race - // on the Host: an active session queues the text as steering, an idle - // one starts the Turn. Skill and orchestration sends keep the error — - // their turn semantics cannot be expressed as a queued message — and - // the Desktop composer carries Skills as canonical /skill: tokens in - // the text, not as skillIds. - if ( - !(error instanceof RuntimeHostOperationError) || - error.code !== "session_busy" || - (command.skillIds?.length ?? 0) > 0 || - command.turnOrchestration || - new RegExp(SKILL_INVOCATION_TOKEN_SOURCE).test(command.text) - ) { - throw error; - } - const submitted = await deps.client.submitMessage({ - sessionId, - messageId: newId(), - content: startInput.content, - placement: "current_turn", - }); - const emptySkillInvocation = { loaded: [], failed: [], receipts: [] }; - if (submitted.disposition === "turn_started") { - deps.emitSessionsChanged("status-change", sessionId, { - turnId: submitted.turnId, - }); - return { - ok: true as const, - turnId: submitted.turnId, - attachments, - inlineReferences, - skillInvocation: emptySkillInvocation, - }; - } - // The steering renderer believed this session idle; nudge it to - // refresh so its composer converges on the running turn. - deps.emitSessionsChanged("status-change", sessionId); + }); + if (!submitted) { return { - ok: true as const, - steered: true as const, - turnId, - attachments, - inlineReferences, - skillInvocation: emptySkillInvocation, + ok: false as const, + reason: 'outcome_unknown' as const, + messageId, + skillInvocation: EMPTY_SKILL_INVOCATION, }; } - if (startResult.kind === "blocked") { + if (submitted.disposition === "blocked") { return { ok: false as const, + reason: "skill_invocation_failed" as const, + skillInvocation: submitted.skillInvocation, + }; + } + if (submitted.disposition === "turn_started") { + deps.emitSessionsChanged("status-change", sessionId, { + turnId: submitted.turnId, + }); + return { + ok: true as const, + turnId: submitted.turnId, attachments, inlineReferences, - skillInvocation: startResult.skillInvocation, + skillInvocation: submitted.skillInvocation, }; } - deps.emitSessionsChanged("status-change", sessionId, { turnId }); + // The sending surface believed this Session idle; nudge it to refresh so + // its composer converges on the running Turn. + deps.emitSessionsChanged("status-change", sessionId); return { ok: true as const, + steered: true as const, turnId, + ...(sideConversation ? { messageId } : {}), attachments, inlineReferences, - skillInvocation: startResult.skillInvocation, + skillInvocation: submitted.skillInvocation, }; }, ); ipcMain.handle( - "sessions:steer", - async (_event, sessionId: string, text: unknown) => { - const content = steeringContent(text); - await deps.client.submitMessage({ - sessionId, - messageId: newId(), - content: { text: content }, - placement: "current_turn", - }); - return { kind: "queued" as const }; - }, - ); - ipcMain.handle( - "sessions:enqueue", + "sessions:submitMessage", async (event, sessionId: string, placement: unknown, value: unknown) => { if (placement !== "current_turn" && placement !== "next_turn") { throw new Error("Invalid message placement"); @@ -366,12 +414,12 @@ export function registerRuntimeHostSessionExecutionIpc( const command = normalizeSessionSendCommand({ ...(value && typeof value === "object" ? value : {}), type: "send", - turnId: newId(), }); - if (!command) throw new Error("Invalid queued message"); - if ((command.skillIds?.length ?? 0) > 0 || command.turnOrchestration) { - throw new Error("Queued control input is not available"); - } + if (!command) throw new Error("Invalid submitted message"); + // The submitting surface owns the Message identity: it is what reconciles + // the row it already rendered, and what makes a retry the same Message. + // Minting one here would hand back an identity the caller never showed. + if (!command.messageId) throw new Error("Submitted message has no identity"); const session = await deps.client.getSession(sessionId); if (!session) { throw new Error(`Runtime Host Session not found: ${sessionId}`); @@ -405,14 +453,22 @@ export function registerRuntimeHostSessionExecutionIpc( if (attachments.length > MAX_ATTACHMENT_COUNT) { throw new Error("Too many attachments"); } - const displayText = command.displayText ?? command.text; + const displayText = + command.displayText ?? + (command.text.trim().length > 0 + ? command.text + : (command.skillIds ?? []).map((id) => `/skill:${id}`).join(" ")); const inlineReferences = mergeWorkspaceFileInlineReferences({ displayText, workspaceFileReferences: command.workspaceFileReferences, }); - const result = await deps.client.submitMessage({ + const messageId = command.messageId; + // Skill and orchestration intent travels with the Message. Runtime Host + // decides whether it opens its own Turn, steers the running one, or + // fails closed; the Desktop never routes on message content. + const result = await submitMessageWithReconnect(deps.client, { sessionId, - messageId: newId(), + messageId, placement, content: { text: command.text, @@ -420,25 +476,45 @@ export function registerRuntimeHostSessionExecutionIpc( ? { displayText: command.displayText } : {}), ...(attachments.length > 0 ? { attachments } : {}), + ...(command.directoryReferences ? { directoryReferences: command.directoryReferences } : {}), ...(command.quotes ? { quotes: command.quotes } : {}), inlineReferences, }, + ...((command.skillIds?.length ?? 0) > 0 ? { skillIds: command.skillIds } : {}), + ...(command.turnOrchestration + ? { turnOrchestration: command.turnOrchestration } + : {}), }); + if (!result) return { ok: false as const, reason: 'outcome_unknown' as const }; + if (result.disposition === 'blocked') { + return { + ok: false as const, + reason: 'skill_invocation_failed' as const, + skillInvocation: result.skillInvocation, + }; + } if (result.disposition === "turn_started") { deps.emitSessionsChanged("status-change", sessionId, { turnId: result.turnId, }); return { - kind: "started" as const, + ok: true as const, + disposition: result.disposition, turnId: result.turnId, attachments, inlineReferences, + skillInvocation: result.skillInvocation, }; } + // The submitting surface believed this Session idle when it steered; + // nudge it to refresh so its composer converges on the running Turn. + deps.emitSessionsChanged("status-change", sessionId); return { - kind: "queued" as const, + ok: true as const, + disposition: result.disposition, attachments, inlineReferences, + skillInvocation: result.skillInvocation, }; }, ); @@ -507,7 +583,7 @@ export function registerRuntimeHostSessionExecutionIpc( "sessions:stop", async (_event, sessionId: string, input: unknown) => { const normalized = normalizeStopSessionInput(input); - return stopSession(sessionId, normalized.expectedTurnId); + return stopSession(sessionId, normalized); }, ); @@ -564,6 +640,22 @@ export function registerRuntimeHostSessionExecutionIpc( deps.observer.publishInteractionAnswer(answered, pending); }, ); + ipcMain.handle( + "sessions:respondToClientCapability", + async (_event, sessionId: string, input: unknown) => { + const response = normalizeClientCapabilityResponse(input); + const pending = await requireInteraction(deps.observer, sessionId, response.requestId); + if (pending.request.kind !== "client_capability") { + throw new Error("Interaction is not a Client Capability request"); + } + const answered = await deps.client.answerInteraction({ + sessionId, + interactionId: response.requestId, + answer: { kind: "client_capability", decision: response.decision }, + }); + deps.observer.publishInteractionAnswer(answered, pending); + }, + ); ipcMain.handle("sessions:compact", async (_event, sessionId: string) => { const turnId = newId(); @@ -624,36 +716,47 @@ export function registerRuntimeHostSessionExecutionIpc( sourceSessionId: sessionId, targetSessionId: normalized.copyId, sourceTurnId: normalized.sourceTurnId, + ...(normalized.sideConversation ? { intent: 'side_conversation' as const } : {}), }); - let branch = normalized.sideConversation - ? await deps.sessionCopyCleanup.ownCreation( - { - sessionId: normalized.copyId, - kind: 'branch', - sourceSessionId: sessionId, - sourceTurnId: normalized.sourceTurnId, - ownerId: bindCopyOwner(event), - }, - createBranch, - ) - : await createBranch(); - if (normalized.name || normalized.sideConversation) { + let branch; + try { + branch = normalized.sideConversation + ? await deps.sessionCopyCleanup.ownCreation( + { + sessionId: normalized.copyId, + kind: 'branch', + sourceSessionId: sessionId, + sourceTurnId: normalized.sourceTurnId, + intent: 'side_conversation', + ownerId: bindCopyOwner(event), + }, + createBranch, + ) + : await createBranch(); + } catch (error) { + if ( + normalized.sideConversation && + error instanceof RuntimeHostOperationError && + (error.code === 'session_busy' || error.code === 'operation_unavailable') + ) { + await deps.sessionCopyCleanup.rejectCreation(normalized.copyId); + return { + ok: false, + reason: error.code, + } satisfies SideConversationBranchResult; + } + throw error; + } + if (normalized.name) { branch = await deps.client.updateSessionMetadata(branch.id, { - ...(normalized.name ? { name: normalized.name } : {}), - ...(normalized.sideConversation - ? { - labels: [ - ...new Set([ - ...branch.labels, - SIDE_CONVERSATION_SESSION_LABEL, - ]), - ], - } - : {}), + name: normalized.name, }); } deps.emitSessionsChanged("created", branch.id); - return toDesktopHostSessionSummary(branch); + const summary = toDesktopHostSessionSummary(branch); + return normalized.sideConversation + ? ({ ok: true, session: summary } satisfies SideConversationBranchResult) + : summary; }, ); ipcMain.handle( @@ -669,7 +772,9 @@ export function registerRuntimeHostSessionExecutionIpc( return toDesktopHostSessionSummary(revision); }, ); - return stopSession; + return async (sessionId) => { + await stopSession(sessionId); + }; } function normalizeTranscriptRangeRequest(input: unknown): DesktopTranscriptRangeRequest { @@ -690,7 +795,8 @@ function normalizeTranscriptRangeRequest(input: unknown): DesktopTranscriptRange } return { consumerId: requiredId(value.consumerId, 'Transcript consumer'), - generation: requiredId(value.generation, 'Transcript generation'), + sessionId: requiredId(value.sessionId, 'Session'), + hostEpoch: requiredId(value.hostEpoch, 'Host epoch'), anchorSequence: anchorSequence as number | null, maxBytes: maxBytes as number, }; @@ -720,8 +826,42 @@ function createRuntimeHostSessionStop( "beforeStop" | "client" | "observer" | "emitSessionsChanged" >, newId: () => string = randomUUID, -): (sessionId: string, expectedTurnId?: string) => Promise { - return async (sessionId, expectedTurnId) => { +): ( + sessionId: string, + target?: { readonly expectedTurnId?: string; readonly expectedAdmissionId?: string }, +) => Promise { + return async (sessionId, target = {}) => { + let expectedTurnId = target.expectedTurnId; + if (target.expectedAdmissionId) { + const observed = await deps.observer.snapshot(sessionId); + const root = observed.rootTurn; + const entry = [...observed.queue.steering, ...observed.queue.followup].find( + (candidate) => candidate.messageId === target.expectedAdmissionId, + ); + if (entry?.state === 'queued') { + const retractId = newId(); + await retryDispatchedCommand( + () => + deps.client.retractQueueEntry({ + sessionId, + entryId: entry.entryId, + retractId, + }), + () => deps.client.getSession(sessionId), + ); + deps.emitSessionsChanged('status-change', sessionId); + return { kind: 'retracted', messageId: entry.messageId }; + } + if ( + root && + !isTerminalStatus(root.status) && + root.turnId === target.expectedAdmissionId + ) { + expectedTurnId = root.turnId; + } else { + throw new Error('Host admission outcome is unknown'); + } + } if (expectedTurnId) { const observed = (await deps.observer.snapshot(sessionId)).rootTurn; if ( @@ -738,8 +878,13 @@ function createRuntimeHostSessionStop( !turn || isTerminalStatus(turn.status) || (expectedTurnId && turn.turnId !== expectedTurnId) - ) return; - await deps.client.interruptTurn({ + ) { + if (target.expectedAdmissionId) { + throw new Error('Host admission outcome is unknown'); + } + return; + } + const interrupted = await deps.client.interruptTurn({ sessionId, interruptId: newId(), turnId: turn.turnId, @@ -748,6 +893,10 @@ function createRuntimeHostSessionStop( deps.emitSessionsChanged("turn-status-change", sessionId, { turnId: turn.turnId, }); + return { + kind: 'interrupted', + retractedMessageIds: interrupted.retracted.map((message) => message.messageId), + }; }; } @@ -787,16 +936,6 @@ function requiredSequence(value: unknown, label: string): number { return value as number; } -function steeringContent(value: unknown): string { - if ( - typeof value !== "string" || - value.trim().length === 0 || - value.length > 128_000 - ) { - throw new Error("Invalid steering text"); - } - return value.trim(); -} function isTerminalStatus(status: string): boolean { return ( diff --git a/apps/desktop/src/main/runtime-host-session-observation-registry.ts b/apps/desktop/src/main/runtime-host-session-observation-registry.ts index e60ca88983..289c154b63 100644 --- a/apps/desktop/src/main/runtime-host-session-observation-registry.ts +++ b/apps/desktop/src/main/runtime-host-session-observation-registry.ts @@ -76,6 +76,7 @@ function requireTranscriptSource( interface SessionObservationRegistration { readonly sessionId: string; + readonly messageAdmissions: boolean; readonly target: RuntimeHostSessionObserverTarget; readonly destroyedListener: () => void; readonly ready: ObservationReadiness; @@ -151,6 +152,7 @@ export class RuntimeHostSessionObservationRegistry { registration.sessionId, observerId, bindTarget(registration.target), + registration.messageAdmissions, ); if ( this.#source !== source || @@ -207,6 +209,37 @@ export class RuntimeHostSessionObservationRegistry { return [...new Set([...this.#registrations.values()].map((registration) => registration.sessionId))]; } + trackedSessionIds(): string[] { + return [ + ...new Set([ + ...[...this.#registrations.values()].map((registration) => registration.sessionId), + ...[...this.#transcripts.values()].map((registration) => registration.sessionId), + ]), + ]; + } + + async forgetSession(sessionId: string): Promise { + const source = this.#source; + const observations = [...this.#registrations].filter( + ([, registration]) => registration.sessionId === sessionId, + ); + const transcripts = [...this.#transcripts].filter( + ([, registration]) => registration.sessionId === sessionId, + ); + for (const [observerId, registration] of observations) { + this.#deleteRegistration(observerId, registration); + } + for (const [consumerId, registration] of transcripts) { + this.#deleteTranscript(consumerId, registration); + } + if (source) { + await Promise.allSettled([ + ...observations.map(([observerId]) => source.unobserve(observerId)), + ...transcripts.map(([consumerId]) => source.closeTranscript?.(consumerId)), + ]); + } + } + detach(source: SessionObservationSource): void { if (this.#source === source) { this.#source = undefined; @@ -218,11 +251,16 @@ export class RuntimeHostSessionObservationRegistry { sessionId: string, observerId: string, target: RuntimeHostSessionObserverTarget, + messageAdmissions = false, ): Promise { this.#assertOpen(); const previous = this.#registrations.get(observerId); if (previous) { - if (previous.sessionId !== sessionId || previous.target.id !== target.id) { + if ( + previous.sessionId !== sessionId || + previous.target.id !== target.id || + previous.messageAdmissions !== messageAdmissions + ) { throw new Error("Runtime Host Session observer identity was reused"); } return previous.ready.promise; @@ -235,6 +273,7 @@ export class RuntimeHostSessionObservationRegistry { void ready.promise.catch(() => undefined); const registration: SessionObservationRegistration = { sessionId, + messageAdmissions, target, destroyedListener, ready, @@ -246,7 +285,12 @@ export class RuntimeHostSessionObservationRegistry { const source = this.#source; if (!source) return registration.ready.promise; try { - await source.observe(sessionId, observerId, this.#bindTarget(target)); + await source.observe( + sessionId, + observerId, + this.#bindTarget(target), + messageAdmissions, + ); if ( this.#source === source && this.#registrations.get(observerId) === registration diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index 815b960a5a..acf5e12377 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -26,19 +26,19 @@ import { isRuntimeHostTerminalTurn as isTerminalTurn, projectRuntimeHostInteractionRequest, } from "@maka/runtime-host/adapter"; -import type { - InteractionAnsweredSnapshot, - InteractionPendingSnapshot, - SessionDomainChange, - SessionContinuitySnapshot, - SubscriptionFrame, +import { + SESSION_TRANSCRIPT_RANGE_MAX_BYTES, + type InteractionAnsweredSnapshot, + type InteractionPendingSnapshot, + type SessionDomainChange, + type SessionContinuitySnapshot, + type SubscriptionFrame, } from "@maka/runtime-host/protocol"; import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; import { RuntimeHostSubscriptionError } from "@maka/runtime-host/client"; import { DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES, - DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, type DesktopTranscriptBatch, type DesktopTranscriptBatchPayload, @@ -118,13 +118,13 @@ interface ObservedSessionState { snapshot?: SessionContinuitySnapshot; projector?: RuntimeHostSessionProjector; transcriptAccess: number; + messageAdmissions: boolean; closing: boolean; } interface TranscriptConsumer { readonly consumerId: string; readonly target: RuntimeHostTranscriptTarget; - readonly destroyedListener: () => void; generation: string; deliverySequence: number; deliveryBytes: number; @@ -281,13 +281,9 @@ export class RuntimeHostSessionObserver { void this.#closeIfIdle(state); } } - const destroyedListener = () => { - void this.closeTranscript(consumerId); - }; const consumer: TranscriptConsumer = { consumerId, target, - destroyedListener, generation: replica.generation, deliverySequence: 0, deliveryBytes: 0, @@ -296,7 +292,6 @@ export class RuntimeHostSessionObserver { }; state.transcriptConsumers.set(consumerId, consumer); this.#transcriptConsumers.set(consumerId, state); - target.once('destroyed', destroyedListener); try { consumer.resetRequested = true; await this.#scheduleTranscriptDelivery(state, consumer); @@ -360,8 +355,7 @@ export class RuntimeHostSessionObserver { const { state, replica, consumer } = this.#requireTranscriptConsumer(request, targetId); const isCurrent = () => state.replica === replica && - state.transcriptConsumers.get(request.consumerId) === consumer && - consumer.generation === request.generation; + state.transcriptConsumers.get(request.consumerId) === consumer; const task = operation(replica); try { await task; @@ -431,6 +425,7 @@ export class RuntimeHostSessionObserver { sessionId: string, observerId: string, target: RuntimeHostSessionObserverTarget, + messageAdmissions = false, ): Promise { this.#assertOpen(); const previous = this.#observers.get(observerId); @@ -444,6 +439,10 @@ export class RuntimeHostSessionObserver { return; } const state = this.#state(sessionId); + if (messageAdmissions && !state.messageAdmissions) { + state.messageAdmissions = true; + state.projector?.enableMessageAdmissions(); + } let group = state.targets.get(target.id); if (!group) { const destroyedListener = () => { @@ -563,6 +562,12 @@ export class RuntimeHostSessionObserver { status: answered.outcome.status, revision: answered.revision, }); + } else if (answered.outcome.kind === "client_capability_decision") { + this.#broadcast(answered.sessionId, { + type: "client_capability_decision_ack", + ...base, + decision: answered.outcome.decision, + }); } } @@ -628,6 +633,7 @@ export class RuntimeHostSessionObserver { subscriptionOwner, pendingTranscriptConsumers: 0, transcriptAccess: 0, + messageAdmissions: false, closing: false, }; this.#states.set(sessionId, state); @@ -806,6 +812,7 @@ export class RuntimeHostSessionObserver { subscription.replica.projectionSeed, this.#now, subscription.activeAssistantStreams, + state.messageAdmissions, ); const terminalTurnIds = new Set(); for (const turnId of state.watchedTurnIds) { @@ -1061,9 +1068,12 @@ export class RuntimeHostSessionObserver { change: DesktopTranscriptReplicaChange, ): void { if (state.replica !== replica || state.closing) return; - state.projector?.noteTranscriptMessageIds( - change.durableUpserts.map((entry) => entry.message.id), - ); + for (const event of + state.projector?.noteDurableTranscriptMessages( + change.durableUpserts.map((entry) => entry.message), + ) ?? []) { + this.#broadcast(state.sessionId, event); + } this.#sendTranscriptChange(state, replica, change); if (!change.hasNewer && change.durableUpserts.length > 0) { this.#markTranscriptRead(state, replica); @@ -1081,8 +1091,7 @@ export class RuntimeHostSessionObserver { if (consumer.generation !== replica.generation) { this.#requestTranscriptReset(state, consumer); } else if (!this.#mergeTranscriptChange(consumer, change)) { - this.#detachTranscriptConsumer(state, consumer); - void this.#closeIfIdle(state); + this.#requestTranscriptReset(state, consumer); } else { void this.#scheduleTranscriptDelivery(state, consumer).catch(() => undefined); } @@ -1156,8 +1165,9 @@ export class RuntimeHostSessionObserver { } } } catch (error) { - this.#detachTranscriptConsumer(state, consumer); - void this.#closeIfIdle(state); + if (state.transcriptConsumers.get(consumer.consumerId) === consumer) { + consumer.resetRequested = true; + } throw error; } })().finally(() => { @@ -1343,8 +1353,19 @@ export class RuntimeHostSessionObserver { if (targetId !== undefined && consumer.target.id !== targetId) { throw new Error('Desktop transcript consumer belongs to another renderer'); } - if (consumer.generation !== request.generation || replica.generation !== request.generation) { - throw new Error('Desktop transcript generation changed'); + // Durable transcript sequence identities belong to the Session and the + // Runtime Host epoch, not to a Desktop replica generation. Recovery may + // install a replacement replica (new generation, same session and host + // epoch) after the renderer dispatches a range request; continue that + // read against the current replica so navigation completes across + // reconnect. When the Host itself is replaced, sequence identity is not + // preserved, so reject the stale request instead of silently reading a + // different slice. + if (state.sessionId !== request.sessionId) { + throw new Error('Desktop transcript consumer belongs to another session'); + } + if (replica.hostEpoch !== request.hostEpoch) { + throw new Error('Desktop transcript host epoch changed; reopen the transcript'); } return { state, replica, consumer }; } @@ -1362,7 +1383,6 @@ export class RuntimeHostSessionObserver { pending.reject(new Error('Desktop transcript consumer was closed')); } consumer.pendingDeliveries.clear(); - consumer.target.off('destroyed', consumer.destroyedListener); } #touchReplica(state: ObservedSessionState, protectedState?: ObservedSessionState): boolean { @@ -1446,7 +1466,7 @@ function requireTranscriptRangeBytes(value: number): number { function resetDeliveryWorkingSetBytes(residentBytes: number): number { return ( - Math.min(residentBytes, DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES) + + Math.min(residentBytes, SESSION_TRANSCRIPT_RANGE_MAX_BYTES) + Math.min( residentBytes, (TRANSCRIPT_DELIVERY_WINDOW * 2 + 1) * DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, diff --git a/apps/desktop/src/main/runtime-host-session-subscription-owner.ts b/apps/desktop/src/main/runtime-host-session-subscription-owner.ts index 70908c3087..4f4b7ed1f8 100644 --- a/apps/desktop/src/main/runtime-host-session-subscription-owner.ts +++ b/apps/desktop/src/main/runtime-host-session-subscription-owner.ts @@ -17,7 +17,10 @@ * under the License. */ -import { RuntimeHostSubscriptionError } from "@maka/runtime-host/client"; +import { + RuntimeHostOperationError, + RuntimeHostSubscriptionError, +} from "@maka/runtime-host/client"; import type { SessionAssistantStreamIdentity, SessionContinuitySnapshot, @@ -401,19 +404,28 @@ export class RuntimeHostSessionSubscriptionOwner { } function subscriptionClosedError( - reason: "slow_consumer" | "session_removed", + reason: "slow_consumer" | "session_removed" | 'access_revoked', ): Error { - return reason === "session_removed" - ? new SessionRemovedSubscriptionError( - "Runtime Host Session was removed while it was observed", - ) - : new RuntimeHostSubscriptionError( - "slow_consumer", - "Runtime Host Session subscription closed for a slow consumer", - ); + if (reason === 'session_removed') { + return new SessionRemovedSubscriptionError( + 'Runtime Host Session was removed while it was observed', + ); + } + if (reason === 'access_revoked') { + return new SessionRemovedSubscriptionError( + 'Access to the shared Runtime Host Session was revoked', + ); + } + return new RuntimeHostSubscriptionError( + 'slow_consumer', + 'Runtime Host Session subscription closed for a slow consumer', + ); } function isRecoverableSubscriptionFailure(error: unknown): boolean { + if (error instanceof RuntimeHostOperationError) { + return error.operation === "session.transcript.page" && error.code === "not_found"; + } if (!(error instanceof RuntimeHostSubscriptionError)) return false; return ( error.reason === "slow_consumer" || diff --git a/apps/desktop/src/main/runtime-host-settings-ipc-main.ts b/apps/desktop/src/main/runtime-host-settings-ipc-main.ts index 9326dbdf76..f7b6065941 100644 --- a/apps/desktop/src/main/runtime-host-settings-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-settings-ipc-main.ts @@ -30,10 +30,10 @@ import type { } from "@maka/core/runtime-policy"; import { SENSITIVE_PLACEHOLDER } from "@maka/core/settings/network-settings"; import type { - ProxySettings, TestProxyInput, + TestProxySettings, } from "@maka/core/settings/network-settings"; -import type { SettingsStore } from "@maka/storage"; +import type { SettingsStore } from "@maka/storage/settings-store"; import { buildSettingsUpdateResult, maskAppSettings, @@ -149,11 +149,12 @@ export async function updateRuntimeHostSettings( } function toRuntimeHostProxyPolicy( - proxy: ProxySettings, + proxy: TestProxySettings, autoBypassDomains: readonly string[], ): RuntimePolicy["networkProxy"] { const username = proxy.username?.trim() ?? ""; - const authEnabled = Boolean(username || proxy.password); + const authEnabled = + proxy.authEnabled ?? Boolean(username || proxy.password); return { enabled: proxy.enabled, protocol: proxy.type, diff --git a/apps/desktop/src/main/runtime-host-setup-package.ts b/apps/desktop/src/main/runtime-host-setup-package.ts new file mode 100644 index 0000000000..adfd04a85a --- /dev/null +++ b/apps/desktop/src/main/runtime-host-setup-package.ts @@ -0,0 +1,388 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { copyFile, mkdtemp, realpath, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import { + DEFAULT_PROCESS_TERMINATION_GRACE_MS, + terminateChildProcessTree, +} from '@maka/runtime/process-tree-terminator'; +const DEVELOPMENT_ARCHIVE_ENV = 'MAKA_RUNTIME_HOST_SETUP_ARCHIVE'; + +export type DesktopRuntimeHostSetupPackage = + | { readonly kind: 'npm'; readonly specifier: string } + | { + readonly kind: 'development_archive'; + readonly path: string; + readonly integrity: string; + }; + +function isExactRuntimeHostSetupPackageSpecifier(value: unknown): value is string { + return typeof value === 'string' && /^maka-agent@[0-9][0-9A-Za-z.+-]*$/u.test(value); +} + +export function runtimeHostSetupPackageVersion( + setupPackage: + | { readonly kind: 'npm'; readonly specifier: string } + | { readonly kind: 'development_archive' }, +): string | undefined { + if (setupPackage.kind === 'development_archive') return undefined; + if (!isExactRuntimeHostSetupPackageSpecifier(setupPackage.specifier)) { + throw new Error('Runtime Host setup package must use an exact Maka version'); + } + return setupPackage.specifier.slice('maka-agent@'.length); +} + +interface DevelopmentArchiveBuild { + readonly result: Promise; + close(): Promise; +} + +interface DevelopmentBuildState { + readonly task: DevelopmentArchiveBuild; + readonly result: Promise; + waiters: number; + settled: boolean; + closing?: Promise; +} + +export type DesktopRuntimeHostDevelopmentPeerTarget = + | 'none' + | 'darwin-arm64' + | 'linux-arm64' + | 'linux-x64' + | 'win32-x64'; + +export interface RuntimeHostSetupPackageResolver { + readonly mode: 'published' | 'development'; + resolve( + peerTarget: DesktopRuntimeHostDevelopmentPeerTarget, + signal?: AbortSignal, + ): Promise; + close(): Promise; +} + +export function desktopRuntimeHostDevelopmentPeerTarget( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, +): Exclude { + const target = `${platform}-${arch}`; + if ( + target !== 'darwin-arm64' && + target !== 'linux-arm64' && + target !== 'linux-x64' && + target !== 'win32-x64' + ) { + throw new Error(`Direct peer is not available on ${target}`); + } + return target; +} + +export function createRuntimeHostSetupPackageResolver(input: { + readonly isPackaged: boolean; + readonly appPath: string; + readonly environment: NodeJS.ProcessEnv; + readonly startDevelopmentArchiveBuild?: ( + repoRoot: string, + peerTarget: DesktopRuntimeHostDevelopmentPeerTarget, + ) => DevelopmentArchiveBuild; +}): RuntimeHostSetupPackageResolver { + let closed = false; + const developmentBuilds = new Map< + DesktopRuntimeHostDevelopmentPeerTarget, + DevelopmentBuildState + >(); + let overrideSnapshot: ReturnType | undefined; + + const resolveOverrideSnapshot = (path: string) => { + overrideSnapshot ??= snapshotDevelopmentSetupPackage(path).catch((error: unknown) => { + overrideSnapshot = undefined; + throw error; + }); + return overrideSnapshot; + }; + + const startBuild = ( + peerTarget: DesktopRuntimeHostDevelopmentPeerTarget, + ): DevelopmentBuildState => { + const repoRoot = resolve(input.appPath, '..', '..'); + const task = input.startDevelopmentArchiveBuild?.(repoRoot, peerTarget) ?? + startDevelopmentArchiveBuild(repoRoot, input.environment, peerTarget); + const build: DevelopmentBuildState = { + task, + result: task.result.then(developmentSetupPackage), + waiters: 0, + settled: false, + }; + developmentBuilds.set(peerTarget, build); + void build.result.then( + () => { + build.settled = true; + }, + () => { + build.settled = true; + if (developmentBuilds.get(peerTarget) === build) { + developmentBuilds.delete(peerTarget); + } + void stopBuild(peerTarget, build).catch(() => undefined); + }, + ); + return build; + }; + + const stopBuild = async ( + peerTarget: DesktopRuntimeHostDevelopmentPeerTarget, + build: DevelopmentBuildState, + ) => { + build.closing ??= build.task.close().finally(() => { + if (developmentBuilds.get(peerTarget) === build) developmentBuilds.delete(peerTarget); + }); + await build.closing; + await build.result.catch(() => undefined); + }; + + const acquireBuild = async ( + peerTarget: DesktopRuntimeHostDevelopmentPeerTarget, + signal?: AbortSignal, + ) => { + while (true) { + if (closed) throw new Error('Runtime Host setup package resolver is closed'); + const build = developmentBuilds.get(peerTarget); + if (!build) return startBuild(peerTarget); + if (!build.closing) return build; + await waitForPackage(build.closing, signal); + } + }; + + return { + mode: input.isPackaged ? 'published' : 'development', + async resolve(peerTarget, signal) { + if (closed) throw new Error('Runtime Host setup package resolver is closed'); + if (input.isPackaged) return packagedSetupPackage(input.appPath); + + const override = input.environment[DEVELOPMENT_ARCHIVE_ENV]; + if (override) { + const snapshot = await waitForPackage(resolveOverrideSnapshot(override), signal); + return snapshot.setupPackage; + } + + const build = await acquireBuild(peerTarget, signal); + build.waiters += 1; + try { + return await waitForPackage(build.result, signal); + } finally { + build.waiters -= 1; + if (signal?.aborted && build.waiters === 0 && !build.settled) { + await stopBuild(peerTarget, build); + } + } + }, + async close() { + if (closed) return; + closed = true; + const builds = [...developmentBuilds.entries()]; + developmentBuilds.clear(); + await Promise.all(builds.map(([peerTarget, build]) => stopBuild(peerTarget, build))); + const snapshot = await overrideSnapshot?.catch(() => undefined); + if (snapshot) await rm(snapshot.root, { recursive: true, force: true }); + }, + }; +} + +function packagedSetupPackage(appPath: string): DesktopRuntimeHostSetupPackage { + const manifest = JSON.parse(readFileSync(join(appPath, 'package.json'), 'utf8')) as { + runtimeHostSetupPackage?: unknown; + }; + if (!isExactRuntimeHostSetupPackageSpecifier(manifest.runtimeHostSetupPackage)) { + throw new Error('Desktop does not declare an exact Runtime Host setup package'); + } + return { kind: 'npm', specifier: manifest.runtimeHostSetupPackage }; +} + +function startDevelopmentArchiveBuild( + repoRoot: string, + environment: NodeJS.ProcessEnv, + peerTarget: DesktopRuntimeHostDevelopmentPeerTarget, +): DevelopmentArchiveBuild { + const script = join(repoRoot, 'scripts', 'release-cli-package.mjs'); + const nodeExecutable = environment.npm_node_execpath?.trim() || 'node'; + const outputBase = join(repoRoot, 'packages', 'cli', '.development'); + mkdirSync(outputBase, { recursive: true, mode: 0o755 }); + const outputRoot = mkdtempSync(join(outputBase, 'desktop-')); + const child = spawn(nodeExecutable, [script, '--development'], { + cwd: repoRoot, + detached: process.platform !== 'win32', + env: { + ...environment, + MAKA_CLI_DEVELOPMENT_OUTPUT_ROOT: outputRoot, + MAKA_CLI_DEVELOPMENT_PEER_TARGET: peerTarget, + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + let settled = false; + let stdout = ''; + let stderr = ''; + let processError: Error | undefined; + const appendOutput = (current: string, chunk: Buffer): string => { + const next = current + chunk.toString('utf8'); + if (Buffer.byteLength(next, 'utf8') <= 64 * 1024 * 1024) return next; + processError = new Error('Local Runtime Host CLI build output exceeded 64 MiB'); + void terminateChildProcessTree(child, 'SIGKILL'); + return current; + }; + const output = new Promise((resolveOutput, reject) => { + child.stdout?.on('data', (chunk: Buffer) => { + stdout = appendOutput(stdout, chunk); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderr = appendOutput(stderr, chunk); + }); + child.once('error', (error) => { + processError = error; + }); + child.once('close', (code, signal) => { + settled = true; + if (code === 0 && !processError) { + resolveOutput(stdout); + return; + } + const detail = ( + stderr.trim() || + processError?.message || + `process exited with ${code === null ? signal ?? 'an unknown status' : `code ${code}`}` + ).slice(-2_000); + reject(new Error(`Failed to prepare the local Runtime Host CLI: ${detail}`)); + }); + }); + const result = output.then((stdout) => { + const archive = Array.from(stdout.matchAll(/^\[release-cli\] tarball: (.+)$/gmu)).at(-1)?.[1]; + if (!archive) throw new Error('The local Runtime Host CLI build did not report an archive'); + const resolvedArchive = resolve(archive.trim()); + const relativeArchive = relative(outputRoot, resolvedArchive); + if ( + !relativeArchive || + relativeArchive.startsWith('..') || + isAbsolute(relativeArchive) || + !resolvedArchive.endsWith('.tgz') || + !existsSync(resolvedArchive) + ) { + throw new Error('The local Runtime Host CLI build returned an invalid archive path'); + } + return resolvedArchive; + }); + void result.catch(() => rmSync(outputRoot, { recursive: true, force: true })); + let closing: Promise | undefined; + return { + result, + close() { + closing ??= (async () => { + try { + if (!settled) await terminateBuildProcess(child, result); + } finally { + rmSync(outputRoot, { recursive: true, force: true }); + } + })(); + return closing; + }, + }; +} + +async function developmentSetupPackage(path: string): Promise { + const archive = await realpath(path); + if (!(await stat(archive)).isFile() || !archive.endsWith('.tgz')) { + throw new Error('Runtime Host development package must be a .tgz file'); + } + return { + kind: 'development_archive', + path: archive, + integrity: await sha512Integrity(archive), + }; +} + +async function snapshotDevelopmentSetupPackage(path: string): Promise<{ + readonly root: string; + readonly setupPackage: DesktopRuntimeHostSetupPackage; +}> { + const source = await realpath(path); + if (!(await stat(source)).isFile() || !source.endsWith('.tgz')) { + throw new Error('Runtime Host development package must be a .tgz file'); + } + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-host-setup-override-')); + const snapshot = join(root, 'package.tgz'); + try { + await copyFile(source, snapshot); + return { root, setupPackage: await developmentSetupPackage(snapshot) }; + } catch (error) { + await rm(root, { recursive: true, force: true }); + throw error; + } +} + +function sha512Integrity(path: string): Promise { + return new Promise((resolveIntegrity, reject) => { + const hash = createHash('sha512'); + const stream = createReadStream(path); + stream.on('data', (chunk) => hash.update(chunk)); + stream.once('error', reject); + stream.once('end', () => resolveIntegrity(`sha512-${hash.digest('base64')}`)); + }); +} + +function waitForPackage(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return promise; + signal.throwIfAborted(); + return new Promise((resolvePackage, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + void promise.then(resolvePackage, reject).finally(() => { + signal.removeEventListener('abort', onAbort); + }); + }); +} + +async function terminateBuildProcess(child: ChildProcess, result: Promise): Promise { + await terminateChildProcessTree(child, 'SIGTERM'); + if (await settlesWithin(result, DEFAULT_PROCESS_TERMINATION_GRACE_MS)) return; + await terminateChildProcessTree(child, 'SIGKILL'); + if (!(await settlesWithin(result, DEFAULT_PROCESS_TERMINATION_GRACE_MS))) { + throw new Error('Local Runtime Host CLI build did not exit after forced termination'); + } +} + +async function settlesWithin(promise: Promise, timeoutMs: number): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise.then( + () => true, + () => true, + ), + new Promise((resolveTimeout) => { + timeout = setTimeout(() => resolveTimeout(false), timeoutMs); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} diff --git a/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts b/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts index 7c92763735..fa7651209e 100644 --- a/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts @@ -20,6 +20,7 @@ import { randomUUID } from 'node:crypto'; import type { ShellRunUpdate } from '@maka/core/events'; import type { ShellRunPtySnapshot } from '@maka/runtime/shell-run-contract'; +import type { SessionDomainChange } from '@maka/runtime-host/protocol'; import { RuntimeHostOperationError } from '@maka/runtime-host/client'; import { handleReconnectableRead, @@ -39,6 +40,38 @@ export type RuntimeHostShellRunsClient = Pick< | 'stopRuntimeResource' >; +export type RuntimeHostShellRunQueriesClient = Pick< + DesktopRuntimeHostClient, + 'getRuntimeResource' | 'listRuntimeResources' +>; + +export interface RuntimeHostShellRunQueriesIpcHandle { + sessionDomainChanged(change: SessionDomainChange): void; + sessionSubscriptionRecovered(sessionId: string): void; +} + +export function registerRuntimeHostShellRunQueriesIpc( + deps: { + client: RuntimeHostShellRunQueriesClient; + sendToRenderer?(channel: string, payload: unknown): void; + onError?(error: unknown): void; + }, + ipcMain: ReconnectableReadIpcMain, +): RuntimeHostShellRunQueriesIpcHandle { + handleReconnectableRead(ipcMain, 'shell-runs:list', (_event, sessionId: unknown) => + deps.client.listRuntimeResources(requiredId(sessionId, 'Session')), + ); + return { + sessionDomainChanged(change) { + if (change.domain !== 'runtime_resource') return; + void refreshRuntimeResources(deps, change.sessionId, change.resources); + }, + sessionSubscriptionRecovered(sessionId) { + deps.sendToRenderer?.('shell-runs:resync', { sessionId }); + }, + }; +} + export function registerRuntimeHostShellRunsIpc( deps: { client: RuntimeHostShellRunsClient; @@ -60,10 +93,6 @@ export function registerRuntimeHostShellRunsIpc( newId, deps.sessionObserver, ); - - handleReconnectableRead(ipcMain, 'shell-runs:list', (_event, sessionId: unknown) => - deps.client.listRuntimeResources(requiredId(sessionId, 'Session')), - ); ipcMain.handle('shell-runs:start', async (_event, sessionId: unknown) => { const normalizedSessionId = requiredId(sessionId, 'Session'); const started = await deps.client.startRuntimeResource({ @@ -95,6 +124,25 @@ export function registerRuntimeHostShellRunsIpc( return { close: () => controllers.close() }; } +async function refreshRuntimeResources( + deps: { + client: Pick; + sendToRenderer?(channel: string, payload: unknown): void; + onError?(error: unknown): void; + }, + sessionId: string, + resources: readonly { ref: string }[], +): Promise { + for (const resource of resources) { + try { + const update = await deps.client.getRuntimeResource(sessionId, resource.ref); + if (update) deps.sendToRenderer?.('shell-runs:update', update); + } catch (error) { + deps.onError?.(error); + } + } +} + interface RuntimeResourceIdentity { readonly sessionId: string; readonly ref: string; diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index 43c133a6b6..535379e60a 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -19,43 +19,71 @@ import { homedir } from 'node:os'; import { createHash, randomUUID } from 'node:crypto'; -import { realpath, stat } from 'node:fs/promises'; import { posix as pathPosix } from 'node:path'; import type { IpcMain } from 'electron'; import type { IPty } from 'node-pty'; import { spawn as spawnPty } from 'node-pty'; +import { terminateProcessTree } from '@maka/runtime/process-tree-terminator'; import { + activateRuntimeHostSshOperator, normalizeRuntimeHostSshDestination, openRuntimeHostSshTunnel, + type RuntimeHostSshOperatorActivationInput, type RuntimeHostSshProcess, type RuntimeHostSshProcessFactory, type RuntimeHostSshTunnel, type RuntimeHostSshTunnelInput, } from '@maka/runtime-host/client'; import { + decodeRuntimeHostActivationFrame, decodeRuntimeHostAccessManagementFrame, + decodeRuntimeHostPeerManagementFrame, + decodeRuntimeHostPeerMeshManagementFrame, decodeRuntimeHostServiceManagementFrame, decodeRuntimeHostSetupFrame, + RUNTIME_HOST_ACTIVATION_FRAME_MAX_BYTES, + RUNTIME_HOST_ACTIVATION_FRAME_PREFIX, RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, + RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV, + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, + RUNTIME_HOST_PEER_MANAGEMENT_FRAME_PREFIX, + RUNTIME_HOST_PEER_MESH_MANAGEMENT_FRAME_MAX_BYTES, + RUNTIME_HOST_PEER_MESH_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_SETUP_FRAME_PREFIX, + RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV, type RuntimeHostAccessManagementFrame, + type RuntimeHostActivationResult, + type RuntimeHostManagedUpdatePolicy, + type RuntimeHostPeerManagementAction, + type RuntimeHostPeerManagementFrame, + type RuntimeHostPeerMeshManagementAction, + type RuntimeHostPeerMeshManagementFrame, + type RuntimeHostOperatorCapability, type RuntimeHostServiceManagementAction, type RuntimeHostServiceManagementFrame, type RuntimeHostServiceUpdatePhase, type RuntimeHostSetupFrame, + type RuntimeHostWebRtcStunPolicy, } from '@maka/runtime-host/operator'; import type { DesktopRuntimeHostSshTerminalEvent, DesktopRuntimeHostSshTerminalSnapshot, } from '../preload/bridge-contract.js'; +import { createRuntimeHostFramedOutputFilter } from './runtime-host-framed-output.js'; +import { + runtimeHostSetupPackageVersion, + type DesktopRuntimeHostDevelopmentPeerTarget, + type DesktopRuntimeHostSetupPackage, +} from './runtime-host-setup-package.js'; interface ActiveTerminal { readonly sessionId: string; readonly pty: IPty; readonly exited: Promise; + readonly hasExited: () => boolean; revealTimer: ReturnType | undefined; phase: 'connecting' | 'connected'; revealed: boolean; @@ -64,11 +92,16 @@ interface ActiveTerminal { output: string; } +type DesktopRuntimeHostSshProcess = RuntimeHostSshProcess & { + readonly hasExited: () => boolean; +}; + const TERMINAL_REVEAL_DELAY_MS = 500; const TERMINAL_OUTPUT_MAX = 64 * 1024; const SETUP_FRAME_PENDING_MAX = 20 * 1024; const MANAGEMENT_FRAME_PENDING_MAX = 128 * 1024; const ACCESS_MANAGEMENT_FRAME_PENDING_MAX = 768 * 1024; +const PEER_MANAGEMENT_FRAME_PENDING_MAX = 128 * 1024; const SETUP_TIMEOUT_MS = 10 * 60_000; const MANAGEMENT_TIMEOUT_MS = 2 * 60_000; const PROCESS_STOP_GRACE_MS = 2_000; @@ -78,6 +111,14 @@ export interface DesktopRuntimeHostSshSetupInput { readonly sshPort?: number; readonly setupPackage: DesktopRuntimeHostSetupPackage; readonly principalId: string; + readonly lifecycle?: 'supervised' | 'on_demand'; + readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[]; + readonly signal?: AbortSignal; +} + +export interface DesktopRuntimeHostSshTargetInput { + readonly destination: string; + readonly sshPort?: number; readonly signal?: AbortSignal; } @@ -85,16 +126,24 @@ export interface DesktopRuntimeHostSshManagementInput { readonly destination: string; readonly sshPort?: number; readonly operatorPath: string; - readonly action: Exclude; + readonly action: Exclude< + RuntimeHostServiceManagementAction, + 'check_update' | 'update' | 'update_policy' | 'reconcile_update' + >; readonly expectedTarget: { readonly serviceId: string; readonly rootPath: string; readonly rootId: string; + readonly deploymentId?: string; }; readonly rootPath?: string; readonly websocketPort?: number; readonly websocketPath?: string; + readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[]; + readonly expectedConfigFingerprint?: string; + readonly allowInterruptActiveTasks?: boolean; readonly retainManagedDeployment?: boolean; + readonly capabilityRequest?: RuntimeHostOperatorCapability; readonly signal?: AbortSignal; } @@ -107,11 +156,55 @@ export interface DesktopRuntimeHostSshUpdateInput { readonly signal?: AbortSignal; } +export interface DesktopRuntimeHostSshUpdatePolicyInput { + readonly destination: string; + readonly sshPort?: number; + readonly operatorPath: string; + readonly policy?: RuntimeHostManagedUpdatePolicy; + readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget']; + readonly signal?: AbortSignal; +} + +export interface DesktopRuntimeHostSshUpdateReconciliationInput { + readonly destination: string; + readonly sshPort?: number; + readonly operatorPath: string; + readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget']; + readonly signal?: AbortSignal; +} + +export interface DesktopRuntimeHostSshPeerManagementInput { + readonly destination: string; + readonly sshPort?: number; + readonly operatorPath: string; + readonly action: Extract; + readonly coordinationRelays?: readonly string[]; + readonly automaticRelayDiscovery?: boolean; + readonly webRtcStunPolicy?: RuntimeHostWebRtcStunPolicy; + readonly webRtcStunStatus?: boolean; + readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget']; + readonly signal?: AbortSignal; +} + +export interface DesktopRuntimeHostSshPeerMeshManagementInput { + readonly destination: string; + readonly sshPort?: number; + readonly operatorPath: string; + readonly action: RuntimeHostPeerMeshManagementAction; + readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget']; + readonly meshId?: string | null; + readonly peerId?: string; + readonly displayName?: string | null; + readonly invitation?: string; + readonly signal?: AbortSignal; +} + export interface DesktopRuntimeHostSshCleanupInput { readonly destination: string; readonly sshPort?: number; readonly operatorPath: string; readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget']; + readonly finalize?: boolean; readonly signal?: AbortSignal; } @@ -135,19 +228,21 @@ export type DesktopRuntimeHostSshAccessInput = DesktopRuntimeHostSshAccessTarget } ); -export type DesktopRuntimeHostSetupPackage = - | { readonly kind: 'npm'; readonly specifier: string } - | { readonly kind: 'development_archive'; readonly path: string }; - export type RuntimeHostServiceUpdateTerminalFrame = | Extract | (Extract & { readonly action: 'update'; }); -export function isExactRuntimeHostSetupPackageSpecifier(value: unknown): value is string { - return typeof value === 'string' && /^maka-agent@[0-9][0-9A-Za-z.+-]*$/u.test(value); -} +export type RuntimeHostServiceUpdatePolicyTerminalFrame = Extract< + RuntimeHostServiceManagementFrame, + { kind: 'result' | 'error'; action: 'update_policy' } +>; + +export type RuntimeHostServiceUpdateReconciliationTerminalFrame = Extract< + RuntimeHostServiceManagementFrame, + { kind: 'result' | 'error'; action: 'reconcile_update' } +>; type RuntimeHostSetupCompleteFrame = Extract; @@ -156,11 +251,19 @@ export function createDesktopRuntimeHostSshTerminal(input: { readonly send: (channel: string, event: DesktopRuntimeHostSshTerminalEvent) => void; readonly spawnPty?: typeof spawnPty; readonly openSshTunnel?: typeof openRuntimeHostSshTunnel; + readonly activateSshOperator?: typeof activateRuntimeHostSshOperator; readonly revealDelayMs?: number; readonly managementTimeoutMs?: number; readonly processStopGraceMs?: number; + readonly terminateProcessTree?: typeof terminateProcessTree; }): { + activateSshOperator( + input: RuntimeHostSshOperatorActivationInput, + ): Promise; openSshTunnel(input: RuntimeHostSshTunnelInput): Promise; + resolveDevelopmentPeerTarget( + input: DesktopRuntimeHostSshTargetInput, + ): Promise>; runSetup( input: DesktopRuntimeHostSshSetupInput, onProgress: (frame: Extract) => void, @@ -173,9 +276,22 @@ export function createDesktopRuntimeHostSshTerminal(input: { input: DesktopRuntimeHostSshUpdateInput, onProgress: (phase: RuntimeHostServiceUpdatePhase) => void, ): Promise; + runUpdatePolicy( + input: DesktopRuntimeHostSshUpdatePolicyInput, + ): Promise; + runUpdateReconciliation( + input: DesktopRuntimeHostSshUpdateReconciliationInput, + onProgress: (phase: RuntimeHostServiceUpdatePhase) => void, + ): Promise; runAccessManagement( input: DesktopRuntimeHostSshAccessInput, ): Promise; + runPeerManagement( + input: DesktopRuntimeHostSshPeerManagementInput, + ): Promise; + runPeerMeshManagement( + input: DesktopRuntimeHostSshPeerMeshManagementInput, + ): Promise>; cleanupManagedDeployment(input: DesktopRuntimeHostSshCleanupInput): Promise; close(): Promise; } { @@ -238,7 +354,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { args: readonly string[], transformOutput: (data: string) => string = (data) => data, successfulExitCompletes = false, - ): { readonly process: RuntimeHostSshProcess; readonly terminal: ActiveTerminal } => { + ): { readonly process: DesktopRuntimeHostSshProcess; readonly terminal: ActiveTerminal } => { if (closed) throw new Error('Runtime Host SSH terminal is closed'); if (active) throw new Error('Another Runtime Host SSH terminal is already active'); const sessionId = randomUUID(); @@ -259,10 +375,13 @@ export function createDesktopRuntimeHostSshTerminal(input: { }>((resolve) => { resolveExit = resolve; }); + let processExited = false; + const hasExited = () => processExited; const terminal: ActiveTerminal = { sessionId, pty, exited: exited.then(() => undefined), + hasExited, revealTimer: undefined, phase: 'connecting', revealed: false, @@ -305,6 +424,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { }); }); pty.onExit(({ exitCode, signal }) => { + processExited = true; if (terminal.revealTimer !== undefined) clearTimeout(terminal.revealTimer); if (successfulExitCompletes && exitCode === 0) completePresentation(terminal); if (active === terminal) active = undefined; @@ -338,7 +458,8 @@ export function createDesktopRuntimeHostSshTerminal(input: { // The exit event is the authority; a concurrent exit makes kill a no-op. } }, - } satisfies RuntimeHostSshProcess; + hasExited, + } satisfies DesktopRuntimeHostSshProcess; return { process, terminal }; }; const spawnProcess: RuntimeHostSshProcessFactory = ({ executable, args, interaction }) => { @@ -391,7 +512,11 @@ export function createDesktopRuntimeHostSshTerminal(input: { return; } dismissPresentation(terminal); - await terminateActiveTerminal(terminal, input.processStopGraceMs); + await terminateActiveTerminal( + terminal, + input.processStopGraceMs, + input.terminateProcessTree, + ); }); const runFramedManagement = async (options: { @@ -406,6 +531,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { readonly frameAction: (frame: Frame) => string; readonly isTerminalFrame?: (frame: Frame) => boolean; readonly onProgress?: (frame: Frame) => void; + readonly inputLine?: string; readonly label: string; readonly timeoutMs?: number; }): Promise => { @@ -417,7 +543,13 @@ export function createDesktopRuntimeHostSshTerminal(input: { let failure: Error | undefined; let activeTerminal: ActiveTerminal | undefined; let receivedProgress = false; - const filter = createFramedOutputFilter({ + let inputSent = false; + const sendInput = () => { + if (inputSent || options.inputLine === undefined || !activeTerminal) return; + inputSent = true; + activeTerminal.pty.write(`${options.inputLine}\r`); + }; + const filter = createRuntimeHostFramedOutputFilter({ prefix: options.prefix, pendingMaxBytes: options.pendingMaxBytes, decode: options.decode, @@ -432,6 +564,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { receivedProgress = true; if (activeTerminal) suppressPresentation(activeTerminal); options.onProgress?.(next); + sendInput(); return; } if (frame) { @@ -452,6 +585,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { true, ); activeTerminal = terminal; + if (receivedProgress) sendInput(); if (frame) completePresentation(terminal); else if (receivedProgress) suppressPresentation(terminal); const wait = await waitForTerminalProcess(process, { @@ -459,7 +593,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { timeoutMs: options.timeoutMs ?? input.managementTimeoutMs ?? MANAGEMENT_TIMEOUT_MS, stopGraceMs: input.processStopGraceMs, onAbort: () => dismissPresentation(terminal), - }); + }, input.terminateProcessTree); filter.finish(); if (failure) throw failure; if (!frame) { @@ -476,6 +610,27 @@ export function createDesktopRuntimeHostSshTerminal(input: { }; return { + activateSshOperator: async (activationInput) => { + if (activationInput.interaction !== 'terminal') { + return (input.activateSshOperator ?? activateRuntimeHostSshOperator)(activationInput); + } + const frame = await runFramedManagement({ + ...activationInput, + remoteCommand: runtimeHostActivationRemoteCommand(activationInput), + prefix: RUNTIME_HOST_ACTIVATION_FRAME_PREFIX, + pendingMaxBytes: RUNTIME_HOST_ACTIVATION_FRAME_MAX_BYTES, + decode: decodeRuntimeHostActivationFrame, + action: 'activate', + frameAction: () => 'activate', + label: 'Remote Runtime Host activation', + timeoutMs: activationInput.timeoutMs, + }); + if (frame.kind === 'error') throw new Error(frame.error.message); + if (frame.rootId !== activationInput.rootId) { + throw new Error('Remote Runtime Host activation returned an inconsistent root'); + } + return frame; + }, openSshTunnel: async (tunnelInput) => { if (closed) throw new Error('Runtime Host SSH terminal is closed'); const openSshTunnel = input.openSshTunnel ?? openRuntimeHostSshTunnel; @@ -488,6 +643,66 @@ export function createDesktopRuntimeHostSshTerminal(input: { } return tunnel; }, + resolveDevelopmentPeerTarget: async (targetInput) => { + if (closed) throw new Error('Runtime Host SSH terminal is closed'); + targetInput.signal?.throwIfAborted(); + const destination = normalizeRuntimeHostSshDestination(targetInput.destination); + const sshPort = targetInput.sshPort === undefined + ? undefined + : requireSetupPort(targetInput.sshPort); + const marker = `__MAKA_RUNTIME_HOST_TARGET_${randomUUID().replaceAll('-', '')}__`; + const remoteCommand = `printf '${marker}%s:%s\\n' "$(uname -s)" "$(uname -m)"`; + let target: Exclude | undefined; + let failure: Error | undefined; + const filter = createRuntimeHostFramedOutputFilter({ + prefix: marker, + pendingMaxBytes: 256, + decode: (line) => line.slice(marker.length).replaceAll('\r', '').trimEnd(), + label: 'Remote Runtime Host target detection', + onFrame: (identity) => { + if (target) { + failure = new Error('Remote Runtime Host target detection returned multiple results'); + return; + } + const [system, machine, ...extra] = identity.split(':'); + if (!system || !machine || extra.length > 0) { + failure = new Error('Remote Runtime Host target detection returned an invalid result'); + return; + } + try { + target = runtimeHostDevelopmentPeerTargetFromUname(system, machine); + } catch (error) { + failure = error instanceof Error ? error : new Error(String(error)); + } + }, + onError: (error) => { + failure = error; + }, + }); + const { process, terminal } = startTerminalProcess( + 'ssh', + sshRemoteCommandArgs(destination, sshPort, remoteCommand), + filter.push, + true, + ); + const wait = await waitForTerminalProcess(process, { + signal: targetInput.signal, + timeoutMs: input.managementTimeoutMs ?? MANAGEMENT_TIMEOUT_MS, + stopGraceMs: input.processStopGraceMs, + onAbort: () => dismissPresentation(terminal), + }, input.terminateProcessTree); + if (wait.timedOut) throw new Error('Remote Runtime Host target detection timed out'); + if (wait.exit.code !== 0) { + throw new Error( + `Remote Runtime Host target detection exited with code ${String(wait.exit.code)}`, + ); + } + filter.finish(); + if (failure) throw failure; + completePresentation(terminal); + if (!target) throw new Error('Remote Runtime Host target detection returned no result'); + return target; + }, runSetup: async (setupInput, onProgress, onComplete) => { if (closed) throw new Error('Runtime Host SSH terminal is closed'); setupInput.signal?.throwIfAborted(); @@ -506,12 +721,13 @@ export function createDesktopRuntimeHostSshTerminal(input: { cancellation.signal, input.processStopGraceMs, dismissPresentation, + input.terminateProcessTree, ); const remoteCommand = runtimeHostSetupRemoteCommand(setupPackage, setupInput); let complete: RuntimeHostSetupCompleteFrame | undefined; let setupFailure: Error | undefined; let setupTerminal: ActiveTerminal | undefined; - const filter = createFramedOutputFilter({ + const filter = createRuntimeHostFramedOutputFilter({ prefix: RUNTIME_HOST_SETUP_FRAME_PREFIX, pendingMaxBytes: SETUP_FRAME_PENDING_MAX, decode: decodeRuntimeHostSetupFrame, @@ -541,7 +757,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { timeoutMs: SETUP_TIMEOUT_MS, stopGraceMs: input.processStopGraceMs, onAbort: () => dismissPresentation(terminal), - }); + }, input.terminateProcessTree); filter.finish(); if (setupFailure) throw setupFailure; if (!complete) { @@ -593,6 +809,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { updateInput.signal, input.processStopGraceMs, dismissPresentation, + input.terminateProcessTree, ); const frame = await runFramedManagement({ ...updateInput, @@ -617,6 +834,41 @@ export function createDesktopRuntimeHostSshTerminal(input: { } throw new Error('Remote Runtime Host update returned an invalid result'); }, + runUpdatePolicy: async (policyInput) => { + const frame = await runFramedManagement({ + ...policyInput, + remoteCommand: runtimeHostUpdatePolicyRemoteCommand(policyInput), + prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, + pendingMaxBytes: MANAGEMENT_FRAME_PENDING_MAX, + decode: decodeRuntimeHostServiceManagementFrame, + action: 'update_policy', + frameAction: (candidate) => candidate.action, + label: 'Remote Runtime Host update policy', + }); + if (frame.kind === 'result' && frame.action === 'update_policy') return frame; + if (frame.kind === 'error' && frame.action === 'update_policy') return frame; + throw new Error('Remote Runtime Host update policy returned an invalid result'); + }, + runUpdateReconciliation: async (reconciliationInput, onProgress) => { + const frame = await runFramedManagement({ + ...reconciliationInput, + remoteCommand: runtimeHostUpdateReconciliationRemoteCommand(reconciliationInput), + prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, + pendingMaxBytes: MANAGEMENT_FRAME_PENDING_MAX, + decode: decodeRuntimeHostServiceManagementFrame, + action: 'reconcile_update', + frameAction: (candidate) => candidate.action, + isTerminalFrame: (candidate) => candidate.kind !== 'progress', + onProgress: (candidate) => { + if (candidate.kind === 'progress') onProgress(candidate.phase); + }, + label: 'Remote Runtime Host update reconciliation', + timeoutMs: SETUP_TIMEOUT_MS, + }); + if (frame.kind === 'result' && frame.action === 'reconcile_update') return frame; + if (frame.kind === 'error' && frame.action === 'reconcile_update') return frame; + throw new Error('Remote Runtime Host update reconciliation returned an invalid result'); + }, runAccessManagement: (accessInput) => runFramedManagement({ ...accessInput, @@ -628,6 +880,35 @@ export function createDesktopRuntimeHostSshTerminal(input: { frameAction: (frame) => frame.action, label: 'Remote Runtime Host access management', }), + runPeerManagement: (peerInput) => + runFramedManagement({ + ...peerInput, + remoteCommand: runtimeHostPeerManagementRemoteCommand(peerInput), + prefix: RUNTIME_HOST_PEER_MANAGEMENT_FRAME_PREFIX, + pendingMaxBytes: PEER_MANAGEMENT_FRAME_PENDING_MAX, + decode: decodeRuntimeHostPeerManagementFrame, + action: peerInput.action, + frameAction: (frame) => frame.action, + label: 'Remote Runtime Host direct-peer management', + }), + runPeerMeshManagement: async (meshInput) => { + const frame = await runFramedManagement({ + ...meshInput, + remoteCommand: runtimeHostPeerMeshManagementRemoteCommand(meshInput), + prefix: RUNTIME_HOST_PEER_MESH_MANAGEMENT_FRAME_PREFIX, + pendingMaxBytes: RUNTIME_HOST_PEER_MESH_MANAGEMENT_FRAME_MAX_BYTES, + decode: decodeRuntimeHostPeerMeshManagementFrame, + action: meshInput.action, + frameAction: (candidate) => candidate.action, + isTerminalFrame: (candidate) => candidate.kind !== 'input', + ...(meshInput.invitation ? { inputLine: meshInput.invitation } : {}), + label: 'Remote Runtime Host Peer Mesh management', + }); + if (frame.kind === 'input') { + throw new Error('Remote Runtime Host Peer Mesh management ended before its result'); + } + return frame; + }, cleanupManagedDeployment: async (cleanupInput) => { if (closed) throw new Error('Runtime Host SSH terminal is closed'); cleanupInput.signal?.throwIfAborted(); @@ -650,7 +931,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { timeoutMs: input.managementTimeoutMs ?? MANAGEMENT_TIMEOUT_MS, stopGraceMs: input.processStopGraceMs, onAbort: () => dismissPresentation(terminal), - }); + }, input.terminateProcessTree); if (wait.timedOut) { throw new Error('Remote Runtime Host deployment cleanup timed out'); } @@ -670,11 +951,30 @@ export function createDesktopRuntimeHostSshTerminal(input: { presentation = undefined; terminal.dismissed = true; if (terminal.revealTimer !== undefined) clearTimeout(terminal.revealTimer); - await terminateActiveTerminal(terminal, input.processStopGraceMs).catch(() => undefined); + await terminateActiveTerminal( + terminal, + input.processStopGraceMs, + input.terminateProcessTree, + ).catch(() => undefined); }, }; } +export function runtimeHostDevelopmentPeerTargetFromUname( + system: string, + machine: string, +): Exclude { + const normalizedMachine = machine.toLowerCase(); + if (system === 'Darwin' && normalizedMachine === 'arm64') return 'darwin-arm64'; + if (system === 'Linux') { + if (normalizedMachine === 'x86_64') return 'linux-x64'; + if (normalizedMachine === 'aarch64' || normalizedMachine === 'arm64') { + return 'linux-arm64'; + } + } + throw new Error(`Direct peer is not available on ${system}/${machine}`); +} + function cancellableUntilComplete(signal: AbortSignal | undefined): { readonly signal: AbortSignal; commit(): boolean; @@ -700,87 +1000,14 @@ function cancellableUntilComplete(signal: AbortSignal | undefined): { }; } -function createFramedOutputFilter(input: { - readonly prefix: string; - readonly pendingMaxBytes: number; - readonly decode: (line: string) => Frame | undefined; - readonly label: string; - readonly onFrame: (frame: Frame) => void; - readonly onError: (error: Error) => void; -}): { push(data: string): string; finish(): string } { - let pending = ''; - let discardReservedLine = false; - const drain = (finished: boolean): string => { - let visible = ''; - while (pending) { - if (discardReservedLine) { - const newline = pending.indexOf('\n'); - if (newline < 0) { - pending = ''; - break; - } - pending = pending.slice(newline + 1); - discardReservedLine = false; - continue; - } - const marker = pending.indexOf(input.prefix); - if (marker >= 0) { - visible += pending.slice(0, marker); - pending = pending.slice(marker); - const newline = pending.indexOf('\n'); - if (newline < 0) { - if (finished) { - input.onError(new Error(`${input.label} returned an incomplete result`)); - pending = ''; - } else if (pending.length > input.pendingMaxBytes) { - input.onError(new Error(`${input.label} returned an oversized result`)); - pending = ''; - discardReservedLine = true; - } - break; - } - const line = pending.slice(0, newline + 1); - pending = pending.slice(newline + 1); - const frame = input.decode(line); - if (frame) input.onFrame(frame); - else input.onError(new Error(`${input.label} returned an invalid result`)); - continue; - } - if (finished) { - visible += pending; - pending = ''; - break; - } - const retained = markerSuffixLength(pending, input.prefix); - visible += pending.slice(0, pending.length - retained); - pending = pending.slice(pending.length - retained); - break; - } - return visible; - }; - return { - push(data) { - pending += data; - return drain(false); - }, - finish() { - return drain(true); - }, - }; -} - -function markerSuffixLength(value: string, prefix: string): number { - const limit = Math.min(value.length, prefix.length - 1); - for (let length = limit; length > 0; length -= 1) { - if (prefix.startsWith(value.slice(-length))) return length; - } - return 0; -} - -interface PreparedSetupPackage { - readonly specifier: string; - readonly removeAfterSetup?: string; -} +type PreparedSetupPackage = + | { readonly kind: 'npm'; readonly specifier: string } + | { + readonly kind: 'development_archive'; + readonly specifier: string; + readonly integrity: string; + readonly removeAfterSetup: string; + }; async function prepareSetupPackage( setupPackage: DesktopRuntimeHostSetupPackage, @@ -792,22 +1019,16 @@ async function prepareSetupPackage( args: readonly string[], transformOutput?: (data: string) => string, successfulExitCompletes?: boolean, - ) => { readonly process: RuntimeHostSshProcess; readonly terminal: ActiveTerminal }, + ) => { readonly process: DesktopRuntimeHostSshProcess; readonly terminal: ActiveTerminal }, signal: AbortSignal | undefined, stopGraceMs: number | undefined, dismissPresentation: (terminal: ActiveTerminal) => void, + terminateTree: typeof terminateProcessTree | undefined, ): Promise { if (setupPackage.kind === 'npm') { - if (!isExactRuntimeHostSetupPackageSpecifier(setupPackage.specifier)) { - throw new Error('Runtime Host setup package is invalid'); - } - return { specifier: setupPackage.specifier }; + return setupPackage; } signal?.throwIfAborted(); - const archive = await realpath(setupPackage.path); - if (!(await stat(archive)).isFile() || !archive.endsWith('.tgz')) { - throw new Error('Runtime Host development package must be a .tgz file'); - } const remoteArchive = remoteDevelopmentArchivePath(principalId); const { process, terminal } = startTerminalProcess('scp', [ '-o', @@ -821,7 +1042,7 @@ async function prepareSetupPackage( '-o', 'ClearAllForwardings=yes', ...(sshPort === undefined ? [] : ['-P', String(sshPort)]), - archive, + setupPackage.path, `${destination}:${remoteArchive}`, ], undefined, true); const wait = await waitForTerminalProcess(process, { @@ -829,7 +1050,7 @@ async function prepareSetupPackage( timeoutMs: SETUP_TIMEOUT_MS, stopGraceMs, onAbort: () => dismissPresentation(terminal), - }); + }, terminateTree); if (wait.timedOut) { throw new Error('Uploading the Runtime Host development package timed out'); } @@ -838,7 +1059,12 @@ async function prepareSetupPackage( `Uploading the Runtime Host development package exited with code ${String(wait.exit.code)}`, ); } - return { specifier: remoteArchive, removeAfterSetup: remoteArchive }; + return { + kind: 'development_archive', + specifier: remoteArchive, + integrity: setupPackage.integrity, + removeAfterSetup: remoteArchive, + }; } function remoteDevelopmentArchivePath(principalId: string): string { @@ -848,13 +1074,14 @@ function remoteDevelopmentArchivePath(principalId: string): string { } async function waitForTerminalProcess( - process: RuntimeHostSshProcess, + process: DesktopRuntimeHostSshProcess, input: { readonly signal?: AbortSignal; readonly timeoutMs: number; readonly stopGraceMs?: number; readonly onAbort?: () => void; }, + terminateTree: typeof terminateProcessTree = terminateProcessTree, ): Promise<{ readonly exit: Awaited; readonly timedOut: boolean; @@ -877,7 +1104,7 @@ async function waitForTerminalProcess( process.exited, stopRequested.then(async (reason) => { if (reason === 'aborted') input.onAbort?.(); - await terminateTerminalProcess(process, input.stopGraceMs); + await terminateTerminalProcess(process, input.stopGraceMs, terminateTree); return process.exited; }), ]); @@ -890,16 +1117,36 @@ async function waitForTerminalProcess( } async function terminateTerminalProcess( - process: Pick, + process: Pick, graceMs = PROCESS_STOP_GRACE_MS, + terminateTree: typeof terminateProcessTree = terminateProcessTree, ): Promise { - process.kill('SIGTERM'); + await signalTerminalProcess(process, 'SIGTERM', terminateTree); if (await settlesWithin(process.exited, graceMs)) return; - process.kill('SIGKILL'); + await signalTerminalProcess(process, 'SIGKILL', terminateTree); if (await settlesWithin(process.exited, graceMs)) return; throw new Error('SSH process did not exit after forced termination'); } +async function signalTerminalProcess( + process: Pick, + signal: 'SIGTERM' | 'SIGKILL', + terminateTree: typeof terminateProcessTree, +): Promise { + const fallback = () => process.kill(signal); + if (process.pid === undefined) { + fallback(); + return; + } + await terminateTree({ + pid: process.pid, + signal, + fallback, + hasExited: process.hasExited, + beforeSignal: () => !process.hasExited(), + }); +} + async function settlesWithin(promise: Promise, timeoutMs: number): Promise { let timeout: ReturnType | undefined; try { @@ -919,7 +1166,10 @@ async function settlesWithin(promise: Promise, timeoutMs: number): Prom function runtimeHostSetupRemoteCommand( setupPackage: PreparedSetupPackage, - input: Pick, + input: Pick< + DesktopRuntimeHostSshSetupInput, + 'principalId' | 'projectDirectoryRoots' | 'lifecycle' + >, ): string { if (!/^[A-Za-z0-9_.:-]{1,128}$/u.test(input.principalId)) { throw new Error('Runtime Host setup principal is invalid'); @@ -931,11 +1181,40 @@ function runtimeHostSetupRemoteCommand( input.principalId, '--preset', 'desktop-client', + '--lifecycle', + input.lifecycle === 'on_demand' ? 'on-demand' : 'supervised', + // Development archives identify every source revision as a distinct exact + // package. Re-running Add computer is the explicit replacement gesture in + // that environment; released packages keep using the normal update UI. + ...(setupPackage.kind === 'development_archive' ? ['--update-existing'] : []), '--defer-pairing-commit', + ...(input.projectDirectoryRoots === undefined + ? [] + : input.projectDirectoryRoots.length === 0 + ? ['--no-project-roots'] + : input.projectDirectoryRoots.flatMap(({ label, path }) => [ + '--project-root-json', + JSON.stringify({ label, path }), + ])), '--json', ]); } +function runtimeHostActivationRemoteCommand( + input: RuntimeHostSshOperatorActivationInput, +): string { + if (!pathPosix.isAbsolute(input.operatorPath)) { + throw new Error('Runtime Host operator path must be absolute'); + } + return [ + input.operatorPath, + 'activate', + '--framed', + '--root-id', + input.rootId, + ].map(quotePosix).join(' '); +} + function runtimeHostServiceManagementRemoteCommand( input: DesktopRuntimeHostSshManagementInput, ): string { @@ -948,12 +1227,25 @@ function runtimeHostServiceManagementRemoteCommand( ? [] : ['--websocket-port', String(input.websocketPort)]), ...(input.websocketPath ? ['--websocket-path', input.websocketPath] : []), + ...(input.projectDirectoryRoots === undefined + ? [] + : input.projectDirectoryRoots.length === 0 + ? ['--no-project-roots'] + : input.projectDirectoryRoots.flatMap(({ label, path }) => [ + '--project-root-json', + JSON.stringify({ label, path }), + ])), + ...(input.expectedConfigFingerprint + ? ['--expected-config-fingerprint', input.expectedConfigFingerprint] + : []), + ...(input.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), ...(input.retainManagedDeployment ? ['--retain-managed-deployment'] : []), ...managedServiceTargetArgs(input.expectedTarget), ].map(quotePosix).join(' '); const invocation = + `${RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV}=1 ` + `${RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV}=` + - `${quotePosix(RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY)} exec ${command}`; + `${quotePosix(input.capabilityRequest ?? RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY)} exec ${command}`; return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`; } @@ -961,6 +1253,10 @@ function runtimeHostUpdateRemoteCommand( setupPackage: PreparedSetupPackage, input: DesktopRuntimeHostSshUpdateInput, ): string { + if (!input.expectedTarget.deploymentId) { + throw new Error('Runtime Host update requires a deployment generation'); + } + const targetVersion = runtimeHostSetupPackageVersion(setupPackage); return runtimeHostPackageRemoteCommand( setupPackage, [ @@ -968,16 +1264,58 @@ function runtimeHostUpdateRemoteCommand( 'service', 'update', '--framed', + ...(targetVersion ? ['--target', targetVersion] : []), + '--managed-root-id', + input.expectedTarget.rootId, ...managedServiceTargetArgs(input.expectedTarget), ...(input.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), ], { [RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]: RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + [RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV]: '1', }, ); } +function runtimeHostUpdatePolicyRemoteCommand( + input: DesktopRuntimeHostSshUpdatePolicyInput, +): string { + const policy = input.policy; + const target = policy === undefined + ? [] + : ['--target', policy.kind === 'channel' ? policy.channel : policy.kind === 'fixed' ? policy.version : 'manual']; + const command = [ + input.operatorPath, + 'update-policy', + '--framed', + ...target, + ...managedServiceTargetArgs(input.expectedTarget), + ].map(quotePosix).join(' '); + const invocation = + `${RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV}=` + + `${quotePosix(RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY)} exec ${command}`; + return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`; +} + +function runtimeHostUpdateReconciliationRemoteCommand( + input: DesktopRuntimeHostSshUpdateReconciliationInput, +): string { + const command = [ + input.operatorPath, + 'reconcile-update', + '--framed', + ...managedServiceTargetArgs(input.expectedTarget), + ] + .map(quotePosix) + .join(' '); + const invocation = + `${RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV}=1 ` + + `${RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV}=` + + `${quotePosix(RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY)} exec ${command}`; + return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`; +} + function runtimeHostAccessManagementRemoteCommand( input: DesktopRuntimeHostSshAccessInput, ): string { @@ -1003,6 +1341,62 @@ function runtimeHostAccessManagementRemoteCommand( return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(`exec ${command}`)}`; } +function runtimeHostPeerManagementRemoteCommand( + input: DesktopRuntimeHostSshPeerManagementInput, +): string { + const command = [ + input.operatorPath, + 'peer', + input.action, + '--framed', + '--relay-discovery-status', + ...(input.webRtcStunStatus ? ['--webrtc-stun-status'] : []), + ...(input.action === 'enable' && input.coordinationRelays + ? input.coordinationRelays.length === 0 + ? ['--clear-coordination-relays'] + : input.coordinationRelays.flatMap((relay) => ['--coordination-relay', relay]) + : []), + ...(input.action === 'enable' && input.automaticRelayDiscovery !== undefined + ? [input.automaticRelayDiscovery + ? '--automatic-relay-discovery' + : '--no-automatic-relay-discovery'] + : []), + ...(input.action === 'enable' && input.webRtcStunPolicy + ? input.webRtcStunPolicy.kind === 'default' + ? ['--default-public-stun'] + : input.webRtcStunPolicy.kind === 'disabled' + ? ['--no-public-stun'] + : input.webRtcStunPolicy.urls.flatMap((url) => ['--webrtc-stun', url]) + : []), + ...managedServiceTargetArgs(input.expectedTarget), + ].map(quotePosix).join(' '); + return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(`exec ${command}`)}`; +} + +function runtimeHostPeerMeshManagementRemoteCommand( + input: DesktopRuntimeHostSshPeerMeshManagementInput, +): string { + const command = [ + input.operatorPath, + 'mesh', + input.action, + '--framed', + ...(typeof input.meshId === 'string' + ? ['--mesh', input.meshId] + : input.meshId === null + ? ['--off'] + : []), + ...(input.peerId ? ['--peer', input.peerId] : []), + ...(input.displayName === null + ? ['--clear-name'] + : input.displayName + ? ['--name', input.displayName] + : []), + ...managedServiceTargetArgs(input.expectedTarget), + ].map(quotePosix).join(' '); + return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(`exec ${command}`)}`; +} + function runtimeHostManagedDeploymentCleanupRemoteCommand( input: DesktopRuntimeHostSshCleanupInput, ): string { @@ -1011,12 +1405,13 @@ function runtimeHostManagedDeploymentCleanupRemoteCommand( const cleanup = [ input.operatorPath, '__cleanup-managed-deployment', + ...(input.finalize ? ['--finalize'] : []), ...managedServiceTargetArgs(input.expectedTarget), ].map(quotePosix).join(' '); const invocation = `if [ ! -e ${operator} ]; then ` + `if [ ! -e ${deploymentRoot} ]; then exit 0; fi; ` + - `exec rmdir -- ${deploymentRoot}; fi; ` + + `exit 1; fi; ` + `exec ${cleanup}`; return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`; } @@ -1025,11 +1420,13 @@ function managedServiceTargetArgs(input: { readonly serviceId: string; readonly rootPath: string; readonly rootId: string; + readonly deploymentId?: string; }): string[] { return [ '--expected-service-id', input.serviceId, '--expected-root-path', input.rootPath, '--expected-root-id', input.rootId, + ...(input.deploymentId ? ['--expected-deployment-id', input.deploymentId] : []), ]; } @@ -1039,14 +1436,19 @@ function runtimeHostPackageRemoteCommand( environment: Readonly> = {}, ): string { const commandArgs = ['maka', ...args].map(quotePosix).join(' '); - const environmentPrefix = Object.entries(environment) + const environmentPrefix = Object.entries({ + ...environment, + ...(setupPackage.kind === 'development_archive' + ? { [RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV]: setupPackage.integrity } + : {}), + }) .map(([name, value]) => `${name}=${quotePosix(value)}`) .join(' '); const invocationPrefix = environmentPrefix ? `${environmentPrefix} ` : ''; - const commandInvocation = setupPackage.removeAfterSetup + const commandInvocation = setupPackage.kind === 'development_archive' ? `${invocationPrefix}npx --yes --package ${quotePosix(setupPackage.specifier)} ${commandArgs}` : `${invocationPrefix}npx --yes --prefix "$maka_command_prefix" --package ${quotePosix(setupPackage.specifier)} ${commandArgs}`; - const command = setupPackage.removeAfterSetup + const command = setupPackage.kind === 'development_archive' ? `cd "$HOME" || exit 1; maka_command_exit=0; ${commandInvocation} || maka_command_exit=$?; rm -f -- ${quotePosix(setupPackage.removeAfterSetup)}; exit "$maka_command_exit"` : `maka_command_prefix=$(mktemp -d) || exit 1; trap 'rm -rf -- "$maka_command_prefix"' EXIT; trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM; cd "$maka_command_prefix" || exit 1; ${commandInvocation}`; const loginCommand = `exec /bin/sh -c ${quotePosix(command)}`; @@ -1106,10 +1508,13 @@ function findActive( function terminateActiveTerminal( terminal: ActiveTerminal, graceMs: number | undefined, + terminateTree: typeof terminateProcessTree | undefined, ): Promise { return terminateTerminalProcess( { + pid: terminal.pty.pid, exited: terminal.exited.then(() => ({ code: null, signal: null })), + hasExited: terminal.hasExited, kill: (signal) => { try { terminal.pty.kill(signal); @@ -1119,6 +1524,7 @@ function terminateActiveTerminal( }, }, graceMs, + terminateTree, ); } diff --git a/apps/desktop/src/main/runtime-host-startup-recovery.ts b/apps/desktop/src/main/runtime-host-startup-recovery.ts new file mode 100644 index 0000000000..3f6b282986 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-startup-recovery.ts @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { RuntimeHostStartupError } from "@maka/runtime-host/client"; + +export type DesktopRuntimeHostStartupRepairResult = + | { readonly kind: "repaired" } + | { readonly kind: "active_tasks" } + | { readonly kind: "unavailable" }; + +export interface DesktopRuntimeHostStartupRecoveryPrompt { + readonly startupError: Error; + readonly repairError?: Error; + readonly activeTasks: boolean; +} + +export interface DesktopRuntimeHostStartupRepairAuthority { + readonly allowManualUpdate: boolean; + readonly allowInterruptActiveTasks: boolean; +} + +export class DesktopRuntimeHostStartupRecoveryCancelledError extends Error { + readonly name = "DesktopRuntimeHostStartupRecoveryCancelledError"; + + constructor(options?: ErrorOptions) { + super("Runtime Host startup recovery was cancelled", options); + } +} + +export async function startDesktopRuntimeHostWithRecovery(input: { + readonly start: () => Promise; + readonly repair: ( + authority: DesktopRuntimeHostStartupRepairAuthority, + ) => Promise; + readonly prompt: ( + input: DesktopRuntimeHostStartupRecoveryPrompt, + ) => Promise<"repair" | "exit">; +}): Promise { + let startupError: Error; + try { + return await input.start(); + } catch (error) { + startupError = asError(error); + if (!canRepairManagedRuntimeHostStartup(startupError)) throw startupError; + } + + let activeTasks = false; + let repairError: Error | undefined; + let automatic: DesktopRuntimeHostStartupRepairResult | undefined; + try { + automatic = await input.repair({ + allowManualUpdate: false, + allowInterruptActiveTasks: false, + }); + } catch (error) { + repairError = asError(error); + } + if (automatic?.kind === "unavailable") throw startupError; + if (automatic?.kind === "active_tasks") activeTasks = true; + if (automatic?.kind === "repaired") { + try { + return await input.start(); + } catch (error) { + startupError = asError(error); + if (!canRepairManagedRuntimeHostStartup(startupError)) throw startupError; + } + } + + for (;;) { + const decision = await input.prompt({ + startupError, + ...(repairError ? { repairError } : {}), + activeTasks, + }); + if (decision === "exit") { + throw new DesktopRuntimeHostStartupRecoveryCancelledError({ + cause: startupError, + }); + } + + let repaired: DesktopRuntimeHostStartupRepairResult; + try { + repaired = await input.repair({ + allowManualUpdate: true, + allowInterruptActiveTasks: activeTasks, + }); + } catch (error) { + repairError = asError(error); + continue; + } + if (repaired.kind === "unavailable") throw startupError; + if (repaired.kind === "active_tasks") { + activeTasks = true; + repairError = undefined; + continue; + } + try { + return await input.start(); + } catch (error) { + startupError = asError(error); + if (!canRepairManagedRuntimeHostStartup(startupError)) throw startupError; + repairError = undefined; + } + } +} + +export function canRepairManagedRuntimeHostStartup(error: Error): boolean { + return ( + error instanceof RuntimeHostStartupError && + (error.reason === "managed_root_requires_operator" || + error.reason === "deployment_claim_mismatch" || + error.reason === "deployment_lifecycle_mismatch" || + error.reason === "deployment_launch_mismatch" || + error.reason === "deployment_transition_in_progress" || + error.reason === "deployment_needs_repair") + ); +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/apps/desktop/src/main/runtime-host-upgrade-copy.ts b/apps/desktop/src/main/runtime-host-upgrade-copy.ts index 4a4a46c99a..53fd212d68 100644 --- a/apps/desktop/src/main/runtime-host-upgrade-copy.ts +++ b/apps/desktop/src/main/runtime-host-upgrade-copy.ts @@ -20,11 +20,20 @@ import type { UiLocale } from '@maka/core/ui-locale'; import type { MessageBoxOptions } from 'electron'; import type { + RuntimeHostNonRestartableAction, RuntimeHostRestartableConflict, RuntimeHostWaitConflict, } from './runtime-host-desktop-manager.js'; type Conflict = RuntimeHostRestartableConflict | RuntimeHostWaitConflict; +export type RuntimeHostUpgradeDialogDecision = 'restart' | 'replace' | 'wait' | 'cancel'; + +type RuntimeHostUpgradeAvailability = RuntimeHostNonRestartableAction | 'restart'; + +export interface RuntimeHostUpgradeDialog { + readonly options: MessageBoxOptions; + readonly decisions: readonly RuntimeHostUpgradeDialogDecision[]; +} type ActivityKey = | 'goal' | 'scheduledTask' @@ -34,32 +43,57 @@ type ActivityKey = | 'graph' | 'other'; -export function buildRuntimeHostUpgradeDialogOptions( +export function buildRuntimeHostUpgradeDialog( conflict: Conflict, - canRestart: boolean, + availability: RuntimeHostUpgradeAvailability, locale: UiLocale, -): MessageBoxOptions { - const activity = conflict.handshake?.activity; - const hasWork = - (activity?.activeOperations ?? 0) > 0 || (activity?.residencies.length ?? 0) > 0; +): RuntimeHostUpgradeDialog { const copy = UPGRADE_COPY[locale]; - const buttons = canRestart ? [copy.restart, copy.wait, copy.cancel] : [copy.wait, copy.cancel]; + const choices: { readonly label: string; readonly decision: RuntimeHostUpgradeDialogDecision }[] = + []; + const action = + availability === 'restart' + ? 'restart' + : availability === 'replace_may_interrupt_work' + ? 'replace' + : undefined; + const canWait = + availability === 'wait' || + (availability === 'restart' && conflict.registration.lifecycleMode !== 'service'); + if (action) { + choices.push({ + label: action === 'restart' ? copy.restart : copy.replace, + decision: action, + }); + } + if (canWait) choices.push({ label: copy.wait, decision: 'wait' }); + choices.push({ label: copy.cancel, decision: 'cancel' }); + const cancelId = choices.length - 1; return { - type: 'warning', - title: copy.title, - message: copy.message, - detail: formatActivity(conflict, locale), - buttons, - defaultId: hasWork || !canRestart ? (canRestart ? 1 : 0) : 0, - cancelId: canRestart ? 2 : 1, - noLink: true, + options: { + type: 'warning', + title: copy.title, + message: copy.message, + detail: formatActivity(conflict, availability, canWait, locale), + buttons: choices.map((choice) => choice.label), + defaultId: cancelId, + cancelId, + noLink: true, + }, + decisions: choices.map((choice) => choice.decision), }; } -function formatActivity(conflict: Conflict, locale: UiLocale): string { +function formatActivity( + conflict: Conflict, + availability: RuntimeHostUpgradeAvailability, + canWait: boolean, + locale: UiLocale, +): string { const activity = conflict.handshake?.activity; const copy = UPGRADE_COPY[locale]; const lines: string[] = []; + lines.push(copy.processId(conflict.registration.pid)); if (activity) { const minutes = Math.max(1, Math.round(activity.processUptimeSeconds / 60)); lines.push(copy.uptime(minutes)); @@ -68,10 +102,20 @@ function formatActivity(conflict: Conflict, locale: UiLocale): string { for (const residency of activity.residencies) { lines.push(`${copy.activity[activityKey(residency.label)]}: ${residency.count}`); } + } else if (availability === 'replace_may_interrupt_work') { + lines.push(copy.idleNotVerified); } else lines.push(copy.unknownActivity); - lines.push('', copy.restartWarning); - if (conflict.kind !== 'upgrade_required' || !conflict.restartable) lines.push(copy.exitOwner); - lines.push(copy.waitExplanation); + if (availability === 'replace_may_interrupt_work') { + lines.push('', copy.replaceWarning, copy.replaceExplanation); + } else if (availability === 'restart') { + lines.push('', copy.restartWarning); + } else if (conflict.kind !== 'upgrade_required' || !conflict.restartable) { + lines.push(''); + lines.push(copy.exitOwner(conflict.registration.pid)); + } + if (canWait) { + lines.push(copy.waitExplanation); + } return lines.join('\n'); } @@ -93,15 +137,22 @@ const UPGRADE_COPY = { title: 'Older Runtime Host is running', message: 'Another Runtime Host process still owns this workspace.', restart: 'Restart Runtime Host', + replace: 'Stop Host and Continue', wait: 'Wait', cancel: 'Cancel Startup', uptime: (n: number) => `Running for about ${n} ${n === 1 ? 'minute' : 'minutes'}`, connections: (n: number) => `${n} other client(s) are still connected`, operations: (n: number) => `${n} operation(s) are running`, + idleNotVerified: 'Maka could not verify that this Host is idle during the safe replacement check.', unknownActivity: 'This Host version cannot report its background activity.', + processId: (pid: number) => `Process ID (PID): ${pid}`, restartWarning: 'Restarting preserves durable state, but it can interrupt in-flight external work.', - exitOwner: 'Exit the process that owns this Host to replace it safely.', + replaceWarning: + 'Stopping preserves durable state, but it can interrupt in-flight external work.', + replaceExplanation: 'Maka will stop this Host, replace it safely, and continue startup.', + exitOwner: (pid: number) => + `End process ${pid} with your system process manager to replace this Host safely.`, waitExplanation: 'If you wait, Maka will continue automatically when this Host exits.', activity: { goal: 'Goal', scheduledTask: 'Scheduled Task', dailyReview: 'Daily Review', @@ -113,14 +164,20 @@ const UPGRADE_COPY = { title: '旧版 Runtime Host 正在运行', message: '另一个 Runtime Host 进程仍占用此工作区。', restart: '重启 Runtime Host', + replace: '停止 Host 并继续', wait: '等待', cancel: '取消启动', uptime: (n: number) => `已运行约 ${n} 分钟`, connections: (n: number) => `仍有 ${n} 个其他客户端连接`, operations: (n: number) => `有 ${n} 个操作正在运行`, + idleNotVerified: '安全替换检查无法确认此 Host 是否处于空闲状态。', unknownActivity: '此 Host 版本无法报告后台活动。', + processId: (pid: number) => `进程 ID (PID):${pid}`, restartWarning: '重启会保留持久化状态,但可能中断正在进行的外部工作。', - exitOwner: '请退出当前占用此 Host 的进程,以便安全替换。', + replaceWarning: '停止 Host 会保留持久化状态,但可能中断正在进行的外部工作。', + replaceExplanation: 'Maka 将停止并安全替换此 Host,然后继续启动。', + exitOwner: (pid: number) => + `请使用系统进程管理工具结束进程 ${pid},以便安全替换此 Host。`, waitExplanation: '若选择等待,当前 Host 退出后 Maka 将自动继续。', activity: { goal: '目标', scheduledTask: '计划任务', dailyReview: '每日回顾', execution: '活动执行', diff --git a/apps/desktop/src/main/runtime-host-upgrade-dialog.ts b/apps/desktop/src/main/runtime-host-upgrade-dialog.ts index b051eabc9e..488307dc13 100644 --- a/apps/desktop/src/main/runtime-host-upgrade-dialog.ts +++ b/apps/desktop/src/main/runtime-host-upgrade-dialog.ts @@ -22,9 +22,9 @@ import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'; import type { RuntimeHostRestartDecision, RuntimeHostUpgradePrompts, - RuntimeHostWaitDecision, + RuntimeHostNonRestartableDecision, } from './runtime-host-desktop-manager.js'; -import { buildRuntimeHostUpgradeDialogOptions } from './runtime-host-upgrade-copy.js'; +import { buildRuntimeHostUpgradeDialog } from './runtime-host-upgrade-copy.js'; export function createRuntimeHostUpgradePrompts( resolveLocale: () => Promise, @@ -36,21 +36,26 @@ export function createRuntimeHostUpgradePrompts( return { restartable: async (conflict): Promise => { const locale = await resolveLocale(); + const dialog = buildRuntimeHostUpgradeDialog(conflict, 'restart', locale); const { response } = await showDialog( - buildRuntimeHostUpgradeDialogOptions(conflict, true, locale), + dialog.options, locale, ); - if (response === 0) return 'restart'; - if (response === 1) return 'wait'; - return 'cancel'; + const decision = dialog.decisions[response] ?? 'cancel'; + return decision === 'restart' || decision === 'wait' ? decision : 'cancel'; }, - waitOnly: async (conflict): Promise => { + nonRestartable: async ( + conflict, + action, + ): Promise => { const locale = await resolveLocale(); + const dialog = buildRuntimeHostUpgradeDialog(conflict, action, locale); const { response } = await showDialog( - buildRuntimeHostUpgradeDialogOptions(conflict, false, locale), + dialog.options, locale, ); - return response === 0 ? 'wait' : 'cancel'; + const decision = dialog.decisions[response] ?? 'cancel'; + return decision === 'replace' || decision === 'wait' ? decision : 'cancel'; }, }; } diff --git a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts index a950f033f2..d3b18b6cf3 100644 --- a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts @@ -17,16 +17,24 @@ * under the License. */ +import { resolveUsageRange } from "@maka/core/model-call-usage-projection"; import { tryResult } from "@maka/core/result"; +import type { UsageRange, UsageStats } from "@maka/core/settings"; import { normalizePricingConfig, normalizePricingModelKey, } from "@maka/core/usage-stats/pricing"; import type { PricingConfig, + TimeRange, UsageGroupBy, UsageQuery, } from "@maka/core/usage-stats/types"; +import { + USAGE_PAGE_MAX_ITEMS, + type LlmUsageLogProjection, + type ToolUsageLogProjection, +} from "@maka/runtime-host/protocol"; import { handleReconnectableRead, type ReconnectableReadIpcMain, @@ -40,7 +48,7 @@ interface RuntimeHostUsageIpcDeps { readonly sendToRenderer: (channel: string, ...args: unknown[]) => void; } -const PAGE_LIMIT = 100; +const MAX_ACTIVITY_RECORDS = 50_000; export function registerRuntimeHostUsageIpc( deps: RuntimeHostUsageIpcDeps, @@ -55,6 +63,12 @@ export function registerRuntimeHostUsageIpc( return result; }; + handleReconnectableRead( + deps.ipcMain, + "settings:usageStats", + (_event, range: UsageRange = "24h") => + loadUsageStats(deps.client, normalizeUsageRange(range)), + ); handleReconnectableRead( deps.ipcMain, "usage:summary", @@ -142,6 +156,240 @@ export function registerRuntimeHostUsageIpc( ); } +async function loadUsageStats( + client: DesktopRuntimeHostClient, + range: UsageRange, +): Promise { + const query = { range: resolveUsageRange(range, Date.now()) } satisfies UsageQuery; + const [summaryResult, llmResult, toolResult, pricing] = await Promise.all([ + client.queryUsage({ kind: "summary", query }), + loadAllLogs(client, "llm", query), + loadAllLogs(client, "tool", query), + client.loadPricingSnapshot(), + ]); + if (summaryResult.kind !== "summary") throw invalidUsageProjection(); + const llmLogs = llmResult.rows; + const toolLogs = toolResult.rows; + const logsTruncated = llmResult.truncated || toolResult.truncated; + // The canonical summary is the authoritative headline count. We no longer + // throw when it disagrees with the number of activity rows we managed to + // load: a Host restart with pending repairs can make the summary read land + // before a catch-up commits and the logs read land after, and truncation + // (above) deliberately shortens the list. Either way the summary total stays + // correct; `provenance`/`logsTruncated` tell the page the activity list may + // be incomplete instead of erroring the whole page. + + return { + summary: { + totalRequests: summaryResult.summary.totalRequests, + totalCostUsd: summaryResult.summary.totalCostUsd, + totalTokens: summaryResult.summary.totalTokens.total, + inputTokens: summaryResult.summary.totalTokens.input, + outputTokens: summaryResult.summary.totalTokens.output, + cacheTokens: + summaryResult.summary.totalTokens.cacheRead + + summaryResult.summary.totalTokens.cacheWrite, + cacheMiss: summaryResult.summary.totalTokens.cacheMiss, + cacheRead: summaryResult.summary.totalTokens.cacheRead, + cacheCreation: summaryResult.summary.totalTokens.cacheWrite, + reasoning: summaryResult.summary.totalTokens.reasoning, + }, + logs: [...llmLogs.map(projectLlmLog), ...toolLogs.map(projectToolLog)].sort( + (left, right) => right.ts - left.ts, + ), + byProvider: aggregateModelLogs(llmLogs, "provider"), + byModel: aggregateModelLogs(llmLogs, "model"), + byTool: aggregateToolLogs(toolLogs), + pricing: pricing.entries + .filter((entry) => entry.source === "custom") + .map(({ pricing: entry }) => projectPricing(entry)) + .sort( + (left, right) => + left.provider.localeCompare(right.provider) || left.model.localeCompare(right.model), + ), + provenance: summaryResult.provenance, + ...(logsTruncated ? { logsTruncated: true } : {}), + }; +} + +async function loadAllLogs( + client: DesktopRuntimeHostClient, + source: "llm", + query: UsageQuery & { range: TimeRange }, +): Promise<{ rows: LlmUsageLogProjection[]; truncated: boolean }>; +async function loadAllLogs( + client: DesktopRuntimeHostClient, + source: "tool", + query: UsageQuery & { range: TimeRange }, +): Promise<{ rows: ToolUsageLogProjection[]; truncated: boolean }>; +async function loadAllLogs( + client: DesktopRuntimeHostClient, + source: "llm" | "tool", + query: UsageQuery & { range: TimeRange }, +): Promise<{ + rows: Array; + truncated: boolean; +}> { + const rows: Array = []; + let offset = 0; + let total: number | undefined; + while (true) { + const result = await client.queryUsage( + source === "llm" + ? { + kind: "logs", + source, + query: toLlmQuery(query), + offset, + limit: USAGE_PAGE_MAX_ITEMS, + } + : { + kind: "logs", + source, + query: toToolQuery(query), + offset, + limit: USAGE_PAGE_MAX_ITEMS, + }, + ); + if (result.kind !== "logs" || result.source !== source || result.offset !== offset) { + throw invalidUsageProjection(); + } + total ??= result.total; + if (result.total !== total) throw invalidUsageProjection(); + rows.push(...result.rows); + // Structural integrity: the Host must never return more rows than it claims. + if (rows.length > total) throw invalidUsageProjection(); + // Client-side cap: when a range holds more activity than we render, keep the + // newest MAX_ACTIVITY_RECORDS and stop paging. This is truncation, not a + // protocol error, and the exhaustiveness check below is skipped for it — the + // caller surfaces `logsTruncated` so the page can say the list is partial. + if (total > MAX_ACTIVITY_RECORDS && rows.length >= MAX_ACTIVITY_RECORDS) { + return { rows: rows.slice(0, MAX_ACTIVITY_RECORDS), truncated: true }; + } + if (result.nextOffset === null) { + if (rows.length !== total) throw invalidUsageProjection(); + return { rows, truncated: false }; + } + if (result.nextOffset <= offset) throw invalidUsageProjection(); + offset = result.nextOffset; + } +} + +// The Task column names the session each usage row belongs to. The Host resolves +// the human-readable title (from the durable session header) and carries it on +// the projection as `sessionTitle`; untitled/unreadable sessions omit it, and the +// renderer falls back to the untitled label. +function projectLlmLog(row: LlmUsageLogProjection): UsageStats["logs"][number] { + return { + id: row.id, + ts: row.ts, + kind: "model", + ...(row.sessionId === undefined ? {} : { sessionId: row.sessionId }), + ...(row.sessionTitle === undefined ? {} : { sessionName: row.sessionTitle }), + ...(row.turnId === undefined ? {} : { turnId: row.turnId }), + provider: row.providerId, + model: row.modelId, + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + cacheMiss: row.cacheMissTokens, + cacheRead: row.cacheReadTokens, + cacheCreation: row.cacheWriteTokens, + reasoning: row.reasoningTokens, + ...(row.costUsd === undefined ? {} : { costUsd: row.costUsd }), + latencyMs: row.latencyMs, + status: row.status, + }; +} + +function projectToolLog(row: ToolUsageLogProjection): UsageStats["logs"][number] { + return { + id: row.id, + ts: row.ts, + kind: "tool", + ...(row.sessionId === undefined ? {} : { sessionId: row.sessionId }), + ...(row.sessionTitle === undefined ? {} : { sessionName: row.sessionTitle }), + ...(row.turnId === undefined ? {} : { turnId: row.turnId }), + provider: row.providerId ?? "", + model: row.modelId ?? "", + toolName: row.toolName, + inputTokens: 0, + outputTokens: 0, + latencyMs: row.durationMs, + status: row.status, + }; +} + +function aggregateModelLogs( + logs: readonly LlmUsageLogProjection[], + key: "provider", +): UsageStats["byProvider"]; +function aggregateModelLogs( + logs: readonly LlmUsageLogProjection[], + key: "model", +): UsageStats["byModel"]; +function aggregateModelLogs( + logs: readonly LlmUsageLogProjection[], + key: "provider" | "model", +): UsageStats["byProvider"] | UsageStats["byModel"] { + const rows = new Map(); + for (const log of logs) { + // Provider breakdown keys on the connection the user configured, not the + // raw provider type: two connections to the same provider are two rows, not + // one collapsed row. `connectionSlug` is optional on pre-cutover rows, so + // fall back to the provider id. + const id = key === "provider" ? (log.connectionSlug ?? log.providerId) : log.modelId; + const current = rows.get(id) ?? { requests: 0, tokens: 0, costUsd: 0 }; + current.requests += 1; + current.tokens += log.inputTokens + log.outputTokens; + current.costUsd += log.costUsd ?? 0; + rows.set(id, current); + } + return [...rows.entries()] + .map(([id, row]) => ({ [key]: id, ...row })) + .sort((left, right) => right.requests - left.requests) as + | UsageStats["byProvider"] + | UsageStats["byModel"]; +} + +function aggregateToolLogs(logs: readonly ToolUsageLogProjection[]): UsageStats["byTool"] { + const rows = new Map< + string, + { calls: number; success: number; errors: number; totalDurationMs: number } + >(); + for (const log of logs) { + const current = rows.get(log.toolName) ?? { + calls: 0, + success: 0, + errors: 0, + totalDurationMs: 0, + }; + current.calls += 1; + if (log.status === "success") current.success += 1; + if (log.status === "error") current.errors += 1; + current.totalDurationMs += log.durationMs; + rows.set(log.toolName, current); + } + return [...rows.entries()] + .map(([tool, row]) => ({ + tool, + calls: row.calls, + success: row.success, + errors: row.errors, + avgDurationMs: row.calls === 0 ? 0 : Math.round(row.totalDurationMs / row.calls), + })) + .sort((left, right) => right.calls - left.calls || left.tool.localeCompare(right.tool)); +} + +function projectPricing(pricing: PricingConfig): UsageStats["pricing"][number] { + const separator = pricing.modelKey.indexOf(":"); + return { + provider: separator < 0 ? "" : pricing.modelKey.slice(0, separator), + model: separator < 0 ? pricing.modelKey : pricing.modelKey.slice(separator + 1), + inputPerMTokUsd: pricing.inputUsdPer1M, + outputPerMTokUsd: pricing.outputUsdPer1M, + }; +} + async function loadAllBuckets( client: DesktopRuntimeHostClient, query: UsageQuery & { groupBy: UsageGroupBy }, @@ -156,14 +404,14 @@ async function loadAllBuckets( query: toToolQuery(query), groupBy: "tool", offset, - limit: PAGE_LIMIT, + limit: USAGE_PAGE_MAX_ITEMS, } : { kind: "buckets", query: toLlmQuery(query), groupBy: query.groupBy, offset, - limit: PAGE_LIMIT, + limit: USAGE_PAGE_MAX_ITEMS, }, ); if (result.kind !== "buckets" || result.offset !== offset) @@ -180,6 +428,12 @@ function toLlmQuery(query: UsageQuery) { return llmQuery; } +function normalizeUsageRange(range: unknown): UsageRange { + return range === "24h" || range === "7d" || range === "30d" || range === "all" + ? range + : "24h"; +} + function toToolQuery(query: UsageQuery) { return { range: query.range, diff --git a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts new file mode 100644 index 0000000000..4210a0c38c --- /dev/null +++ b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { + OperationError, + OperationOutcome, + WorkHubCoordinationActInput, + WorkHubCoordinationActResult, + WorkspaceTarget, +} from '@maka/runtime-host/protocol'; +import { RuntimeHostOperationError } from '@maka/runtime-host/client'; +import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; +import type { ReconnectableReadIpcMain } from './ipc-reconnect-policy.js'; + +type RuntimeHostWorkHubClient = Pick< + DesktopRuntimeHostClient, + | 'actWorkHubCoordination' + | 'answerWorkHubCoordination' + | 'listWorkHubCoordinationCandidates' + | 'recordWorkHubCoordination' + | 'resolveWorkHubCoordinationSession' +>; + +type RendererWorkHubActionInput = Omit; + +export interface RuntimeHostWorkHubIpcOptions { + resolveCreateProject(): Promise; + emitSessionsChanged(reason: 'created' | 'status-change', sessionId: string): void; +} + +/** Projects the Runtime Host WorkHub domain onto renderer IPC. */ +export function registerRuntimeHostWorkHubIpc( + client: RuntimeHostWorkHubClient, + ipcMain: Pick, + options: RuntimeHostWorkHubIpcOptions, +): void { + ipcMain.handle('workhub:resolveCoordinationSession', () => + client.resolveWorkHubCoordinationSession(), + ); + ipcMain.handle('workhub:answer', (_event, input) => + client.answerWorkHubCoordination(input), + ); + ipcMain.handle('workhub:record', (_event, input) => + client.recordWorkHubCoordination(input), + ); + ipcMain.handle('workhub:candidates', () => client.listWorkHubCoordinationCandidates()); + ipcMain.handle('workhub:act', async (_event, rawInput: RendererWorkHubActionInput) => { + try { + const proposal = rawInput?.proposal; + const base = { + actionId: rawInput?.actionId, + userText: rawInput?.userText, + proposal, + ...(rawInput?.confirmation === undefined + ? {} + : { confirmation: rawInput.confirmation }), + } as Pick< + WorkHubCoordinationActInput, + 'actionId' | 'userText' | 'proposal' | 'confirmation' + >; + let result: WorkHubCoordinationActResult; + const createsTarget = + proposal?.disposition === 'create_new' || + (proposal?.disposition === 'replace' && + proposal.target.disposition === 'create_new'); + if (createsTarget) { + result = await client.actWorkHubCoordination({ + ...base, + create: { + workspace: await options.resolveCreateProject(), + }, + }); + } else { + result = await client.actWorkHubCoordination({ + ...base, + ...(rawInput?.candidateSetId === undefined + ? {} + : { candidateSetId: rawInput.candidateSetId }), + }); + } + if ( + result.disposition === 'create_new' || + (result.disposition === 'replace' && result.replacementDisposition === 'create_new') + ) { + options.emitSessionsChanged('created', result.targetSessionId); + } else if ( + result.disposition === 'delegate_existing' || + result.disposition === 'replace' + ) { + options.emitSessionsChanged('status-change', result.targetSessionId); + } + return { ok: true, result } satisfies OperationOutcome<'workhub.coordination.act'>; + } catch (error) { + if (!(error instanceof RuntimeHostOperationError)) throw error; + return { + ok: false, + error: workHubActError(error), + } satisfies OperationOutcome<'workhub.coordination.act'>; + } + }); +} + +function workHubActError( + error: RuntimeHostOperationError, +): OperationError<'workhub.coordination.act'> { + switch (error.code) { + case 'host_not_ready': + case 'host_draining': + case 'unauthorized': + case 'operation_unavailable': + case 'not_found': + case 'session_archived': + case 'session_busy': + case 'operation_conflict': + case 'persistence_failed': + case 'commit_outcome_unknown': + case 'internal_failure': + return { code: error.code, message: error.message }; + default: + return { code: 'internal_failure', message: 'WorkHub action failed' }; + } +} diff --git a/apps/desktop/src/main/runtime-host-wsl-controller.ts b/apps/desktop/src/main/runtime-host-wsl-controller.ts new file mode 100644 index 0000000000..c8ff168b4e --- /dev/null +++ b/apps/desktop/src/main/runtime-host-wsl-controller.ts @@ -0,0 +1,418 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import type { Readable } from 'node:stream'; +import { + normalizeRuntimeHostWslDistribution, + normalizeRuntimeHostWslOperatorPath, + resolveSystemRuntimeHostWslExecutable, + type RuntimeHostWslProcessFactory, +} from '@maka/runtime-host/client'; +import { + decodeRuntimeHostSetupFrame, + decodeRuntimeHostServiceManagementFrame, + RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV, + RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, + RUNTIME_HOST_SETUP_FRAME_PREFIX, + RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV, + type RuntimeHostSetupFrame, + type RuntimeHostSetupPhase, + type RuntimeHostServiceManagementFrame, +} from '@maka/runtime-host/operator'; +import { createRuntimeHostFramedOutputFilter } from './runtime-host-framed-output.js'; +import type { DesktopRuntimeHostSetupPackage } from './runtime-host-setup-package.js'; + +const WSL_SETUP_TIMEOUT_MS = 10 * 60_000; +const WSL_SETUP_OUTPUT_MAX_BYTES = 64 * 1024; +const WSL_SETUP_STDERR_MAX_BYTES = 8 * 1024; + +type RuntimeHostSetupCompleteFrame = Extract; +type RuntimeHostManagementTerminalFrame = Exclude< + RuntimeHostServiceManagementFrame, + { readonly kind: 'progress' } +>; + +export interface DesktopRuntimeHostWslManagementInput { + readonly distribution: string; + readonly operatorPath: string; + readonly action: 'status' | 'configure'; + readonly expectedTarget: { + readonly serviceId: string; + readonly rootPath: string; + readonly rootId: string; + readonly deploymentId?: string; + }; + readonly projectDirectoryRoots?: readonly { + readonly label: string; + readonly path: string; + }[]; + readonly expectedConfigFingerprint?: string; + readonly allowInterruptActiveTasks?: boolean; + readonly signal?: AbortSignal; +} + +type RuntimeHostWslManagementProcessFactory = ( + executable: string, + args: readonly string[], + environment: NodeJS.ProcessEnv, +) => ChildProcessWithoutNullStreams; + +export async function runDesktopRuntimeHostWslManagement( + input: DesktopRuntimeHostWslManagementInput, + overrides: { + readonly processFactory?: RuntimeHostWslManagementProcessFactory; + readonly wslExecutable?: string; + } = {}, +): Promise { + input.signal?.throwIfAborted(); + const distribution = normalizeRuntimeHostWslDistribution(input.distribution); + const operatorPath = normalizeRuntimeHostWslOperatorPath(input.operatorPath); + const args = [ + '--distribution', + distribution, + '--exec', + operatorPath, + input.action, + '--framed', + ...(input.projectDirectoryRoots === undefined + ? [] + : input.projectDirectoryRoots.length === 0 + ? ['--no-project-roots'] + : input.projectDirectoryRoots.flatMap(({ label, path }) => [ + '--project-root-json', + JSON.stringify({ label, path }), + ])), + ...(input.expectedConfigFingerprint + ? ['--expected-config-fingerprint', input.expectedConfigFingerprint] + : []), + ...(input.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), + '--expected-service-id', + input.expectedTarget.serviceId, + '--expected-root-path', + input.expectedTarget.rootPath, + '--expected-root-id', + input.expectedTarget.rootId, + ...(input.expectedTarget.deploymentId + ? ['--expected-deployment-id', input.expectedTarget.deploymentId] + : []), + ]; + const environment = passEnvironmentToWsl( + process.env, + RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV, + '1', + ); + const child = (overrides.processFactory ?? spawnWslManagement)( + overrides.wslExecutable ?? resolveSystemRuntimeHostWslExecutable(), + args, + environment, + ); + const terminal = await runWslFramedProcess({ + child, + signal: input.signal, + prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, + decode: decodeRuntimeHostServiceManagementFrame, + label: 'WSL Runtime Host management', + onFrame: (frame) => { + if (frame.kind === 'progress') { + throw new Error('WSL Runtime Host management returned unexpected progress'); + } + return frame; + }, + }); + if (terminal.action !== input.action) { + throw new Error('WSL Runtime Host returned an unrelated management result'); + } + return terminal; +} + +export interface DesktopRuntimeHostWslSetupInput { + readonly distribution: string; + readonly setupPackage: DesktopRuntimeHostSetupPackage; + readonly principalId: string; + readonly projectDirectoryRoots?: readonly { + readonly label: string; + readonly path: string; + }[]; + readonly signal?: AbortSignal; +} + +export async function runDesktopRuntimeHostWslSetup( + input: DesktopRuntimeHostWslSetupInput, + onProgress: (frame: { readonly phase: RuntimeHostSetupPhase }) => void, + onComplete?: () => void, + overrides: { + readonly processFactory?: RuntimeHostWslProcessFactory; + readonly wslExecutable?: string; + } = {}, +): Promise { + input.signal?.throwIfAborted(); + const distribution = normalizeRuntimeHostWslDistribution(input.distribution); + const processFactory = overrides.processFactory ?? spawnWsl; + const executable = overrides.wslExecutable ?? resolveSystemRuntimeHostWslExecutable(); + const setupPackage = await resolveWslPackageSpecifier( + input.setupPackage, + distribution, + executable, + processFactory, + ); + const command = runtimeHostWslSetupCommand(setupPackage, input); + const child = processFactory(executable, ['--distribution', distribution, '--exec', '/bin/sh', '-lc', command]); + return runWslFramedProcess({ + child, + signal: input.signal, + prefix: RUNTIME_HOST_SETUP_FRAME_PREFIX, + decode: decodeRuntimeHostSetupFrame, + label: 'WSL Maka setup', + onFrame: (frame) => { + if (frame.kind === 'progress') { + onProgress(frame); + return undefined; + } + if (frame.kind === 'error') throw new Error(frame.error.message); + return frame; + }, + onResult: () => onComplete?.(), + }); +} + +async function resolveWslPackageSpecifier( + setupPackage: DesktopRuntimeHostSetupPackage, + distribution: string, + executable: string, + processFactory: RuntimeHostWslProcessFactory, +): Promise<{ readonly specifier: string; readonly integrity?: string }> { + if (setupPackage.kind === 'npm') { + return { specifier: setupPackage.specifier }; + } + const child = processFactory(executable, ['--distribution', distribution, '--exec', 'wslpath', '-a', '-u', setupPackage.path]); + child.stdin.end(); + const stdout = collectBounded(child.stdout, 4 * 1024); + const stderr = collectBounded(child.stderr, WSL_SETUP_STDERR_MAX_BYTES); + const [exit, capturedStdout, capturedStderr] = await Promise.all([ + childExit(child), + stdout, + stderr, + ]); + const path = requireBoundedOutput(capturedStdout).toString('utf8').trim(); + if (exit.code !== 0 || !path.startsWith('/')) { + const diagnostic = formatBoundedDiagnostic(capturedStderr); + throw new Error(`WSL could not resolve the setup package path${diagnostic ? `: ${diagnostic}` : ''}`); + } + return { specifier: path, integrity: setupPackage.integrity }; +} + +function runtimeHostWslSetupCommand( + setupPackage: { readonly specifier: string; readonly integrity?: string }, + input: Pick, +): string { + if (!/^[A-Za-z0-9_.:-]{1,128}$/u.test(input.principalId)) { + throw new Error('Runtime Host setup principal is invalid'); + } + const args = [ + 'maka', + 'runtime-host', + 'setup', + '--principal', + input.principalId, + '--preset', + 'desktop-client', + '--lifecycle', + 'on-demand', + '--repair-root-after-remount', + '--update-existing', + ...(input.projectDirectoryRoots === undefined + ? [] + : input.projectDirectoryRoots.length === 0 + ? ['--no-project-roots'] + : input.projectDirectoryRoots.flatMap(({ label, path }) => [ + '--project-root-json', + JSON.stringify({ label, path }), + ])), + '--json', + ]; + const invocation = ['npx', '--yes', '--package', setupPackage.specifier, ...args] + .map(quotePosix) + .join(' '); + const environment = setupPackage.integrity + ? `${RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV}=${quotePosix(setupPackage.integrity)} ` + : ''; + const command = `maka_prefix=$(mktemp -d) || exit 1; trap 'rm -rf -- "$maka_prefix"' EXIT; cd "$maka_prefix" || exit 1; ${environment}${invocation}`; + const loginCommand = `exec /bin/sh -c ${quotePosix(command)}`; + return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(loginCommand)}`; +} + +function quotePosix(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function spawnWsl(executable: string, args: readonly string[]) { + return spawn(executable, args, { + shell: false, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }); +} + +function spawnWslManagement( + executable: string, + args: readonly string[], + environment: NodeJS.ProcessEnv, +) { + return spawn(executable, args, { + shell: false, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + env: environment, + }); +} + +function passEnvironmentToWsl( + source: NodeJS.ProcessEnv, + name: string, + value: string, +): NodeJS.ProcessEnv { + const existing = source.WSLENV?.split(':').filter(Boolean) ?? []; + const included = existing.some((entry) => entry.split('/')[0] === name); + return { + ...source, + [name]: value, + WSLENV: included ? existing.join(':') : [...existing, name].join(':'), + }; +} + +async function runWslFramedProcess(input: { + readonly child: ChildProcessWithoutNullStreams; + readonly signal?: AbortSignal; + readonly prefix: string; + readonly decode: (line: string) => Frame | undefined; + readonly label: string; + readonly onFrame: (frame: Frame) => Result | undefined; + readonly onResult?: (result: Result) => void; +}): Promise { + const abort = () => input.child.kill(); + input.signal?.addEventListener('abort', abort, { once: true }); + if (input.signal?.aborted) abort(); + input.child.stdin.end(); + let result: Result | undefined; + let failure: Error | undefined; + const filter = createRuntimeHostFramedOutputFilter({ + prefix: input.prefix, + pendingMaxBytes: WSL_SETUP_OUTPUT_MAX_BYTES, + decode: input.decode, + label: input.label, + onFrame: (frame) => { + try { + const terminal = input.onFrame(frame); + if (terminal === undefined) return; + if (result !== undefined) { + throw new Error(`${input.label} returned multiple results`); + } + result = terminal; + input.onResult?.(terminal); + } catch (error) { + failure = error instanceof Error ? error : new Error(String(error)); + } + }, + onError: (error) => { + failure = error; + }, + }); + let outputBytes = 0; + input.child.stdout.setEncoding('utf8'); + input.child.stdout.on('data', (value: Buffer | string) => { + const chunk = typeof value === 'string' ? Buffer.from(value) : value; + outputBytes += chunk.byteLength; + if (outputBytes > WSL_SETUP_OUTPUT_MAX_BYTES) { + failure = new Error(`${input.label} output exceeded its byte limit`); + input.child.kill(); + return; + } + filter.push(chunk.toString('utf8')); + }); + const stderr = collectBounded(input.child.stderr, WSL_SETUP_STDERR_MAX_BYTES); + const timeout = setTimeout(() => input.child.kill(), WSL_SETUP_TIMEOUT_MS); + const [exit, capturedStderr] = await Promise.all([ + childExit(input.child), + stderr, + ]).finally(() => { + clearTimeout(timeout); + input.signal?.removeEventListener('abort', abort); + }); + filter.finish(); + input.signal?.throwIfAborted(); + if (failure) throw failure; + if (result === undefined) { + const diagnostic = formatBoundedDiagnostic(capturedStderr); + throw new Error( + `${input.label} exited with code ${String(exit.code)} without a result${diagnostic ? `: ${diagnostic}` : ''}`, + ); + } + return result; +} + +function childExit(child: ChildProcessWithoutNullStreams): Promise<{ + readonly code: number | null; + readonly signal: NodeJS.Signals | null; +}> { + return new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal })); + }); +} + +interface BoundedOutput { + readonly bytes: Buffer; + readonly complete: boolean; +} + +async function collectBounded(stream: Readable, maxBytes: number): Promise { + const chunks: Buffer[] = []; + let bytes = 0; + let complete = true; + try { + for await (const value of stream) { + const chunk = typeof value === 'string' ? Buffer.from(value) : value; + const remaining = Math.max(0, maxBytes - bytes); + if (remaining > 0) { + chunks.push(chunk.subarray(0, remaining)); + bytes += Math.min(chunk.byteLength, remaining); + } + if (chunk.byteLength > remaining) complete = false; + } + } catch { + complete = false; + } + return { bytes: Buffer.concat(chunks), complete }; +} + +function requireBoundedOutput(output: BoundedOutput): Buffer { + if (!output.complete) { + throw new Error('WSL process output exceeded its byte limit or could not be read'); + } + return output.bytes; +} + +function formatBoundedDiagnostic(output: BoundedOutput): string { + const message = output.bytes.toString('utf8').trim(); + return [ + ...(message ? [message] : []), + ...(!output.complete ? [''] : []), + ].join('\n'); +} diff --git a/apps/desktop/src/main/session-model-input.ts b/apps/desktop/src/main/session-model-input.ts index 3e476bc6f4..82f105b48f 100644 --- a/apps/desktop/src/main/session-model-input.ts +++ b/apps/desktop/src/main/session-model-input.ts @@ -18,6 +18,7 @@ */ export interface SessionModelSelection { + llmConnectionId: string; llmConnectionSlug: string; model: string; } @@ -27,9 +28,10 @@ export function normalizeSessionModelSelection(input: unknown): SessionModelSele throw new Error('Invalid model selection'); } const record = input as Record; + const llmConnectionId = normalizeRequiredString(record.llmConnectionId, 'model connection id'); const llmConnectionSlug = normalizeRequiredString(record.llmConnectionSlug, 'model connection'); const model = normalizeRequiredString(record.model, 'model'); - return { llmConnectionSlug, model }; + return { llmConnectionId, llmConnectionSlug, model }; } function normalizeRequiredString(value: unknown, label: string): string { diff --git a/apps/desktop/src/main/settings-bots-ipc-main.ts b/apps/desktop/src/main/settings-bots-ipc-main.ts index c98eafa257..5910c3b117 100644 --- a/apps/desktop/src/main/settings-bots-ipc-main.ts +++ b/apps/desktop/src/main/settings-bots-ipc-main.ts @@ -27,7 +27,7 @@ import { testBotChannel as testRuntimeBotChannel, type BotRegistry, } from '@maka/runtime/bots'; -import type { SettingsStore } from '@maka/storage'; +import type { SettingsStore } from '@maka/storage/settings-store'; import { BotOnboardingService, type BotOnboardingProviderAdapter, diff --git a/apps/desktop/src/main/task-submission-readiness-main.ts b/apps/desktop/src/main/task-submission-readiness-main.ts index de57eb6e19..2f7bcd3ea6 100644 --- a/apps/desktop/src/main/task-submission-readiness-main.ts +++ b/apps/desktop/src/main/task-submission-readiness-main.ts @@ -45,22 +45,6 @@ export type DesktopModelTargetResolution = | { kind: 'connection_missing'; connectionSlug: string } | { kind: 'unknown' }; -export async function resolveStoredModelTarget( - requestedSlug: string | undefined, - deps: { - getSnapshot(): Promise<{ defaultSlug: string | null; connections: LlmConnection[] }>; - hasCredential(connection: LlmConnection): Promise; - }, -): Promise { - const catalog = await deps.getSnapshot(); - const connectionSlug = requestedSlug ?? catalog.defaultSlug ?? undefined; - if (!connectionSlug) return { kind: 'missing_default' }; - const connection = catalog.connections.find((candidate) => candidate.slug === connectionSlug); - if (!connection) return { kind: 'connection_missing', connectionSlug }; - const hasSecret = await deps.hasCredential(connection).catch(() => undefined); - return { kind: 'resolved', connection, hasSecret }; -} - export function createDesktopTaskSubmissionReadinessService( deps: DesktopTaskSubmissionReadinessDeps, ) { diff --git a/apps/desktop/src/main/theme-source.ts b/apps/desktop/src/main/theme-source.ts index 296f43f624..b636e297fe 100644 --- a/apps/desktop/src/main/theme-source.ts +++ b/apps/desktop/src/main/theme-source.ts @@ -41,3 +41,20 @@ export function isThemePreference(value: unknown): value is ThemePreference { export function toNativeThemeSource(pref: ThemePreference): NativeThemeSource { return pref === 'auto' ? 'system' : pref; } + +/** + * Whether the app is currently showing dark appearance. + * + * The stored preference alone cannot answer this: `auto` defers to the OS, so + * the caller has to supply what the OS currently says. Split out as a pure + * function because three places need the same answer — the window background, + * the window `icon` option, and the dock tile — and a disagreement between + * them is visible as a flash of the wrong theme on the first frame. + */ +export function isDarkAppearance( + pref: ThemePreference | undefined, + systemPrefersDark: boolean, +): boolean { + const resolved = isThemePreference(pref) ? pref : 'auto'; + return resolved === 'dark' || (resolved === 'auto' && systemPrefersDark); +} diff --git a/apps/desktop/src/main/work-board-ipc-main.ts b/apps/desktop/src/main/work-board-ipc-main.ts new file mode 100644 index 0000000000..780c35e196 --- /dev/null +++ b/apps/desktop/src/main/work-board-ipc-main.ts @@ -0,0 +1,179 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { IpcMain } from 'electron'; +import type { WorkBoardItem, WorkBoardPage } from '@maka/core/work-board'; +import { + createWorkBoardStore, + WorkBoardStoreError, + type WorkBoardStore, + type WorkBoardStoreErrorCode, + type WorkBoardMutationOptions, +} from '@maka/storage/work-board-store'; +import type { createMainWindowController } from './main-window.js'; +import type { WorkBoardChangedEvent, WorkBoardIpcResult } from '../shared/work-board-ipc.js'; + +export type { WorkBoardChangedEvent, WorkBoardIpcResult } from '../shared/work-board-ipc.js'; + +type MainWindowController = Pick, 'send'>; + +export interface WorkBoardIpcRegistration { + close(): void; +} + +/** + * Desktop main process owns the Work Board store (the v1 mutation boundary). + * Renderer code reads a projection through IPC and reloads on the change + * signal; the Runtime Host and model tools are intentionally not involved. + */ +export function registerWorkBoardIpc(input: { + readonly ipcMain: Pick; + readonly workspaceRoot: string; + readonly mainWindowController: MainWindowController; + readonly store?: WorkBoardStore; + readonly now?: () => number; +}): WorkBoardIpcRegistration { + const store = input.store ?? createWorkBoardStore(input.workspaceRoot); + const now = input.now ?? Date.now; + const emitChanged = (): void => { + input.mainWindowController.send('workBoard:changed', { + type: 'work_board_changed', + ts: now(), + } satisfies WorkBoardChangedEvent); + }; + + input.ipcMain.handle( + 'workBoard:list', + async (_event, query: unknown): Promise> => { + try { + return { ok: true, value: await store.list(query) }; + } catch (error) { + return { ok: false, ...workBoardFailure(error) }; + } + }, + ); + + input.ipcMain.handle( + 'workBoard:create', + async (_event, item: unknown): Promise> => { + try { + const created = await store.create(item); + emitChanged(); + return { ok: true, value: created }; + } catch (error) { + return { ok: false, ...workBoardFailure(error) }; + } + }, + ); + + input.ipcMain.handle( + 'workBoard:update', + async ( + _event, + id: unknown, + patch: unknown, + options?: unknown, + ): Promise> => { + try { + const updated = await store.update( + requireWorkBoardId(id), + patch, + options as WorkBoardMutationOptions | undefined, + ); + emitChanged(); + return { ok: true, value: updated }; + } catch (error) { + return { ok: false, ...workBoardFailure(error) }; + } + }, + ); + + input.ipcMain.handle( + 'workBoard:archive', + async (_event, id: unknown, options?: unknown): Promise> => { + try { + const archived = await store.archive( + requireWorkBoardId(id), + options as WorkBoardMutationOptions | undefined, + ); + emitChanged(); + return { ok: true, value: archived }; + } catch (error) { + return { ok: false, ...workBoardFailure(error) }; + } + }, + ); + + input.ipcMain.handle( + 'workBoard:unarchive', + async (_event, id: unknown, options?: unknown): Promise> => { + try { + const unarchived = await store.unarchive( + requireWorkBoardId(id), + options as WorkBoardMutationOptions | undefined, + ); + emitChanged(); + return { ok: true, value: unarchived }; + } catch (error) { + return { ok: false, ...workBoardFailure(error) }; + } + }, + ); + + input.ipcMain.handle( + 'workBoard:remove', + async ( + _event, + id: unknown, + options?: unknown, + ): Promise> => { + try { + await store.remove(requireWorkBoardId(id), options as WorkBoardMutationOptions | undefined); + emitChanged(); + return { ok: true, value: null }; + } catch (error) { + return { ok: false, ...workBoardFailure(error) }; + } + }, + ); + + return { + close: () => store.close(), + }; +} + +function requireWorkBoardId(id: unknown): string { + if (typeof id !== 'string') { + throw new WorkBoardStoreError('invalid_input', 'Work Board item id must be a string'); + } + return id; +} + +function workBoardFailure(error: unknown): { + readonly code: WorkBoardStoreErrorCode | 'unknown'; + readonly message: string; +} { + if (error instanceof WorkBoardStoreError) { + return { code: error.code, message: error.message }; + } + return { + code: 'unknown', + message: error instanceof Error ? error.message : 'Work Board operation failed', + }; +} diff --git a/apps/desktop/src/main/workspace-file-search.ts b/apps/desktop/src/main/workspace-file-search.ts index 8c37f41a24..242b8e1dae 100644 --- a/apps/desktop/src/main/workspace-file-search.ts +++ b/apps/desktop/src/main/workspace-file-search.ts @@ -70,14 +70,14 @@ function runGitLsFiles( return new Promise((resolve) => { execFileImpl( 'git', - ['ls-files', '--cached', '--others', '--exclude-standard'], + ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd, timeout: LS_TIMEOUT_MS, windowsHide: true, maxBuffer: 32 * 1024 * 1024 }, (error, stdout) => { if (error) { resolve({ ok: false }); return; } - const files = stdout.split('\n').map((line) => line.trim()).filter(Boolean); + const files = stdout.split('\0').filter(Boolean); resolve({ ok: true, files }); }, ); diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index f05bdac90d..f24dc5a794 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -29,6 +29,7 @@ import type { import type { AppIcon, AppIconChoice, + AppIconTarget, AppSettings, ChatDefaultsSettings, SettingsTestResult, @@ -42,15 +43,16 @@ import type { BotProvider } from '@maka/core/bot-chat-settings'; import type { BotOnboardingSnapshot, BotOnboardingStartInput } from '@maka/core/bot-onboarding'; import type { HealthSnapshot } from '@maka/core/health'; import type { ExecutionBoundaryReadModel, SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; +import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant'; import type { ActiveInteractionRequestEvent, MessageContent, SessionCommand, SessionEvent, ShellRunUpdate, - QueueEnqueueOutcome, } from '@maka/core/events'; import type { UserQuestionResponse } from '@maka/core/user-question'; +import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind'; import type { PermissionMode } from '@maka/core/permission'; import type { CollaborationMode } from '@maka/core/collaboration'; import type { OrchestrationMode } from '@maka/core/orchestration'; @@ -94,17 +96,30 @@ import type { } from '@maka/core/daily-review'; import type { WebSearchProvider, WebSearchResponse } from '@maka/core/web-search'; import type { BrowserState, BrowserViewRect } from '@maka/core/browser'; -import type { Task, TaskLedgerChangedEvent } from '@maka/core/task-ledger'; +import type { SessionTodoItem } from '@maka/core/session-todo'; import type { DeepResearchChangedEvent, DeepResearchClientProgress } from '@maka/core/deep-research-run'; import type { DesktopTranscriptBatch, DesktopTranscriptHandle, } from './transcript-contract.js'; import type { PetPackManifestV1 } from '@maka/core/pet'; +import type { WorkBoardItem, WorkBoardListQuery, WorkBoardPage } from '@maka/core/work-board'; +import type { WorkBoardMutationOptions } from '@maka/storage/work-board-store'; import type { OperationInput, + OperationOutcome, OperationOutput, } from '@maka/runtime-host/protocol'; +import type { + CollaborationAccessQueryResult, + CollaborationGrantRevokeResult, + CollaborationInvitationPrepareResult, + CollaborationPrincipalRevokeResult, + CollaborationTurnRequestAcknowledgeResult, + CollaborationTurnRequestDecideResult, + CollaborationTurnRequestQueryResult, + SessionTurnAccessRequest, +} from '@maka/runtime-host/protocol'; import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client'; import type { RuntimeHostServiceManagementFrame, @@ -121,17 +136,33 @@ import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; import type { TestProxyInput } from '@maka/core/settings/network-settings'; import type { ExternalSessionImportIpcResult } from './external-session-import-result.js'; import type { DesktopSessionSummary } from '../shared/desktop-session-projection.js'; +import type { + SessionCollaborationCancelResult, + SessionCollaborationImportPhase, + SessionCollaborationImportResult, + SessionCollaborationMountSummary, +} from '../shared/session-collaboration.js'; /** * Outcome of importing artwork. `cancelled` is the user closing the dialog and * is not an error; the rest name why the file could not become an icon, so the * picker can say which rather than showing one generic failure. */ export type AppIconSelectResult = - | { readonly ok: true; readonly selection: AppIconChoice } + | { + readonly ok: true; + readonly selection: AppIconChoice; + /** Absent when one icon serves both appearances. */ + readonly darkSelection?: AppIconChoice; + } | { readonly ok: false; readonly reason: 'invalid_id' | 'missing_artwork' | 'write_failed' }; export type AppIconRemoveResult = - | { readonly ok: true; readonly selection: AppIconChoice } + | { + readonly ok: true; + readonly selection: AppIconChoice; + /** Absent when one icon serves both appearances. */ + readonly darkSelection?: AppIconChoice; + } | { readonly ok: false; readonly reason: 'invalid_id' | 'reset_failed' | 'remove_failed' }; export type AppIconImportResult = @@ -149,6 +180,7 @@ export type AppIconImportResult = }; export type { DesktopSessionSummary } from '../shared/desktop-session-projection.js'; +export type { WorkBoardChangedEvent, WorkBoardIpcResult } from '../shared/work-board-ipc.js'; import type { DesktopConnectionSnapshot } from '../shared/desktop-connection-snapshot.js'; import type { DesktopExternalSessionCatalogItem } from './external-session-catalog.js'; import type { DesktopDiagnosticInput } from './diagnostics-contract.js'; @@ -156,6 +188,7 @@ import type { Result } from '@maka/core/result'; import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs'; import type { McpConfigAddResult, + McpConfigImportResult, McpConfigFile, McpServerConfig, McpServerStatus, @@ -169,17 +202,18 @@ import type { import type { BotStatus, WechatBridgeQrCodeResult } from '@maka/runtime/bots'; import type { ShellRunPtyDataEvent, ShellRunPtySnapshot } from '@maka/runtime/shell-run-contract'; import type { BundledSkillCatalogEntry, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry } from '@maka/ui'; -import type { ConfigCategory } from '@maka/storage'; +import type { ConfigCategory } from '@maka/storage/config-transfer'; import type { OnboardingMilestone, OnboardingMilestoneId, OnboardingState } from '@maka/core/onboarding'; import type { RemoteRuntimeHostProfile, RuntimeHostProfile, + RuntimeHostProfileAccess, } from '@maka/runtime-host/client'; export interface OnboardingSnapshot { state: OnboardingState; milestones: OnboardingMilestone[]; sessions: DesktopSessionSummary[]; - connections: import('@maka/core/llm-connections').LlmConnection[]; + connections: import('@maka/core/llm-connections').ProjectedLlmConnection[]; defaultSlug: string | null; chatModelChoices: import('@maka/core/chat-model-choice').ChatModelChoice[]; sessionSendOutcomes: Record; @@ -200,6 +234,15 @@ export type DesktopBranchFromTurnInput = BranchFromTurnInput & { copyId: string; }; +export type DesktopSideConversationBranchResult = + | { ok: true; session: DesktopSessionSummary } + | { ok: false; reason: 'session_busy' | 'operation_unavailable' }; + +export type DesktopSessionStopResult = + | { kind: 'retracted'; messageId: string } + | { kind: 'interrupted'; retractedMessageIds: string[] } + | undefined; + export type DesktopReviseBeforeTurnInput = ReviseBeforeTurnInput & { /** Stable target identity for retrying one Desktop copy action. */ copyId: string; @@ -247,6 +290,7 @@ export type AppUpdateStatus = total?: number; }; } + | { state: 'verifying'; currentVersion: string; latestVersion: string } | { state: 'downloaded'; currentVersion: string; @@ -274,10 +318,12 @@ export type AppUpdateInstallResult = export interface DesktopRuntimeHostProfileEntry { readonly profile: RuntimeHostProfile; readonly managedService?: true; + readonly pairingPending?: true; readonly enabled: boolean; readonly isDefault: boolean; readonly readiness: 'disabled' | 'connecting' | 'ready' | 'reconnecting' | 'unavailable'; readonly hostId?: string; + readonly peerPath?: import('@maka/runtime-host/client').RuntimeHostPeerConnectionPath; readonly message?: string; } @@ -288,11 +334,61 @@ export interface DesktopRuntimeHostProfileSnapshot { readonly pairingRecoveryPending?: true; } +export type DesktopSessionCollaborationImportResult = SessionCollaborationImportResult; + +export type DesktopGuestSessionMountSummary = SessionCollaborationMountSummary; + +export type DesktopSessionCollaborationImportPhase = SessionCollaborationImportPhase; + +export type DesktopSessionCollaborationCancelResult = SessionCollaborationCancelResult; + +export type DesktopSessionCollaborationPrepareResult = + | { + readonly kind: 'prepared'; + readonly invitation: CollaborationInvitationPrepareResult & { + readonly connectivity: + | { + readonly kind: 'peer'; + readonly coordinationRelayCount: number; + } + | { readonly kind: 'configured' }; + }; + } + | { readonly kind: 'insecure_confirmation_required' }; + export interface DesktopRuntimeHostRef { readonly profileId: string; readonly hostId: string; } +export type DesktopConnectionOnboardingSaveOutcome = + | { + readonly kind: 'result'; + readonly result: OperationOutput<'connection.onboarding.save'>; + } + | { readonly kind: 'not_saved' } + | { readonly kind: 'outcome_unknown' }; + +/** Desktop-local OAuth intent. Runtime Host remains the identity allocator. */ +export type DesktopOAuthLoginTarget = + | { readonly kind: 'create' } + | { readonly kind: 'existing'; readonly connectionId: string }; + +/** Secret-free canonical identity returned by the Runtime Host OAuth flow. */ +export interface DesktopOAuthConnectionIdentity { + readonly connectionId: string; + readonly slug: string; + readonly providerType: 'openai-codex' | 'xai-oauth'; +} + +export type DesktopOAuthAuthorizationStartResult = + | (AuthorizationUrlPayload & { readonly connection: DesktopOAuthConnectionIdentity }) + | Exclude; + +export type DesktopOAuthAuthorizationResult = + | { readonly ok: true; readonly connection: DesktopOAuthConnectionIdentity } + | Exclude; + export type DesktopNewTaskHostRef = DesktopRuntimeHostRef; export interface DesktopNewTaskTarget extends DesktopRuntimeHostRef { @@ -333,7 +429,7 @@ export interface DesktopNewTaskCatalog { export interface DesktopRuntimeHostProfileAddInput { readonly profile: RemoteRuntimeHostProfile; - readonly credential?: string; + readonly credential: string; } export type DesktopRuntimeHostProfileAddResult = @@ -351,13 +447,55 @@ export interface DesktopRuntimeHostProfileChangedEvent { readonly epoch: string; readonly profileId: string; readonly profileName: string; - readonly profileKind: 'local' | 'remote'; + readonly profileKind: RuntimeHostProfileKind; + readonly profileAccess: RuntimeHostProfileAccess; + readonly collaborationAuthority?: boolean; readonly readiness: 'connecting' | 'ready' | 'reconnecting' | 'unavailable'; readonly hostId?: string; readonly isDefault: boolean; readonly removed?: boolean; } +export interface DesktopRuntimeHostIdentity extends DesktopRuntimeHostRef { + readonly targetEpoch: string; + readonly profileName: string; + readonly profileKind: RuntimeHostProfileKind; + readonly profileAccess: RuntimeHostProfileAccess; + readonly collaborationAuthority?: boolean; + readonly readiness: 'ready' | 'reconnecting'; +} + +export type DesktopLocalRuntimeHostRemoteAccessSnapshot = + | { readonly state: 'unsupported'; readonly message: string; readonly managedService?: true } + | { readonly state: 'off'; readonly managedService?: true; readonly sharedAccess?: true } + | { readonly state: 'on'; readonly managedService: true; readonly sharedAccess?: true } + | { + readonly state: 'unavailable'; + readonly message: string; + readonly managedService?: true; + readonly sharedAccess?: true; + }; + +export type DesktopRuntimeHostConnectionCodeImportResult = + | { readonly kind: 'connected'; readonly profileId: string } + | { + readonly kind: 'error'; + readonly reason: + | 'invalid_code' + | 'code_unavailable' + | 'host_unreachable' + | 'host_mismatch' + | 'unknown'; + }; + +export type DesktopLocalRuntimeHostRemoteAccessEnableResult = + | { readonly kind: 'active_tasks' } + | { + readonly kind: 'enabled'; + readonly connectionCode: string; + readonly snapshot: Extract; + }; + export type DesktopRuntimeHostSshTerminalEvent = | { readonly kind: 'opened'; readonly revision: number; readonly sessionId: string } | { readonly kind: 'data'; readonly revision: number; readonly sessionId: string; readonly data: string } @@ -383,14 +521,25 @@ export type DesktopRuntimeHostSshTerminalSnapshot = readonly signal: string | null; }; -export interface DesktopRuntimeHostOnboardingInput { - readonly name?: string; - readonly destination: string; - readonly sshPort?: number; -} +export type DesktopRuntimeHostOnboardingInput = + | { + readonly kind: 'ssh'; + readonly name?: string; + readonly destination: string; + readonly sshPort?: number; + readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[]; + } + | { + readonly kind: 'wsl'; + readonly name?: string; + readonly distribution: string; + readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[]; + }; export type DesktopRuntimeHostOnboardingPhase = + | 'preparing_cli' | 'connecting_ssh' + | 'connecting_wsl' | RuntimeHostSetupPhase | 'connecting_host'; @@ -424,14 +573,15 @@ export type DesktopRuntimeHostManagementResult = Extract< RuntimeHostServiceManagementFrame, { kind: 'result' } > & { - readonly action: DesktopRuntimeHostManagementAction | 'update'; + readonly action: DesktopRuntimeHostManagementAction | 'configure' | 'update'; readonly accessManagementAvailable: boolean; + readonly reconnectError?: { readonly code: string; readonly message: string }; }; export type DesktopRuntimeHostManagementResponse = | DesktopRuntimeHostManagementResult | (Extract & { - readonly action: DesktopRuntimeHostManagementAction | 'update'; + readonly action: DesktopRuntimeHostManagementAction | 'configure' | 'update'; }) | { readonly kind: 'uninstalled'; @@ -440,9 +590,75 @@ export type DesktopRuntimeHostManagementResponse = export interface DesktopRuntimeHostManagementProgress { readonly profileId: string; - readonly phase: import('@maka/runtime-host/operator').RuntimeHostServiceUpdatePhase; + readonly phase: + | 'preparing_cli' + | import('@maka/runtime-host/operator').RuntimeHostServiceUpdatePhase; +} + +export interface DesktopRuntimeHostDirectPeerSnapshot { + readonly state: 'unsupported' | 'not_configured' | 'disabled' | 'enabled'; + readonly peerId?: string; + readonly routeHints: readonly string[]; + readonly coordinationRelays: readonly string[]; + readonly automaticRelayDiscovery: boolean; + readonly webRtcStunPolicy?: import('@maka/runtime-host/operator').RuntimeHostWebRtcStunPolicy; + readonly profilePresent: boolean; + readonly profileEnabled: boolean; + readonly clientAvailable: boolean; + readonly managementAvailable: boolean; } +export type DesktopRuntimeHostPeerMeshTarget = + | { readonly kind: 'desktop' } + | { readonly kind: 'local_host' } + | { readonly kind: 'managed_host'; readonly profileId: string }; + +export type DesktopRuntimeHostPeerMeshAction = + import('@maka/runtime-host/operator').RuntimeHostPeerMeshManagementAction; + +export type DesktopRuntimeHostPeerMeshResult = + | import('@maka/runtime-host/protocol').PeerMeshQueryResult + | import('@maka/runtime-host/protocol').PeerMeshInvitationResult; + +export type DesktopRuntimeHostPeerMeshExecutionOutcome = + | { readonly kind: 'completed'; readonly result: DesktopRuntimeHostPeerMeshResult } + | { readonly kind: 'outcome_unknown' }; + +type RuntimeHostUpdatePolicyResult = Extract< + RuntimeHostServiceManagementFrame, + { kind: 'result'; action: 'update_policy' } +>; + +type RuntimeHostUpdateReconciliationResult = Extract< + RuntimeHostServiceManagementFrame, + { kind: 'result'; action: 'reconcile_update' } +>; + +export type DesktopRuntimeHostUpdateSchedulingState = + | 'unsupported' + | import('@maka/runtime-host/operator').RuntimeHostUpdateSchedulerState; + +export type DesktopRuntimeHostUpdatePolicySnapshot = + RuntimeHostUpdatePolicyResult['updatePolicy'] & { + readonly schedulingState: DesktopRuntimeHostUpdateSchedulingState; + }; + +export type DesktopRuntimeHostUpdateReconciliationOutcome = + RuntimeHostUpdateReconciliationResult['reconciliation']; + +export type DesktopRuntimeHostUpdateReconciliationResponse = + | { + readonly kind: 'error'; + readonly error: { readonly code: string; readonly message: string }; + } + | { + readonly kind: 'result'; + readonly updatePolicy: DesktopRuntimeHostUpdatePolicySnapshot; + readonly reconciliation: DesktopRuntimeHostUpdateReconciliationOutcome; + readonly service?: NonNullable; + readonly reconnectError?: { readonly code: string; readonly message: string }; + }; + export interface DesktopRuntimeHostAccessCredential { readonly credentialId: string; readonly principalKind: 'remote_owner' | 'capability_provider'; @@ -521,6 +737,49 @@ export interface DesktopSessionUsageSummary extends UsageSummaryV2 { } export interface MakaBridge { + sessionCollaboration: { + prepareInvitation( + sessionId: string, + preset: 'observe' | 'request_turn', + allowInsecure?: boolean, + ): Promise; + getAccess(sessionId: string): Promise; + revokeGrant( + sessionId: string, + grantId: string, + ): Promise; + revokePrincipal( + sessionId: string, + principalId: string, + ): Promise; + importInvitation(input: { + readonly code: string; + readonly allowInsecure?: boolean; + readonly operationId: string; + }, onProgress?: (phase: DesktopSessionCollaborationImportPhase) => void): Promise; + cancelImport(operationId: string): Promise; + /** Reads only after the user invokes the invitation paste action. */ + readInvitationClipboard(): Promise; + listMounts(): Promise; + removeMount(mountId: string): Promise; + requestTurn( + sessionId: string, + input: { readonly turnId: string; readonly text: string }, + ): Promise; + getTurnRequests(sessionId: string): Promise; + /** Pending Owner decisions across every connected Owner Runtime Host. */ + getPendingTurnRequests(): Promise; + acknowledgeTurnRequest( + sessionId: string, + requestId: string, + ): Promise; + decideTurnRequest( + sessionId: string, + requestId: string, + decision: 'approve' | 'reject', + ): Promise; + }; + runtimeHost: { query( operation: K, @@ -538,15 +797,28 @@ export interface MakaBridge { addAndEnable( input: DesktopRuntimeHostProfileAddInput, ): Promise; + importConnectionCode(code: string): Promise; remove(profileId: string): Promise; + discardPairing(profileId: string): Promise; setEnabled(profileId: string, enabled: boolean): Promise; setDefault(profileId: string): Promise; - resolvePairingRecovery(): Promise; + resolvePairingRecovery(profileId?: string): Promise; subscribeChanges( handler: (event: DesktopRuntimeHostProfileChangedEvent) => void, ): () => void; }; + localRuntimeHostRemoteAccess: { + getSnapshot(): Promise; + enable(input: { + readonly allowInterruptActiveTasks: boolean; + readonly coordinationRelays: readonly string[]; + }): Promise; + createConnectionCode(): Promise; + revokeSharedAccess(): Promise; + disable(): Promise; + }; + runtimeHostSshTerminal: { getSnapshot(): Promise; write(sessionId: string, data: string): Promise; @@ -556,6 +828,7 @@ export interface MakaBridge { }; runtimeHostOnboarding: { + listWslDistributions(): Promise; getSnapshot(): Promise; start(input: DesktopRuntimeHostOnboardingInput): Promise; cancel(): Promise; @@ -567,14 +840,35 @@ export interface MakaBridge { run( profileId: string, action: DesktopRuntimeHostManagementAction, + allowInterruptActiveTasks?: boolean, ): Promise; update( profileId: string, allowInterruptActiveTasks: boolean, ): Promise; + configureProjectDirectories( + profileId: string, + roots: readonly { readonly label: string; readonly path: string }[], + expectedConfigFingerprint: string, + allowInterruptActiveTasks: boolean, + ): Promise; subscribeProgress( handler: (progress: DesktopRuntimeHostManagementProgress) => void, ): () => void; + getUpdatePolicy(profileId: string): Promise; + setUpdatePolicy( + profileId: string, + policy: import('@maka/runtime-host/operator').RuntimeHostManagedUpdatePolicy, + ): Promise; + reconcileUpdate(profileId: string): Promise; + getDirectPeer(profileId: string): Promise; + configureDirectPeer( + profileId: string, + enabled: boolean, + coordinationRelays: readonly string[], + automaticRelayDiscovery: boolean, + webRtcStunPolicy?: import('@maka/runtime-host/operator').RuntimeHostWebRtcStunPolicy, + ): Promise; listCredentials(profileId: string): Promise; rotateCredential(profileId: string): Promise; revokeCredential( @@ -583,6 +877,25 @@ export interface MakaBridge { ): Promise; }; + runtimeHostPeerMesh: { + getConnectivityPolicy(): Promise; + setConnectivityPolicy( + policy: import('@maka/runtime-host/client').RuntimeHostWebRtcStunPolicy, + ): Promise; + execute( + target: DesktopRuntimeHostPeerMeshTarget, + action: DesktopRuntimeHostPeerMeshAction, + input: { + readonly meshId?: string | null; + readonly peerId?: string; + readonly invitation?: string; + readonly displayName?: string | null; + readonly operationId: string; + }, + ): Promise; + cancel(operationId: string): Promise; + }; + newTasks: { getCatalog(): Promise; subscribeChanges(handler: () => void): () => void; @@ -657,9 +970,29 @@ export interface MakaBridge { subscribeChanges(handler: (event: PetPackChangedEvent) => void): () => void; }; - tasks: { - list(sessionId: string): Promise; - subscribeChanges(handler: (event: TaskLedgerChangedEvent) => void): () => void; + workBoard: { + list(query?: WorkBoardListQuery): Promise>; + create(item: unknown): Promise>; + update( + id: string, + patch: unknown, + options?: WorkBoardMutationOptions, + ): Promise>; + archive( + id: string, + options?: WorkBoardMutationOptions, + ): Promise>; + unarchive( + id: string, + options?: WorkBoardMutationOptions, + ): Promise>; + remove(id: string, options?: WorkBoardMutationOptions): Promise>; + subscribeChanges(handler: (event: WorkBoardChangedEvent) => void): () => void; + }; + + todo: { + read(sessionId: string): Promise; + subscribeChanges(handler: (event: { sessionId: string; at: number }) => void): () => void; }; deepResearch: { get(sessionId: string): Promise; @@ -683,36 +1016,83 @@ export interface MakaBridge { handler: () => void, ): () => void; }; + workHub: { + /** Resolve the active Runtime Host's stable coordination conversation. */ + resolveCoordinationSession(): Promise; + /** Answer an ordinary question inside the persistent Coordination Session. */ + answer( + coordinationSessionId: string, + input: { turnId: string; text: string }, + ): Promise<{ turnId: string }>; + /** Persist one deterministic clarification or routing summary. */ + record( + coordinationSessionId: string, + input: { turnId: string; userText: string; assistantText: string }, + ): Promise<{ turnId: string }>; + /** Read one bounded, Host-issued candidate set for a coordination action. */ + candidates( + coordinationSessionId: string, + ): Promise>; + /** Submit a typed proposal; trusted creation context is added outside the renderer. */ + act( + coordinationSessionId: string, + input: Omit, 'create'>, + ): Promise>; + /** Create an ordinary Session on the exact Host owning the resolved conversation. */ + createSession( + coordinationSessionId: string, + input: { name: string }, + ): Promise; + }; sessions: { list(filter?: SessionListFilter): Promise; + listWithCoverage(): Promise<{ + sessions: DesktopSessionSummary[]; + completeHostIds: string[]; + }>; create(input?: CreateSessionRequestInput): Promise; send( sessionId: string, - command: - | SessionCommand - | { - type: 'send'; - turnId: string; - text: string; - displayText?: string; - skillIds?: string[]; - attachmentItems?: RendererIngestInput[]; - retainedAttachments?: import('@maka/core/events').AttachmentRef[]; - turnOrchestration?: TurnOrchestration; - quotes?: import('@maka/core/events').QuoteRef[]; - workspaceFileReferences?: Array< - Pick - >; - }, + command: { + type: 'send'; + turnId: string; + text: string; + displayText?: string; + skillIds?: string[]; + attachmentItems?: RendererIngestInput[]; + retainedAttachments?: import('@maka/core/events').AttachmentRef[]; + turnOrchestration?: TurnOrchestration; + directoryReferences?: import('@maka/core/events').DirectoryReference[]; + quotes?: import('@maka/core/events').QuoteRef[]; + workspaceFileReferences?: Array< + Pick + >; + }, ): Promise< | { ok: true; + /** + * The Turn Runtime Host opened for this Message. Admission mints it, + * so it is not the `turnId` the caller reserved — that identity is + * the Message's, and stays the caller's to reconcile with. + */ turnId: string; + steered?: never; + messageId?: never; + attachments: import('@maka/core/events').AttachmentRef[]; + inlineReferences: import('@maka/core/events').InlineReference[]; + skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; + } + | { + ok: true; /** - * The send raced a root Turn another client opened first and was - * queued into it as steering instead of starting `turnId`. + * The running Turn this Message was queued into as steering, rather + * than one opened for it. */ - steered?: true; + turnId: string; + steered: true; + /** Host admission identity for the message queued as steering. */ + messageId: string; attachments: import('@maka/core/events').AttachmentRef[]; inlineReferences: import('@maka/core/events').InlineReference[]; skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; @@ -722,31 +1102,67 @@ export interface MakaBridge { reason: 'skill_invocation_failed'; skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; } + | { + ok: false; + reason: 'outcome_unknown'; + messageId: string; + skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; + } >; stop( sessionId: string, - input?: { source?: 'stop_button'; expectedTurnId?: string }, - ): Promise; - steer(sessionId: string, text: string): Promise; - enqueue( + input?: { + source?: 'stop_button'; + expectedTurnId?: string; + expectedAdmissionId?: string; + }, + ): Promise; + /** + * The single Message admission path. Skill and orchestration intent travel + * with the Message; Runtime Host decides whether it opens its own Turn, + * steers the running one, or fails closed. + */ + submitMessage( sessionId: string, placement: 'current_turn' | 'next_turn', command: { + messageId: string; text: string; displayText?: string; + skillIds?: string[]; + turnOrchestration?: TurnOrchestration; attachmentItems?: RendererIngestInput[]; retainedAttachments?: import('@maka/core/events').AttachmentRef[]; + directoryReferences?: import('@maka/core/events').DirectoryReference[]; quotes?: import('@maka/core/events').QuoteRef[]; workspaceFileReferences?: Array< Pick >; }, - ): Promise<{ - kind: 'queued' | 'started'; - turnId?: string; - attachments: import('@maka/core/events').AttachmentRef[]; - inlineReferences: import('@maka/core/events').InlineReference[]; - }>; + ): Promise< + | { + ok: true; + disposition: 'turn_started' | 'steering' | 'followup'; + turnId?: string; + attachments: import('@maka/core/events').AttachmentRef[]; + inlineReferences: import('@maka/core/events').InlineReference[]; + skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; + } + | { + ok: false; + reason: 'skill_invocation_failed'; + skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; + } + | { ok: false; reason: 'outcome_unknown' } + >; + queryCancelledMessages( + sessionId: string, + messageIds: readonly string[], + ): Promise; + queryMessageExecutions( + sessionId: string, + messageIds: readonly string[], + ): Promise; retractQueueEntry(sessionId: string, entryId: string): Promise; promoteQueueEntry(sessionId: string, entryId: string): Promise; updateQueueEntry( @@ -772,9 +1188,20 @@ export interface MakaBridge { | { disposition: 'park'; rejectionReasons: string[]; diagnostics: unknown[] } >; regenerateTurn(sessionId: string, input: RegenerateTurnInput): Promise; - branchFromTurn(sessionId: string, input: DesktopBranchFromTurnInput): Promise; + branchFromTurn( + sessionId: string, + input: DesktopBranchFromTurnInput & { sideConversation: true }, + ): Promise; + branchFromTurn( + sessionId: string, + input: DesktopBranchFromTurnInput & { sideConversation?: false }, + ): Promise; reviseBeforeTurn(sessionId: string, input: DesktopReviseBeforeTurnInput): Promise; respondToSandboxBoundary(sessionId: string, response: SandboxBoundaryResponse): Promise; + respondToClientCapability( + sessionId: string, + response: ClientCapabilityResponse, + ): Promise; respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise; saveConversationToFile(input: { markdown: string; @@ -787,6 +1214,7 @@ export interface MakaBridge { handler: (event: SessionEvent) => void, onSeeded?: () => void, onObservationSeed?: (phase: 'pending' | 'ready') => void, + onSeedError?: (error: unknown) => void, ): () => void; subscribeChanges(handler: (event: SessionChangedEvent) => void): () => void; archive(sessionId: string, options?: { revisionFamily?: boolean }): Promise; @@ -823,16 +1251,24 @@ export interface MakaBridge { executionId: string; }>; abandonPlanExecution(sessionId: string, executionId: string): Promise; - setModel(sessionId: string, input: { llmConnectionSlug: string; model: string }): Promise; + setModel(sessionId: string, input: { llmConnectionId: string; llmConnectionSlug: string; model: string }): Promise; setThinkingLevel(sessionId: string, level: ThinkingLevel | undefined | null): Promise; /** * `requireArchived` holds the caller's premise through the deletion: a task - * restored meanwhile answers `restored` and is kept. + * restored meanwhile answers `restored` and is kept. `archivedSubtaskCount` + * is the Host's executed count of ordinary linked subtasks moved to the + * archive — 0 when restored or when nothing was archived. */ remove( sessionId: string, options?: { revisionFamily?: boolean; requireArchived?: boolean }, - ): Promise<'removed' | 'restored'>; + ): Promise<{ disposition: 'removed' | 'restored'; archivedSubtaskCount: number }>; + /** + * How many linked subtasks a delete of this parent would move to the + * archive, per the Host's removal plan. The confirm warns off this instead + * of estimating from the catalog projection. + */ + previewRemoval(sessionId: string): Promise; cleanupSessionCopy(sessionId: string): Promise; abandonSessionCopy(sourceSessionId: string, copyId: string): Promise; }; @@ -942,17 +1378,25 @@ export interface MakaBridge { }; connections: { getSnapshot(sessionId?: string, host?: DesktopRuntimeHostRef): Promise; - setDefault(slug: string | null, host?: DesktopRuntimeHostRef): Promise; + setDefault(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string | null, host?: DesktopRuntimeHostRef): Promise; setDefaultModel(input: { slug: string; model: string } | null, host?: DesktopRuntimeHostRef): Promise; - create(input: CreateConnectionInput, host?: DesktopRuntimeHostRef): Promise; - update(slug: string, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise; - delete(slug: string, host?: DesktopRuntimeHostRef): Promise; - test(slug: string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise; - fetchModels(slug: string, host?: DesktopRuntimeHostRef): Promise; - hasSecret(slug: string, host?: DesktopRuntimeHostRef): Promise; - getRequestHeaders(slug: string, host?: DesktopRuntimeHostRef): Promise; + create(input: CreateConnectionInput, host?: DesktopRuntimeHostRef): Promise; + verifyOnboarding( + input: OperationInput<'connection.onboarding.verify'>, + host?: DesktopRuntimeHostRef, + ): Promise>; + saveOnboarding( + input: OperationInput<'connection.onboarding.save'>, + host?: DesktopRuntimeHostRef, + ): Promise; + update(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise; + delete(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise; + test(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise; + fetchModels(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise>; + hasSecret(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise; + getRequestHeaders(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise; setRequestHeaders( - slug: string, + connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, headers: readonly import('@maka/core/llm-connections').RequestHeaderUpdate[], host?: DesktopRuntimeHostRef, ): Promise; @@ -961,7 +1405,7 @@ export interface MakaBridge { mcp: { getConfig(host?: DesktopRuntimeHostRef): Promise; listStatuses(host?: DesktopRuntimeHostRef): Promise; - setConfig(config: McpConfigFile, host?: DesktopRuntimeHostRef): Promise; + importConfig(source: string, host?: DesktopRuntimeHostRef): Promise; /** Adds a new server; a taken id comes back as `{ status: 'exists' }` * instead of an error, so the dialog can put it on the id field. */ add(serverId: string, config: McpServerConfig, host?: DesktopRuntimeHostRef): Promise; @@ -985,7 +1429,7 @@ export interface MakaBridge { subscribeExternalChanged(handler: () => void, host?: DesktopRuntimeHostRef): () => void; testNetworkProxy(input?: TestProxyInput, host?: DesktopRuntimeHostRef): Promise; testBotChannel(provider: BotProvider): Promise; - usageStats(range?: UsageRange): Promise; + usageStats(range?: UsageRange, host?: DesktopRuntimeHostRef): Promise; bots: { listStatuses(): Promise>; restart(provider: BotProvider): Promise; @@ -1056,6 +1500,7 @@ export interface MakaBridge { openBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; message: string }>; }; attachments: { + pickDirectory(): Promise<{ ok: true; reference: import('@maka/core/events').DirectoryReference } | { ok: false; reason: 'cancelled' }>; pickFiles(): Promise< | { ok: true; @@ -1072,10 +1517,7 @@ export interface MakaBridge { | { ok: true; base64: string; mimeType: string } | { ok: false; reason: string } >; - readBytes(sessionId: string, relativePath: string): Promise< - | { ok: true; base64: string; mimeType: string } - | { ok: false; reason: string } - >; + readBytes(sessionId: string, artifactId: string): Promise; }; search: { thread( @@ -1087,45 +1529,51 @@ export interface MakaBridge { }; openAiCodex: { isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise; - getAuthUrl(host?: DesktopRuntimeHostRef): Promise; + getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget): Promise; openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; - completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; + completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }>; - getAccountState(host?: DesktopRuntimeHostRef): Promise<{ - provider: 'openai-codex'; - runtimeState: - | 'not_logged_in' - | 'authorizing' - | 'authenticated' - | 'refreshing' - | 'refresh_failed'; - accountId?: string; - email?: string; - plan?: string; - picture?: string; - errorMessage?: string; - }>; - refreshTokens(host?: DesktopRuntimeHostRef): Promise; - logout(host?: DesktopRuntimeHostRef): Promise; + getAccountState(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise< + | { + provider: 'openai-codex'; + runtimeState: + | 'not_logged_in' + | 'authorizing' + | 'authenticated' + | 'refreshing' + | 'refresh_failed'; + accountId?: string; + email?: string; + plan?: string; + picture?: string; + errorMessage?: string; + } + | Exclude + >; + refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise; + logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise; }; xaiOAuth: { - getAuthUrl(host?: DesktopRuntimeHostRef): Promise; + getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget): Promise; openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; - completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; + completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }>; - getAccountState(host?: DesktopRuntimeHostRef): Promise<{ - provider: 'xai-oauth'; - runtimeState: - | 'not_logged_in' - | 'authorizing' - | 'authenticated' - | 'refreshing' - | 'refresh_failed' - | 'storage_failed'; - errorMessage?: string; - }>; - refreshTokens(host?: DesktopRuntimeHostRef): Promise; - logout(host?: DesktopRuntimeHostRef): Promise; + getAccountState(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise< + | { + provider: 'xai-oauth'; + runtimeState: + | 'not_logged_in' + | 'authorizing' + | 'authenticated' + | 'refreshing' + | 'refresh_failed' + | 'storage_failed'; + errorMessage?: string; + } + | Exclude + >; + refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise; + logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise; }; githubCopilotSubscription: { connectExistingLogin(host?: DesktopRuntimeHostRef): Promise; @@ -1245,7 +1693,7 @@ export interface MakaBridge { * generic settings channel so it queues behind import and removal in the * main process, and so a choice whose artwork is gone can be refused. */ - selectIcon(icon: AppIconChoice): Promise; + selectIcon(icon: AppIconChoice, target?: AppIconTarget): Promise; /** Opens a file picker in the main process and stores a normalized copy. */ importIcon(): Promise; /** @@ -1302,6 +1750,8 @@ export interface MakaBridge { }; diagnostics: { copyReport(input: DesktopDiagnosticInput): Promise; + takePreviousMainProcessInterruption(): Promise; + copyPreviousMainProcessInterruption(): Promise; }; workspace: { searchFiles( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 67b2c15bb7..dd8946a877 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -18,10 +18,17 @@ */ import { contextBridge, ipcRenderer } from 'electron'; +import { + isRuntimeHostProfileKind, + type RuntimeHostProfileKind, +} from '@maka/runtime-host/profile-kind'; import { encodeIngestItems } from './attachment-ingest-payload.js'; import { collectThreadSearchResponses } from './multi-host-thread-search.js'; -import { notifyWhenSeeded } from './seed-completion.js'; import { releaseSessionObservation } from './session-observation-release.js'; +import { + resolveDesktopWorkHubCoordinationCreateScope, + resolveDesktopWorkHubCoordinationSession, +} from './workhub-coordination-session.js'; import type { MakaBridge, OnboardingSnapshot, @@ -30,12 +37,15 @@ import type { PermissionOverlayStartResult, RendererIngestInput, DesktopBranchFromTurnInput, + DesktopSideConversationBranchResult, + DesktopSessionStopResult, DesktopReviseBeforeTurnInput, AppUpdateInstallRequest, AppUpdateInstallResult, AppUpdateStatus, WindowCommand, PetPackChangedEvent, + WorkBoardChangedEvent, DesktopRuntimeHostProfileAddInput, DesktopRuntimeHostProfileChangedEvent, DesktopRuntimeHostProfileSnapshot, @@ -52,6 +62,8 @@ import type { DesktopNewTaskHostRef, DesktopNewTaskTarget, DesktopRuntimeHostRef, + DesktopOAuthLoginTarget, + DesktopOAuthAuthorizationResult, DesktopProjectSnapshot, DesktopAppInfo, DesktopSessionTracePage, @@ -73,6 +85,10 @@ import { type DesktopTranscriptHandle, type DesktopTranscriptOpenResult, } from './transcript-contract.js'; +import { + adoptTranscriptIdentity, + type DesktopTranscriptIdentity, +} from './transcript-identity.js'; import type { DesktopDiagnosticInput, DesktopErrorDiagnosticWireInput, @@ -92,6 +108,7 @@ import type { import type { AppIcon, AppIconChoice, + AppIconTarget, AppSettings, SettingsTestResult, UpdateAppSettingsInput, @@ -103,15 +120,22 @@ import type { import type { BotProvider } from '@maka/core/bot-chat-settings'; import type { BotOnboardingSnapshot, BotOnboardingStartInput } from '@maka/core/bot-onboarding'; import type { HealthSnapshot } from '@maka/core/health'; -import { collectRuntimeHostSessionCatalogs } from './runtime-host-session-catalog.js'; +import { + collectRuntimeHostSessionCatalogsWithCoverage, + resolveRuntimeHostSessionCatalog, +} from './runtime-host-session-catalog.js'; +import { + collectAvailablePendingTurnRequests, + selectRuntimeHostCollaborationScopes, +} from './runtime-host-turn-request-inbox.js'; import type { ExecutionBoundaryReadModel, SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; +import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant'; import type { ActiveInteractionRequestEvent, MessageContent, SessionCommand, SessionEvent, ShellRunUpdate, - QueueEnqueueOutcome, } from '@maka/core/events'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { PermissionMode } from '@maka/core/permission'; @@ -142,10 +166,7 @@ import type { } from '@maka/core/artifacts'; import type { CapabilitySnapshotCollection, PermissionSnapshot } from '@maka/core/capabilities'; import type { LocalMemoryState } from '@maka/core/local-memory'; -import type { - AuthorizationUrlPayload, - SubscriptionActionResult, -} from '@maka/core/oauth-subscription'; +import type { SubscriptionActionResult } from '@maka/core/oauth-subscription'; import type { CreateScheduledTaskInput, ScheduledTask, UpdateScheduledTaskInput } from '@maka/core/scheduled-task'; import type { ProjectRecord } from '@maka/core/project'; import type { @@ -158,7 +179,7 @@ import type { import type { WebSearchProvider, WebSearchResponse } from '@maka/core/web-search'; import type { BrowserState, BrowserViewRect } from '@maka/core/browser'; import { createBrowserSelectionCoordinator } from './browser-selection.js'; -import type { Task, TaskLedgerChangedEvent } from '@maka/core/task-ledger'; +import type { SessionTodoItem } from '@maka/core/session-todo'; import type { DeepResearchChangedEvent, DeepResearchClientProgress } from '@maka/core/deep-research-run'; import { isWebSearchProvider, @@ -183,7 +204,7 @@ import type { BotStatus, WechatBridgeQrCodeResult } from '@maka/runtime/bots'; import type { ShellRunPtyDataEvent, ShellRunPtySnapshot } from '@maka/runtime/shell-run-contract'; import type { GoalState } from '@maka/runtime/goal-state'; import type { BundledSkillCatalogEntry, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry } from '@maka/ui'; -import type { ConfigCategory } from '@maka/storage'; +import type { ConfigCategory } from '@maka/storage/config-transfer'; import { SENSITIVE_PLACEHOLDER, type TestProxyInput, @@ -192,6 +213,7 @@ import type { Result } from '@maka/core/result'; import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs'; import type { McpConfigAddResult, + McpConfigImportResult, McpConfigFile, McpServerConfig, McpServerStatus, @@ -202,7 +224,10 @@ import type { OnboardingMilestoneId } from '@maka/core/onboarding'; import { SCHEDULED_TASK_CATALOG_MAX_ITEMS, type OperationInput, + type OperationOutcome, type OperationOutput, + type CollaborationTurnRequestQueryResult, + type SessionTurnAccessRequest, } from '@maka/runtime-host/protocol'; import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client'; import { @@ -222,45 +247,82 @@ import { projectDesktopSessionEvent, projectDesktopSessionSummary, projectDesktopTurnRecord, + projectDesktopUsageStats, type DesktopSessionSummary, } from '../shared/desktop-session-projection.js'; let activeRuntimeHost: DesktopTargetScope | undefined; let activeRuntimeHostGeneration = 0; +type RuntimeHostScopeKey = string; const runtimeHostScopes = new Map(); const runtimeHostProfiles = new Map(); const runtimeHostMetadata = new Map< string, - { readonly profileId: string; readonly profileName: string; readonly profileKind: 'local' | 'remote' } + { + readonly profileId: string; + readonly profileName: string; + readonly profileKind: RuntimeHostProfileKind; + readonly profileAccess: 'owner' | 'session_guest'; + readonly collaborationAuthority?: boolean; + } >(); +const runtimeHostSessionScopes = new Map(); +let lastDesktopSessionCatalog: DesktopSessionSummary[] = []; const newTaskChangeListeners = new Set<() => void>(); +let previousMainProcessInterruptionRead: Promise | undefined; + +function runtimeHostScopeKey(scope: DesktopTargetScope): RuntimeHostScopeKey { + return `${scope.hostId}\u0000${scope.targetEpoch}`; +} + +function runtimeHostMetadataFor(scope: DesktopTargetScope) { + return runtimeHostMetadata.get(runtimeHostScopeKey(scope)); +} + +function recordRuntimeHostSessionScope(scope: DesktopTargetScope, sessionId: string): string { + const projected = desktopSessionKey({ hostId: scope.hostId, sessionId }); + runtimeHostSessionScopes.set(projected, runtimeHostScopeKey(scope)); + return projected; +} type RuntimeHostProfileWireEvent = DesktopRuntimeHostProfileChangedEvent; ipcRenderer.on( 'runtime-host-profiles:changed', (_event, change: RuntimeHostProfileWireEvent) => { - const previousHostId = runtimeHostProfiles.get(change.profileId); + const previousScopeKey = runtimeHostProfiles.get(change.profileId); + const nextScope = change.hostId + ? { hostId: change.hostId, targetEpoch: change.epoch } + : undefined; + const nextScopeKey = nextScope ? runtimeHostScopeKey(nextScope) : undefined; if ( - previousHostId && - (change.removed || (change.hostId !== undefined && previousHostId !== change.hostId)) + previousScopeKey && + (change.removed || (nextScopeKey !== undefined && previousScopeKey !== nextScopeKey)) ) { - runtimeHostScopes.delete(previousHostId); + for (const [sessionId, scopeKey] of runtimeHostSessionScopes) { + if (scopeKey !== previousScopeKey) continue; + if (nextScopeKey) runtimeHostSessionScopes.set(sessionId, nextScopeKey); + else runtimeHostSessionScopes.delete(sessionId); + } + runtimeHostScopes.delete(previousScopeKey); + runtimeHostMetadata.delete(previousScopeKey); if (change.removed) { - runtimeHostMetadata.delete(previousHostId); runtimeHostProfiles.delete(change.profileId); } } - if (change.hostId) { - const scope = { hostId: change.hostId, targetEpoch: change.epoch }; - runtimeHostScopes.set(scope.hostId, scope); - runtimeHostProfiles.set(change.profileId, change.hostId); - runtimeHostMetadata.set(change.hostId, { + if (nextScope && nextScopeKey) { + runtimeHostScopes.set(nextScopeKey, nextScope); + runtimeHostProfiles.set(change.profileId, nextScopeKey); + runtimeHostMetadata.set(nextScopeKey, { profileId: change.profileId, profileName: change.profileName, profileKind: change.profileKind, + profileAccess: change.profileAccess, + ...(typeof change.collaborationAuthority === 'boolean' + ? { collaborationAuthority: change.collaborationAuthority } + : {}), }); - if (change.isDefault) activeRuntimeHost = scope; + if (change.isDefault) activeRuntimeHost = nextScope; } else if (change.isDefault) { activeRuntimeHost = undefined; } @@ -285,22 +347,30 @@ function recordRuntimeHostIdentity(value: unknown): { profileId?: unknown; profileName?: unknown; profileKind?: unknown; + profileAccess?: unknown; + collaborationAuthority?: unknown; readiness?: unknown; }; if ( typeof metadata.profileId !== 'string' || typeof metadata.profileName !== 'string' || - (metadata.profileKind !== 'local' && metadata.profileKind !== 'remote') || + !isRuntimeHostProfileKind(metadata.profileKind) || + (metadata.profileAccess !== 'owner' && metadata.profileAccess !== 'session_guest') || (metadata.readiness !== 'ready' && metadata.readiness !== 'reconnecting') ) { throw new Error('Desktop Runtime Host identity is invalid'); } - runtimeHostScopes.set(scope.hostId, scope); - runtimeHostProfiles.set(metadata.profileId, scope.hostId); - runtimeHostMetadata.set(scope.hostId, { + const scopeKey = runtimeHostScopeKey(scope); + runtimeHostScopes.set(scopeKey, scope); + runtimeHostProfiles.set(metadata.profileId, scopeKey); + runtimeHostMetadata.set(scopeKey, { profileId: metadata.profileId, profileName: metadata.profileName, profileKind: metadata.profileKind, + profileAccess: metadata.profileAccess, + ...(typeof metadata.collaborationAuthority === 'boolean' + ? { collaborationAuthority: metadata.collaborationAuthority } + : {}), }); return { scope, readiness: metadata.readiness }; } @@ -313,16 +383,17 @@ async function runtimeHostScopeList(): Promise { if (!Array.isArray(identities)) { throw new Error('Desktop Runtime Host identities are unavailable'); } - const authoritativeHostIds = new Set(); + const authoritativeScopeKeys = new Set(); const readyScopes: DesktopTargetScope[] = []; for (const identity of identities) { const { scope, readiness } = recordRuntimeHostIdentity(identity); - authoritativeHostIds.add(scope.hostId); + authoritativeScopeKeys.add(runtimeHostScopeKey(scope)); if (readiness === 'ready') readyScopes.push(scope); } - for (const hostId of runtimeHostScopes.keys()) { - if (authoritativeHostIds.has(hostId)) continue; - runtimeHostScopes.delete(hostId); + for (const scopeKey of runtimeHostScopes.keys()) { + if (authoritativeScopeKeys.has(scopeKey)) continue; + runtimeHostScopes.delete(scopeKey); + runtimeHostMetadata.delete(scopeKey); } return readyScopes; } @@ -334,7 +405,12 @@ async function runtimeHostSessionRef(sessionId: string): Promise<{ }> { const ref = parseDesktopSessionKey(sessionId); await runtimeHostScopeList(); - const scope = runtimeHostScopes.get(ref.hostId); + const recordedScopeKey = runtimeHostSessionScopes.get(sessionId); + let scope = recordedScopeKey ? runtimeHostScopes.get(recordedScopeKey) : undefined; + if (!scope) { + const candidates = [...runtimeHostScopes.values()].filter(({ hostId }) => hostId === ref.hostId); + if (candidates.length === 1) scope = candidates[0]; + } if (!scope) throw new Error('The Runtime Host for this task is unavailable'); return { scope, sessionId: ref.sessionId }; } @@ -348,8 +424,8 @@ type TaskDiagnosticRuntimeHostResolution = DiagnosticRuntimeHostResolution<'task type ManualDiagnosticRuntimeHostResolution = DiagnosticRuntimeHostResolution<'default' | 'task'>; type ManualDiagnosticHostSelector = - | { readonly kind: 'host'; readonly hostId: string } - | { readonly kind: 'profile'; readonly profileId: string }; + | { readonly kind: 'profile'; readonly profileId: string } + | { readonly kind: 'session'; readonly sessionId: string }; async function resolveManualDiagnosticRuntimeHost( value: DesktopManualDiagnosticTarget | undefined, @@ -370,10 +446,14 @@ async function resolveTaskDiagnosticRuntimeHost( } catch { return { hostTarget: 'task' }; } - const hostId = selector.kind === 'host' - ? selector.hostId - : runtimeHostProfiles.get(selector.profileId); - const scope = hostId ? runtimeHostScopes.get(hostId) : undefined; + if (selector.kind === 'session') { + try { + return { hostTarget: 'task', scope: (await runtimeHostSessionRef(selector.sessionId)).scope }; + } catch { + return { hostTarget: 'task' }; + } + } + const scope = runtimeHostScopes.get(runtimeHostProfiles.get(selector.profileId) ?? ''); return { hostTarget: 'task', ...(scope ? { scope } : {}) }; } @@ -392,7 +472,7 @@ function parseDiagnosticTarget(value: unknown): { Buffer.byteLength(record.sessionId, 'utf8') <= 512 ) { return { - selector: { kind: 'host', hostId: parseDesktopSessionKey(record.sessionId).hostId }, + selector: { kind: 'session', sessionId: record.sessionId }, }; } if ( @@ -416,11 +496,10 @@ function parseDiagnosticTarget(value: unknown): { typeof record.eventId === 'string' && Buffer.byteLength(record.eventId, 'utf8') <= 512 ) { - const session = parseDesktopSessionKey(record.sessionId); return { - selector: { kind: 'host', hostId: session.hostId }, + selector: { kind: 'session', sessionId: record.sessionId }, execution: { - sessionId: session.sessionId, + sessionId: parseDesktopSessionKey(record.sessionId).sessionId, turnId: record.turnId, eventId: record.eventId, }, @@ -442,7 +521,7 @@ async function activeRuntimeHostRef(): Promise { async function localRuntimeHostRef(): Promise { const scopes = await runtimeHostScopeList(); const scope = scopes.find( - (candidate) => runtimeHostMetadata.get(candidate.hostId)?.profileKind === 'local', + (candidate) => runtimeHostMetadataFor(candidate)?.profileKind === 'local', ); if (!scope) throw new Error('The Local Runtime Host is unavailable'); return scope; @@ -453,9 +532,9 @@ async function runtimeHostScope(host: DesktopRuntimeHostRef): Promise { ) as DesktopRuntimeHostProfileSnapshot; await runtimeHostScopeList(); const hosts = await Promise.all( - profiles.entries.filter((entry) => entry.enabled).map(async (entry): Promise => { + profiles.entries + .filter( + (entry) => + entry.enabled && + (entry.profile.kind !== 'remote' || entry.profile.access !== 'session_guest'), + ) + .map(async (entry): Promise => { if (entry.readiness !== 'ready' || !entry.hostId) { return { profile: entry.profile, @@ -521,7 +606,7 @@ async function loadNewTaskCatalog(): Promise { message: error instanceof Error ? error.message : String(error), }; } - }), + }), ); if (generation !== activeRuntimeHostGeneration) continue; return { defaultProfileId: profiles.defaultProfileId, hosts }; @@ -608,6 +693,34 @@ async function invokeSessionSummary( return projectSessionSummary(session.scope, summary); } +async function invokeBranchFromTurn( + sessionId: string, + input: DesktopBranchFromTurnInput & { sideConversation: true }, +): Promise; +async function invokeBranchFromTurn( + sessionId: string, + input: DesktopBranchFromTurnInput & { sideConversation?: false }, +): Promise; +async function invokeBranchFromTurn( + sessionId: string, + input: DesktopBranchFromTurnInput, +): Promise { + const ref = await runtimeHostSessionRef(sessionId); + const result = await ipcRenderer.invoke( + 'sessions:branchFromTurn', + ref.scope, + ref.sessionId, + input, + ) as SessionSummary | { ok: true; session: SessionSummary } | { ok: false; reason: string }; + if (input.sideConversation) { + if (!('ok' in result) || result.ok === false) { + return result as DesktopSideConversationBranchResult; + } + return { ok: true, session: projectSessionSummary(ref.scope, result.session) }; + } + return projectSessionSummary(ref.scope, result as SessionSummary); +} + async function invokeSessionInput( channel: string, input: I, @@ -626,7 +739,16 @@ function projectSessionSummary( scope: DesktopTargetScope, session: SessionSummary, ): DesktopSessionSummary { - const metadata = runtimeHostMetadata.get(scope.hostId); + const projected = projectSessionCatalogSummary(scope, session); + runtimeHostSessionScopes.set(projected.id, runtimeHostScopeKey(scope)); + return projected; +} + +function projectSessionCatalogSummary( + scope: DesktopTargetScope, + session: SessionSummary, +): DesktopSessionSummary { + const metadata = runtimeHostMetadataFor(scope); if (!metadata) throw new Error('Desktop Runtime Host metadata is unavailable'); return projectDesktopSessionSummary( { ...scope, ...metadata }, @@ -634,6 +756,17 @@ function projectSessionSummary( ); } +function recordSessionCatalogScopes(sessions: readonly DesktopSessionSummary[]): void { + for (const session of sessions) { + const scopeKey = runtimeHostProfiles.get(session.profileId); + const scope = scopeKey ? runtimeHostScopes.get(scopeKey) : undefined; + if (!scopeKey || !scope || scope.hostId !== session.runtimeHostId) { + throw new Error('Desktop Runtime Host Session scope is unavailable'); + } + runtimeHostSessionScopes.set(session.id, scopeKey); + } +} + function projectOnboardingSnapshot( scope: DesktopTargetScope, snapshot: OnboardingSnapshot, @@ -643,7 +776,7 @@ function projectOnboardingSnapshot( sessions: snapshot.sessions.map((session) => projectSessionSummary(scope, session)), sessionSendOutcomes: Object.fromEntries( Object.entries(snapshot.sessionSendOutcomes).map(([sessionId, outcome]) => [ - desktopSessionKey({ hostId: scope.hostId, sessionId }), + recordRuntimeHostSessionScope(scope, sessionId), outcome, ]), ), @@ -690,7 +823,7 @@ function projectShellRunUpdate( update: ShellRunUpdate, ): ShellRunUpdate { const sessionId = (value: string): string => - desktopSessionKey({ hostId: scope.hostId, sessionId: value }); + recordRuntimeHostSessionScope(scope, value); return { ...update, sessionId: sessionId(update.sessionId), @@ -725,7 +858,7 @@ function subscribeRuntimeHostEvent( } catch { return; } - const current = runtimeHostScopes.get(scope.hostId); + const current = runtimeHostScopes.get(runtimeHostScopeKey(scope)); if ( !current || eventScope.hostId !== current.hostId || @@ -753,7 +886,7 @@ function subscribeEveryRuntimeHostEvent( } catch { return; } - const current = runtimeHostScopes.get(scope.hostId); + const current = runtimeHostScopes.get(runtimeHostScopeKey(scope)); if (!current || current.targetEpoch !== scope.targetEpoch) return; handler(scope, ...(args as unknown as T)); }; @@ -776,17 +909,63 @@ async function listDesktopSessions( ) as SessionCatalogSummary[]; return sessions.map((session) => projectSessionSummary(parent.scope, session)); } + lastDesktopSessionCatalog = await resolveRuntimeHostSessionCatalog( + lastDesktopSessionCatalog, + listDesktopSessionsWithCoverage(), + () => [...runtimeHostMetadata.values()].map(({ profileId }) => profileId), + // Unknown Guest coverage cannot suppress healthy Owner catalogs or prove + // that a previously observed Guest mount was removed. + listKnownGuestMountProfileIds(), + ); + return lastDesktopSessionCatalog; +} + +async function listDesktopSessionsWithCoverage(): Promise<{ + sessions: DesktopSessionSummary[]; + completeHostIds: string[]; +}> { const scopes = await runtimeHostScopeList(); - return collectRuntimeHostSessionCatalogs( - scopes.map(async (scope) => { - const sessions = await ipcRenderer.invoke( - 'sessions:list', - scope, - filter, - ) as SessionCatalogSummary[]; - return sessions.map((session) => projectSessionSummary(scope, session)); + const catalog = await collectRuntimeHostSessionCatalogsWithCoverage( + scopes.map((scope) => { + const metadata = runtimeHostMetadataFor(scope); + if (!metadata) throw new Error('Desktop Runtime Host metadata is unavailable'); + return { + hostId: scope.hostId, + access: metadata.profileAccess, + sessions: ipcRenderer.invoke('sessions:list', scope) + .then((sessions: SessionCatalogSummary[]) => + sessions.map((session) => projectSessionCatalogSummary(scope, session))), + }; }), ); + recordSessionCatalogScopes(catalog.sessions); + return catalog; +} + +async function listKnownGuestMountProfileIds(): Promise { + const mounts: unknown = await ipcRenderer.invoke('session-collaboration:mount:list'); + if (!Array.isArray(mounts)) { + throw new Error('Desktop shared Session mounts are unavailable'); + } + return mounts.map((mount) => { + if ( + !mount || + typeof mount !== 'object' || + !('mountId' in mount) || + typeof mount.mountId !== 'string' + ) { + throw new Error('Desktop shared Session mount is invalid'); + } + return mount.mountId; + }); +} + +async function createDesktopSessionOnScope( + scope: DesktopTargetScope, + input?: CreateSessionRequestInput, +): Promise { + const session = await ipcRenderer.invoke('sessions:create', scope, input) as SessionSummary; + return projectSessionSummary(scope, session); } function sendActiveRuntimeHost(channel: string, ...args: unknown[]): void { @@ -830,7 +1009,7 @@ function subscribeSelectedRuntimeHostEvent( return subscribeEveryRuntimeHostEvent(channel, (scope, ...args: T) => { if ( scope.hostId !== host.hostId || - runtimeHostProfiles.get(host.profileId) !== scope.hostId + runtimeHostProfiles.get(host.profileId) !== runtimeHostScopeKey(scope) ) return; handler(...args); }); @@ -1097,30 +1276,168 @@ const browserSelection = createBrowserSelectionCoordinator(runtimeHostSessionRef const makaBridge = { runtimeHost, + sessionCollaboration: { + async prepareInvitation(sessionId, preset, allowInsecure = false) { + const session = await runtimeHostSessionRef(sessionId); + return ipcRenderer.invoke( + 'session-collaboration:prepare', + session.scope, + session.sessionId, + preset, + allowInsecure, + ); + }, + async getAccess(sessionId) { + const session = await runtimeHostSessionRef(sessionId); + return ipcRenderer.invoke( + 'session-collaboration:getAccess', + session.scope, + session.sessionId, + ); + }, + async revokePrincipal(sessionId, principalId) { + const session = await runtimeHostSessionRef(sessionId); + return ipcRenderer.invoke( + 'session-collaboration:revokePrincipal', + session.scope, + principalId, + ); + }, + async revokeGrant(sessionId, grantId) { + const session = await runtimeHostSessionRef(sessionId); + return ipcRenderer.invoke( + 'session-collaboration:revokeGrant', + session.scope, + grantId, + ); + }, + async importInvitation({ code, allowInsecure = false, operationId }, onProgress) { + const listener = ( + _event: Electron.IpcRendererEvent, + progressOperationId: string, + phase: Parameters>[0], + ) => { + if (progressOperationId === operationId) onProgress?.(phase); + }; + ipcRenderer.on('session-collaboration:import:progress', listener); + try { + return await ipcRenderer.invoke( + 'session-collaboration:import', + code, + allowInsecure, + operationId, + ); + } finally { + ipcRenderer.off('session-collaboration:import:progress', listener); + } + }, + cancelImport(operationId) { + return ipcRenderer.invoke('session-collaboration:import:cancel', operationId); + }, + readInvitationClipboard() { + return ipcRenderer.invoke('session-collaboration:invitation:read-clipboard'); + }, + listMounts() { + return ipcRenderer.invoke('session-collaboration:mount:list'); + }, + removeMount(mountId) { + return ipcRenderer.invoke('session-collaboration:mount:remove', mountId); + }, + async requestTurn(sessionId, input) { + const session = await runtimeHostSessionRef(sessionId); + return ipcRenderer.invoke( + 'session-collaboration:turn-request:create', + session.scope, + { + sessionId: session.sessionId, + turnId: input.turnId, + content: { text: input.text }, + }, + ); + }, + async getTurnRequests(sessionId) { + const session = await runtimeHostSessionRef(sessionId); + return ipcRenderer.invoke( + 'session-collaboration:turn-request:query', + session.scope, + session.sessionId, + ); + }, + async getPendingTurnRequests() { + const scopes = selectRuntimeHostCollaborationScopes( + (await runtimeHostScopeList()).flatMap((scope) => { + const metadata = runtimeHostMetadataFor(scope); + return metadata?.profileAccess === 'owner' + ? [{ scope, collaborationAuthority: metadata.collaborationAuthority }] + : []; + }), + ).map(({ scope }) => scope); + return collectAvailablePendingTurnRequests( + scopes.map(async (scope) => { + const result = await ipcRenderer.invoke( + 'session-collaboration:turn-request:query', + scope, + ) as CollaborationTurnRequestQueryResult; + return result.requests + .filter((request) => request.state.kind === 'pending') + .map((request): SessionTurnAccessRequest => ({ + ...request, + intent: { + ...request.intent, + sessionId: recordRuntimeHostSessionScope(scope, request.intent.sessionId), + }, + })); + }), + ); + }, + async acknowledgeTurnRequest(sessionId, requestId) { + const session = await runtimeHostSessionRef(sessionId); + return ipcRenderer.invoke( + 'session-collaboration:turn-request:acknowledge', + session.scope, + requestId, + ); + }, + async decideTurnRequest(sessionId, requestId, decision) { + const session = await runtimeHostSessionRef(sessionId); + return ipcRenderer.invoke( + 'session-collaboration:turn-request:decide', + session.scope, + requestId, + decision, + ); + }, + }, runtimeHostProfiles: { getSnapshot() { return ipcRenderer.invoke('runtime-host-profiles:getSnapshot'); }, async getDefaultHost(): Promise { const scope = await activeRuntimeHostRef(); - const metadata = runtimeHostMetadata.get(scope.hostId); + const metadata = runtimeHostMetadataFor(scope); if (!metadata) throw new Error('The default Runtime Host identity is unavailable'); return { profileId: metadata.profileId, hostId: scope.hostId }; }, addAndEnable(input: DesktopRuntimeHostProfileAddInput) { return ipcRenderer.invoke('runtime-host-profiles:add-and-enable', input); }, + importConnectionCode(code: string) { + return ipcRenderer.invoke('runtime-host-profiles:import-connection-code', code); + }, remove(profileId: string) { return ipcRenderer.invoke('runtime-host-profiles:remove', profileId); }, + discardPairing(profileId: string) { + return ipcRenderer.invoke('runtime-host-profiles:discard-pairing', profileId); + }, setEnabled(profileId: string, enabled: boolean) { return ipcRenderer.invoke('runtime-host-profiles:set-enabled', profileId, enabled); }, setDefault(profileId: string) { return ipcRenderer.invoke('runtime-host-profiles:set-default', profileId); }, - resolvePairingRecovery() { - return ipcRenderer.invoke('runtime-host-profiles:resolve-pairing-recovery'); + resolvePairingRecovery(profileId?: string) { + return ipcRenderer.invoke('runtime-host-profiles:resolve-pairing-recovery', profileId); }, subscribeChanges(handler: (event: DesktopRuntimeHostProfileChangedEvent) => void) { const listener = ( @@ -1133,6 +1450,28 @@ const makaBridge = { return () => ipcRenderer.off('runtime-host-profiles:changed', listener); }, }, + localRuntimeHostRemoteAccess: { + getSnapshot() { + return ipcRenderer.invoke('local-runtime-host-remote-access:get-snapshot'); + }, + enable(input: { + readonly allowInterruptActiveTasks: boolean; + readonly coordinationRelays: readonly string[]; + }) { + return ipcRenderer.invoke('local-runtime-host-remote-access:enable', input); + }, + createConnectionCode() { + return ipcRenderer.invoke( + 'local-runtime-host-remote-access:create-connection-code', + ); + }, + revokeSharedAccess() { + return ipcRenderer.invoke('local-runtime-host-remote-access:revoke-shared-access'); + }, + disable() { + return ipcRenderer.invoke('local-runtime-host-remote-access:disable'); + }, + }, runtimeHostSshTerminal: { getSnapshot(): Promise { return ipcRenderer.invoke('runtime-host-ssh-terminal:getSnapshot'); @@ -1163,6 +1502,9 @@ const makaBridge = { }, }, runtimeHostOnboarding: { + listWslDistributions(): Promise { + return ipcRenderer.invoke('runtime-host-onboarding:listWslDistributions'); + }, getSnapshot(): Promise { return ipcRenderer.invoke('runtime-host-onboarding:getSnapshot'); }, @@ -1188,8 +1530,14 @@ const makaBridge = { run( profileId: string, action: DesktopRuntimeHostManagementAction, + allowInterruptActiveTasks = false, ): Promise { - return ipcRenderer.invoke('runtime-host-management:run', profileId, action); + return ipcRenderer.invoke( + 'runtime-host-management:run', + profileId, + action, + allowInterruptActiveTasks, + ); }, update( profileId: string, @@ -1201,6 +1549,20 @@ const makaBridge = { allowInterruptActiveTasks, ); }, + configureProjectDirectories( + profileId: string, + roots: readonly { readonly label: string; readonly path: string }[], + expectedConfigFingerprint: string, + allowInterruptActiveTasks: boolean, + ): Promise { + return ipcRenderer.invoke( + 'runtime-host-management:configure-project-directories', + profileId, + roots, + expectedConfigFingerprint, + allowInterruptActiveTasks, + ); + }, subscribeProgress(handler: (progress: DesktopRuntimeHostManagementProgress) => void) { const listener = ( _event: Electron.IpcRendererEvent, @@ -1209,6 +1571,37 @@ const makaBridge = { ipcRenderer.on('runtime-host-management:progress', listener); return () => ipcRenderer.off('runtime-host-management:progress', listener); }, + getUpdatePolicy(profileId: string) { + return ipcRenderer.invoke('runtime-host-management:get-update-policy', profileId); + }, + setUpdatePolicy( + profileId: string, + policy: import('@maka/runtime-host/operator').RuntimeHostManagedUpdatePolicy, + ) { + return ipcRenderer.invoke('runtime-host-management:set-update-policy', profileId, policy); + }, + reconcileUpdate(profileId: string) { + return ipcRenderer.invoke('runtime-host-management:reconcile-update', profileId); + }, + getDirectPeer(profileId: string) { + return ipcRenderer.invoke('runtime-host-management:get-direct-peer', profileId); + }, + configureDirectPeer( + profileId: string, + enabled: boolean, + coordinationRelays: readonly string[], + automaticRelayDiscovery: boolean, + webRtcStunPolicy?: import('@maka/runtime-host/operator').RuntimeHostWebRtcStunPolicy, + ) { + return ipcRenderer.invoke( + 'runtime-host-management:configure-direct-peer', + profileId, + enabled, + coordinationRelays, + automaticRelayDiscovery, + webRtcStunPolicy, + ); + }, listCredentials(profileId: string): Promise { return ipcRenderer.invoke('runtime-host-management:list-credentials', profileId); }, @@ -1226,6 +1619,41 @@ const makaBridge = { ); }, }, + runtimeHostPeerMesh: { + getConnectivityPolicy() { + return ipcRenderer.invoke('runtime-host-peer-mesh:get-connectivity-policy'); + }, + setConnectivityPolicy( + policy: import('@maka/runtime-host/client').RuntimeHostWebRtcStunPolicy, + ) { + return ipcRenderer.invoke('runtime-host-peer-mesh:set-connectivity-policy', policy); + }, + execute( + target: import('./bridge-contract.js').DesktopRuntimeHostPeerMeshTarget, + action: import('./bridge-contract.js').DesktopRuntimeHostPeerMeshAction, + input: { + readonly meshId?: string | null; + readonly peerId?: string; + readonly invitation?: string; + readonly displayName?: string | null; + readonly operationId: string; + }, + ) { + return ipcRenderer.invoke( + 'runtime-host-peer-mesh:execute', + target, + action, + input.meshId, + input.peerId, + input.invitation, + input.displayName, + input.operationId, + ); + }, + cancel(operationId: string) { + return ipcRenderer.invoke('runtime-host-peer-mesh:cancel', operationId); + }, + }, newTasks: { getCatalog(): Promise { return loadNewTaskCatalog(); @@ -1308,11 +1736,10 @@ const makaBridge = { input?: CreateSessionRequestInput, ): Promise { const scope = await runtimeHostScope(target); - const session = await ipcRenderer.invoke('sessions:create', scope, { + return createDesktopSessionOnScope(scope, { ...input, projectId: target.projectId, - }) as SessionSummary; - return projectSessionSummary(scope, session); + }); }, }, pets: { @@ -1341,15 +1768,41 @@ const makaBridge = { return () => ipcRenderer.off('pets:changed', listener); }, }, - tasks: { - list(sessionId: string): Promise { - return invokeProjectedSessionRuntimeHost('tasks:list', sessionId); + workBoard: { + list(query) { + return ipcRenderer.invoke('workBoard:list', query); + }, + create(item) { + return ipcRenderer.invoke('workBoard:create', item); + }, + update(id, patch, options) { + return ipcRenderer.invoke('workBoard:update', id, patch, options); + }, + archive(id, options) { + return ipcRenderer.invoke('workBoard:archive', id, options); + }, + unarchive(id, options) { + return ipcRenderer.invoke('workBoard:unarchive', id, options); + }, + remove(id, options) { + return ipcRenderer.invoke('workBoard:remove', id, options); + }, + subscribeChanges(handler: (event: WorkBoardChangedEvent) => void): () => void { + const listener = (_event: Electron.IpcRendererEvent, payload: WorkBoardChangedEvent) => + handler(payload); + ipcRenderer.on('workBoard:changed', listener); + return () => ipcRenderer.off('workBoard:changed', listener); + }, + }, + todo: { + read(sessionId: string): Promise { + return invokeProjectedSessionRuntimeHost('todo:read', sessionId); }, - subscribeChanges(handler: (event: TaskLedgerChangedEvent) => void): () => void { - return subscribeEveryRuntimeHostEvent('tasks:changed', (scope, event: TaskLedgerChangedEvent) => + subscribeChanges(handler: (event: { sessionId: string; at: number }) => void): () => void { + return subscribeEveryRuntimeHostEvent('todo:changed', (scope, event: { sessionId: string; at: number }) => handler({ ...event, - sessionId: desktopSessionKey({ hostId: scope.hostId, sessionId: event.sessionId }), + sessionId: recordRuntimeHostSessionScope(scope, event.sessionId), }), ); }, @@ -1362,7 +1815,7 @@ const makaBridge = { return subscribeEveryRuntimeHostEvent('deepResearch:changed', (scope, event: DeepResearchChangedEvent) => handler({ ...event, - sessionId: desktopSessionKey({ hostId: scope.hostId, sessionId: event.sessionId }), + sessionId: recordRuntimeHostSessionScope(scope, event.sessionId), }), ); }, @@ -1432,10 +1885,96 @@ const makaBridge = { }; }, }, + workHub: { + resolveCoordinationSession(): Promise { + return resolveDesktopWorkHubCoordinationSession( + activeRuntimeHostRef, + (scope) => ipcRenderer.invoke('workhub:resolveCoordinationSession', scope), + ); + }, + async answer( + coordinationSessionId: string, + input: { turnId: string; text: string }, + ): Promise<{ turnId: string }> { + const scope = await resolveDesktopWorkHubCoordinationCreateScope( + coordinationSessionId, + runtimeHostSessionRef, + ); + return ipcRenderer.invoke('workhub:answer', scope, input) as Promise<{ turnId: string }>; + }, + async record( + coordinationSessionId: string, + input: { turnId: string; userText: string; assistantText: string }, + ): Promise<{ turnId: string }> { + const scope = await resolveDesktopWorkHubCoordinationCreateScope( + coordinationSessionId, + runtimeHostSessionRef, + ); + return ipcRenderer.invoke('workhub:record', scope, input) as Promise<{ turnId: string }>; + }, + async candidates( + coordinationSessionId: string, + ): Promise> { + const scope = await resolveDesktopWorkHubCoordinationCreateScope( + coordinationSessionId, + runtimeHostSessionRef, + ); + const result = await ipcRenderer.invoke( + 'workhub:candidates', + scope, + ) as OperationOutput<'workhub.coordination.candidates'>; + return { + ...result, + candidates: result.candidates.map((candidate) => ({ + ...candidate, + sessionId: recordRuntimeHostSessionScope(scope, candidate.sessionId), + })), + }; + }, + async act( + coordinationSessionId: string, + input: Omit, 'create'>, + ): Promise> { + const scope = await resolveDesktopWorkHubCoordinationCreateScope( + coordinationSessionId, + runtimeHostSessionRef, + ); + const result = await ipcRenderer.invoke( + 'workhub:act', + scope, + input, + ) as OperationOutcome<'workhub.coordination.act'>; + if (!result.ok) return result; + if ( + result.result.disposition === 'answer_here' || + result.result.disposition === 'clarify' + ) return result; + return { + ok: true, + result: { + ...result.result, + targetSessionId: recordRuntimeHostSessionScope(scope, result.result.targetSessionId), + }, + }; + }, + async createSession( + coordinationSessionId: string, + input: { name: string }, + ): Promise { + const scope = await resolveDesktopWorkHubCoordinationCreateScope( + coordinationSessionId, + runtimeHostSessionRef, + ); + return createDesktopSessionOnScope(scope, input); + }, + }, sessions: { list(filter?: SessionListFilter): Promise { return listDesktopSessions(filter); }, + listWithCoverage() { + return listDesktopSessionsWithCoverage(); + }, /** * The single session-creation channel (#1433). `mode` names a * product intent — main derives the permission boundary, name and @@ -1444,62 +1983,26 @@ const makaBridge = { */ async create(input?: CreateSessionRequestInput): Promise { const scope = await activeRuntimeHostRef(); - const session = await ipcRenderer.invoke('sessions:create', scope, input) as SessionSummary; - return projectSessionSummary(scope, session); + return createDesktopSessionOnScope(scope, input); }, - async send( - sessionId: string, - command: - | SessionCommand - | { - type: 'send'; - turnId: string; - text: string; - displayText?: string; - skillIds?: string[]; - attachmentItems?: RendererIngestInput[]; - retainedAttachments?: AttachmentRef[]; - turnOrchestration?: TurnOrchestration; - quotes?: QuoteRef[]; - workspaceFileReferences?: Array>; - }, - ): Promise< - | { - ok: true; - turnId: string; - attachments: AttachmentRef[]; - inlineReferences: InlineReference[]; - skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; - } - | { - ok: false; - reason: 'skill_invocation_failed'; - skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; - } - > { + async send(sessionId, command) { const session = await runtimeHostSessionRef(sessionId); - const send = async (input: SessionCommand | Record) => { - const result = await ipcRenderer.invoke( - 'sessions:send', - session.scope, - session.sessionId, - input, - ) as Awaited>; - return result.ok - ? { - ...result, - attachments: projectDesktopAttachmentRefs(session.scope, result.attachments), - } - : result; - }; - if (command.type === 'send' && 'attachmentItems' in command && command.attachmentItems) { - const encoded = await encodeIngestItems(command.attachmentItems as RendererIngestInput[]); - return send({ - ...command, - attachmentItems: encoded, - }); + if (command.directoryReferences?.some((ref) => ref.hostId !== session.scope.hostId)) { + throw new Error('Directory references belong to a different Runtime Host. Select the folder on the target Host.'); } - return send(command); + const encoded = + 'attachmentItems' in command && command.attachmentItems + ? { ...command, attachmentItems: await encodeIngestItems(command.attachmentItems) } + : command; + const result = (await ipcRenderer.invoke( + 'sessions:send', + session.scope, + session.sessionId, + encoded, + )) as Awaited>; + return result.ok + ? { ...result, attachments: projectDesktopAttachmentRefs(session.scope, result.attachments) } + : result; }, compact(sessionId: string): Promise> { return invokeSessionRuntimeHost('sessions:compact', sessionId); @@ -1512,36 +2015,24 @@ const makaBridge = { }, stop( sessionId: string, - input?: { source?: 'stop_button'; expectedTurnId?: string }, - ): Promise { + input?: { + source?: 'stop_button'; + expectedTurnId?: string; + expectedAdmissionId?: string; + }, + ): Promise { return invokeSessionRuntimeHost('sessions:stop', sessionId, input); }, - steer(sessionId: string, text: string): Promise { - return invokeSessionRuntimeHost('sessions:steer', sessionId, text); - }, - async enqueue( - sessionId: string, - placement: 'current_turn' | 'next_turn', - command: { - text: string; - displayText?: string; - attachmentItems?: RendererIngestInput[]; - retainedAttachments?: AttachmentRef[]; - quotes?: QuoteRef[]; - workspaceFileReferences?: Array>; - }, - ): Promise<{ - kind: 'queued' | 'started'; - turnId?: string; - attachments: AttachmentRef[]; - inlineReferences: InlineReference[]; - }> { + async submitMessage(sessionId, placement, command) { const session = await runtimeHostSessionRef(sessionId); + if (command.directoryReferences?.some((ref) => ref.hostId !== session.scope.hostId)) { + throw new Error('Directory references belong to a different Runtime Host. Select the folder on the target Host.'); + } const attachmentItems = command.attachmentItems ? await encodeIngestItems(command.attachmentItems) : undefined; - const result = await ipcRenderer.invoke( - 'sessions:enqueue', + const result = (await ipcRenderer.invoke( + 'sessions:submitMessage', session.scope, session.sessionId, placement, @@ -1549,16 +2040,16 @@ const makaBridge = { ...command, ...(attachmentItems ? { attachmentItems } : {}), }, - ) as { - kind: 'queued' | 'started'; - turnId?: string; - attachments: AttachmentRef[]; - inlineReferences: InlineReference[]; - }; - return { - ...result, - attachments: projectDesktopAttachmentRefs(session.scope, result.attachments), - }; + )) as Awaited>; + return result.ok + ? { ...result, attachments: projectDesktopAttachmentRefs(session.scope, result.attachments) } + : result; + }, + queryCancelledMessages(sessionId, messageIds) { + return invokeSessionRuntimeHost('sessions:queryCancelledMessages', sessionId, messageIds); + }, + queryMessageExecutions(sessionId, messageIds) { + return invokeSessionRuntimeHost('sessions:queryMessageExecutions', sessionId, messageIds); }, retractQueueEntry(sessionId: string, entryId: string): Promise { return invokeSessionRuntimeHost('sessions:retractQueueEntry', sessionId, entryId); @@ -1600,7 +2091,7 @@ const makaBridge = { (scope, event: { sessionId: string; interactions: ActiveInteractionRequestEvent[] }) => handler({ ...event, - sessionId: desktopSessionKey({ hostId: scope.hostId, sessionId: event.sessionId }), + sessionId: recordRuntimeHostSessionScope(scope, event.sessionId), }), ); }, @@ -1619,13 +2110,7 @@ const makaBridge = { regenerateTurn(sessionId: string, input: RegenerateTurnInput): Promise { return invokeSessionRuntimeHost('sessions:regenerateTurn', sessionId, input); }, - async branchFromTurn(sessionId: string, input: DesktopBranchFromTurnInput): Promise { - const ref = await runtimeHostSessionRef(sessionId); - const summary = await ipcRenderer.invoke( - 'sessions:branchFromTurn', ref.scope, ref.sessionId, input, - ) as SessionSummary; - return projectSessionSummary(ref.scope, summary); - }, + branchFromTurn: invokeBranchFromTurn, async reviseBeforeTurn(sessionId: string, input: DesktopReviseBeforeTurnInput): Promise { const ref = await runtimeHostSessionRef(sessionId); const summary = await ipcRenderer.invoke( @@ -1636,6 +2121,16 @@ const makaBridge = { respondToSandboxBoundary(sessionId: string, response: SandboxBoundaryResponse): Promise { return invokeSessionRuntimeHost('sessions:respondToSandboxBoundary', sessionId, response); }, + respondToClientCapability( + sessionId: string, + response: ClientCapabilityResponse, + ): Promise { + return invokeSessionRuntimeHost( + 'sessions:respondToClientCapability', + sessionId, + response, + ); + }, respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise { return invokeSessionRuntimeHost('sessions:respondToUserQuestion', sessionId, response); }, @@ -1658,6 +2153,7 @@ const makaBridge = { handler: (event: SessionEvent) => void, onSeeded?: () => void, onObservationSeed?: (phase: 'pending' | 'ready') => void, + onSeedError?: (error: unknown) => void, ): () => void { const observerId = crypto.randomUUID(); let disposed = false; @@ -1665,15 +2161,23 @@ const makaBridge = { let unsubscribeObservationSeed = () => {}; const observeDispatch = runtimeHostSessionRef(sessionId).then((session) => { if (disposed) return { completion: Promise.resolve() }; - unsubscribeEvents = subscribeRuntimeHostEvent( + const profileId = runtimeHostMetadataFor(session.scope)?.profileId; + if (!profileId) throw new Error('The Runtime Host profile for this task is unavailable'); + // Keep the renderer listener across Host target epochs. The observer + // registry restores this observer on the replacement target. Profile + // identity admits that replacement without accepting another Host's + // same-named Session channel. + unsubscribeEvents = subscribeEveryRuntimeHostEvent( `sessions:event:${session.sessionId}`, - session.scope, - (event: SessionEvent) => handler(projectDesktopSessionEvent(session.scope, event)), + (scope, event: SessionEvent) => { + if (runtimeHostMetadataFor(scope)?.profileId !== profileId) return; + handler(projectDesktopSessionEvent(scope, event)); + }, ); - unsubscribeObservationSeed = subscribeRuntimeHostEvent( + unsubscribeObservationSeed = subscribeEveryRuntimeHostEvent( 'sessions:observation-seed', - session.scope, - (payload: { sessionId?: string; phase?: string }) => { + (scope, payload: { sessionId?: string; phase?: string }) => { + if (runtimeHostMetadataFor(scope)?.profileId !== profileId) return; if (payload.sessionId !== session.sessionId) return; if (payload.phase === 'pending' || payload.phase === 'ready') { onObservationSeed?.(payload.phase); @@ -1690,10 +2194,16 @@ const makaBridge = { }; }); const observing = observeDispatch.then(({ completion }) => completion); - const disposeSeedNotification = notifyWhenSeeded(observing, onSeeded); + void observing.then( + () => { + if (!disposed) onSeeded?.(); + }, + (error: unknown) => { + if (!disposed) onSeedError?.(error); + }, + ); return () => { disposed = true; - disposeSeedNotification(); unsubscribeObservationSeed(); unsubscribeEvents(); void releaseSessionObservation(observeDispatch, () => @@ -1709,10 +2219,7 @@ const makaBridge = { ...event, ...(event.sessionId ? { - sessionId: desktopSessionKey({ - hostId: scope.hostId, - sessionId: event.sessionId, - }), + sessionId: recordRuntimeHostSessionScope(scope, event.sessionId), } : {}), }), @@ -1788,7 +2295,7 @@ const makaBridge = { abandonPlanExecution(sessionId: string, executionId: string): Promise { return invokeProjectedSessionRuntimeHost('plan-mode:abandonExecution', sessionId, executionId); }, - setModel(sessionId: string, input: { llmConnectionSlug: string; model: string }): Promise { + setModel(sessionId: string, input: { llmConnectionId: string; llmConnectionSlug: string; model: string }): Promise { return invokeSessionSummary('sessions:setModel', sessionId, input); }, setThinkingLevel(sessionId: string, level: ThinkingLevel | undefined | null): Promise { @@ -1797,9 +2304,12 @@ const makaBridge = { remove( sessionId: string, options?: { revisionFamily?: boolean; requireArchived?: boolean }, - ): Promise<'removed' | 'restored'> { + ): Promise<{ disposition: 'removed' | 'restored'; archivedSubtaskCount: number }> { return invokeSessionRuntimeHost('sessions:remove', sessionId, options); }, + previewRemoval(sessionId: string): Promise { + return invokeSessionRuntimeHost('sessions:removePreview', sessionId); + }, cleanupSessionCopy(sessionId: string): Promise { return invokeSessionRuntimeHost('sessions:cleanupSessionCopy', sessionId); }, @@ -1816,7 +2326,7 @@ const makaBridge = { ): Promise { const consumerId = crypto.randomUUID(); const channel = `sessions:transcript:${consumerId}`; - let generation: string | undefined; + let identity: DesktopTranscriptIdentity | undefined; let closed = false; let requestClose = () => {}; let consumerScope: DesktopTargetScope | undefined; @@ -1835,11 +2345,12 @@ const makaBridge = { host.targetEpoch !== consumerScope.targetEpoch ) return; batch = assertDesktopTranscriptBatch(value); - if (batch.reset || generation === undefined) { - generation = batch.generation; + const adopted = adoptTranscriptIdentity(identity, batch); + if (adopted !== identity) { + identity = adopted; consumerScope = host; } - if (batch.generation === generation) handler(batch); + if (identity !== undefined && batch.generation === identity.generation) handler(batch); } catch (error) { requestClose(); throw error; @@ -1886,18 +2397,24 @@ const makaBridge = { throw error; } if (closed) throw new Error('Desktop transcript open was cancelled'); - generation ??= opened.generation; + identity ??= { generation: opened.generation, hostEpoch: opened.hostEpoch }; const range = ( operation: 'sessions:transcript:load-before' | 'sessions:transcript:load-around', anchorSequence: number | null, maxBytes = DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - ): Promise => - ipcRenderer.invoke(operation, consumerScope, { + ): Promise => { + const currentIdentity = identity; + if (!currentIdentity) { + throw new Error('Desktop transcript identity is unavailable'); + } + return ipcRenderer.invoke(operation, consumerScope, { consumerId, - generation, + sessionId: opened.sessionId, + hostEpoch: currentIdentity.hostEpoch, anchorSequence, maxBytes, }) as Promise; + }; return { ...opened, sessionId, @@ -1991,7 +2508,7 @@ const makaBridge = { }, subscribeLocalChanges(handler: () => void): () => void { return subscribeEveryRuntimeHostEvent('projects:changed', (scope) => { - if (runtimeHostMetadata.get(scope.hostId)?.profileKind === 'local') handler(); + if (runtimeHostMetadataFor(scope)?.profileKind === 'local') handler(); }); }, add(host?: DesktopRuntimeHostRef): Promise< @@ -2064,10 +2581,7 @@ const makaBridge = { return snapshot ? { ...snapshot, - sessionId: desktopSessionKey({ - hostId: session.scope.hostId, - sessionId: snapshot.sessionId, - }), + sessionId: recordRuntimeHostSessionScope(session.scope, snapshot.sessionId), } : null; }, @@ -2114,14 +2628,14 @@ const makaBridge = { return subscribeEveryRuntimeHostEvent('shell-runs:pty-data', (scope, event: ShellRunPtyDataEvent) => handler({ ...event, - sessionId: desktopSessionKey({ hostId: scope.hostId, sessionId: event.sessionId }), + sessionId: recordRuntimeHostSessionScope(scope, event.sessionId), }), ); }, subscribeResync(handler: (event: { sessionId: string }) => void): () => void { return subscribeEveryRuntimeHostEvent('shell-runs:resync', (scope, event: { sessionId: string }) => handler({ - sessionId: desktopSessionKey({ hostId: scope.hostId, sessionId: event.sessionId }), + sessionId: recordRuntimeHostSessionScope(scope, event.sessionId), }), ); }, @@ -2158,39 +2672,54 @@ const makaBridge = { ? invokeRuntimeHostForSession('connections:getSnapshot', sessionId) : invokeSelectedRuntimeHost(host, 'connections:getSnapshot'); }, - setDefault(slug: string | null, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'connections:setDefault', slug); + setDefault(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity | string | null, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost( + host, + typeof connection === 'string' ? 'connections:setDefaultBySlug' : 'connections:setDefault', + connection, + ); }, setDefaultModel(input: { slug: string; model: string } | null, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'connections:setDefaultModel', input); }, - create(input: CreateConnectionInput, host?: DesktopRuntimeHostRef): Promise { + create(input: CreateConnectionInput, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'connections:create', input); }, - update(slug: string, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'connections:update', slug, patch); + verifyOnboarding(input, host) { + return invokeSelectedRuntimeHost(host, 'connections:onboardingVerify', input); }, - delete(slug: string, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'connections:delete', slug); + saveOnboarding(input, host) { + return invokeSelectedRuntimeHost(host, 'connections:onboardingSave', input); }, - test(slug: string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'connections:test', slug, opts); + update(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost(host, 'connections:update', connection, patch); }, - fetchModels(slug: string, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'connections:fetchModels', slug); + delete(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost(host, 'connections:delete', connection); }, - hasSecret(slug: string, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'connections:hasSecret', slug); + test(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity | string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost( + host, + typeof connection === 'string' ? 'connections:testBySlug' : 'connections:test', + connection, + opts, + ); + }, + fetchModels(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise> { + return invokeSelectedRuntimeHost(host, 'connections:fetchModels', connection); + }, + hasSecret(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost(host, 'connections:hasSecret', connection); }, - getRequestHeaders(slug: string, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'connections:getRequestHeaders', slug); + getRequestHeaders(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost(host, 'connections:getRequestHeaders', connection); }, setRequestHeaders( - slug: string, + connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, headers: readonly import('@maka/core/llm-connections').RequestHeaderUpdate[], host?: DesktopRuntimeHostRef, ): Promise { - return invokeSelectedRuntimeHost(host, 'connections:setRequestHeaders', slug, headers); + return invokeSelectedRuntimeHost(host, 'connections:setRequestHeaders', connection, headers); }, subscribeEvents(handler: (event: ConnectionEvent) => void, host?: DesktopRuntimeHostRef): () => void { return host @@ -2208,8 +2737,8 @@ const makaBridge = { listStatuses(host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'mcp:listStatuses'); }, - setConfig(config: McpConfigFile, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'mcp:setConfig', config); + importConfig(source: string, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost(host, 'mcp:importConfig', source); }, add(serverId: string, config: McpServerConfig, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'mcp:add', serverId, config); @@ -2332,6 +2861,7 @@ const makaBridge = { }, }, attachments: { + pickDirectory: () => ipcRenderer.invoke('directories:pick'), pickFiles(): Promise< | { ok: true; @@ -2354,11 +2884,8 @@ const makaBridge = { > { return ipcRenderer.invoke('attachments:previewApproval', approvalId); }, - readBytes(sessionId: string, relativePath: string): Promise< - | { ok: true; base64: string; mimeType: string } - | { ok: false; reason: string } - > { - return invokeSessionRuntimeHost('attachments:readBytes', sessionId, relativePath); + readBytes(sessionId: string, artifactId: string): Promise { + return invokeSessionRuntimeHost('attachments:readBytes', sessionId, artifactId); }, }, search: { @@ -2378,10 +2905,7 @@ const makaBridge = { ...entry, target: { ...entry.target, - sessionId: desktopSessionKey({ - hostId: scope.hostId, - sessionId: entry.target.sessionId, - }), + sessionId: recordRuntimeHostSessionScope(scope, entry.target.sessionId), }, } : entry, @@ -2402,19 +2926,19 @@ const makaBridge = { isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'openai-codex:is-experimental-enabled'); }, - getAuthUrl(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'openai-codex:get-auth-url'); + getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget) { + return invokeSelectedRuntimeHost(host, 'openai-codex:get-auth-url', target); }, openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'openai-codex:open-auth-url', authRequestId); }, - completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { + completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'openai-codex:complete-authorization', authRequestId); }, cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }> { return invokeSelectedRuntimeHost(host, 'openai-codex:cancel-authorization', authRequestId); }, - getAccountState(host?: DesktopRuntimeHostRef): Promise<{ + getAccountState(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<{ provider: 'openai-codex'; runtimeState: 'not_logged_in' | 'authorizing' | 'authenticated' | 'refreshing' | 'refresh_failed'; accountId?: string; @@ -2423,29 +2947,29 @@ const makaBridge = { picture?: string; errorMessage?: string; }> { - return invokeSelectedRuntimeHost(host, 'openai-codex:get-account-state'); + return invokeSelectedRuntimeHost(host, 'openai-codex:get-account-state', connectionId); }, - refreshTokens(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'openai-codex:refresh-tokens'); + refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise { + return invokeSelectedRuntimeHost(host, 'openai-codex:refresh-tokens', connectionId); }, - logout(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'openai-codex:logout'); + logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise { + return invokeSelectedRuntimeHost(host, 'openai-codex:logout', connectionId); }, }, xaiOAuth: { - getAuthUrl(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'xai-oauth:get-auth-url'); + getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget) { + return invokeSelectedRuntimeHost(host, 'xai-oauth:get-auth-url', target); }, openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'xai-oauth:open-auth-url', authRequestId); }, - completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { + completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'xai-oauth:complete-authorization', authRequestId); }, cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }> { return invokeSelectedRuntimeHost(host, 'xai-oauth:cancel-authorization', authRequestId); }, - getAccountState(host?: DesktopRuntimeHostRef): Promise<{ + getAccountState(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<{ provider: 'xai-oauth'; runtimeState: | 'not_logged_in' @@ -2456,13 +2980,13 @@ const makaBridge = { | 'storage_failed'; errorMessage?: string; }> { - return invokeSelectedRuntimeHost(host, 'xai-oauth:get-account-state'); + return invokeSelectedRuntimeHost(host, 'xai-oauth:get-account-state', connectionId); }, - refreshTokens(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'xai-oauth:refresh-tokens'); + refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise { + return invokeSelectedRuntimeHost(host, 'xai-oauth:refresh-tokens', connectionId); }, - logout(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'xai-oauth:logout'); + logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise { + return invokeSelectedRuntimeHost(host, 'xai-oauth:logout', connectionId); }, }, githubCopilotSubscription: { @@ -2555,8 +3079,10 @@ const makaBridge = { testBotChannel(provider: BotProvider): Promise { return ipcRenderer.invoke('settings:testBotChannel', provider); }, - usageStats(range?: UsageRange): Promise { - return ipcRenderer.invoke('settings:usageStats', range); + async usageStats(range?: UsageRange, host?: DesktopRuntimeHostRef): Promise { + const scope = await selectedRuntimeHostScope(host); + const stats = await ipcRenderer.invoke('settings:usageStats', scope, range) as UsageStats; + return projectDesktopUsageStats(scope, stats); }, bots: { listStatuses(): Promise> { @@ -2612,13 +3138,24 @@ const makaBridge = { return loadSessionUsageSummary(sessionId); }, subscribeUsageChanges(sessionId: string, handler: () => void): () => void { - const session = parseDesktopSessionKey(sessionId); - return subscribeEveryRuntimeHostEvent( - 'usage:changed', - (scope, event: { sessionId: string }) => { - if (scope.hostId === session.hostId && event.sessionId === session.sessionId) handler(); - }, - ); + let disposed = false; + let unsubscribe = () => {}; + void runtimeHostSessionRef(sessionId) + .then(({ scope, sessionId: rawSessionId }) => { + if (disposed) return; + unsubscribe = subscribeRuntimeHostEvent( + 'usage:changed', + scope, + (event: { sessionId: string }) => { + if (event.sessionId === rawSessionId) handler(); + }, + ); + }) + .catch(() => undefined); + return () => { + disposed = true; + unsubscribe(); + }; }, /** * What the session's context is made of right now (#2323). @@ -2775,8 +3312,8 @@ const makaBridge = { iconPreviews(): Promise> { return ipcRenderer.invoke('app:iconPreviews'); }, - selectIcon(icon: AppIconChoice): Promise { - return ipcRenderer.invoke('app:selectIcon', icon); + selectIcon(icon: AppIconChoice, target?: AppIconTarget): Promise { + return ipcRenderer.invoke('app:selectIcon', icon, target); }, importIcon(): Promise { return ipcRenderer.invoke('app:importIcon'); @@ -2846,6 +3383,15 @@ const makaBridge = { }, }, diagnostics: { + takePreviousMainProcessInterruption(): Promise { + previousMainProcessInterruptionRead ??= ipcRenderer.invoke( + 'diagnostics:takePreviousMainProcessInterruption', + ) as Promise; + return previousMainProcessInterruptionRead; + }, + copyPreviousMainProcessInterruption(): Promise { + return ipcRenderer.invoke('diagnostics:copyPreviousMainProcessInterruption'); + }, async copyReport(input: DesktopDiagnosticInput): Promise { const rendererContext = { rendererUserAgent: navigator.userAgent, @@ -2924,10 +3470,7 @@ const makaBridge = { const scope = await activeRuntimeHostRef(); return { ...state, - activeSessionId: desktopSessionKey({ - hostId: scope.hostId, - sessionId: state.activeSessionId, - }), + activeSessionId: recordRuntimeHostSessionScope(scope, state.activeSessionId), }; }, }, @@ -2948,7 +3491,7 @@ const makaBridge = { return subscribeEveryRuntimeHostEvent('artifacts:changed', (scope, event: ArtifactChangedEvent) => handler({ ...event, - sessionId: desktopSessionKey({ hostId: scope.hostId, sessionId: event.sessionId }), + sessionId: recordRuntimeHostSessionScope(scope, event.sessionId), }), ); }, @@ -3076,7 +3619,7 @@ const makaBridge = { (scope, payload: { sessionId: string; state: BrowserState }) => handler({ ...payload, - sessionId: desktopSessionKey({ hostId: scope.hostId, sessionId: payload.sessionId }), + sessionId: recordRuntimeHostSessionScope(scope, payload.sessionId), }), ); }, @@ -3086,7 +3629,7 @@ const makaBridge = { (scope, payload: { sessionIds: string[] }) => handler({ sessionIds: payload.sessionIds.map((sessionId) => - desktopSessionKey({ hostId: scope.hostId, sessionId }), + recordRuntimeHostSessionScope(scope, sessionId), ), }), ); @@ -3094,27 +3637,32 @@ const makaBridge = { }, } satisfies MakaBridge; -// E2E-only IPC latches. Real users never get these: the preload mirrors the +// E2E-only async controls. Real users never get these: the preload mirrors the // main process's isolated-E2E gate (startup-context.ts) — MAKA_E2E alone is // not enough without the throwaway profile dir. An armed latch holds the next -// call (or every call) to one bridge method until the test releases it, so -// Playwright gets a deterministic in-flight window instead of racing the fake -// backend's near-instant replies. The wrappers must be installed BEFORE +// bridge call or an explicitly gated renderer boundary until the test releases +// it, while a settled-call waiter exposes a deterministic completion boundary +// for work whose visible result may intentionally keep the same DOM identity. +// The wrappers must be installed BEFORE // exposeInMainWorld: the bridge is cloned into the main world at expose time, // and the exposed clone is sealed against later patching. if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) { - type LatchKey = 'newTasks.listInvocableSkills' | 'sessions.list'; + type LatchKey = 'newTasks.listInvocableSkills' | 'sessions.list' | 'settings.chunk'; const gates = new Map; oneShot: boolean }>(); const releases = new Map void; reject: (error: Error) => void }>(); + let nextSessionObservationError: Error | undefined; + const invocableSkillsWaiters = new Map void>>(); + const waitForLatch = async (key: LatchKey): Promise => { + const gate = gates.get(key); + if (!gate) return; + if (gate.oneShot) gates.delete(key); + await gate.promise; + }; const wrapLatched = ( call: (...args: Args) => Promise, key: LatchKey, ) => async (...args: Args): Promise => { - const gate = gates.get(key); - if (gate) { - if (gate.oneShot) gates.delete(key); - await gate.promise; - } + await waitForLatch(key); return call(...args); }; makaBridge.newTasks.listInvocableSkills = wrapLatched( @@ -3125,6 +3673,51 @@ if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) { makaBridge.sessions.list.bind(makaBridge.sessions), 'sessions.list', ); + const subscribeSessionEvents = makaBridge.sessions.subscribeEvents.bind(makaBridge.sessions); + makaBridge.sessions.subscribeEvents = ( + sessionId, + handler, + onSeeded, + onObservationSeed, + onSeedError, + ) => { + const nextError = nextSessionObservationError; + nextSessionObservationError = undefined; + if (!nextError) { + return subscribeSessionEvents( + sessionId, + handler, + onSeeded, + onObservationSeed, + onSeedError, + ); + } + let disposed = false; + void Promise.resolve().then(() => { + if (!disposed) onSeedError?.(nextError); + }); + return () => { + disposed = true; + }; + }; + const listInvocableSkills = makaBridge.skills.listInvocable.bind(makaBridge.skills); + makaBridge.skills.listInvocable = async (...args) => { + try { + return await listInvocableSkills(...args); + } finally { + const sessionId = args[0]; + if (sessionId) { + const waiters = invocableSkillsWaiters.get(sessionId); + const resolve = waiters?.shift(); + if (waiters?.length === 0) invocableSkillsWaiters.delete(sessionId); + if (resolve) { + // Let consumers of the bridge promise run their state updates before + // the test continues from the observed completion. + setTimeout(resolve, 0); + } + } + } + }; contextBridge.exposeInMainWorld('makaE2eLatch', { arm(key: LatchKey, options?: { oneShot?: boolean }) { let resolve: () => void = () => {}; @@ -3136,6 +3729,19 @@ if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) { gates.set(key, { promise, oneShot: options?.oneShot === true }); releases.set(key, { resolve, reject }); }, + wait(key: 'settings.chunk') { + return waitForLatch(key); + }, + waitForInvocableSkillsCall(sessionId: string) { + return new Promise((resolve) => { + const waiters = invocableSkillsWaiters.get(sessionId) ?? []; + waiters.push(resolve); + invocableSkillsWaiters.set(sessionId, waiters); + }); + }, + rejectNextSessionObservation(message: string) { + nextSessionObservationError = new Error(message); + }, release(key: LatchKey) { releases.get(key)?.resolve(); releases.delete(key); diff --git a/apps/desktop/src/preload/runtime-host-session-catalog.ts b/apps/desktop/src/preload/runtime-host-session-catalog.ts index a23f9a6c37..d8c5955d36 100644 --- a/apps/desktop/src/preload/runtime-host-session-catalog.ts +++ b/apps/desktop/src/preload/runtime-host-session-catalog.ts @@ -19,18 +19,99 @@ import type { DesktopSessionSummary } from './bridge-contract.js'; -export async function collectRuntimeHostSessionCatalogs( - requests: readonly Promise[], +export interface RuntimeHostSessionCatalogRequest { + readonly hostId: string; + readonly access: 'owner' | 'session_guest'; + readonly sessions: Promise; +} + +export interface RuntimeHostSessionCatalogCoverage { + readonly sessions: DesktopSessionSummary[]; + readonly completeHostIds: string[]; +} + +export interface RuntimeHostSessionCatalogSnapshot extends RuntimeHostSessionCatalogCoverage { + /** Profiles still retained by Desktop, including unavailable Guest mounts. */ + readonly knownProfileIds: string[]; +} + +export async function resolveRuntimeHostSessionCatalog( + current: readonly DesktopSessionSummary[], + coverage: Promise, + knownRuntimeProfileIds: () => readonly string[], + guestMountProfileIds: Promise, ): Promise { - const results = await Promise.allSettled(requests); - const groups = results.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []); - if (requests.length > 0 && groups.length === 0) { + const [snapshot, knownGuestProfileIds] = await Promise.all([ + coverage, + guestMountProfileIds.catch(() => + current.flatMap((session) => session.shared === true ? [session.profileId] : []), + ), + ]); + return reconcileRuntimeHostSessionCatalog(current, { + ...snapshot, + knownProfileIds: [...knownRuntimeProfileIds(), ...knownGuestProfileIds], + }); +} + +export async function collectRuntimeHostSessionCatalogsWithCoverage( + requests: readonly RuntimeHostSessionCatalogRequest[], +): Promise { + const results = await Promise.allSettled(requests.map((request) => request.sessions)); + const fulfilled = results.flatMap((result, index) => result.status === 'fulfilled' + ? [{ ...requests[index]!, sessions: result.value }] + : []); + const fulfilledRequests = new Set( + results.flatMap((result, index) => result.status === 'fulfilled' ? [requests[index]!] : []), + ); + if (requests.length > 0 && fulfilled.length === 0) { throw new AggregateError( results.flatMap((result) => result.status === 'rejected' ? [result.reason] : []), 'Every Runtime Host Session Catalog request failed', ); } - return groups.flat().sort((left, right) => { + const hostIds = [...new Set(requests.map((request) => request.hostId))]; + return { + sessions: sortSessionCatalogs(fulfilled.flatMap((entry) => entry.sessions)), + completeHostIds: hostIds.filter((hostId) => { + const hostRequests = requests.filter((request) => request.hostId === hostId); + const ownerRequests = hostRequests.filter((request) => request.access === 'owner'); + return ownerRequests.length > 0 + ? ownerRequests.some((request) => fulfilledRequests.has(request)) + : hostRequests.every((request) => fulfilledRequests.has(request)); + }), + }; +} + +/** + * Commits complete Host catalogs authoritatively while retaining the last + * accepted rows for a Host that still exists but cannot answer this read. + * An explicitly removed profile is absent from knownProfileIds and therefore + * retires immediately; transport availability alone cannot change access. + */ +export function reconcileRuntimeHostSessionCatalog( + current: readonly DesktopSessionSummary[], + snapshot: RuntimeHostSessionCatalogSnapshot, +): DesktopSessionSummary[] { + const completeHostIds = new Set(snapshot.completeHostIds); + const knownProfileIds = new Set(snapshot.knownProfileIds); + return sortSessionCatalogs([ + ...snapshot.sessions, + ...current.filter( + (session) => + knownProfileIds.has(session.profileId) && !completeHostIds.has(session.runtimeHostId), + ), + ]); +} + +function sortSessionCatalogs(sessions: DesktopSessionSummary[]): DesktopSessionSummary[] { + const unique = new Map(); + for (const session of sessions) { + const current = unique.get(session.id); + if (!current || (current.shared === true && session.shared !== true)) { + unique.set(session.id, session); + } + } + return [...unique.values()].sort((left, right) => { if (left.activityAt === undefined || right.activityAt === undefined) { throw new Error('Runtime Host Session Catalog activity is unavailable'); } diff --git a/apps/desktop/src/preload/runtime-host-turn-request-inbox.ts b/apps/desktop/src/preload/runtime-host-turn-request-inbox.ts new file mode 100644 index 0000000000..629b769393 --- /dev/null +++ b/apps/desktop/src/preload/runtime-host-turn-request-inbox.ts @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol'; + +export interface RuntimeHostCollaborationScope { + readonly collaborationAuthority?: boolean; +} + +/** Hosts with an explicit negative capability cannot answer collaboration queries. */ +export function selectRuntimeHostCollaborationScopes( + scopes: readonly T[], +): T[] { + return scopes.filter((scope) => scope.collaborationAuthority !== false); +} + +export async function collectAvailablePendingTurnRequests( + queries: readonly Promise[], +): Promise { + const results = await Promise.allSettled(queries); + const available = results.flatMap( + (result) => result.status === 'fulfilled' ? [result.value] : [], + ); + if (queries.length > 0 && available.length === 0) { + throw new AggregateError( + results.flatMap((result) => result.status === 'rejected' ? [result.reason] : []), + 'Every Runtime Host collaboration inbox request failed', + ); + } + return available + .flat() + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); +} diff --git a/apps/desktop/src/preload/transcript-contract.ts b/apps/desktop/src/preload/transcript-contract.ts index 3d7790fe8c..41f00924c7 100644 --- a/apps/desktop/src/preload/transcript-contract.ts +++ b/apps/desktop/src/preload/transcript-contract.ts @@ -19,10 +19,9 @@ export const DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES = 128 * 1024; export const DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES = 512 * 1024; -export const DESKTOP_TRANSCRIPT_SESSION_CACHE_MAX_BYTES = 20 * 1024 * 1024; +export const DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS = 10; export const DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES = 16 * 1024 * 1024; export const DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES = 64 * 1024 * 1024; -export const DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES = 16 * 1024 * 1024; export interface DesktopTranscriptFragment { readonly source: 'durable' | 'overlay'; @@ -60,7 +59,8 @@ export interface DesktopTranscriptOpenResult { export interface DesktopTranscriptRangeRequest { readonly consumerId: string; - readonly generation: string; + readonly sessionId: string; + readonly hostEpoch: string; readonly anchorSequence: number | null; readonly maxBytes: number; } diff --git a/apps/desktop/src/preload/transcript-identity.ts b/apps/desktop/src/preload/transcript-identity.ts new file mode 100644 index 0000000000..d02ec35d53 --- /dev/null +++ b/apps/desktop/src/preload/transcript-identity.ts @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { DesktopTranscriptBatch } from './transcript-contract.js'; + +/** + * Durable transcript sequence identity: the Session's generation and the + * Runtime Host epoch that produced it. + * + * The renderer keeps this alongside the open handle so range requests always + * carry the epoch of the Host the renderer currently accepts batches from. + * A replacement Host sends a reset batch with a new generation and epoch; + * adopting that identity lets the next range request pass the Main-process + * guard, while requests already dispatched with the previous epoch still fail + * closed on the old Host. + */ +export interface DesktopTranscriptIdentity { + readonly generation: string; + readonly hostEpoch: string; +} + +/** + * Adopts a batch's identity when none is tracked yet or when the batch is a + * reset; otherwise keeps the current identity. Returns the current identity + * by reference when nothing changed so callers can detect adoption. + */ +export function adoptTranscriptIdentity( + current: DesktopTranscriptIdentity | undefined, + batch: DesktopTranscriptBatch, +): DesktopTranscriptIdentity { + if (current !== undefined && !batch.reset) return current; + return { generation: batch.generation, hostEpoch: batch.hostEpoch }; +} diff --git a/apps/desktop/src/preload/workhub-coordination-session.ts b/apps/desktop/src/preload/workhub-coordination-session.ts new file mode 100644 index 0000000000..4daf8ac807 --- /dev/null +++ b/apps/desktop/src/preload/workhub-coordination-session.ts @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session'; +import { + desktopSessionKey, + type DesktopTargetScope, +} from '../shared/runtime-host-identity.js'; + +export async function resolveDesktopWorkHubCoordinationSession( + activeRuntimeHost: () => Promise, + resolveOnHost: ( + scope: DesktopTargetScope, + ) => Promise<{ readonly sessionId: string }>, +): Promise { + const scope = await activeRuntimeHost(); + const result = await resolveOnHost(scope); + return desktopSessionKey({ hostId: scope.hostId, sessionId: result.sessionId }); +} + +/** Resolves an exact Host scope from the durable Coordination identity, never from UI focus. */ +export async function resolveDesktopWorkHubCoordinationCreateScope( + coordinationSessionId: string, + resolveSession: ( + sessionId: string, + ) => Promise<{ readonly scope: DesktopTargetScope; readonly sessionId: string }>, +): Promise { + const coordination = await resolveSession(coordinationSessionId); + if (coordination.sessionId !== WORKHUB_COORDINATION_SESSION_ID) { + throw new Error('Invalid WorkHub Coordination Session identity'); + } + return coordination.scope; +} diff --git a/apps/desktop/src/renderer/README.md b/apps/desktop/src/renderer/README.md index f95082b7db..c9ea51eaca 100644 --- a/apps/desktop/src/renderer/README.md +++ b/apps/desktop/src/renderer/README.md @@ -29,21 +29,139 @@ For the main/preload/renderer split and the IPC contract, see `apps/desktop/READ `styles.css` is the **only** bundled style entry: it imports Astryx, fonts, `maka-tokens.css`, `reference-shell.css`, and every `styles/*.css`. It contains only top-level orchestration; real selector rules go in `styles/*.css`. One contract-pinned exception: `index.html` carries an inline `.maka-preload` skeleton with hardcoded colors (no CSS variables — `maka-tokens.css` hasn't loaded yet) so there's no blank window during the CSS + JS load gap; `createRoot` replaces it on mount. -## AppShell + the action modules - -`app-shell.tsx` is the shell component: it owns session state and wires the -`@maka/ui` conversation surfaces. The right/bottom Workbar is the vertical -feature under `features/workbar`; its own README defines the state and lifecycle -boundary for Review, Terminal, Tasks, Browser, Files, Inspector and Side Chat. -The shell is supported by a set of `app-shell-*` modules, each a narrow slice of -shell logic split by one concern (e.g. `app-shell-session-events.ts`, -`app-shell-chat-actions.ts`, `app-shell-effects.ts`, and -`app-shell-overlays.tsx`). Keep a slice to one concern; if it grows, split along -the same seam. - -Autonomous Goal state, controls, dialog lifecycle, and Desktop I/O live under -`features/goals`; AppShell supplies the active Session boundary and consumes -the feature's host, commands, and selectors. +## Renderer ownership boundary + +`app-shell.tsx`, `app-shell-*`, and `use-app-shell-*` are a **frozen legacy +boundary**, not a pattern for new renderer code. They temporarily retain +ownership that predates the composition-root migration. Do not add another +file to this family or move new state, effects, subscriptions, bridge calls, or +feature view-model construction into it. Its recorded debt may only decrease as +each capability moves to its target owner. + +The target dependency direction is: + +```text +bootstrap -> composition -> shell + application contracts + feature public entries +platform/desktop -> injected feature/application ports +features -> own internals + shared contracts/core/UI +application -> shared contracts + injected ports +``` + +- `shell/` owns only the fixed frame, regions, and mount/visibility policy. It + has no Desktop bridge access, feature implementation imports, or business + state/effects. Direct storage, timers, fetches, and DOM/global subscriptions + are environment ownership too and are forbidden here. +- `bootstrap/` owns one-shot startup and React mount sequencing. Apart from + locating the DOM mount point, it owns no React state/class lifecycle, + storage, timers, subscriptions, or network access. +- `composition/` assembles providers, adapters, and public feature hosts. It is + wiring, not another lifecycle or browser-environment owner. +- `application/` owns explicitly shared renderer authorities. It must not + depend on feature, shell, Desktop adapter, preload, or main-process + implementations. +- `features//` owns one vertical capability. A feature cannot access + `window.maka`, import another feature's internals, or depend on AppShell, + preload, main, or `platform/desktop`. Consumers use its public `index` entry; + `testing` is test/Storybook-only. +- `platform/desktop/` is the outer adapter zone for the preload bridge. It + implements narrow inward-facing ports rather than exporting the whole bridge; + composition and adapters consume application public entries, not deep + implementation modules. Adapters may own bridge and browser-environment + access, but never React UI/hooks/class lifecycle, Electron/Node imports, or + non-static dependency loading. + +The right/bottom Workbar and the other extracted features define their detailed +state and lifecycle boundaries in their own READMEs. Cross-feature behavior +uses explicit contracts and intents, not private imports or a service locator. + +### Architecture guardrail and migration ledger + +`apps/desktop/scripts/check-renderer-architecture.mjs` parses renderer imports, +bridge aliases, browser-environment access, and stateful-hook ownership and +enforces the zones above, including imported/local hook aliases, React 19 state +hooks, React component class state/lifecycle, and non-static global access. It +also rejects Electron/Node imports from inward zones, deep/cross-feature +imports, `import.meta.glob` escape hatches, production use of feature testing +entries, and application contracts that re-export application implementation. +`apps/desktop/renderer-architecture.json` records the exact legacy/root debt and +maps every AppShell/root path to its intended owner. It freezes every +unclassified legacy renderer source and every non-owner Desktop source +transitively reachable from AppShell. The graph crosses explicit feature, +application, and platform owners while recording legacy renderer, shared, +preload, and other non-owner intermediaries in the debt closure. The ledger +traverses declarations for dependency resolution without treating them as +runtime debt; explicit owner nodes remain governed by their zone rules. It also +freezes the separate root-entry closure and each transitional +feature/platform import of legacy code. The AppShell-family and root-entry +files are full ratchets: dependency paths, imported bindings, +bridge/hooks/browser capabilities, action factories, and non-trivia tokens may +not grow. Their transitive support closures ratchet only architectural +capabilities and dependencies, so ordinary implementation can evolve without +token-count ledger noise. A support entry may move one way from the AppShell +closure to the root closure without resetting its budget; the reverse move is +rejected. Legacy import allowlists may only shrink relative to the base branch. Same-count +dependency replacement is allowed only when it moves ownership behind a shell, +feature public, or application public/contract boundary. + +`ownership[].targetZone` is migration-roadmap metadata in this foundation: its +shape and legacy path coverage are validated, but it does not claim to prove +that a capability has reached its final owner. The directory dependency rules +remain executable. A later owner contract can add verifiable owner paths and +public entries once each mixed legacy capability has been split precisely. + +Exact Hook names remain visible in the generated ledger, and no tracked Hook +call count may grow in a debt file. +The separate AppShell render-scope inventory tracks which calls still execute +above the whole renderer tree; this architecture checker governs the broader +root and transitive capability debt. + +New flat renderer modules are forbidden; new code belongs in an explicit zone. +The existing `settings`, `locales`, `astryx-theme`, and +`computer-use-overlay` directories may still add scoped legacy files, but those +files cannot become newly reachable from AppShell/root or a feature/Desktop +adapter without passing the corresponding ratchet. + +Run the current-tree check locally with: + +```sh +npm run check:renderer-architecture +``` + +Before opening a PR, also verify that debt did not grow relative to main: + +```sh +npm run check:renderer-architecture -- --base upstream/main +``` + +After a legitimate debt-reducing move, regenerate the mechanical counts and +then run the base comparison; regeneration cannot hide growth from CI: + +```sh +npm run check:renderer-architecture -- --write --base upstream/main +``` + +`main.tsx` and `app.tsx` are permanent guarded root entries while those files +exist. Their recorded debt can fall to zero as they become thin mounts, but the +ledger entries remain so that a later PR cannot add bridge, hook, +browser-environment, dynamic-import, or legacy dependency ownership back into +them. A root entry guard may be removed only when the guarded source file is +deleted. + +The production entry chain is part of the same root contract. The main process +delegates its one renderer navigation to `main-renderer-loader.ts`, which loads +only `dist-renderer/index.html`; Vite must build that document from +`src/renderer`; and the source HTML must keep a single external module entry at +`/main.tsx`. A build-time Vite attestation also inspects the final module graph, +and a post-build verifier binds the emitted HTML's sole script to that exact +entry chunk while preserving the fixed CSP and rejecting extra executable or +navigation surfaces. An HTML-transform plugin therefore cannot silently +replace or augment the canonical entry after the source check. `main.tsx` +remains under the permanent root guard. Moving any part of this chain requires +an explicit architecture change instead of routing around the ledger. + +This initial guardrail is source-policy and migration metadata only. It does not +change runtime behavior, provider order, IPC/storage contracts, bootstrap, +Composer mount semantics, Session switching, or Workbar resource lifecycles. `settings/` holds the settings pages and the `SettingsModal` shell — one page per `SettingsSection` (defined in `@maka/core`); the models/providers page is `ProvidersPanel`. Plus the `provider-*` files and the shared `settings-rows` / `settings-skeleton` / `settings-surface` helpers. diff --git a/apps/desktop/src/renderer/app-shell-build-stamp.ts b/apps/desktop/src/renderer/app-shell-build-stamp.ts deleted file mode 100644 index 3972c2a8b5..0000000000 --- a/apps/desktop/src/renderer/app-shell-build-stamp.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { useEffect, useState } from 'react'; -import type { SidebarBuildStamp } from '@maka/ui'; - -/** - * The running build, for the rail's footer. - * - * Read once at mount and never again: neither the version nor the commit can - * change without the process restarting, so a subscription would re-render for - * a value that cannot move. - * - * Failure is silent and the stamp is simply absent. This is an orientation - * label — a rail that shows nothing is strictly better than one that shows an - * error where a version belongs, and the About page reports the same failure - * where a user went looking for it. - */ -export function useBuildStamp(): SidebarBuildStamp | undefined { - const [stamp, setStamp] = useState(undefined); - - useEffect(() => { - let cancelled = false; - window.maka.app - .info() - .then((info) => { - if (cancelled) return; - setStamp({ version: info.appVersion, commit: info.buildCommit }); - }) - .catch(() => undefined); - return () => { - cancelled = true; - }; - }, []); - - return stamp; -} diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 3c74ddfd47..275740b9c4 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -23,6 +23,7 @@ import type { DesktopNewTaskTarget } from '../preload/bridge-contract.js'; import type { InlineReference, QuoteRef } from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; +import type { SkillInvocationResult } from '@maka/runtime/skill-invocation'; import type { StoredMessage } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { TurnOrchestration } from '@maka/core/runtime-inputs'; @@ -36,6 +37,7 @@ import { type InteractionQueues, type LiveTurnProjection, type NavSelection, + type TransientUserMessageProjection, } from '@maka/ui'; import { messageRefreshErrorMessage } from './app-shell-copy.js'; import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; @@ -49,6 +51,7 @@ import { skillInvocationDisplayText, } from './skill-invocation-feedback.js'; import type { DesktopTranscriptRangeController } from './desktop-transcript-range-store.js'; +import type { SessionPendingClaim } from './app-shell-session-ui-state.js'; import { retainedAttachmentRefs, toComposerIngestItems, @@ -65,6 +68,7 @@ import { noRealConnectionSetupDescription, } from './model-connection-errors.js'; import type { RefreshMessagesOptions } from './session-message-settlement.js'; +import type { MessageListUpdater } from './session-workspace-actions.js'; export type { RefreshMessagesOptions }; @@ -75,15 +79,14 @@ type ComposerImportOwner = { }; type RefBox = { current: T }; -type BooleanRecordUpdater = (updater: (current: Record) => Record) => void; type LiveTurnRecordUpdater = ( updater: (current: Record) => Record, ) => void; -type MessageListUpdater = (next: StoredMessage[] | ((current: StoredMessage[]) => StoredMessage[])) => void; type MessageLoadErrorUpdater = (updater: (current: Record) => Record) => void; type InteractionQueueUpdater = (updater: (current: InteractionQueues) => InteractionQueues) => void; type PendingNewChatModel = { + llmConnectionId: string; llmConnectionSlug: string; model: string; } | null; @@ -100,17 +103,42 @@ type ToastApi = { info(title: string, description?: string): void; }; +type DirectoryReferences = NonNullable; +type MessageContextOptions = { + directoryReferences?: DirectoryReferences; + quotes?: readonly QuoteRef[]; + workspaceFileReferences?: readonly WorkspaceFileReferencePosition[]; +}; +type SendOptions = MessageContextOptions & { + turnOrchestration?: TurnOrchestration; + displayText?: string; + onSessionResolved?: (sessionId: string) => void; +}; + +function copiedArray( + key: K, + values: readonly T[] | undefined, +): Partial> { + return values?.length ? { [key]: [...values] } as Record : {}; +} + export interface AppShellChatActions { send( text: string, pending?: readonly PendingAttachment[], - options?: { - turnOrchestration?: TurnOrchestration; - quotes?: readonly QuoteRef[]; - workspaceFileReferences?: readonly WorkspaceFileReferencePosition[]; - displayText?: string; - onSessionResolved?: (sessionId: string) => void; - }, + options?: SendOptions, + ): Promise; + /** + * Resolves with whether the Message was sent. An unproven outcome counts as + * sent — Runtime Host may well have it — so the caller does not offer the + * same text twice; only a refusal is `false`. + */ + enqueueMessage( + sessionId: string, + text: string, + placement: 'current_turn' | 'next_turn', + pending?: readonly PendingAttachment[], + options?: MessageContextOptions, ): Promise; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; respondToUserQuestion(response: UserQuestionResponse): Promise; @@ -121,31 +149,29 @@ export interface AppShellChatActions { export function createAppShellChatActions(deps: { uiLocale: UiLocale; activeIdRef: RefBox; - addPendingSessionAction: ( - sessionId: string, - pendingRef: RefBox>, - setPendingBySession: BooleanRecordUpdater, - ) => boolean; captureComposerImportOwner: () => ComposerImportOwner; checkTaskSubmissionReadiness: () => Promise; - clearPendingSessionAction: ( - sessionId: string, - pendingRef: RefBox>, - setPendingBySession: BooleanRecordUpdater, - ) => void; isNewChatSendSurfaceActive: (owner: ComposerImportOwner) => boolean; /** The shell's one answer to "is this owner still the surface the user is * looking at". Both halves matter — the section AND the session id — which * is why the send path asks it instead of comparing the id itself. */ isShellSurfaceOwnerActive: (owner: ComposerImportOwner) => boolean; - messageRetryPendingRef: RefBox>; + messageRetryPending: SessionPendingClaim; refreshSessions: () => Promise; + activateSessionForFirstSend: (sessionId: string) => Promise; setActiveId: (sessionId: string | undefined) => void; setMessageLoadErrorBySession: MessageLoadErrorUpdater; - setMessageRetryPendingBySession: BooleanRecordUpdater; setMessages: MessageListUpdater; + addTransientMessage: ( + sessionId: string, + message: TransientUserMessageProjection, + ) => void; + updateTransientMessage: ( + sessionId: string, + message: TransientUserMessageProjection, + ) => void; + removeTransientMessage: (sessionId: string, messageId: string) => void; transcriptRangeRef: RefBox; - setNavSelection: (selection: NavSelection) => void; /** #646: arm the "正在处理…" indicator locally at send() — the model-wait * window opens before any SessionEvent arrives (turn_started is not one). */ setLiveTurnBySession: LiveTurnRecordUpdater; @@ -180,20 +206,20 @@ export function createAppShellChatActions(deps: { const { uiLocale, activeIdRef, - addPendingSessionAction, captureComposerImportOwner, checkTaskSubmissionReadiness, - clearPendingSessionAction, isNewChatSendSurfaceActive, isShellSurfaceOwnerActive, - messageRetryPendingRef, + messageRetryPending, refreshSessions, + activateSessionForFirstSend, setActiveId, setMessageLoadErrorBySession, - setMessageRetryPendingBySession, setMessages, + addTransientMessage, + updateTransientMessage, + removeTransientMessage, transcriptRangeRef, - setNavSelection, setLiveTurnBySession, setInteractionBySession, onInteractionChanged, @@ -210,75 +236,52 @@ export function createAppShellChatActions(deps: { } = deps; const copy = getShellCopy(uiLocale).chatActions; - function optimisticUserMessage( - turnId: string, - text: string, - attachments: readonly import('@maka/core/events').AttachmentRef[] = [], - quotes: readonly QuoteRef[] = [], - inlineReferences: readonly InlineReference[] = [], - ): StoredMessage { - return { - type: 'user', - id: `optimistic-user-${turnId}`, - turnId, - ts: Date.now(), - text, - ...(attachments.length > 0 ? { attachments: [...attachments] } : {}), - ...(quotes.length > 0 ? { quotes: [...quotes] } : {}), - inlineReferences: [...inlineReferences], - }; - } - - function showOptimisticUserMessage( + function showTransientUserMessage( sessionId: string, - turnId: string, + messageId: string, text: string, attachments: readonly import('@maka/core/events').AttachmentRef[] = [], options: { - replaceCurrentMessages?: boolean; + placement?: TransientUserMessageProjection['transientPlacement']; + hostTurnId?: string; + updateOnly?: boolean; + directoryReferences?: DirectoryReferences; quotes?: readonly QuoteRef[]; inlineReferences?: readonly InlineReference[]; } = {}, ): void { + const directoryReferences = options.directoryReferences; + const quotes = options.quotes ?? []; + const next: TransientUserMessageProjection = { + id: messageId, + ts: Date.now(), + text, + ...copiedArray('attachments', attachments), + ...copiedArray('directoryReferences', directoryReferences), + ...copiedArray('quotes', quotes), + inlineReferences: [...(options.inlineReferences ?? [])], + transientPlacement: options.placement ?? 'current_turn', + ...(options.hostTurnId ? { hostTurnId: options.hostTurnId } : {}), + }; + if (options.updateOnly) updateTransientMessage(sessionId, next); + else addTransientMessage(sessionId, next); if (activeIdRef.current !== sessionId) return; setMessageLoadErrorBySession((current) => { if (!current[sessionId]) return current; - const next = { ...current }; - delete next[sessionId]; - return next; - }); - setMessages((current) => { - if (current.some((message) => message.type === 'user' && message.turnId === turnId)) return current; - const next = optimisticUserMessage( - turnId, - text, - attachments, - options.quotes, - options.inlineReferences, - ); - return options.replaceCurrentMessages ? [next] : [...current, next]; + const cleared = { ...current }; + delete cleared[sessionId]; + return cleared; }); } function removeOptimisticUserMessage(sessionId: string, turnId: string): void { - if (activeIdRef.current !== sessionId) return; - setMessages((current) => current.filter((message) => message.id !== `optimistic-user-${turnId}`)); + removeTransientMessage(sessionId, turnId); } - // #646: open the turn's model-wait window for a session. Armed the moment - // send() commits (before the IPC round-trip) so the "正在处理…" indicator - // covers the connect-to-first-token gap that has no SessionEvent of its own; - // disarmed if the send never reaches the runtime (the catch below). Always - // (re)set to `'waiting'`: a fresh send is a new first-token wait, so it must - // overwrite any `'streamed'` left by a prior turn whose terminal event was - // missed — otherwise the new turn's head would never show the indicator. - // - // The arm carries `unconfirmed` until the authority names this turn back. The - // runtime writes `status: 'running'` only at the END of `AgentRun.begin`, so - // every session list refreshed in between still reports the pre-send status — - // which is the same status a finished turn leaves behind. Without that bit, - // the stale value retires the arm the send just created - // (settled-session-transients.ts). + // Explicit orchestration reserves an exact Turn identity before IPC, so its + // renderer command surface keeps the existing first-token wait. Ordinary + // messages never call this path: LocalIntent presents the message and the + // Host subscription alone introduces the actual Turn. function armTurnActive(sessionId: string, turnId: string): void { setLiveTurnBySession((current) => { const active = current[sessionId]; @@ -287,6 +290,23 @@ export function createAppShellChatActions(deps: { }); } + /** + * The arm was placed under the client's Message identity because that is all + * the client had; Runtime Host answers with the Turn identity every later + * event will carry. Adopt it, but only while the arm is still the one this + * send placed and still waiting — once the authority has said anything about + * a Turn here, that Turn is the one on screen and renaming it would retire + * the wrong claim. + */ + function rebindTurnActive(sessionId: string, fromTurnId: string, toTurnId: string): void { + if (fromTurnId === toTurnId) return; + setLiveTurnBySession((current) => { + const active = current[sessionId]; + if (active?.turnId !== fromTurnId || !active.unconfirmed) return current; + return { ...current, [sessionId]: { ...active, turnId: toTurnId } }; + }); + } + function disarmTurnActive(sessionId: string, turnId: string): void { setLiveTurnBySession((current) => { if (current[sessionId]?.turnId !== turnId) return current; @@ -296,52 +316,107 @@ export function createAppShellChatActions(deps: { }); } - // Rename only the exact unconfirmed arm this send created. Host events can - // beat the IPC response (main emits the sessions-changed nudge before it - // returns), and an authoritative projection that already arrived for the - // Host-chosen turn must not be replaced with a fresh waiting arm. - function rebindTurnActive(sessionId: string, fromTurnId: string, toTurnId: string): void { - setLiveTurnBySession((current) => { - const active = current[sessionId]; - if (!active || active.turnId !== fromTurnId || !active.unconfirmed || active.phase !== 'waiting') { - return current; - } - return { ...current, [sessionId]: armLiveTurn(toTurnId) }; - }); - } + /** + * What a submitted Message became, as far as this client can tell. + * + * `unreconciled` is the only outcome that leaves the transient row in place: + * the answer was lost, so Runtime Host may well have acted on the Message and + * canonical transcript is what settles it. A `refused` Message opened no Turn + * and will never be replaced by a canonical one, so its row is already gone. + */ + type SubmittedMessage = + | { kind: 'projected'; skillInvocation: SkillInvocationResult; turnId?: string } + | { kind: 'unreconciled' } + | { kind: 'refused'; skillInvocation: SkillInvocationResult }; - // One interpretation of a successful sessions:send for both the new-chat and - // existing-session branches: a busy-raced send can come back `steered` (this - // send owns no turn — the steering_message event renders the text) or under - // a Host-chosen turnId. Returns the turn the send owns, if any. - function settleSendBookkeeping( - sessionId: string, - requestedTurnId: string, - sendResult: { steered?: true; turnId?: string }, - ): string | undefined { - if (sendResult.steered) { - disarmTurnActive(sessionId, requestedTurnId); - return undefined; + /** + * The one place a submitted Message's outcome becomes UI. Every submission — + * first send, send into an existing Session, Follow Up — projects its row the + * same way, so the rules for retiring and updating it cannot drift apart. + */ + async function submitAndProject(input: { + sessionId: string; + messageId: string; + placement: 'current_turn' | 'next_turn'; + command: Omit< + Parameters[2], + 'messageId' + >; + displayText?: string; + quotes?: readonly QuoteRef[]; + exactTurn?: boolean; + /** Whether this Session's surface is on screen to receive Skill feedback. */ + isSurfaceVisible?: () => boolean; + }): Promise { + const { sessionId, messageId, placement } = input; + const directoryReferences = input.command.directoryReferences; + const quotes = input.quotes ?? []; + const result = await window.maka.sessions.submitMessage(sessionId, placement, { + ...input.command, + messageId, + }); + const surfaceVisible = input.isSurfaceVisible?.() ?? true; + if (!result.ok) { + if (result.reason === 'outcome_unknown') { + // The Message may well have been admitted, so its row stays for + // canonical transcript to settle. The Turn arm is a different claim: + // nothing proves a Turn opened under this identity, and no event + // carrying it will ever arrive to retire it. + if (input.exactTurn) disarmTurnActive(sessionId, messageId); + return { kind: 'unreconciled' }; + } + removeOptimisticUserMessage(sessionId, messageId); + if (input.exactTurn) disarmTurnActive(sessionId, messageId); + if (surfaceVisible) { + showSkillInvocationFeedback(uiLocale, toastApi, result.skillInvocation, sessionId); + } + return { kind: 'refused', skillInvocation: result.skillInvocation }; + } + if (input.exactTurn) { + if (result.disposition === 'turn_started' && result.turnId) { + rebindTurnActive(sessionId, messageId, result.turnId); + } else { + // Host admitted the Message into a Turn this send did not open, so the + // arm placed for an exact Turn describes nothing. + disarmTurnActive(sessionId, messageId); + } } - const startedTurnId = sendResult.turnId ?? requestedTurnId; - if (startedTurnId !== requestedTurnId) { - rebindTurnActive(sessionId, requestedTurnId, startedTurnId); + if (surfaceVisible) { + showSkillInvocationFeedback(uiLocale, toastApi, result.skillInvocation, sessionId); } - return startedTurnId; + // The row is updated whether or not the surface is on screen: attachments, + // inline references and the Host Turn grouping are what the user finds when + // they come back to it. + showTransientUserMessage( + sessionId, + messageId, + input.displayText ?? + skillInvocationDisplayText(input.command.text, result.skillInvocation), + result.attachments, + { + updateOnly: true, + placement, + ...(result.turnId ? { hostTurnId: result.turnId } : {}), + ...copiedArray('directoryReferences', directoryReferences), + ...copiedArray('quotes', quotes), + inlineReferences: result.inlineReferences ?? [], + }, + ); + return { + kind: 'projected', + skillInvocation: result.skillInvocation, + ...(result.turnId ? { turnId: result.turnId } : {}), + }; } async function send( text: string, pending?: readonly PendingAttachment[], - options: { - turnOrchestration?: TurnOrchestration; - quotes?: readonly QuoteRef[]; - workspaceFileReferences?: readonly WorkspaceFileReferencePosition[]; - displayText?: string; - onSessionResolved?: (sessionId: string) => void; - } = {}, + options: SendOptions = {}, ): Promise { + const directoryReferences = options.directoryReferences; const quotes = options.quotes; + const exactTurn = options.turnOrchestration !== undefined; const initialSessionId = activeIdRef.current; const initialNewTaskTarget = initialSessionId ? undefined : newTaskTarget; const sendOwner = captureComposerImportOwner(); @@ -355,7 +430,7 @@ export function createAppShellChatActions(deps: { return false; } let optimisticSessionId: string | undefined; - let optimisticTurnId: string | undefined; + let optimisticMessageId: string | undefined; // #1433: the composer creates the session BEFORE it sends, so a first // send that never lands has to take the session with it. Set the moment // creation succeeds, cleared the moment the send does — while it holds a @@ -371,13 +446,14 @@ export function createAppShellChatActions(deps: { unsentSessionId = undefined; try { await window.maka.sessions.remove(sessionId); + if (activeIdRef.current === sessionId) setActiveId(undefined); await refreshSessions(); } catch { // Best-effort: a failed cleanup must not replace the real error. } }; try { - const turnId = crypto.randomUUID(); + const messageId = crypto.randomUUID(); if (!initialSessionId) { if (!initialNewTaskTarget) return false; if (pending && pending.length > 0) preflightAttachmentItems(pending, uiLocale); @@ -385,6 +461,7 @@ export function createAppShellChatActions(deps: { name: DEFAULT_SESSION_NAME, ...(newChatModel ? { + llmConnectionId: newChatModel.llmConnectionId, llmConnectionSlug: newChatModel.llmConnectionSlug, model: newChatModel.model, } @@ -395,12 +472,37 @@ export function createAppShellChatActions(deps: { orchestrationMode: newChatOrchestrationMode, }); unsentSessionId = session.id; + optimisticSessionId = session.id; + optimisticMessageId = messageId; + // Stage the first row before activation. `setActiveId` projects this + // session-owned transient in the same state transition that replaces + // the new-chat surface, so the empty-session Maka hero cannot paint + // between observation settling and the submitted content appearing. + showTransientUserMessage( + session.id, + messageId, + options.displayText ?? text, + [], + { + ...copiedArray('directoryReferences', directoryReferences), + ...copiedArray('quotes', quotes), + inlineReferences: [], + }, + ); // Consumed: the choice is now the created Session's, not the next // draft's. A failed create leaves it in place so a retry keeps it. if (newChatPermissionChoice) clearNewChatPermissionChoice(); - optimisticSessionId = session.id; - optimisticTurnId = turnId; - armTurnActive(session.id, turnId); + // Active-stream snapshots can only restore assistant segments that + // are still streaming. Wait until the observer is ready before the + // first admission so a completed segment in a still-running Turn + // cannot become durable text without live identity. + await activateSessionForFirstSend(session.id); + if (activeIdRef.current !== session.id) { + removeOptimisticUserMessage(session.id, messageId); + await discardUnsentSession(); + return false; + } + if (exactTurn) armTurnActive(session.id, messageId); const attachmentItems = pending && pending.length > 0 ? toComposerIngestItems(pending) @@ -409,64 +511,38 @@ export function createAppShellChatActions(deps: { pending && pending.length > 0 ? retainedAttachmentRefs(pending) : undefined; - const sendResult = await window.maka.sessions.send(session.id, { - type: 'send', - turnId, + const sendCommand = { text, ...(options.displayText ? { displayText: options.displayText } : {}), - ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), - ...(attachmentItems && attachmentItems.length > 0 ? { attachmentItems } : {}), + ...copiedArray('attachmentItems', attachmentItems), ...(retainedAttachments && retainedAttachments.length > 0 ? { retainedAttachments } : {}), - ...(quotes && quotes.length > 0 ? { quotes: [...quotes] } : {}), - ...(options.workspaceFileReferences && options.workspaceFileReferences.length > 0 - ? { workspaceFileReferences: [...options.workspaceFileReferences] } - : {}), + ...copiedArray('directoryReferences', directoryReferences), + ...copiedArray('quotes', quotes), + ...copiedArray('workspaceFileReferences', options.workspaceFileReferences), + }; + const submitted = await submitAndProject({ + sessionId: session.id, + messageId, + placement: 'current_turn', + command: { + ...sendCommand, + ...(options.turnOrchestration + ? { turnOrchestration: options.turnOrchestration } + : {}), + }, + ...(options.displayText ? { displayText: options.displayText } : {}), + ...copiedArray('quotes', quotes), + exactTurn, + isSurfaceVisible: () => activeIdRef.current === session.id, }); - if (!sendResult.ok) { - if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { - showSkillInvocationFeedback( - uiLocale, - toastApi, - sendResult.skillInvocation, - session.id, - ); - } - disarmTurnActive(session.id, turnId); + if (submitted.kind === 'refused') { await discardUnsentSession(); return false; } unsentSessionId = undefined; - const settledTurnId = settleSendBookkeeping(session.id, turnId, sendResult); - if (settledTurnId !== undefined) optimisticTurnId = settledTurnId; options.onSessionResolved?.(session.id); - if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { - showSkillInvocationFeedback( - uiLocale, - toastApi, - sendResult.skillInvocation, - session.id, - ); - } - if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { - setNavSelection({ section: 'sessions' }); - setActiveId(session.id); - if (settledTurnId !== undefined) { - showOptimisticUserMessage( - session.id, - settledTurnId, - options.displayText ?? - skillInvocationDisplayText(text, sendResult.skillInvocation), - sendResult.attachments, - { - replaceCurrentMessages: true, - ...(quotes && quotes.length > 0 ? { quotes } : {}), - inlineReferences: sendResult.inlineReferences ?? [], - }, - ); - } - } await refreshSessions(); return true; } @@ -489,8 +565,19 @@ export function createAppShellChatActions(deps: { } } optimisticSessionId = sessionId; - optimisticTurnId = turnId; - armTurnActive(sessionId, turnId); + optimisticMessageId = messageId; + showTransientUserMessage( + sessionId, + messageId, + options.displayText ?? text, + [], + { + ...copiedArray('directoryReferences', directoryReferences), + ...copiedArray('quotes', quotes), + inlineReferences: [], + }, + ); + if (exactTurn) armTurnActive(sessionId, messageId); const attachmentItems = pending && pending.length > 0 ? toComposerIngestItems(pending) @@ -499,67 +586,62 @@ export function createAppShellChatActions(deps: { pending && pending.length > 0 ? retainedAttachmentRefs(pending) : undefined; - const sendResult = await window.maka.sessions.send(sessionId, { - type: 'send', - turnId, + const sendCommand = { text, ...(options.displayText ? { displayText: options.displayText } : {}), - ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), - ...(attachmentItems && attachmentItems.length > 0 ? { attachmentItems } : {}), + ...copiedArray('attachmentItems', attachmentItems), ...(retainedAttachments && retainedAttachments.length > 0 ? { retainedAttachments } : {}), - ...(quotes && quotes.length > 0 ? { quotes: [...quotes] } : {}), - ...(options.workspaceFileReferences && options.workspaceFileReferences.length > 0 - ? { workspaceFileReferences: [...options.workspaceFileReferences] } - : {}), - }); - if (!sendResult.ok) { - if (activeIdRef.current === sessionId) { - showSkillInvocationFeedback( - uiLocale, - toastApi, - sendResult.skillInvocation, - sessionId, - ); - } - disarmTurnActive(sessionId, turnId); - return false; - } - const startedTurnId = settleSendBookkeeping(sessionId, turnId, sendResult); - options.onSessionResolved?.(sessionId); - if (startedTurnId === undefined) return true; - optimisticTurnId = startedTurnId; - if (activeIdRef.current === sessionId) { - showSkillInvocationFeedback( - uiLocale, - toastApi, - sendResult.skillInvocation, - sessionId, - ); - } - showOptimisticUserMessage( + ...copiedArray('directoryReferences', directoryReferences), + ...copiedArray('quotes', quotes), + ...copiedArray('workspaceFileReferences', options.workspaceFileReferences), + }; + const submitted = await submitAndProject({ sessionId, - startedTurnId, - options.displayText ?? - skillInvocationDisplayText(text, sendResult.skillInvocation), - sendResult.attachments, - { - ...(quotes && quotes.length > 0 ? { quotes } : {}), - inlineReferences: sendResult.inlineReferences ?? [], + messageId, + placement: 'current_turn', + command: { + ...sendCommand, + ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), }, - ); + ...(options.displayText ? { displayText: options.displayText } : {}), + ...copiedArray('quotes', quotes), + exactTurn, + isSurfaceVisible: () => activeIdRef.current === sessionId, + }); + if (submitted.kind === 'refused') return false; + if (submitted.kind === 'unreconciled') return true; + options.onSessionResolved?.(sessionId); return true; } catch (error) { + // Capture ownership before cleanup clears the optimistic Session. A + // barrier timeout belongs to the surface that was waiting for it, while + // navigation away still suppresses feedback. + const feedbackSessionId = optimisticSessionId ?? initialSessionId; + const diagnosticTarget = feedbackSessionId + ? { sessionId: feedbackSessionId } + : initialNewTaskTarget + ? { profileId: initialNewTaskTarget.profileId } + : undefined; + const sendStillOwnsCurrentSurface = + (feedbackSessionId !== undefined && + isShellSurfaceOwnerActive({ + ...sendOwner, + sessionId: feedbackSessionId, + })) || + (newChatOwner !== null && isNewChatSendSurfaceActive(newChatOwner)); await discardUnsentSession(); - if (optimisticSessionId && optimisticTurnId) { - removeOptimisticUserMessage(optimisticSessionId, optimisticTurnId); + if (optimisticSessionId && optimisticMessageId) { + removeOptimisticUserMessage(optimisticSessionId, optimisticMessageId); } // The turn never reached the runtime — close the model-wait window so the // "正在处理…" indicator doesn't hang after a failed send. Nothing else has // to be undone: the arm was the only claim the send made, and no // subscribeChanges event would reconcile a turn that never started. - if (optimisticSessionId && optimisticTurnId) disarmTurnActive(optimisticSessionId, optimisticTurnId); + if (exactTurn && optimisticSessionId && optimisticMessageId) { + disarmTurnActive(optimisticSessionId, optimisticMessageId); + } // Which surface is allowed to hear about this failure. The id alone is // not it: `selectNavigation` never clears `activeId` (nav-selection.ts), // so a user who left for 扩展 → 技能 mid-flight still "is" session A by @@ -572,19 +654,6 @@ export function createAppShellChatActions(deps: { // The owner MOVES on an optimistic create: the send began on the new-chat // surface and the app is now on the session it just made, so the id is // taken from the flight and only the section comes from the capture. - const feedbackSessionId = optimisticSessionId ?? initialSessionId; - const diagnosticTarget = feedbackSessionId - ? { sessionId: feedbackSessionId } - : initialNewTaskTarget - ? { profileId: initialNewTaskTarget.profileId } - : undefined; - const sendStillOwnsCurrentSurface = - (feedbackSessionId !== undefined && - isShellSurfaceOwnerActive({ - ...sendOwner, - sessionId: feedbackSessionId, - })) || - (newChatOwner !== null && isNewChatSendSurfaceActive(newChatOwner)); if (!sendStillOwnsCurrentSurface) return false; if (isNoRealConnectionError(error)) { const reason = noRealConnectionReasonFromError(error); @@ -607,6 +676,49 @@ export function createAppShellChatActions(deps: { } } + async function enqueueMessage( + sessionId: string, + text: string, + placement: 'current_turn' | 'next_turn', + pending?: readonly PendingAttachment[], + options: MessageContextOptions = {}, + ): Promise { + const messageId = crypto.randomUUID(); + const directoryReferences = options.directoryReferences; + const quotes = options.quotes ?? []; + showTransientUserMessage(sessionId, messageId, text, retainedAttachmentRefs(pending ?? []), { + placement, + ...copiedArray('directoryReferences', directoryReferences), + ...copiedArray('quotes', quotes), + inlineReferences: [], + }); + try { + const attachmentItems = pending?.length ? toComposerIngestItems(pending) : []; + const retainedAttachments = pending?.length ? retainedAttachmentRefs(pending) : []; + const submitted = await submitAndProject({ + sessionId, + messageId, + placement, + command: { + text, + ...copiedArray('attachmentItems', attachmentItems), + ...copiedArray('retainedAttachments', retainedAttachments), + ...copiedArray('directoryReferences', directoryReferences), + ...copiedArray('quotes', quotes), + ...copiedArray('workspaceFileReferences', options.workspaceFileReferences), + }, + ...copiedArray('quotes', quotes), + isSurfaceVisible: () => activeIdRef.current === sessionId, + }); + // A refused Message opened nothing and left no row. Reporting it as sent + // would clear the composer draft the user has to retry from. + return submitted.kind !== 'refused'; + } catch (error) { + removeOptimisticUserMessage(sessionId, messageId); + throw error; + } + } + async function respondToSandboxBoundary(response: SandboxBoundaryResponse) { const sessionId = activeIdRef.current; if (!sessionId) return; @@ -704,7 +816,7 @@ export function createAppShellChatActions(deps: { } } async function retryMessages(sessionId: string) { - if (!addPendingSessionAction(sessionId, messageRetryPendingRef, setMessageRetryPendingBySession)) return; + if (!messageRetryPending.claim(sessionId)) return; try { if (activeIdRef.current !== sessionId) return; await transcriptRangeRef.current?.reload(); @@ -717,12 +829,13 @@ export function createAppShellChatActions(deps: { })); toastApi.error(copy.refreshFailedTitle, message, undefined, { sessionId }); } finally { - clearPendingSessionAction(sessionId, messageRetryPendingRef, setMessageRetryPendingBySession); + messageRetryPending.release(sessionId); } } return { send, + enqueueMessage, respondToSandboxBoundary, respondToUserQuestion, refreshMessages, diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index c2d3ae8b1d..61a60dc34d 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -18,17 +18,14 @@ */ import { useMemo, useRef } from "react"; -import type { DailyReviewSummary } from '@maka/core/daily-review'; import type { LlmConnection } from '@maka/core/llm-connections'; import type { PermissionMode } from '@maka/core/permission'; -import type { SessionStartMode } from '@maka/core/explore-agent'; import type { SessionSummary, StoredMessage } from '@maka/core/session'; import type { SettingsSection, ThemePreference } from '@maka/core/settings'; import type { UiLocale } from '@maka/core/ui-locale'; -import { formatDailyReviewMarkdown } from "@maka/ui"; -import type { DailyReviewMarkdownActionInput, NavSelection } from "@maka/ui"; +import type { NavSelection } from "@maka/ui"; import type { DesktopManualDiagnosticTarget } from '../preload/diagnostics-contract.js'; -import type { DesktopRuntimeHostRef } from '../preload/bridge-contract.js'; +import type { SessionStartMode } from './application/contracts/session-start-mode.js'; import { defaultRuntimeHostDiagnosticTarget, runOnDefaultRuntimeHost, @@ -39,7 +36,6 @@ import { } from "./command-palette-commands.js"; import type { Command } from "./command-palette-types.js"; import { renderConversationMarkdown } from "./conversation-markdown.js"; -import { dailyReviewActionErrorMessage } from "./daily-review-actions.js"; import { commandPaletteActionErrorMessage, commandPaletteConnectionTestFailureMessage, @@ -66,18 +62,6 @@ type ComposerImportOwner = { type RefBox = { current: T }; -type ComposerAppendHandle = { - appendText(text: string): void; -}; - -type DailyReviewBridge = { - fetchDay( - offsetDays: number, - daySpan?: number, - host?: DesktopRuntimeHostRef, - ): Promise; -}; - export interface AppShellCommandListOptions { uiLocale: UiLocale; activeId: string | undefined; @@ -86,22 +70,19 @@ export interface AppShellCommandListOptions { clientPathsAccessible: boolean; connections: LlmConnection[]; defaultConnection: string | null; - dailyReviewBridge: DailyReviewBridge; messages: StoredMessage[]; newTaskProfileId: string | undefined; settingsOpen: boolean; settingsProfileId: string | undefined; - sessions: SessionSummary[]; + sessions: readonly SessionSummary[]; themePref: ThemePreference; visibleSessions: SessionSummary[]; captureComposerImportOwner: () => ComposerImportOwner; - composerRef: RefBox; createSession: () => void; openSideConversation: () => void; startModeSession: (mode: SessionStartMode) => Promise; - isComposerImportOwnerActive: (owner: ComposerImportOwner) => boolean; openHelp: () => void; - openScheduledTaskForm: () => void; + openScheduledTaskCreate: () => void; openProjectFolder: () => Promise; openSessionInChat: (sessionId: string) => void; openSettings: () => void; @@ -109,9 +90,9 @@ export interface AppShellCommandListOptions { openSkillsFolder: () => Promise; openWorkspaceFolder: () => Promise; refreshConnections: () => Promise; - saveDailyReviewMarkdown: ( - input: DailyReviewMarkdownActionInput, - ) => Promise; + copyTodayDailyReview: () => Promise; + pasteTodayDailyReview: () => Promise; + saveTodayDailyReview: () => Promise; setNavSelection: (selection: NavSelection) => void; setPermissionMode: (mode: PermissionMode) => Promise; setThemePref: (themePref: ThemePreference) => void; @@ -160,7 +141,7 @@ export function buildAppShellCommandList( const { startModeSession } = optionsRef.current; await startModeSession("deep_research"); }, - onStartScheduledTask: () => optionsRef.current.openScheduledTaskForm(), + onStartScheduledTask: () => optionsRef.current.openScheduledTaskCreate(), onOpenSettings: () => optionsRef.current.openSettings(), onOpenSettingsSection: (section) => optionsRef.current.openSettingsSection(section), @@ -339,145 +320,9 @@ export function buildAppShellCommandList( } : undefined, activePermissionMode: options.activePermissionMode, - onCopyTodayDailyReview: async () => { - const { dailyReviewBridge, toastApi } = optionsRef.current; - let summary: DailyReviewSummary; - try { - summary = ( - await runOnDefaultRuntimeHost((host) => - dailyReviewBridge.fetchDay(0, 1, host), - ) - ).value; - } catch (err) { - toastApi.error( - copy.copyFailedTitle, - dailyReviewActionErrorMessage( - err, - copy.reviewCopyFallback, - options.uiLocale, - ), - undefined, - defaultRuntimeHostDiagnosticTarget(err), - ); - return; - } - try { - const markdown = formatDailyReviewMarkdown( - summary, - copy.today, - options.uiLocale, - ); - await navigator.clipboard.writeText(markdown); - toastApi.success( - copy.reviewCopiedTitle, - copy.reviewSummary( - summary.totals.sessionCount, - summary.totals.requestCount, - ), - ); - } catch (err) { - toastApi.error( - copy.copyFailedTitle, - dailyReviewActionErrorMessage( - err, - copy.clipboardDenied, - options.uiLocale, - ), - ); - } - }, - onPasteTodayDailyReviewIntoComposer: async () => { - const { - captureComposerImportOwner, - composerRef, - dailyReviewBridge, - isComposerImportOwnerActive, - toastApi, - } = optionsRef.current; - const owner = captureComposerImportOwner(); - if (!owner.sessionId) return; - try { - const summary = ( - await runOnDefaultRuntimeHost((host) => - dailyReviewBridge.fetchDay(0, 1, host), - ) - ).value; - const markdown = formatDailyReviewMarkdown( - summary, - copy.today, - options.uiLocale, - ); - if (!isComposerImportOwnerActive(owner)) return; - composerRef.current?.appendText(markdown); - toastApi.success( - copy.reviewPastedTitle, - copy.reviewSummary( - summary.totals.sessionCount, - summary.totals.requestCount, - ), - ); - } catch (err) { - if (isComposerImportOwnerActive(owner)) { - toastApi.error( - copy.pasteFailedTitle, - dailyReviewActionErrorMessage( - err, - copy.reviewUnavailable, - options.uiLocale, - ), - undefined, - defaultRuntimeHostDiagnosticTarget(err), - ); - } - } - }, - onSaveTodayDailyReviewToFile: async () => { - const { dailyReviewBridge, saveDailyReviewMarkdown, toastApi } = - optionsRef.current; - let summary: DailyReviewSummary; - try { - summary = ( - await runOnDefaultRuntimeHost((host) => - dailyReviewBridge.fetchDay(0, 1, host), - ) - ).value; - } catch (err) { - toastApi.error( - copy.saveFailedTitle, - dailyReviewActionErrorMessage( - err, - copy.reviewUnavailable, - options.uiLocale, - ), - undefined, - defaultRuntimeHostDiagnosticTarget(err), - ); - return; - } - try { - const markdown = formatDailyReviewMarkdown( - summary, - copy.today, - options.uiLocale, - ); - await saveDailyReviewMarkdown({ - day: summary.day, - range: 1, - totals: summary.totals, - markdown, - label: copy.today, - }); - } catch (err) { - toastApi.error( - copy.saveFailedTitle, - dailyReviewActionErrorMessage( - err, - copy.reviewUnavailable, - options.uiLocale, - ), - ); - } - }, + onCopyTodayDailyReview: () => optionsRef.current.copyTodayDailyReview(), + onPasteTodayDailyReviewIntoComposer: () => optionsRef.current.pasteTodayDailyReview(), + onSaveTodayDailyReviewToFile: () => optionsRef.current.saveTodayDailyReview(), onCopyDiagnostics: async () => { const { captureComposerImportOwner, diff --git a/apps/desktop/src/renderer/app-shell-copy.ts b/apps/desktop/src/renderer/app-shell-copy.ts index 78da030fd6..2f15529095 100644 --- a/apps/desktop/src/renderer/app-shell-copy.ts +++ b/apps/desktop/src/renderer/app-shell-copy.ts @@ -77,10 +77,3 @@ function commandPaletteConnectionTestFailureFallback(result: ConnectionTestResul } return copy.unknown; } - -export function openSkillFailureCopy( - reason: 'invalid_id' | 'missing' | 'blocked_path' | 'not_file' | 'not_directory' | 'open_failed', - locale: UiLocale, -): string { - return getShellCopy(locale).skillActions.openFailures[reason]; -} diff --git a/apps/desktop/src/renderer/app-shell-daily-review-actions.ts b/apps/desktop/src/renderer/app-shell-daily-review-actions.ts deleted file mode 100644 index 27b81dc2be..0000000000 --- a/apps/desktop/src/renderer/app-shell-daily-review-actions.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { UiLocale } from '@maka/core/ui-locale'; -import type { DailyReviewMarkdownActionInput } from '@maka/ui'; -import { dailyReviewActionErrorMessage, dailyReviewExportDefaultName } from './daily-review-actions'; -import { getShellCopy } from './locales/shell-copy.js'; - -type ToastApi = { - success(title: string, description?: string): void; - error(title: string, description?: string): void; -}; - -type RefBox = { current: T }; - -type ComposerAppendHandle = { - appendText(text: string): void; -}; - -type DailyReviewFeedbackOptions = { - shouldShowFeedback?: () => boolean; -}; - -export interface AppShellDailyReviewActions { - copyDailyReviewMarkdown(input: DailyReviewMarkdownActionInput, options?: DailyReviewFeedbackOptions): Promise; - appendDailyReviewMarkdown(input: DailyReviewMarkdownActionInput): void; - saveDailyReviewMarkdown(input: DailyReviewMarkdownActionInput, options?: DailyReviewFeedbackOptions): Promise; -} - -export function createAppShellDailyReviewActions(deps: { - uiLocale: UiLocale; - composerRef: RefBox; - toastApi: ToastApi; -}): AppShellDailyReviewActions { - const { uiLocale, composerRef, toastApi } = deps; - const copy = getShellCopy(uiLocale).commandActions; - - async function copyDailyReviewMarkdown(input: DailyReviewMarkdownActionInput, options: DailyReviewFeedbackOptions = {}) { - const shouldShowFeedback = options.shouldShowFeedback ?? (() => true); - try { - await navigator.clipboard.writeText(input.markdown); - if (shouldShowFeedback()) { - toastApi.success( - copy.reviewCopied(input.label), - copy.reviewSummary(input.totals.sessionCount, input.totals.requestCount), - ); - } - } catch (error) { - if (shouldShowFeedback()) { - toastApi.error(copy.copyFailedTitle, dailyReviewActionErrorMessage(error, copy.clipboardDenied, uiLocale)); - } - } - } - - function appendDailyReviewMarkdown(input: DailyReviewMarkdownActionInput): void { - composerRef.current?.appendText(input.markdown); - toastApi.success( - copy.reviewPasted(input.label), - copy.reviewSummary(input.totals.sessionCount, input.totals.requestCount), - ); - } - - async function saveDailyReviewMarkdown(input: DailyReviewMarkdownActionInput, options: DailyReviewFeedbackOptions = {}) { - const shouldShowFeedback = options.shouldShowFeedback ?? (() => true); - try { - const result = await window.maka.dailyReview.saveMarkdownToFile({ - markdown: input.markdown, - defaultName: dailyReviewExportDefaultName({ range: input.range, day: input.day }), - }); - if (result.ok) { - if (shouldShowFeedback()) { - toastApi.success( - copy.reviewSaved(input.label), - copy.reviewSummary(input.totals.sessionCount, input.totals.requestCount), - ); - } - } else if (result.reason === 'canceled') { - // User dismissed the dialog, no toast. - } else if (result.reason === 'invalid_input') { - if (shouldShowFeedback()) toastApi.error(copy.saveFailedTitle, copy.invalidExport); - } else { - if (shouldShowFeedback()) toastApi.error(copy.saveFailedTitle, copy.writeFailed); - } - } catch (err) { - if (shouldShowFeedback()) { - toastApi.error(copy.saveFailedTitle, dailyReviewActionErrorMessage(err, copy.reviewSaveFallback, uiLocale)); - } - } - } - - return { - copyDailyReviewMarkdown, - appendDailyReviewMarkdown, - saveDailyReviewMarkdown, - }; -} diff --git a/apps/desktop/src/renderer/app-shell-daily-review-bridge.ts b/apps/desktop/src/renderer/app-shell-daily-review-bridge.ts deleted file mode 100644 index 7752a4f84f..0000000000 --- a/apps/desktop/src/renderer/app-shell-daily-review-bridge.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { DailyReviewRange } from '@maka/core/daily-review'; -import type { UiLocale } from '@maka/core/ui-locale'; -import type { DesktopRuntimeHostRef } from '../preload/bridge-contract.js'; -import { getShellRemainingCopy } from './locales/shell-remaining-copy.js'; - -export function createAppShellDailyReviewBridge(locale: UiLocale = 'zh') { - const copy = getShellRemainingCopy(locale).dailyReview; - return { - async fetchDay(offsetDays: number, daySpan?: number, host?: DesktopRuntimeHostRef) { - const result = await window.maka.dailyReview.day(offsetDays, daySpan, host); - if (!result.ok) throw new Error(result.error.message); - return result.data; - }, - runOnce(input: { range: DailyReviewRange; offsetDays?: number }) { - const runOnce = window.maka.dailyReview.runOnce; - if (!runOnce) throw new Error(copy.unavailable); - return runOnce(input); - }, - listArchives() { - const listArchives = window.maka.dailyReview.listArchives; - if (!listArchives) throw new Error(copy.historyUnavailable); - return listArchives(); - }, - async getArchive(archiveId: string) { - const getArchive = window.maka.dailyReview.getArchive; - if (!getArchive) throw new Error(copy.historyUnavailable); - const archive = await getArchive(archiveId); - if (!archive) throw new Error(copy.archiveMissing); - return archive; - }, - }; -} diff --git a/apps/desktop/src/renderer/app-shell-e2e-fixture.ts b/apps/desktop/src/renderer/app-shell-e2e-fixture.ts index fa88f59c9d..b37fc542e1 100644 --- a/apps/desktop/src/renderer/app-shell-e2e-fixture.ts +++ b/apps/desktop/src/renderer/app-shell-e2e-fixture.ts @@ -34,7 +34,7 @@ export function createAppShellE2eFixtureActions(options: { setActiveId: (sessionId: string | undefined) => void; setNavSelection: Dispatch>; setSearchModalOpen: Dispatch>; - setSessionListCollapsed: Dispatch>; + setSessionListCollapsed(collapsed: boolean): void; workbar: { rightCollapsed: boolean; toggleRight(): void; diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index ed37f4cafb..4a9b383fdf 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -28,15 +28,13 @@ import type { UiLocale } from '@maka/core/ui-locale'; import { generalizedErrorMessageChinese } from '@maka/core/redaction'; import { sessionExpectsEventStream } from '@maka/core/session-event-health'; import { type ShellRunUpdate } from '@maka/core/events'; -import type { LiveTurnProjection, NavSelection, SessionViewMode } from '@maka/ui'; +import type { LiveTurnProjection, NavSelection } from '@maka/ui'; import { messageReadErrorMessage } from './app-shell-copy'; import { getDesktopConversationCopy } from './locales/conversation-copy.js'; -import { getShellRemainingCopy } from './locales/shell-remaining-copy.js'; import { applyTheme, applyThemePalette } from './theme'; import { startTitlebarModalSync } from './titlebar-modal-sync'; import { safeLocalStorageSet } from './browser-storage'; import type { NavigationState } from './nav-selection.js'; -import { writeSessionListViewMode } from './session-list-layout.js'; import { createSessionEventStreamSubscription, evaluateSessionEventStreamSnapshot, @@ -47,13 +45,13 @@ import type { DesktopRuntimeHostProfileChangedEvent, WindowCommand, } from '../preload/bridge-contract.js'; -import { parseDesktopSessionKey } from '../shared/runtime-host-identity.js'; import { mergeShellRunNotification, mergeShellRunUpdates, ShellRunHydration, type ShellRunUpdatesBySession, } from './shell-run-update-state.js'; +import { runtimeHostChangeRetiresSession } from '../shared/runtime-host-identity.js'; import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore, @@ -61,7 +59,6 @@ import { } from './desktop-transcript-range-store.js'; type RefBox = { current: T }; -const LAYOUT_PERSIST_DEBOUNCE_MS = 200; type SessionEventHealthUpdater = ( updater: (current: Record) => Record, @@ -121,9 +118,6 @@ export function useAppShellHostEffects() { export function useAppShellPersistenceEffects(options: { navigationState: NavigationState; - sessionListCollapsed: boolean; - sessionListWidth: number; - sessionListViewMode: SessionViewMode; themePalette: ThemePalette; themePref: ThemePreference; }) { @@ -144,28 +138,6 @@ export function useAppShellPersistenceEffects(options: { applyThemePalette(options.themePalette); }, [options.themePalette]); - // PR-FE-BUG-HUNT-5 (kenji bug-hunt 2026-06-24 LOW): pointer drag on - // the sidebar resizer fires `setSessionListWidth` on every move - // event — at ~60Hz over a long drag, that's a couple hundred - // localStorage writes for a single resize gesture. The setting - // converges to the user's final width at rest; intermediate - // values aren't load-bearing. 200ms trailing debounce keeps the - // last-render value in storage without flushing every pixel. - useEffect(() => { - const handle = window.setTimeout(() => { - safeLocalStorageSet('maka-chat-list-width-v1', String(options.sessionListWidth)); - }, LAYOUT_PERSIST_DEBOUNCE_MS); - return () => window.clearTimeout(handle); - }, [options.sessionListWidth]); - - useEffect(() => { - safeLocalStorageSet('maka-chat-list-collapsed-v1', options.sessionListCollapsed ? 'true' : 'false'); - }, [options.sessionListCollapsed]); - - useEffect(() => { - writeSessionListViewMode(options.sessionListViewMode); - }, [options.sessionListViewMode]); - // Persist the active destination and each hub's last selected module. // Strict localStorage availability check — Vite dev sometimes runs through // a worker where it isn't defined. @@ -187,59 +159,41 @@ export function useAppShellBootstrapSubscriptions(options: { handleConnectionEvent: (event: ConnectionEvent) => void; openHelp: () => void; openSettings: () => void; - pendingPermissionModeChangesRef: RefBox>; - pendingSessionModelChangesRef: RefBox>; - pendingTurnActionTimersRef: RefBox>>; - pendingTurnActionsRef: RefBox>; + clearPendingTurnActions: () => void; projectPickerPendingRef: RefBox; projectPickerRequestRef: RefBox; refreshConnections: () => Promise; refreshMemoryActive: (failureContext?: 'load') => Promise; refreshMessages: (sessionId: string) => Promise; - refreshScheduledTasks: (options?: { shouldShowError?: () => boolean }) => Promise; refreshProjects: () => Promise; refreshShellSettings: () => Promise; - refreshSkills: (options?: { shouldShowError?: () => boolean }) => Promise; - refreshManagedSkillSources: (options?: { shouldShowError?: () => boolean }) => Promise; - refreshBundledSkillCatalog: (options?: { shouldShowError?: () => boolean }) => Promise; refreshSessions: () => Promise; rendererMountedRef: RefBox; setActiveId: (sessionId: string | undefined) => void; setMessages: (messages: StoredMessage[]) => void; - setNavSelection: (selection: NavSelection) => void; setSessionEventHealthBySession: SessionEventHealthUpdater; toastApi: ToastApi; }) { const runDeferredStartupRefreshes = useEffectEvent(() => { void options.refreshSessions(); - void options.refreshSkills(); - void options.refreshManagedSkillSources(); - void options.refreshBundledSkillCatalog(); - void options.refreshScheduledTasks(); void options.applyE2eFixture(); }); const handleConnectionSubscriptionEvent = useEffectEvent((event: ConnectionEvent) => { options.handleConnectionEvent(event); }); const handleRuntimeHostChange = useEffectEvent((event: DesktopRuntimeHostProfileChangedEvent) => { - if ((event.removed || event.readiness === 'unavailable') && event.hostId) { + void options.refreshSessions().then((sessions) => { const activeSessionId = options.activeIdRef.current; - if (activeSessionId && desktopSessionHostId(activeSessionId) === event.hostId) { - options.setActiveId(undefined); - options.setMessages([]); - options.clearSessionRendererState(activeSessionId); - } - } - void options.refreshSessions(); + if (!runtimeHostChangeRetiresSession(event, activeSessionId, sessions)) return; + options.setActiveId(undefined); + options.setMessages([]); + options.clearSessionRendererState(activeSessionId); + }); if (event.readiness !== 'ready') return; if (!event.isDefault) return; void options.refreshProjects(); void options.refreshConnections(); void options.refreshMemoryActive('load'); - void options.refreshSkills(); - void options.refreshManagedSkillSources(); - void options.refreshBundledSkillCatalog(); - void options.refreshScheduledTasks(); }); // PR-2088: the macOS application menu routes New Task / Settings / Keyboard // Shortcuts here through one channel. The renderer already owns these @@ -297,23 +251,6 @@ export function useAppShellBootstrapSubscriptions(options: { } }, ); - const handleScheduledTaskChange = useEffectEvent(() => { - void options.refreshScheduledTasks(); - }); - const handleScheduledTaskDue = useEffectEvent((task: { id: string; title: string }) => { - const copy = getShellRemainingCopy(options.uiLocale).notifications; - void options.refreshScheduledTasks(); - options.toastApi.toast({ - title: copy.scheduledTask, - description: task.title, - variant: 'info', - duration: 8000, - action: { - label: copy.viewScheduledTasks, - onClick: () => options.setNavSelection({ section: 'automations', module: 'scheduled-tasks' }), - }, - }); - }); // Both shortcuts fire while the composer has focus — they always did, and // that is the point of a global new-task / settings key — so both opt out of // the hook's default "stay silent while typing" rule. @@ -346,13 +283,7 @@ export function useAppShellBootstrapSubscriptions(options: { options.rendererMountedRef.current = false; options.projectPickerRequestRef.current += 1; options.projectPickerPendingRef.current = false; - for (const timeoutHandle of options.pendingTurnActionTimersRef.current.values()) { - clearTimeout(timeoutHandle); - } - options.pendingTurnActionTimersRef.current.clear(); - options.pendingTurnActionsRef.current.clear(); - options.pendingPermissionModeChangesRef.current.clear(); - options.pendingSessionModelChangesRef.current.clear(); + options.clearPendingTurnActions(); }); useEffect(() => { @@ -379,8 +310,6 @@ export function useAppShellBootstrapSubscriptions(options: { () => void options.refreshShellSettings(), ); const unsubscribeSessionChanges = window.maka.sessions.subscribeChanges(handleSessionChange); - const unsubscribeScheduledTaskChanges = window.maka.scheduledTasks.subscribeChanges(handleScheduledTaskChange); - const unsubscribeScheduledTaskDue = window.maka.scheduledTasks.subscribeDue(handleScheduledTaskDue); const unsubscribeWindowCommand = window.maka.appWindow.subscribeCommand(handleWindowCommand); markRendererMounted(); return () => { @@ -391,24 +320,15 @@ export function useAppShellBootstrapSubscriptions(options: { unsubscribeSettingsExternal(); unsubscribeClientSettings(); unsubscribeSessionChanges(); - unsubscribeScheduledTaskChanges(); - unsubscribeScheduledTaskDue(); unsubscribeWindowCommand(); }; }, []); } -function desktopSessionHostId(sessionId: string): string | undefined { - try { - return parseDesktopSessionKey(sessionId).hostId; - } catch { - return undefined; - } -} - export function useActiveSessionEvents(options: { uiLocale: UiLocale; activeId: string | undefined; + activeProfileId: string | undefined; activeIdRef: RefBox; handleEvent: (sessionId: string, event: SessionEvent) => void; beginObservationSeed?: (sessionId: string) => number; @@ -428,8 +348,7 @@ export function useActiveSessionEvents(options: { ) => { if (!isDisposed() && options.activeIdRef.current === sessionId) { const snapshot = store.snapshot(); - const next = [...snapshot.messages]; - options.setMessages(next); + options.setMessages([...snapshot.messages]); if (snapshot.ready) options.setMessageLoadPending(false); } }); @@ -487,10 +406,12 @@ export function useActiveSessionEvents(options: { useLayoutEffect(() => { if (!activeId) return; - const observationGeneration = beginObservationSeed(activeId); let disposed = false; + let observationAttempt = 0; + let observationFailures = 0; + let observationRetryTimer: ReturnType | undefined; + let unsubscribeSessionEvents = () => {}; const transcript = new DesktopTranscriptRangeStore(activeId); - const subscribedAt = Date.now(); options.setMessageLoadErrorBySession((current) => { if (!current[activeId]) return current; const next = { ...current }; @@ -501,7 +422,7 @@ export function useActiveSessionEvents(options: { ...current, [activeId]: createSessionEventStreamSubscription({ sessionId: activeId, - now: subscribedAt, + now: Date.now(), }), })); const openTranscript = (signal: AbortSignal) => @@ -527,31 +448,64 @@ export function useActiveSessionEvents(options: { applyReadError(activeId, error, () => disposed); }); options.transcriptRangeRef.current = controller; - const unsubscribe = window.maka.sessions.subscribeEvents( - activeId, - (event) => { - handleSessionEvent(activeId, event); - }, - () => completeObservationSeed(activeId, observationGeneration), - (phase) => { - if (phase === 'pending') beginObservationSeed(activeId); - else completeObservationSeed(activeId); - }, - ); + const subscribeSessionEvents = () => { + const attempt = ++observationAttempt; + const observationGeneration = beginObservationSeed(activeId); + let unsubscribeRequested = false; + let unsubscribeCurrent = () => { + unsubscribeRequested = true; + }; + const unsubscribe = window.maka.sessions.subscribeEvents( + activeId, + (event) => { + if (attempt !== observationAttempt) return; + handleSessionEvent(activeId, event); + }, + () => { + if (attempt !== observationAttempt) return; + observationFailures = 0; + completeObservationSeed(activeId, observationGeneration); + }, + (phase) => { + if (attempt !== observationAttempt) return; + if (phase === 'pending') beginObservationSeed(activeId); + else completeObservationSeed(activeId); + }, + () => { + if (disposed || attempt !== observationAttempt) return; + unsubscribeCurrent(); + observationFailures += 1; + const retryDelayMs = Math.min(100 * (2 ** (observationFailures - 1)), 2_000); + observationRetryTimer = globalThis.setTimeout(() => { + observationRetryTimer = undefined; + if (!disposed && attempt === observationAttempt) subscribeSessionEvents(); + }, retryDelayMs); + }, + ); + unsubscribeCurrent = unsubscribe; + unsubscribeSessionEvents = unsubscribe; + if (unsubscribeRequested) unsubscribe(); + }; + subscribeSessionEvents(); return () => { disposed = true; + observationAttempt += 1; + if (observationRetryTimer !== undefined) { + globalThis.clearTimeout(observationRetryTimer); + } if (options.transcriptRangeRef.current?.store === transcript) { options.transcriptRangeRef.current = undefined; } void controller.close(); - unsubscribe(); + unsubscribeSessionEvents(); markSessionEventStreamClosed(activeId); }; - }, [activeId]); + }, [activeId, options.activeProfileId]); } export function useShellRunUpdates(options: { activeId: string | undefined; + hydrate?: boolean; setShellRunUpdatesBySession: (updater: (current: ShellRunUpdatesBySession) => ShellRunUpdatesBySession) => void; }) { const applyUpdates = useEffectEvent( @@ -580,6 +534,7 @@ export function useShellRunUpdates(options: { let retryTimer: ReturnType | undefined; let retryDelayMs = 250; const hydration = new ShellRunHydration(); + if (options.hydrate === false) hydration.commit(0); const unsubscribe = window.maka.shellRuns.subscribeUpdates((update) => { if (disposed) return; const live = hydration.accept(update); @@ -590,6 +545,7 @@ export function useShellRunUpdates(options: { } }); const hydrate = (epoch: number) => { + if (options.hydrate === false) return; void window.maka.shellRuns .list(sessionId) .then((updates) => { @@ -613,7 +569,7 @@ export function useShellRunUpdates(options: { }); }; const unsubscribeResync = window.maka.shellRuns.subscribeResync((event) => { - if (disposed || event.sessionId !== sessionId) return; + if (disposed || options.hydrate === false || event.sessionId !== sessionId) return; const epoch = hydration.begin(); retryDelayMs = 250; if (retryTimer !== undefined) { @@ -622,14 +578,14 @@ export function useShellRunUpdates(options: { } hydrate(epoch); }); - hydrate(hydration.begin()); + if (options.hydrate !== false) hydrate(hydration.begin()); return () => { disposed = true; if (retryTimer !== undefined) globalThis.clearTimeout(retryTimer); unsubscribe(); unsubscribeResync(); }; - }, [options.activeId]); + }, [options.activeId, options.hydrate]); } export function useSessionEventHealthPolling(options: { diff --git a/apps/desktop/src/renderer/app-shell-open-skill-action.ts b/apps/desktop/src/renderer/app-shell-open-skill-action.ts deleted file mode 100644 index a5588f4f2f..0000000000 --- a/apps/desktop/src/renderer/app-shell-open-skill-action.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { UiLocale } from '@maka/core/ui-locale'; -import { openSkillFailureCopy } from './app-shell-copy'; -import { - defaultRuntimeHostDiagnosticTarget, - runOnDefaultRuntimeHost, -} from './default-runtime-host-operation.js'; -import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; - -type ToastApi = { - error(title: string, description?: string, diagnosticTarget?: { profileId: string }): void; -}; - -export function createOpenSkillAction(deps: { - uiLocale: UiLocale; - isSkillsSurfaceActive: () => boolean; - toastApi: ToastApi; -}): (skillId: string) => Promise { - const { uiLocale, isSkillsSurfaceActive, toastApi } = deps; - const copy = getShellCopy(uiLocale).skillActions; - - async function openSkill(skillId: string) { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.skills.open(skillId, 'file', host), - ); - if (!result.ok) { - if (isSkillsSurfaceActive()) - toastApi.error( - copy.openFailedTitle, - openSkillFailureCopy(result.reason, uiLocale), - diagnosticTarget, - ); - } - } catch (error) { - if (isSkillsSurfaceActive()) { - toastApi.error( - copy.openFailedTitle, - localizedShellErrorMessage(error, copy.openFallback, uiLocale), - defaultRuntimeHostDiagnosticTarget(error), - ); - } - } - } - - return openSkill; -} diff --git a/apps/desktop/src/renderer/app-shell-overlays.tsx b/apps/desktop/src/renderer/app-shell-overlays.tsx index c904c4de6d..d675b618e1 100644 --- a/apps/desktop/src/renderer/app-shell-overlays.tsx +++ b/apps/desktop/src/renderer/app-shell-overlays.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { lazy, Suspense } from 'react'; +import { lazy, Suspense, useLayoutEffect, useRef } from 'react'; import type { ChatDefaultPermissionMode, SettingsSection, ThemePalette, ThemePreference } from '@maka/core/settings'; import type { ProviderType } from '@maka/core/llm-connections'; import type { DesktopSessionSummary } from '../preload/bridge-contract.js'; @@ -32,7 +32,15 @@ import type { ArchivedTasksBridge } from './settings/tasks-settings-page'; import type { UiLocaleUpdateGate } from './settings/ui-locale-update-gate'; import { getShellRemainingCopy } from './locales/shell-remaining-copy.js'; -const SettingsModal = lazy(() => import('./settings/settings-modal').then((m) => ({ default: m.SettingsModal }))); +const SettingsModal = lazy(async () => { + const e2eLatch = ( + window as typeof window & { + makaE2eLatch?: { wait(key: 'settings.chunk'): Promise }; + } + ).makaE2eLatch; + await e2eLatch?.wait('settings.chunk'); + return import('./settings/settings-modal'); +}); type SearchModalProps = Parameters[0]; @@ -70,7 +78,7 @@ export function AppShellOverlays(props: { * can disagree the moment anything else writes the setting. */ refreshChatDefaults(): void; - settingsRequestedSection: SettingsSection | undefined; + settingsRequest: { readonly section?: SettingsSection; readonly profileId?: string }; settingsProviderCatalogOpen: boolean; settingsConnectionDetailSlug: string | undefined; settingsCreateProviderType: ProviderType | undefined; @@ -103,7 +111,7 @@ export function AppShellOverlays(props: { searchModalOnNavigate, searchModalOpen, settingsOpen, - settingsRequestedSection, + settingsRequest, settingsProviderCatalogOpen, settingsConnectionDetailSlug, settingsCreateProviderType, @@ -118,15 +126,44 @@ export function AppShellOverlays(props: { onExternalSessionImported, } = props; + const closeSettingsRef = useRef(closeSettings); + useLayoutEffect(() => { + closeSettingsRef.current = closeSettings; + }); + + // The overlay boundary, rather than the lazy Settings chunk, owns Escape. + // That keeps one owner installed before paint for both the Suspense loading + // surface and the resolved Settings surface. Keep the listener stable while + // Settings is open, but read the latest shell callback after every commit. + useLayoutEffect(() => { + if (!settingsOpen) return; + + function onKeyDown(event: globalThis.KeyboardEvent) { + if ( + event.key.toLowerCase() !== 'escape' || + event.defaultPrevented || + event.ctrlKey || + event.metaKey || + event.altKey + ) { + return; + } + event.preventDefault(); + closeSettingsRef.current(); + } + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [settingsOpen]); + // #1045: base commands freeze per open/close; session rows stay live on // visibleSessions/activeId. run() closures read latest options via ref. const commands = useAppShellCommands(paletteOpen, commandOptions); - const copyDiagnosticsCommand = commands.find((command) => command.id === 'diag:copy-diagnostics'); useHotkeys([ { keys: 'mod+shift+d', allowInInputs: true, - onPress: () => void copyDiagnosticsCommand?.run(), + onPress: () => void commands.find((command) => command.id === 'diag:copy-diagnostics')?.run(), }, ]); return ( @@ -143,7 +180,7 @@ export function AppShellOverlays(props: { uiLocaleUpdateGate={uiLocaleUpdateGate} onUserLabelChange={setUserLabel} onDefaultPermissionModeChange={() => refreshChatDefaults()} - requestedSection={settingsRequestedSection} + request={settingsRequest} openProviderCatalog={settingsProviderCatalogOpen} initialConnectionSlug={settingsConnectionDetailSlug} initialCreateProviderType={settingsCreateProviderType} diff --git a/apps/desktop/src/renderer/app-shell-revision-actions.ts b/apps/desktop/src/renderer/app-shell-revision-actions.ts index 7bd256fb07..18b722ef7a 100644 --- a/apps/desktop/src/renderer/app-shell-revision-actions.ts +++ b/apps/desktop/src/renderer/app-shell-revision-actions.ts @@ -37,11 +37,9 @@ import { type SessionCopyAttemptKey, } from './session-copy-attempt.js'; import { readSettledMessages } from './session-message-settlement.js'; +import type { MessageListUpdater } from './session-workspace-actions.js'; type RefBox = { current: T }; -type MessageListUpdater = ( - next: StoredMessage[] | ((current: StoredMessage[]) => StoredMessage[]), -) => void; type ToastApi = { info(title: string, description?: string): void; diff --git a/apps/desktop/src/renderer/app-shell-scheduled-task-actions.ts b/apps/desktop/src/renderer/app-shell-scheduled-task-actions.ts deleted file mode 100644 index 54a35cc63b..0000000000 --- a/apps/desktop/src/renderer/app-shell-scheduled-task-actions.ts +++ /dev/null @@ -1,232 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { Dispatch, SetStateAction } from "react"; -import type { CreateScheduledTaskInput, ScheduledTask, UpdateScheduledTaskInput } from '@maka/core/scheduled-task'; -import type { UiLocale } from '@maka/core/ui-locale'; -import { getShellRemainingCopy } from "./locales/shell-remaining-copy.js"; -import { localizedShellErrorMessage } from "./locales/shell-copy.js"; -import type { DesktopRuntimeHostRef } from '../preload/bridge-contract.js'; -import { - defaultRuntimeHostDiagnosticTarget, - runIfDefaultRuntimeHostCurrent, - runOnDefaultRuntimeHost, -} from './default-runtime-host-operation.js'; - -type ToastApi = { - success(title: string, description?: string): void; - error( - title: string, - description?: string, - diagnosticDetails?: string, - diagnosticTarget?: { profileId: string }, - ): void; - confirm(options: { - title: string; - description: string; - confirmLabel: string; - cancelLabel: string; - destructive?: boolean; - }): Promise; -}; - -type ScheduledTaskCreateInput = Omit; -type RefBox = { current: T }; - -export interface AppShellScheduledTaskActions { - refreshScheduledTasks(options?: { - shouldShowError?: () => boolean; - }): Promise; - createScheduledTask(input: ScheduledTaskCreateInput): Promise; - updateScheduledTask(id: string, patch: UpdateScheduledTaskInput): Promise; - toggleScheduledTask(id: string, enabled: boolean): Promise; - triggerScheduledTaskNow(id: string): Promise; - snoozeScheduledTask(id: string): Promise; - clearScheduledTaskRunHistory(id: string): Promise; - deleteScheduledTask(id: string): Promise; -} - -export function createAppShellScheduledTaskActions(deps: { - uiLocale: UiLocale; - getScheduledTasks: () => readonly ScheduledTask[]; - isScheduledTasksSurfaceActive: () => boolean; - refreshGenerationsRef: RefBox<{ scheduledTasks: number }>; - setScheduledTasks: Dispatch>; - toastApi: ToastApi; -}): AppShellScheduledTaskActions { - const { - uiLocale, - getScheduledTasks, - isScheduledTasksSurfaceActive, - refreshGenerationsRef, - setScheduledTasks, - toastApi, - } = deps; - const copy = getShellRemainingCopy(uiLocale).scheduledTaskActions; - - async function refreshScheduledTasks( - options: { shouldShowError?: () => boolean } = {}, - ) { - const generation = ++refreshGenerationsRef.current.scheduledTasks; - try { - const next = await runOnDefaultRuntimeHost((host) => - window.maka.scheduledTasks.list(host), - ); - await runIfDefaultRuntimeHostCurrent(next.host, () => { - if (generation === refreshGenerationsRef.current.scheduledTasks) { - setScheduledTasks(next.value); - } - }); - } catch (error) { - if (generation !== refreshGenerationsRef.current.scheduledTasks) return; - if (options.shouldShowError?.() ?? true) { - toastApi.error( - copy.refreshFailed, - localizedShellErrorMessage(error, copy.refreshFallback, uiLocale), - undefined, - defaultRuntimeHostDiagnosticTarget(error), - ); - } - } - } - - async function runScheduledTaskMutation(mutation: { - run: (host: DesktopRuntimeHostRef) => Promise; - successTitle?: string; - successDetail?: string; - errorTitle: string; - errorFallback: string; - errorMessage?: (error: unknown) => string | undefined; - }): Promise { - try { - await runOnDefaultRuntimeHost(mutation.run); - await refreshScheduledTasks({ - shouldShowError: isScheduledTasksSurfaceActive, - }); - if (mutation.successTitle && isScheduledTasksSurfaceActive()) { - toastApi.success(mutation.successTitle, mutation.successDetail); - } - return true; - } catch (error) { - if (isScheduledTasksSurfaceActive()) { - toastApi.error( - mutation.errorTitle, - mutation.errorMessage?.(error) ?? - localizedShellErrorMessage(error, mutation.errorFallback, uiLocale), - undefined, - defaultRuntimeHostDiagnosticTarget(error), - ); - } - return false; - } - } - - return { - refreshScheduledTasks, - createScheduledTask(input) { - return runScheduledTaskMutation({ - run: (host) => window.maka.scheduledTasks.create(input, host), - successTitle: copy.created, - successDetail: input.title, - errorTitle: copy.createFailed, - errorFallback: copy.createFallback, - errorMessage: (error) => - errorMessage(error).includes("SCHEDULED_TASK_INCOGNITO_ACTIVE") - ? copy.createIncognitoBlocked - : undefined, - }); - }, - updateScheduledTask(id, patch) { - return runScheduledTaskMutation({ - run: (host) => window.maka.scheduledTasks.update(id, patch, host), - successTitle: copy.saved, - successDetail: patch.title, - errorTitle: copy.saveFailed, - errorFallback: copy.saveFallback, - }); - }, - async toggleScheduledTask(id, enabled) { - await runScheduledTaskMutation({ - run: (host) => window.maka.scheduledTasks.setEnabled(id, enabled, host), - successTitle: enabled ? copy.enabled : copy.paused, - errorTitle: copy.updateFailed, - errorFallback: copy.updateFallback, - }); - }, - async triggerScheduledTaskNow(id) { - const task = getScheduledTasks().find((entry) => entry.id === id); - await runScheduledTaskMutation({ - run: (host) => window.maka.scheduledTasks.triggerNow(id, host), - successTitle: copy.triggered, - successDetail: task?.title, - errorTitle: copy.triggerFailed, - errorFallback: copy.triggerFallback, - }); - }, - async snoozeScheduledTask(id) { - const task = getScheduledTasks().find((entry) => entry.id === id); - await runScheduledTaskMutation({ - run: (host) => window.maka.scheduledTasks.snooze(id, host), - successTitle: copy.snoozed, - successDetail: task?.title, - errorTitle: copy.snoozeFailed, - errorFallback: copy.snoozeFallback, - }); - }, - async clearScheduledTaskRunHistory(id) { - const task = getScheduledTasks().find((entry) => entry.id === id); - const ok = await toastApi.confirm({ - title: copy.clearTitle(task?.title ?? copy.task), - description: copy.clearDescription, - confirmLabel: copy.clear, - cancelLabel: copy.cancel, - destructive: true, - }); - if (!ok) return; - await runScheduledTaskMutation({ - run: (host) => window.maka.scheduledTasks.clearRunHistory(id, host), - successTitle: copy.cleared, - successDetail: task?.title, - errorTitle: copy.clearFailed, - errorFallback: copy.clearFallback, - }); - }, - async deleteScheduledTask(id) { - const task = getScheduledTasks().find((entry) => entry.id === id); - const ok = await toastApi.confirm({ - title: copy.deleteTitle(task?.title ?? copy.task), - description: copy.deleteDescription, - confirmLabel: copy.delete, - cancelLabel: copy.cancel, - destructive: true, - }); - if (!ok) return; - await runScheduledTaskMutation({ - run: (host) => window.maka.scheduledTasks.delete(id, host), - successTitle: copy.deleted, - errorTitle: copy.deleteFailed, - errorFallback: copy.deleteFallback, - }); - }, - }; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error ?? ""); -} diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index dfdf850ec3..cf3491ca7f 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -23,13 +23,14 @@ import type { UiLocale } from '@maka/core/ui-locale'; import { applyLiveTurnEvent, clearInteractions, - dequeueInteractionByRequestId, - dequeueInteractionByToolUseId, - enqueueInteraction, + reduceInteractionQueues, reconcileTerminalLiveTurn, settleLiveTurnStep, + TOOL_STREAM_MAX_CHUNKS, + TOOL_STREAM_MAX_TOTAL_CHARS, type LiveTurnProjection, type InteractionQueues, + type TransientUserMessageProjection, } from '@maka/ui'; import type { RefreshMessagesOptions } from './app-shell-chat-actions.js'; import type { MessageQueueUiState } from './app-shell-session-ui-state.js'; @@ -61,6 +62,7 @@ export interface AppShellSessionEventHandlers { reconcilePersistedMessages(sessionId: string, messages: readonly StoredMessage[]): void; settleAssistantStreaming(sessionId: string, messageId?: string): Promise; flushDisplayEvents(sessionId: string): void; + dropDisplayEvents(sessionId: string): void; markDisplayPending(sessionId: string): void; markDisplayReady(sessionId: string): void; } @@ -84,6 +86,11 @@ export function createAppShellSessionEventHandlers(options: { setLiveTurnBySession: StateUpdater>; setInteractionBySession: StateUpdater; setMessageQueueBySession?: StateUpdater>; + projectQueuedTransientMessages?: ( + sessionId: string, + messages: readonly TransientUserMessageProjection[], + ) => void; + removeTransientMessage?: (sessionId: string, messageId: string) => void; onInteractionChanged?: (sessionId: string) => void; /** A boundary decision settled: the session's execution boundary may have moved. */ onExecutionBoundaryChanged?: (sessionId: string) => void; @@ -111,6 +118,8 @@ export function createAppShellSessionEventHandlers(options: { setLiveTurnBySession, setInteractionBySession, setMessageQueueBySession, + projectQueuedTransientMessages, + removeTransientMessage, onInteractionChanged, onExecutionBoundaryChanged, onContextCompactionOutcome, @@ -165,14 +174,33 @@ export function createAppShellSessionEventHandlers(options: { } function scheduleDisplayEvent(sessionId: string, event: SessionEvent): void { - const events = displayBatch.pendingEvents.get(sessionId); - if (events) events.push(event); - else displayBatch.pendingEvents.set(sessionId, [event]); + const events = displayBatch.pendingEvents.get(sessionId) ?? []; + events.push(event); + displayBatch.pendingEvents.set(sessionId, events); + if (event.type === 'tool_output_delta') { + let chunks = 0; + let chars = 0; + for (let index = events.length - 1; index >= 0; index -= 1) { + const candidate = events[index]; + if ( + candidate?.type === 'tool_output_delta' + && candidate.turnId === event.turnId + && candidate.toolUseId === event.toolUseId + ) { + chunks += 1; + chars += candidate.chunk.length; + if ( + chunks > TOOL_STREAM_MAX_CHUNKS + || chars > TOOL_STREAM_MAX_TOTAL_CHARS + ) events.splice(index, 1); + } + } + } if (displayBatch.framePending || !scheduleFrame) return; displayBatch.framePending = true; scheduleFrame(() => { displayBatch.framePending = false; - if (displayBatch.pendingEvents.size === 0) return; + if (!displayBatch.pendingEvents.size) return; const batches = new Map(displayBatch.pendingEvents); displayBatch.pendingEvents.clear(); setLiveTurnBySession((current) => replaceLiveTurns(current, batches)); @@ -181,10 +209,15 @@ export function createAppShellSessionEventHandlers(options: { function flushDisplayEvents(sessionId: string): void { const events = takePendingDisplayEvents(sessionId); - if (events.length === 0) return; + if (!events.length) return; updateLiveTurn(sessionId, events); } + function dropDisplayEvents(sessionId: string): void { + displayBatch.pendingEvents.delete(sessionId); + displayBatch.displayPendingSessions.delete(sessionId); + } + function markDisplayPending(sessionId: string): void { displayBatch.displayPendingSessions.add(sessionId); } @@ -267,12 +300,18 @@ export function createAppShellSessionEventHandlers(options: { return messageId ? { requiredAssistantMessageId: messageId } : undefined; } - function handleEvent(sessionId: string, event: SessionEvent): void { + function handleEvent(sessionId: string, event: SessionEvent) { + // Only unbounded, append-only display streams may wait for paint. Every + // lifecycle/readiness event stays synchronous and flushes these first. if ( scheduleFrame && activeIdRef.current === sessionId && canBatchDisplayEvents(sessionId) - && (event.type === 'text_delta' || event.type === 'thinking_delta') + && ( + event.type === 'text_delta' + || event.type === 'thinking_delta' + || event.type === 'tool_output_delta' + ) ) { scheduleDisplayEvent(sessionId, event); return; @@ -280,12 +319,35 @@ export function createAppShellSessionEventHandlers(options: { const pending = takePendingDisplayEvents(sessionId); const before = applyProjectionEvents(liveTurnBySessionRef.current[sessionId], pending); updateLiveTurn(sessionId, [...pending, event]); + setInteractionBySession((current) => + reduceInteractionQueues(current, sessionId, event), + ); switch (event.type) { case 'queue_update': + projectQueuedTransientMessages?.( + sessionId, + (event.steeringEntries ?? []).concat(event.followupEntries ?? []) + .filter((entry) => entry.state === 'queued') + .map((entry) => ({ + id: entry.messageId, + transientPlacement: entry.placement, + ...(entry.placement === 'current_turn' && { hostTurnId: event.turnId }), + ts: event.ts, + text: entry.content.displayText ?? entry.content.text, + ...(entry.content.attachments && { attachments: [...entry.content.attachments] }), + ...(entry.content.directoryReferences && { + directoryReferences: entry.content.directoryReferences, + }), + ...(entry.content.quotes && { quotes: [...entry.content.quotes] }), + ...(entry.content.inlineReferences && { + inlineReferences: [...entry.content.inlineReferences], + }), + })), + ); setMessageQueueBySession?.((current) => { - if (event.steering.length === 0 && event.followup.length === 0) { - if (!(sessionId in current)) return current; + if (!event.steering.length && !event.followup.length) { + if (!current[sessionId]) return current; const next = { ...current }; delete next[sessionId]; return next; @@ -302,22 +364,43 @@ export function createAppShellSessionEventHandlers(options: { }; }); break; + case 'message_admission': + if (event.outcome === 'retracted') { + removeTransientMessage?.(sessionId, event.messageId); + } + break; + case 'steering_message': + // The live Turn projection now renders this same messageId in place. + // Retire the renderer-owned tail row and its pending-queue card; a + // later nack queue_update will project both again if the Host returns + // the message to the queue. + removeTransientMessage?.(sessionId, event.messageId); + setMessageQueueBySession?.((current) => { + const queue = current[sessionId]; + if (!queue?.entries.some((entry) => entry.messageId === event.messageId)) return current; + const entries = queue.entries.filter((entry) => entry.messageId !== event.messageId); + if (entries.length > 0) { + return { ...current, [sessionId]: { ...queue, entries } }; + } + const next = { ...current }; + delete next[sessionId]; + return next; + }); + break; case 'text_complete': void refreshMessages(sessionId, { requiredAssistantMessageId: event.messageId }).catch(() => false); break; case 'sandbox_boundary_request': + case 'client_capability_request': case 'user_question_request': onInteractionChanged?.(sessionId); - setInteractionBySession((current) => enqueueInteraction(current, sessionId, event)); break; // The runtime drops its owner on this ack, not on the tool result that // follows it, so this is where the request stops being answerable — the // same point its boundary sibling settles on, below. case 'user_question_answer_ack': + case 'client_capability_decision_ack': onInteractionChanged?.(sessionId); - setInteractionBySession((current) => - dequeueInteractionByRequestId(current, sessionId, event.requestId), - ); break; case 'sandbox_boundary_decision_ack': onInteractionChanged?.(sessionId); @@ -326,17 +409,12 @@ export function createAppShellSessionEventHandlers(options: { // or the permission label keeps describing the permissions the session // had before the user granted more. onExecutionBoundaryChanged?.(sessionId); - setInteractionBySession((current) => - dequeueInteractionByRequestId(current, sessionId, event.requestId), - ); break; case 'tool_result': - setInteractionBySession((current) => dequeueInteractionByToolUseId(current, sessionId, event.toolUseId)); void refreshMessages(sessionId); break; case 'error': onInteractionChanged?.(sessionId); - setInteractionBySession((current) => clearInteractions(current, sessionId)); if (activeIdRef.current === sessionId) { if (isNoRealConnectionEvent(event)) { const reason = noRealConnectionReasonFromEvent(event); @@ -398,6 +476,7 @@ export function createAppShellSessionEventHandlers(options: { reconcilePersistedMessages, settleAssistantStreaming, flushDisplayEvents, + dropDisplayEvents, markDisplayPending, markDisplayReady, }; diff --git a/apps/desktop/src/renderer/app-shell-session-row-actions.ts b/apps/desktop/src/renderer/app-shell-session-row-actions.ts deleted file mode 100644 index cc4a002448..0000000000 --- a/apps/desktop/src/renderer/app-shell-session-row-actions.ts +++ /dev/null @@ -1,306 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { SessionSummary, StoredMessage } from '@maka/core/session'; -import type { UiLocale } from '@maka/core/ui-locale'; -import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; -import { revisionFamilySessionIds } from '@maka/core/session-revisions'; - -type RefBox = { current: T }; - -/** What `sessions.remove` settled on. `restored` means the task is still there. */ -type SessionRemoveDisposition = 'removed' | 'restored'; - -type ToastApi = { - success(title: string, description?: string): void; - error( - title: string, - description?: string, - diagnosticDetails?: string, - diagnosticTarget?: { sessionId: string }, - ): void; - confirm(options: { - title: string; - description: string; - confirmLabel: string; - cancelLabel: string; - destructive?: boolean; - }): Promise; -}; - -/** - * What a sweep can honestly say afterwards. `verified: false` means the catalog - * could not be read back, so neither `remaining` nor a success claim is safe. - */ -export interface SessionPurgeOutcome { - /** Tasks confirmed gone. */ - removed: number; - /** Tasks the catalog still reports. Empty when `verified` is false. */ - remaining: string[]; - /** - * Tasks restored while the sweep was reaching them. Neither removed nor - * failed: the deletion was called off because its premise was gone. - */ - restored: string[]; - verified: boolean; - /** First rejection and the Session whose Host produced it. */ - firstFailure?: { - error: unknown; - sessionId: string; - }; -} - -export interface AppShellSessionRowActions { - flagSession(sessionId: string, flagged: boolean): Promise; - archiveSession(sessionId: string): Promise; - unarchiveSession(sessionId: string): Promise; - renameSession(sessionId: string, name: string): Promise; - deleteSession(sessionId: string): Promise; - purgeSessions(sessionIds: readonly string[]): Promise; -} - -export function createAppShellSessionRowActions(deps: { - uiLocale: UiLocale; - activeIdRef: RefBox; - clearSessionRendererState: (sessionId: string) => void; - pendingSessionRowActionsRef: RefBox>; - refreshSessions: () => Promise; - sessionsRef: RefBox; - setActiveId: (sessionId: string | undefined) => void; - setMessages: (messages: StoredMessage[]) => void; - toastApi: ToastApi; -}): AppShellSessionRowActions { - const { - uiLocale, - activeIdRef, - clearSessionRendererState, - pendingSessionRowActionsRef, - refreshSessions, - sessionsRef, - setActiveId, - setMessages, - toastApi, - } = deps; - const copy = getShellCopy(uiLocale).sessionRowActions; - - async function runSessionRowAction( - sessionId: string, - actionId: 'flag' | 'archive' | 'rename' | 'delete', - errorTitle: string, - action: () => Promise, - ): Promise { - const sessionPrefix = `${sessionId}:`; - if (Array.from(pendingSessionRowActionsRef.current).some((key) => key.startsWith(sessionPrefix))) return; - const key = `${sessionId}:${actionId}`; - pendingSessionRowActionsRef.current.add(key); - try { - await action(); - } catch (error) { - toastApi.error( - errorTitle, - localizedShellErrorMessage(error, copy.actionFallback, uiLocale), - undefined, - { sessionId }, - ); - } finally { - pendingSessionRowActionsRef.current.delete(key); - } - } - - async function flagSession(sessionId: string, flagged: boolean) { - return runSessionRowAction(sessionId, 'flag', flagged ? copy.flagFailedTitle : copy.unflagFailedTitle, async () => { - await window.maka.sessions.setFlagged(sessionId, flagged, { revisionFamily: true }); - await refreshSessions(); - }); - } - - async function archiveSession(sessionId: string) { - return runSessionRowAction(sessionId, 'archive', copy.archiveFailedTitle, async () => { - const familyIds = revisionFamilySessionIds(sessionsRef.current, sessionId); - await window.maka.sessions.archive(sessionId, { revisionFamily: true }); - if (activeIdRef.current && familyIds.includes(activeIdRef.current)) { - setActiveId(undefined); - setMessages([]); - } - for (const id of familyIds) clearSessionRendererState(id); - await refreshSessions(); - }); - } - - async function unarchiveSession(sessionId: string) { - return runSessionRowAction(sessionId, 'archive', copy.unarchiveFailedTitle, async () => { - await window.maka.sessions.unarchive(sessionId, { revisionFamily: true }); - await refreshSessions(); - }); - } - - async function renameSession(sessionId: string, name: string) { - return runSessionRowAction(sessionId, 'rename', copy.renameFailedTitle, async () => { - await window.maka.sessions.rename(sessionId, name, { revisionFamily: true }); - await refreshSessions(); - }); - } - - async function deleteSession(sessionId: string) { - return runSessionRowAction(sessionId, 'delete', copy.deleteFailedTitle, async () => { - const session = sessionsRef.current.find((entry) => entry.id === sessionId); - const name = session?.name ?? copy.currentConversation; - const ok = await toastApi.confirm({ - title: copy.deleteTitle(name), - description: copy.deleteDescription, - confirmLabel: copy.deleteLabel, - cancelLabel: copy.cancelLabel, - destructive: true, - }); - if (!ok) return; - // The confirm named an archived task, so a restore revokes it. An active - // task has no such premise to lose. - const disposition = await removeSessionFamily(sessionId, { - requireArchived: session?.isArchived === true, - }); - await refreshSessions(); - if (disposition === 'restored') toastApi.success(copy.deleteRestoredTitle(name)); - else toastApi.success(copy.deletedTitle(name)); - }); - } - - /** - * Removes one task's whole revision family and drops what the renderer was - * holding for it. A resolved `remove` means the IPC both committed the - * deletion and released those resources, so the cleanup below is only ever - * reached for a task that is really gone — and `restored` means it was never - * deleted, so there is nothing to drop. - */ - async function removeSessionFamily( - sessionId: string, - options: { requireArchived: boolean }, - ): Promise { - // Read before the write: the family comes off the live catalog, which no - // longer lists it afterwards. - const familyIds = revisionFamilySessionIds(sessionsRef.current, sessionId); - const disposition = await window.maka.sessions.remove(sessionId, { - revisionFamily: true, - requireArchived: options.requireArchived, - }); - if (disposition === 'restored') return disposition; - if (activeIdRef.current && familyIds.includes(activeIdRef.current)) { - setActiveId(undefined); - setMessages([]); - } - for (const id of familyIds) clearSessionRendererState(id); - return disposition; - } - - /** - * Deletes a set of archived tasks in one sweep. - * - * Every id takes one path and lands in exactly one outcome. A task still - * archived is removed; one restored meanwhile answers `restored` and is kept; - * one already gone elsewhere rejects and settles as removed against the - * catalog; anything else is an error to explain. The archived premise is - * asserted where it can be held — inside the Host's compare-and-set (#3050) — - * rather than against a renderer snapshot that a second window can outdate - * between the check and the write. - * - * Ids with a row action already in flight are skipped for the same reason - * single-row actions skip each other. - * - * A rejection is not evidence the task survived — the delete IPC commits the - * removal before it releases renderer resources — so the rejected ids, and - * only those, are checked back against the catalog. `refreshSessions` cannot - * answer that: it swallows a listing failure and returns the pre-delete list, - * which would read as "none of them went". When the catalog cannot be read at - * all, `verified` is false and the caller claims nothing. - * - * No confirm and no toast: the caller owns the wording for a sweep, which is - * the one thing single-row delete cannot phrase. - */ - async function purgeSessions(sessionIds: readonly string[]): Promise { - const unsettled: string[] = []; - const restored: string[] = []; - let firstFailure: SessionPurgeOutcome['firstFailure']; - let removed = 0; - for (const sessionId of sessionIds) { - const key = `${sessionId}:delete`; - if ( - Array.from(pendingSessionRowActionsRef.current).some((pending) => - pending.startsWith(`${sessionId}:`), - ) - ) { - unsettled.push(sessionId); - continue; - } - pendingSessionRowActionsRef.current.add(key); - try { - const disposition = await removeSessionFamily(sessionId, { requireArchived: true }); - if (disposition === 'restored') restored.push(sessionId); - else removed += 1; - } catch (error) { - unsettled.push(sessionId); - firstFailure ??= { error, sessionId }; - } finally { - pendingSessionRowActionsRef.current.delete(key); - } - } - if (unsettled.length === 0) { - await refreshSessions(); - return { - removed, - remaining: [], - restored, - verified: true, - firstFailure, - }; - } - let listed: SessionSummary[] | undefined; - try { - listed = await window.maka.sessions.list(); - } catch { - listed = undefined; - } - await refreshSessions(); - if (!listed) { - return { - removed, - remaining: [], - restored, - verified: false, - firstFailure, - }; - } - const present = new Set(listed.map((session) => session.id)); - const remaining = unsettled.filter((sessionId) => present.has(sessionId)); - return { - removed: removed + (unsettled.length - remaining.length), - remaining, - restored, - verified: true, - firstFailure, - }; - } - - return { - flagSession, - archiveSession, - unarchiveSession, - renameSession, - deleteSession, - purgeSessions, - }; -} diff --git a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts index 3140d55a26..2041b4abc0 100644 --- a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts @@ -18,7 +18,7 @@ */ import type { ChatDefaultPermissionMode } from '@maka/core/settings'; -import type { LlmConnection } from '@maka/core/llm-connections'; +import type { ProjectedLlmConnection } from '@maka/core/llm-connections'; import type { PermissionMode } from '@maka/core/permission'; import { latestAssistantModelId, @@ -28,9 +28,9 @@ import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { UiLocale } from '@maka/core/ui-locale'; import type { DesktopSessionSummary } from '../preload/bridge-contract.js'; import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; +import type { SessionPendingClaim } from './app-shell-session-ui-state.js'; type RefBox = { current: T }; -type BooleanRecordUpdater = (updater: (current: Record) => Record) => void; type ToastApi = { success(title: string, description?: string): void; @@ -51,26 +51,28 @@ type ToastApi = { export interface AppShellSessionSettingsActions { setPermissionMode(mode: PermissionMode): Promise; - setSessionModel(input: { llmConnectionSlug: string; model: string }): Promise; + setSessionModel(input: { + llmConnectionId: string; + llmConnectionSlug: string; + model: string; + }): Promise; setSessionThinkingLevel(level: ThinkingLevel | undefined): Promise; } export function createAppShellSessionSettingsActions(deps: { uiLocale: UiLocale; activeIdRef: RefBox; - connections: readonly LlmConnection[]; + connections: readonly ProjectedLlmConnection[]; messages: readonly StoredMessage[]; - pendingPermissionModeChangesRef: RefBox>; - pendingSessionModelChangesRef: RefBox>; + permissionModePending: SessionPendingClaim; + sessionModelPending: SessionPendingClaim; refreshSessions: () => Promise; saveComposerDefaults: (patch: { - model: { llmConnectionSlug: string; model: string }; + model: { llmConnectionId: string; llmConnectionSlug: string; model: string }; }) => void; sessionsRef: RefBox; /** Persists the chat default; awaited so a failure surfaces as one. */ setNewTaskPermissionMode: (mode: ChatDefaultPermissionMode) => void | Promise; - setPendingPermissionModeBySession: BooleanRecordUpdater; - setPendingSessionModelBySession: BooleanRecordUpdater; toastApi: ToastApi; }): AppShellSessionSettingsActions { const { @@ -78,29 +80,21 @@ export function createAppShellSessionSettingsActions(deps: { activeIdRef, connections, messages, - pendingPermissionModeChangesRef, - pendingSessionModelChangesRef, + permissionModePending, + sessionModelPending, refreshSessions, saveComposerDefaults, sessionsRef, setNewTaskPermissionMode, - setPendingPermissionModeBySession, - setPendingSessionModelBySession, toastApi, } = deps; const copy = getShellCopy(uiLocale).sessionSettingsActions; - function omitSessionKey(current: Record, sessionId: string): Record { - if (!(sessionId in current)) return current; - const next = { ...current }; - delete next[sessionId]; - return next; - } - + // The Host's entries, not the stored rows: a provider with no model-list + // endpoint stores bare ids, and the pickers beside this show resolved names. function modelLabel(connectionSlug: string, model: string): string { const connection = connections.find((entry) => entry.slug === connectionSlug); - const displayName = connection?.models?.find((entry) => entry.id === model)?.displayName?.trim(); - return displayName || model; + return connection?.catalogEntries.find((entry) => entry.id === model)?.displayName?.trim() || model; } function modelEndpointLabel(connectionSlug: string, model: string, includeConnection: boolean): string { @@ -117,8 +111,14 @@ export function createAppShellSessionSettingsActions(deps: { ? sessionsRef.current.find((session) => session.id === sessionId)?.permissionMode : undefined; if (currentMode === mode) return true; + // No session means the chat default, which has no row to mark pending. It + // still needs a key of its own so two rapid switches cannot both run. const pendingKey = sessionId ?? '__global_permission_mode__'; - if (pendingPermissionModeChangesRef.current.has(pendingKey)) return false; + // Claimed before the confirm rather than after it: the dialog is part of + // the change, so a second click while it is open must not open a second + // one. The cost is that the control reads as pending while the user + // decides, which is what is actually true. + if (!permissionModePending.claim(pendingKey)) return false; if ( mode === 'bypass' && !(await toastApi.confirm({ @@ -129,15 +129,10 @@ export function createAppShellSessionSettingsActions(deps: { destructive: true, })) ) { + permissionModePending.release(pendingKey); return false; } - pendingPermissionModeChangesRef.current.add(pendingKey); - if (sessionId) - setPendingPermissionModeBySession((current) => ({ - ...current, - [sessionId]: true, - })); try { let nextMode = mode; if (sessionId) { @@ -161,22 +156,20 @@ export function createAppShellSessionSettingsActions(deps: { ); return false; } finally { - pendingPermissionModeChangesRef.current.delete(pendingKey); - if (sessionId) setPendingPermissionModeBySession((current) => omitSessionKey(current, sessionId)); + permissionModePending.release(pendingKey); } } - async function setSessionModel(input: { llmConnectionSlug: string; model: string }) { + async function setSessionModel(input: { + llmConnectionId: string; + llmConnectionSlug: string; + model: string; + }) { const sessionId = activeIdRef.current; if (!sessionId) return; const previous = sessionsRef.current.find((session) => session.id === sessionId); const lastUsedModel = latestAssistantModelId(messages); - if (pendingSessionModelChangesRef.current.has(sessionId)) return; - pendingSessionModelChangesRef.current.add(sessionId); - setPendingSessionModelBySession((current) => ({ - ...current, - [sessionId]: true, - })); + if (!sessionModelPending.claim(sessionId)) return; try { const next = await window.maka.sessions.setModel(sessionId, input); if (activeIdRef.current === sessionId) { @@ -201,16 +194,16 @@ export function createAppShellSessionSettingsActions(deps: { await refreshSessions(); } catch (error) { if (activeIdRef.current === sessionId) { + const detail = localizedShellErrorMessage(error, copy.modelFallback, uiLocale); toastApi.error( copy.modelFailedTitle, - localizedShellErrorMessage(error, copy.modelFallback, uiLocale), + `${detail} ${copy.modelRecoveryHint}`, undefined, { sessionId }, ); } } finally { - pendingSessionModelChangesRef.current.delete(sessionId); - setPendingSessionModelBySession((current) => omitSessionKey(current, sessionId)); + sessionModelPending.release(sessionId); } } @@ -219,12 +212,7 @@ export function createAppShellSessionSettingsActions(deps: { if (!sessionId) return; const current = sessionsRef.current.find((session) => session.id === sessionId); if (current && current.thinkingLevel === level) return; - if (pendingSessionModelChangesRef.current.has(sessionId)) return; - pendingSessionModelChangesRef.current.add(sessionId); - setPendingSessionModelBySession((currentPending) => ({ - ...currentPending, - [sessionId]: true, - })); + if (!sessionModelPending.claim(sessionId)) return; try { await window.maka.sessions.setThinkingLevel(sessionId, level); if (activeIdRef.current === sessionId) { @@ -241,8 +229,7 @@ export function createAppShellSessionSettingsActions(deps: { ); } } finally { - pendingSessionModelChangesRef.current.delete(sessionId); - setPendingSessionModelBySession((currentPending) => omitSessionKey(currentPending, sessionId)); + sessionModelPending.release(sessionId); } } diff --git a/apps/desktop/src/renderer/app-shell-session-start-actions.ts b/apps/desktop/src/renderer/app-shell-session-start-actions.ts index c87538a9cd..48309d896f 100644 --- a/apps/desktop/src/renderer/app-shell-session-start-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-start-actions.ts @@ -17,10 +17,10 @@ * under the License. */ -import type { SessionStartMode } from '@maka/core/explore-agent'; import type { UiLocale } from '@maka/core/ui-locale'; import type { NavSelection } from '@maka/ui'; import type { DesktopNewTaskTarget } from '../preload/bridge-contract.js'; +import type { SessionStartMode } from './application/contracts/session-start-mode.js'; import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; import { isNoRealConnectionError, diff --git a/apps/desktop/src/renderer/app-shell-session-ui-state.ts b/apps/desktop/src/renderer/app-shell-session-ui-state.ts index bd3ba23947..aa283d9f96 100644 --- a/apps/desktop/src/renderer/app-shell-session-ui-state.ts +++ b/apps/desktop/src/renderer/app-shell-session-ui-state.ts @@ -21,6 +21,7 @@ import { useRef } from 'react'; import type { MessageQueueEntryProjection } from '@maka/core/events'; import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; import { confirmLiveTurn, type InteractionQueues, type LiveTurnProjection } from '@maka/ui'; +import { createObservableState } from './observable-state.js'; import type { ShellRunUpdatesBySession } from './shell-run-update-state.js'; type StateUpdater = (updater: (current: T) => T) => void; @@ -46,6 +47,20 @@ export interface MessageQueueUiState { type AppShellSessionUiStateMapKey = keyof AppShellSessionUiState; +/** The maps that record nothing but "an action is in flight for this key". */ +type BooleanMapKey = + | 'messageRetryPendingBySession' + | 'stopPendingBySession' + | 'pendingPermissionModeBySession' + | 'pendingSessionModelBySession'; + +export interface SessionPendingClaim { + /** Marks `key` in flight. Returns false — a no-op — if it already was. */ + claim(key: string): boolean; + /** Gives the claim back. Safe to call for a key that never held one. */ + release(key: string): void; +} + const SESSION_UI_MAP_KEYS = [ 'messageLoadErrorBySession', 'messageRetryPendingBySession', @@ -129,38 +144,27 @@ export function clearAppShellTurnTransientForSession( export function createAppShellSessionUiStateController( initialState: AppShellSessionUiState = createInitialAppShellSessionUiState(), ) { - let currentState = initialState; - const liveTurnBySessionRef = { current: currentState.liveTurnBySession }; + const state = createObservableState(initialState); + const liveTurnBySessionRef = { current: initialState.liveTurnBySession }; // Written by the event-health probes and read back by them alone. Kept off - // `currentState` so a probe never notifies a subscriber. + // the observed state so a probe never notifies a subscriber. const sessionEventHealthBySessionRef: { current: Record } = { current: {}, }; - const listeners = new Set<() => void>(); + // The ref mirrors whatever is about to become current, so it is already + // correct when the synchronous notification reaches a listener that reads it. function replaceState(next: AppShellSessionUiState): void { - if (next === currentState) return; - currentState = next; liveTurnBySessionRef.current = next.liveTurnBySession; - // Synchronous, immediately after the swap. Never schedule this: the - // terminal-turn handoff reads the state it announces (#1985). - for (const listener of [...listeners]) listener(); - } - - /** Subscribe to state replacements. Stable identity, for `useSyncExternalStore`. */ - function subscribe(listener: () => void): () => void { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; + state.replaceState(next); } function updateMap( key: K, updater: (current: AppShellSessionUiState[K]) => AppShellSessionUiState[K], ): void { - const nextMap = updater(currentState[key]); - const latestState = currentState; + const latestState = state.getState(); + const nextMap = updater(latestState[key]); if (nextMap === latestState[key]) return; replaceState({ ...latestState, [key]: nextMap }); } @@ -169,14 +173,41 @@ export function createAppShellSessionUiStateController( return (updater) => updateMap(key, updater); } + /** + * The in-flight claim on one of the pending maps: `claim` marks a key and + * reports whether it won, `release` gives it back. + * + * Both read and write the same map, which is what makes this the only + * representation of "an action is in flight". Each of these four used to be a + * `Set` ref for the duplicate guard beside a map for the rendered flag, + * synchronized by hand at every add and every `finally`, and cleared by two + * separate teardown paths that stayed aligned only by ordering. Nothing + * needed the ref: state replacement is synchronous, so a claim is visible to + * the next `getState()` in the same task. + */ + function createPendingClaim(key: BooleanMapKey): SessionPendingClaim { + return { + claim(claimKey: string): boolean { + if (state.getState()[key][claimKey] === true) return false; + updateMap(key, (current) => ({ ...current, [claimKey]: true })); + return true; + }, + release(claimKey: string): void { + updateMap(key, (current) => omitSessionKey(current, claimKey)); + }, + }; + } + return { - getState: () => currentState, - subscribe, + getState: state.getState, + subscribe: state.subscribe, liveTurnBySessionRef, sessionEventHealthBySessionRef, setMessageLoadErrorBySession: createMapSetter('messageLoadErrorBySession'), - setMessageRetryPendingBySession: createMapSetter('messageRetryPendingBySession'), - setStopPendingBySession: createMapSetter('stopPendingBySession'), + messageRetryPending: createPendingClaim('messageRetryPendingBySession'), + stopPending: createPendingClaim('stopPendingBySession'), + permissionModePending: createPendingClaim('pendingPermissionModeBySession'), + sessionModelPending: createPendingClaim('pendingSessionModelBySession'), setLiveTurnBySession: createMapSetter('liveTurnBySession'), setShellRunUpdatesBySession: createMapSetter('shellRunUpdatesBySession'), setInteractionBySession: createMapSetter('interactionBySession'), @@ -184,8 +215,6 @@ export function createAppShellSessionUiStateController( setSessionEventHealthBySession: ((updater) => { sessionEventHealthBySessionRef.current = updater(sessionEventHealthBySessionRef.current); }) satisfies StateUpdater>, - setPendingPermissionModeBySession: createMapSetter('pendingPermissionModeBySession'), - setPendingSessionModelBySession: createMapSetter('pendingSessionModelBySession'), /** * The authority said something about `turnId` — it started, failed to * start, or ended. Drop that arm's `unconfirmed` claim so a session list @@ -205,14 +234,15 @@ export function createAppShellSessionUiStateController( sessionEventHealthBySessionRef.current, sessionId, ); - replaceState(clearAppShellSessionUiStateForSession(currentState, sessionId)); + replaceState(clearAppShellSessionUiStateForSession(state.getState(), sessionId)); }, clearTurnTransientStateIfCurrent: ( sessionId: string, expected: LiveTurnProjection | undefined, ) => { - if (currentState.liveTurnBySession[sessionId] !== expected) return; - replaceState(clearAppShellTurnTransientForSession(currentState, sessionId)); + const current = state.getState(); + if (current.liveTurnBySession[sessionId] !== expected) return; + replaceState(clearAppShellTurnTransientForSession(current, sessionId)); }, }; } @@ -222,34 +252,16 @@ export type AppShellSessionUiStateController = ReturnType(null); - - if (!controllerRef.current) { - controllerRef.current = createAppShellSessionUiStateController(); - } - - const controller = controllerRef.current; - - return { - controller, - liveTurnBySessionRef: controller.liveTurnBySessionRef, - sessionEventHealthBySessionRef: controller.sessionEventHealthBySessionRef, - setMessageLoadErrorBySession: controller.setMessageLoadErrorBySession, - setMessageRetryPendingBySession: controller.setMessageRetryPendingBySession, - setStopPendingBySession: controller.setStopPendingBySession, - setLiveTurnBySession: controller.setLiveTurnBySession, - setShellRunUpdatesBySession: controller.setShellRunUpdatesBySession, - setInteractionBySession: controller.setInteractionBySession, - setMessageQueueBySession: controller.setMessageQueueBySession, - setSessionEventHealthBySession: controller.setSessionEventHealthBySession, - setPendingPermissionModeBySession: controller.setPendingPermissionModeBySession, - setPendingSessionModelBySession: controller.setPendingSessionModelBySession, - confirmLiveTurn: controller.confirmLiveTurn, - clearSessionUiState: controller.clearSessionUiState, - clearTurnTransientStateIfCurrent: controller.clearTurnTransientStateIfCurrent, - }; + controllerRef.current ??= createAppShellSessionUiStateController(); + return controllerRef.current; } diff --git a/apps/desktop/src/renderer/app-shell-skill-actions.ts b/apps/desktop/src/renderer/app-shell-skill-actions.ts deleted file mode 100644 index 22be8179d5..0000000000 --- a/apps/desktop/src/renderer/app-shell-skill-actions.ts +++ /dev/null @@ -1,377 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { Dispatch, SetStateAction } from 'react'; -import type { UiLocale } from '@maka/core/ui-locale'; -import type { - BundledSkillCatalogEntry, - ManagedSkillSourceEntry, - ManagedSkillUpdatePreview, - SkillEntry, -} from '@maka/ui'; -import { openSkillFailureCopy } from './app-shell-copy'; -import { createOpenSkillAction } from './app-shell-open-skill-action'; -import { - defaultRuntimeHostDiagnosticTarget, - runIfDefaultRuntimeHostCurrent, - runOnDefaultRuntimeHost, -} from './default-runtime-host-operation.js'; -import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; - -type ToastApi = { - success(title: string, description?: string): void; - error( - title: string, - description?: string, - diagnosticDetails?: string, - diagnosticTarget?: { profileId: string }, - ): void; -}; - -type RefBox = { current: T }; - -export interface AppShellSkillActions { - refreshSkills(options?: { shouldShowError?: () => boolean }): Promise; - refreshManagedSkillSources(options?: { shouldShowError?: () => boolean }): Promise; - refreshBundledSkillCatalog(options?: { shouldShowError?: () => boolean }): Promise; - importManagedSkillSource(): Promise; - installManagedSkill(sourceId: string): Promise; - installBundledSkill(id: string): Promise; - previewManagedSkillUpdate(skillId: string): Promise; - updateManagedSkill( - skillId: string, - options?: { - force?: boolean; - expectedCurrentSha256?: string; - expectedSourceSha256?: string; - }, - ): Promise; - setSkillEnabled(skillId: string, enabled: boolean): Promise; - setSkillPinned(skillRef: string, pinned: boolean): Promise; - deleteSkill(skillRef: string): Promise; - openSkill(skillId: string): Promise; -} - -export function createAppShellSkillActions(deps: { - uiLocale: UiLocale; - isSkillsSurfaceActive: () => boolean; - refreshGenerationsRef: RefBox<{ - skills: number; - managedSkillSources: number; - bundledSkillCatalog: number; - }>; - setSkills: Dispatch>; - setManagedSkillSources: Dispatch>; - setBundledSkillCatalog: Dispatch>; - toastApi: ToastApi; -}): AppShellSkillActions { - const { - uiLocale, - isSkillsSurfaceActive, - refreshGenerationsRef, - setBundledSkillCatalog, - setManagedSkillSources, - setSkills, - toastApi: baseToastApi, - } = deps; - const toastApi = { - success: (title: string, description?: string) => - baseToastApi.success(title, description), - error: (title: string, description?: string, diagnosticTarget?: { profileId: string }) => - baseToastApi.error(title, description, undefined, diagnosticTarget), - }; - const reportRuntimeHostError = (title: string, fallback: string, error: unknown) => - toastApi.error( - title, - localizedShellErrorMessage(error, fallback, uiLocale), - defaultRuntimeHostDiagnosticTarget(error), - ); - const copy = getShellCopy(uiLocale).skillActions; - const openSkill = createOpenSkillAction({ - uiLocale, - isSkillsSurfaceActive, - toastApi, - }); - - async function refreshSkills(options: { shouldShowError?: () => boolean } = {}) { - const generation = ++refreshGenerationsRef.current.skills; - try { - const next = await runOnDefaultRuntimeHost((host) => window.maka.skills.list(host)); - await runIfDefaultRuntimeHostCurrent(next.host, () => { - if (generation === refreshGenerationsRef.current.skills) setSkills(next.value); - }); - } catch (error) { - if (generation !== refreshGenerationsRef.current.skills) return; - if (options.shouldShowError?.() ?? true) { - reportRuntimeHostError(copy.refreshSkillsFailedTitle, copy.refreshSkillsFallback, error); - } - } - } - - async function refreshManagedSkillSources(options: { shouldShowError?: () => boolean } = {}) { - const generation = ++refreshGenerationsRef.current.managedSkillSources; - try { - const next = await runOnDefaultRuntimeHost((host) => window.maka.skills.sources.list(host)); - await runIfDefaultRuntimeHostCurrent(next.host, () => { - if (generation === refreshGenerationsRef.current.managedSkillSources) { - setManagedSkillSources(next.value); - } - }); - } catch (error) { - if (generation !== refreshGenerationsRef.current.managedSkillSources) return; - if (options.shouldShowError?.() ?? true) { - reportRuntimeHostError(copy.refreshSourcesFailedTitle, copy.refreshSourcesFallback, error); - } - } - } - - async function refreshBundledSkillCatalog(options: { shouldShowError?: () => boolean } = {}) { - const generation = ++refreshGenerationsRef.current.bundledSkillCatalog; - try { - const next = await runOnDefaultRuntimeHost((host) => window.maka.skills.catalog.list(host)); - await runIfDefaultRuntimeHostCurrent(next.host, () => { - if (generation === refreshGenerationsRef.current.bundledSkillCatalog) { - setBundledSkillCatalog(next.value); - } - }); - } catch (error) { - if (generation !== refreshGenerationsRef.current.bundledSkillCatalog) return; - if (options.shouldShowError?.() ?? true) { - reportRuntimeHostError(copy.refreshBundledFailedTitle, copy.refreshBundledFallback, error); - } - } - } - - async function installBundledSkill(id: string) { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.skills.catalog.install(id, host), - ); - if (!result.ok) { - if (isSkillsSurfaceActive()) - toastApi.error( - copy.installBundledFailedTitle, - copy.installFailures[result.reason], - diagnosticTarget, - ); - return; - } - await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); - await refreshBundledSkillCatalog({ - shouldShowError: isSkillsSurfaceActive, - }); - if (isSkillsSurfaceActive()) - toastApi.success(copy.installedBundledTitle, copy.installedDescription(result.skill.id)); - } catch (error) { - if (isSkillsSurfaceActive()) { - reportRuntimeHostError(copy.installBundledFailedTitle, copy.installBundledFallback, error); - } - } - } - - async function importManagedSkillSource() { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.skills.sources.importLocalFile(host), - ); - if (!result.ok) { - if (result.reason !== 'cancelled' && isSkillsSurfaceActive()) { - toastApi.error( - copy.importSourceFailedTitle, - copy.sourceFailures[result.reason], - diagnosticTarget, - ); - } - return; - } - await refreshManagedSkillSources({ - shouldShowError: isSkillsSurfaceActive, - }); - if (isSkillsSurfaceActive()) toastApi.success(copy.importedSourceTitle, result.source.name); - } catch (error) { - if (isSkillsSurfaceActive()) { - reportRuntimeHostError(copy.importSourceFailedTitle, copy.importSourceFallback, error); - } - } - } - - async function installManagedSkill(sourceId: string) { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.skills.installManaged(sourceId, host), - ); - if (!result.ok) { - if (isSkillsSurfaceActive()) { - toastApi.error(copy.installFailedTitle, copy.installFailures[result.reason], diagnosticTarget); - } - return; - } - await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); - await refreshManagedSkillSources({ - shouldShowError: isSkillsSurfaceActive, - }); - if (isSkillsSurfaceActive()) toastApi.success(copy.installedTitle, copy.installedDescription(result.skill.id)); - } catch (error) { - if (isSkillsSurfaceActive()) { - reportRuntimeHostError(copy.installFailedTitle, copy.installFallback, error); - } - } - } - - async function previewManagedSkillUpdate(skillId: string): Promise { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.skills.previewUpdate(skillId, host), - ); - if (!result.ok) { - if (isSkillsSurfaceActive()) { - toastApi.error(copy.previewFailedTitle, copy.previewFailures[result.reason], diagnosticTarget); - } - return null; - } - return result.preview; - } catch (error) { - if (isSkillsSurfaceActive()) { - reportRuntimeHostError(copy.previewFailedTitle, copy.previewFallback, error); - } - return null; - } - } - - async function updateManagedSkill( - skillId: string, - options: { - force?: boolean; - expectedCurrentSha256?: string; - expectedSourceSha256?: string; - } = {}, - ): Promise { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.skills.updateManaged(skillId, options, host), - ); - if (!result.ok) { - if (isSkillsSurfaceActive()) { - toastApi.error(copy.updateFailedTitle, copy.updateFailures[result.reason], diagnosticTarget); - } - return false; - } - await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); - if (isSkillsSurfaceActive()) { - toastApi.success( - options.force ? copy.forceUpdatedTitle : copy.updatedTitle, - copy.updatedDescription(result.skill.id), - ); - } - return true; - } catch (error) { - if (isSkillsSurfaceActive()) { - reportRuntimeHostError(copy.updateFailedTitle, copy.updateFallback, error); - } - return false; - } - } - - async function setSkillEnabled(skillId: string, enabled: boolean) { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.skills.setEnabled(skillId, enabled, host), - ); - if (!result.ok) { - if (isSkillsSurfaceActive()) { - toastApi.error(copy.toggleFailedTitle, copy.runtimeFailures[result.reason], diagnosticTarget); - } - return; - } - await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); - if (isSkillsSurfaceActive()) { - toastApi.success(enabled ? copy.enabledTitle : copy.disabledTitle, copy.runtimeDescription(result.skill.name)); - } - } catch (error) { - if (isSkillsSurfaceActive()) { - reportRuntimeHostError(copy.toggleFailedTitle, copy.toggleFallback, error); - } - } - } - - async function setSkillPinned(skillRef: string, pinned: boolean) { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.skills.setPinned(skillRef, pinned, host), - ); - if (!result.ok) { - if (isSkillsSurfaceActive()) { - toastApi.error(copy.toggleFailedTitle, copy.runtimeFailures[result.reason], diagnosticTarget); - } - return; - } - await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); - if (isSkillsSurfaceActive()) { - toastApi.success(pinned ? copy.pinnedTitle : copy.unpinnedTitle, result.skill.name); - } - } catch (error) { - if (isSkillsSurfaceActive()) { - reportRuntimeHostError(copy.toggleFailedTitle, copy.toggleFallback, error); - } - } - } - - async function deleteSkill(skillRef: string) { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.skills.delete(skillRef, host), - ); - if (!result.ok) { - if (isSkillsSurfaceActive()) { - toastApi.error(copy.deleteFailedTitle, copy.deleteFailures[result.reason], diagnosticTarget); - } - return; - } - await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); - // A deleted bundled skill must reappear as installable under 内置, so - // refresh the catalog's installed flags after removal. - await refreshBundledSkillCatalog({ - shouldShowError: isSkillsSurfaceActive, - }); - // The ref is scope-qualified (`user:agents:gh-cli`); the toast shows the - // bare skill id, which is what the row itself is labelled with. - const displayId = skillRef.slice(skillRef.lastIndexOf(':') + 1); - if (isSkillsSurfaceActive()) toastApi.success(copy.deletedTitle, copy.deletedDescription(displayId)); - } catch (error) { - if (isSkillsSurfaceActive()) { - reportRuntimeHostError(copy.deleteFailedTitle, copy.deleteFallback, error); - } - } - } - - return { - refreshSkills, - refreshManagedSkillSources, - refreshBundledSkillCatalog, - importManagedSkillSource, - installManagedSkill, - installBundledSkill, - previewManagedSkillUpdate, - updateManagedSkill, - setSkillEnabled, - setSkillPinned, - deleteSkill, - openSkill, - }; -} diff --git a/apps/desktop/src/renderer/app-shell-stop-action.ts b/apps/desktop/src/renderer/app-shell-stop-action.ts index 81b8c34dc7..36525e8311 100644 --- a/apps/desktop/src/renderer/app-shell-stop-action.ts +++ b/apps/desktop/src/renderer/app-shell-stop-action.ts @@ -20,9 +20,9 @@ import type { UiLocale } from '@maka/core/ui-locale'; import { localizedShellErrorMessage } from './locales/shell-copy.js'; import { getDesktopConversationCopy } from './locales/conversation-copy.js'; +import type { SessionPendingClaim } from './app-shell-session-ui-state.js'; type RefBox = { current: T }; -type BooleanRecordUpdater = (updater: (current: Record) => Record) => void; type ToastApi = { error( @@ -36,35 +36,28 @@ type ToastApi = { export function createAppShellStopAction(deps: { uiLocale: UiLocale; activeIdRef: RefBox; - addPendingSessionAction: ( - sessionId: string, - pendingRef: RefBox>, - setPendingBySession: BooleanRecordUpdater, - ) => boolean; - clearPendingSessionAction: ( - sessionId: string, - pendingRef: RefBox>, - setPendingBySession: BooleanRecordUpdater, - ) => void; - setStopPendingBySession: BooleanRecordUpdater; - stopPendingRef: RefBox>; + stopPending: SessionPendingClaim; + removeTransientMessage: (sessionId: string, messageId: string) => void; toastApi: ToastApi; }): () => Promise { const { uiLocale, activeIdRef, - addPendingSessionAction, - clearPendingSessionAction, - setStopPendingBySession, - stopPendingRef, + stopPending, + removeTransientMessage, toastApi, } = deps; async function stop() { const sessionId = activeIdRef.current; - if (!sessionId || !addPendingSessionAction(sessionId, stopPendingRef, setStopPendingBySession)) return; + if (!sessionId || !stopPending.claim(sessionId)) return; try { - await window.maka.sessions.stop(sessionId, { source: 'stop_button' }); + const result = await window.maka.sessions.stop(sessionId, { source: 'stop_button' }); + if (result?.kind === 'interrupted') { + for (const messageId of result.retractedMessageIds) { + removeTransientMessage(sessionId, messageId); + } + } } catch (error) { // The Composer wires this through both the Stop button onClick // and the Escape key. Both invoke `onStop` without awaiting, so @@ -82,7 +75,7 @@ export function createAppShellStopAction(deps: { ); } } finally { - clearPendingSessionAction(sessionId, stopPendingRef, setStopPendingBySession); + stopPending.release(sessionId); } } diff --git a/apps/desktop/src/renderer/app-shell-turn-actions.ts b/apps/desktop/src/renderer/app-shell-turn-actions.ts index 1c594d0cc5..80ac9e0a11 100644 --- a/apps/desktop/src/renderer/app-shell-turn-actions.ts +++ b/apps/desktop/src/renderer/app-shell-turn-actions.ts @@ -28,9 +28,9 @@ import { showSessionWorkspaceUnavailableToast, } from './session-workspace-errors.js'; import { acquireSessionCopyAttempt } from './session-copy-attempt.js'; +import type { MessageListUpdater } from './session-workspace-actions.js'; type RefBox = { current: T }; -type MessageListUpdater = (next: StoredMessage[] | ((current: StoredMessage[]) => StoredMessage[])) => void; type ToastApi = { info(title: string, description?: string): void; diff --git a/apps/desktop/src/renderer/app-shell-turn-view-model.ts b/apps/desktop/src/renderer/app-shell-turn-view-model.ts index 725220e095..a8061ceaf5 100644 --- a/apps/desktop/src/renderer/app-shell-turn-view-model.ts +++ b/apps/desktop/src/renderer/app-shell-turn-view-model.ts @@ -30,7 +30,12 @@ import { type TurnPresentationDeriver, type TurnViewModel, } from '@maka/ui'; -import { deriveFailedTurnRecovery, describeTurnErrorClass } from './session-status-presentation.js'; +import { + type FailedTurnSeverity, + describeFailedTurnExecutionState, + describeTurnErrorClass, + deriveFailedTurnSeverity, +} from './session-status-presentation.js'; import { deriveTurnFooterActions } from './turn-footer-actions.js'; import { deriveTurnLineageBadges } from './derive-turn-lineage-badges.js'; import { latestInterruptedResumeTurnId } from './interrupted-resume.js'; @@ -52,7 +57,8 @@ interface TurnPresentationEntry { footerActions: ReadonlyArray; lineageBadges?: TurnLineageBadge[]; failedReasonLabel?: string; - failedRecoveryLabel?: string; + failedSeverity?: FailedTurnSeverity; + failedExecutionStateLabel?: string; } const PENDING_ACTION_IDS = ['regenerate', 'branch', 'copy'] as const; @@ -115,7 +121,8 @@ export function createAppShellTurnPresentationDerivation(): AppShellTurnPresenta const existsTurn = (id: string) => turnIds.has(id); const footerActionsByTurn: Record> = {}; const failedReasonLabels: Record = {}; - const failedRecoveryLabels: Record = {}; + const failedSeverities: Record = {}; + const failedExecutionStateLabels: Record = {}; const lineageBadgesByTurn: Record = {}; for (const turn of turns) { @@ -154,7 +161,10 @@ export function createAppShellTurnPresentationDerivation(): AppShellTurnPresenta footerActionsByTurn[turn.turnId] = entry.footerActions; if (entry.lineageBadges) lineageBadgesByTurn[turn.turnId] = entry.lineageBadges; if (entry.failedReasonLabel !== undefined) failedReasonLabels[turn.turnId] = entry.failedReasonLabel; - if (entry.failedRecoveryLabel !== undefined) failedRecoveryLabels[turn.turnId] = entry.failedRecoveryLabel; + if (entry.failedSeverity !== undefined) failedSeverities[turn.turnId] = entry.failedSeverity; + if (entry.failedExecutionStateLabel !== undefined) { + failedExecutionStateLabels[turn.turnId] = entry.failedExecutionStateLabel; + } } const resumeCandidateTurnId = latestInterruptedResumeTurnId(turns); @@ -165,7 +175,8 @@ export function createAppShellTurnPresentationDerivation(): AppShellTurnPresenta lastResult = { footerActionsByTurn, failedReasonLabels, - failedRecoveryLabels, + failedSeverities, + failedExecutionStateLabels, lineageBadgesByTurn, ...(resumeCandidateTurnId ? { resumeCandidateTurnId } : {}), }; @@ -208,12 +219,13 @@ function deriveTurnPresentationEntry(input: { if (turn.status === 'failed' && !isSandboxOnlyToolFailure(turn)) { entry.failedReasonLabel = describeTurnErrorClass(turn.errorClass, uiLocale); - entry.failedRecoveryLabel = deriveFailedTurnRecovery({ - errorClass: turn.errorClass, + entry.failedSeverity = deriveFailedTurnSeverity(turn.errorClass); + const executionState = describeFailedTurnExecutionState({ partialOutputRetained: turn.partialOutputRetained, toolActivityCount: turn.tools.length, erroredToolCount: turn.tools.filter((tool) => tool.status === 'errored').length, - }, uiLocale).label; + }, uiLocale); + if (executionState) entry.failedExecutionStateLabel = executionState; } const lineageBadges = deriveTurnLineageBadges({ diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 29a97fb25b..270e1d6b39 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -25,10 +25,10 @@ import { useRef, useState, type CSSProperties, + type ComponentProps, type Dispatch, type SetStateAction, } from 'react'; -import type { ScheduledTask } from '@maka/core/scheduled-task'; import type { ProjectRecord } from '@maka/core/project'; import type { FollowUpMode, @@ -46,8 +46,6 @@ import { resolveUiLocale } from '@maka/core/ui-locale'; import { slashCommandsForSurface } from '@maka/core/slash-command-catalog'; import { hasSettledInitialOnboarding } from '@maka/core/onboarding-milestone'; import { - ScheduledTasksPage, - DailyReviewPage, ChatSurfaceLayout, type ComposerHandle, type ComposerSendMetadata, @@ -56,24 +54,19 @@ import { MakaUriContext, AstryxLocaleProvider, LocaleProvider, - ModuleHubSelector, ToastProvider, type ToastDiagnosticTarget, type ToastErrorAction, type NavSelection, + type ProjectRowActions, SessionListPanel, - type SessionHistoryGroup, - SkillsPage, - type SessionViewMode, TitlebarSessionIdentity, type TurnFooterActionMeta, - type WorkspacePickerModel, useToast, activeInteractionFor, + deriveComposerModelSwitchAvailability, deriveTitlebarProjectName, enqueueInteraction, - getConversationCopy, - getSharedUiCopy, reconcileInteractions, } from '@maka/ui'; import type { ConnectionEvent } from '@maka/core/connections'; @@ -98,14 +91,32 @@ import { WorkbarTitlebarActions, useWorkbarController, } from './features/workbar'; -import { GoalHost, useGoalController } from './features/goals'; -import { UNRESOLVED_NEW_TASK_DRAFT_KEY } from './new-task-reload-intent'; +import * as Goals from './features/goals'; +import { ModuleHubHost, useModuleHubController } from './features/module-hub'; +import { + SessionNavigationProvider, + createSessionOpenCommand, + sessionRailLayoutStore, + useSessionNavigationReads, + type SessionNavigationPorts, + type SessionNavigationRowActions, +} from './features/session-navigation'; +import { + TaskEntryHost, + useTaskEntryController, + type TaskEntryError, +} from './features/task-entry'; import { useNewTaskChoice } from './use-new-task-choice'; +import { SessionCollaborationDialog } from './session-collaboration-dialog'; +import { SessionTurnRequestComposer } from './session-turn-request-composer.js'; +import * as SessionCollaboration from './features/session-collaboration'; +import { getSessionCollaborationCopy } from './locales/session-collaboration-copy'; import { NEW_TASK_PENDING_KEY } from './pending-items'; import { parseDesktopSlashCommand } from './desktop-slash-command'; import { hasActiveTurnAtSubmit, mergeWorkspaceReferences, + rebaseWorkspaceFileReferences, resolveFollowUpModeAtSubmit, } from './follow-up-submit-routing'; import { @@ -113,7 +124,6 @@ import { PlanProposalCard, usePlanModeState, } from './plan-mode-panel'; -import { McpPage } from './mcp-page'; import { getOnboardingActivationCandidate, useOnboardingSnapshot } from './use-onboarding-snapshot'; import type { AppUpdateStatus, @@ -129,9 +139,13 @@ import { ProviderLogo } from './settings/provider-display'; import { ProviderBrandMark } from './settings/provider-brand-marks'; import { RuntimeHostSshTerminalDialog } from './settings/runtime-host-ssh-terminal-dialog.js'; import { createWorkHubController } from './workhub-controller.js'; +import { startWorkHubCoordinationLifecycle } from './workhub-coordination-lifecycle.js'; +import { scopeWorkHubSessionsToCoordinationHost } from './workhub-coordination-host-scope.js'; import { createDesktopWorkHubSessionPort } from './workhub-session-port.js'; -import { WorkHubSurface } from './workhub-surface.js'; +import { createDesktopWorkHubCoordinationPort } from './workhub-coordination-port.js'; +import { WorkHubCoordinationStatus, WorkHubSurface } from './workhub-surface.js'; import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy'; +import { getShellRemainingCopy } from './locales/shell-remaining-copy.js'; import { getDesktopConversationCopy } from './locales/conversation-copy'; import { ErrorBoundary } from './error-boundary'; import { useShellAppearance } from './use-shell-appearance'; @@ -139,21 +153,11 @@ import { useShellSearch } from './use-shell-search'; import { useSessionSettingIntent } from './use-session-setting-intent'; import { deriveStaleSessionIds } from './stale-sessions'; import { pendingSessionView } from './pending-session-view'; -import { deriveProjectGroups, deriveWorktreeSessionIds } from './session-project-grouping'; -import { deriveSessionRail } from './session-rail'; import { useAppShellTurnPresentation } from './app-shell-turn-view-model'; import { readScrollMotionBehavior } from './scroll-motion-policy'; -import { deriveBranchBanner } from './branch-banner'; import { readNavigationState, selectNavigation } from './nav-selection'; -import { sessionMatchesRail } from './session-nav-filter'; -import { deriveSessionRevisionNavigation } from './session-revisions'; import { deriveDesktopExecutionBoundarySurface } from './desktop-execution-boundary-surface'; import { useActiveExecutionBoundary } from './use-active-execution-boundary'; -import { - SESSION_LIST_EXPANDED_MAX_WIDTH, - SESSION_LIST_EXPANDED_MIN_WIDTH, - readSessionListViewMode, -} from './session-list-layout'; import { modelSetupToastCopy } from './model-connection-errors'; import type { AppShellCommandListOptions } from './app-shell-command-actions'; import { @@ -162,33 +166,22 @@ import { } from './app-shell-context-compaction'; import { AppShellTopbarActions } from './app-shell-chrome-actions'; import { updateReminderFromStatus } from './app-shell-app-update'; -import { useBuildStamp } from './app-shell-build-stamp'; import { AppShellDetailPanel } from './app-shell-detail-panel'; import { AppShellOverlays } from './app-shell-overlays'; import type { ArchivedTasksBridge } from './settings/tasks-settings-page'; import { CustomPetCompanion } from './custom-pet-companion'; import { derivePetActivityState } from './custom-pet-companion-model'; -import { createAppShellDailyReviewBridge } from './app-shell-daily-review-bridge'; import { defaultRuntimeHostDiagnosticTarget, runOnDefaultRuntimeHost, } from './default-runtime-host-operation.js'; -import { useAppShellModuleData } from './use-module-data'; -import { useKeepSystemAwake } from './use-keep-system-awake'; import { useAppShellProjectContext } from './use-project-context'; import { createAppShellSessionDisplayBatch, createAppShellSessionEventHandlers, } from './app-shell-session-events'; import { createAppShellE2eFixtureActions } from './app-shell-e2e-fixture'; -import { - createAppShellChatActions, - type WorkspaceFileReferencePosition, -} from './app-shell-chat-actions'; -import { - retainedAttachmentRefs, - toComposerIngestItems, -} from './composer-attachments'; +import { createAppShellChatActions } from './app-shell-chat-actions'; import { createAppShellTurnActions } from './app-shell-turn-actions'; import { abandonTurnRevisionCopyAttempt, @@ -197,10 +190,9 @@ import { type TurnRevisionDraft, } from './app-shell-revision-actions'; import { createAppShellSessionStartActions } from './app-shell-session-start-actions'; -import { createAppShellDailyReviewActions } from './app-shell-daily-review-actions'; -import { createAppShellSessionRowActions } from './app-shell-session-row-actions'; import { createAppShellSessionSettingsActions } from './app-shell-session-settings-actions'; import { createAppShellStopAction } from './app-shell-stop-action'; +import { useExternalStoreSelector } from './use-external-store-selector'; import { useStableActions } from './use-stable-actions'; import { useActiveSessionEvents, @@ -219,44 +211,24 @@ import { type LiveContentSeed, } from './live-content-seed'; import { loadComposerDefaults, saveComposerDefaults } from './composer-defaults'; -import { useKeyedPendingRegistry } from './use-pending-action-registry'; +import { useTurnActionRegistry } from './use-turn-action-registry'; import { useComposerAttachments } from './use-composer-attachments'; import { useAppShellComposerQuotes } from './use-app-shell-composer-quotes'; -import { useComposerMentions } from './use-composer-mentions'; +import { ComposerMentionsProvider, type ComposerMentionsSurface } from './composer-mentions'; import { useAppShellSessionWorkspace } from './use-app-shell-session-workspace'; import { useShellMemoryPill } from './use-shell-memory-pill'; import { useShellConnections } from './use-shell-connections'; -import { useNewTaskTarget } from './use-new-task-target'; import { useShellChatModel } from './use-shell-chat-model'; import { useShellLiveTurn } from './use-shell-live-turn'; -import { useShellLayout } from './use-shell-layout'; import { useShellResume } from './use-shell-resume'; -function rebaseWorkspaceFileReferences( - sourceText: string, - projectedText: string, - references: readonly WorkspaceFileReferencePosition[], -): WorkspaceFileReferencePosition[] { - const offset = sourceText.lastIndexOf(projectedText); - if (offset < 0) return []; - return references - .filter( - (reference) => - reference.start >= offset && - reference.start + reference.value.length <= offset + projectedText.length, - ) - .map((reference) => ({ ...reference, start: reference.start - offset })); -} - import { useSettingsModal } from './use-settings-modal'; -import { RemoteProjectDirectoryDialog } from './remote-project-directory-dialog'; import { useSystemUiLocale } from './use-system-ui-locale'; import { isSessionWorkspaceUnavailableError, showSessionWorkspaceUnavailableToast, } from './session-workspace-errors'; import { AppShell as AstryxAppShell } from '@astryxdesign/core/AppShell'; -import type { SideNavImperativeCollapseHandle } from '@astryxdesign/core/SideNav'; type ComposerImportOwner = { sessionId: string | undefined; @@ -264,21 +236,19 @@ type ComposerImportOwner = { newTaskDraftKey?: string; }; -function newTaskDraftKey(target: { - profileId: string; - hostId: string; - projectId: string | null; -} | undefined): string { - return target - ? JSON.stringify(['new-task', target.profileId, target.hostId, target.projectId]) - : UNRESOLVED_NEW_TASK_DRAFT_KEY; -} - /** * Grace period before the committed-history fallback force-settles an * assistant stream slot when the primary post-commit signal is missed. */ const SETTLE_FALLBACK_GRACE_MS = 1000; +const FIRST_SEND_OBSERVATION_TIMEOUT_MS = 30_000; +const { useSessionCollaborationDialog } = SessionCollaboration; +type FirstSendObservationWaiter = { + promise: Promise; + resolve: () => void; + reject: (error: Error) => void; + timeoutId: ReturnType; +}; /** * Module surfaces that own their whole column and render no workspace toolbar. * This used to be a `display: none` rule keyed on the detail panel's @@ -336,6 +306,17 @@ export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = { ); } +/** + * The Session rail, as one element built once. + * + * AppShell re-renders about fourteen times per session switch. Written inline + * in the JSX below, each of those rebuilt this element and re-rendered the + * rail's ~1,000 fibers with it; hoisted here, React sees the same element and + * skips the subtree, and what reaches the rail is the two rail contexts alone. + * The panel takes no props for exactly this reason (#4109). + */ +const SESSION_RAIL = ; + function AppShellContent({ initialOnboardingSnapshot = null, uiLocale, @@ -351,8 +332,10 @@ function AppShellContent({ }) { const toastApi = useToast(); const [appUpdateStatus, setAppUpdateStatus] = useState(null); + const sharedSessionDialog = useSessionCollaborationDialog(); const updateInstallInFlightRef = useRef(false); const notifiedInstallErrorRef = useRef(null); + const previousInterruptionShownRef = useRef(false); const { sessions, catalogRevision, @@ -367,51 +350,93 @@ function AppShellContent({ startNewSession, clearOwnedSessionState, messages, + transientMessages, setMessages, + addTransientMessage, + updateTransientMessage, + projectQueuedTransientMessages, + retireCancelledTransientMessages, + removeTransientMessage, transcriptRangeRef, messageLoadPending, setMessageLoadPending, - messageRetryPendingRef, - stopPendingRef, sessionUiController, - liveTurnBySessionRef, - sessionEventHealthBySessionRef, - setMessageLoadErrorBySession, - setMessageRetryPendingBySession, - setStopPendingBySession, - setLiveTurnBySession, - confirmLiveTurn, - setShellRunUpdatesBySession, - setInteractionBySession, - setMessageQueueBySession, - setSessionEventHealthBySession, - setPendingPermissionModeBySession, - setPendingSessionModelBySession, } = useAppShellSessionWorkspace(toastApi); + const activeCatalogSession = sessions.find((session) => session.id === activeId); + const sharedSessionActive = + (activeCatalogSession as DesktopSessionSummary | undefined)?.shared === true; + const ownerActiveId = activeCatalogSession && !sharedSessionActive ? activeId : undefined; const interactionHydrationEpochRef = useRef(new Map()); const markInteractionChanged = useCallback((sessionId: string) => { const epochs = interactionHydrationEpochRef.current; epochs.set(sessionId, (epochs.get(sessionId) ?? 0) + 1); }, []); + const { + settingsOpen, + settingsRequest, + settingsProviderCatalogOpen, + settingsConnectionDetailSlug, + settingsCreateProviderType, + setSettingsOpen, + setSettingsProviderCatalogOpen, + setSettingsProfileId, + openSettings, + openSettingsSection, + openProjectSettings, + openProviderCatalog, + openConnectionDetail, + openProviderCreate, + } = useSettingsModal(); + const onboarding = useOnboardingSnapshot(initialOnboardingSnapshot); - const newTask = useNewTaskTarget({ toastApi, uiLocale }); - const currentNewTaskDraftKey = newTaskDraftKey(newTask.target); + const reportTaskEntryError = useCallback( + ({ title, description, profileId }: TaskEntryError) => { + toastApi.error(title, description, undefined, { profileId }); + }, + [toastApi], + ); + const taskEntry = useTaskEntryController({ + reportError: reportTaskEntryError, + manageProjects: openProjectSettings, + }); + // Named on its own because the rail depends on it: `taskEntry.commands` is a + // fresh object every render, so depending on the bag rather than the command + // would rebuild the rail's Project rows on every AppShell commit (#4109). + const { selectLocalProject } = taskEntry.commands; + const currentNewTaskDraftKey = taskEntry.selectors.draftKey; // Staged files and quotes do NOT take the target-scoped key: they belong to // the composer the user is looking at, and an in-flight send needs an owner // that cannot move under it. See NEW_TASK_PENDING_KEY. const attachmentDraftKey = activeId ?? NEW_TASK_PENDING_KEY; + const directoryHostId = activeId + ? (activeCatalogSession?.profileKind === 'local' + ? activeCatalogSession.runtimeHostId + : undefined) + : (taskEntry.selectors.selectedHost?.kind === 'local' + ? taskEntry.selectors.target?.hostId + : undefined); const { pendingAttachments, + submittableAttachments, + hasPendingContext, + directoryOptions, + directoryComposerProps, pickAttachments, attachFilePaths, restoreAttachments, removeAttachment, - clearSubmittedAttachments, + clearSubmittedContext, + imageNoticeLifecycle, } = useComposerAttachments({ draftKey: attachmentDraftKey, + directoryHostId, toastApi, service: window.maka.attachments, + imageNotice: { + supportsVision: () => composerSupportsVision, + notify: toastApi.info, + }, }); const { pendingQuotes, @@ -426,10 +451,14 @@ function AppShellContent({ // Plan toggle and one orchestration value, not one fused choice. const [newChatPlanModeActive, setNewChatPlanModeActive] = useState(false); const [newChatOrchestrationMode, setNewChatOrchestrationMode] = useState('default'); - const [scheduledTaskCreateRequestNonce, setScheduledTaskCreateRequestNonce] = useState(0); const [newTaskPermissionChoice, setNewTaskPermissionChoice, clearNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); const [historyLoadPendingSessionId, setHistoryLoadPendingSessionId] = useState(); + // The state above is what the transcript renders; this is what the guard + // reads. A scroller can ask twice in one task — two scroll events before + // React has re-rendered anything — and a state read is still the old value + // for both of them. + const historyLoadPendingRef = useRef(false); const [transcriptTurnIndex, setTranscriptTurnIndex] = useState<{ sessionId: string; throughSequence: number | null; @@ -447,7 +476,43 @@ function AppShellContent({ const navSelectionRef = useRef(navSelection); const [workHubEnabled, setWorkHubEnabled] = useState(false); const [workHubActive, setWorkHubActive] = useState(false); + const [workHubCoordinationSessionId, setWorkHubCoordinationSessionId] = useState(); + const [workHubCoordinationState, setWorkHubCoordinationState] = useState< + 'resolving' | 'failed' + >('resolving'); + const workHubCoordinationRetryRef = useRef<() => void>(() => undefined); + const workHubCoordinationGenerationRef = useRef(0); + const workHubCoordinationSessionIdRef = useRef(workHubCoordinationSessionId); + workHubCoordinationSessionIdRef.current = workHubCoordinationSessionId; const workHubEnabledRef = useRef(false); + useEffect(() => { + if (!workHubEnabled || !workHubActive) return; + return startWorkHubCoordinationLifecycle({ + resolve: () => window.maka.workHub.resolveCoordinationSession(), + subscribeHostChanges: (handler) => + window.maka.runtimeHostProfiles.subscribeChanges(handler), + subscribeAvailabilityChanges: (handler) => + window.maka.connections.subscribeEvents((event) => { + if (event.type === 'connection_list_changed') handler(); + }), + onResolving: () => { + workHubCoordinationGenerationRef.current += 1; + workHubCoordinationSessionIdRef.current = undefined; + setWorkHubCoordinationSessionId(undefined); + setWorkHubCoordinationState('resolving'); + }, + onResolved: (sessionId) => { + workHubCoordinationSessionIdRef.current = sessionId; + setWorkHubCoordinationSessionId(sessionId); + setWorkHubCoordinationState('resolving'); + }, + reportFailure: (error, retry) => { + workHubCoordinationRetryRef.current = retry; + setWorkHubCoordinationState('failed'); + console.error('[workhub] failed to resolve Coordination Session:', error); + }, + }); + }, [workHubActive, workHubEnabled]); useEffect(() => { let disposed = false; const refresh = async () => { @@ -457,7 +522,12 @@ function AppShellContent({ const becameEnabled = enabled && !workHubEnabledRef.current; workHubEnabledRef.current = enabled; setWorkHubEnabled(enabled); - if (!enabled) setWorkHubActive(false); + if (!enabled) { + workHubCoordinationGenerationRef.current += 1; + workHubCoordinationSessionIdRef.current = undefined; + setWorkHubActive(false); + setWorkHubCoordinationSessionId(undefined); + } if (becameEnabled) { setWorkHubActive(true); setNavSelection({ section: 'sessions' }); @@ -492,10 +562,14 @@ function AppShellContent({ const { memoryActive, refreshMemoryActive } = useShellMemoryPill({ toastApi, uiLocale, - sessionId: activeId, + sessionId: ownerActiveId, + disabled: sharedSessionActive, }); - const newTaskHost = newTask.selectedHost - ? { profileId: newTask.selectedHost.profile.id, hostId: newTask.selectedHost.hostId } + const newTaskHost = taskEntry.selectors.selectedHost + ? { + profileId: taskEntry.selectors.selectedHost.profileId, + hostId: taskEntry.selectors.selectedHost.hostId, + } : undefined; const newTaskConnections = useShellConnections({ toastApi, @@ -510,18 +584,16 @@ function AppShellContent({ const sessionHostConnections = useShellConnections({ toastApi, uiLocale, - target: { kind: 'session', sessionId: activeId }, + target: { kind: 'session', sessionId: ownerActiveId }, }); const startupConnectionSnapshot = initialOnboardingSnapshot ?? onboarding.mountedSnapshotHandoff; - const newTaskUsesDefaultHost = - newTask.catalog.hosts.length === 0 || - newTask.selectedProfileId === newTask.catalog.defaultProfileId; + const newTaskUsesDefaultHost = taskEntry.selectors.usesDefaultHost; let newTaskConnectionSnapshot = newTaskConnections.snapshot; - if (!newTaskConnections.hasSnapshot && newTaskUsesDefaultHost) { - newTaskConnectionSnapshot = defaultHostConnections.hasSnapshot + if (newTaskConnections.projection.status !== 'ready' && newTaskUsesDefaultHost) { + newTaskConnectionSnapshot = defaultHostConnections.projection.status === 'ready' ? defaultHostConnections.snapshot - : startupConnectionSnapshot + : defaultHostConnections.projection.status === 'unrequested' && startupConnectionSnapshot ? { connections: startupConnectionSnapshot.connections, defaultConnection: startupConnectionSnapshot.defaultSlug, @@ -542,13 +614,13 @@ function AppShellContent({ return Promise.all([ defaultHostConnections.refreshConnections(), newTaskConnections.refreshConnections(), - ...(activeId ? [sessionHostConnections.refreshConnections()] : []), + ...(ownerActiveId ? [sessionHostConnections.refreshConnections()] : []), ]).then(() => undefined); } function handleConnectionEvent(event: ConnectionEvent): void { defaultHostConnections.handleConnectionEvent(event); newTaskConnections.handleConnectionEvent(event); - if (activeId) sessionHostConnections.handleConnectionEvent(event); + if (ownerActiveId) sessionHostConnections.handleConnectionEvent(event); } const onboardingState = onboarding.snapshot?.state; const onboardingSettled = hasSettledInitialOnboarding(onboarding.snapshot?.milestones ?? []); @@ -556,28 +628,13 @@ function AppShellContent({ onboarding.snapshot, sessions.length > 0, ); - const { - settingsOpen, - settingsRequestedSection, - settingsProviderCatalogOpen, - settingsConnectionDetailSlug, - settingsCreateProviderType, - setSettingsOpen, - setSettingsProviderCatalogOpen, - openSettings, - openSettingsSection, - openProviderCatalog, - openConnectionDetail, - openProviderCreate, - } = useSettingsModal(); - const [settingsDiagnosticProfileId, setSettingsDiagnosticProfileId] = - useState(); const { themePref, setThemePref, themePalette, setThemePalette, uiLocaleUpdateGate, + appearanceHydrated, userLabel, setUserLabel, @@ -590,7 +647,9 @@ function AppShellContent({ setUiLocalePreference, }); const shellCopy = getShellCopy(uiLocale).app; - const projectActionsCopy = getShellCopy(uiLocale).projectActions; + const sessionCollaborationCopy = getSessionCollaborationCopy(uiLocale); + const previousInterruptionCopy = + getShellRemainingCopy(uiLocale).previousMainProcessInterruption; const desktopConversationCopy = getDesktopConversationCopy(uiLocale); /** * What this draft would start in: the user's choice for it if they made one, @@ -601,8 +660,37 @@ function AppShellContent({ * never written back to `chatDefaults` — the Settings surface owns that. */ const newTaskPermissionMode = - newTaskPermissionChoice ?? newTask.selectedHost?.chatDefaults.permissionMode ?? 'ask'; + newTaskPermissionChoice ?? + taskEntry.selectors.selectedHost?.chatDefaults.permissionMode ?? + 'ask'; const setNewTaskPermissionMode = setNewTaskPermissionChoice; + useEffect(() => { + if (!appearanceHydrated) return; + let cancelled = false; + void window.maka.diagnostics + .takePreviousMainProcessInterruption() + .then((interrupted) => { + if (cancelled || !interrupted || previousInterruptionShownRef.current) return; + previousInterruptionShownRef.current = true; + toastApi.toast({ + variant: 'warning', + title: previousInterruptionCopy.title, + description: previousInterruptionCopy.description, + duration: 10_000, + action: { + label: previousInterruptionCopy.copyDiagnostics, + onClick: () => + window.maka.diagnostics.copyPreviousMainProcessInterruption(), + }, + }); + }) + .catch((error) => + console.error('[diagnostics] previous-session notice failed:', error), + ); + return () => { + cancelled = true; + }; + }, [appearanceHydrated, previousInterruptionCopy, toastApi]); useEffect(() => { if (!isAppUpdateInstallFailure(appUpdateStatus)) { notifiedInstallErrorRef.current = null; @@ -635,7 +723,6 @@ function AppShellContent({ }, []); const updateReminder = updateReminderFromStatus(appUpdateStatus); - const buildStamp = useBuildStamp(); // Dispatches on the task, not on the raw status: the footer is this // callback's only caller and it only renders for the two states above, so // reading the status again here would be the same "who needs the user" list @@ -690,42 +777,17 @@ function AppShellContent({ ); }); }, [updateReminder, shellCopy, toastApi, uiLocale]); - const moduleHubCopy = getSharedUiCopy(uiLocale).moduleHubs; - const extensionsHubHeader = { - title: moduleHubCopy.extensions.title, - subtitle: moduleHubCopy.extensions.description, - badge: ( - setNavSelection({ section: 'extensions', module })} - /> - ), - }; - const automationsHubHeader = { - title: moduleHubCopy.automations.title, - subtitle: moduleHubCopy.automations.description, - badge: ( - setNavSelection({ section: 'automations', module })} - /> - ), - }; // Persisted composer defaults seed the empty-state model, project path, and // recent workspace history so the home view is populated before the async // `app:info` round-trip completes on mount. const persistedComposerDefaults = loadComposerDefaults(); const [helpOpen, closeHelp, openHelp] = useKeyboardHelp(); const [paletteOpen, openPalette, closePalette] = useCommandPalette(); - const [viewMode, setViewMode] = useState(() => readSessionListViewMode()); const composerRef = useRef(null); + const openComposerModelPicker = useCallback(() => { + composerRef.current?.openModelPicker(); + }, []); const retractedWorkspaceReferencesRef = useRef>({}); - // The rail's toggle has to reach Astryx's resizable state, not just this - // boolean — see the prop's note on SessionListPanel. The sidenav is mounted - // for the whole shell, so the handle is always live by the time it is called. - const sessionSideNavHandleRef = useRef(null); const [revisionDraft, setRevisionDraft] = useState(null); const revisionDraftRef = useRef(null); const commitRevisionDraft = useCallback((draft: TurnRevisionDraft | null) => { @@ -751,12 +813,8 @@ function AppShellContent({ resumePendingSessionId, resumeParkDescriptionBySession, resumeInterruptedSession, - } = useShellResume({ activeId, toastApi, shellCopy, uiLocale }); + } = useShellResume({ activeId: ownerActiveId, toastApi, shellCopy, uiLocale }); const rendererMountedRef = useRef(true); - const goals = useGoalController({ - activeSessionId: activeId, - reportError: showSessionError, - }); // Set of session ids whose backend / connection is no longer usable — // drives the sidebar "已过期" pill (PR108g, paired with the PR108e chat // header banner). Derivation is pure (see `stale-sessions.ts`) so the @@ -769,25 +827,10 @@ function AppShellContent({ }), [sessions, onboarding.snapshot?.sessionSendOutcomes], ); - // PR-DAILY-REVIEW-MVP-0: bridge for the main Daily Review module. - // Memoized so the panel's `useEffect` cleanup keys - // off a stable reference instead of refetching on every render. - const dailyReviewBridge = useMemo(() => createAppShellDailyReviewBridge(uiLocale), [uiLocale]); - const { - appendDailyReviewMarkdown, - copyDailyReviewMarkdown, - saveDailyReviewMarkdown, - } = useStableActions(createAppShellDailyReviewActions, { - uiLocale, - composerRef, - toastApi, - }); - const activeInteraction = activeInteractionFor(interactionBySession, activeId); - const activeSandboxBoundary = - activeInteraction?.type === 'sandbox_boundary_request' ? activeInteraction : undefined; - const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined; - const activeSession = sessions.find((session) => session.id === activeId); + const activeInteraction = activeInteractionFor(interactionBySession, ownerActiveId); + const activeSession = activeCatalogSession; const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined; + const activeMessageSubmitting = transientMessages.length > 0; const activeDesktopSession = activeSession; // The shell's reading of the active live turn: streaming/settled flags, the // in-flight tool signal, and the #646 turn-wait cues, all derived from the @@ -825,13 +868,21 @@ function AppShellContent({ : undefined; const composerProfileId = activeId ? activeDesktopSession?.profileId - : newTask.selectedProfileId; + : taskEntry.selectors.selectedProfileId; const composerProfileName = activeId ? activeDesktopSession?.profileName - : newTask.selectedHost?.profile.name; + : taskEntry.selectors.selectedHost?.name; const modelSettingsOwnsComposerHost = composerProfileId !== undefined && - composerProfileId === newTask.catalog.defaultProfileId; + composerProfileId === taskEntry.selectors.defaultProfileId; + const modelChangePending = activeId + ? pendingSessionModelBySession[activeId] === true + : false; + const modelSwitchAvailability = deriveComposerModelSwitchAvailability({ + streaming: turnActive || activeStreamingLive, + sessionStatus: activeSession?.status, + pending: modelChangePending, + }); const { chatModelChoices, activeConnection, @@ -844,6 +895,7 @@ function AppShellContent({ newChatModelLabel, newChatThinkingLevels, newChatThinkingLevel, + composerSupportsVision, setPendingNewChatModel, pendingNewChatThinkingLevel, setPendingNewChatThinkingLevel, @@ -861,8 +913,14 @@ function AppShellContent({ activeSession, persistedComposerDefaults, usePersistedComposerDefaults: modelSettingsOwnsComposerHost, - defaultThinkingLevel: newTask.selectedHost?.chatDefaults.thinkingLevel, + defaultThinkingLevel: taskEntry.selectors.selectedHost?.chatDefaults.thinkingLevel, + connectionSnapshotReady: activeId + ? sessionHostConnections.projection.status === 'ready' + : true, + modelPickerDisabled: !modelSwitchAvailability.available, openSettingsSection, + openModelPicker: openComposerModelPicker, + refreshModelChoices: sessionHostConnections.refreshConnections, }); const newChatProviderType = newChatModel ? connections.find((connection) => connection.slug === newChatModel.llmConnectionSlug)?.providerType @@ -873,81 +931,26 @@ function AppShellContent({ // mask. Per @kenji PR109d review: pending state prevents double-click // duplicate sibling turns by disabling the action button between // click and `sessions:changed turn-status-change` arriving. - // The four de-dup registries (turn-footer actions, session-row actions, - // per-session permission-mode / model changes) all share the same keyed-Set - // shape; see useKeyedPendingRegistry. Only the turn-footer registry mirrors - // into React state (drives the disabled mask) and arms a 5s auto-clear - // fallback timer; the other three stay ref-only and clear in their action's - // `finally`. - const turnActionRegistry = useKeyedPendingRegistry({ - trackState: true, - autoClearMs: 5000, - }); + // Session-row mutations live in Session Navigation; the per-session mode and + // model claims live in the session UI store. + const turnActionRegistry = useTurnActionRegistry(); const pendingTurnActions = turnActionRegistry.keys; - const sessionRowActionRegistry = useKeyedPendingRegistry(); - const permissionModeChangeRegistry = useKeyedPendingRegistry(); - const sessionModelChangeRegistry = useKeyedPendingRegistry(); const pendingKeyOf = (sessionId: string, turnId: string, actionId: string) => `${sessionId}:${turnId}:${actionId}`; - function omitSessionKey(current: Record, sessionId: string): Record { - if (!(sessionId in current)) return current; - const next = { ...current }; - delete next[sessionId]; - return next; - } - - function addPendingSessionAction( - sessionId: string, - pendingRef: { current: Set }, - setPendingBySession?: (updater: (current: Record) => Record) => void, - ): boolean { - if (pendingRef.current.has(sessionId)) return false; - pendingRef.current.add(sessionId); - setPendingBySession?.((current) => ({ ...current, [sessionId]: true })); - return true; - } - - function clearPendingSessionAction( - sessionId: string, - pendingRef: { current: Set }, - setPendingBySession?: (updater: (current: Record) => Record) => void, - ): void { - if (!pendingRef.current.has(sessionId)) return; - pendingRef.current.delete(sessionId); - setPendingBySession?.((current) => omitSessionKey(current, sessionId)); - } + // A hoisted declaration on purpose: `dropDisplayEvents` is destructured + // hundreds of lines below, and the rail does not need this identity held + // still — the rail's controller reads it through `portsRef`. function clearSessionRendererState(sessionId: string): void { + dropDisplayEvents(sessionId); + // `clearOwnedSessionState` ends in `clearSessionUiState`, which drops this + // session from every session-UI map — the four pending claims included. clearOwnedSessionState(sessionId); turnActionRegistry.clearForSession(sessionId); - permissionModeChangeRegistry.keysRef.current.delete(sessionId); planModeIntent.clear(sessionId); orchestrationModeIntent.clear(sessionId); - sessionModelChangeRegistry.keysRef.current.delete(sessionId); } - const sessionRowActionHandlers = useStableActions(createAppShellSessionRowActions, { - uiLocale, - activeIdRef, - clearSessionRendererState, - pendingSessionRowActionsRef: sessionRowActionRegistry.keysRef, - refreshSessions, - sessionsRef, - setActiveId, - setMessages, - toastApi, - }); - const sessionRowActions = useMemo[0]['rowActions']>>( - () => ({ - onToggleFlag: (sessionId, next) => sessionRowActionHandlers.flagSession(sessionId, next), - onArchive: (sessionId) => sessionRowActionHandlers.archiveSession(sessionId), - onUnarchive: (sessionId) => sessionRowActionHandlers.unarchiveSession(sessionId), - onRename: (sessionId, name) => sessionRowActionHandlers.renameSession(sessionId, name), - onDelete: (sessionId) => sessionRowActionHandlers.deleteSession(sessionId), - }), - [], - ); - const { setPermissionMode, setSessionModel, @@ -957,14 +960,12 @@ function AppShellContent({ activeIdRef, connections, messages, - pendingPermissionModeChangesRef: permissionModeChangeRegistry.keysRef, - pendingSessionModelChangesRef: sessionModelChangeRegistry.keysRef, + permissionModePending: sessionUiController.permissionModePending, + sessionModelPending: sessionUiController.sessionModelPending, refreshSessions, saveComposerDefaults, sessionsRef, setNewTaskPermissionMode, - setPendingPermissionModeBySession, - setPendingSessionModelBySession, toastApi, }); @@ -1002,6 +1003,8 @@ function AppShellContent({ }, }); + // Stable: the rail's row actions are built from it, and it only reaches + // registries and refs that are themselves stable (#4109). /** * Enter or leave Plan for one Session — the only path that writes * `collaborationMode`, and it writes nothing else. @@ -1116,7 +1119,7 @@ function AppShellContent({ // 1. `data-maka-reduced-motion="true"` — PR-IR-04 reduced variant // 2. `data-maka-e2e-fixture="true"` — PR-IR-02 any capture // 3. `prefers-reduced-motion: reduce` — OS-level user preference - function handleLineageBadgeClick(targetTurnId: string): void { + function handleLineageBadgeClick(targetTurnId: string) { requestAnimationFrame(() => { const el = document.querySelector(`[data-turn-id="${CSS.escape(targetTurnId)}"]`); if (!el || !('scrollIntoView' in el)) return; @@ -1127,16 +1130,15 @@ function AppShellContent({ }); } - function openSessionInChat(sessionId: string, turnId?: string, sequence?: number): void { - setWorkHubActive(false); - setNavSelection({ section: 'sessions' }); - setActiveId(sessionId); - if (turnId) { - setSearchScrollTarget({ sessionId, turnId, sequence, nonce: Date.now() }); - } else { - setSearchScrollTarget(null); - } - } + const openSessionInChatRef = useRef< + (sessionId: string, turnId?: string, sequence?: number) => void + >(() => undefined); + const openSessionInChat = useCallback( + (sessionId: string, turnId?: string, sequence?: number): void => { + openSessionInChatRef.current(sessionId, turnId, sequence); + }, + [], + ); /* PR-FE-BUG-HUNT-0 (kenji bug-hunt 2026-06-24): SearchModal + CommandPalette callbacks used to be inline arrows in JSX, so @@ -1148,8 +1150,6 @@ function AppShellContent({ dead while a stream was active. Same root cause for the palette selection effect that resets keyboard highlight on every deps change. Stable refs + memos keep the timers alive. */ - const openSessionInChatRef = useRef(openSessionInChat); - openSessionInChatRef.current = openSessionInChat; const { searchModalOpen, setSearchModalOpen, @@ -1180,37 +1180,11 @@ function AppShellContent({ }, [shellCopy], ); - const sessionListSelectSession = useCallback((sessionId: string) => { - openSessionInChatRef.current(sessionId); - }, []); const openWorkHub = useCallback(() => { setNavSelection({ section: 'sessions' }); setWorkHubActive(true); }, [setNavSelection]); - // PR109f: branched session context. When the active session was - // created via `sessions:branchFromTurn`, its `parentSessionId` is - // set; render a banner above the chat surface so the user knows - // they're in a derived conversation and can jump back to the parent. - // - // v1 intentionally omits the fromAbortedTurn hint because checking - // it requires loading the parent's full message log. The session - // banner stays at "分自 ${parentName}" until parent-message - // preloading lands; "从中断前" is only surfaced in the aborted - // turn's branch footer tooltip where the active turn status is known. - const branchBanner = useMemo( - () => deriveBranchBanner(activeSession, sessions), - [activeSession?.parentSessionId, sessions], - ); - const revisionNavigation = useMemo( - () => deriveSessionRevisionNavigation(sessions, activeId), - [sessions, activeId], - ); - - function handleBranchBannerClick(parentSessionId: string): void { - openSessionInChat(parentSessionId); - } - // Transient placeholder while the real SessionSummary loads, so the composer // does not flash a value the session never had. const activeSessionForView: SessionSummary | undefined = @@ -1253,41 +1227,41 @@ function AppShellContent({ unreadable: activeExecutionBoundaryUnreadable, reading: activeExecutionBoundaryReading, reload: reloadActiveExecutionBoundary, - } = useActiveExecutionBoundary(activeId, activeSessionForView?.permissionMode); + } = useActiveExecutionBoundary(ownerActiveId, activeSessionForView?.permissionMode); // The session view only subscribes to the session it shows, so a request // raised while another session was active never reaches this surface as a // live event — and neither does one raised before the window existed. The // runtime holds every unanswered request, so read them back whenever the // active session changes (#2072). useEffect(() => { - if (!activeId) return; + if (!ownerActiveId) return; let cancelled = false; - const hydrationEpoch = interactionHydrationEpochRef.current.get(activeId) ?? 0; + const hydrationEpoch = interactionHydrationEpochRef.current.get(ownerActiveId) ?? 0; void window.maka.sessions - .listActiveInteractions(activeId) + .listActiveInteractions(ownerActiveId) .then((requests) => { if ( cancelled || - (interactionHydrationEpochRef.current.get(activeId) ?? 0) !== hydrationEpoch + (interactionHydrationEpochRef.current.get(ownerActiveId) ?? 0) !== hydrationEpoch ) { return; } - setInteractionBySession((current) => reconcileInteractions(current, activeId, requests)); + sessionUiController.setInteractionBySession((current) => reconcileInteractions(current, ownerActiveId, requests)); }) .catch(() => {}); return () => { cancelled = true; }; - }, [activeId, setInteractionBySession]); + }, [ownerActiveId, sessionUiController.setInteractionBySession]); useEffect( () => window.maka.sessions.subscribeActiveInteractions(({ sessionId, interactions }) => { markInteractionChanged(sessionId); - setInteractionBySession((current) => + sessionUiController.setInteractionBySession((current) => reconcileInteractions(current, sessionId, interactions), ); }), - [markInteractionChanged, setInteractionBySession], + [markInteractionChanged, sessionUiController.setInteractionBySession], ); const activeBoundarySurface = deriveDesktopExecutionBoundarySurface( activeId, @@ -1295,7 +1269,7 @@ function AppShellContent({ activeId ? (activeSessionForView?.permissionMode ?? 'ask') : newTaskPermissionMode, ); const activePermissionMode = activeBoundarySurface.permissionMode; - const planMode = usePlanModeState(activeSessionForView); + const planMode = usePlanModeState(sharedSessionActive ? undefined : activeSessionForView); const planConversationItems = (planMode.state?.proposals ?? []).map((proposal) => ({ id: proposal.proposalId, afterTurnId: proposal.turnId, @@ -1420,12 +1394,6 @@ function AppShellContent({ onRetry: () => reloadActiveExecutionBoundary(activeId), } : undefined; - const { - sessionListWidth, - setSessionListWidth, - sessionListCollapsed, - setSessionListCollapsed, - } = useShellLayout(); const desktopSlashCommands = useMemo( () => { const streaming = turnActive || activeStreamingLive; @@ -1463,55 +1431,7 @@ function AppShellContent({ }, [activeId, activeStreamingLive, shellCopy.slashCommands, turnActive], ); - function isScheduledTasksSurfaceActive(): boolean { - return navSelectionRef.current.section === 'automations' && navSelectionRef.current.module === 'scheduled-tasks'; - } - - function isSkillsSurfaceActive(): boolean { - return navSelectionRef.current.section === 'extensions' && navSelectionRef.current.module === 'skills'; - } - - function isDailyReviewSurfaceActive(): boolean { - return navSelectionRef.current.section === 'automations' && navSelectionRef.current.module === 'daily-review'; - } - - const { - skills, - managedSkillSources, - bundledSkillCatalog, - scheduledTasks, - refreshScheduledTasks, - createScheduledTask, - updateScheduledTask, - toggleScheduledTask, - triggerScheduledTaskNow, - snoozeScheduledTask, - clearScheduledTaskRunHistory, - deleteScheduledTask, - refreshSkills, - refreshManagedSkillSources, - refreshBundledSkillCatalog, - importManagedSkillSource, - installManagedSkill, - installBundledSkill, - previewManagedSkillUpdate, - updateManagedSkill, - setSkillEnabled, - setSkillPinned, - deleteSkill, - openSkill, - } = useAppShellModuleData({ - uiLocale, - isSkillsSurfaceActive, - isScheduledTasksSurfaceActive, - toastApi, - }); - - // 保持系统唤醒 capability for the 定时任务 page: reads/writes - // settings.system.keepSystemAwake over the existing settings bridge. When - // the bridge is absent the panel hides the row (fail-soft). - const keepSystemAwakeController = useKeepSystemAwake(); - + const refreshProjectSkillsRef = useRef<() => Promise>(async () => {}); const { projectInfo, projects, @@ -1533,95 +1453,81 @@ function AppShellContent({ } = useAppShellProjectContext({ uiLocale, rendererMountedRef, - sessionId: activeId, - sessionCwd: activeSession?.cwd, - sessionProjectId: activeSession?.projectId, - sessionProfileKind: activeDesktopSession?.profileKind, + sessionId: ownerActiveId, + sessionCwd: sharedSessionActive ? undefined : activeSession?.cwd, + sessionProjectId: sharedSessionActive ? undefined : activeSession?.projectId, + sessionProfileKind: sharedSessionActive ? undefined : activeDesktopSession?.profileKind, onProjectSelected: (ownerSessionId) => { - void refreshSkills(); - void refreshManagedSkillSources(); - void refreshBundledSkillCatalog(); + void refreshProjectSkillsRef.current(); if (ownerSessionId && activeIdRef.current === ownerSessionId) openNewTaskSurface(); }, toastApi, }); - const workHubController = useMemo(() => createWorkHubController({ - sessions: createDesktopWorkHubSessionPort({ - sessions: window.maka.sessions, - transcripts: window.maka.transcripts, - projectName: (projectId) => projects.find((project) => project.id === projectId)?.name, - newTurnId: () => crypto.randomUUID(), + const captureActiveComposerClaim = useCallback(() => { + const sessionId = activeIdRef.current; + const composer = composerRef.current; + if ( + !sessionId || + !composer || + navSelectionRef.current.section !== 'sessions' + ) { + return undefined; + } + return { + isCurrent: () => + activeIdRef.current === sessionId && + navSelectionRef.current.section === 'sessions' && + composerRef.current === composer, + append: (text: string) => composer.appendText(text), + }; + }, []); + const moduleHub = useModuleHubController({ + selection: navSelection, + selectModule: setNavSelection, + ...(projectCapabilities.viewClientPath ? { openSkillsFolder } : {}), + useSkillInChat, + openSession: (sessionId) => openSessionInChatRef.current(sessionId), + appendComposerText: (text) => composerRef.current?.appendText(text), + captureActiveComposerClaim, + }); + refreshProjectSkillsRef.current = moduleHub.commands.refreshProjectSkills; + const workHubProjectsRef = useRef(projects); + workHubProjectsRef.current = projects; + const workHubCoordinationGeneration = workHubCoordinationGenerationRef.current; + const workHubController = useMemo( + () => createWorkHubController({ + coordination: createDesktopWorkHubCoordinationPort({ + sessionId: workHubCoordinationSessionId ?? 'workhub-coordination-unresolved', + transcripts: window.maka.transcripts, + record: (input) => + window.maka.workHub.record(workHubCoordinationSessionId!, input), + candidates: () => + window.maka.workHub.candidates(workHubCoordinationSessionId!), + act: (input) => + window.maka.workHub.act(workHubCoordinationSessionId!, input), + }), + sessions: createDesktopWorkHubSessionPort({ + sessions: scopeWorkHubSessionsToCoordinationHost( + window.maka.sessions, + { + sessionId: workHubCoordinationSessionId, + isCurrent: () => + workHubCoordinationGenerationRef.current === workHubCoordinationGeneration && + workHubCoordinationSessionIdRef.current === workHubCoordinationSessionId, + }, + ), + transcripts: window.maka.transcripts, + projectName: (projectId) => + workHubProjectsRef.current.find((project) => project.id === projectId)?.name, + }), }), - }), [projects]); + [workHubCoordinationGeneration, workHubCoordinationSessionId], + ); // Where a NEW chat starts. Built unconditionally and handed to the composer, // which renders it only while no session owns it — the project is fixed once // the first message creates one, so there is nothing to pick after that. - const selectedNewTaskHost = newTask.catalog.hosts.find( - (host) => host.profile.id === newTask.selectedProfileId, - ); - const selectedNewTaskProject = newTask.currentProject?.name ?? - (newTask.selectedProjectId === null && newTask.selectedHost?.capabilities.selectNoProject - ? getConversationCopy(uiLocale).workspace.noProject - : undefined); - const selectedNewTaskBranch = - newTask.selectedHost && - newTask.selectedProjectId === newTask.selectedHost.selectedProjectId - ? newTask.selectedHost.branch - : undefined; - const newTaskCatalogNeedsRetry = Boolean(newTask.error) || newTask.catalog.hosts.some( - (host) => host.readiness === 'ready' && host.state === 'error', - ); - const workspacePicker: WorkspacePickerModel = { - label: selectedNewTaskProject ?? selectedNewTaskHost?.profile.name ?? - (newTask.error ? getShellCopy(uiLocale).projectActions.catalogUnavailable : undefined), - ...(selectedNewTaskHost?.profile.kind === 'remote' - ? { hostBadge: selectedNewTaskHost.profile.name } - : {}), - branch: newTask.selectedProjectId === null ? null : selectedNewTaskBranch, - pending: newTask.pending || (newTask.refreshing && newTask.catalog.hosts.length === 0), - selectedGroupId: newTask.selectedProfileId, - groups: newTask.catalog.hosts.map((host) => { - if (host.readiness !== 'ready' || host.state !== 'available') { - return { - id: host.profile.id, - label: host.profile.name, - status: host.readiness === 'ready' - ? host.message - : getShellCopy(uiLocale).projectActions.runtimeHostReadiness[host.readiness], - disabled: true, - projects: [], - }; - } - const selectedProjectId = host.profile.id === newTask.selectedProfileId - ? newTask.selectedProjectId - : host.selectedProjectId; - return { - id: host.profile.id, - label: host.profile.name, - projects: host.projects.filter((project) => project.archivedAt === undefined), - selectedProjectId, - onSelectProject: (projectId: string) => newTask.selectProject(host, projectId), - ...(host.capabilities.chooseClientDirectory || host.capabilities.chooseHostDirectory - ? { onAdd: () => void newTask.addProject(host) } - : {}), - ...(host.capabilities.chooseClientDirectory - ? { onRelink: (projectId: string) => void newTask.relinkProject(host, projectId) } - : {}), - ...(host.capabilities.selectNoProject - ? { onSelectNoProject: () => newTask.selectNoProject(host) } - : {}), - }; - }), - ...(newTaskCatalogNeedsRetry - ? { - retry: { - label: getShellCopy(uiLocale).projectActions.retryCatalog, - onClick: () => void newTask.refresh().catch(() => undefined), - }, - } - : {}), - }; - const taskReadinessWorkspace = activeSession?.cwd ?? newTask.projectPath; + const workspacePicker = taskEntry.selectors.workspacePicker; + const taskReadinessWorkspace = activeSession?.cwd ?? taskEntry.selectors.projectPath; const taskReadinessRequest = { ...resolveTaskReadinessModelTarget(activeSession, activeSessionSendOutcome, newChatModel), ...(taskReadinessWorkspace ? { cwd: taskReadinessWorkspace } : {}), @@ -1629,24 +1535,26 @@ function AppShellContent({ const taskReadiness = useTaskSubmissionReadiness( taskReadinessRequest, onboarding.snapshot, - activeId, - activeId ? undefined : newTask.target, + ownerActiveId, + activeId ? undefined : taskEntry.selectors.target, ); const taskReadinessNotice = deriveTaskReadinessNotice(taskReadiness.snapshot, uiLocale); const ignoreTaskReadinessModelTarget = activeSession !== undefined && activeSessionSendOutcome?.kind !== 'blocked'; const taskSubmissionHardBlocked = - (!activeId && !newTask.target) || + (!activeId && !taskEntry.selectors.target) || isTaskSubmissionHardBlocked(taskReadiness.snapshot, { ignoreModelTarget: ignoreTaskReadinessModelTarget, }); // The titlebar names the directory the ACTIVE session runs in, so it reads // the same projected project state the picker does — `projectInfo` already // resolves to the session's own cwd once a session owns it. - const titlebarProjectName = deriveTitlebarProjectName({ - projectName: currentProject?.name, - projectPath: projectInfo?.projectPath, - }); + const titlebarProjectName = sharedSessionActive + ? undefined + : deriveTitlebarProjectName({ + projectName: currentProject?.name, + projectPath: projectInfo?.projectPath, + }); const { startModeSession } = useStableActions(createAppShellSessionStartActions, { uiLocale, activeIdRef, @@ -1654,47 +1562,94 @@ function AppShellContent({ composerRef, isShellSurfaceOwnerActive, openSessionInChat, - newTaskTarget: newTask.target, + newTaskTarget: taskEntry.selectors.target, sessionStartPendingRef, refreshOnboarding: onboarding.refresh, refreshSessions, showModelSetupToast, toastApi, }); + const openNewTaskSurface = useCallback(() => { + imageNoticeLifecycle.reset(NEW_TASK_PENDING_KEY); + startNewSession(); + // Only Plan resets: a new task starts out of Plan, in whatever + // orchestration the last one was set to. + setNewChatPlanModeActive(false); + setNavSelection({ section: 'sessions' }); + setSearchScrollTarget(null); + // New-task affordances reset to the empty-state composer; move focus + // there so the user can start typing immediately. + window.requestAnimationFrame(() => composerRef.current?.focus()); + }, [imageNoticeLifecycle, setNavSelection, setSearchScrollTarget, startNewSession]); + + const createSession = useCallback(async () => { + openNewTaskSurface(); + }, [openNewTaskSurface]); + + // Stable, because the rail's Project rows carry it: a fresh identity here + // rebuilt the whole list on every AppShell commit (#4109). + const createSessionInProject = useCallback( + async (projectId: string) => { + if (!selectLocalProject(projectId)) return; + openNewTaskSurface(); + }, + [openNewTaskSurface, selectLocalProject], + ); + // Sidebar Project groups are Local. Their catalog mutations remain on the // default-scoped bridge until Settings receives its own Host selector. - const projectRowActions: Parameters[0]['projectActions'] = - projectCapabilities.setLocalDefault - ? { - onNew: createSessionInProject, - onRename: renameProject, - onArchive: archiveProject, - onRestore: restoreProject, - ...(projectCapabilities.chooseClientDirectory - ? { - onRelink: (projectId: string) => - relinkProject(projectId).then(() => undefined), - } - : {}), - } - : undefined; + // + // Memoized because the rail reads it: rebuilt per render, this one object + // would put the whole list back on every AppShell commit (#4109). + const projectRowActions = useMemo( + () => + projectCapabilities.setLocalDefault + ? { + onNew: createSessionInProject, + onRename: renameProject, + onArchive: archiveProject, + onRestore: restoreProject, + ...(projectCapabilities.chooseClientDirectory + ? { + onRelink: (projectId: string) => + relinkProject(projectId).then(() => undefined), + } + : {}), + } + : undefined, + [ + archiveProject, + createSessionInProject, + projectCapabilities.chooseClientDirectory, + projectCapabilities.setLocalDefault, + relinkProject, + renameProject, + restoreProject, + ], + ); // Composer mention popups: `/` uses Runtime's session/project-aware, // host-compatible projection; `@` uses workspace file search. Keep the - // resolved project path as a refresh key for new-chat project changes. - const { mentionSkills, mentionSkillsUnavailable, mentionSkillsLoading, searchMentionFiles } = useComposerMentions({ - skills, - sessionId: activeId, - projectPath: activeId ? projectInfo?.projectPath : newTask.projectPath, - newTaskTarget: activeId ? undefined : newTask.target, + // resolved project path as a refresh key for new-chat project changes. Only + // the SURFACE is named here — the projection itself is owned by + // `ComposerMentionsProvider` below, so its reloads do not re-render the shell. + const composerMentionsSurface: ComposerMentionsSurface = { + skillCatalogRevision: moduleHub.selectors.skillCatalogRevision, + sessionId: ownerActiveId, + projectPath: activeId + ? ownerActiveId + ? projectInfo?.projectPath + : undefined + : taskEntry.selectors.projectPath, + newTaskTarget: activeId ? undefined : taskEntry.selectors.target, newSessionModel: newChatModel, newSessionCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', // Refresh only; Desktop Main re-reads the authoritative default before // constructing the Runtime Host preview target. newSessionPermissionMode: newTaskPermissionMode, - }); + }; - const hasModalOpen = helpOpen || paletteOpen || searchModalOpen; + const hasModalOpen = helpOpen || paletteOpen || searchModalOpen || sharedSessionDialog.isOpen; const shellObscured = hasModalOpen || settingsOpen; const contextCompactionPresentation = useMemo( () => @@ -1720,73 +1675,135 @@ function AppShellContent({ const workbar = useWorkbarController({ available: workbarAvailable, activeSession: activeSessionForView, + projectId: currentProjectId, + projectAliases: currentProject?.aliases ?? [], authoritativeSessionIds: authoritativeSessionIds ?? undefined, shellObscured, modelChoices: chatModelChoices, - mentionSkills, - mentionSkillsUnavailable, - mentionSkillsLoading, - searchMentionFiles, reportError: reportWorkbarError, }); - // One projection owns rail membership, active highlight, and titlebar parent. - // Companion forks remain hidden until their authoritative cleanup completes. - const { - sessions: visibleSessions, - activeRowId: sidebarActiveId, - activeParentSession: railParentSession, - } = useMemo( + const exitWorkHub = useCallback(() => setWorkHubActive(false), []); + const selectSessionSurface = useCallback( + () => setNavSelection({ section: 'sessions' }), + [setNavSelection], + ); + const clearActiveMessages = useCallback(() => setMessages([]), [setMessages]); + const openSession = useMemo( () => - deriveSessionRail(sessions, activeId, (session) => - !workbar.selectors.hiddenSessionIds.has(session.id) && - sessionMatchesRail(session), - ), - [sessions, activeId, workbar.selectors.hiddenSessionIds], + createSessionOpenCommand({ + activateSession: setActiveId, + exitWorkHub, + selectSessionSurface, + setSearchTarget: setSearchScrollTarget, + }), + [exitWorkHub, selectSessionSurface, setActiveId, setSearchScrollTarget], ); + useLayoutEffect(() => { + openSessionInChatRef.current = openSession; + }, [openSession]); + const pendingSessionRowActionsRef = useRef(new Set()); + const sessionNavigationCommandsRef = useRef(null); + // Built inline: the rail reads these through a ref published on commit, so + // their identity carries no information and this object never has to be + // held still by hand (#4109). + const sessionNavigationPorts: SessionNavigationPorts = { + activeIdRef, + sessionsRef, + pendingSessionRowActionsRef, + activateSession: setActiveId, + clearActiveMessages, + clearSessionRendererState, + refreshSessions, + toastApi, + }; + const { + rail: sessionRail, + branchBanner, + revisionNavigation, + layout: railLayout, + } = useSessionNavigationReads({ + sessions, + activeSessionId: activeId, + activeSession, + hiddenSessionIds: workbar.selectors.hiddenSessionIds, + }); + const visibleSessions = sessionRail.sessions; + const sessionListCollapsed = railLayout.collapsed; + const sessionListWidth = railLayout.width; + const sessionSideNavHandleRef = sessionRailLayoutStore.collapseHandleRef; const titlebarParentSession = useMemo(() => { - if (!railParentSession) return undefined; - const parentId = railParentSession.id; + const parent = sessionRail.activeParentSession; + if (!parent) return undefined; + const parentId = parent.id; return { - name: railParentSession.name, + name: parent.name, onOpen: () => openSessionInChatRef.current(parentId), }; - }, [railParentSession]); - const sessionProjectGroups = useMemo( - () => deriveDesktopSessionGroups(visibleSessions, localProjects, uiLocale), - [visibleSessions, localProjects, uiLocale], - ); - const worktreeSessionIds = useMemo( - () => - deriveWorktreeSessionIds( - visibleSessions.filter( - (session) => session.profileKind !== 'remote', - ), - localProjects, - ), - [visibleSessions, localProjects], - ); + }, [sessionRail.activeParentSession]); const archivedTasksBridge = useMemo( () => ({ sessions, projects: localProjects, onRestore: (sessionId) => - void sessionRowActionHandlers.unarchiveSession(sessionId), + void sessionNavigationCommandsRef.current?.unarchiveSession(sessionId), onDelete: (sessionId) => - void sessionRowActionHandlers.deleteSession(sessionId), + void sessionNavigationCommandsRef.current?.deleteSession(sessionId), onPurge: (sessionIds) => - sessionRowActionHandlers.purgeSessions(sessionIds), + sessionNavigationCommandsRef.current!.purgeSessions(sessionIds), }), [sessions, localProjects], ); + const firstSendObservationWaitersRef = useRef( + new Map(), + ); + const activateSessionForFirstSend = useCallback((sessionId: string): Promise => { + let waiter = firstSendObservationWaitersRef.current.get(sessionId); + if (!waiter) { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + let created!: FirstSendObservationWaiter; + const timeoutId = globalThis.setTimeout(() => { + if (firstSendObservationWaitersRef.current.get(sessionId) !== created) return; + firstSendObservationWaitersRef.current.delete(sessionId); + created.reject(new Error('Timed out while preparing the new Session event stream')); + }, FIRST_SEND_OBSERVATION_TIMEOUT_MS); + created = { promise, resolve, reject, timeoutId }; + waiter = created; + firstSendObservationWaitersRef.current.set(sessionId, waiter); + } + setNavSelection({ section: 'sessions' }); + setActiveId(sessionId); + return waiter.promise; + }, [setActiveId, setNavSelection]); + useEffect(() => { + for (const [sessionId, waiter] of firstSendObservationWaitersRef.current) { + if (sessionId === activeId) continue; + firstSendObservationWaitersRef.current.delete(sessionId); + globalThis.clearTimeout(waiter.timeoutId); + waiter.reject(new Error('The new Session was left before its event stream became ready')); + } + }, [activeId]); + useEffect(() => () => { + for (const [sessionId, waiter] of firstSendObservationWaitersRef.current) { + firstSendObservationWaitersRef.current.delete(sessionId); + globalThis.clearTimeout(waiter.timeoutId); + waiter.reject(new Error('The app closed before the new Session event stream became ready')); + } + }, []); + const { applyE2eFixture } = useStableActions(createAppShellE2eFixtureActions, { openSettingsSection, refreshSessions, setActiveId, setNavSelection, setSearchModalOpen, - setSessionListCollapsed, + setSessionListCollapsed: sessionRailLayoutStore.setCollapsed, workbar: { rightCollapsed: workbar.selectors.rightCollapsed, toggleRight: workbar.commands.toggleRight, @@ -1798,6 +1815,7 @@ function AppShellContent({ const { send, + enqueueMessage, respondToSandboxBoundary, respondToUserQuestion, refreshMessages, @@ -1805,22 +1823,22 @@ function AppShellContent({ } = useStableActions(createAppShellChatActions, { uiLocale, activeIdRef, - addPendingSessionAction, captureComposerImportOwner, checkTaskSubmissionReadiness: taskSubmissionReadyAtSend, - clearPendingSessionAction, isNewChatSendSurfaceActive, isShellSurfaceOwnerActive, - messageRetryPendingRef, + messageRetryPending: sessionUiController.messageRetryPending, refreshSessions, + activateSessionForFirstSend, setActiveId, - setMessageLoadErrorBySession, - setMessageRetryPendingBySession, + setMessageLoadErrorBySession: sessionUiController.setMessageLoadErrorBySession, setMessages, + addTransientMessage, + updateTransientMessage, + removeTransientMessage, transcriptRangeRef, - setNavSelection, - setLiveTurnBySession, - setInteractionBySession, + setLiveTurnBySession: sessionUiController.setLiveTurnBySession, + setInteractionBySession: sessionUiController.setInteractionBySession, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, showModelSetupToast, @@ -1831,7 +1849,7 @@ function AppShellContent({ clearNewChatPermissionChoice: clearNewTaskPermissionChoice, newChatCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', newChatOrchestrationMode: newChatOrchestrationMode, - newTaskTarget: newTask.target, + newTaskTarget: taskEntry.selectors.target, }); const { handleTurnFooterAction } = useStableActions(createAppShellTurnActions, { @@ -1866,7 +1884,7 @@ function AppShellContent({ activeIdRef, composerRef, messages, - hasPendingAttachments: () => pendingAttachments.length > 0, + hasPendingAttachments: () => hasPendingContext, openSessionInChat, refreshMessages, refreshSessions, @@ -1903,36 +1921,40 @@ function AppShellContent({ } } + function settleNewTaskImageNoticeOwner(sourceSessionId?: string) { + const createdSessionId = activeIdRef.current; + if (!sourceSessionId && createdSessionId) { + imageNoticeLifecycle.transfer(NEW_TASK_PENDING_KEY, createdSessionId); + } + } + async function enqueueFollowUp( sessionId: string, text: string, mode: FollowUpMode, metadata?: ComposerSendMetadata, ): Promise { - const pending = pendingAttachments.length > 0 ? pendingAttachments : undefined; - const quotes = pendingQuotes.length > 0 ? pendingQuotes : undefined; - const attachmentItems = pending ? toComposerIngestItems(pending) : []; - const retainedAttachments = pending ? retainedAttachmentRefs(pending) : []; + const pending = submittableAttachments; + const quotes = pendingQuotes.length ? pendingQuotes : undefined; try { - const result = await window.maka.sessions.enqueue( + const sent = await enqueueMessage( sessionId, + text, mode === 'steer' ? 'current_turn' : 'next_turn', + pending, { - text, - ...(attachmentItems.length > 0 ? { attachmentItems } : {}), - ...(retainedAttachments.length > 0 ? { retainedAttachments } : {}), + ...directoryOptions, ...(quotes ? { quotes: [...quotes] } : {}), ...(metadata?.workspaceFileReferences?.length ? { workspaceFileReferences: [...metadata.workspaceFileReferences] } : {}), }, ); - if (pending) clearSubmittedAttachments(pending); + // Refused: the composer keeps the draft, the attachments and the quotes, + // because the user has to change something and send it again. + if (!sent) return false; + clearSubmittedContext(pending); if (quotes) clearQuotes(); - if (result.kind === 'started') { - await refreshMessages(sessionId); - await refreshSessions(); - } return true; } catch (error) { if (activeIdRef.current === sessionId) { @@ -1966,7 +1988,7 @@ function AppShellContent({ metadata?.workspaceFileReferences, sessionId ? retractedWorkspaceReferencesRef.current[sessionId] : undefined, ); - const liveTurn = sessionId ? liveTurnBySessionRef.current[sessionId] : undefined; + const liveTurn = sessionId ? sessionUiController.liveTurnBySessionRef.current[sessionId] : undefined; const runningTurnIds = sessionId ? sessionsRef.current.find((session) => session.id === sessionId)?.runningTurnIds : undefined; @@ -1988,7 +2010,7 @@ function AppShellContent({ revisionSend && revision && text.trim() === revision.originalText.trim() && - pendingAttachments.length === 0 + !hasPendingContext ) { const actionCopy = getDesktopConversationCopy(uiLocale).actions; toastApi.info(actionCopy.revisionReadyTitle, actionCopy.revisionUnchanged); @@ -1996,7 +2018,7 @@ function AppShellContent({ } if (revisionSend && revision) { const actionCopy = getDesktopConversationCopy(uiLocale).actions; - if (pendingAttachments.length > 0) { + if (hasPendingContext) { toastApi.info(actionCopy.revisionUnavailableTitle, actionCopy.revisionAttachmentsUnsupported); return false; } @@ -2040,9 +2062,9 @@ function AppShellContent({ return false; } if ( - pendingAttachments.length > 0 || - pendingQuotes.length > 0 || - (metadata?.workspaceFileReferences?.length ?? 0) > 0 + hasPendingContext || + pendingQuotes.length || + metadata?.workspaceFileReferences?.length ) { toastApi.info( shellCopy.sideChatContextPendingTitle, @@ -2079,10 +2101,11 @@ function AppShellContent({ } return changed; } - const pending = pendingAttachments.length > 0 ? pendingAttachments : undefined; - const quotes = pendingQuotes.length > 0 ? pendingQuotes : undefined; + const pending = submittableAttachments; + const quotes = pendingQuotes.length ? pendingQuotes : undefined; const ok = await send(swarmCommand.task, pending, { turnOrchestration: { mode: 'swarm', source: 'slash_command' }, + ...directoryOptions, ...(quotes ? { quotes } : {}), ...(metadata?.workspaceFileReferences?.length ? { @@ -2094,8 +2117,11 @@ function AppShellContent({ } : {}), }); - if (ok !== false && pending) clearSubmittedAttachments(pending); - if (ok !== false && quotes) clearQuotes(); + if (ok !== false) { + clearSubmittedContext(pending); + if (quotes) clearQuotes(); + settleNewTaskImageNoticeOwner(sessionId); + } return ok; } if (slashCommand?.kind === 'graph') { @@ -2124,10 +2150,11 @@ function AppShellContent({ } return changed; } - const pending = pendingAttachments.length > 0 ? pendingAttachments : undefined; - const quotes = pendingQuotes.length > 0 ? pendingQuotes : undefined; + const pending = submittableAttachments; + const quotes = pendingQuotes.length ? pendingQuotes : undefined; const ok = await send(graphCommand.task, pending, { turnOrchestration: { mode: 'graph', source: 'slash_command' }, + ...directoryOptions, ...(quotes ? { quotes } : {}), ...(metadata?.workspaceFileReferences?.length ? { @@ -2139,25 +2166,30 @@ function AppShellContent({ } : {}), }); - if (ok !== false && pending) clearSubmittedAttachments(pending); - if (ok !== false && quotes) clearQuotes(); + if (ok !== false) { + clearSubmittedContext(pending); + if (quotes) clearQuotes(); + settleNewTaskImageNoticeOwner(sessionId); + } return ok; } - const pending = pendingAttachments.length > 0 ? pendingAttachments : undefined; + const pending = submittableAttachments; const expectedRevisionDraft = revisionSend ? revisionDraftRef.current : undefined; - const quotes = pendingQuotes.length > 0 ? pendingQuotes : undefined; + const quotes = pendingQuotes.length ? pendingQuotes : undefined; const ok = await send(text, pending, { + ...directoryOptions, ...(quotes ? { quotes } : {}), - ...(workspaceFileReferences.length > 0 + ...(workspaceFileReferences.length ? { workspaceFileReferences } : {}), }); - if (ok !== false && pending) clearSubmittedAttachments(pending); - if (ok !== false && quotes) clearQuotes(); - if (ok !== false && sessionId) { - delete retractedWorkspaceReferencesRef.current[sessionId]; + if (ok !== false) { + clearSubmittedContext(pending); + if (quotes) clearQuotes(); + settleNewTaskImageNoticeOwner(sessionId); + if (sessionId) delete retractedWorkspaceReferencesRef.current[sessionId]; } if (ok !== false && revisionSend) { if (expectedRevisionDraft) { @@ -2183,18 +2215,23 @@ function AppShellContent({ } async function deleteQueuedEntry(entryId: string): Promise { - await runQueueEntryAction((sessionId) => + const messageId = activeMessageQueue?.entries.find((entry) => entry.entryId === entryId)?.messageId; + const sessionId = await runQueueEntryAction((sessionId) => window.maka.sessions.retractQueueEntry(sessionId, entryId).then(() => undefined) ); + if (sessionId && messageId) removeTransientMessage(sessionId, messageId); } // Surfaces the failure, then rethrows so the pending plate can settle its // in-flight action state without guessing with a timer. - async function runQueueEntryAction(action: (sessionId: string) => Promise): Promise { + async function runQueueEntryAction( + action: (sessionId: string) => Promise, + ): Promise { const sessionId = activeIdRef.current; if (!sessionId) return; try { await action(sessionId); + return sessionId; } catch (error) { if (activeIdRef.current === sessionId) { const copy = getDesktopConversationCopy(uiLocale).actions; @@ -2223,10 +2260,8 @@ function AppShellContent({ const stop = createAppShellStopAction({ uiLocale, activeIdRef, - addPendingSessionAction, - clearPendingSessionAction, - setStopPendingBySession, - stopPendingRef, + stopPending: sessionUiController.stopPending, + removeTransientMessage, toastApi, }); @@ -2236,17 +2271,20 @@ function AppShellContent({ reconcilePersistedMessages, settleAssistantStreaming, flushDisplayEvents, + dropDisplayEvents, markDisplayPending, markDisplayReady, } = useStableActions(createAppShellSessionEventHandlers, { uiLocale, activeIdRef, - liveTurnBySessionRef, + liveTurnBySessionRef: sessionUiController.liveTurnBySessionRef, refreshMessages, refreshSessions, - setLiveTurnBySession, - setInteractionBySession, - setMessageQueueBySession, + setLiveTurnBySession: sessionUiController.setLiveTurnBySession, + setInteractionBySession: sessionUiController.setInteractionBySession, + setMessageQueueBySession: sessionUiController.setMessageQueueBySession, + projectQueuedTransientMessages, + removeTransientMessage, displayBatch: sessionDisplayBatch, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, @@ -2292,54 +2330,43 @@ function AppShellContent({ applyE2eFixture, bootstrapSessions, clearPendingTurnActionsForSession: turnActionRegistry.clearForSession, - confirmLiveTurn, + confirmLiveTurn: sessionUiController.confirmLiveTurn, clearSessionRendererState, createSession, handleConnectionEvent, openHelp, openSettings, - pendingPermissionModeChangesRef: permissionModeChangeRegistry.keysRef, - pendingSessionModelChangesRef: sessionModelChangeRegistry.keysRef, - pendingTurnActionTimersRef: turnActionRegistry.timersRef, - pendingTurnActionsRef: turnActionRegistry.keysRef, + clearPendingTurnActions: turnActionRegistry.clearAll, projectPickerPendingRef, projectPickerRequestRef, refreshConnections: refreshConnectionProjections, refreshMemoryActive, refreshMessages, - refreshScheduledTasks, refreshProjects, refreshShellSettings, - refreshSkills, - refreshManagedSkillSources, - refreshBundledSkillCatalog, refreshSessions, rendererMountedRef, setActiveId, setMessages, - setNavSelection, - setSessionEventHealthBySession, + setSessionEventHealthBySession: sessionUiController.setSessionEventHealthBySession, toastApi, }); useAppShellPersistenceEffects({ navigationState, - sessionListCollapsed, - sessionListWidth, - sessionListViewMode: viewMode, themePalette, themePref, }); const [activeEventSeed, setActiveEventSeed] = useState(EMPTY_LIVE_CONTENT_SEED); const activeEventSeedRef = useRef(activeEventSeed); activeEventSeedRef.current = activeEventSeed; - const beginObservationSeed = (sessionId: string): number => { + const beginObservationSeed = (sessionId: string) => { const next = beginLiveContentSeed(activeEventSeedRef.current, sessionId); activeEventSeedRef.current = next; markDisplayPending(sessionId); setActiveEventSeed(next); return next.generation; }; - const completeObservationSeed = (sessionId: string, generation?: number): void => { + const completeObservationSeed = (sessionId: string, generation?: number) => { const current = activeEventSeedRef.current; const expected = generation ?? current.generation; if (current.sessionId !== sessionId || current.generation !== expected) return; @@ -2348,19 +2375,27 @@ function AppShellContent({ const next = completeLiveContentSeed(current, sessionId, expected); activeEventSeedRef.current = next; setActiveEventSeed(next); + const firstSendWaiter = firstSendObservationWaitersRef.current.get(sessionId); + if (firstSendWaiter) { + firstSendObservationWaitersRef.current.delete(sessionId); + globalThis.clearTimeout(firstSendWaiter.timeoutId); + firstSendWaiter.resolve(); + } + void retireCancelledTransientMessages(sessionId); }; useActiveSessionEvents({ uiLocale, activeId, + activeProfileId: activeSession?.profileId, activeIdRef, handleEvent, beginObservationSeed, completeObservationSeed, - setMessageLoadErrorBySession, + setMessageLoadErrorBySession: sessionUiController.setMessageLoadErrorBySession, setMessageLoadPending, setMessages, transcriptRangeRef, - setSessionEventHealthBySession, + setSessionEventHealthBySession: sessionUiController.setSessionEventHealthBySession, toastApi, }); let newestDurablePromptSequence: number | null = null; @@ -2419,7 +2454,7 @@ function AppShellContent({ }) .catch((error) => { if (disposed || activeIdRef.current !== target.sessionId) return; - setMessageLoadErrorBySession((current) => ({ + sessionUiController.setMessageLoadErrorBySession((current) => ({ ...current, [target.sessionId]: localizedShellErrorMessage( error, @@ -2432,7 +2467,10 @@ function AppShellContent({ disposed = true; }; }, [activeId, searchScrollTarget?.nonce]); - useShellRunUpdates({ activeId, setShellRunUpdatesBySession }); + useShellRunUpdates({ + activeId, + setShellRunUpdatesBySession: sessionUiController.setShellRunUpdatesBySession, + }); useSessionEventHealthPolling({ activeId, activeInteraction, @@ -2441,8 +2479,8 @@ function AppShellContent({ hasInFlightLiveTools, refreshMessages, refreshSessions, - sessionEventHealthBySessionRef, - setSessionEventHealthBySession, + sessionEventHealthBySessionRef: sessionUiController.sessionEventHealthBySessionRef, + setSessionEventHealthBySession: sessionUiController.setSessionEventHealthBySession, }); function captureComposerImportOwner(): ComposerImportOwner { return { @@ -2489,34 +2527,6 @@ function AppShellContent({ bootstrapSelectionLease.release(); } - function openNewTaskSurface() { - startNewSession(); - // Only Plan resets: a new task starts out of Plan, in whatever - // orchestration the last one was set to. - setNewChatPlanModeActive(false); - setNavSelection({ section: 'sessions' }); - setSearchScrollTarget(null); - // New-task affordances reset to the empty-state composer; move focus - // there so the user can start typing immediately. - window.requestAnimationFrame(() => composerRef.current?.focus()); - } - - async function createSession() { - openNewTaskSurface(); - } - - async function createSessionInProject(projectId: string) { - if (!newTask.localHost) return; - newTask.selectProject(newTask.localHost, projectId); - openNewTaskSurface(); - } - - function openScheduledTaskForm() { - setNavSelection({ section: 'automations', module: 'scheduled-tasks' }); - closePalette(); - setScheduledTaskCreateRequestNonce((nonce) => nonce + 1); - } - /** * PR-UI-RENDER-2 - single chokepoint for the Markdown internal-URI * router. Receives a typed `MakaUriDest` from the link override in @@ -2606,7 +2616,8 @@ function AppShellContent({ toastApi.error(title, description, undefined, { sessionId }); } - const canStageComposerContext = activeId !== undefined || newTask.target !== undefined; + const canStageComposerContext = + activeId !== undefined || taskEntry.selectors.target !== undefined; const activeMessageLoadError = activeId ? messageLoadErrorBySession[activeId] : undefined; let activeTranscriptRange; @@ -2617,14 +2628,15 @@ function AppShellContent({ } catch { activeTranscriptRange = undefined; } - async function loadTranscriptHistory(target: 'earlier' | 'latest') { + async function loadTranscriptHistory(target: 'earlier' | 'latest', anchorTurnId?: string) { const controller = transcriptRangeRef.current; const sessionId = activeId; - if (!controller || !sessionId || historyLoadPendingSessionId) return; + if (!controller || !sessionId || historyLoadPendingRef.current) return; + historyLoadPendingRef.current = true; setHistoryLoadPendingSessionId(sessionId); try { if (target === 'earlier') { - await controller.loadBefore(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); + await controller.loadBefore(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, anchorTurnId); } else { await controller.loadLatest(); } @@ -2645,6 +2657,7 @@ function AppShellContent({ ), ); } finally { + historyLoadPendingRef.current = false; setHistoryLoadPendingSessionId((current) => current === sessionId ? undefined : current); } } @@ -2664,21 +2677,21 @@ function AppShellContent({ : projectCapabilities.viewClientPath, connections: defaultHostConnections.snapshot.connections, defaultConnection: defaultHostConnections.snapshot.defaultConnection, - dailyReviewBridge, messages, - newTaskProfileId: newTask.selectedProfileId, + newTaskProfileId: taskEntry.selectors.selectedProfileId, settingsOpen, - settingsProfileId: settingsDiagnosticProfileId, + settingsProfileId: settingsRequest.profileId, sessions, themePref, visibleSessions, captureComposerImportOwner, - composerRef, createSession, startModeSession, - isComposerImportOwnerActive, openHelp, - openScheduledTaskForm, + openScheduledTaskCreate: () => { + closePalette(); + moduleHub.commands.openScheduledTaskCreate(); + }, openProjectFolder, openSessionInChat, openSideConversation: () => workbar.commands.openTool('side-chat'), @@ -2687,7 +2700,9 @@ function AppShellContent({ openSkillsFolder, openWorkspaceFolder, refreshConnections: defaultHostConnections.refreshConnections, - saveDailyReviewMarkdown, + copyTodayDailyReview: moduleHub.commands.copyTodayDailyReview, + pasteTodayDailyReview: moduleHub.commands.pasteTodayDailyReview, + saveTodayDailyReview: moduleHub.commands.saveTodayDailyReview, setNavSelection, setPermissionMode, setThemePref, @@ -2704,6 +2719,20 @@ function AppShellContent({ : 'im_hub'; return ( + // Goal state lives below the shell and wakes only its three readers. Composer + // mentions still wrap the frame so one projection serves every composer. + + +
sharedSessionDialog.openSession(activeDesktopSession), + } + } onRenameSession={(name) => { - void sessionRowActionHandlers.renameSession(activeSessionForView.id, name); + void sessionNavigationCommandsRef.current?.renameSession(activeSessionForView.id, name); }} project={ titlebarProjectName @@ -2793,7 +2833,7 @@ function AppShellContent({ parentSession={titlebarParentSession} /> )} - {!VIEWS_WITHOUT_WORKSPACE_ACTIONS.has(agentsView) && ( + {!sharedSessionActive && !VIEWS_WITHOUT_WORKSPACE_ACTIONS.has(agentsView) && ( { - if (width >= SESSION_LIST_EXPANDED_MIN_WIDTH) setSessionListWidth(width); - }} - minWidth={SESSION_LIST_EXPANDED_MIN_WIDTH} - maxWidth={SESSION_LIST_EXPANDED_MAX_WIDTH} - selection={navSelection} - sessions={visibleSessions} - activeId={workHubActive ? undefined : sidebarActiveId} - scheduledTasks={scheduledTasks} + { - setWorkHubActive(false); - setNavSelection(selection); - }} - onSelectSession={sessionListSelectSession} + onSelect={setNavSelection} onOpenSettings={openSettings} - buildStamp={buildStamp} updateReminder={updateReminder} onOpenUpdate={openUpdateDownload} - onNew={() => { - setWorkHubActive(false); - void createSession(); - }} + onNew={() => void createSession()} workHubEntry={workHubEnabled ? { active: workHubActive, label: 'WorkHub', onSelect: openWorkHub, } : undefined} - rowActions={sessionRowActions} projectActions={projectRowActions} - /> + > + {SESSION_RAIL} + } > @@ -2873,93 +2899,44 @@ function AppShellContent({
- {navSelection.section === 'extensions' && navSelection.module === 'skills' ? ( - refreshSkills()} - onRefreshManagedSkillSources={() => refreshManagedSkillSources()} - onOpenSkill={projectCapabilities.viewClientPath - ? (skillId) => openSkill(skillId) - : undefined} - onUseSkill={useSkillInChat} - onOpenSkillsFolder={projectCapabilities.viewClientPath - ? () => openSkillsFolder() - : undefined} - managedSkillSources={managedSkillSources} - onImportManagedSkillSource={projectCapabilities.viewClientPath - ? () => importManagedSkillSource() - : undefined} - onInstallManagedSkill={(sourceId) => installManagedSkill(sourceId)} - bundledSkillCatalog={bundledSkillCatalog} - onRefreshBundledSkillCatalog={() => refreshBundledSkillCatalog()} - onInstallBundledSkill={(id) => installBundledSkill(id)} - onPreviewManagedSkillUpdate={(skillId) => previewManagedSkillUpdate(skillId)} - onUpdateManagedSkill={(skillId, options) => updateManagedSkill(skillId, options)} - onSetSkillEnabled={(skillId, enabled) => setSkillEnabled(skillId, enabled)} - onSetSkillPinned={(skillRef, pinned) => setSkillPinned(skillRef, pinned)} - onDeleteSkill={(skillRef) => deleteSkill(skillRef)} - /> - ) : navSelection.section === 'extensions' && navSelection.module === 'mcp' ? ( - - ) : navSelection.section === 'automations' && navSelection.module === 'scheduled-tasks' ? ( - setScheduledTaskCreateRequestNonce(0)} - keepSystemAwake={ - keepSystemAwakeController.supported - ? keepSystemAwakeController.keepSystemAwake - : undefined - } - onKeepSystemAwakeChange={ - keepSystemAwakeController.supported - ? keepSystemAwakeController.setKeepSystemAwake - : undefined - } - onRefresh={() => - refreshScheduledTasks({ - shouldShowError: isScheduledTasksSurfaceActive, - }) - } - onCreate={(input) => createScheduledTask(input)} - onUpdate={(id, patch) => updateScheduledTask(id, patch)} - onToggle={(id, enabled) => toggleScheduledTask(id, enabled)} - onTriggerNow={(id) => triggerScheduledTaskNow(id)} - onSnooze={(id) => snoozeScheduledTask(id)} - onClearRunHistory={(id) => clearScheduledTaskRunHistory(id)} - onDelete={(id) => deleteScheduledTask(id)} - /> - ) : navSelection.section === 'automations' && navSelection.module === 'daily-review' ? ( - copyDailyReviewMarkdown(input, { shouldShowFeedback: isDailyReviewSurfaceActive })} - onAppendMarkdown={appendDailyReviewMarkdown} - onSaveMarkdown={(input) => saveDailyReviewMarkdown(input, { shouldShowFeedback: isDailyReviewSurfaceActive })} - /> - ) : null} + {workHubEnabled && workHubActive && navSelection.section === 'sessions' ? ( - + workHubCoordinationSessionId ? ( + + ) : ( + workHubCoordinationRetryRef.current()} + /> + ) ) : (
+ + + ); } - -function runtimeHostSessionMeta(session: DesktopSessionSummary): string | undefined { - return session.profileKind === 'remote' ? session.profileName : undefined; -} - -function deriveDesktopSessionGroups( - sessions: readonly DesktopSessionSummary[], - projects: readonly ProjectRecord[], - locale: UiLocale, -): SessionHistoryGroup[] { - const local: DesktopSessionSummary[] = []; - const remote = new Map(); - for (const session of sessions) { - if (session.profileKind !== 'remote') { - local.push(session); - continue; - } - const key = session.profileId; - const group = remote.get(key) ?? { - label: session.profileName, - sessions: [], - }; - group.sessions.push(session); - remote.set(key, group); - } - return [ - ...deriveProjectGroups(local, projects, locale), - ...[...remote].map(([id, group]) => ({ - id: `runtime-host:${id}`, - label: group.label, - sessions: group.sessions, - })), - ]; -} diff --git a/apps/desktop/src/renderer/application/contracts/connection-error-cleaner.ts b/apps/desktop/src/renderer/application/contracts/connection-error-cleaner.ts new file mode 100644 index 0000000000..03672fa0d0 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/connection-error-cleaner.ts @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { parseNoRealConnectionError } from '@maka/core/connection-error-copy'; + +export { parseNoRealConnectionError }; + +export const NO_REAL_CONNECTION_CODE = 'NO_REAL_CONNECTION'; +const NO_REAL_CONNECTION_REASON_RE = /NO_REAL_CONNECTION:([a-z_]+): /; + +export function cleanErrorMessage(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error); + return cleanEventMessage(raw); +} + +function cleanEventMessage(message: string): string { + return message + .replace(/^Error invoking remote method '[^']+': (?:[A-Za-z_$][\w$]*)?Error: /, '') + .replace(NO_REAL_CONNECTION_REASON_RE, '') + .replace(`${NO_REAL_CONNECTION_CODE}: `, ''); +} diff --git a/apps/desktop/src/renderer/application/contracts/session-start-mode.ts b/apps/desktop/src/renderer/application/contracts/session-start-mode.ts new file mode 100644 index 0000000000..dfcc44e660 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/session-start-mode.ts @@ -0,0 +1,20 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export type { SessionStartMode } from '@maka/core/session-start-mode'; diff --git a/apps/desktop/src/renderer/application/contracts/workhub-request-intent.ts b/apps/desktop/src/renderer/application/contracts/workhub-request-intent.ts new file mode 100644 index 0000000000..ce7d1e8993 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/workhub-request-intent.ts @@ -0,0 +1,24 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + readWorkHubRequestIntent, + workHubCorrectionTargetsSession, +} from '@maka/core/workhub-creation-intent'; +export type { WorkHubRequestIntent } from '@maka/core/workhub-creation-intent'; diff --git a/apps/desktop/src/renderer/astryx-theme/maka.css b/apps/desktop/src/renderer/astryx-theme/maka.css index 65b6ce220b..5605ff52e2 100644 --- a/apps/desktop/src/renderer/astryx-theme/maka.css +++ b/apps/desktop/src/renderer/astryx-theme/maka.css @@ -95,7 +95,7 @@ --color-syntax-tag: light-dark(#89001a, #ffaeaa); --color-syntax-attribute: light-dark(#584400, #eec12f); --color-syntax-property: light-dark(#005348, #83dac9); - --color-syntax-punctuation: light-dark(#a3a3a3, #525252); + --color-syntax-punctuation: light-dark(#6e6e6e, #a0a0a0); --color-syntax-background: light-dark(#fafafa, #0a0a0a); --color-background-surface: var(--background); --color-background-body: var(--surface-canvas); @@ -125,9 +125,9 @@ --color-success: light-dark(#007004, #9fe59b); --color-error: light-dark(#a50c25, #ffc6c1); --color-warning: light-dark(#745b00, #fdcf4f); - --color-success-muted: light-dark(#c5e5c0, #84c9803D); - --color-error-muted: light-dark(#facecb, #ff9e973D); - --color-warning-muted: light-dark(#f8da9d, #deb4333D); + --color-success-muted: oklch(from var(--success) l c h / 0.24); + --color-error-muted: oklch(from var(--destructive) l c h / 0.24); + --color-warning-muted: oklch(from var(--warning) l c h / 0.24); --color-border: var(--border); --color-border-emphasized: light-dark(#d4d4d4, #525252); --color-skeleton: light-dark(#ebebeb, #525252); @@ -174,11 +174,11 @@ --color-icon-gray: light-dark(#525252, #a3a3a3); --color-text-gray: light-dark(#262626, #e5e5e5); --radius-none: 0px; - --radius-inner: 0.375rem; - --radius-element: 0.625rem; - --radius-container: 0.75rem; + --radius-inner: var(--radius-control); + --radius-element: var(--radius-surface); + --radius-container: var(--radius-modal); --radius-page: 1.75rem; - --radius-full: 9999px; + --radius-full: var(--radius-pill); --shadow-low: 0 2px 4px light-dark(oklch(0 0 0 / 5%), oklch(0 0 0 / 25%)), 0 4px 8px light-dark(oklch(0 0 0 / 10%), oklch(0 0 0 / 40%)), inset 0 0 0 1px light-dark(transparent, oklch(1 0 0 / 8%)); --shadow-med: 0 2px 4px light-dark(oklch(0 0 0 / 5%), oklch(0 0 0 / 35%)), 0 4px 12px light-dark(oklch(0 0 0 / 10%), oklch(0 0 0 / 50%)), inset 0 0 0 1px light-dark(transparent, oklch(1 0 0 / 12%)); --shadow-high: 0 4px 6px light-dark(oklch(0 0 0 / 10%), oklch(0 0 0 / 50%)), 0 12px 24px light-dark(oklch(0 0 0 / 15%), oklch(0 0 0 / 70%)), inset 0 0 0 1px light-dark(transparent, oklch(1 0 0 / 15%)); @@ -323,7 +323,7 @@ } .astryx-badge.error { - background-color: light-dark(#e33f4a, #ff705d); + background-color: light-dark(#c9303a, #ff705d); color: light-dark(#ffffff, #171717); } @@ -386,7 +386,7 @@ } .astryx-statusdot.error { - background-color: light-dark(#e33f4a, #ff705d); + background-color: light-dark(#c9303a, #ff705d); } .astryx-statusdot.accent { @@ -439,7 +439,7 @@ } .astryx-progressbar.error { - --color-error: #e33f4a; + --color-error: #c9303a; } .astryx-card { @@ -450,12 +450,6 @@ --astryx-section-padding: var(--spacing-3); } - .astryx-app-shell-sidenav.elevated { - border-inline-end-width: 1px; - border-inline-end-style: solid; - border-inline-end-color: var(--color-border); - } - .astryx-text.primary { color: var(--color-text-primary); } .astryx-heading.primary { color: var(--color-text-primary); } diff --git a/apps/desktop/src/renderer/astryx-theme/maka.js b/apps/desktop/src/renderer/astryx-theme/maka.js index 96c9e63ff3..30ec3b2679 100644 --- a/apps/desktop/src/renderer/astryx-theme/maka.js +++ b/apps/desktop/src/renderer/astryx-theme/maka.js @@ -96,7 +96,7 @@ export const makaTheme = { "--color-syntax-tag": "light-dark(#89001a, #ffaeaa)", "--color-syntax-attribute": "light-dark(#584400, #eec12f)", "--color-syntax-property": "light-dark(#005348, #83dac9)", - "--color-syntax-punctuation": "light-dark(#a3a3a3, #525252)", + "--color-syntax-punctuation": "light-dark(#6e6e6e, #a0a0a0)", "--color-syntax-background": "light-dark(#fafafa, #0a0a0a)", "--color-background-surface": "var(--background)", "--color-background-body": "var(--surface-canvas)", @@ -126,9 +126,9 @@ export const makaTheme = { "--color-success": "light-dark(#007004, #9fe59b)", "--color-error": "light-dark(#a50c25, #ffc6c1)", "--color-warning": "light-dark(#745b00, #fdcf4f)", - "--color-success-muted": "light-dark(#c5e5c0, #84c9803D)", - "--color-error-muted": "light-dark(#facecb, #ff9e973D)", - "--color-warning-muted": "light-dark(#f8da9d, #deb4333D)", + "--color-success-muted": "oklch(from var(--success) l c h / 0.24)", + "--color-error-muted": "oklch(from var(--destructive) l c h / 0.24)", + "--color-warning-muted": "oklch(from var(--warning) l c h / 0.24)", "--color-border": "var(--border)", "--color-border-emphasized": "light-dark(#d4d4d4, #525252)", "--color-skeleton": "light-dark(#ebebeb, #525252)", @@ -175,11 +175,11 @@ export const makaTheme = { "--color-icon-gray": "light-dark(#525252, #a3a3a3)", "--color-text-gray": "light-dark(#262626, #e5e5e5)", "--radius-none": "0px", - "--radius-inner": "0.375rem", - "--radius-element": "0.625rem", - "--radius-container": "0.75rem", + "--radius-inner": "var(--radius-control)", + "--radius-element": "var(--radius-surface)", + "--radius-container": "var(--radius-modal)", "--radius-page": "1.75rem", - "--radius-full": "9999px", + "--radius-full": "var(--radius-pill)", "--shadow-low": "0 2px 4px light-dark(oklch(0 0 0 / 5%), oklch(0 0 0 / 25%)), 0 4px 8px light-dark(oklch(0 0 0 / 10%), oklch(0 0 0 / 40%)), inset 0 0 0 1px light-dark(transparent, oklch(1 0 0 / 8%))", "--shadow-med": "0 2px 4px light-dark(oklch(0 0 0 / 5%), oklch(0 0 0 / 35%)), 0 4px 12px light-dark(oklch(0 0 0 / 10%), oklch(0 0 0 / 50%)), inset 0 0 0 1px light-dark(transparent, oklch(1 0 0 / 12%))", "--shadow-high": "0 4px 6px light-dark(oklch(0 0 0 / 10%), oklch(0 0 0 / 50%)), 0 12px 24px light-dark(oklch(0 0 0 / 15%), oklch(0 0 0 / 70%)), inset 0 0 0 1px light-dark(transparent, oklch(1 0 0 / 15%))", @@ -309,7 +309,7 @@ export const makaTheme = { "color": "#171717" }, "variant:error": { - "backgroundColor": "light-dark(#e33f4a, #ff705d)", + "backgroundColor": "light-dark(#c9303a, #ff705d)", "color": "light-dark(#ffffff, #171717)" }, "variant:red": { @@ -361,7 +361,7 @@ export const makaTheme = { "backgroundColor": "#ffce2f" }, "variant:error": { - "backgroundColor": "light-dark(#e33f4a, #ff705d)" + "backgroundColor": "light-dark(#c9303a, #ff705d)" }, "variant:accent": { "backgroundColor": "light-dark(#0074e2, #6d9cfe)" @@ -409,7 +409,7 @@ export const makaTheme = { "--color-warning": "#ffce2f" }, "variant:error": { - "--color-error": "#e33f4a" + "--color-error": "#c9303a" } }, "card": { @@ -421,13 +421,6 @@ export const makaTheme = { "base": { "padding": "var(--spacing-3)" } - }, - "app-shell-sidenav": { - "variant:elevated": { - "borderInlineEndWidth": "1px", - "borderInlineEndStyle": "solid", - "borderInlineEndColor": "var(--color-border)" - } } }, __onDark: { diff --git a/apps/desktop/src/renderer/astryx-theme/makaTheme.ts b/apps/desktop/src/renderer/astryx-theme/makaTheme.ts index 409de272df..5768c93760 100644 --- a/apps/desktop/src/renderer/astryx-theme/makaTheme.ts +++ b/apps/desktop/src/renderer/astryx-theme/makaTheme.ts @@ -32,6 +32,7 @@ // npm run astryx:theme import { defineTheme } from '@astryxdesign/core/theme'; import { neutralTheme, neutralIconRegistry } from '@astryxdesign/theme-neutral'; +import { TYPE_SCALE_BASE_PX } from './type-scale.js'; export const makaTheme = defineTheme({ name: 'maka', @@ -89,7 +90,7 @@ export const makaTheme = defineTheme({ // stacks in the renderer; the product's own --font-sans / --font-mono names // are gone, and maka-tokens.css reads these directly (#1875). typography: { - scale: { base: 14, ratio: 1.125 }, + scale: { base: TYPE_SCALE_BASE_PX, ratio: 1.125 }, // PR-UI-ALIGN-0's "clean native" feel comes from the SYSTEM font (SF Pro // on macOS), not a bundled geometric face; Geist stays a late fallback. body: { @@ -141,6 +142,23 @@ export const makaTheme = defineTheme({ // than an opaque literal, so it is defined RELATIVE to whatever it sits on and // cannot invert in any palette or mode — the failure above is unrepresentable // rather than merely fixed. + // + // The radius rungs below are the same convergence one tier down. Maka and + // Astryx name one ladder twice — control/inner, surface/element, + // modal/container, pill/full — and both vocabularies are live in product + // CSS. They carried independent literals that happened to agree, in + // different units: the product's absolute px against Astryx's rem, which + // the root font-size note above is the reason to keep out. Aliasing makes + // the px side the single authority, so an upstream rung change can no + // longer move one name out from under the other without anyone seeing it. + // + // The three status tints belong here too, for the same reason --color-border + // does: nothing in maka.css re-declares them below the root, so a root + // declaration inside the theme's own @scope is enough. Their fourth sibling + // --color-accent-muted is NOT here — maka.css re-declares the accent pair at + // component level, which beats a root rule in the same layer, so that one + // needs the unlayered bridge in maka-tokens.css. 0.24 is the alpha the + // neutral theme's own pastels already sit at. tokens: { '--color-background-body': 'var(--surface-canvas)', '--color-background-surface': 'var(--background)', @@ -148,34 +166,12 @@ export const makaTheme = defineTheme({ '--color-background-popover': 'var(--background-elevated)', '--color-background-muted': 'var(--muted)', '--color-border': 'var(--border)', - }, - // The column edge itself. Astryx draws a divider only on - // `AppShell variant="section"`, which is a hardcoded `variant === 'section'` - // in AppShell.tsx and pairs the line with no wash at all. Cursor and Codex - // both do the opposite of one-or-the-other: a 1px rule AND a wash, with the - // wash pulled far back (measured off their windows, sidebar→content ΔL 0.025 - // and ~0.010 against Maka's 0.045). The line states the boundary; the wash - // only says the two columns are different material. Authored here rather than - // as a product override so the shell keeps one paint authority — this emits - // `.astryx-app-shell-sidenav { border-inline-end }` inside the theme's own - // @scope. It draws with --color-border, the same token Divider, Card and the - // generated `hr` rule use, so the column edge cannot drift away from every - // other hairline in the app; that token is remapped to the product's --border - // just above. Keyed on the variant, not `base`: the edge belongs to the - // elevated treatment, so a future variant change re-decides it instead of - // inheriting a line that no longer matches the columns. - components: { - 'app-shell-sidenav': { - // No color ease on the column: neither half of the edge changes across the - // collapse any more. The wash is one material in both states (the rail - // wears --color-background-body throughout, see shell-layout.css) and the - // hairline is transparent in both, so a background-color/border-color - // transition here had nothing left to interpolate. - 'variant:elevated': { - borderInlineEndWidth: '1px', - borderInlineEndStyle: 'solid', - borderInlineEndColor: 'var(--color-border)', - }, - }, + '--color-success-muted': 'oklch(from var(--success) l c h / 0.24)', + '--color-warning-muted': 'oklch(from var(--warning) l c h / 0.24)', + '--color-error-muted': 'oklch(from var(--destructive) l c h / 0.24)', + '--radius-inner': 'var(--radius-control)', + '--radius-element': 'var(--radius-surface)', + '--radius-container': 'var(--radius-modal)', + '--radius-full': 'var(--radius-pill)', }, }); diff --git a/apps/desktop/src/renderer/astryx-theme/type-scale.ts b/apps/desktop/src/renderer/astryx-theme/type-scale.ts new file mode 100644 index 0000000000..3872ad9431 --- /dev/null +++ b/apps/desktop/src/renderer/astryx-theme/type-scale.ts @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// The renderer type-scale base, in px. `makaTheme.ts` (THE type-scale +// authority — see the long comment there) feeds this into Astryx's +// `expandTypeScale`, and `theme.ts` divides by it to turn a chosen UI font +// size into a document-root font-size. It lives in this dependency-free +// module rather than in `makaTheme.ts` so the pre-paint bootstrap path +// (`cached-theme-bootstrap.ts` → `theme.ts`) does not pull the full theme +// (icon registry included) into the first frame. +// +// `DEFAULT_UI_FONT_SIZE` in `@maka/core/settings` must equal this value — +// the default means "the type scale as designed, no root scaling". Core +// cannot import renderer code, so the coupling is enforced by +// `font-size-type-scale.test.ts` instead of the type system. +export const TYPE_SCALE_BASE_PX = 14; diff --git a/apps/desktop/src/renderer/cached-theme-bootstrap.ts b/apps/desktop/src/renderer/cached-theme-bootstrap.ts index 0d510fb93f..10d9c473db 100644 --- a/apps/desktop/src/renderer/cached-theme-bootstrap.ts +++ b/apps/desktop/src/renderer/cached-theme-bootstrap.ts @@ -18,6 +18,7 @@ */ import { safeLocalStorageGet } from './browser-storage'; +import { applyCachedFontAppearanceBeforeMount } from './theme'; // Apply the cached theme before React mounts so dark-theme users don't get // a brief light-mode flash while settings.json loads. We persist the resolved @@ -47,4 +48,8 @@ export function applyCachedThemeBeforeMount(): void { if (cachedPalette && cachedPalette !== 'default' && /^[a-z0-9-]{1,32}$/.test(cachedPalette)) { document.documentElement.setAttribute('data-maka-theme', cachedPalette); } + // Restore a cached non-default UI font scale (and seed the terminal size) + // the same way, so the first paint is at the user's chosen size rather than + // 1× snapping once app-shell applies settings.json. + applyCachedFontAppearanceBeforeMount(); } diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index d4a4dcff6a..2c25797fd7 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -18,8 +18,17 @@ */ import { useLayoutEffect, useRef, type ComponentProps, type RefObject } from 'react'; -import { Button, Composer, SandboxBoundaryPrompt, UserQuestionPrompt, Banner } from '@maka/ui'; -import type { ComposerHandle, ComposerInteraction } from '@maka/ui'; +import { + Banner, + Button, + ClientCapabilityPrompt, + Composer, + ComposerGoalProjectionConsumer, + SandboxBoundaryPrompt, + UserQuestionPrompt, +} from '@maka/ui'; +import type { ComposerHandle } from '@maka/ui'; +import { useComposerMentionsContext } from './composer-mentions.js'; import { readNewTaskReloadDraft, readNewTaskReloadIntent, @@ -52,6 +61,11 @@ interface BoundaryUnreadableNotice { onRetry(): void; } +type ComposerInteraction = + | ComponentProps['request'] + | ComponentProps['request'] + | ComponentProps['request']; + /** * The composer region of the chat surface (issue #1043): the composer * interaction slot (permission / user-question prompts) plus the always-mounted @@ -67,7 +81,25 @@ interface BoundaryUnreadableNotice { * `draftKey`, and `stopPending` are derived here from the active-session state * so AppShell only forwards the orchestration callbacks and the session maps. */ -interface ChatComposerRegionProps extends Omit, 'hidden' | 'draftKey' | 'stopPending'> { +interface ChatComposerRegionProps + extends Omit< + ComponentProps, + | 'hidden' + | 'draftKey' + | 'stopPending' + | 'goalActive' + | 'onSetGoal' + | 'allowAttachmentImportWhileStreaming' + // Read from ComposerMentionsProvider, so a catalog reload repaints the + // popups without re-rendering the shell that would otherwise pass them. + | 'mentionSkills' + | 'mentionSkillsUnavailable' + | 'mentionSkillsLoading' + | 'onSearchMentionFiles' + | 'pendingDirectories' + | 'onRemoveDirectory' + | 'onPickDirectory' + > { composerRef: RefObject; active: boolean; onboardingComposerHidden: boolean; @@ -78,11 +110,15 @@ interface ChatComposerRegionProps extends Omit, newTaskSendPending: boolean; stopPendingBySession: Record; respondToSandboxBoundary: ComponentProps['onRespond']; - activeSandboxBoundary: ComponentProps['request'] | undefined; - activeQuestion: ComponentProps['request'] | undefined; + respondToClientCapability: ComponentProps['onRespond']; respondToUserQuestion: ComponentProps['onRespond']; stop: ComponentProps['onStop']; boundaryUnreadableNotice?: BoundaryUnreadableNotice; + directoryComposerProps: Pick< + ComponentProps, + 'pendingDirectories' | 'onRemoveDirectory' | 'onPickDirectory' + >; + directoryPickerEnabled: boolean; } export function ChatComposerRegion({ @@ -95,13 +131,20 @@ export function ChatComposerRegion({ newTaskSendPending, stopPendingBySession, respondToSandboxBoundary, - activeSandboxBoundary, - activeQuestion, + respondToClientCapability, respondToUserQuestion, stop, boundaryUnreadableNotice, + directoryComposerProps, + directoryPickerEnabled, ...composerRest }: ChatComposerRegionProps) { + const mentions = useComposerMentionsContext(); + const activeSandboxBoundary = + activeInteraction?.type === 'sandbox_boundary_request' ? activeInteraction : undefined; + const activeClientCapability = + activeInteraction?.type === 'client_capability_request' ? activeInteraction : undefined; + const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined; const previousNewTaskDraftKey = useRef(newTaskDraftKey); useLayoutEffect(() => { const previous = previousNewTaskDraftKey.current; @@ -191,6 +234,12 @@ export function ChatComposerRegion({ onRespond={respondToSandboxBoundary} /> )} + {activeClientCapability && ( + + )} {activeQuestion && ( )}
-