diff --git a/.github/workflows/cloud-deploy-relay-asia-topology.yml b/.github/workflows/cloud-deploy-relay-asia-topology.yml index 62e5ebb1426..5f594852eb5 100644 --- a/.github/workflows/cloud-deploy-relay-asia-topology.yml +++ b/.github/workflows/cloud-deploy-relay-asia-topology.yml @@ -55,6 +55,8 @@ jobs: CLOUD_SQL_INSTANCE: ${{ inputs.environment == 'production' && 'orca-cloud-auth-db' || 'orca-cloud-staging-auth-db' }} VERIFIED_DEFAULT_MAX_CONNECTIONS_TIER: db-custom-4-15360 VERIFIED_DEFAULT_MAX_CONNECTIONS_DATABASE_VERSION: POSTGRES_17 + # SHOW max_connections on the live instance, 2026-09-16; no flag is set. + VERIFIED_DEFAULT_MAX_CONNECTIONS: '500' TF_BACKEND: ${{ inputs.environment == 'production' && 'backend/production.hcl' || 'backend/staging.hcl' }} TF_VARS: ${{ inputs.environment == 'production' && 'environments/production.tfvars' || 'environments/staging.tfvars' }} TOPOLOGY_WORKLOAD_IDENTITY_PROVIDER: ${{ inputs.environment == 'production' && vars.PRODUCTION_GCP_RELAY_ASIA_TOPOLOGY_WORKLOAD_IDENTITY_PROVIDER || vars.STAGING_GCP_RELAY_ASIA_TOPOLOGY_WORKLOAD_IDENTITY_PROVIDER }} @@ -73,6 +75,7 @@ jobs: case "${TARGET_ENVIRONMENT}:${TARGET_CELL_IDS}" in staging:staging-gce-c4) ;; production:production-gce-c27,production-gce-c28,production-gce-c29) ;; + production:production-gce-c30) ;; *) echo "cell-ids do not match the reviewed environment topology" >&2; exit 1 ;; esac [[ "${TARGET_IMAGE}" =~ ^us-central1-docker\.pkg\.dev/${GCP_PROJECT_ID}/orca-cloud/relay@sha256:[0-9a-f]{64}$ ]] @@ -118,13 +121,13 @@ jobs: live_max="${live_flag}" live_source=explicit-flag else - # The verified production database uses Cloud SQL's 400-connection - # default for this exact shape; fail closed if its shape changes. + # No flag: the ceiling is the tier default measured for this exact + # shape; fail closed if the shape changes. test "$(jq -er '.settings.tier' <<< "${instance}")" = \ "${VERIFIED_DEFAULT_MAX_CONNECTIONS_TIER}" test "$(jq -er '.databaseVersion' <<< "${instance}")" = \ "${VERIFIED_DEFAULT_MAX_CONNECTIONS_DATABASE_VERSION}" - live_max=400 + live_max="${VERIFIED_DEFAULT_MAX_CONNECTIONS}" live_source=verified-shape-default fi test "${live_max}" = "${checked_max}" @@ -166,6 +169,38 @@ jobs: done echo "file=${file}" >> "${GITHUB_OUTPUT}" + - id: live-images + name: Plan every non-target cell at the image it serves + shell: bash + run: | + set -euo pipefail + # The URL map target pulls every cell's template into the plan, and same-cap rolls + # leave committed images behind the served ones; read only templates out of state. + cells="${RUNNER_TEMP}/relay-committed-cells.json" + live="${RUNNER_TEMP}/relay-live-cell-templates.json" + overlay="${RUNNER_TEMP}/relay-asia-live-images.tfvars.json" + committed_plan="${RUNNER_TEMP}/relay-committed-cells.tfplan" + mapfile -t targets < "${{ steps.targets.outputs.file }}" + # Not console: it evaluates every output against state, where a new cell has no MIG yet. + terraform -chdir=infra/terraform plan -input=false -refresh=false -lock=false \ + -var-file="${TF_VARS}" "${targets[@]}" -out="${committed_plan}" > /dev/null + terraform -chdir=infra/terraform show -json "${committed_plan}" \ + | jq -ce '.variables.relay_gce_cells.value | objects' > "${cells}" + terraform -chdir=infra/terraform show -json | jq -ce '[ + .values.root_module.resources[]? + | select(.mode == "managed" and + .type == "google_compute_instance_template" and .name == "relay_gce_cell") + | { index, metadata_startup_script: .values.metadata_startup_script } + ]' > "${live}" + summary="$(node dev/scripts/relay-live-cell-image-overlay.mjs \ + --cells-json "${cells}" --live-templates-json "${live}" \ + --cell-ids "${TARGET_CELL_IDS}" --output "${overlay}")" + echo "file=${overlay}" >> "${GITHUB_OUTPUT}" + { + echo "### Live images held for non-target cells" + echo "- Cells whose committed image differs from the served one: $(jq -r '.drifted | join(", ")' <<< "${summary}")" + } >> "${GITHUB_STEP_SUMMARY}" + - name: Create and validate the saved topology plan id: plan shell: bash @@ -175,7 +210,7 @@ jobs: plan_json="${RUNNER_TEMP}/relay-asia-topology.json" mapfile -t targets < "${{ steps.targets.outputs.file }}" terraform -chdir=infra/terraform plan -input=false -lock-timeout=30s \ - -var-file="${TF_VARS}" \ + -var-file="${TF_VARS}" -var-file="${{ steps.live-images.outputs.file }}" \ "${targets[@]}" -out="${plan}" terraform -chdir=infra/terraform show -json "${plan}" > "${plan_json}" committed="${RUNNER_TEMP}/relay-committed-asia-topology.json" @@ -221,7 +256,7 @@ jobs: plan="${RUNNER_TEMP}/relay-asia-topology-readback.tfplan" plan_json="${RUNNER_TEMP}/relay-asia-topology-readback.json" terraform -chdir=infra/terraform plan -input=false -lock-timeout=30s \ - -var-file="${TF_VARS}" \ + -var-file="${TF_VARS}" -var-file="${{ steps.live-images.outputs.file }}" \ "${targets[@]}" -out="${plan}" terraform -chdir=infra/terraform show -json "${plan}" > "${plan_json}" result="$(node dev/scripts/validate-relay-asia-topology-plan.mjs \ diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml index 46ffba9c027..abe0f327430 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml @@ -273,7 +273,7 @@ jobs: EXPECTED_REGION=us-central1 EXPECTED_DATABASE_POOL_MAX= ;; - c27|c28|c29) + c27|c28|c29|c30) EXPECTED_HARD_CAP=3000 EXPECTED_REGION=asia-east2 EXPECTED_DATABASE_POOL_MAX=16 diff --git a/.github/workflows/cloud-operate-relay-asia-admission.yml b/.github/workflows/cloud-operate-relay-asia-admission.yml index 7abae2f8646..2c7c289da26 100644 --- a/.github/workflows/cloud-operate-relay-asia-admission.yml +++ b/.github/workflows/cloud-operate-relay-asia-admission.yml @@ -39,11 +39,11 @@ on: required: false type: string evidence-run-id: - description: Successful staging or C27 evidence workflow run ID; required for production promotion + description: Staging evidence run ID for C27, C27 canary run ID for C28/C29; C30 takes none and proves itself by its own canary required: false type: string evidence-run-attempt: - description: Exact evidence workflow run attempt; required for production promotion + description: Exact evidence workflow run attempt; required with an evidence run ID required: false type: string confirmation: @@ -104,6 +104,8 @@ jobs: test -n "${DEPLOY_SERVICE_ACCOUNT}" [[ "${IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] evidence_kind=none + canary_cell=none + artifact_name=none if test "${OPERATION_MODE}" = inspect; then test -z "${EXPECTED_SELECTOR_GENERATION}" test -z "${SELECTOR_ATTEMPT_ID}" @@ -143,28 +145,42 @@ jobs: esac fi if test "${TARGET_ENVIRONMENT}:${OPERATION_MODE}" = production:promote; then - [[ "${EVIDENCE_RUN_ID}" =~ ^[1-9][0-9]*$ ]] - [[ "${EVIDENCE_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]] case "${TARGET_CELL_IDS}" in production-gce-c27) - test "${#SELECTOR_ATTEMPT_ID}" -le 119 evidence_kind=staging artifact_name="relay-asia-staging-${EVIDENCE_RUN_ID}-${EVIDENCE_RUN_ATTEMPT}" + canary_cell=production-gce-c27 ;; production-gce-c28,production-gce-c29) evidence_kind=c27 artifact_name="relay-asia-c27-canary-${EVIDENCE_RUN_ID}-${EVIDENCE_RUN_ATTEMPT}" ;; + production-gce-c30) + # No earlier proof binds C30's generation; its own canary below rolls it back on failure. + canary_cell=production-gce-c30 + ;; *) echo "production promotion wave is not reviewed" >&2; exit 1 ;; esac - else + fi + if test "${evidence_kind}" = none; then test -z "${EVIDENCE_RUN_ID}" test -z "${EVIDENCE_RUN_ATTEMPT}" - artifact_name=none + else + [[ "${EVIDENCE_RUN_ID}" =~ ^[1-9][0-9]*$ ]] + [[ "${EVIDENCE_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]] + fi + canary=false + if test "${canary_cell}" != none; then + canary=true + # Leaves room for the rollback attempt's -rollback suffix within 128 characters. + test "${#SELECTOR_ATTEMPT_ID}" -le 119 fi { echo "evidence_kind=${evidence_kind}" echo "artifact_name=${artifact_name}" + echo "canary=${canary}" + echo "canary_cell=${canary_cell}" + echo "canary_hostname=${canary_cell##*-}" } >> "${GITHUB_OUTPUT}" - uses: actions/setup-node@v4 @@ -174,14 +190,14 @@ jobs: - uses: pnpm/action-setup@v4 with: package_json_file: cloud/package.json - if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }} + if: ${{ steps.inputs.outputs.canary == 'true' }} - - name: Install exact C27 canary dependencies - if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }} + - name: Install exact canary dependencies + if: ${{ steps.inputs.outputs.canary == 'true' }} run: pnpm install --frozen-lockfile - - name: Build the C27 canary Relay contract - if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }} + - name: Build the canary Relay contract + if: ${{ steps.inputs.outputs.canary == 'true' }} run: pnpm --filter @orca-cloud/relay-contract build - name: Download immutable rollout evidence @@ -249,7 +265,7 @@ jobs: with: bucket: ${{ inputs.environment == 'production' && 'onorca-cloud-terraform-state' || 'onorca-cloud-staging-terraform-state' }} object: ${{ inputs.environment == 'production' && 'terraform/state/cloud-sql-rollout/production.lock' || 'terraform/state/cloud-sql-rollout/staging.lock' }} - if: ${{ inputs.mode == 'configure' || (inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27') }} + if: ${{ inputs.mode == 'configure' || steps.inputs.outputs.canary == 'true' }} - uses: hashicorp/setup-terraform@v3 if: ${{ inputs.mode == 'configure' }} @@ -292,33 +308,45 @@ jobs: fi } >> "${GITHUB_STEP_SUMMARY}" - - name: Verify C27 state and start the timed canary - id: c27-start - if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }} + - name: Verify the canary cell state and start the timed canary + id: canary-start + if: ${{ steps.inputs.outputs.canary == 'true' }} env: ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.auth.outputs.id_token }} + CANARY_CELL: ${{ steps.inputs.outputs.canary_cell }} shell: bash run: | set -euo pipefail + # C30's launch cells were checked general by the promotion; verify reads only C30's digest. + case "${CANARY_CELL}" in + production-gce-c27) + verify_cells=production-gce-c27,production-gce-c28,production-gce-c29 + expected_states='{"production-gce-c27":"general","production-gce-c28":"migration-only","production-gce-c29":"migration-only"}' + ;; + production-gce-c30) + verify_cells=production-gce-c30 + expected_states='{"production-gce-c30":"general"}' + ;; + *) exit 1 ;; + esac result="$(node dev/scripts/operate-relay-asia-admission.mjs \ --environment production \ --mode verify \ - --cell-ids production-gce-c27,production-gce-c28,production-gce-c29 \ + --cell-ids "${verify_cells}" \ --expected-generation "${{ steps.admission-operation.outputs.generation }}" \ --image-digest "${IMAGE_DIGEST}")" - test "$(jq -r '.states["production-gce-c27"]' <<< "${result}")" = general - test "$(jq -r '.states["production-gce-c28"]' <<< "${result}")" = migration-only - test "$(jq -r '.states["production-gce-c29"]' <<< "${result}")" = migration-only + test "$(jq -cS '.states' <<< "${result}")" = "$(jq -cS '.' <<< "${expected_states}")" + echo "verify_cells=${verify_cells}" >> "${GITHUB_OUTPUT}" echo "started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}" - - name: Run a real five-minute C27 control and splice canary - id: c27-load - if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }} + - name: Run a real five-minute canary control and splice + id: canary-load + if: ${{ steps.inputs.outputs.canary == 'true' }} shell: bash run: | set -euo pipefail - log="${RUNNER_TEMP}/relay-asia-c27-load.jsonl" - report="${RUNNER_TEMP}/relay-asia-c27-load.json" + log="${RUNNER_TEMP}/relay-asia-canary-load.jsonl" + report="${RUNNER_TEMP}/relay-asia-canary-load.json" node dev/scripts/load-relay-controls.mjs \ --director-origin "${DIRECTOR_ORIGIN}" \ --auth-origin "${AUTH_ORIGIN}" \ @@ -335,31 +363,34 @@ jobs: echo "ended_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}" - name: Collect regional, Relay SQL, and Cloud SQL canary evidence - if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }} + if: ${{ steps.inputs.outputs.canary == 'true' }} env: ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.auth.outputs.id_token }} - CANARY_STARTED_AT: ${{ steps.c27-start.outputs.started_at }} + CANARY_CELL: ${{ steps.inputs.outputs.canary_cell }} + CANARY_STARTED_AT: ${{ steps.canary-start.outputs.started_at }} + CANARY_VERIFY_CELLS: ${{ steps.canary-start.outputs.verify_cells }} shell: bash run: | set -euo pipefail result="$(node dev/scripts/operate-relay-asia-admission.mjs \ --environment production \ --mode verify \ - --cell-ids production-gce-c27,production-gce-c28,production-gce-c29 \ + --cell-ids "${CANARY_VERIFY_CELLS}" \ --expected-generation "${{ steps.admission-operation.outputs.generation }}" \ --image-digest "${IMAGE_DIGEST}")" - test "$(jq -r '.states["production-gce-c27"]' <<< "${result}")" = general - ended_at="${{ steps.c27-load.outputs.ended_at }}" + test "$(jq -r --arg cell "${CANARY_CELL}" '.states[$cell]' <<< "${result}")" = general + ended_at="${{ steps.canary-load.outputs.ended_at }}" sleep 60 output="${RUNNER_TEMP}/relay-asia-output-evidence" - logs="${RUNNER_TEMP}/relay-asia-c27-runtime-metrics.json" + logs="${RUNNER_TEMP}/relay-asia-canary-runtime-metrics.json" mkdir -p "${output}" gcloud logging read \ "timestamp>=\"${CANARY_STARTED_AT}\" AND timestamp<=\"${ended_at}\" AND jsonPayload.event=\"orca_relay_runtime_metrics\"" \ --project "${GCP_PROJECT_ID}" \ --limit 20000 \ --format json > "${logs}" - node dev/scripts/relay-asia-rollout-evidence.mjs create-c27 \ + node dev/scripts/relay-asia-rollout-evidence.mjs create-canary \ + --cell-id "${CANARY_CELL}" \ --repository "${GITHUB_REPOSITORY}" \ --run-id "${GITHUB_RUN_ID}" \ --run-attempt "${GITHUB_RUN_ATTEMPT}" \ @@ -368,18 +399,18 @@ jobs: --selector-generation "${{ steps.admission-operation.outputs.generation }}" \ --started-at "${CANARY_STARTED_AT}" \ --ended-at "${ended_at}" \ - --load-report "${RUNNER_TEMP}/relay-asia-c27-load.json" \ + --load-report "${RUNNER_TEMP}/relay-asia-canary-load.json" \ --logs-json "${logs}" \ --output "${output}/evidence.json" jq -r '.metrics | to_entries[] | "- \(.key): \(.value)"' \ "${output}/evidence.json" >> "${GITHUB_STEP_SUMMARY}" - - name: Upload immutable C27 canary evidence - id: c27-evidence-upload - if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }} + - name: Upload immutable canary evidence + id: canary-evidence-upload + if: ${{ steps.inputs.outputs.canary == 'true' }} uses: actions/upload-artifact@v4 with: - name: relay-asia-c27-canary-${{ github.run_id }}-${{ github.run_attempt }} + name: relay-asia-${{ steps.inputs.outputs.canary_hostname }}-canary-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/relay-asia-output-evidence/evidence.json if-no-files-found: error retention-days: 7 @@ -393,17 +424,18 @@ jobs: if-no-files-found: error retention-days: 7 - - name: Return an unproven C27 canary to migration-only - if: ${{ always() && inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' && steps.admission-operation.outcome != 'skipped' && steps.c27-evidence-upload.outcome != 'success' }} + - name: Return an unproven canary cell to migration-only + if: ${{ always() && steps.inputs.outputs.canary == 'true' && steps.admission-operation.outcome != 'skipped' && steps.canary-evidence-upload.outcome != 'success' }} env: ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.auth.outputs.id_token }} + CANARY_CELL: ${{ steps.inputs.outputs.canary_cell }} shell: bash run: | set -euo pipefail promoted="$(node dev/scripts/operate-relay-asia-admission.mjs \ --environment production \ --mode recover-promotion \ - --cell-ids production-gce-c27 \ + --cell-ids "${CANARY_CELL}" \ --expected-generation "${EXPECTED_SELECTOR_GENERATION}" \ --attempt-id "${SELECTOR_ATTEMPT_ID}" \ --image-digest "${IMAGE_DIGEST}")" @@ -412,11 +444,11 @@ jobs: result="$(node dev/scripts/operate-relay-asia-admission.mjs \ --environment production \ --mode rollback \ - --cell-ids production-gce-c27 \ + --cell-ids "${CANARY_CELL}" \ --expected-generation "${promoted_generation}" \ --attempt-id "${SELECTOR_ATTEMPT_ID}-rollback" \ --image-digest "${IMAGE_DIGEST}")" - test "$(jq -r '.states["production-gce-c27"]' <<< "${result}")" = migration-only + test "$(jq -r --arg cell "${CANARY_CELL}" '.states[$cell]' <<< "${result}")" = migration-only - name: Require registered migration-only cells before director configuration if: ${{ inputs.mode == 'configure' }} diff --git a/README.md b/README.md index db7bd4a80c8..89b9552f7b8 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. Grok logo Grok   Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   + Muse logo Muse   OpenCode logo OpenCode   MiMo Code logo MiMo Code   Amp logo Amp   @@ -203,6 +204,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. Mistral Vibe logo Mistral Vibe   Qwen Code logo Qwen Code   Rovo Dev logo Rovo Dev   + Muse logo Muse   + any CLI agent

diff --git a/cloud/dev/scripts/load-relay-controls.mjs b/cloud/dev/scripts/load-relay-controls.mjs index 32cd1858dd6..e78ebdb4b2b 100644 --- a/cloud/dev/scripts/load-relay-controls.mjs +++ b/cloud/dev/scripts/load-relay-controls.mjs @@ -113,6 +113,8 @@ function report(state, final = false) { ...readerQueueEvidence.map(({ increaseBytes }) => increaseBytes) ), readerClosesByCode: state.readerClosesByCode, + // Every cell a control connected to, so a canary can prove its load reached the target. + assignedCellOrigins: [...state.assignedCellOrigins].sort(), controlHeadroom: Math.max(0, state.controls - state.active.size), generatorRssMiB: Number(rssMiB.toFixed(1)), generatorPeakRssMiB: Number(state.generatorPeakRssMiB.toFixed(1)), @@ -241,6 +243,7 @@ const state = { wedgedReaderSplicesClosed: 0, readerEvidence: null, readerClosesByCode: {}, + assignedCellOrigins: new Set(), generatorBaselineRssMiB, generatorBaselineCpu, generatorPeakRssMiB: generatorBaselineRssMiB, @@ -299,6 +302,8 @@ function observe(type, detail) { if (type === 'connected') { state.active.add(detail.index) state.connected++ + const cellOrigin = peers.get(detail.index)?.assignedCellUrl() + if (cellOrigin) state.assignedCellOrigins.add(cellOrigin) state.peakActive = Math.max(state.peakActive, state.active.size) recordSteadyMinimum() } else if (type === 'closed') { diff --git a/cloud/dev/scripts/operate-relay-asia-admission.mjs b/cloud/dev/scripts/operate-relay-asia-admission.mjs index 45322bbe620..b02b7c04cda 100644 --- a/cloud/dev/scripts/operate-relay-asia-admission.mjs +++ b/cloud/dev/scripts/operate-relay-asia-admission.mjs @@ -12,16 +12,30 @@ const SHAPES = { staging: { directorOrigin: 'https://relay-staging.onorca.dev', domain: 'relay-staging.onorca.dev', - allCells: ['staging-gce-c4'] + allCells: ['staging-gce-c4'], + registrationWaves: [['staging-gce-c4']], + promotionWaves: [['staging-gce-c4']] }, production: { directorOrigin: 'https://relay.onorca.dev', domain: 'relay.onorca.dev', - allCells: ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'] + allCells: ['production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30'], + // The launch set was registered together; each later cell registers alone beside it. + registrationWaves: [ + ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'], + ['production-gce-c30'] + ], + promotionWaves: [ + ['production-gce-c27'], + ['production-gce-c28', 'production-gce-c29'], + ['production-gce-c30'] + ] } } -function parseArguments(argv) { +const PRODUCTION_CANARY_CELL = 'production-gce-c27' + +export function parseRelayAsiaAdmissionArguments(argv) { const values = {} for (let index = 0; index < argv.length; index += 2) { const key = argv[index] @@ -58,13 +72,17 @@ function parseArguments(argv) { throw new Error('--cell-ids are invalid') } const exact = (expected) => JSON.stringify([...cells].sort()) === JSON.stringify([...expected].sort()) + const [launchWave] = shape.registrationWaves if ( - (['inspect', 'initialize', 'register', 'registered', 'verify'].includes(values.mode) && - !exact(shape.allCells)) || - (['promote', 'recover-promotion'].includes(values.mode) && values.environment === 'production' && - !exact(['production-gce-c27']) && !exact(['production-gce-c28', 'production-gce-c29'])) || - (['promote', 'recover-promotion'].includes(values.mode) && values.environment === 'staging' && !exact(shape.allCells)) || - (values.mode === 'rollback' && cells.length === 0) + (['inspect', 'verify'].includes(values.mode) && + !exact(shape.allCells) && !shape.registrationWaves.some(exact)) || + // Generation zero predates every later cell, so the boundary freezes only the launch set. + (values.mode === 'initialize' && !exact(launchWave)) || + (['register', 'registered'].includes(values.mode) && !shape.registrationWaves.some(exact)) || + (['promote', 'recover-promotion'].includes(values.mode) && !shape.promotionWaves.some(exact)) || + // Rollback takes any reviewed wave or the whole set, never a mixed partial set. + (values.mode === 'rollback' && !exact(shape.allCells) && + ![...shape.registrationWaves, ...shape.promotionWaves].some(exact)) ) throw new Error('--cell-ids do not match the reviewed admission wave') const attemptId = values['attempt-id'] if (!['inspect', 'verify', 'registered'].includes(values.mode) && @@ -418,11 +436,19 @@ export async function operateRelayAsiaAdmission(config, dependencies = {}) { if ( config.mode === 'promote' && config.environment === 'production' && - config.cells.includes('production-gce-c28') && - selectorCellState(current.selector, 'production-gce-c27') !== 'general' + !config.cells.includes(PRODUCTION_CANARY_CELL) && + selectorCellState(current.selector, PRODUCTION_CANARY_CELL) !== 'general' ) { throw new Error('Asia expansion requires the C27 canary to be general') } + const [launchWave] = shape.registrationWaves + if ( + config.mode === 'promote' && + !config.cells.some((cellId) => launchWave.includes(cellId)) && + launchWave.some((cellId) => selectorCellState(current.selector, cellId) !== 'general') + ) { + throw new Error('a later Asia cell requires every launch cell to be general') + } const result = await applyExactAdmissionSelector( selectorPost, membershipWithStates(current.selector, Object.fromEntries( @@ -434,7 +460,7 @@ export async function operateRelayAsiaAdmission(config, dependencies = {}) { } if (process.argv[1] === fileURLToPath(import.meta.url)) { - const config = parseArguments(process.argv.slice(2)) + const config = parseRelayAsiaAdmissionArguments(process.argv.slice(2)) if (!config.token) throw new Error('ORCA_RELAY_ADMIN_ID_TOKEN is required') console.log(JSON.stringify(await operateRelayAsiaAdmission(config))) } diff --git a/cloud/dev/scripts/operate-relay-asia-admission.test.mjs b/cloud/dev/scripts/operate-relay-asia-admission.test.mjs index 7f23bb1f8e6..0a84a3ca20d 100644 --- a/cloud/dev/scripts/operate-relay-asia-admission.test.mjs +++ b/cloud/dev/scripts/operate-relay-asia-admission.test.mjs @@ -1,13 +1,16 @@ import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import { test } from 'node:test' -import { operateRelayAsiaAdmission } from './operate-relay-asia-admission.mjs' +import { + operateRelayAsiaAdmission, + parseRelayAsiaAdmissionArguments +} from './operate-relay-asia-admission.mjs' const digest = `sha256:${'a'.repeat(64)}` const membershipDigest = (membership) => createHash('sha256').update(JSON.stringify(membership)).digest('hex') -function harness(initialSelector) { +function harness(initialSelector, runtimeDigests = {}) { const initialMembership = structuredClone(initialSelector.membership) let selector = structuredClone(initialSelector) const intents = new Map() @@ -23,7 +26,7 @@ function harness(initialSelector) { cellId: `production-gce-${cell}`, cellUrl: parsed.origin, region: 'asia-east2', - imageDigest: digest, + imageDigest: runtimeDigests[`production-gce-${cell}`] ?? digest, draining: false, connectionCapacity: { hardCap: 3_000, unobservedBound: 60 } } @@ -510,3 +513,136 @@ test('requires the C27 canary before promoting C28 and C29', async () => { imageDigest: digest, attemptId: 'asia_wave_before_canary', token: 'not-logged' }, subject), /C27 canary/) }) + +const launchCells = ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'] + +function admissionArguments(environment, mode, cellIds) { + return [ + '--environment', environment, '--mode', mode, '--cell-ids', cellIds, + '--image-digest', digest, '--expected-generation', '9', '--attempt-id', 'asia_wave_9' + ] +} + +test('accepts only reviewed Asia admission waves', () => { + const accepted = [ + ['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29'], + ['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'], + ['inspect', 'production-gce-c30'], + ['verify', 'production-gce-c27,production-gce-c28,production-gce-c29'], + ['verify', 'production-gce-c30'], + ['initialize', 'production-gce-c27,production-gce-c28,production-gce-c29'], + ['register', 'production-gce-c27,production-gce-c28,production-gce-c29'], + ['register', 'production-gce-c30'], + ['registered', 'production-gce-c30'], + ['promote', 'production-gce-c27'], + ['promote', 'production-gce-c28,production-gce-c29'], + ['promote', 'production-gce-c30'], + ['recover-promotion', 'production-gce-c30'], + ['rollback', 'production-gce-c27'], + ['rollback', 'production-gce-c28,production-gce-c29'], + ['rollback', 'production-gce-c30'], + ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29'], + ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'] + ] + for (const [mode, cellIds] of accepted) { + assert.deepEqual( + parseRelayAsiaAdmissionArguments(admissionArguments('production', mode, cellIds)).cells, + cellIds.split(','), + `${mode} ${cellIds}` + ) + } + const rejected = [ + ['initialize', 'production-gce-c30'], + ['initialize', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'], + ['register', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'], + ['register', 'production-gce-c29,production-gce-c30'], + ['registered', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'], + ['verify', 'production-gce-c27,production-gce-c30'], + ['promote', 'production-gce-c27,production-gce-c30'], + ['promote', 'production-gce-c28,production-gce-c29,production-gce-c30'], + ['promote', 'production-gce-c31'], + ['rollback', 'production-gce-c27,production-gce-c30'], + ['rollback', 'production-gce-c28,production-gce-c29,production-gce-c30'], + ['rollback', 'production-gce-c29'], + ['register', 'staging-gce-c4'] + ] + for (const [mode, cellIds] of rejected) { + assert.throws( + () => parseRelayAsiaAdmissionArguments(admissionArguments('production', mode, cellIds)), + /--cell-ids/, + `${mode} ${cellIds}` + ) + } + assert.deepEqual( + parseRelayAsiaAdmissionArguments(admissionArguments('staging', 'promote', 'staging-gce-c4')).cells, + ['staging-gce-c4'] + ) + assert.throws( + () => parseRelayAsiaAdmissionArguments(admissionArguments('staging', 'promote', 'production-gce-c30')), + /--cell-ids are invalid/ + ) +}) + +test('registers C30 alone beside the general launch cells', async () => { + const subject = harness({ + generation: 9, + membership: { existingOnly: [], migrationOnly: [], general: [...launchCells] } + }) + const result = await operateRelayAsiaAdmission({ + environment: 'production', mode: 'register', cells: ['production-gce-c30'], + expectedGeneration: 9, imageDigest: digest, attemptId: 'asia_register_c30', token: 'not-logged' + }, subject) + const request = subject.requests.find(({ path }) => path.endsWith('/add-migration-cells')) + assert.deepEqual(request.body.cells, [{ + cellId: 'production-gce-c30', cellUrl: 'https://c30.relay.onorca.dev', region: 'asia-east2', + capacityRequests: 6_000, connectionHardCap: 3_000, connectionUnobservedBound: 60 + }]) + assert.deepEqual(result.states, { 'production-gce-c30': 'migration-only' }) + assert.deepEqual(subject.selector().membership.general, launchCells) +}) + +test('requires the C27 canary to be general before promoting C30', async () => { + const selector = (general) => ({ + generation: 10, + membership: { + existingOnly: [], + migrationOnly: ['production-gce-c30', ...launchCells.filter((cell) => !general.includes(cell))].sort(), + general + } + }) + const config = { + environment: 'production', mode: 'promote', cells: ['production-gce-c30'], + expectedGeneration: 10, imageDigest: digest, attemptId: 'asia_promote_c30', token: 'not-logged' + } + await assert.rejects( + operateRelayAsiaAdmission(config, harness(selector(['production-gce-c28', 'production-gce-c29']))), + /C27 canary/ + ) + await assert.rejects( + operateRelayAsiaAdmission(config, harness(selector(['production-gce-c27', 'production-gce-c29']))), + /every launch cell to be general/ + ) + const subject = harness(selector([...launchCells])) + const result = await operateRelayAsiaAdmission(config, subject) + assert.deepEqual(result.states, { 'production-gce-c30': 'general' }) + assert.equal(subject.requests.filter(({ path }) => path === '/v1/admin/cell-status').length, 1) +}) + +test('promotes C30 on its own digest while the launch cells serve another', async () => { + const c30Digest = `sha256:${'b'.repeat(64)}` + const selector = { + generation: 10, + membership: { existingOnly: [], migrationOnly: ['production-gce-c30'], general: [...launchCells] } + } + const config = { + environment: 'production', mode: 'promote', cells: ['production-gce-c30'], + expectedGeneration: 10, imageDigest: c30Digest, attemptId: 'asia_promote_c30', token: 'not-logged' + } + const digests = { 'production-gce-c30': c30Digest } + const result = await operateRelayAsiaAdmission(config, harness(selector, digests)) + assert.deepEqual(result.states, { 'production-gce-c30': 'general' }) + await assert.rejects( + operateRelayAsiaAdmission({ ...config, imageDigest: digest }, harness(selector, digests)), + /production-gce-c30 runtime does not match/ + ) +}) diff --git a/cloud/dev/scripts/prepare-relay-asia-director-cells.mjs b/cloud/dev/scripts/prepare-relay-asia-director-cells.mjs index cd39a83c549..5f4cd599619 100644 --- a/cloud/dev/scripts/prepare-relay-asia-director-cells.mjs +++ b/cloud/dev/scripts/prepare-relay-asia-director-cells.mjs @@ -15,6 +15,9 @@ function argumentsFrom(argv) { return values } +// Production Asia pools sit 176 ms from Cloud SQL and run at 16; staging C4 stays at 10. +const ASIA_DATABASE_POOL_MAX = { production: 16, staging: 10 } + export function prepareRelayAsiaDirectorCells({ currentCells, topology, cellIds, imageDigest }) { if (!Array.isArray(currentCells) || !topology || Array.isArray(topology)) { throw new Error('director inputs are invalid') @@ -36,7 +39,7 @@ export function prepareRelayAsiaDirectorCells({ currentCells, topology, cellIds, !cell || cell.region !== 'asia-east2' || cell.capacity_requests !== 6_000 || - cell.database_pool_max !== 10 || + cell.database_pool_max !== ASIA_DATABASE_POOL_MAX[cellId.split('-')[0]] || cell.connection_hard_cap !== 3_000 || cell.connection_unobserved_bound !== 60 || cell.initially_enabled !== false || diff --git a/cloud/dev/scripts/prepare-relay-asia-director-cells.test.mjs b/cloud/dev/scripts/prepare-relay-asia-director-cells.test.mjs index 9a223725006..da0f4232fcd 100644 --- a/cloud/dev/scripts/prepare-relay-asia-director-cells.test.mjs +++ b/cloud/dev/scripts/prepare-relay-asia-director-cells.test.mjs @@ -5,7 +5,7 @@ import { prepareRelayAsiaDirectorCells } from './prepare-relay-asia-director-cel const digest = `sha256:${'a'.repeat(64)}` const topologyCell = (ordinal, zone) => ({ origin: `https://c${ordinal}.relay.onorca.dev`, region: 'asia-east2', zone, - capacity_requests: 6_000, database_pool_max: 10, + capacity_requests: 6_000, database_pool_max: 16, connection_hard_cap: 3_000, connection_unobserved_bound: 60, initially_enabled: false, image: `us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay@${digest}` @@ -50,6 +50,69 @@ test('is idempotent for an exact existing Asia cell and rejects director drift', }), /director configuration differs/) }) +test('appends C30 after the configured launch cells without touching them', () => { + const launch = prepareRelayAsiaDirectorCells({ + currentCells: [], + topology: { + 'production-gce-c27': topologyCell(27, 'asia-east2-a'), + 'production-gce-c28': topologyCell(28, 'asia-east2-b'), + 'production-gce-c29': topologyCell(29, 'asia-east2-c') + }, + cellIds: 'production-gce-c27,production-gce-c28,production-gce-c29', + imageDigest: digest + }) + const result = prepareRelayAsiaDirectorCells({ + currentCells: launch, + topology: { 'production-gce-c30': topologyCell(30, 'asia-east2-a') }, + cellIds: 'production-gce-c30', + imageDigest: digest + }) + assert.deepEqual(result.slice(0, 3), launch) + assert.deepEqual(result[3], { + id: 'production-gce-c30', url: 'https://c30.relay.onorca.dev', capacityRequests: 6_000, + region: 'asia-east2', initiallyEnabled: false, connectionHardCap: 3_000, + connectionUnobservedBound: 60 + }) +}) + +test('checks C30 against its own digest, not the launch cells\' digest', () => { + const c30Digest = `sha256:${'b'.repeat(64)}` + const c30 = { + ...topologyCell(30, 'asia-east2-a'), + image: `us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay@${c30Digest}` + } + const launch = [27, 28, 29].map((ordinal) => ({ + id: `production-gce-c${ordinal}`, url: `https://c${ordinal}.relay.onorca.dev`, + capacityRequests: 6_000, region: 'asia-east2', initiallyEnabled: false, + connectionHardCap: 3_000, connectionUnobservedBound: 60 + })) + assert.equal(prepareRelayAsiaDirectorCells({ + currentCells: launch, topology: { 'production-gce-c30': c30 }, + cellIds: 'production-gce-c30', imageDigest: c30Digest + }).length, 4) + assert.throws(() => prepareRelayAsiaDirectorCells({ + currentCells: launch, topology: { 'production-gce-c30': c30 }, + cellIds: 'production-gce-c30', imageDigest: digest + }), /does not match/) +}) + +test('pins the Asia pool per environment', () => { + const staging = { ...topologyCell(4, 'asia-east2-a'), database_pool_max: 10 } + assert.equal(prepareRelayAsiaDirectorCells({ + currentCells: [], topology: { 'staging-gce-c4': staging }, + cellIds: 'staging-gce-c4', imageDigest: digest + }).length, 1) + assert.throws(() => prepareRelayAsiaDirectorCells({ + currentCells: [], topology: { 'staging-gce-c4': { ...staging, database_pool_max: 16 } }, + cellIds: 'staging-gce-c4', imageDigest: digest + }), /does not match/) + assert.throws(() => prepareRelayAsiaDirectorCells({ + currentCells: [], + topology: { 'production-gce-c30': { ...topologyCell(30, 'asia-east2-a'), database_pool_max: 10 } }, + cellIds: 'production-gce-c30', imageDigest: digest + }), /does not match/) +}) + test('rejects a mismatching topology state output', () => { const wrong = topologyCell(27, 'asia-east2-a') wrong.database_pool_max = 20 diff --git a/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs b/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs index db76a83ede0..1199b3df377 100644 --- a/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs +++ b/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs @@ -3,14 +3,26 @@ import { fileURLToPath } from 'node:url' const BOOT_IMAGE = 'https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21' const SHAPES = { - staging: { project: 'onorca-cloud-staging', cells: { 'staging-gce-c4': 'asia-east2-a' } }, + staging: { + project: 'onorca-cloud-staging', + databasePoolMax: 10, + cells: { 'staging-gce-c4': 'asia-east2-a' }, + waves: [['staging-gce-c4']] + }, production: { project: 'onorca-cloud', + databasePoolMax: 16, cells: { 'production-gce-c27': 'asia-east2-a', 'production-gce-c28': 'asia-east2-b', - 'production-gce-c29': 'asia-east2-c' - } + 'production-gce-c29': 'asia-east2-c', + 'production-gce-c30': 'asia-east2-a' + }, + // The launch set, then each later additive cell; a plan targets one wave, never live cells. + waves: [ + ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'], + ['production-gce-c30'] + ] } } @@ -46,8 +58,10 @@ export function prepareRelayAsiaTopologyInput({ const shape = SHAPES[environment] if (!shape) throw new Error('invalid environment') const requested = cellIds.split(',').map((value) => value.trim()).filter(Boolean).sort() - const expected = Object.keys(shape.cells).sort() - if (new Set(requested).size !== requested.length || JSON.stringify(requested) !== JSON.stringify(expected)) { + const expected = shape.waves + .map((wave) => [...wave].sort()) + .find((wave) => JSON.stringify(wave) === JSON.stringify(requested)) + if (new Set(requested).size !== requested.length || !expected) { throw new Error('cell IDs do not match the reviewed Asia topology') } const prefix = `us-central1-docker.pkg.dev/${shape.project}/orca-cloud/relay@sha256:` @@ -76,7 +90,7 @@ export function prepareRelayAsiaTopologyInput({ boot_disk_gb: 30, boot_image: BOOT_IMAGE, capacity_requests: 6_000, - database_pool_max: 10, + database_pool_max: shape.databasePoolMax, image, initially_enabled: false, connection_hard_cap: 3_000, diff --git a/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs b/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs index 03622ffbf56..6cfd04eaf94 100644 --- a/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs +++ b/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { test } from 'node:test' import { prepareRelayAsiaTopologyInput } from './prepare-relay-asia-topology-input.mjs' @@ -9,12 +10,13 @@ const additionalRegions = { 'asia-east2': '10.42.1.0/24' } const productionCells = () => Object.fromEntries([ [27, 'asia-east2-a'], [28, 'asia-east2-b'], - [29, 'asia-east2-c'] + [29, 'asia-east2-c'], + [30, 'asia-east2-a'] ].map(([ordinal, zone]) => [`production-gce-c${ordinal}`, { hostname: `c${ordinal}`, region: 'asia-east2', zone, machine_type: 'e2-standard-4', boot_disk_gb: 30, boot_image: 'https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21', - capacity_requests: 6_000, database_pool_max: 10, image, initially_enabled: false, + capacity_requests: 6_000, database_pool_max: 16, image, initially_enabled: false, connection_hard_cap: 3_000, connection_unobserved_bound: 60 }])) @@ -32,11 +34,54 @@ test('accepts the exact production topology only after it is durably committed', hostname: 'c27', region: 'asia-east2', zone: 'asia-east2-a', machine_type: 'e2-standard-4', boot_disk_gb: 30, boot_image: 'https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21', - capacity_requests: 6_000, database_pool_max: 10, image, initially_enabled: false, + capacity_requests: 6_000, database_pool_max: 16, image, initially_enabled: false, connection_hard_cap: 3_000, connection_unobserved_bound: 60 }) }) +test('accepts the additive C30 wave without re-planning the launch cells', () => { + const result = prepareRelayAsiaTopologyInput({ existingCells: productionCells(), + existingAdditionalRegions: additionalRegions, environment: 'production', + cellIds: 'production-gce-c30', image }) + assert.equal(result.relay_gce_cells['production-gce-c30'].zone, 'asia-east2-a') +}) + +// Reads the committed file so a reviewed-shape constant cannot drift from what the plan reads. +test('matches every committed production Asia cell entry', () => { + const tfvars = readFileSync( + new URL('../../infra/terraform/environments/production.tfvars', import.meta.url), + 'utf8' + ) + const committed = {} + for (const cellId of Object.keys(productionCells())) { + const start = tfvars.indexOf(` "${cellId}" = {`) + assert.notEqual(start, -1, `${cellId} is not committed`) + const body = tfvars.slice(start, tfvars.indexOf('\n }', start)) + const cell = {} + for (const [, key, raw] of body.matchAll(/^\s+([a-z_]+)\s+=\s+("[^"]*"|\S+)/gm)) { + cell[key] = raw.startsWith('"') ? raw.slice(1, -1) + : raw === 'true' || raw === 'false' ? raw === 'true' : Number(raw) + } + committed[cellId] = cell + } + // Each wave is pinned on its own: C30 launches on the director's digest, not C27's. + for (const wave of [ + 'production-gce-c27,production-gce-c28,production-gce-c29', + 'production-gce-c30' + ]) { + const committedImage = committed[wave.split(',')[0]].image + assert.doesNotThrow(() => prepareRelayAsiaTopologyInput({ + existingCells: committed, existingAdditionalRegions: additionalRegions, + environment: 'production', cellIds: wave, image: committedImage + }), wave) + } + assert.throws(() => prepareRelayAsiaTopologyInput({ + existingCells: committed, existingAdditionalRegions: additionalRegions, + environment: 'production', cellIds: 'production-gce-c30', + image + }), /differs from the reviewed topology/) +}) + test('accepts the one exact committed staging Asia cell', () => { const stagingImage = image.replace('onorca-cloud/', 'onorca-cloud-staging/') const stagingCell = { @@ -59,10 +104,24 @@ test('accepts the one exact committed staging Asia cell', () => { }) test('rejects an uncommitted subnet or cell, partial wave, wrong image, and drift', () => { + for (const cellIds of [ + 'production-gce-c27', + 'production-gce-c27,production-gce-c30', + 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30', + 'production-gce-c30,production-gce-c30', + 'production-gce-c31' + ]) { + assert.throws(() => prepareRelayAsiaTopologyInput({ + existingCells: productionCells(), existingAdditionalRegions: additionalRegions, + environment: 'production', cellIds, image + }), /cell IDs/, cellIds) + } + const poolDrift = productionCells() + poolDrift['production-gce-c30'].database_pool_max = 10 assert.throws(() => prepareRelayAsiaTopologyInput({ - existingCells: productionCells(), existingAdditionalRegions: additionalRegions, - environment: 'production', cellIds: 'production-gce-c27', image - }), /cell IDs/) + existingCells: poolDrift, existingAdditionalRegions: additionalRegions, + environment: 'production', cellIds: 'production-gce-c30', image + }), /differs from the reviewed topology/) assert.throws(() => prepareRelayAsiaTopologyInput({ existingCells: productionCells(), existingAdditionalRegions: additionalRegions, environment: 'production', diff --git a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs index 1378e26416d..c2c2b4b2e70 100644 --- a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs +++ b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs @@ -117,7 +117,7 @@ describe('production Relay capacity cell admission', () => { for (const cellId of [ 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', // Migration-only canaries: the US-only capacity rollout never touches them either. - 'production-gce-c17', 'production-gce-c18' + 'production-gce-c17', 'production-gce-c18', 'production-gce-c30' ]) { const hostname = cellId.slice('production-gce-'.length) assert.deepEqual(parseProductionCapacityCellArguments([ @@ -134,7 +134,7 @@ describe('production Relay capacity cell admission', () => { paceWindowMs: 0 }) } - for (const cellId of ['production-gce-c12', 'production-gce-c30']) { + for (const cellId of ['production-gce-c12', 'production-gce-c31']) { const hostname = cellId.slice('production-gce-'.length) assert.throws(() => parseProductionCapacityCellArguments([ '--director-origin', 'https://relay.onorca.dev', diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.mjs index 73ea44a40f7..7fef67cd237 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.mjs @@ -1,9 +1,9 @@ import { pathToFileURL } from 'node:url' import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs' -// Every general cell that carries the rehome identity: the sixteen US cells and the -// three asia-east2 cells that drain mis-homed hosts back the other way. -const PRODUCTION_CELL = /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29)$/ +// Every cell that carries the rehome identity: the sixteen US cells and the +// four asia-east2 cells that drain mis-homed hosts back the other way. +const PRODUCTION_CELL = /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29|30)$/ const DIRECTOR_ORIGIN = 'https://relay.onorca.dev' export function parseRehomeTrustProbeArguments(argv, environment = process.env) { diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs index 034fcd06c4f..f7c9696aeeb 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs @@ -113,14 +113,16 @@ test('fails when both trust-probe attempts return a transient 503', async () => }) test('approves the asia-east2 rehome sources and still rejects unlisted cells', () => { - for (const cellId of ['production-gce-c27', 'production-gce-c28', 'production-gce-c29']) { + for (const cellId of [ + 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30' + ]) { const parsed = parseRehomeTrustProbeArguments( argv.map((value) => (value === 'production-gce-c7' ? cellId : value)), environment ) assert.equal(parsed.cellId, cellId) } - for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c30']) { + for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c31']) { assert.throws( () => parseRehomeTrustProbeArguments( diff --git a/cloud/dev/scripts/relay-asia-admission-workflow.test.mjs b/cloud/dev/scripts/relay-asia-admission-workflow.test.mjs index 21d712601d0..8283d43ca44 100644 --- a/cloud/dev/scripts/relay-asia-admission-workflow.test.mjs +++ b/cloud/dev/scripts/relay-asia-admission-workflow.test.mjs @@ -90,7 +90,7 @@ test('uploads one sanitized machine-readable admission result', () => { assert.match(upload, /retention-days: 7/) assert.ok( workflow.indexOf('Upload sanitized admission result') > - workflow.indexOf('Upload immutable C27 canary evidence') + workflow.indexOf('Upload immutable canary evidence') ) }) @@ -109,7 +109,7 @@ test('binds selector operations and director configuration to reviewed implement assert.doesNotMatch(workflow, /dns/i) }) -test('requires immutable staged evidence and a timed C27 canary before expansion', () => { +test('requires immutable staged evidence and a timed production canary before expansion', () => { assert.match(workflow, /actions: read/) assert.match(workflow, /actions\/download-artifact@v4/) assert.match(workflow, /relay-asia-staging-\$\{EVIDENCE_RUN_ID\}-\$\{EVIDENCE_RUN_ATTEMPT\}/) @@ -122,25 +122,26 @@ test('requires immutable staged evidence and a timed C27 canary before expansion assert.match(workflow, /--duration-seconds 300/) assert.match(workflow, /--required-lease-horizons 2/) assert.match(workflow, /pnpm\/action-setup@v4/) - assert.match(workflow, /Install exact C27 canary dependencies/) + assert.match(workflow, /Install exact canary dependencies/) assert.match(workflow, /pnpm install --frozen-lockfile/) assert.match(workflow, /pnpm --filter @orca-cloud\/relay-contract build/) assert.ok( - workflow.indexOf('Build the C27 canary Relay contract') < - workflow.indexOf('Run a real five-minute C27 control and splice canary') + workflow.indexOf('Build the canary Relay contract') < + workflow.indexOf('Run a real five-minute canary control and splice') ) - assert.match(workflow, /--load-report "\$\{RUNNER_TEMP\}\/relay-asia-c27-load\.json"/) - assert.match(workflow, /states\["production-gce-c28"\].*= migration-only/) - assert.match(workflow, /states\["production-gce-c29"\].*= migration-only/) - assert.match(workflow, /relay-asia-c27-canary-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/) - assert.match(workflow, /id: c27-evidence-upload/) - assert.match(workflow, /Return an unproven C27 canary to migration-only/) - assert.match(workflow, /steps\.c27-evidence-upload\.outcome != 'success'/) + assert.match(workflow, /--load-report "\$\{RUNNER_TEMP\}\/relay-asia-canary-load\.json"/) + assert.match(workflow, /"production-gce-c28":"migration-only","production-gce-c29":"migration-only"/) + // C28/C29 promotion downloads C27's canary under exactly this name. + assert.match(workflow, /relay-asia-\$\{\{ steps\.inputs\.outputs\.canary_hostname \}\}-canary-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/) + assert.match(workflow, /echo "canary_hostname=\$\{canary_cell##\*-\}"/) + assert.match(workflow, /id: canary-evidence-upload/) + assert.match(workflow, /Return an unproven canary cell to migration-only/) + assert.match(workflow, /steps\.canary-evidence-upload\.outcome != 'success'/) assert.match(workflow, /--mode recover-promotion[\s\S]*?--attempt-id "\$\{SELECTOR_ATTEMPT_ID\}"/) assert.match(workflow, /--attempt-id "\$\{SELECTOR_ATTEMPT_ID\}-rollback"/) assert.match(workflow, /evidence_kind=c27/) assert.match(workflow, /orca_relay_runtime_metrics/) - assert.match(workflow, /relay-asia-rollout-evidence\.mjs create-c27/) + assert.match(workflow, /relay-asia-rollout-evidence\.mjs create-canary \\\n\s+--cell-id "\$\{CANARY_CELL\}"/) assert.match(workflow, /retention-days: 7/) assert.match(workflow, /Require the exact director image before promotion/) assert.match(workflow, /DIRECTOR_ORIGIN.*\/v1\/admin\/runtime-status/) @@ -153,6 +154,57 @@ test('requires immutable staged evidence and a timed C27 canary before expansion assert.doesNotMatch(provenance, /--commit-sha "\$\{GITHUB_SHA\}"/) }) +test('binds each production promotion wave to its exact evidence and canary', () => { + const cases = /case "\$\{TARGET_CELL_IDS\}" in\n([\s\S]*?)\n\s*esac/.exec(workflow)?.[1] + assert.ok(cases) + const waves = Object.fromEntries( + [...cases.matchAll(/^ {14}([a-z0-9,-]+)\)\n([\s\S]*?);;/gm)].map((match) => [match[1], { + evidence: /evidence_kind=([a-z0-9]+)/.exec(match[2])?.[1] ?? 'none', + canary: /canary_cell=([a-z0-9-]+)/.exec(match[2])?.[1] ?? 'none' + }]) + ) + assert.deepEqual(waves, { + 'production-gce-c27': { evidence: 'staging', canary: 'production-gce-c27' }, + 'production-gce-c28,production-gce-c29': { evidence: 'c27', canary: 'none' }, + 'production-gce-c30': { evidence: 'none', canary: 'production-gce-c30' } + }) + assert.match(cases, /\*\) echo "production promotion wave is not reviewed" >&2; exit 1 ;;/) + assert.match(workflow, /if test "\$\{evidence_kind\}" = none; then\n\s+test -z "\$\{EVIDENCE_RUN_ID\}"/) + assert.doesNotMatch(workflow, /inputs\.cell-ids == /) +}) + +test('runs the timed canary and its automatic rollback for C27 and C30 alike', () => { + const steps = workflow.split(/\n(?= - )/) + const named = (name) => steps.find((step) => step.includes(`name: ${name}`)) + for (const name of [ + 'Install exact canary dependencies', + 'Build the canary Relay contract', + 'Verify the canary cell state and start the timed canary', + 'Run a real five-minute canary control and splice', + 'Collect regional, Relay SQL, and Cloud SQL canary evidence', + 'Upload immutable canary evidence' + ]) { + assert.match(named(name), /if: \$\{\{ steps\.inputs\.outputs\.canary == 'true' \}\}/, name) + } + assert.match( + workflow, + /if: \$\{\{ inputs\.mode == 'configure' \|\| steps\.inputs\.outputs\.canary == 'true' \}\}/ + ) + const rollback = named('Return an unproven canary cell to migration-only') + assert.match( + rollback, + /if: \$\{\{ always\(\) && steps\.inputs\.outputs\.canary == 'true' && steps\.admission-operation\.outcome != 'skipped' && steps\.canary-evidence-upload\.outcome != 'success' \}\}/ + ) + assert.match(rollback, /CANARY_CELL: \$\{\{ steps\.inputs\.outputs\.canary_cell \}\}/) + assert.match(rollback, /--mode recover-promotion \\\n\s+--cell-ids "\$\{CANARY_CELL\}"/) + assert.match(rollback, /--mode rollback \\\n\s+--cell-ids "\$\{CANARY_CELL\}"/) + assert.match(rollback, /'\.states\[\$cell\]' <<< "\$\{result\}"\)" = migration-only/) + const start = named('Verify the canary cell state and start the timed canary') + assert.match(start, /production-gce-c30\)\n\s+verify_cells=production-gce-c30\n\s+expected_states='\{"production-gce-c30":"general"\}'/) + assert.match(start, /test "\$\(jq -cS '\.states' <<< "\$\{result\}"\)" = "\$\(jq -cS '\.' <<< "\$\{expected_states\}"\)"/) + assert.match(named('Run a real five-minute canary control and splice'), /--duration-seconds 300/) +}) + test('creates staging evidence only after the bounded launch-path load and rollback', () => { assert.match(stagingProof, /runs-on: \[self-hosted, linux, x64, relay-asia-east2-load\]/) assert.doesNotMatch(stagingProof, /group: relay-asia-east2-load/) diff --git a/cloud/dev/scripts/relay-asia-rollout-evidence.mjs b/cloud/dev/scripts/relay-asia-rollout-evidence.mjs index e833a0ae16b..4be059fcb31 100644 --- a/cloud/dev/scripts/relay-asia-rollout-evidence.mjs +++ b/cloud/dev/scripts/relay-asia-rollout-evidence.mjs @@ -8,12 +8,17 @@ const ADMISSION_WORKFLOW = relayWorkflowPath('operate-relay-asia-admission.yml') const STAGING_WORKFLOW = relayWorkflowPath('prove-relay-asia-staging.yml') const STAGING_CELL = 'staging-gce-c4' const C27 = 'production-gce-c27' +// Each canary proves its own cell under production load; C28/C29 promotion consumes only C27's. +const PRODUCTION_CANARIES = { + [C27]: { kind: 'production-c27-canary', origin: 'https://c27.relay.onorca.dev' }, + 'production-gce-c30': { kind: 'production-c30-canary', origin: 'https://c30.relay.onorca.dev' } +} const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/ const SHA_PATTERN = /^[a-f0-9]{40}$/ const MAX_LOG_EDGE_GAP_MS = 120_000 const MAX_LOG_SAMPLE_GAP_MS = 120_000 const CLOUD_SQL_LIMIT = 320 -const C27_CANARY_MINIMUM_MS = 5 * 60_000 +const CANARY_MINIMUM_MS = 5 * 60_000 const GENERATOR_CPU_PERCENT_LIMIT = 80 const GENERATOR_EVENT_LOOP_P99_MS_LIMIT = 100 const GENERATOR_RSS_GROWTH_MIB_LIMIT = 512 @@ -280,28 +285,36 @@ function cloudSqlMaximum(response, start, end) { return Math.max(...values) } -export function buildC27CanaryEvidence(input) { +function productionCanary(cellId) { + const canary = PRODUCTION_CANARIES[cellId] + if (!canary) throw new Error('canary cell is not a reviewed production Asia canary') + return { ...canary, label: cellId.split('-').at(-1).toUpperCase() } +} + +export function buildProductionCanaryEvidence(input) { + const canary = productionCanary(input.cellId) const start = instant(input.startedAt, 'canary start') const end = instant(input.endedAt, 'canary end') - if (end.valueOf() - start.valueOf() < C27_CANARY_MINIMUM_MS) { - throw new Error('C27 canary window is shorter than 5 minutes') + if (end.valueOf() - start.valueOf() < CANARY_MINIMUM_MS) { + throw new Error(`${canary.label} canary window is shorter than 5 minutes`) } - const load = object(input.loadReport, 'C27 load report') - assertC27CanaryLoad(load) - const metrics = runtimeMetrics(input.logs, start, end, C27) - assertPassingRuntimeMetrics(metrics, 'C27 canary') + const load = object(input.loadReport, `${canary.label} load report`) + assertCanaryLoad(load, input.cellId) + const metrics = runtimeMetrics(input.logs, start, end, input.cellId) + assertPassingRuntimeMetrics(metrics, `${canary.label} canary`) metrics.cloudSqlBackendsMax = cloudSqlMaximum(input.cloudSql, start, end) - assertPassingCanary(metrics) + assertPassingCanary(metrics, input.cellId) return { - ...baseEvidence(input, 'production', [C27]), - kind: 'production-c27-canary', + ...baseEvidence(input, 'production', [input.cellId]), + kind: canary.kind, window: { startedAt: start.toISOString(), endedAt: end.toISOString() }, load, metrics } } -function assertC27CanaryLoad(report) { +function assertCanaryLoad(report, cellId) { + const { label, origin } = productionCanary(cellId) if ( report.event !== 'relay_load_complete' || report.controls !== 1 || report.shardCount !== 1 || report.shardIndex !== 0 || @@ -312,18 +325,22 @@ function assertC27CanaryLoad(report) { report.peakActive !== 1 || report.steadyMinimumActive !== 1 || report.configuredSplices !== 1 || report.peakActiveSplices !== 1 || report.completedSplices !== 1 || report.failedSplices !== 0 - ) throw new Error('C27 control and splice canary did not match') + ) throw new Error(`${label} control and splice canary did not match`) + // Placement picks the least-loaded general Asia cell, so the canary's own control must be on it. + if (JSON.stringify(report.assignedCellOrigins) !== JSON.stringify([origin])) { + throw new Error(`${label} canary load was not placed only on ${label}`) + } for (const key of [ 'connectionFailures', 'unexpectedCloses', 'protocolErrors', 'refreshErrors', 'socketErrors' ]) { - if (number(report[key], key) !== 0) throw new Error(`C27 canary ${key} must be zero`) + if (number(report[key], key) !== 0) throw new Error(`${label} canary ${key} must be zero`) } - const shutdown = object(report.shutdownEvidence, 'C27 load shutdown evidence') + const shutdown = object(report.shutdownEvidence, `${label} load shutdown evidence`) if ( shutdown.peerShutdowns !== 1 || shutdown.activeControls !== 0 || shutdown.activeSplices !== 0 || shutdown.reconnectTimers !== 0 - ) throw new Error('C27 load cleanup is incomplete') + ) throw new Error(`${label} load cleanup is incomplete`) } function runtimeMetrics(logs, start, end, targetCellId) { @@ -406,11 +423,12 @@ function assertPassingRuntimeMetrics(metrics, label, expectedRegionFallbacks = 0 ) throw new Error(`${label} transient database pool pressure exceeded its bound`) } -function assertPassingCanary(metrics) { +function assertPassingCanary(metrics, cellId) { + const { label } = productionCanary(cellId) if ( - number(metrics.targetControlsMax, 'C27 controls') < 1 || - number(metrics.targetSplicesMax, 'C27 splices') < 1 - ) throw new Error('C27 canary traffic did not reach C27') + number(metrics.targetControlsMax, `${label} controls`) < 1 || + number(metrics.targetSplicesMax, `${label} splices`) < 1 + ) throw new Error(`${label} canary traffic did not reach ${label}`) if (number(metrics.cloudSqlBackendsMax, 'Cloud SQL backends') >= CLOUD_SQL_LIMIT) { throw new Error(`Cloud SQL backends must remain below ${CLOUD_SQL_LIMIT}`) } @@ -457,13 +475,16 @@ export function verifyRolloutEvidence(evidence, run, expected) { if (proofTime > now || now.valueOf() - proofTime.valueOf() > maxAgeMs) { throw new Error('rollout evidence is stale') } - if (expected.kind === 'production-c27-canary') { + const canaryCell = Object.keys(PRODUCTION_CANARIES) + .find((cellId) => PRODUCTION_CANARIES[cellId].kind === expected.kind) + if (canaryCell) { + if (!exactCells(expected.cellIds, [canaryCell])) throw new Error('evidence topology does not match') const start = instant(evidence.window?.startedAt, 'canary start') - if (proofTime.valueOf() - start.valueOf() < C27_CANARY_MINIMUM_MS) { - throw new Error('C27 canary window is shorter than 5 minutes') + if (proofTime.valueOf() - start.valueOf() < CANARY_MINIMUM_MS) { + throw new Error(`${productionCanary(canaryCell).label} canary window is shorter than 5 minutes`) } - assertC27CanaryLoad(object(evidence.load, 'canary load')) - assertPassingCanary(object(evidence.metrics, 'canary metrics')) + assertCanaryLoad(object(evidence.load, 'canary load'), canaryCell) + assertPassingCanary(object(evidence.metrics, 'canary metrics'), canaryCell) } return evidence } @@ -511,11 +532,11 @@ async function main(argv) { }), null, 2)}\n`) return } - if (command === 'create-c27') { + if (command === 'create-canary') { const startedAt = required(values, 'started-at') const endedAt = required(values, 'ended-at') - writeFileSync(output, `${JSON.stringify(buildC27CanaryEvidence({ - ...commonInput(values), startedAt, endedAt, + writeFileSync(output, `${JSON.stringify(buildProductionCanaryEvidence({ + ...commonInput(values), cellId: required(values, 'cell-id'), startedAt, endedAt, loadReport: JSON.parse(readFileSync(required(values, 'load-report'), 'utf8')), logs: JSON.parse(readFileSync(required(values, 'logs-json'), 'utf8')), cloudSql: await readCloudSqlBackends('production', startedAt, endedAt) diff --git a/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs b/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs index 5d7b316605c..67ad7e82f82 100644 --- a/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs +++ b/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { RELAY_GITHUB_REPOSITORY, relayWorkflowPath } from './relay-repository.mjs' import { - buildC27CanaryEvidence, + buildProductionCanaryEvidence, buildStagingEvidence, verifyRolloutEvidence } from './relay-asia-rollout-evidence.mjs' @@ -115,18 +115,19 @@ function stagingInput(overrides = {}) { } } -function canaryInput(overrides = {}) { +function canaryInput(overrides = {}, cellId = 'production-gce-c27') { const load = loadReport({ controls: 1, shardIndex: 0, splices: 1 }) Object.assign(load, { shardCount: 1, configuredSteadySeconds: 300, configuredSpliceHoldSeconds: 60, - relayAsiaLoadPrincipalCount: 1 + relayAsiaLoadPrincipalCount: 1, + assignedCellOrigins: [`https://${cellId.split('-').at(-1)}.relay.onorca.dev`] }) return { - ...sourceInput(), startedAt: start.toISOString(), endedAt: canaryEnd.toISOString(), + ...sourceInput(), cellId, startedAt: start.toISOString(), endedAt: canaryEnd.toISOString(), loadReport: load, - logs: completeLogs('production-gce-c27', canaryEnd), cloudSql: cloudSql(319, canaryEnd), + logs: completeLogs(cellId, canaryEnd), cloudSql: cloudSql(319, canaryEnd), ...overrides } } @@ -295,7 +296,7 @@ test('rejects mismatched staging provenance, digest, topology, or age', () => { }) test('builds and verifies a passing continuous 5-minute C27 canary', () => { - const evidence = buildC27CanaryEvidence(canaryInput()) + const evidence = buildProductionCanaryEvidence(canaryInput()) assert.equal(evidence.load.completedSplices, 1) assert.equal(evidence.metrics.asiaSelections, 6) assert.equal(evidence.metrics.cloudSqlBackendsMax, 319) @@ -306,14 +307,14 @@ test('builds and verifies a passing continuous 5-minute C27 canary', () => { }) test('rejects short, sparse, or unrelated-cell-only C27 coverage', () => { - assert.throws(() => buildC27CanaryEvidence(canaryInput({ + assert.throws(() => buildProductionCanaryEvidence(canaryInput({ endedAt: new Date(canaryEnd.valueOf() - 1).toISOString() })), /shorter than 5 minutes/) const sparse = completeLogs('production-gce-c27', canaryEnd).filter((entry) => entry.timestamp === start.toISOString() || entry.timestamp === canaryEnd.toISOString() ) - assert.throws(() => buildC27CanaryEvidence(canaryInput({ logs: sparse })), /sampling gap/) - assert.throws(() => buildC27CanaryEvidence(canaryInput({ + assert.throws(() => buildProductionCanaryEvidence(canaryInput({ logs: sparse })), /sampling gap/) + assert.throws(() => buildProductionCanaryEvidence(canaryInput({ logs: completeLogs('production-gce-c26', canaryEnd) })), /production-gce-c27 metrics has no samples/) }) @@ -321,10 +322,10 @@ test('rejects short, sparse, or unrelated-cell-only C27 coverage', () => { test('rejects a C27 canary without a real control and splice', () => { const input = canaryInput() input.loadReport.completedSplices = 0 - assert.throws(() => buildC27CanaryEvidence(input), /did not match/) + assert.throws(() => buildProductionCanaryEvidence(input), /did not match/) const shortHold = canaryInput() shortHold.loadReport.configuredSpliceHoldSeconds = 59 - assert.throws(() => buildC27CanaryEvidence(shortHold), /did not match/) + assert.throws(() => buildProductionCanaryEvidence(shortHold), /did not match/) }) for (const [label, mutation, message] of [ @@ -344,14 +345,74 @@ for (const [label, mutation, message] of [ test(`rejects C27 evidence with ${label}`, () => { const input = canaryInput() mutation(input) - assert.throws(() => buildC27CanaryEvidence(input), message) + assert.throws(() => buildProductionCanaryEvidence(input), message) }) } test('rejects C27 evidence from a different selector generation', () => { - const evidence = buildC27CanaryEvidence(canaryInput()) + const evidence = buildProductionCanaryEvidence(canaryInput()) assert.throws(() => verifyRolloutEvidence( evidence, workflowRun(evidence), verifyExpected('production-c27-canary', { selectorGeneration: 10 }) ), /selector generation/) }) + +test('rejects a C27 canary whose control was placed on another cell', () => { + for (const origins of [ + ['https://c28.relay.onorca.dev'], + ['https://c27.relay.onorca.dev', 'https://c28.relay.onorca.dev'], + [], + undefined + ]) { + const input = canaryInput() + input.loadReport.assignedCellOrigins = origins + assert.throws(() => buildProductionCanaryEvidence(input), /C27 canary load was not placed only on C27/) + } +}) + +const c30 = 'production-gce-c30' + +test('builds and verifies a C30 canary from C30 runtime metrics and placement', () => { + const evidence = buildProductionCanaryEvidence(canaryInput({}, c30)) + assert.equal(evidence.kind, 'production-c30-canary') + assert.deepEqual(evidence.topology, { cellIds: [c30], selectorGeneration: 9 }) + assert.equal(verifyRolloutEvidence( + evidence, workflowRun(evidence), + verifyExpected('production-c30-canary', { cellIds: [c30], selectorGeneration: 9 }) + ), evidence) + assert.throws(() => verifyRolloutEvidence( + evidence, workflowRun(evidence), + verifyExpected('production-c30-canary', { cellIds: [c30], selectorGeneration: 10 }) + ), /selector generation/) + assert.throws(() => verifyRolloutEvidence( + evidence, workflowRun(evidence), verifyExpected('production-c27-canary', { selectorGeneration: 9 }) + ), /evidence kind is invalid/) +}) + +test('rejects a C30 canary that C30 did not serve', () => { + const onLaunchCell = canaryInput({}, c30) + onLaunchCell.loadReport.assignedCellOrigins = ['https://c27.relay.onorca.dev'] + assert.throws(() => buildProductionCanaryEvidence(onLaunchCell), /C30 canary load was not placed only on C30/) + assert.throws(() => buildProductionCanaryEvidence(canaryInput({ + logs: completeLogs('production-gce-c27', canaryEnd) + }, c30)), /production-gce-c30 metrics has no samples/) + const idle = canaryInput({}, c30) + idle.logs.filter((entry) => entry.jsonPayload.role === 'cell') + .forEach((entry) => { entry.jsonPayload.splices = 0 }) + assert.throws(() => buildProductionCanaryEvidence(idle), /C30 canary traffic did not reach C30/) + const poolPressure = canaryInput({}, c30) + poolPressure.logs.at(-1).jsonPayload.databasePoolWaiting = 1 + assert.throws(() => buildProductionCanaryEvidence(poolPressure), /C30 canary databasePoolWaitingMax/) + assert.throws(() => buildProductionCanaryEvidence(canaryInput({ + endedAt: new Date(canaryEnd.valueOf() - 1).toISOString() + }, c30)), /C30 canary window is shorter than 5 minutes/) +}) + +test('accepts canaries only for the reviewed production Asia cells', () => { + for (const cellId of ['production-gce-c28', 'production-gce-c29', 'staging-gce-c4', undefined]) { + assert.throws( + () => buildProductionCanaryEvidence({ ...canaryInput(), cellId }), + /not a reviewed production Asia canary/ + ) + } +}) diff --git a/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs b/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs index 1927d9016ef..d8965f02869 100644 --- a/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs +++ b/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs @@ -35,6 +35,20 @@ test('uses only its exact workflow-bound topology identity', () => { assert.match(iam, /assertion\.environment == '\$\{var\.environment\}'/) }) +test('accepts only the reviewed Asia topology waves', () => { + const cases = /case "\$\{TARGET_ENVIRONMENT\}:\$\{TARGET_CELL_IDS\}" in\n([\s\S]*?)\n\s*esac/ + .exec(workflow)?.[1] + assert.ok(cases) + assert.deepEqual( + [...cases.matchAll(/^\s*([a-z]+:[a-z0-9,-]+)\) ;;$/gm)].map((match) => match[1]), + [ + 'staging:staging-gce-c4', + 'production:production-gce-c27,production-gce-c28,production-gce-c29', + 'production:production-gce-c30' + ] + ) +}) + test('plans only additive Asia topology and applies the saved plan', () => { assert.doesNotMatch(workflow, /manage_artifact_dns/) for (const target of [ @@ -52,8 +66,13 @@ test('plans only additive Asia topology and applies the saved plan', () => { workflow, /\.variables\.relay_gce_additional_region_subnetwork_cidrs\.value/ ) - assert.doesNotMatch(workflow, /terraform -chdir=infra\/terraform console/) - assert.equal((workflow.match(/-var-file="\$\{TF_VARS\}"/g) ?? []).length, 2) + // Console evaluates every output against state and fails while a declared cell has no MIG. + assert.doesNotMatch(workflow, /terraform[^\n]*console/) + assert.equal( + (workflow.match(/-var-file="\$\{TF_VARS\}" -var-file="\$\{\{ steps\.live-images\.outputs\.file \}\}"/g) ?? []).length, + 2 + ) + assert.equal((workflow.match(/-var-file=/g) ?? []).length, 5) assert.doesNotMatch(workflow, /terraform[^\n]*apply[^\n]*-target/) assert.doesNotMatch(workflow, /google_(?:sql|cloudflare|dns|certificate_manager)/) }) @@ -71,6 +90,9 @@ test('checks the connection budget and production live ceiling before planning', assert.match(workflow, /select\(\.name == "max_connections"\)/) assert.match(workflow, /VERIFIED_DEFAULT_MAX_CONNECTIONS_TIER: db-custom-4-15360/) assert.match(workflow, /VERIFIED_DEFAULT_MAX_CONNECTIONS_DATABASE_VERSION: POSTGRES_17/) + assert.match(workflow, /VERIFIED_DEFAULT_MAX_CONNECTIONS: '500'/) + assert.match(workflow, /live_max="\$\{VERIFIED_DEFAULT_MAX_CONNECTIONS\}"/) + assert.doesNotMatch(workflow, /live_max=\d/) assert.match(workflow, /live_source=verified-shape-default/) assert.match(workflow, /test "\$\(jq -er '\.settings\.tier'/) assert.match(workflow, /test "\$\(jq -er '\.databaseVersion'/) @@ -122,3 +144,34 @@ test('the custom role cannot delete topology or mutate SQL and DNS', () => { /resource "google_storage_bucket_iam_member" "github_relay_asia_topology_state_list"[\s\S]*?role\s+= google_project_iam_custom_role\.github_relay_asia_topology_state_list\[0\]\.id/ ) }) + +test('plans every non-target cell at its served image, read from state templates only', () => { + const step = /- id: live-images\n[\s\S]*?\n\n/.exec(workflow)?.[0] + assert.ok(step) + assert.match(step, /terraform -chdir=infra\/terraform show -json \| jq -ce '\[/) + assert.match(step, /\.type == "google_compute_instance_template" and \.name == "relay_gce_cell"/) + assert.match(step, /\{ index, metadata_startup_script: \.values\.metadata_startup_script \}/) + assert.match(step, /relay-live-cell-image-overlay\.mjs/) + assert.match(step, /--cell-ids "\$\{TARGET_CELL_IDS\}"/) + // The committed map comes from a read-only plan over the same targets, never from console. + assert.match(step, /mapfile -t targets < "\$\{\{ steps\.targets\.outputs\.file \}\}"/) + assert.match( + step, + /terraform -chdir=infra\/terraform plan -input=false -refresh=false -lock=false \\\n\s+-var-file="\$\{TF_VARS\}" "\$\{targets\[@\]\}" -out="\$\{committed_plan\}" > \/dev\/null/ + ) + assert.match(step, /show -json "\$\{committed_plan\}" \\\n\s+\| jq -ce '\.variables\.relay_gce_cells\.value \| objects' > "\$\{cells\}"/) + assert.equal((step.match(/terraform -chdir=infra\/terraform (?:plan|apply)/g) ?? []).length, 1) + assert.ok(workflow.indexOf('- id: targets') < workflow.indexOf('- id: live-images')) + assert.ok(workflow.indexOf('- id: live-images') < workflow.indexOf('- name: Create and validate the saved topology plan')) +}) + +test('the deployments output tolerates a cell declared before its topology apply', () => { + const outputs = readFileSync(new URL('../../infra/terraform/outputs.tf', import.meta.url), 'utf8') + const start = outputs.indexOf('output "relay_gce_cell_deployments" {') + const block = outputs.slice(start, outputs.indexOf('\n}\n', start)) + const lookups = [...block.matchAll(/^\s+\w+\s+=\s+(.*\.relay_gce_cell\[cell_id\].*)$/gm)].map((match) => match[1]) + assert.equal(lookups.length, 6) + for (const lookup of lookups) { + assert.match(lookup, /^try\(google_compute_\w+\.relay_gce_cell\[cell_id\]\.\w+, null\)$/) + } +}) diff --git a/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs b/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs index f1dc962cb18..333969e5684 100644 --- a/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs +++ b/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs @@ -7,12 +7,12 @@ import { } from './relay-cloud-sql-connection-budget.mjs' test('production shared consumers keep allowance and reserve below the ceiling', () => { - // cells: 20 pools at 10 (200) + the three asia-east2 pools at 16 (48). + // cells: 20 pools at 10 (200) + the four asia-east2 pools at 16 (64). const report = readRelayCloudSqlConnectionBudget() - assert.deepEqual(report.consumers, { cells: 248, directors: 15, auth: 20, api: 50 }) - assert.deepEqual(report.asia, { cells: 3, poolMax: 16 }) - assert.equal(report.configuredMaximum, 333) + assert.deepEqual(report.consumers, { cells: 264, directors: 15, auth: 20, api: 50 }) + assert.deepEqual(report.asia, { cells: 4, poolMax: 16 }) + assert.equal(report.configuredMaximum, 349) assert.equal(report.rolloutOverlap.relayDirectorCandidate, 30) assert.equal(report.rolloutOverlap.apiCandidate, 65) assert.equal(report.rolloutOverlap.authCandidate, 35) @@ -22,10 +22,10 @@ test('production shared consumers keep allowance and reserve below the ceiling', assert.equal(report.maintenanceAdminAllowance, 5) assert.equal(report.explicitReserve, 10) assert.equal(report.usableCeiling, 490) - assert.equal(report.operatingMaximum, 403) - assert.equal(report.remainingWithinUsableCeiling, 87) - assert.equal(report.budgetedTotal, 413) - assert.equal(report.unallocated, 87) + assert.equal(report.operatingMaximum, 419) + assert.equal(report.remainingWithinUsableCeiling, 71) + assert.equal(report.budgetedTotal, 429) + assert.equal(report.unallocated, 71) assert.equal(report.withinBudget, true) }) @@ -81,6 +81,33 @@ test('excludes fenced cell pools and reads per-cell pool overrides', () => { assert.equal(report.budgetedTotal, 47) }) +test('refuses an Asia cell whose pool differs from its siblings', () => { + const budget = (c30PoolMax) => readRelayCloudSqlConnectionBudget({ + appConsumers: { authInstances: 1, authPoolMax: 10, apiInstances: 1, apiPoolMax: 5, maxConnections: 500 }, + sources: { + productionTfvars: ` + relay_max_instances = 1 + relay_gce_fenced_cells = [] + relay_gce_cells = { +${['c27', 'c28', 'c29', 'c30'].map((hostname) => ` "production-gce-${hostname}" = { + region = "asia-east2" + database_pool_max = ${hostname === 'c30' ? c30PoolMax : 16} + }`).join('\n')} + } + `, + terraformVariables: [ + 'variable "relay_director_database_pool_max" { default = 3 }', + 'variable "push_max_instances" { default = 1 }', + 'variable "push_database_pool_max" { default = 2 }' + ].join('\n'), + relayConfig: 'export const RELAY_DATABASE_POOL_MAX = 10' + }, + maxConnections: 500 + }) + assert.deepEqual(budget(16).asia, { cells: 4, poolMax: 16 }) + assert.throws(() => budget(10), /Asia Relay cells must use one checked pool maximum/) +}) + test('dedicated push scaling does not consume shared capacity', () => { const report = readRelayCloudSqlConnectionBudget({ proposedAsiaCellCount: 1, diff --git a/cloud/dev/scripts/relay-live-cell-image-overlay.mjs b/cloud/dev/scripts/relay-live-cell-image-overlay.mjs new file mode 100644 index 00000000000..b4444e9cfe9 --- /dev/null +++ b/cloud/dev/scripts/relay-live-cell-image-overlay.mjs @@ -0,0 +1,84 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +// Committed images lag what same-cap rolls serve, and a URL map target pulls every cell's +// template into the plan, so a non-target cell must be planned at the image it serves. +const IMAGE_PATTERN = /^[a-z0-9.-]+\/[a-z0-9-]+\/[a-z0-9-]+\/relay@sha256:[a-f0-9]{64}$/ + +function liveRelayImage(script, committedImage, cellId) { + const repository = committedImage.split('@')[0] + const pulled = [...script.matchAll(/^docker pull '([^']+)'$/gm)] + .map((match) => match[1]) + .filter((image) => image.split('@')[0] === repository) + const digest = /^\s*printf 'ORCA_RELAY_IMAGE_DIGEST=%s\\n' '(sha256:[a-f0-9]{64})'$/m.exec(script)?.[1] + if (pulled.length !== 1 || !IMAGE_PATTERN.test(pulled[0]) || pulled[0].split('@')[1] !== digest) { + throw new Error(`${cellId} live template has no single pinned Relay image`) + } + return pulled[0] +} + +export function overlayRelayLiveCellImages({ committedCells, liveTemplates, targetCellIds }) { + if (!committedCells || Array.isArray(committedCells) || typeof committedCells !== 'object') { + throw new Error('committed Relay cells must be an object') + } + if (!Array.isArray(liveTemplates)) throw new Error('live templates must be an array') + const targets = new Set(targetCellIds) + for (const cellId of targets) { + if (!committedCells[cellId]) throw new Error(`${cellId} is not a committed Relay cell`) + } + const scripts = new Map() + for (const template of liveTemplates) { + if (typeof template?.index !== 'string' || typeof template.metadata_startup_script !== 'string') { + throw new Error('live template entry is malformed') + } + if (scripts.has(template.index)) throw new Error(`${template.index} has more than one live template`) + scripts.set(template.index, template.metadata_startup_script) + } + const cells = {} + for (const [cellId, cell] of Object.entries(committedCells)) { + if (targets.has(cellId)) { + cells[cellId] = cell + continue + } + const script = scripts.get(cellId) + // A declared cell with no live template would be created here, outside the reviewed wave. + if (script === undefined) throw new Error(`${cellId} is not a target and has no live template`) + cells[cellId] = { ...cell, image: liveRelayImage(script, cell.image, cellId) } + } + return { relay_gce_cells: cells } +} + +function argumentsFrom(argv) { + const values = {} + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index] + const value = argv[index + 1] + if (!key?.startsWith('--') || value === undefined) throw new Error('invalid arguments') + values[key.slice(2)] = value + } + for (const key of ['cells-json', 'live-templates-json', 'cell-ids', 'output']) { + if (!values[key]) throw new Error(`missing --${key}`) + } + return values +} + +function readJsonFile(path, label) { + const text = readFileSync(path, 'utf8') + if (!text.trim()) throw new Error(`${label} is empty`) + return JSON.parse(text) +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const values = argumentsFrom(process.argv.slice(2)) + const committedCells = readJsonFile(values['cells-json'], 'committed Relay cells') + const overlay = overlayRelayLiveCellImages({ + committedCells, + liveTemplates: readJsonFile(values['live-templates-json'], 'live Relay templates'), + targetCellIds: values['cell-ids'].split(',').map((value) => value.trim()).filter(Boolean) + }) + writeFileSync(values.output, `${JSON.stringify(overlay)}\n`) + const drifted = Object.keys(committedCells).filter( + (cellId) => overlay.relay_gce_cells[cellId].image !== committedCells[cellId].image + ) + console.log(JSON.stringify({ cells: Object.keys(committedCells).length, drifted })) +} diff --git a/cloud/dev/scripts/relay-live-cell-image-overlay.test.mjs b/cloud/dev/scripts/relay-live-cell-image-overlay.test.mjs new file mode 100644 index 00000000000..267cc974639 --- /dev/null +++ b/cloud/dev/scripts/relay-live-cell-image-overlay.test.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { overlayRelayLiveCellImages } from './relay-live-cell-image-overlay.mjs' + +const repository = 'us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay' +const committed = `${repository}@sha256:${'a'.repeat(64)}` +const served = `${repository}@sha256:${'b'.repeat(64)}` +const template = readFileSync( + new URL('../../infra/terraform/relay-gce-startup.sh.tftpl', import.meta.url), + 'utf8' +) + +// Renders only the two lines the overlay reads, from the real template, so a template edit fails here. +function startupScript(image) { + return template + .replaceAll('${trimprefix(regex("@sha256:[a-f0-9]{64}$", relay_image), "@")}', image.split('@')[1]) + .replaceAll('${relay_image}', image) + .replaceAll('${cloud_sql_proxy_image}', `gcr.io/cloud-sql-connectors/cloud-sql-proxy@sha256:${'c'.repeat(64)}`) +} + +const cell = (image) => ({ hostname: 'x', region: 'asia-east2', zone: 'asia-east2-a', image, connection_hard_cap: 3000 }) +const committedCells = { + 'production-gce-c1': cell(committed), + 'production-gce-c27': cell(committed), + 'production-gce-c30': cell(committed) +} +const live = [ + { index: 'production-gce-c1', metadata_startup_script: startupScript(committed) }, + { index: 'production-gce-c27', metadata_startup_script: startupScript(served) } +] + +test('plans every non-target cell at its served image while the target has no template yet', () => { + const overlay = overlayRelayLiveCellImages({ + committedCells, liveTemplates: live, targetCellIds: ['production-gce-c30'] + }) + assert.deepEqual(overlay, { + relay_gce_cells: { + 'production-gce-c1': cell(committed), + 'production-gce-c27': cell(served), + 'production-gce-c30': cell(committed) + } + }) +}) + +test('leaves a target cell at its committed image even once it has a live template', () => { + const overlay = overlayRelayLiveCellImages({ + committedCells, + liveTemplates: [...live, { index: 'production-gce-c30', metadata_startup_script: startupScript(served) }], + targetCellIds: ['production-gce-c30'] + }) + assert.equal(overlay.relay_gce_cells['production-gce-c30'].image, committed) +}) + +test('refuses a non-target cell that has no live template', () => { + assert.throws(() => overlayRelayLiveCellImages({ + committedCells, liveTemplates: live.slice(1), targetCellIds: ['production-gce-c30'] + }), /production-gce-c1 is not a target and has no live template/) +}) + +test('refuses a live template without one pinned Relay image', () => { + const digestMismatch = startupScript(served) + .replace(`%s\\n' 'sha256:${'b'.repeat(64)}'`, `%s\\n' 'sha256:${'d'.repeat(64)}'`) + for (const script of [ + digestMismatch, + startupScript(served).replace(`docker pull '${served}'`, ''), + `${startupScript(served)}\ndocker pull '${committed}'`, + startupScript(`${repository}:latest`) + ]) { + assert.throws(() => overlayRelayLiveCellImages({ + committedCells, + liveTemplates: [live[0], { index: 'production-gce-c27', metadata_startup_script: script }], + targetCellIds: ['production-gce-c30'] + }), /production-gce-c27 live template has no single pinned Relay image/) + } +}) + +test('refuses duplicate live templates and an undeclared target', () => { + assert.throws(() => overlayRelayLiveCellImages({ + committedCells, liveTemplates: [...live, live[1]], targetCellIds: ['production-gce-c30'] + }), /more than one live template/) + assert.throws(() => overlayRelayLiveCellImages({ + committedCells, liveTemplates: live, targetCellIds: ['production-gce-c31'] + }), /production-gce-c31 is not a committed Relay cell/) +}) + +test('builds the overlay from plan variables when the target cell has no template in state yet', () => { + const dir = mkdtempSync(join(tmpdir(), 'relay-live-cell-image-overlay-')) + try { + const cells = join(dir, 'cells.json') + const templates = join(dir, 'templates.json') + const output = join(dir, 'overlay.tfvars.json') + // Plan variables carry the map as written: optional attributes the tfvars omit stay absent. + const { connection_hard_cap: _omitted, ...c30 } = cell(committed) + writeFileSync(cells, JSON.stringify({ ...committedCells, 'production-gce-c30': c30 })) + writeFileSync(templates, JSON.stringify(live)) + assert.ok(!live.some(({ index }) => index === 'production-gce-c30')) + const run = (cellsPath) => spawnSync(process.execPath, [ + fileURLToPath(new URL('./relay-live-cell-image-overlay.mjs', import.meta.url)), + '--cells-json', cellsPath, '--live-templates-json', templates, + '--cell-ids', 'production-gce-c30', '--output', output + ], { encoding: 'utf8' }) + const result = run(cells) + assert.equal(result.status, 0, result.stderr) + assert.deepEqual(JSON.parse(result.stdout), { cells: 3, drifted: ['production-gce-c27'] }) + assert.deepEqual(JSON.parse(readFileSync(output, 'utf8')).relay_gce_cells['production-gce-c30'], c30) + const empty = join(dir, 'empty.json') + writeFileSync(empty, '') + const failed = run(empty) + assert.notEqual(failed.status, 0) + assert.match(failed.stderr, /committed Relay cells is empty/) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.mjs index f1ef2a9c4d5..21d6f418113 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.mjs @@ -4,7 +4,10 @@ import { requireSameEvidenceCode } from './relay-evidence-code-provenance.mjs' // Migration-only by policy: zero hosts and no reservation, so a wave rolls one without // displacing anybody. It enters and must leave migration-only, never general. -export const SAME_CAP_MIGRATION_ONLY_CELLS = ['production-gce-c17', 'production-gce-c18'] +// C30 stays here until its Asia canary promotes it; that follow-up moves it to the general list. +export const SAME_CAP_MIGRATION_ONLY_CELLS = [ + 'production-gce-c17', 'production-gce-c18', 'production-gce-c30' +] export const SAME_CAP_CELLS = [ 'production-gce-c7', 'production-gce-c8', 'production-gce-c9', 'production-gce-c10', diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs index bed12988e0b..49b85778fa1 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs @@ -48,12 +48,19 @@ test('requires one canary or a bounded reviewed batch', () => { rollbackDigest, confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c28` }).cells, ['production-gce-c28']) - assert.throws(() => validateSameCapWave({ + assert.deepEqual(validateSameCapWave({ mode: 'canary-apply', cellIds: 'production-gce-c30', targetDigest, rollbackDigest, confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c30` + }).cells, ['production-gce-c30']) + assert.throws(() => validateSameCapWave({ + mode: 'canary-apply', + cellIds: 'production-gce-c31', + targetDigest, + rollbackDigest, + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c31` }), /cells/) }) @@ -120,6 +127,17 @@ test('rolls the migration-only cells but never mixes the two classes in one wave confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${cellIds}`, canaryRunId: '42' }).cells, ['production-gce-c17', 'production-gce-c18']) + // Until its canary promotes it, a same-cap restore must hand C30 back isolated, never activated. + assert.equal(entryAdmission('production-gce-c30'), 'migration-only') + const asiaMixed = 'production-gce-c29,production-gce-c30' + assert.throws(() => validateSameCapWave({ + mode: 'batch-apply', + cellIds: asiaMixed, + targetDigest, + rollbackDigest, + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${asiaMixed}`, + canaryRunId: '42' + }), /all general or all migration-only/) // A mixed wave has no single selector delta for its later cells to offset from. const mixed = 'production-gce-c7,production-gce-c17' assert.throws(() => validateSameCapWave({ diff --git a/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs b/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs index 6704a67f5ae..aabf80a65f0 100644 --- a/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs +++ b/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs @@ -20,7 +20,7 @@ test('same-cap wrapper is reusable, canary-bound, and sequential', () => { assert.match(wrapper, /needs: \[gate, cell_2\]/) assert.match(wrapper, /needs: \[gate, cell_3\]/) assert.match(job, /on:\n workflow_call:/) - assert.match(job, /c27\|c28\|c29/) + assert.match(job, /c27\|c28\|c29\|c30\)/) assert.match(job, /EXPECTED_HARD_CAP=3000/) assert.match(job, /EXPECTED_REGION=asia-east2/) assert.match(job, /--hard-cap "\$\{EXPECTED_HARD_CAP\}"/) diff --git a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs index 2ef86ed80b8..73171cabfc5 100644 --- a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs +++ b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs @@ -292,7 +292,7 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.equal(String(cellShape(cellId).cap), tfvarsHardCap(cellId), cellId) } assert.equal(resolveCellShape('production-gce-c12').status, 1) - assert.equal(resolveCellShape('production-gce-c30').status, 1) + assert.equal(resolveCellShape('production-gce-c31').status, 1) }) it('passes the same-cap allowlist on every canary invocation the job runs', () => { @@ -367,9 +367,10 @@ describe('same-cap roll scripts accept every same-cap cell', () => { const trusted = SAME_CAP_CELLS.filter((cell) => REHOME_SOURCE_CELLS.has(cell)) // Only a declared rehome source may roll at a trusted protocol at all; the job refuses // the rest before it plans, and the next test covers them at protocol 0. + // C30 is already a rehome source but stays migration-only until its Asia canary promotes it. assert.deepEqual( SAME_CAP_CELLS.filter((cell) => !REHOME_SOURCE_CELLS.has(cell)), - SAME_CAP_MIGRATION_ONLY_CELLS + SAME_CAP_MIGRATION_ONLY_CELLS.filter((cell) => cell !== 'production-gce-c30') ) for (const [cellId, protocol] of trusted.flatMap((cell) => [[cell, 1], [cell, 3]])) { const { cap, pool } = cellShape(cellId) diff --git a/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs b/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs index cf25b4ff9ff..1218b92fc26 100644 --- a/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs +++ b/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs @@ -18,7 +18,8 @@ export const ENTRY_LIMIT = 20000 export const BASELINE_OFFSET_HOURS = [24, 48] // The asia-east2 cells share a 16-connection pool at 176 ms RTT, which is where pool pressure -// shows up first for the whole fleet. +// shows up first for the whole fleet. A cell joins only once it serves: zero samples read as +// unverified, so listing a not-yet-general cell would turn every verdict into WARN. export const FLEET_POOL_CELL_IDS = [ 'production-gce-c27', 'production-gce-c28', diff --git a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs index 0e777b06c38..67f30aa6d2b 100644 --- a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs +++ b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs @@ -38,6 +38,8 @@ const launchDigest = '5aedbca5c86de24c8b4d4bf7e3b444b76c712f281ede916cb9d90f70ca // Scoped to the Asia cells by name: the production capacity cells now serve this digest too, // so a file-wide count no longer isolates Asia. const asiaCells = ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'] +// C30 launches after the launch cells rolled, so it pins the director's digest instead. +const c30Digest = '4158d8a2e18e9caec439d257f0c1e45d92ffea8c0262f057b2f08c76a134bcf0' function cellBlock(tfvars, cellId) { const start = tfvars.indexOf(`"${cellId}"`) @@ -48,12 +50,13 @@ function cellBlock(tfvars, cellId) { const productionCell = (cellId) => cellBlock(productionTfvars, cellId) // Scoped to C4 by name: staging C3 serves this digest too since its 2026-09-03 re-pin. -test('pins staging C4 and all production Asia cells to the same launch image', () => { +test('pins staging C4 and the launch Asia cells to one image, and C30 to the director image', () => { assert.match(cellBlock(stagingTfvars, 'staging-gce-c4'), new RegExp(`relay@sha256:${launchDigest}"`)) for (const cellId of asiaCells) { assert.match(productionCell(cellId), new RegExp(`relay@sha256:${launchDigest}"`), cellId) } assert.match(recoveryWorkflow, new RegExp(`TARGET_IMAGE_DIGEST: sha256:${launchDigest}`)) + assert.match(productionCell('production-gce-c30'), new RegExp(`relay@sha256:${c30Digest}"`)) }) test('refreshes only empty staging C4 through the trusted capacity identity', () => { diff --git a/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs b/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs index af5283bd2da..0a702771852 100644 --- a/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs +++ b/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs @@ -14,20 +14,28 @@ const CELL_SHAPES = { production: { domain: 'relay.onorca.dev', project: 'onorca-cloud', + databasePoolMax: '16', cells: { 'production-gce-c27': 'asia-east2-a', 'production-gce-c28': 'asia-east2-b', - 'production-gce-c29': 'asia-east2-c' - } + 'production-gce-c29': 'asia-east2-c', + 'production-gce-c30': 'asia-east2-a' + }, + waves: [ + ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'], + ['production-gce-c30'] + ] }, staging: { domain: 'relay-staging.onorca.dev', project: 'onorca-cloud-staging', - cells: { 'staging-gce-c4': 'asia-east2-a' } + databasePoolMax: '10', + cells: { 'staging-gce-c4': 'asia-east2-a' }, + waves: [['staging-gce-c4']] } } -function parseArguments(argv) { +export function parseRelayAsiaTopologyPlanArguments(argv) { const values = {} for (let index = 0; index < argv.length; index += 2) { const key = argv[index] @@ -40,8 +48,11 @@ function parseArguments(argv) { } if (!(values.environment in CELL_SHAPES)) throw new Error('--environment is invalid') const cells = values['cell-ids'].split(',').map((value) => value.trim()).filter(Boolean) - const expectedCells = Object.keys(CELL_SHAPES[values.environment].cells) - if (new Set(cells).size !== cells.length || JSON.stringify(cells.sort()) !== JSON.stringify(expectedCells.sort())) { + const sorted = JSON.stringify([...cells].sort()) + if ( + new Set(cells).size !== cells.length || + !CELL_SHAPES[values.environment].waves.some((wave) => JSON.stringify([...wave].sort()) === sorted) + ) { throw new Error('--cell-ids must be the exact reviewed Asia topology set') } if (values.region !== REGION) throw new Error('--region must be asia-east2') @@ -90,7 +101,8 @@ function requireCellTemplate(change, config, cellId) { (after?.network_interface?.[0]?.access_config?.length ?? 0) !== 0 || startupValue(script, 'ORCA_RELAY_REGION') !== REGION || startupValue(script, 'ORCA_RELAY_CELL_CAPACITY') !== '6000' || - startupValue(script, 'ORCA_RELAY_DATABASE_POOL_MAX') !== '10' || + startupValue(script, 'ORCA_RELAY_DATABASE_POOL_MAX') !== + CELL_SHAPES[config.environment].databasePoolMax || startupValue(script, 'ORCA_RELAY_CELL_CONNECTION_HARD_CAP') !== '3000' || startupValue(script, 'ORCA_RELAY_CELL_CONNECTION_UNOBSERVED_BOUND') !== '60' || startupValue(script, 'ORCA_RELAY_IMAGE_DIGEST') !== config.image.split('@')[1] || @@ -288,6 +300,12 @@ export function validateRelayAsiaTopologyPlan(plan, config) { (action) => action === 'no-op' || action === 'read' )) for (const change of changes) { + const cellId = /^google_compute_(?:instance_template|instance_group_manager|backend_service)\.relay_gce_cell\["([^"]+)"\]$/ + .exec(change.address)?.[1] + // Usually a stale committed image; the workflow overlays live images so this stays empty. + if (cellId && !config.cells.includes(cellId)) { + throw new Error(`${change.address} changes a live cell outside the planned wave`) + } const allowedActions = required.get(change.address) if (!allowedActions || !allowedActions.some((expected) => sameActions(change, expected))) { throw new Error(`${change.address} has an unreviewed topology action`) @@ -297,7 +315,7 @@ export function validateRelayAsiaTopologyPlan(plan, config) { } if (process.argv[1] === fileURLToPath(import.meta.url)) { - const config = parseArguments(process.argv.slice(2)) + const config = parseRelayAsiaTopologyPlanArguments(process.argv.slice(2)) const plan = JSON.parse(readFileSync(config.planJson, 'utf8')) console.log(JSON.stringify(validateRelayAsiaTopologyPlan(plan, config))) } diff --git a/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs b/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs index 65bdd93ee37..77071a94fce 100644 --- a/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs +++ b/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs @@ -5,6 +5,7 @@ import { RELAY_CELL_BACKEND_TIMEOUT_SECONDS, RELAY_CELL_CONNECTION_DRAIN_SECONDS, RELAY_CELL_LOG_SAMPLE_RATE, + parseRelayAsiaTopologyPlanArguments, validateRelayAsiaTopologyPlan } from './validate-relay-asia-topology-plan.mjs' @@ -90,6 +91,121 @@ test('accepts the exact additive staging Asia topology', () => { }) }) +const productionImage = image.replace('onorca-cloud-staging/', 'onorca-cloud/') +const productionConfig = { + environment: 'production', cells: ['production-gce-c30'], image: productionImage +} + +// C30 joins a live Asia region: the network is a no-op and the existing C27 route is preserved. +function productionC30Plan() { + const plan = JSON.parse(JSON.stringify(resources) + .replaceAll('onorca-cloud-staging/', 'onorca-cloud/') + .replaceAll('orca-cloud-staging-relay-gce', 'orca-cloud-relay-gce') + .replaceAll('staging-gce-c4', 'production-gce-c30') + .replaceAll('relay-gce-c4', 'relay-gce-c30') + .replaceAll('cell-c4', 'cell-c30') + .replaceAll('c4.relay-staging.onorca.dev', 'c30.relay.onorca.dev') + .replaceAll("'10'", "'16'")) + for (const network of plan.slice(0, 3)) { + network.change.actions = ['no-op'] + network.change.before = structuredClone(network.change.after) + } + const existingHost = { hosts: ['c27.relay.onorca.dev'], path_matcher: 'cell-c27' } + const existingMatcher = { + name: 'cell-c27', + default_service: 'projects/p/global/backendServices/orca-cloud-relay-gce-c27' + } + const urlMap = plan.at(-1).change + urlMap.before = { host_rule: [existingHost], path_matcher: [existingMatcher], fingerprint: 'old' } + urlMap.after.host_rule.unshift(structuredClone(existingHost)) + urlMap.after.path_matcher.unshift(structuredClone(existingMatcher)) + return plan +} + +test('accepts the additive production C30 wave at the 16-connection Asia pool', () => { + assert.deepEqual( + validateRelayAsiaTopologyPlan({ resource_changes: productionC30Plan() }, productionConfig), + { environment: 'production', cells: ['production-gce-c30'], changes: 4 } + ) + const staleShape = productionC30Plan() + staleShape[3].change.after.metadata_startup_script = + staleShape[3].change.after.metadata_startup_script.replace("'16'", "'10'") + assert.throws( + () => validateRelayAsiaTopologyPlan({ resource_changes: staleShape }, productionConfig), + /reviewed Asia cell shape/ + ) + const wrongZone = productionC30Plan() + wrongZone[4].change.after.zone = 'asia-east2-b' + assert.throws( + () => validateRelayAsiaTopologyPlan({ resource_changes: wrongZone }, productionConfig), + /fixed-one Asia MIG shape/ + ) + const liveCellTouched = productionC30Plan() + liveCellTouched.push(create('google_compute_instance_template.relay_gce_cell["production-gce-c27"]')) + assert.throws( + () => validateRelayAsiaTopologyPlan({ resource_changes: liveCellTouched }, productionConfig), + /outside the planned wave/ + ) +}) + +// The URL map pulls every cell's backend, MIG and template into a targeted plan. +const liveCellResources = ['instance_template', 'instance_group_manager', 'backend_service'] + .flatMap((kind) => ['production-gce-c1', 'production-gce-c27', 'production-gce-c28', 'production-gce-c29'] + .map((cellId) => `google_compute_${kind}.relay_gce_cell["${cellId}"]`)) + +test('accepts live cells the URL map pulls in only while they stay unchanged', () => { + const plan = productionC30Plan() + for (const address of liveCellResources) plan.push({ address, change: { actions: ['no-op'] } }) + assert.equal( + validateRelayAsiaTopologyPlan({ resource_changes: plan }, productionConfig).changes, + 4 + ) + for (const address of liveCellResources) { + for (const action of [['update'], ['delete'], ['create', 'delete'], ['delete', 'create']]) { + const drifted = productionC30Plan() + drifted.push({ address, change: { actions: action } }) + assert.throws( + () => validateRelayAsiaTopologyPlan({ resource_changes: drifted }, productionConfig), + new RegExp(`${address.replaceAll(/[.[\]]/g, '\\$&')} changes a live cell outside the planned wave`) + ) + } + } +}) + +test('accepts only a reviewed Asia topology wave', () => { + const argv = (environment, cellIds, planImage) => [ + '--plan-json', 'plan.json', '--environment', environment, '--cell-ids', cellIds, + '--region', 'asia-east2', '--image', planImage + ] + for (const cellIds of [ + 'production-gce-c27,production-gce-c28,production-gce-c29', + 'production-gce-c29,production-gce-c27,production-gce-c28', + 'production-gce-c30' + ]) { + assert.doesNotThrow( + () => parseRelayAsiaTopologyPlanArguments(argv('production', cellIds, productionImage)), + cellIds + ) + } + for (const cellIds of [ + 'production-gce-c27', + 'production-gce-c27,production-gce-c30', + 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30', + 'production-gce-c30,production-gce-c30', + 'production-gce-c31' + ]) { + assert.throws( + () => parseRelayAsiaTopologyPlanArguments(argv('production', cellIds, productionImage)), + /exact reviewed Asia topology set/, + cellIds + ) + } + assert.throws( + () => parseRelayAsiaTopologyPlanArguments(argv('staging', 'production-gce-c30', image)), + /exact reviewed Asia topology set/ + ) +}) + test('accepts an idempotent empty plan', () => { const noChanges = structuredClone(resources).map((resource) => ({ ...resource, diff --git a/cloud/docs/relay-incident-monitor.md b/cloud/docs/relay-incident-monitor.md index 8804b34fae6..500a2e5a140 100644 --- a/cloud/docs/relay-incident-monitor.md +++ b/cloud/docs/relay-incident-monitor.md @@ -330,7 +330,8 @@ without its segment is a compile error in relay-contract, not a silent gap. latest-sum over 24 healthy hours: mean ~100, 1-minute spikes to 216, with 10 minutes over the old bar of 160 — enough to freeze roughly one in ten 15-minute pre-drain gates on baseline noise. 250 cleared the healthy peaks - measured then and still fired well before the verified 400-connection ceiling; + measured then and still fired well before the 400-connection ceiling assumed at + the time (the live instance measured 500 on 2026-09-16); pool waiters and pool wait latency keep their strict thresholds. Superseded by the 2026-09-17 entry above, which re-measured a grown baseline against the 490-connection budget. diff --git a/cloud/docs/relay-workflows.md b/cloud/docs/relay-workflows.md index d81620b837d..472ee69b416 100644 --- a/cloud/docs/relay-workflows.md +++ b/cloud/docs/relay-workflows.md @@ -165,6 +165,28 @@ or moving a cell and rejects intervening drift. Then atomically register the new cells as migration-only, binding every mutation to the exact live selector generation and a durable attempt ID. Deploy and verify the director configuration only after registration, then promote C27 alone before C28/C29. + +The production Asia set is C27-C30. C27-C29 launched as one wave; C30 is an additive wave of its +own at the same shape. Its plan names C30's template, MIG, and backend plus the shared URL map, and +the URL map pulls every existing cell's backend, MIG, and template into the plan. Committed images +lag what same-cap rolls serve, so the workflow first reads each non-target cell's served image out +of its live template in state and plans that cell at it. It reads the committed cell map from a +no-refresh, unlocked plan over the same targets, not `terraform console`. Console evaluates every +output against state, so `relay_gce_cell_deployments` wraps each per-cell resource lookup in +`try`: until C30's topology apply, console succeeds and that output shows C30 with null MIG, +backend, and template fields. The validator then rejects any change to a +cell outside the wave, so the plan must read as C30's three creations plus the URL map update. +Before the apply dispatch, run the plan mode and read its `Plan:` line; any other drift, such as a +cell whose startup script changed since its last roll, fails the plan and must be rolled first. +Register C30 alone as migration-only, configure the director with `cell-ids` set to C30 while +regional rehome is paused, then promote it alone. Promotion requires C27-C29 to be general and takes +no input evidence. It runs the same five-minute production control and splice canary C27 ran, on +C30: the evidence must show the canary control was placed on C30, read C30's own runtime metrics, +and bind the selector generation, and any failure returns C30 to migration-only. Until then the +same-cap job lists C30 as a migration-only cell, so a same-cap roll hands it back isolated rather +than activating it. The follow-up after promotion adds C30 to the shadow gate's fleet pool list and +moves it to the same-cap general list together. Any later Asia cell follows the same pattern as its +own reviewed wave. Rollback returns Asia cells to migration-only; it does not destroy the network or use existing-only. The production topology dispatch remains unavailable until the diff --git a/cloud/infra/terraform/README.md b/cloud/infra/terraform/README.md index eb5f1699621..a41c609ddd5 100644 --- a/cloud/infra/terraform/README.md +++ b/cloud/infra/terraform/README.md @@ -323,7 +323,8 @@ only additive regional resources; cells select the subnet from their declared region. Every cell also declares an explicit database pool maximum in startup metadata and deployment outputs. The initial Asia shape is `e2-standard-4`, 3,000 physical connections, 60 unobserved connections, 6,000 request units, -and a database pool maximum of 10. +and a database pool maximum of 10. Production Asia pools now run at 16 and +staging C4 stays at 10; the topology and director validators pin both. Provision the complete identical Asia wave in one `Deploy Relay Asia Topology` saved plan. Its validator permits only the additive subnet/router/NAT, reviewed @@ -331,7 +332,10 @@ cell templates/MIGs/backends, and exact shared URL-map host additions. It rejects deletes, replacements, loss of an existing host route, US-resource changes, and unrelated drift. Do not add production C27-C29 until the compatible image has been published and each entry can pin its immutable -digest. +digest. A later cell, such as C30, is its own reviewed wave. The shared URL map +pulls every live cell into its plan, so the workflow plans each live cell at the +image its state template already serves, and the validator rejects any change +to a cell outside the wave. Topology creation intentionally does not apply the director resource. Once all MIGs and backends are healthy, register every new cell atomically as diff --git a/cloud/infra/terraform/environments/production.tfvars b/cloud/infra/terraform/environments/production.tfvars index dab79b262c6..0f6ce9deda0 100644 --- a/cloud/infra/terraform/environments/production.tfvars +++ b/cloud/infra/terraform/environments/production.tfvars @@ -393,6 +393,20 @@ relay_gce_cells = { connection_hard_cap = 3000 connection_unobserved_bound = 60 } + "production-gce-c30" = { + hostname = "c30" + region = "asia-east2" + zone = "asia-east2-a" + machine_type = "e2-standard-4" + boot_disk_gb = 30 + boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21" + capacity_requests = 6000 + database_pool_max = 16 # 176 ms from us-central1 Postgres saturates 10 (94-156 waiters). + image = "us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay@sha256:4158d8a2e18e9caec439d257f0c1e45d92ffea8c0262f057b2f08c76a134bcf0" + initially_enabled = false + connection_hard_cap = 3000 + connection_unobserved_bound = 60 + } } relay_region_rehome_source_cell_ids = [ @@ -415,7 +429,8 @@ relay_region_rehome_source_cell_ids = [ # Asia cells carry the same trust so mis-homed hosts can be drained back off them. "production-gce-c27", "production-gce-c28", - "production-gce-c29" + "production-gce-c29", + "production-gce-c30" ] # Slack #orca-relay-alerts, created out of band on 2026-08-05. Declared here because an apply diff --git a/cloud/infra/terraform/outputs.tf b/cloud/infra/terraform/outputs.tf index 5fd3b647b76..f886b137fce 100644 --- a/cloud/infra/terraform/outputs.tf +++ b/cloud/infra/terraform/outputs.tf @@ -158,17 +158,18 @@ output "relay_gce_cell_backend_services" { } output "relay_gce_cell_deployments" { + # try: a cell declared before its topology apply has no resources, and console evaluates this output. value = { for cell_id, cell in var.relay_gce_cells : cell_id => { origin = local.relay_gce_cell_urls[cell_id] region = cell.region zone = cell.zone - mig_name = google_compute_instance_group_manager.relay_gce_cell[cell_id].name - instance_group = google_compute_instance_group_manager.relay_gce_cell[cell_id].instance_group - backend_name = google_compute_backend_service.relay_gce_cell[cell_id].name - backend_id = google_compute_backend_service.relay_gce_cell[cell_id].id + mig_name = try(google_compute_instance_group_manager.relay_gce_cell[cell_id].name, null) + instance_group = try(google_compute_instance_group_manager.relay_gce_cell[cell_id].instance_group, null) + backend_name = try(google_compute_backend_service.relay_gce_cell[cell_id].name, null) + backend_id = try(google_compute_backend_service.relay_gce_cell[cell_id].id, null) url_map_name = google_compute_url_map.relay_gce[0].name - generation_identity = google_compute_instance_template.relay_gce_cell[cell_id].self_link + generation_identity = try(google_compute_instance_template.relay_gce_cell[cell_id].self_link, null) image = cell.image capacity_requests = cell.capacity_requests database_pool_max = cell.database_pool_max @@ -177,7 +178,7 @@ output "relay_gce_cell_deployments" { initially_enabled = cell.initially_enabled fenced = contains(var.relay_gce_fenced_cells, cell_id) desired_target_size = local.relay_gce_cell_target_sizes[cell_id] - target_size = google_compute_instance_group_manager.relay_gce_cell[cell_id].target_size + target_size = try(google_compute_instance_group_manager.relay_gce_cell[cell_id].target_size, null) } } description = "Non-secret candidate deployment topology consumed by the GCE preflight workflow." diff --git a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs index e54aae406f9..6461d4ac91d 100644 --- a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs +++ b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs @@ -406,8 +406,14 @@ const MERMAID_PACKAGE = 'node_modules/mermaid/' * * modules 4212 -> 4213 (+1) * local modules 1026 -> 1027 (+1) + * + * Muse then joined the mobile agent catalog with its bundled icon, one more local input to the + * shared agent picker. + * + * modules 4213 -> 4214 (+1) + * local modules 1027 -> 1028 (+1) */ -const SESSION_ROUTE_MODULES = 4213 +const SESSION_ROUTE_MODULES = 4214 /** What the page enters this route through once the route is a switch with a `.web.tsx` sibling. */ const ROUTE_ENTRY = [ diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 43eac7cb024..3954303811e 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -131,6 +131,9 @@ "../src/main/in-flight-run-dedupe.ts", "../src/main/kimi/hook-service.ts", "../src/main/kimi/kimi-hook-config-toml.ts", + "../src/main/muse/hook-config-json.ts", + "../src/main/muse/hook-service.ts", + "../src/main/muse/hook-settings.ts", "../src/main/openclaude/hook-service.ts", "../src/main/rolling-file-backup.ts", "../src/main/startup/hydrate-shell-path.ts", diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index aa2b20c2f63..27075756236 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -178,6 +178,7 @@ Funciona con **cualquier agente CLI** — si corre en una terminal, corre en Orc Grok logo Grok   Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   + Muse logo Muse   OpenCode logo OpenCode   Amp logo Amp   OpenClaude logo OpenClaude   diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index e1b261b11ec..86d18b43925 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -182,6 +182,7 @@ Fonctionne avec **n'importe quel agent CLI** — s'il tourne dans un terminal, i Logo Grok Grok   Logo Cursor Cursor   Logo GitHub Copilot GitHub Copilot   + Logo Muse Muse   Logo OpenCode OpenCode   Logo MiMo Code MiMo Code   Logo Amp Amp   diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index de3216562a6..ab5ea12e5fb 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -178,6 +178,7 @@ PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意の Grok logo Grok   Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   + Muse logo Muse   OpenCode logo OpenCode   Amp logo Amp   OpenClaude logo OpenClaude   diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index dbcea975ce6..6245658c152 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -178,6 +178,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내 Grok logo Grok   Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   + Muse logo Muse   OpenCode logo OpenCode   MiMo Code logo MiMo Code   Amp logo Amp   diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index b465615863b..53524927393 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -178,6 +178,7 @@ Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca. Logotipo do Grok Grok   Logotipo do Cursor Cursor   Logotipo do GitHub Copilot GitHub Copilot   + Logotipo do Muse Muse   Logotipo do OpenCode OpenCode   Logotipo do MiMo Code MiMo Code   Logotipo do Amp Amp   diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 69f0e8775ae..5a282888353 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -178,6 +178,7 @@ VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智 Grok logo Grok   Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   + Muse logo Muse   OpenCode logo OpenCode   Amp logo Amp   OpenClaude logo OpenClaude   diff --git a/docs/site/content/docs/agents/session-history.mdx b/docs/site/content/docs/agents/session-history.mdx index 0756b2448a5..2218cce349b 100644 --- a/docs/site/content/docs/agents/session-history.mdx +++ b/docs/site/content/docs/agents/session-history.mdx @@ -27,7 +27,7 @@ Remote workspaces can browse local history, but resume actions only run from loc The view-options menu (next to the search box) controls which agents are scanned, plus sort and grouping: -- **Agents** — toggle individual CLIs on or off (Claude, Codex, Hermes, Pi, OMP, Prime Agent, Cursor, Gemini, Antigravity, Rovo Dev, Copilot, OpenCode, Grok, OpenClaw, Devin, Droid, Kimi). Disabled agents are skipped during the scan. Use **Select all** / **Clear** in the Agents header to flip every agent at once — **Clear** leaves none selected so you can turn on only the CLI you care about without unchecking a long list. An empty selection shows **No agents selected** instead of the usual empty-filter message. +- **Agents** — toggle individual CLIs on or off (Claude, Codex, Hermes, Pi, OMP, Prime Agent, Cursor, Gemini, Antigravity, Rovo Dev, Copilot, OpenCode, Grok, OpenClaw, Devin, Droid, Kimi, Muse). Disabled agents are skipped during the scan. Use **Select all** / **Clear** in the Agents header to flip every agent at once — **Clear** leaves none selected so you can turn on only the CLI you care about without unchecking a long list. An empty selection shows **No agents selected** instead of the usual empty-filter message. - **Sort** — `Last updated` or `Created`. - **Group** — `Project`, `Folder` (one heading per `cwd`), or `Agent` (one heading per CLI). - **Hide empty sessions** — drop sessions with zero recorded messages. diff --git a/docs/site/content/docs/agents/supported.mdx b/docs/site/content/docs/agents/supported.mdx index 6fef0e2bb53..f5774c11f18 100644 --- a/docs/site/content/docs/agents/supported.mdx +++ b/docs/site/content/docs/agents/supported.mdx @@ -16,7 +16,7 @@ Orca works with **any CLI agent** — the agent combobox just launches a process ## Permissions default -For new launches, Orca pre-fills each supported CLI's permission-bypass flag — `--dangerously-skip-permissions` for Claude, `--dangerously-bypass-approvals-and-sandbox` for Codex, `--yolo` for Gemini / Cursor / Crush / Kimi / Rovo Dev / Hermes / GitHub Copilot / Command Code, plus the equivalent flag for every other agent that exposes one. These flags allow an agent to act without confirming every shell command; review the trust boundary before using them. +For new launches, Orca pre-fills each supported CLI's permission-bypass flag — `--dangerously-skip-permissions` for Claude, `--dangerously-bypass-approvals-and-sandbox` for Codex, `--yolo` for Gemini / Cursor / Crush / Kimi / Muse / Rovo Dev / Hermes / GitHub Copilot / Command Code, plus the equivalent flag for every other agent that exposes one. These flags allow an agent to act without confirming every shell command; review the trust boundary before using them. Use **Settings → Agents → Agent Permissions** when you want to switch all uncustomized agents between **Yolo** and **Manual** launches. If you already overrode a specific agent's launch arguments or environment, Orca leaves that agent alone so the global switch doesn't erase your custom command. @@ -48,6 +48,7 @@ To restore prompts for one agent only, edit that agent's default arguments or en | Codebuff | Auto-setup | [Codebuff](https://www.codebuff.com/docs/help/quick-start) | | Freebuff | Auto-setup | [Freebuff](https://freebuff.com/cli) | | Command Code | Auto-setup, status | [Command Code](https://commandcode.ai/docs/quickstart) | +| Muse | macOS/Linux; trusts the workspace at launch | [Meta](https://dev.meta.ai/docs/muse-code) | | Continue | Auto-setup | [Continue](https://docs.continue.dev/guides/cli) | | Cursor CLI | Deep integration | [Cursor](https://cursor.com/cli) | | Devin | Auto-setup | [Devin](https://devin.ai/cli) | diff --git a/mobile/src/components/mobile-agent-icon-assets.ts b/mobile/src/components/mobile-agent-icon-assets.ts index e9b62da8c03..ee07291b3b6 100644 --- a/mobile/src/components/mobile-agent-icon-assets.ts +++ b/mobile/src/components/mobile-agent-icon-assets.ts @@ -40,5 +40,6 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial> 'mimo-code': 'mimo.xiaomi.com', ante: 'antigma.ai', trae: 'www.trae.cn', + muse: 'dev.meta.ai', omp: 'omp.sh', 'prime-agent': 'primeintellect.ai', gemini: 'gemini.google.com', diff --git a/resources/skills/current-manifest.json b/resources/skills/current-manifest.json index 0353868f591..c0c492a2736 100644 --- a/resources/skills/current-manifest.json +++ b/resources/skills/current-manifest.json @@ -5,17 +5,17 @@ "name": "computer-use", "sourcePath": "skills/computer-use", "releaseRevision": 9, - "packageDigest": "ff60c0d0fcb142047fbb83477b829459cfb57d9e6f7fb715644e2b13aa441bf1", - "gitTreeSha": "335986bf5b78557d5d6973eea183eeb2dc527eba", + "packageDigest": "425634e3ebf27690cc613eaf17b6337b36769153ec6bca85dc4ebf65d4d6b8b4", + "gitTreeSha": "b59f27370a41225c22127e91da0c91cd2519f217", "files": [ { "path": "SKILL.md", - "size": 2050, + "size": 2211, "executable": false, "classification": "text", - "exactSha256": "244b06656c849aaaa79c29a6053fd4c2be98933ca10bd1edaeb727ce73ede793", - "textNormalizedSha256": "244b06656c849aaaa79c29a6053fd4c2be98933ca10bd1edaeb727ce73ede793", - "identitySha256": "244b06656c849aaaa79c29a6053fd4c2be98933ca10bd1edaeb727ce73ede793" + "exactSha256": "2839933fd35216845461466403e6061488005f6df57126d0cd0f769ea45753f3", + "textNormalizedSha256": "2839933fd35216845461466403e6061488005f6df57126d0cd0f769ea45753f3", + "identitySha256": "2839933fd35216845461466403e6061488005f6df57126d0cd0f769ea45753f3" } ] }, @@ -23,17 +23,17 @@ "name": "linear-tickets", "sourcePath": "skills/linear-tickets", "releaseRevision": 11, - "packageDigest": "2c8a0bae253341fd3147e3fc0b41ab1a298df31f6768be46eee31b7da9a4b059", - "gitTreeSha": "01b3a89c1c3209f8b2de1ae05014937b0cfc58b2", + "packageDigest": "1eab442d048b79ab0b836adf57663ac384988dd79172ec5f14193eb05e037715", + "gitTreeSha": "30d9b40144d4a9a07ce12f0d1a9261fd9cf9649b", "files": [ { "path": "SKILL.md", - "size": 2070, + "size": 2231, "executable": false, "classification": "text", - "exactSha256": "af2d33d98c21e22726a6a36a3e41b1967770c4df6691530d02409d8aca861c15", - "textNormalizedSha256": "af2d33d98c21e22726a6a36a3e41b1967770c4df6691530d02409d8aca861c15", - "identitySha256": "af2d33d98c21e22726a6a36a3e41b1967770c4df6691530d02409d8aca861c15" + "exactSha256": "e181f86073da65c492361469d504fe15e7ae61bc99990bc41bea47aa13455619", + "textNormalizedSha256": "e181f86073da65c492361469d504fe15e7ae61bc99990bc41bea47aa13455619", + "identitySha256": "e181f86073da65c492361469d504fe15e7ae61bc99990bc41bea47aa13455619" } ] }, @@ -41,17 +41,17 @@ "name": "orca-cli", "sourcePath": "skills/orca-cli", "releaseRevision": 37, - "packageDigest": "15b5fd49198b080322a55545932acfb2e8351c88746fac468df73ea999693260", - "gitTreeSha": "ae1a86f92d7bf38f4dc161cc0c57e15a74f54832", + "packageDigest": "0736bcbbb69ed18f9a36a58ad2eda47b6db55b30509953cf5ba5f0032058c535", + "gitTreeSha": "572f7952ac451a30a9c2451b472a639b125ee584", "files": [ { "path": "SKILL.md", - "size": 2211, + "size": 2372, "executable": false, "classification": "text", - "exactSha256": "6522a3355993b377abe7d7a5c8f6a00f9726e1a6a6b9aa9c015651e8d0f5e6b8", - "textNormalizedSha256": "6522a3355993b377abe7d7a5c8f6a00f9726e1a6a6b9aa9c015651e8d0f5e6b8", - "identitySha256": "6522a3355993b377abe7d7a5c8f6a00f9726e1a6a6b9aa9c015651e8d0f5e6b8" + "exactSha256": "aa76f86505010096e8ea9edda1705a78aae7af45e54485f3fe0460664c1d9e4a", + "textNormalizedSha256": "aa76f86505010096e8ea9edda1705a78aae7af45e54485f3fe0460664c1d9e4a", + "identitySha256": "aa76f86505010096e8ea9edda1705a78aae7af45e54485f3fe0460664c1d9e4a" } ] }, @@ -59,17 +59,17 @@ "name": "orca-emulator", "sourcePath": "skills/orca-emulator", "releaseRevision": 8, - "packageDigest": "54a3b8e534d3e9cb63fab11bfd3690908b21385398da06c618b6fd63851317c5", - "gitTreeSha": "bd23a74f2c55b393fe288f9e2806d0ebc028a513", + "packageDigest": "1dc42e5addc613abd85eba639d4ac36d9c7b3bc6f7186f0a2d54d10dae3f06d3", + "gitTreeSha": "110f6ab59bd73428d7de28bd4761d1fc332efa20", "files": [ { "path": "SKILL.md", - "size": 2176, + "size": 2337, "executable": false, "classification": "text", - "exactSha256": "654746c72c0fa4aaa540c3aa7450413404450c195bcdeaf0aaa27fa114d0f058", - "textNormalizedSha256": "654746c72c0fa4aaa540c3aa7450413404450c195bcdeaf0aaa27fa114d0f058", - "identitySha256": "654746c72c0fa4aaa540c3aa7450413404450c195bcdeaf0aaa27fa114d0f058" + "exactSha256": "3da7191179e46cb0e1a6a936f1eb54254f6e98ec38849e1c95f0e11073076b48", + "textNormalizedSha256": "3da7191179e46cb0e1a6a936f1eb54254f6e98ec38849e1c95f0e11073076b48", + "identitySha256": "3da7191179e46cb0e1a6a936f1eb54254f6e98ec38849e1c95f0e11073076b48" } ] }, @@ -77,17 +77,17 @@ "name": "orca-emulator-android", "sourcePath": "skills/orca-emulator-android", "releaseRevision": 6, - "packageDigest": "bf670be58d2650274943b32b1abcdc58b135b0ad81f96aaee491f47af32fe2f5", - "gitTreeSha": "2dd0b64d4e5ef4748b5fb30fb7bdf0aa13f51084", + "packageDigest": "c348091d953427fc9800a1d49d94054866348008766879b24ef742cb613dc3d9", + "gitTreeSha": "437ed5e35698ee6421386a5a08fe7f8c7cf1a2a7", "files": [ { "path": "SKILL.md", - "size": 2073, + "size": 2234, "executable": false, "classification": "text", - "exactSha256": "ae242330d98c9335160fbd4356894e15633d63c02da4edfd7109ab1845368002", - "textNormalizedSha256": "ae242330d98c9335160fbd4356894e15633d63c02da4edfd7109ab1845368002", - "identitySha256": "ae242330d98c9335160fbd4356894e15633d63c02da4edfd7109ab1845368002" + "exactSha256": "3ade4e6f8f2717ca899fd841e61f116963a406e9527015b803854c16d012e278", + "textNormalizedSha256": "3ade4e6f8f2717ca899fd841e61f116963a406e9527015b803854c16d012e278", + "identitySha256": "3ade4e6f8f2717ca899fd841e61f116963a406e9527015b803854c16d012e278" } ] }, @@ -95,17 +95,17 @@ "name": "orca-linear", "sourcePath": "skills/orca-linear", "releaseRevision": 9, - "packageDigest": "86c7e2b1d2712cea280ceac45b2cefcb98591cb25fa46539cc9e159338caa1bb", - "gitTreeSha": "2b0b3b3d422f0d9cdb88574e955c345ed4370ea8", + "packageDigest": "95f52429823e887317046f23b671d05408a947c296e5b2e44e9173dfc1d5aa4e", + "gitTreeSha": "8e747165847651926ca54804b012689ebcbd9432", "files": [ { "path": "SKILL.md", - "size": 1927, + "size": 2088, "executable": false, "classification": "text", - "exactSha256": "85ee0d4d3cfadfec301e3a852c8ff959a1f366ac51b9c312cf463f050e427e72", - "textNormalizedSha256": "85ee0d4d3cfadfec301e3a852c8ff959a1f366ac51b9c312cf463f050e427e72", - "identitySha256": "85ee0d4d3cfadfec301e3a852c8ff959a1f366ac51b9c312cf463f050e427e72" + "exactSha256": "8da23ef96470906315cace8ff84f8056255ee37d1b2d5db84d8b7b3270a0ba0f", + "textNormalizedSha256": "8da23ef96470906315cace8ff84f8056255ee37d1b2d5db84d8b7b3270a0ba0f", + "identitySha256": "8da23ef96470906315cace8ff84f8056255ee37d1b2d5db84d8b7b3270a0ba0f" } ] }, @@ -113,17 +113,17 @@ "name": "orca-per-workspace-env", "sourcePath": "skills/orca-per-workspace-env", "releaseRevision": 6, - "packageDigest": "b41563e217d38af2ded7d88ea099a9f996a5280f3333e771a2867a0e3f680055", - "gitTreeSha": "49103d96472ad790758f14cfc3ed5c69434a6f1b", + "packageDigest": "103f0671da111c9ad3def6c46da8d432e656878b840e6cc742219f4a3a4dbceb", + "gitTreeSha": "58dfb5dc3ad287a6a42625f9d15c7c0c3dfd02ec", "files": [ { "path": "SKILL.md", - "size": 2096, + "size": 2257, "executable": false, "classification": "text", - "exactSha256": "d3a23c0d3e87c6024f710145e0cc6e5c7eb37eda1386c27d074f2538a92b7d1c", - "textNormalizedSha256": "d3a23c0d3e87c6024f710145e0cc6e5c7eb37eda1386c27d074f2538a92b7d1c", - "identitySha256": "d3a23c0d3e87c6024f710145e0cc6e5c7eb37eda1386c27d074f2538a92b7d1c" + "exactSha256": "c005d126a13d2913351472f07690f1c1a660d4f286e2a5f21864aee294f436ce", + "textNormalizedSha256": "c005d126a13d2913351472f07690f1c1a660d4f286e2a5f21864aee294f436ce", + "identitySha256": "c005d126a13d2913351472f07690f1c1a660d4f286e2a5f21864aee294f436ce" } ] }, @@ -131,17 +131,17 @@ "name": "orchestration", "sourcePath": "skills/orchestration", "releaseRevision": 29, - "packageDigest": "00d30c68d91693b6210e43c1fca9ba8b8711e31b4d76d1fcbeaa6257209d569f", - "gitTreeSha": "23bc2ff6bb9f6e7d17f41734709ac951dbe3ee80", + "packageDigest": "195f26431ecfb6df41b941b22958a2330b110bac7b7e97004e0b35fe586e41d9", + "gitTreeSha": "b0cd1d58b0c317cf7c3726fef7e6b1197c405246", "files": [ { "path": "SKILL.md", - "size": 3510, + "size": 3671, "executable": false, "classification": "text", - "exactSha256": "36bc32f022447418b566db35f9ffcff75113eb023e37d06fd1bd609c5dd173ef", - "textNormalizedSha256": "36bc32f022447418b566db35f9ffcff75113eb023e37d06fd1bd609c5dd173ef", - "identitySha256": "36bc32f022447418b566db35f9ffcff75113eb023e37d06fd1bd609c5dd173ef" + "exactSha256": "cd1b364bf35781bad06bf75ab1766afa8d6cec69cb2060529691d89871a2098a", + "textNormalizedSha256": "cd1b364bf35781bad06bf75ab1766afa8d6cec69cb2060529691d89871a2098a", + "identitySha256": "cd1b364bf35781bad06bf75ab1766afa8d6cec69cb2060529691d89871a2098a" } ] } diff --git a/resources/skills/snapshot-registry.json b/resources/skills/snapshot-registry.json index 5ca43872680..731d0a85c8e 100644 --- a/resources/skills/snapshot-registry.json +++ b/resources/skills/snapshot-registry.json @@ -580,17 +580,17 @@ }, { "releaseRevision": 37, - "packageDigest": "15b5fd49198b080322a55545932acfb2e8351c88746fac468df73ea999693260", - "gitTreeSha": "ae1a86f92d7bf38f4dc161cc0c57e15a74f54832", + "packageDigest": "0736bcbbb69ed18f9a36a58ad2eda47b6db55b30509953cf5ba5f0032058c535", + "gitTreeSha": "572f7952ac451a30a9c2451b472a639b125ee584", "files": [ { "path": "SKILL.md", - "size": 2211, + "size": 2372, "executable": false, "classification": "text", - "exactSha256": "6522a3355993b377abe7d7a5c8f6a00f9726e1a6a6b9aa9c015651e8d0f5e6b8", - "textNormalizedSha256": "6522a3355993b377abe7d7a5c8f6a00f9726e1a6a6b9aa9c015651e8d0f5e6b8", - "identitySha256": "6522a3355993b377abe7d7a5c8f6a00f9726e1a6a6b9aa9c015651e8d0f5e6b8" + "exactSha256": "aa76f86505010096e8ea9edda1705a78aae7af45e54485f3fe0460664c1d9e4a", + "textNormalizedSha256": "aa76f86505010096e8ea9edda1705a78aae7af45e54485f3fe0460664c1d9e4a", + "identitySha256": "aa76f86505010096e8ea9edda1705a78aae7af45e54485f3fe0460664c1d9e4a" } ] } @@ -1046,17 +1046,17 @@ }, { "releaseRevision": 29, - "packageDigest": "00d30c68d91693b6210e43c1fca9ba8b8711e31b4d76d1fcbeaa6257209d569f", - "gitTreeSha": "23bc2ff6bb9f6e7d17f41734709ac951dbe3ee80", + "packageDigest": "195f26431ecfb6df41b941b22958a2330b110bac7b7e97004e0b35fe586e41d9", + "gitTreeSha": "b0cd1d58b0c317cf7c3726fef7e6b1197c405246", "files": [ { "path": "SKILL.md", - "size": 3510, + "size": 3671, "executable": false, "classification": "text", - "exactSha256": "36bc32f022447418b566db35f9ffcff75113eb023e37d06fd1bd609c5dd173ef", - "textNormalizedSha256": "36bc32f022447418b566db35f9ffcff75113eb023e37d06fd1bd609c5dd173ef", - "identitySha256": "36bc32f022447418b566db35f9ffcff75113eb023e37d06fd1bd609c5dd173ef" + "exactSha256": "cd1b364bf35781bad06bf75ab1766afa8d6cec69cb2060529691d89871a2098a", + "textNormalizedSha256": "cd1b364bf35781bad06bf75ab1766afa8d6cec69cb2060529691d89871a2098a", + "identitySha256": "cd1b364bf35781bad06bf75ab1766afa8d6cec69cb2060529691d89871a2098a" } ] } @@ -1210,17 +1210,17 @@ }, { "releaseRevision": 9, - "packageDigest": "ff60c0d0fcb142047fbb83477b829459cfb57d9e6f7fb715644e2b13aa441bf1", - "gitTreeSha": "335986bf5b78557d5d6973eea183eeb2dc527eba", + "packageDigest": "425634e3ebf27690cc613eaf17b6337b36769153ec6bca85dc4ebf65d4d6b8b4", + "gitTreeSha": "b59f27370a41225c22127e91da0c91cd2519f217", "files": [ { "path": "SKILL.md", - "size": 2050, + "size": 2211, "executable": false, "classification": "text", - "exactSha256": "244b06656c849aaaa79c29a6053fd4c2be98933ca10bd1edaeb727ce73ede793", - "textNormalizedSha256": "244b06656c849aaaa79c29a6053fd4c2be98933ca10bd1edaeb727ce73ede793", - "identitySha256": "244b06656c849aaaa79c29a6053fd4c2be98933ca10bd1edaeb727ce73ede793" + "exactSha256": "2839933fd35216845461466403e6061488005f6df57126d0cd0f769ea45753f3", + "textNormalizedSha256": "2839933fd35216845461466403e6061488005f6df57126d0cd0f769ea45753f3", + "identitySha256": "2839933fd35216845461466403e6061488005f6df57126d0cd0f769ea45753f3" } ] } @@ -1340,17 +1340,17 @@ }, { "releaseRevision": 8, - "packageDigest": "54a3b8e534d3e9cb63fab11bfd3690908b21385398da06c618b6fd63851317c5", - "gitTreeSha": "bd23a74f2c55b393fe288f9e2806d0ebc028a513", + "packageDigest": "1dc42e5addc613abd85eba639d4ac36d9c7b3bc6f7186f0a2d54d10dae3f06d3", + "gitTreeSha": "110f6ab59bd73428d7de28bd4761d1fc332efa20", "files": [ { "path": "SKILL.md", - "size": 2176, + "size": 2337, "executable": false, "classification": "text", - "exactSha256": "654746c72c0fa4aaa540c3aa7450413404450c195bcdeaf0aaa27fa114d0f058", - "textNormalizedSha256": "654746c72c0fa4aaa540c3aa7450413404450c195bcdeaf0aaa27fa114d0f058", - "identitySha256": "654746c72c0fa4aaa540c3aa7450413404450c195bcdeaf0aaa27fa114d0f058" + "exactSha256": "3da7191179e46cb0e1a6a936f1eb54254f6e98ec38849e1c95f0e11073076b48", + "textNormalizedSha256": "3da7191179e46cb0e1a6a936f1eb54254f6e98ec38849e1c95f0e11073076b48", + "identitySha256": "3da7191179e46cb0e1a6a936f1eb54254f6e98ec38849e1c95f0e11073076b48" } ] } @@ -1518,17 +1518,17 @@ }, { "releaseRevision": 11, - "packageDigest": "2c8a0bae253341fd3147e3fc0b41ab1a298df31f6768be46eee31b7da9a4b059", - "gitTreeSha": "01b3a89c1c3209f8b2de1ae05014937b0cfc58b2", + "packageDigest": "1eab442d048b79ab0b836adf57663ac384988dd79172ec5f14193eb05e037715", + "gitTreeSha": "30d9b40144d4a9a07ce12f0d1a9261fd9cf9649b", "files": [ { "path": "SKILL.md", - "size": 2070, + "size": 2231, "executable": false, "classification": "text", - "exactSha256": "af2d33d98c21e22726a6a36a3e41b1967770c4df6691530d02409d8aca861c15", - "textNormalizedSha256": "af2d33d98c21e22726a6a36a3e41b1967770c4df6691530d02409d8aca861c15", - "identitySha256": "af2d33d98c21e22726a6a36a3e41b1967770c4df6691530d02409d8aca861c15" + "exactSha256": "e181f86073da65c492361469d504fe15e7ae61bc99990bc41bea47aa13455619", + "textNormalizedSha256": "e181f86073da65c492361469d504fe15e7ae61bc99990bc41bea47aa13455619", + "identitySha256": "e181f86073da65c492361469d504fe15e7ae61bc99990bc41bea47aa13455619" } ] } @@ -1664,17 +1664,17 @@ }, { "releaseRevision": 9, - "packageDigest": "86c7e2b1d2712cea280ceac45b2cefcb98591cb25fa46539cc9e159338caa1bb", - "gitTreeSha": "2b0b3b3d422f0d9cdb88574e955c345ed4370ea8", + "packageDigest": "95f52429823e887317046f23b671d05408a947c296e5b2e44e9173dfc1d5aa4e", + "gitTreeSha": "8e747165847651926ca54804b012689ebcbd9432", "files": [ { "path": "SKILL.md", - "size": 1927, + "size": 2088, "executable": false, "classification": "text", - "exactSha256": "85ee0d4d3cfadfec301e3a852c8ff959a1f366ac51b9c312cf463f050e427e72", - "textNormalizedSha256": "85ee0d4d3cfadfec301e3a852c8ff959a1f366ac51b9c312cf463f050e427e72", - "identitySha256": "85ee0d4d3cfadfec301e3a852c8ff959a1f366ac51b9c312cf463f050e427e72" + "exactSha256": "8da23ef96470906315cace8ff84f8056255ee37d1b2d5db84d8b7b3270a0ba0f", + "textNormalizedSha256": "8da23ef96470906315cace8ff84f8056255ee37d1b2d5db84d8b7b3270a0ba0f", + "identitySha256": "8da23ef96470906315cace8ff84f8056255ee37d1b2d5db84d8b7b3270a0ba0f" } ] } @@ -1762,17 +1762,17 @@ }, { "releaseRevision": 6, - "packageDigest": "bf670be58d2650274943b32b1abcdc58b135b0ad81f96aaee491f47af32fe2f5", - "gitTreeSha": "2dd0b64d4e5ef4748b5fb30fb7bdf0aa13f51084", + "packageDigest": "c348091d953427fc9800a1d49d94054866348008766879b24ef742cb613dc3d9", + "gitTreeSha": "437ed5e35698ee6421386a5a08fe7f8c7cf1a2a7", "files": [ { "path": "SKILL.md", - "size": 2073, + "size": 2234, "executable": false, "classification": "text", - "exactSha256": "ae242330d98c9335160fbd4356894e15633d63c02da4edfd7109ab1845368002", - "textNormalizedSha256": "ae242330d98c9335160fbd4356894e15633d63c02da4edfd7109ab1845368002", - "identitySha256": "ae242330d98c9335160fbd4356894e15633d63c02da4edfd7109ab1845368002" + "exactSha256": "3ade4e6f8f2717ca899fd841e61f116963a406e9527015b803854c16d012e278", + "textNormalizedSha256": "3ade4e6f8f2717ca899fd841e61f116963a406e9527015b803854c16d012e278", + "identitySha256": "3ade4e6f8f2717ca899fd841e61f116963a406e9527015b803854c16d012e278" } ] } @@ -1860,17 +1860,17 @@ }, { "releaseRevision": 6, - "packageDigest": "b41563e217d38af2ded7d88ea099a9f996a5280f3333e771a2867a0e3f680055", - "gitTreeSha": "49103d96472ad790758f14cfc3ed5c69434a6f1b", + "packageDigest": "103f0671da111c9ad3def6c46da8d432e656878b840e6cc742219f4a3a4dbceb", + "gitTreeSha": "58dfb5dc3ad287a6a42625f9d15c7c0c3dfd02ec", "files": [ { "path": "SKILL.md", - "size": 2096, + "size": 2257, "executable": false, "classification": "text", - "exactSha256": "d3a23c0d3e87c6024f710145e0cc6e5c7eb37eda1386c27d074f2538a92b7d1c", - "textNormalizedSha256": "d3a23c0d3e87c6024f710145e0cc6e5c7eb37eda1386c27d074f2538a92b7d1c", - "identitySha256": "d3a23c0d3e87c6024f710145e0cc6e5c7eb37eda1386c27d074f2538a92b7d1c" + "exactSha256": "c005d126a13d2913351472f07690f1c1a660d4f286e2a5f21864aee294f436ce", + "textNormalizedSha256": "c005d126a13d2913351472f07690f1c1a660d4f286e2a5f21864aee294f436ce", + "identitySha256": "c005d126a13d2913351472f07690f1c1a660d4f286e2a5f21864aee294f436ce" } ] } diff --git a/skill-stubs/_shared/cli-resolution.md b/skill-stubs/_shared/cli-resolution.md index c5cebf36e56..8c898be5ad6 100644 --- a/skill-stubs/_shared/cli-resolution.md +++ b/skill-stubs/_shared/cli-resolution.md @@ -25,5 +25,7 @@ to another executable, which could silently target a different Orca build. Prefer `--json`. Use the selected executable's `--help` for commands or flags the guide does not cover. If a command reports that Orca is not running, start it with `ORCA open --json` -and retry. If `skills get` is unknown, explain that updating Orca restores the guide; use -`--help` for read-only discovery and do not guess unsupported commands. +and retry. If it fails with `runtime_access_denied`, your sandbox blocked the connection: +re-run it with escalated permissions, and do not run `ORCA open` or restart Orca. If +`skills get` is unknown, explain that updating Orca restores the guide; use `--help` for +read-only discovery and do not guess unsupported commands. diff --git a/skills/computer-use/SKILL.md b/skills/computer-use/SKILL.md index 897d6f9b01f..8c89c613921 100644 --- a/skills/computer-use/SKILL.md +++ b/skills/computer-use/SKILL.md @@ -40,5 +40,7 @@ ORCA skills get computer-use Prefer `--json`. Use the selected executable's `--help` for commands or flags the guide does not cover. If a command reports that Orca is not running, start it with `ORCA open --json` -and retry. If `skills get` is unknown, explain that updating Orca restores the guide; use -`--help` for read-only discovery and do not guess unsupported commands. +and retry. If it fails with `runtime_access_denied`, your sandbox blocked the connection: +re-run it with escalated permissions, and do not run `ORCA open` or restart Orca. If +`skills get` is unknown, explain that updating Orca restores the guide; use `--help` for +read-only discovery and do not guess unsupported commands. diff --git a/skills/linear-tickets/SKILL.md b/skills/linear-tickets/SKILL.md index ddb98f19968..86c9eba8285 100644 --- a/skills/linear-tickets/SKILL.md +++ b/skills/linear-tickets/SKILL.md @@ -41,5 +41,7 @@ ORCA skills get linear-tickets Prefer `--json`. Use the selected executable's `--help` for commands or flags the guide does not cover. If a command reports that Orca is not running, start it with `ORCA open --json` -and retry. If `skills get` is unknown, explain that updating Orca restores the guide; use -`--help` for read-only discovery and do not guess unsupported commands. +and retry. If it fails with `runtime_access_denied`, your sandbox blocked the connection: +re-run it with escalated permissions, and do not run `ORCA open` or restart Orca. If +`skills get` is unknown, explain that updating Orca restores the guide; use `--help` for +read-only discovery and do not guess unsupported commands. diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index b8e2c6b89f6..fbea1b6566c 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -41,5 +41,7 @@ ORCA skills get orca-cli Prefer `--json`. Use the selected executable's `--help` for commands or flags the guide does not cover. If a command reports that Orca is not running, start it with `ORCA open --json` -and retry. If `skills get` is unknown, explain that updating Orca restores the guide; use -`--help` for read-only discovery and do not guess unsupported commands. +and retry. If it fails with `runtime_access_denied`, your sandbox blocked the connection: +re-run it with escalated permissions, and do not run `ORCA open` or restart Orca. If +`skills get` is unknown, explain that updating Orca restores the guide; use `--help` for +read-only discovery and do not guess unsupported commands. diff --git a/skills/orca-emulator-android/SKILL.md b/skills/orca-emulator-android/SKILL.md index 40fbfa07bd4..3754741f4ad 100644 --- a/skills/orca-emulator-android/SKILL.md +++ b/skills/orca-emulator-android/SKILL.md @@ -41,5 +41,7 @@ ORCA skills get orca-emulator-android Prefer `--json`. Use the selected executable's `--help` for commands or flags the guide does not cover. If a command reports that Orca is not running, start it with `ORCA open --json` -and retry. If `skills get` is unknown, explain that updating Orca restores the guide; use -`--help` for read-only discovery and do not guess unsupported commands. +and retry. If it fails with `runtime_access_denied`, your sandbox blocked the connection: +re-run it with escalated permissions, and do not run `ORCA open` or restart Orca. If +`skills get` is unknown, explain that updating Orca restores the guide; use `--help` for +read-only discovery and do not guess unsupported commands. diff --git a/skills/orca-emulator/SKILL.md b/skills/orca-emulator/SKILL.md index d79f8941dd5..317182bfbae 100644 --- a/skills/orca-emulator/SKILL.md +++ b/skills/orca-emulator/SKILL.md @@ -44,5 +44,7 @@ ORCA skills get orca-emulator Prefer `--json`. Use the selected executable's `--help` for commands or flags the guide does not cover. If a command reports that Orca is not running, start it with `ORCA open --json` -and retry. If `skills get` is unknown, explain that updating Orca restores the guide; use -`--help` for read-only discovery and do not guess unsupported commands. +and retry. If it fails with `runtime_access_denied`, your sandbox blocked the connection: +re-run it with escalated permissions, and do not run `ORCA open` or restart Orca. If +`skills get` is unknown, explain that updating Orca restores the guide; use `--help` for +read-only discovery and do not guess unsupported commands. diff --git a/skills/orca-linear/SKILL.md b/skills/orca-linear/SKILL.md index d4b15fe141f..f70f55ca41e 100644 --- a/skills/orca-linear/SKILL.md +++ b/skills/orca-linear/SKILL.md @@ -39,5 +39,7 @@ ORCA skills get orca-linear Prefer `--json`. Use the selected executable's `--help` for commands or flags the guide does not cover. If a command reports that Orca is not running, start it with `ORCA open --json` -and retry. If `skills get` is unknown, explain that updating Orca restores the guide; use -`--help` for read-only discovery and do not guess unsupported commands. +and retry. If it fails with `runtime_access_denied`, your sandbox blocked the connection: +re-run it with escalated permissions, and do not run `ORCA open` or restart Orca. If +`skills get` is unknown, explain that updating Orca restores the guide; use `--help` for +read-only discovery and do not guess unsupported commands. diff --git a/skills/orca-per-workspace-env/SKILL.md b/skills/orca-per-workspace-env/SKILL.md index 7d350bdb90f..ff37d027767 100644 --- a/skills/orca-per-workspace-env/SKILL.md +++ b/skills/orca-per-workspace-env/SKILL.md @@ -40,5 +40,7 @@ ORCA skills get orca-per-workspace-env Prefer `--json`. Use the selected executable's `--help` for commands or flags the guide does not cover. If a command reports that Orca is not running, start it with `ORCA open --json` -and retry. If `skills get` is unknown, explain that updating Orca restores the guide; use -`--help` for read-only discovery and do not guess unsupported commands. +and retry. If it fails with `runtime_access_denied`, your sandbox blocked the connection: +re-run it with escalated permissions, and do not run `ORCA open` or restart Orca. If +`skills get` is unknown, explain that updating Orca restores the guide; use `--help` for +read-only discovery and do not guess unsupported commands. diff --git a/skills/orchestration/SKILL.md b/skills/orchestration/SKILL.md index b9ec4caa0d5..4ecd42624d4 100644 --- a/skills/orchestration/SKILL.md +++ b/skills/orchestration/SKILL.md @@ -60,5 +60,7 @@ reference that gate names with Prefer `--json`. Use the selected executable's `--help` for commands or flags the guide does not cover. If a command reports that Orca is not running, start it with `ORCA open --json` -and retry. If `skills get` is unknown, explain that updating Orca restores the guide; use -`--help` for read-only discovery and do not guess unsupported commands. +and retry. If it fails with `runtime_access_denied`, your sandbox blocked the connection: +re-run it with escalated permissions, and do not run `ORCA open` or restart Orca. If +`skills get` is unknown, explain that updating Orca restores the guide; use `--help` for +read-only discovery and do not guess unsupported commands. diff --git a/src/cli/runtime/runtime-access-denied.test.ts b/src/cli/runtime/runtime-access-denied.test.ts new file mode 100644 index 00000000000..986a58bb776 --- /dev/null +++ b/src/cli/runtime/runtime-access-denied.test.ts @@ -0,0 +1,151 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMetadata } from '../../shared/runtime-bootstrap' +import { formatCliError, reportCliError } from '../cli-error' +import { RuntimeClient } from './client' +import { launchOrcaApp } from './launch' +import { getCliStatus } from './status' +import { sendRequest } from './transport' + +const { connect, tryReadMetadata } = vi.hoisted(() => ({ + connect: vi.fn(), + tryReadMetadata: vi.fn() +})) +vi.mock('node:net', () => ({ createConnection: connect })) +vi.mock('./metadata', () => ({ tryReadMetadata, readMetadata: tryReadMetadata })) +vi.mock('./launch', () => ({ launchOrcaApp: vi.fn() })) +vi.mock('./runtime-remote-pairing', () => ({ resolveRemotePairing: () => null })) + +const metadata: RuntimeMetadata = { + runtimeId: 'runtime-test', + pid: 12345, + transports: [{ kind: 'unix', endpoint: '/private-runtime.sock' }], + authToken: 'private-runtime-token', + startedAt: 1 +} + +class TestSocket extends EventEmitter { + setEncoding = vi.fn() + end = vi.fn() + destroy = vi.fn() + write = vi.fn() +} + +const RESTART_OR_ABSENT_ADVICE = + /Restart Orca and try again|Orca is not running|Run 'orca open' first/ + +let socket: TestSocket + +beforeEach(() => { + vi.stubEnv('CODEX_SANDBOX', '') + socket = new TestSocket() + connect.mockReturnValue(socket) + tryReadMetadata.mockReturnValue(metadata) + mockKill() +}) +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +// Node emits 'error' then 'close' for a refused or denied connect. +function failConnect(code: string): void { + socket.emit('error', Object.assign(new Error(`connect ${code} /private-runtime.sock`), { code })) + socket.emit('close') +} + +function mockKill(code?: string): void { + vi.spyOn(process, 'kill').mockImplementation(() => { + if (code) { + throw Object.assign(new Error(`kill ${code}`), { code }) + } + return true + }) +} + +async function deniedRequest(code: string): Promise { + const pending = sendRequest(metadata, 'status.get', undefined, 1000) + failConnect(code) + return pending.catch((failure: unknown) => failure) +} + +describe('runtime access denied', () => { + it.each(['EPERM', 'EACCES'])('classifies a %s connect without restart advice', async (code) => { + const error = await deniedRequest(code) + + expect(error).toMatchObject({ code: 'runtime_access_denied', data: { systemCode: code } }) + expect(socket.write).not.toHaveBeenCalled() + const human = formatCliError(error) + expect(human).toContain( + `Permission denied connecting to Orca (${code}). Orca may be running normally` + ) + expect(human).toContain("Next step: Do not restart Orca or run 'orca open'") + expect(human).not.toMatch(RESTART_OR_ABSENT_ADVICE) + expect(human).not.toContain('private-runtime') + }) + + it('names the Codex sandbox only when CODEX_SANDBOX is set', async () => { + vi.stubEnv('CODEX_SANDBOX', 'seatbelt') + const human = formatCliError(await deniedRequest('EPERM')) + + expect(human).toContain('The Codex sandbox blocked this command from connecting to Orca') + expect(human).toContain('escalated permissions, outside the Codex sandbox') + expect(human).not.toMatch(RESTART_OR_ABSENT_ADVICE) + }) + + it('keeps ordinary connect failures as runtime_unavailable', async () => { + expect(await deniedRequest('ECONNREFUSED')).toMatchObject({ code: 'runtime_unavailable' }) + }) + + it.each([undefined, 'EPERM'])( + 'fails status instead of guessing a state (kill %s)', + async (killCode) => { + mockKill(killCode) + const pending = getCliStatus('/test') + failConnect('EPERM') + + await expect(pending).rejects.toMatchObject({ code: 'runtime_access_denied' }) + } + ) + + // Why: a dead Orca leaves its socket behind, and the sandbox denies it before ECONNREFUSED. + it('gives not-running advice when the denied endpoint belongs to a dead pid', async () => { + mockKill('ESRCH') + const human = formatCliError(await deniedRequest('EPERM')) + + expect(human).toContain("Orca is not running. Run 'orca open' first.") + expect(human).not.toContain('Do not restart') + const pending = getCliStatus('/test') + failConnect('EPERM') + await expect(pending).resolves.toMatchObject({ + result: { app: { running: false }, runtime: { state: 'stale_bootstrap' } } + }) + }) + + it('does not launch or poll Orca when the initial status is denied', async () => { + const pending = new RuntimeClient('/test', 1000, null, null).openOrca() + failConnect('EPERM') + + await expect(pending).rejects.toMatchObject({ code: 'runtime_access_denied' }) + expect(launchOrcaApp).not.toHaveBeenCalled() + expect(connect).toHaveBeenCalledTimes(1) + }) + + it('reports status --json as an ok:false envelope with the recovery data', async () => { + const pending = getCliStatus('/test') + failConnect('EPERM') + const error = await pending.catch((failure: unknown) => failure) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + reportCliError(error, true, { commandPath: ['status'] }) + + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ + ok: false, + error: { + code: 'runtime_access_denied', + data: { systemCode: 'EPERM', nextSteps: expect.any(Array) } + } + }) + }) +}) diff --git a/src/cli/runtime/runtime-access-denied.ts b/src/cli/runtime/runtime-access-denied.ts new file mode 100644 index 00000000000..720836fa05d --- /dev/null +++ b/src/cli/runtime/runtime-access-denied.ts @@ -0,0 +1,28 @@ +import { RuntimeClientError } from './types' +import { isProcessRunning } from './runtime-pid-liveness' + +// Why: the errno decides the classification; CODEX_SANDBOX only picks the wording. +export function runtimeAccessDeniedError( + socketError: Error, + pid: number +): RuntimeClientError | null { + const systemCode = 'code' in socketError ? socketError.code : undefined + // Why: a sandbox still sees ESRCH, so a dead Orca's leftover socket gets not-running advice. + if ((systemCode !== 'EPERM' && systemCode !== 'EACCES') || !isProcessRunning(pid)) { + return null + } + const codexSandbox = Boolean(process.env.CODEX_SANDBOX) + const message = codexSandbox + ? `The Codex sandbox blocked this command from connecting to Orca (${systemCode}). Orca may be running normally.` + : `Permission denied connecting to Orca (${systemCode}). Orca may be running normally; this command's sandbox or OS permissions block the connection.` + const retryStep = codexSandbox + ? 'Re-run this command with escalated permissions, outside the Codex sandbox.' + : 'Re-run this command outside its sandbox, or as a user allowed to reach the Orca runtime.' + return new RuntimeClientError('runtime_access_denied', message, { + systemCode, + nextSteps: [ + retryStep, + "Do not restart Orca or run 'orca open'; a restart cannot grant this command access." + ] + }) +} diff --git a/src/cli/runtime/runtime-pid-liveness.ts b/src/cli/runtime/runtime-pid-liveness.ts new file mode 100644 index 00000000000..f99f857507e --- /dev/null +++ b/src/cli/runtime/runtime-pid-liveness.ts @@ -0,0 +1,13 @@ +export function isProcessRunning(pid: number | null | undefined): boolean { + if (!pid || pid <= 0) { + return false + } + try { + process.kill(pid, 0) + return true + } catch (error) { + // Why: only ESRCH proves the pid is gone. EPERM means it exists under another uid, and + // reporting that as `stale_bootstrap` calls a live Orca dead. + return !(error instanceof Error && 'code' in error && error.code === 'ESRCH') + } +} diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index ad30f97a96a..f01ffe842df 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -7,7 +7,8 @@ import { projectRemoteAppStatus, resolveDesktopWindowStatus } from '../../shared/cli-app-status-projection' -import { RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' +import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' +import { isProcessRunning } from './runtime-pid-liveness' export { projectRemoteAppStatus, resolveDesktopWindowStatus } @@ -68,7 +69,11 @@ export async function getCliStatus( state: graphState } }) - } catch { + } catch (error) { + // Why: a denied caller cannot tell a live Orca from a dead one, so report the denial, not a state. + if (error instanceof RuntimeClientError && error.code === 'runtime_access_denied') { + throw error + } const running = isProcessRunning(metadata.pid) return buildCliStatusResponse({ app: { @@ -98,17 +103,3 @@ function buildCliStatusResponse(result: CliStatusResult): RuntimeRpcSuccess( metadata: RuntimeMetadata, @@ -68,13 +69,16 @@ export async function sendRequest( } socket.setEncoding('utf8') - socket.once('error', () => { + socket.once('error', (error) => { finish({ ok: false, - error: new RuntimeClientError( - 'runtime_unavailable', - 'Could not connect to the running Orca app. Restart Orca and try again.' - ) + // Why: a sandbox denying the socket/pipe is not a dead app, so restart advice would mislead. + error: + runtimeAccessDeniedError(error, metadata.pid) ?? + new RuntimeClientError( + 'runtime_unavailable', + 'Could not connect to the running Orca app. Restart Orca and try again.' + ) }) }) // Why: a clean peer close (FIN, no 'error') before a terminal frame never diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index 49fdcadda42..a642ef4b527 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.ts @@ -13,6 +13,7 @@ import { geminiHookService } from '../gemini/hook-service' import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' import { kimiHookService } from '../kimi/hook-service' +import { museHookService } from '../muse/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' // Why (#16441): Codex's installer awaits a codex app-server trust-grant session @@ -50,7 +51,8 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] ['copilot', () => copilotHookService.install()], ['hermes', () => hermesHookService.install()], ['devin', () => devinHookService.install()], - ['kimi', () => kimiHookService.install()] + ['kimi', () => kimiHookService.install()], + ['muse', () => museHookService.install()] ] // Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a @@ -71,7 +73,8 @@ export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScri ['grok', () => grokHookService.refreshManagedScripts()], ['copilot', () => copilotHookService.refreshManagedScripts()], ['devin', () => devinHookService.refreshManagedScripts()], - ['kimi', () => kimiHookService.refreshManagedScripts()] + ['kimi', () => kimiHookService.refreshManagedScripts()], + ['muse', () => museHookService.refreshManagedScripts()] ] export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ @@ -88,7 +91,8 @@ export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ ['copilot', () => copilotHookService.remove()], ['hermes', () => hermesHookService.remove()], ['devin', () => devinHookService.remove()], - ['kimi', () => kimiHookService.remove()] + ['kimi', () => kimiHookService.remove()], + ['muse', () => museHookService.remove()] ] export const MANAGED_AGENT_HOOK_ASYNC_REMOVERS: readonly ManagedAgentHookAsyncRemover[] = [ @@ -109,5 +113,6 @@ export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusR ['copilot', () => copilotHookService.getStatus()], ['hermes', () => hermesHookService.getStatus()], ['devin', () => devinHookService.getStatus()], - ['kimi', () => kimiHookService.getStatus()] + ['kimi', () => kimiHookService.getStatus()], + ['muse', () => museHookService.getStatus()] ] diff --git a/src/main/agent-hooks/managed-hook-command-contract.test.ts b/src/main/agent-hooks/managed-hook-command-contract.test.ts index a49b45b6df4..38ee5eda19d 100644 --- a/src/main/agent-hooks/managed-hook-command-contract.test.ts +++ b/src/main/agent-hooks/managed-hook-command-contract.test.ts @@ -21,6 +21,7 @@ import { } from '../copilot/copilot-managed-hook-definitions' import { getDevinManagedCommand, getDevinRemoteManagedCommand } from '../devin/hook-settings' import { getGrokManagedCommand } from '../grok/grok-hook-script' +import { getMuseManagedCommand, getMuseRemoteManagedCommand } from '../muse/hook-settings' import { wrapPosixHookCommand, wrapWindowsCmdHookCommand, @@ -141,6 +142,13 @@ const buildersByAgent = new Map([ local: (path) => [wrapPosixHookCommand(path.replaceAll('\\', '/'))], remote: (path) => [wrapPosixHookCommand(path)] } + ], + [ + 'muse', + { + local: (path) => [getMuseManagedCommand(path)], + remote: (path) => [getMuseRemoteManagedCommand(path)] + } ] ]) diff --git a/src/main/agent-hooks/managed-hook-local-filesystem.test.ts b/src/main/agent-hooks/managed-hook-local-filesystem.test.ts index 3213ee0210e..53a84d67381 100644 --- a/src/main/agent-hooks/managed-hook-local-filesystem.test.ts +++ b/src/main/agent-hooks/managed-hook-local-filesystem.test.ts @@ -45,13 +45,13 @@ describe('managed-hook local filesystem', () => { const cold = await installRemoteManagedAgentHooks(filesystem, home, options) const warm = await installRemoteManagedAgentHooks(filesystem, home, options) - expect(cold).toHaveLength(14) + expect(cold).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length) expect(cold.filter((result) => result.state === 'error')).toEqual([]) - expect(warm).toHaveLength(14) + expect(warm).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length) expect(warm.filter((result) => result.state === 'error')).toEqual([]) const files = await listFiles(home) expect(files.filter((path) => path.endsWith('.tmp'))).toEqual([]) - const scripts = files.filter((path) => path.includes(join('.orca', 'agent-hooks'))) + const scripts = files.filter((path) => /\.(?:sh|cmd)$/.test(path)) expect(scripts.length).toBeGreaterThanOrEqual(10) if (process.platform !== 'win32') { for (const script of scripts) { @@ -71,7 +71,7 @@ describe('managed-hook local filesystem', () => { agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS }) - expect(results).toHaveLength(14) + expect(results).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length) expect(results.find((result) => result.agent === 'claude')?.state).toBe('error') expect(results.find((result) => result.agent === 'openclaude')?.state).toBe('installed') expect(results.find((result) => result.agent === 'kimi')?.state).toBe('installed') diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 2e4cbb06496..62d6c0b9857 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -22,6 +22,7 @@ import { CopilotHookService, copilotHookService } from '../copilot/hook-service' import { HermesHookService, hermesHookService } from '../hermes/hook-service' import { DevinHookService, devinHookService } from '../devin/hook-service' import { KimiHookService, kimiHookService } from '../kimi/hook-service' +import { museHookService } from '../muse/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls' import { @@ -57,10 +58,7 @@ function createFakeSftp(initialFiles: Record = {}): { modes: new Map(), failRenameTo: new Set() } - const noEntryError = (path: string): { code: number; message: string } => ({ - code: 2, - message: `ENOENT ${path}` - }) + const noEntryError = (path: string) => ({ code: 2, message: `ENOENT ${path}` }) const fakeStats = (mode: number): { mode: number } => ({ mode }) const sftp = { @@ -709,7 +707,8 @@ describe('remote hook service installers', () => { ['copilot', copilotHookService], ['hermes', hermesHookService], ['devin', devinHookService], - ['kimi', kimiHookService] + ['kimi', kimiHookService], + ['muse', museHookService] ]) // Guard against a service silently missing from the map above as new agents land. diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index a335e8ccc77..c33d303baef 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -13,6 +13,7 @@ import { droidHookService } from '../droid/hook-service' import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' import { kimiHookService } from '../kimi/hook-service' +import { museHookService } from '../muse/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' export type RemoteManagedHookInstallOptions = { @@ -72,7 +73,8 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ['droid', (sftp, remoteHome) => droidHookService.installRemote(sftp, remoteHome)], ['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)], ['devin', (sftp, remoteHome) => devinHookService.installRemote(sftp, remoteHome)], - ['kimi', (sftp, remoteHome) => kimiHookService.installRemote(sftp, remoteHome)] + ['kimi', (sftp, remoteHome) => kimiHookService.installRemote(sftp, remoteHome)], + ['muse', (sftp, remoteHome) => museHookService.installRemote(sftp, remoteHome)] ] /** Agents wired into the remote (SSH) hook installer. Exported so an invariant diff --git a/src/main/agent-hooks/server-hook-http-ingest.test.ts b/src/main/agent-hooks/server-hook-http-ingest.test.ts index f89dcebd005..4999c23d2cb 100644 --- a/src/main/agent-hooks/server-hook-http-ingest.test.ts +++ b/src/main/agent-hooks/server-hook-http-ingest.test.ts @@ -80,12 +80,13 @@ describe('AgentHookServer listener replay', () => { const server = new AgentHookServer() await server.start({ env: 'production' }) const order: string[] = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: spies on protected AgentHookServer methods that exist on the instance. const internal = server as unknown as { scheduleAssistantMessageRetry: (...args: unknown[]) => void - scheduleCodexSubagentPoll: (...args: unknown[]) => void + scheduleTranscriptPoll: (...args: unknown[]) => void } const originalAssistantRetry = internal.scheduleAssistantMessageRetry.bind(server) - const originalCodexRetry = internal.scheduleCodexSubagentPoll.bind(server) + const originalTranscriptPoll = internal.scheduleTranscriptPoll.bind(server) const assistantRetry = vi .spyOn(internal, 'scheduleAssistantMessageRetry') .mockImplementation((...args) => { @@ -93,10 +94,10 @@ describe('AgentHookServer listener replay', () => { originalAssistantRetry(...args) }) const codexRetry = vi - .spyOn(internal, 'scheduleCodexSubagentPoll') + .spyOn(internal, 'scheduleTranscriptPoll') .mockImplementation((...args) => { order.push('codex-retry') - originalCodexRetry(...args) + originalTranscriptPoll(...args) }) const unsubscribeStatus = server.subscribeStatusChanges(() => order.push('status-change')) server.setListener(() => { @@ -131,12 +132,13 @@ describe('AgentHookServer listener replay', () => { it('fails open after a throwing callback with cache retained and retries skipped', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: spies on protected AgentHookServer methods that exist on the instance. const internal = server as unknown as { scheduleAssistantMessageRetry: (...args: unknown[]) => void - scheduleCodexSubagentPoll: (...args: unknown[]) => void + scheduleTranscriptPoll: (...args: unknown[]) => void } const assistantRetry = vi.spyOn(internal, 'scheduleAssistantMessageRetry') - const codexRetry = vi.spyOn(internal, 'scheduleCodexSubagentPoll') + const codexRetry = vi.spyOn(internal, 'scheduleTranscriptPoll') server.setListener(() => { throw new Error('listener failed') }) diff --git a/src/main/agent-hooks/server-muse-session-log-poll.test.ts b/src/main/agent-hooks/server-muse-session-log-poll.test.ts new file mode 100644 index 00000000000..962c6ca429c --- /dev/null +++ b/src/main/agent-hooks/server-muse-session-log-poll.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { AgentHookServer } from './server' +import type { EnrichedAgentHookEventPayload } from './server/server-types' +import { makePaneKey } from '../../shared/stable-pane-id' + +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const SESSION_ID = '01a0caa3-0e77-7d41-bad7-46283a45633d' +const PROMPT_ID = '01a0caa3-a25a-7810-8229-4de04b2e7ca3' +const QUESTION = { id: 'fav_color', question: 'What is your favorite color?' } + +function sessionLogLine(event: Record): string { + return `${JSON.stringify({ payload: { kind: 'run', event } })}\n` +} + +function createSessionLog(dataHome: string): string { + const date = new Date(Number.parseInt(SESSION_ID.replace(/-/g, '').slice(0, 12), 16)) + const dir = join( + dataHome, + 'muse', + 'sessions', + String(date.getFullYear()), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0'), + SESSION_ID + ) + mkdirSync(dir, { recursive: true }) + const logPath = join(dir, 'session.jsonl') + writeFileSync(logPath, '') + return logPath +} + +describe('AgentHookServer Muse session log polling', () => { + const dirs: string[] = [] + + afterEach(() => { + vi.unstubAllEnvs() + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }) + } + dirs.length = 0 + }) + + // Why: Muse fires no hook for request_user_input, so only the poll can surface the wait and its answer. + it('flips the pane to waiting for a logged question and back once it settles', async () => { + const dataHome = mkdtempSync(join(tmpdir(), 'agent-hook-muse-poll-')) + dirs.push(dataHome) + vi.stubEnv('XDG_DATA_HOME', dataHome) + const logPath = createSessionLog(dataHome) + const server = new AgentHookServer() + const published: EnrichedAgentHookEventPayload[] = [] + server.setListener((event) => published.push(event)) + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/muse`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + hook_event_name: 'UserPromptSubmit', + prompt: 'ask my favorite color', + session_id: SESSION_ID, + turn_id: 'e495d1a5-59aa-47b4-8efb-a1bd75509afc', + cwd: '/tmp/ws', + transcript_path: null, + model: 'muse-spark-1.3', + permission_mode: 'default', + model_provider: 'meta' + } + }) + }) + expect(response.status).toBe(204) + expect(server.getStatusSnapshot()[0]?.state).toBe('working') + expect(published.at(-1)?.hasExplicitPrompt).toBe(true) + + appendFileSync( + logPath, + sessionLogLine({ + kind: 'user_input_prompt_requested', + prompt_id: PROMPT_ID, + tool_name: 'request_user_input', + questions: [QUESTION] + }) + ) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'waiting', + interactivePrompt: JSON.stringify({ questions: [QUESTION] }) + }) + }, + { timeout: 3_000, interval: 50 } + ) + const waiting = published.at(-1) + expect(waiting?.payload.state).toBe('waiting') + expect(waiting?.hasExplicitPrompt).toBeUndefined() + + appendFileSync( + logPath, + sessionLogLine({ kind: 'user_input_prompt_settled', prompt_id: PROMPT_ID }) + ) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]?.state).toBe('working') + }, + { timeout: 3_000, interval: 50 } + ) + expect(published.at(-1)?.hasExplicitPrompt).toBeUndefined() + expect(server.getStatusSnapshot()[0]?.interactivePrompt).toBeUndefined() + } finally { + server.stop() + } + }) +}) diff --git a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts index 3466cb52af9..4e3c4d997b2 100644 --- a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts +++ b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts @@ -40,7 +40,8 @@ const NEW_TURN_EVENT: Record = { opencode: 'SessionStart', opencode2: 'SessionStart', 'mimo-code': null, - 'command-code': null + 'command-code': null, + muse: 'UserPromptSubmit' } function reviveRetiredPane(source: unknown, hookEventName: string): boolean { diff --git a/src/main/agent-hooks/server/server-authority-aliases.ts b/src/main/agent-hooks/server/server-authority-aliases.ts index 83bd2cd77b7..fb4890e10a2 100644 --- a/src/main/agent-hooks/server/server-authority-aliases.ts +++ b/src/main/agent-hooks/server/server-authority-aliases.ts @@ -226,7 +226,7 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe) } this.clearAssistantMessageRetry(previousOwnerPaneKey) - this.clearCodexSubagentPoll(previousOwnerPaneKey) + this.clearTranscriptPoll(previousOwnerPaneKey) // Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner. this.legacyPaneKeyAliases.set(physicalPaneKey, { stablePaneKey: toPaneKey, diff --git a/src/main/agent-hooks/server/server-authority-fences.ts b/src/main/agent-hooks/server/server-authority-fences.ts index 192c52495a4..3bd289d9c55 100644 --- a/src/main/agent-hooks/server/server-authority-fences.ts +++ b/src/main/agent-hooks/server/server-authority-fences.ts @@ -52,7 +52,7 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth this.markPaneClosedForAgentStatus(key) this.restartedStatusLaunchTokenHashByPaneKey.delete(key) this.clearAssistantMessageRetry(key) - this.clearCodexSubagentPoll(key) + this.clearTranscriptPoll(key) clearPaneCacheState(this.state, key) this.activeHookTurnCompletedAtByPaneKey.delete(key) this.runtimeObservedStatusPaneKeys.delete(key) diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index 5425acec7f2..f92e3447866 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -235,7 +235,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) } this.clearAssistantMessageRetry(resolvedPaneKey) - this.clearCodexSubagentPoll(resolvedPaneKey) + this.clearTranscriptPoll(resolvedPaneKey) this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) this.currentAuthorityObservations.delete(resolvedPaneKey) if (existing.payload.state === 'done') { diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index f3f7b625c83..816058e55fd 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -132,7 +132,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) if (enriched) { this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) - this.scheduleCodexSubagentPoll(source, aliasedBody, enriched) + this.scheduleTranscriptPoll(source, aliasedBody, enriched) } } res.writeHead(204) @@ -204,7 +204,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv clearTimeout(timer) } this.assistantMessageRetryTimers.clear() - this.clearAllCodexSubagentPolls() + this.clearAllTranscriptPolls() this.endpointDir = null this.endpointFilePathCache = null this.endpointFileWritten = false diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index b637e8082b8..8ba89b9663b 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -213,9 +213,9 @@ export abstract class AgentHookServerState { ): EnrichedAgentHookEventPayload | undefined protected abstract emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void protected abstract clearAssistantMessageRetry(paneKey: string): void - protected abstract clearCodexSubagentPoll(paneKey: string): void - protected abstract clearAllCodexSubagentPolls(): void - protected abstract scheduleCodexSubagentPoll( + protected abstract clearTranscriptPoll(paneKey: string): void + protected abstract clearAllTranscriptPolls(): void + protected abstract scheduleTranscriptPoll( source: AgentHookSource, body: unknown, original: EnrichedAgentHookEventPayload diff --git a/src/main/agent-hooks/server/server-status-retries.ts b/src/main/agent-hooks/server/server-status-retries.ts index 4620506b210..4d92b372a35 100644 --- a/src/main/agent-hooks/server/server-status-retries.ts +++ b/src/main/agent-hooks/server/server-status-retries.ts @@ -1,10 +1,13 @@ -import { hasCodexTranscriptSubagents } from '../../../shared/agent-hook-listener/providers/codex-state' import { normalizeHookPayload } from '../../../shared/agent-hook-listener' import { hasPendingAgentResultText, preparePendingGrokResultDiscovery } from '../../../shared/agent-hook-listener/grok-result-discovery' import type { AgentHookSource } from '../../../shared/agent-hook-relay' +import { + shouldPollHookTranscript, + transcriptPollUpdate +} from '../../../shared/agent-hook-listener/transcript-poll-policy' import { CodexSubagentPollScheduler } from '../../../shared/codex-subagent-poll-scheduler' import type { EnrichedAgentHookEventPayload } from './server-types' import { @@ -14,20 +17,20 @@ import { } from './server-constants' import { AgentHookServerStatusUpdate } from './server-status-update' -type CodexSubagentPoll = { +type TranscriptPoll = { source: AgentHookSource body: unknown original: EnrichedAgentHookEventPayload } export abstract class AgentHookServerStatusRetries extends AgentHookServerStatusUpdate { - private readonly codexSubagentPollScheduler = new CodexSubagentPollScheduler( + private readonly transcriptPollScheduler = new CodexSubagentPollScheduler( CODEX_SUBAGENT_POLL_MS, - (paneKey, poll) => this.runCodexSubagentPoll(paneKey, poll) + (paneKey, poll) => this.runTranscriptPoll(paneKey, poll) ) - protected clearAllCodexSubagentPolls(): void { - this.codexSubagentPollScheduler.clearAll() + protected clearAllTranscriptPolls(): void { + this.transcriptPollScheduler.clearAll() } protected clearAssistantMessageRetry(paneKey: string): void { @@ -39,27 +42,27 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus this.assistantMessageRetryTimers.delete(paneKey) } - protected clearCodexSubagentPoll(paneKey: string): void { - this.codexSubagentPollScheduler.clear(paneKey) + protected clearTranscriptPoll(paneKey: string): void { + this.transcriptPollScheduler.clear(paneKey) } - protected scheduleCodexSubagentPoll( + protected scheduleTranscriptPoll( source: AgentHookSource, body: unknown, original: EnrichedAgentHookEventPayload ): void { - // Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll. - if (source !== 'codex') { + // Why: a nested CLI of another kind inherits ORCA_PANE_KEY, so clearing here would silently end a live poll. + if (source !== 'codex' && source !== 'muse') { return } - this.codexSubagentPollScheduler.clear(original.paneKey) - if (!hasCodexTranscriptSubagents(this.state, original.paneKey)) { + this.transcriptPollScheduler.clear(original.paneKey) + if (!shouldPollHookTranscript(this.state, source, original)) { return } - this.codexSubagentPollScheduler.schedule(original.paneKey, { source, body, original }) + this.transcriptPollScheduler.schedule(original.paneKey, { source, body, original }) } - private runCodexSubagentPoll(paneKey: string, poll: CodexSubagentPoll): void { + private runTranscriptPoll(paneKey: string, poll: TranscriptPoll): void { const { source, body, original } = poll // Keep the identity check at callback time: a newer event supersedes this // payload even when its pane still has transcript children. @@ -74,11 +77,10 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus if (!normalized) { return } - const subagentsChanged = - JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents) - const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original + const update = transcriptPollUpdate(source, original, normalized) + const next = update ? this.applyNormalizedStatus(update) : original if (next) { - this.scheduleCodexSubagentPoll(source, body, next) + this.scheduleTranscriptPoll(source, body, next) } } diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index a108bdbbd22..599665cc98b 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -75,7 +75,7 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { statusChanged = true } this.clearAssistantMessageRetry(paneKey) - this.clearCodexSubagentPoll(paneKey) + this.clearTranscriptPoll(paneKey) clearPaneCacheState(this.state, paneKey) this.activeHookTurnCompletedAtByPaneKey.delete(paneKey) this.runtimeObservedStatusPaneKeys.delete(paneKey) @@ -109,7 +109,7 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { | undefined const hadStatus = previousStatus !== undefined this.clearAssistantMessageRetry(resolvedPaneKey) - this.clearCodexSubagentPoll(resolvedPaneKey) + this.clearTranscriptPoll(resolvedPaneKey) clearPaneCacheState(this.state, resolvedPaneKey) this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) this.currentAuthorityObservations.delete(resolvedPaneKey) diff --git a/src/main/ai-vault/remote-session-scanner-muse.test.ts b/src/main/ai-vault/remote-session-scanner-muse.test.ts new file mode 100644 index 00000000000..3d94fc1eee4 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-muse.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' +import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' + +describe('scanRemoteAiVaultSessions muse', () => { + it('discovers Muse transcripts under the remote XDG sessions root', async () => { + const provider = new MemoryRemoteProvider() + const sessionDir = '/home/ada/.local/share/muse/sessions/2026/07/04/muse-remote' + provider.addFile( + `${sessionDir}/session.jsonl`, + jsonLines([ + { + record_type: 'event', + payload_type: 'runtime.session.metadata', + recorded_at: 1780000000000000, + payload: { kind: 'metadata', record: { workspace_root: '/home/ada/repo' } } + }, + { + record_type: 'event', + payload_type: 'runtime.user_intent.accepted', + recorded_at: 1780000001000000, + payload: { + intent_id: 'intent-remote', + refill_blocks: [{ kind: 'text', text: 'Remote muse title' }] + } + }, + { + record_type: 'event', + payload_type: 'runtime.session', + recorded_at: 1780000002000000, + payload: { + kind: 'run', + run_id: 'run-remote', + event: { + kind: 'model_completed', + model: 'muse-spark-remote', + usage: { input_tokens: 3, output_tokens: 4 } + } + } + } + ]), + 40 + ) + // Sidecars next to the transcript must not list as sessions. + provider.addFile(`${sessionDir}/cli-abc.log`, 'log output', 41) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + executionHostId: 'ssh:dev-box', + executionHostPlatform: 'linux', + agent: 'muse', + sessionId: 'muse-remote', + title: 'Remote muse title', + model: 'muse-spark-remote', + filePath: `${sessionDir}/session.jsonl` + }) + }) +}) diff --git a/src/main/ai-vault/remote-session-scanner-source-parsers.ts b/src/main/ai-vault/remote-session-scanner-source-parsers.ts new file mode 100644 index 00000000000..c28b69319c2 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-source-parsers.ts @@ -0,0 +1,80 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers' +import { + parseMuseSessionContent, + parseMuseSessionRemoteContent +} from './session-scanner-muse-parser' +import type { FileWithMtime } from './session-scanner-types' +import { normalizeAgentSessionsDir } from './session-scanner-values' +import type { RemoteSessionContent } from './remote-session-content-lines' +import type { RemoteParserOptions } from './remote-session-scanner-types' + +export function parseMuseRemoteContent( + file: FileWithMtime, + content: RemoteSessionContent, + platform: NodeJS.Platform, + options: RemoteParserOptions, + signal?: AbortSignal +): Promise { + if (typeof content === 'string') { + return Promise.resolve(parseMuseSessionContent(file, content, platform, options)) + } + return parseMuseSessionRemoteContent(file, content, platform, options, signal) +} + +export function piParser( + file: FileWithMtime, + content: RemoteSessionContent, + platform: NodeJS.Platform, + options: RemoteParserOptions, + signal?: AbortSignal +): Promise { + return parseMessageGraphSessionContent('pi', file, content, platform, options, signal) +} + +export function ompParser( + file: FileWithMtime, + content: RemoteSessionContent, + platform: NodeJS.Platform, + options: RemoteParserOptions, + signal?: AbortSignal +): Promise { + return parseMessageGraphSessionContent('omp', file, content, platform, options, signal) +} + +export function primeAgentParser( + file: FileWithMtime, + content: RemoteSessionContent, + platform: NodeJS.Platform, + options: RemoteParserOptions, + signal?: AbortSignal +): Promise { + return parseMessageGraphSessionContent('prime-agent', file, content, platform, options, signal) +} + +export function openClawParser( + file: FileWithMtime, + content: RemoteSessionContent, + platform: NodeJS.Platform, + options: RemoteParserOptions, + signal?: AbortSignal +): Promise { + return parseMessageGraphSessionContent('openclaw', file, content, platform, options, signal) +} + +export function remotePathSegments(path: string): string[] { + return path.replace(/\\/g, '/').split('/').filter(Boolean) +} + +export function remotePiSessionsSegments(): string[] { + return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean) +} + +export function remoteOmpSessionsSegments(): string[] { + return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean) +} + +// Remote roots are POSIX regardless of the client platform. +export function remotePrimeAgentSessionsSegments(): string[] { + return ['.prime', 'agent', 'sessions'] +} diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index 8a44061e463..ed34e8b52a5 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -7,7 +7,6 @@ import { parseAntigravitySessionContent } from './session-scanner-antigravity-pa import { isAntigravityTranscriptPath } from './session-scanner-antigravity-paths' import { parseCodexSessionContent } from './session-scanner-codex-parser' import { parseDroidSessionContent } from './session-scanner-droid-parser' -import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers' import { parseClaudeSessionContent } from './session-scanner-primary-parsers' import { parseGeminiSessionContent } from './session-scanner-gemini-parsers' import { parseCopilotSessionContent } from './session-scanner-copilot-parser' @@ -15,8 +14,18 @@ import { parseCursorSessionContent } from './session-scanner-cursor-parser' import { parseHermesSessionContent } from './session-scanner-hermes-parser' import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subagent-transcripts' +import { + ompParser, + openClawParser, + parseMuseRemoteContent, + piParser, + primeAgentParser, + remoteOmpSessionsSegments, + remotePathSegments, + remotePiSessionsSegments, + remotePrimeAgentSessionsSegments +} from './remote-session-scanner-source-parsers' import type { FileWithMtime } from './session-scanner-types' -import { normalizeAgentSessionsDir } from './session-scanner-values' import { remoteCodexIndexedTitleReader } from './remote-session-scanner-codex-index' import { remoteClineSource } from './remote-session-scanner-cline-source' import { remoteDevinSource } from './remote-session-scanner-devin-source' @@ -106,6 +115,16 @@ export function remoteSessionSources( remotePrimeAgentSessionsSegments(), primeAgentParser ), + jsonlSource( + 'muse', + remoteHome, + hostPlatform, + ['.local', 'share', 'muse', 'sessions'], + parseMuseRemoteContent, + // Why: each session dir holds session.jsonl plus .log/.sqlite3 sidecars; + // match only the transcript (same predicate as local discovery). + (path) => remotePathSegments(path).at(-1) === 'session.jsonl' + ), jsonlSource( 'droid', remoteHome, @@ -260,62 +279,3 @@ function parserOptions(context: RemoteScannerContext): RemoteParserOptions { executionHostPlatform: context.hostPlatform.os } } - -function piParser( - file: FileWithMtime, - content: RemoteSessionContent, - platform: NodeJS.Platform, - options: RemoteParserOptions, - signal?: AbortSignal -): Promise { - return parseMessageGraphSessionContent('pi', file, content, platform, options, signal) -} - -function ompParser( - file: FileWithMtime, - content: RemoteSessionContent, - platform: NodeJS.Platform, - options: RemoteParserOptions, - signal?: AbortSignal -): Promise { - return parseMessageGraphSessionContent('omp', file, content, platform, options, signal) -} - -function primeAgentParser( - file: FileWithMtime, - content: RemoteSessionContent, - platform: NodeJS.Platform, - options: RemoteParserOptions, - signal?: AbortSignal -): Promise { - return parseMessageGraphSessionContent('prime-agent', file, content, platform, options, signal) -} - -function openClawParser( - file: FileWithMtime, - content: RemoteSessionContent, - platform: NodeJS.Platform, - options: RemoteParserOptions, - signal?: AbortSignal -): Promise { - return parseMessageGraphSessionContent('openclaw', file, content, platform, options, signal) -} - -function remotePathSegments(path: string): string[] { - return path.replace(/\\/g, '/').split('/').filter(Boolean) -} - -function remotePiSessionsSegments(): string[] { - return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean) -} - -function remoteOmpSessionsSegments(): string[] { - return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean) -} - -// Why: remote roots are posix regardless of the client platform, so these stay literal -// rather than round-tripping through a local-platform path join that would emit -// backslashes on a Windows client and collapse into a single bogus segment. -function remotePrimeAgentSessionsSegments(): string[] { - return ['.prime', 'agent', 'sessions'] -} diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 604d1c5231a..7ca808173cf 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -6,6 +6,7 @@ import { parseClineSessionFile } from './session-scanner-cline-parser' import { parseGrokSessionFile } from './session-scanner-grok-parser' import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers' import { parseKimiSessionFile } from './session-scanner-kimi-parser' +import { parseMuseSessionFile } from './session-scanner-muse-parser' import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' import { captureOpenCodeSqliteSessionViaWorker, @@ -138,5 +139,7 @@ export async function parseAgentSessionFile( return parseDevinSessionFile(candidate.file, platform, messages) case 'kimi': return parseKimiSessionFile(candidate.file, platform, messages) + case 'muse': + return parseMuseSessionFile(candidate.file, platform, messages) } } diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index bd8f83af9e1..ee10422031b 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -12,6 +12,7 @@ import { import { cursorChatMetaPath } from './session-scanner-cursor-chat-meta' import { devinSessionsDbDependencyPath } from './session-scanner-devin-db' import { resolveKimiSessionsDir } from './session-scanner-kimi-paths' +import { resolveMuseSessionsDir } from './session-scanner-muse-paths' import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts' import { claudeProjectsRootDirs, @@ -285,6 +286,20 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { // only those (not the sibling agents/*/wire.jsonl transcripts). filePredicate: (filePath) => basename(filePath) === 'state.json' && basename(dirname(filePath)).startsWith('session_') + }, + muse: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(resolveMuseSessionsDir(options.museSessionsDir), wslHomeDirs, [ + '.local', + 'share', + 'muse', + 'sessions' + ]), + extensions: ['.jsonl'], + // Why: each Muse session is /YYYY/MM/DD//session.jsonl; + // match only those (not sibling .log/.sqlite3 sidecars or the .msp-view + // materialized projection). + filePredicate: (filePath) => basename(filePath) === 'session.jsonl' } } diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index 487661242da..79271cbf39e 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -218,6 +218,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions'), + museSessionsDir: join(root, 'muse-sessions'), platform: 'darwin' }) diff --git a/src/main/ai-vault/session-scanner-every-agent-fixture.ts b/src/main/ai-vault/session-scanner-every-agent-fixture.ts index 84f69b12699..5ae04c11b57 100644 --- a/src/main/ai-vault/session-scanner-every-agent-fixture.ts +++ b/src/main/ai-vault/session-scanner-every-agent-fixture.ts @@ -1,4 +1,8 @@ -import { isolatedScanRoots, writeOpenCode2SqliteFixture } from './session-scanner-test-fixtures' +import { + isolatedScanRoots, + writeMuseScannerFixture, + writeOpenCode2SqliteFixture +} from './session-scanner-test-fixtures' import { writeDocumentAgentFixtures } from './session-scanner-document-agent-fixtures' import { writeLogAgentFixtures } from './session-scanner-log-agent-fixtures' @@ -28,6 +32,7 @@ export async function writeEveryAgentVault(root: string): Promise | null +} + +// Why: `recorded_at` is microseconds since epoch; the shared timeline helpers +// take milliseconds (or ISO strings), so convert here. Values below the +// microsecond floor fall through to the shared parser (seconds/ISO). +function museTimestampMs(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value) && value >= 1e14) { + return Math.floor(value / 1000) + } + const parsed = timestampMs(value) + return Number.isFinite(parsed) ? parsed : null +} + +function unwrapMuseRecords(line: string): MuseRecord[] { + const envelope = parseJsonObject(line) + if (!envelope) { + return [] + } + // Why: retention markers (`retained_marker: omitted_live_only`) stand in for + // ephemeral records excluded from the retained log — no payload to fold. + return unwrapMuseLogRecords(envelope).map((record) => ({ + recordType: extractString(record.record_type), + payloadType: extractString(record.payload_type), + recordedAtMs: museTimestampMs(record.recorded_at), + payload: asRecord(record.payload) + })) +} + +function firstTextBlock(value: unknown): string | null { + for (const block of arrayValue(value)) { + const text = extractString(asRecord(block)?.text) + if (text) { + return text + } + } + return null +} + +// Why: user intent arrives either as `refill_blocks` text blocks or nested +// `model_messages[].content[]` blocks; both shapes carry the same prompt. +function userIntentText(payload: Record): string | null { + return ( + firstTextBlock(payload.refill_blocks) ?? + (() => { + for (const message of arrayValue(payload.model_messages)) { + const text = firstTextBlock(asRecord(message)?.content) + if (text) { + return text + } + } + return null + })() + ) +} + +function foldUserTurn( + accumulator: SessionAccumulator, + text: string | null, + timestampMs: number | null, + dedupe: { text: string | null; ms: number | null }, + // Why: each turn emits both `runtime.user_intent.accepted` and a `run :: + // started` carrying the same prompt ~ms apart — folding both double-counts + // turns and evicts real rows from the 5-message preview window. The intent + // record always folds; `run.started` is the fallback for logs missing intent + // records, so only it dedupes (a deliberately repeated prompt still counts). + skipIfDuplicate: boolean +): void { + if (!text) { + return + } + if ( + skipIfDuplicate && + dedupe.text === text && + dedupe.ms !== null && + timestampMs !== null && + Math.abs(timestampMs - dedupe.ms) < 60_000 + ) { + return + } + dedupe.text = text + dedupe.ms = timestampMs + accumulator.messageCount++ + const titleCandidate = normalizeTitleText(text) + if (titleCandidate) { + accumulator.title ??= titleCandidate + } + addPreviewContent(accumulator, 'user', text, timestampMs ?? undefined) +} + +function foldMuseRecord( + accumulator: SessionAccumulator, + record: MuseRecord, + dedupe: { text: string | null; ms: number | null } +): void { + if (record.recordedAtMs !== null) { + updateTimeline(accumulator, record.recordedAtMs) + } + const payload = record.payload + if (!payload) { + return + } + switch (record.payloadType) { + case 'runtime.session.metadata': { + // Why: the representative cwd is the session's start directory; later + // drift must not move history grouping or the resume `cd` prefix. + accumulator.cwd ??= extractString(asRecord(payload.record)?.workspace_root) + break + } + case 'runtime.session.route_facts': { + // Newer Muse logs carry the execution cwd in route facts as well as + // metadata; retain it as a fallback for partially written sessions. + accumulator.cwd ??= extractString(asRecord(payload.record)?.cwd) + break + } + case 'session.workspace_branch.observed': { + const reference = asRecord(asRecord(payload.record)?.reference) + accumulator.branch ??= extractString(reference?.name) + break + } + case 'run.model.configured': { + accumulator.model ??= extractString(asRecord(payload.record)?.model_id) + break + } + case 'runtime.user_intent.accepted': { + foldUserTurn(accumulator, userIntentText(payload), record.recordedAtMs, dedupe, false) + break + } + case 'runtime.session': { + foldSessionEvent(accumulator, payload, record.recordedAtMs, dedupe) + break + } + case null: + default: + break + } +} + +function foldSessionEvent( + accumulator: SessionAccumulator, + payload: Record, + timestampMs: number | null, + dedupe: { text: string | null; ms: number | null } +): void { + const event = asRecord(payload.event) + if (!event) { + return + } + switch (event.kind) { + case 'started': { + foldUserTurn(accumulator, extractString(event.prompt), timestampMs, dedupe, true) + break + } + case 'assistant_message_committed': { + const text = extractString(event.text) + if (text) { + accumulator.messageCount++ + addPreviewContent(accumulator, 'assistant', text, timestampMs ?? undefined) + } + break + } + case 'model_completed': { + const usage = asRecord(event.usage) + accumulator.totalTokens += + numberValue(usage?.input_tokens) + numberValue(usage?.output_tokens) + accumulator.model ??= extractString(event.model) + break + } + case null: + default: + break + } +} + +type MuseDedupeState = { text: string | null; ms: number | null } + +function foldMuseContent(accumulator: SessionAccumulator, content: string): void { + foldMuseLines(accumulator, content.split('\n')) +} + +function foldMuseLines( + accumulator: SessionAccumulator, + lines: Iterable, + dedupe: MuseDedupeState = { text: null, ms: null } +): void { + for (const line of lines) { + if (!line.trim()) { + continue + } + for (const record of unwrapMuseRecords(line)) { + foldMuseRecord(accumulator, record, dedupe) + } + } +} + +/** Parses remote transcript chunks without requiring the scanner to buffer the file. */ +export async function parseMuseSessionRemoteContent( + file: FileWithMtime, + content: RemoteSessionContent, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {}, + signal?: AbortSignal +): Promise { + const accumulator = createAccumulator({ + agent: 'muse', + file, + sessionId: museSessionIdFromFilePath(file.path) + }) + const lines = remoteSessionContentLines(content, signal) + const dedupe: MuseDedupeState = { text: null, ms: null } + for await (const line of lines) { + foldMuseLines(accumulator, [line], dedupe) + } + return finalizeSession(accumulator, platform, options) +} + +export async function parseMuseSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform, + messages?: TranscriptMessageSink +): Promise { + return parseMuseSessionContent( + file, + await wslGatedReadFile(file.path, 'utf-8', 'scan'), + platform, + {}, + messages + ) +} + +export function parseMuseSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {}, + messages?: TranscriptMessageSink +): AiVaultSession | null { + const accumulator = createAccumulator({ + agent: 'muse', + file, + sessionId: museSessionIdFromFilePath(file.path), + messages + }) + foldMuseContent(accumulator, content) + return finalizeSession(accumulator, platform, options) +} diff --git a/src/main/ai-vault/session-scanner-muse-paths.ts b/src/main/ai-vault/session-scanner-muse-paths.ts new file mode 100644 index 00000000000..7810571f23c --- /dev/null +++ b/src/main/ai-vault/session-scanner-muse-paths.ts @@ -0,0 +1,9 @@ +import { basename, dirname } from 'node:path' + +export { resolveMuseSessionsDir } from '../../shared/muse-session-log' + +// Layout: /YYYY/MM/DD//session.jsonl — the session id is the +// parent directory name (the basename is always the fixed `session.jsonl`). +export function museSessionIdFromFilePath(filePath: string): string { + return basename(dirname(filePath)) +} diff --git a/src/main/ai-vault/session-scanner-muse.test.ts b/src/main/ai-vault/session-scanner-muse.test.ts new file mode 100644 index 00000000000..568f5c19e04 --- /dev/null +++ b/src/main/ai-vault/session-scanner-muse.test.ts @@ -0,0 +1,80 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { parseMuseSessionContent } from './session-scanner-muse-parser' +import { + isolatedScanRoots, + jsonLines, + writeMuseScannerFixture +} from './session-scanner-test-fixtures' +import type { TranscriptMessage } from './session-transcript-consumers' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('scanAiVaultSessions muse', () => { + it('indexes Muse envelopes with title, model, tokens, and resume command', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-muse-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionFile = await writeMuseScannerFixture(roots.museSessionsDir) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + const session = result.sessions[0] + expect(session.agent).toBe('muse') + expect(session.sessionId).toBe('muse-session') + expect(session.title).toBe('Muse vault title') + expect(session.cwd).toBe('/tmp/muse') + expect(session.model).toBe('muse-spark-test') + expect(session.totalTokens).toBe(15) + expect(session.messageCount).toBe(2) + expect(session.filePath).toBe(sessionFile) + expect(session.resumeCommand).toBe("cd '/tmp/muse' && muse resume 'muse-session'") + }) + + it('publishes user and assistant turns to transcript consumers', () => { + const messages: TranscriptMessage[] = [] + const session = parseMuseSessionContent( + { + path: '/tmp/muse-sessions/2026/05/01/muse-capture/session.jsonl', + mtimeMs: 1780000003000, + modifiedAt: '2026-05-01T10:00:03.000Z' + }, + jsonLines([ + { + record_type: 'event', + payload_type: 'runtime.user_intent.accepted', + recorded_at: 1780000000000000, + payload: { refill_blocks: [{ kind: 'text', text: 'Capture this prompt' }] } + }, + { + record_type: 'event', + payload_type: 'runtime.session', + recorded_at: 1780000001000000, + payload: { + kind: 'run', + event: { kind: 'assistant_message_committed', text: 'Captured reply' } + } + } + ]), + 'darwin', + {}, + { active: true, push: (message) => messages.push(message) } + ) + + expect(session?.messageCount).toBe(2) + expect(messages).toEqual([ + { role: 'user', text: 'Capture this prompt', timestamp: '2026-05-28T20:26:40.000Z' }, + { role: 'assistant', text: 'Captured reply', timestamp: '2026-05-28T20:26:41.000Z' } + ]) + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts index 6a376f7bd85..ff51677d27f 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -53,6 +53,7 @@ function isolatedScanRoots(root: string) { droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions'), + museSessionsDir: join(root, 'muse-sessions'), ompSessionsDir: join(root, 'omp-sessions'), primeAgentSessionsDir: join(root, 'prime-agent-sessions') } diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 9a4ffc9b7ea..a5eb98235e4 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -78,6 +78,7 @@ function resumableStateFactoryFor( case 'hermes': case 'cline': case 'kimi': + case 'muse': case 'opencode': case 'opencode2': case 'rovo': diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index ddd81effcb0..7146d237bf7 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -75,7 +75,8 @@ export function isolatedScanRoots(root: string) { droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), clineSessionsDir: join(root, 'cline-sessions'), - kimiSessionsDir: join(root, 'kimi-sessions') + kimiSessionsDir: join(root, 'kimi-sessions'), + museSessionsDir: join(root, 'muse-sessions') } } @@ -190,3 +191,76 @@ export function writeAntigravityScannerFixture( } ]) } + +// Muse sessions are date-sharded /YYYY/MM/DD//session.jsonl +// envelopes mixing bare records, retained_frame envelopes, and +// omitted_live_only retention markers (verified against muse 1.0.3). +export async function writeMuseScannerFixture(sessionsDir: string): Promise { + const sessionFile = join(sessionsDir, '2026', '05', '01', 'muse-session', 'session.jsonl') + const bare = (payloadType: string, payload: unknown, recordedAt: number) => ({ + record_type: 'event', + payload_type: payloadType, + recorded_at: recordedAt, + payload + }) + await writeJsonlFile(sessionFile, [ + bare( + 'runtime.session.metadata', + { kind: 'metadata', record: { workspace_root: '/tmp/muse', provider_id: 'meta' } }, + 1780000000000000 + ), + bare( + 'runtime.user_intent.accepted', + { intent_id: 'intent-1', refill_blocks: [{ kind: 'text', text: 'Muse vault title' }] }, + 1780000001000000 + ), + // Why: every turn also emits `run :: started` carrying the same prompt — + // the parser must fold it once (messageCount stays 2 below). + bare( + 'runtime.session', + { kind: 'run', run_id: 'run-1', event: { kind: 'started', prompt: 'Muse vault title' } }, + 1780000001000007 + ), + { + retained_frame: true, + frame_schema_version: 1, + outer_log_ordinal: 3, + transaction_id: 'txn-1', + children: [ + { + child_index: 0, + record_json: JSON.stringify( + bare( + 'runtime.session', + { + kind: 'run', + run_id: 'run-1', + event: { kind: 'assistant_message_committed', text: 'Muse answer' } + }, + 1780000002000000 + ) + ) + } + ] + }, + bare( + 'runtime.session', + { + kind: 'run', + run_id: 'run-1', + event: { + kind: 'model_completed', + model: 'muse-spark-test', + usage: { input_tokens: 10, output_tokens: 5 } + } + }, + 1780000003000000 + ), + { + retained_marker: 'omitted_live_only', + schema_version: 1, + stream: { kind: 'session', id: 'muse-session' } + } + ]) + return sessionFile +} diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 6845ec5b6b7..f8838078053 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -40,6 +40,7 @@ export type AiVaultScanOptions = { droidProjectsDir?: string clineSessionsDir?: string kimiSessionsDir?: string + museSessionsDir?: string limit?: number unlimited?: boolean limitPerAgent?: number diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index c64a4987b85..b8963ce80b8 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -4,7 +4,11 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { scanAiVaultSessions } from './session-scanner' -import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' +import { + isolatedScanRoots, + jsonLines, + writeMuseScannerFixture +} from './session-scanner-test-fixtures' import { writeEveryAgentVault } from './session-scanner-every-agent-fixture' // Why: the SQLite worker bundle does not exist in the test runtime; route the @@ -394,6 +398,7 @@ describe('scanAiVaultSessions', () => { tempRoots.push(root) const { roots, antigravitySessionId, ompSessionFile, primeAgentSessionFile } = await writeEveryAgentVault(root) + await writeMuseScannerFixture(roots.museSessionsDir) const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) @@ -443,6 +448,7 @@ describe('scanAiVaultSessions', () => { expect(commandByAgent.get('cline')).toBe("cd '/tmp/cline' && cline --id 'cline-session'") expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'") expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'") + expect(commandByAgent.get('muse')).toBe("cd '/tmp/muse' && muse resume 'muse-session'") expect(commandByAgent.get('kimi')).toBe( "cd '/tmp/kimi' && kimi --session 'session_kimi-session'" ) diff --git a/src/main/daemon/daemon-adoption-telemetry-event.test.ts b/src/main/daemon/daemon-adoption-telemetry-event.test.ts index 9f19706b111..67458bd66fe 100644 --- a/src/main/daemon/daemon-adoption-telemetry-event.test.ts +++ b/src/main/daemon/daemon-adoption-telemetry-event.test.ts @@ -2,16 +2,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ParsedDaemonPid } from './daemon-pid-file-parse' import { validate } from '../telemetry/validator' -const { trackMock, opendirMock, existsSyncMock, readFileSyncMock, getVersionMock } = vi.hoisted( - () => ({ - trackMock: vi.fn(), - opendirMock: vi.fn(), - existsSyncMock: vi.fn(() => true), - readFileSyncMock: vi.fn(), - getVersionMock: vi.fn(() => '1.4.191') - }) -) +const { + trackMock, + opendirMock, + existsSyncMock, + readFileSyncMock, + getVersionMock, + codeIdentityMock +} = vi.hoisted(() => ({ + trackMock: vi.fn(), + opendirMock: vi.fn(), + existsSyncMock: vi.fn(() => true), + readFileSyncMock: vi.fn(), + getVersionMock: vi.fn(() => '1.4.191'), + codeIdentityMock: vi.fn(async () => 'parked') +})) vi.mock('../telemetry/client', () => ({ track: trackMock })) +// Never spawn codesign from a unit test; the probe has its own suite. +vi.mock('./daemon-mac-code-identity', () => ({ getDaemonMacCodeIdentity: codeIdentityMock })) vi.mock('node:fs', async (importOriginal) => ({ ...(await importOriginal>()), existsSync: existsSyncMock, @@ -35,7 +43,7 @@ import { hasDaemonPtyCwdDenialDiverged, reportDaemonPtyCwdVerdict, trackDaemonAdopted, - trackDaemonPtyCwdDenied + trackDaemonPtyCwdVerdict } from './daemon-adoption-telemetry-event' import { getDaemonFolderAccessMismatch, @@ -67,7 +75,11 @@ const stalePidRecord: ParsedDaemonPid = { '/Users/alice/Library/Caches/com.stablyai.orca.ShipIt/u/Orca.app/Contents/MacOS/Orca', cgroupUnit: null } -const origin = { app_version_match: 'different', spawner_path_class: 'updater-cache' } as const +const origin = { + app_version_match: 'different', + code_identity: 'parked', + spawner_path_class: 'updater-cache' +} as const const PID_PATH = '/fake/daemon.pid' beforeEach(() => { @@ -76,6 +88,7 @@ beforeEach(() => { opendirMock.mockReset().mockReturnValue(readableDir()) existsSyncMock.mockReset().mockReturnValue(true) readFileSyncMock.mockReset().mockReturnValue(JSON.stringify(stalePidRecord)) + codeIdentityMock.mockClear() vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') }) @@ -84,22 +97,24 @@ afterEach(() => { }) describe('classifyDaemonAdoptionOrigin', () => { - it('compares the recorded app version and classifies the spawner path', () => { - expect(classifyDaemonAdoptionOrigin(stalePidRecord)).toEqual(origin) - expect(classifyDaemonAdoptionOrigin({ ...stalePidRecord, appVersion: '1.4.191' })).toEqual({ - app_version_match: 'same', - spawner_path_class: 'updater-cache' - }) - expect(classifyDaemonAdoptionOrigin(null)).toEqual({ + it('compares the recorded app version, the spawner path, and the daemon pid code identity', async () => { + expect(await classifyDaemonAdoptionOrigin(stalePidRecord)).toEqual(origin) + expect(codeIdentityMock).toHaveBeenCalledWith(stalePidRecord.pid) + expect( + await classifyDaemonAdoptionOrigin({ ...stalePidRecord, appVersion: '1.4.191' }) + ).toEqual({ ...origin, app_version_match: 'same' }) + expect(await classifyDaemonAdoptionOrigin(null)).toEqual({ app_version_match: 'unknown', + code_identity: 'parked', spawner_path_class: 'unknown' }) + expect(codeIdentityMock).toHaveBeenLastCalledWith(undefined) }) }) describe('trackDaemonAdopted', () => { - it('emits a validator-accepted payload', () => { - trackDaemonAdopted(stalePidRecord, 'intact', 7) + it('emits a validator-accepted payload', async () => { + await trackDaemonAdopted(stalePidRecord, 'intact', 7) expect(trackMock).toHaveBeenCalledTimes(1) const [name, props] = trackMock.mock.calls[0] expect(name).toBe('daemon_adopted') @@ -111,11 +126,11 @@ describe('trackDaemonAdopted', () => { expect(validate('daemon_adopted', props).ok).toBe(true) }) - it('swallows a throwing telemetry client', () => { + it('swallows a throwing telemetry client', async () => { trackMock.mockImplementationOnce(() => { throw new Error('posthog exploded') }) - expect(() => trackDaemonAdopted(null, 'unknown', null)).not.toThrow() + await expect(trackDaemonAdopted(null, 'unknown', null)).resolves.toBeUndefined() }) }) @@ -151,17 +166,20 @@ describe('hasDaemonPtyCwdDenialDiverged', () => { }) }) -describe('trackDaemonPtyCwdDenied', () => { - it('emits a validator-accepted payload', () => { - trackDaemonPtyCwdDenied(DENIED_CWD, PID_PATH) - expect(trackMock).toHaveBeenCalledTimes(1) - const [name, props] = trackMock.mock.calls[0] - expect(name).toBe('daemon_pty_cwd_denied') - expect(props).toEqual({ cwd_class: 'documents', ...origin }) - expect(validate('daemon_pty_cwd_denied', props).ok).toBe(true) - }) +describe('trackDaemonPtyCwdVerdict', () => { + it.each(['daemon_pty_cwd_denied', 'daemon_pty_cwd_readable'] as const)( + 'emits a validator-accepted %s payload', + async (event) => { + await trackDaemonPtyCwdVerdict(event, DENIED_CWD, PID_PATH) + expect(trackMock).toHaveBeenCalledTimes(1) + const [name, props] = trackMock.mock.calls[0] + expect(name).toBe(event) + expect(props).toEqual({ cwd_class: 'documents', ...origin }) + expect(validate(event, props).ok).toBe(true) + } + ) - it('attributes the denial to the daemon recorded right now, not a startup snapshot', () => { + it('attributes the denial to the daemon recorded right now, not a startup snapshot', async () => { readFileSyncMock.mockReturnValue( JSON.stringify({ ...stalePidRecord, @@ -169,32 +187,66 @@ describe('trackDaemonPtyCwdDenied', () => { spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca' }) ) - trackDaemonPtyCwdDenied(DENIED_CWD, PID_PATH) + await trackDaemonPtyCwdVerdict('daemon_pty_cwd_denied', DENIED_CWD, PID_PATH) expect(readFileSyncMock).toHaveBeenCalledWith(PID_PATH, 'utf8') expect(trackMock.mock.calls[0][1]).toEqual({ cwd_class: 'documents', app_version_match: 'same', + code_identity: 'parked', spawner_path_class: 'applications' }) }) - it('swallows a throwing app environment or pid-record read instead of failing the spawn', () => { + it('swallows a throwing app environment or pid-record read instead of failing the spawn', async () => { getVersionMock.mockImplementationOnce(() => { throw new Error('AppEnvironment not initialized') }) - expect(() => trackDaemonPtyCwdDenied(DENIED_CWD, PID_PATH)).not.toThrow() + await expect( + trackDaemonPtyCwdVerdict('daemon_pty_cwd_denied', DENIED_CWD, PID_PATH) + ).resolves.toBeUndefined() expect(trackMock).not.toHaveBeenCalled() }) - it('swallows a throwing telemetry client', () => { + it('swallows a throwing telemetry client', async () => { trackMock.mockImplementationOnce(() => { throw new Error('posthog exploded') }) - expect(() => trackDaemonPtyCwdDenied(DENIED_CWD, PID_PATH)).not.toThrow() + await expect( + trackDaemonPtyCwdVerdict('daemon_pty_cwd_denied', DENIED_CWD, PID_PATH) + ).resolves.toBeUndefined() }) }) describe('reportDaemonPtyCwdVerdict', () => { + it('reports a readable TCC-gated cwd as the control, without an app-side read', async () => { + await reportDaemonPtyCwdVerdict({ + cwd: DENIED_CWD, + cwdReadableByDaemon: true, + pidPath: PID_PATH, + daemonIdentity: DAEMON + }) + + expect(opendirMock).not.toHaveBeenCalled() + expect(trackMock.mock.calls.map(([name]) => name)).toEqual(['daemon_pty_cwd_readable']) + }) + + it('reports every readable spawn, but only in a TCC-gated folder on macOS', async () => { + const readable = (cwd: string) => + reportDaemonPtyCwdVerdict({ + cwd, + cwdReadableByDaemon: true, + pidPath: PID_PATH, + daemonIdentity: DAEMON + }) + await readable(DENIED_CWD) + await readable(DENIED_CWD) + await readable('/Users/alice/code/repo') + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + await readable(DENIED_CWD) + + expect(trackMock).toHaveBeenCalledTimes(2) + }) + it('emits the event and records the notice evidence on one directory read', async () => { await reportDaemonPtyCwdVerdict({ cwd: DENIED_CWD, diff --git a/src/main/daemon/daemon-adoption-telemetry-event.ts b/src/main/daemon/daemon-adoption-telemetry-event.ts index c92c7086c44..e912abb1751 100644 --- a/src/main/daemon/daemon-adoption-telemetry-event.ts +++ b/src/main/daemon/daemon-adoption-telemetry-event.ts @@ -1,5 +1,6 @@ -// App-side emitters for `daemon_adopted` and `daemon_pty_cwd_denied` (#17696). Both sit on the -// daemon launch / PTY spawn path, so every failure dies here — telemetry can never cost a terminal. +// App-side emitters for `daemon_adopted`, `daemon_pty_cwd_denied`, and `daemon_pty_cwd_readable` +// (#17696). All sit on the daemon launch / PTY spawn path, so every failure dies here — telemetry +// can never cost a terminal. import { existsSync } from 'node:fs' import { homedir } from 'node:os' @@ -7,6 +8,7 @@ import { getAppEnvironment } from '../../shared/app-environment' import { classifyDaemonPtyCwd, classifyDaemonSpawnerPath, + isMacTccFolderClass, type DaemonAdoptedAppVersionMatch, type DaemonSpawnerPathClass } from '../../shared/daemon-adoption-telemetry' @@ -14,6 +16,7 @@ import { bucketDaemonLiveSessionCount } from '../../shared/daemon-lifecycle-tele import type { EventProps } from '../../shared/telemetry-events' import { track } from '../telemetry/client' import { readDaemonPidRecord } from './daemon-endpoint-incarnation' +import { getDaemonMacCodeIdentity } from './daemon-mac-code-identity' import { enumerateDirectoryOnce } from './directory-enumeration-probe' import type { ParsedDaemonPid } from './daemon-pid-file-parse' import type { MacDaemonTccAttributionHealth } from './daemon-tcc-attribution' @@ -25,13 +28,13 @@ import { export type DaemonAdoptionOrigin = Pick< EventProps<'daemon_pty_cwd_denied'>, - 'app_version_match' | 'spawner_path_class' + 'app_version_match' | 'code_identity' | 'spawner_path_class' > /** Classifies the adopted daemon's pid record against the running app; enum-only by construction. */ -export function classifyDaemonAdoptionOrigin( +export async function classifyDaemonAdoptionOrigin( pidRecord: ParsedDaemonPid | null -): DaemonAdoptionOrigin { +): Promise { const appVersionMatch: DaemonAdoptedAppVersionMatch = !pidRecord?.appVersion ? 'unknown' : pidRecord.appVersion === getAppEnvironment().getVersion() @@ -41,18 +44,22 @@ export function classifyDaemonAdoptionOrigin( pidRecord?.spawnerExecPath ?? null, existsSync ) - return { app_version_match: appVersionMatch, spawner_path_class: spawnerPathClass } + return { + app_version_match: appVersionMatch, + code_identity: await getDaemonMacCodeIdentity(pidRecord?.pid), + spawner_path_class: spawnerPathClass + } } // Adopted a daemon that a previous app launch forked (macOS only; that is where attribution matters). -export function trackDaemonAdopted( +export async function trackDaemonAdopted( pidRecord: ParsedDaemonPid | null, tccAttribution: MacDaemonTccAttributionHealth, liveSessionCount: number | null -): void { +): Promise { try { track('daemon_adopted', { - ...classifyDaemonAdoptionOrigin(pidRecord), + ...(await classifyDaemonAdoptionOrigin(pidRecord)), tcc_attribution: tccAttribution, live_session_count_bucket: bucketDaemonLiveSessionCount(liveSessionCount) }) @@ -80,14 +87,18 @@ export async function hasDaemonPtyCwdDenialDiverged( } } -/** Emits `daemon_pty_cwd_denied` for a cwd `hasDaemonPtyCwdDenialDiverged` already proved diverged. */ -export function trackDaemonPtyCwdDenied(cwd: string, pidPath: string | null): void { +/** Emits a spawn's cwd verdict; `readable` is the control that gives `code_identity` a false-positive rate. */ +export async function trackDaemonPtyCwdVerdict( + event: 'daemon_pty_cwd_denied' | 'daemon_pty_cwd_readable', + cwd: string, + pidPath: string | null +): Promise { try { // Why read now, not the adapter's startup snapshot: a respawn swaps the daemon under a - // long-lived adapter, and the denial must be attributed to the daemon that just spawned. - track('daemon_pty_cwd_denied', { + // long-lived adapter, and the verdict must be attributed to the daemon that just spawned. + track(event, { cwd_class: classifyDaemonPtyCwd(cwd, homedir()), - ...classifyDaemonAdoptionOrigin(readDaemonPidRecord(pidPath)) + ...(await classifyDaemonAdoptionOrigin(readDaemonPidRecord(pidPath))) }) } catch { // Telemetry is best-effort; a dropped event must not reach the caller. @@ -115,13 +126,21 @@ export async function reportDaemonPtyCwdVerdict(args: { } if (args.cwdReadableByDaemon === true) { clearDaemonFolderAccessMismatch(args.daemonIdentity, cwd) + // TCC-gated folders only: elsewhere a readable cwd says nothing about the theory. + if ( + process.platform === 'darwin' && + isMacTccFolderClass(classifyDaemonPtyCwd(cwd, homedir())) + ) { + await trackDaemonPtyCwdVerdict('daemon_pty_cwd_readable', cwd, args.pidPath) + } return } if (!(await hasDaemonPtyCwdDenialDiverged(cwd, args.cwdReadableByDaemon))) { return } - trackDaemonPtyCwdDenied(cwd, args.pidPath) + // Notice first: the event now waits on a codesign probe, and the user-facing notice must not. recordDaemonFolderAccessMismatch(args.daemonIdentity, cwd) + await trackDaemonPtyCwdVerdict('daemon_pty_cwd_denied', cwd, args.pidPath) } catch { // Best-effort evidence; a spawn must not fail because the notice could not be recorded. } diff --git a/src/main/daemon/daemon-mac-code-identity.test.ts b/src/main/daemon/daemon-mac-code-identity.test.ts new file mode 100644 index 00000000000..6c408c1ef89 --- /dev/null +++ b/src/main/daemon/daemon-mac-code-identity.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) + +import { classifyCodesignDisplayOutput, getDaemonMacCodeIdentity } from './daemon-mac-code-identity' + +const HELPER_PATH = + '/Applications/Orca.app/Contents/Frameworks/Orca Helper.app/Contents/MacOS/Orca Helper' +const PARKED_PATH = + '/private/var/folders/x/T/com.stablyai.orca.ShipIt.abc/Orca.app/Contents/MacOS/Orca' + +function codesignReturns(stderr: string, code: number | null, timedOut = false): void { + runProcessMock.mockResolvedValue({ code, stdout: '', stderr, timedOut }) +} + +beforeEach(() => { + runProcessMock.mockReset() + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('classifyCodesignDisplayOutput', () => { + it('resolves the executable path codesign reports for a live process', () => { + expect( + classifyCodesignDisplayOutput( + `Executable=${HELPER_PATH}\nIdentifier=com.stablyai.orca.helper\nFormat=pid diskrep\n`, + 0 + ) + ).toBe('resolved') + }) + + it('separates the Squirrel staging copy from the installed bundle', () => { + expect(classifyCodesignDisplayOutput(`Executable=${PARKED_PATH}\n`, 0)).toBe('parked') + expect( + classifyCodesignDisplayOutput( + 'Executable=/Users/a/Library/Caches/com.stablyai.orca.ShipIt/u/Orca.app/Contents/MacOS/Orca\n', + 0 + ) + ).toBe('parked') + }) + + it('treats the unlinked-executable diagnostic as unresolvable', () => { + expect(classifyCodesignDisplayOutput('+3337: No such file or directory\n', 1)).toBe( + 'unresolvable' + ) + }) + + it('fails open on a dead pid, an exiting pid, unsigned code, or an unexpected failure', () => { + expect(classifyCodesignDisplayOutput('+999999: No such process\n', 1)).toBe('probe-failed') + // errSecCSNoSuchCode: proc_pidpath resolved, the pid is just on its way out. + expect( + classifyCodesignDisplayOutput('+3337: host has no guest with the requested attributes\n', 1) + ).toBe('probe-failed') + expect(classifyCodesignDisplayOutput('/opt/tool: code object is not signed at all\n', 1)).toBe( + 'probe-failed' + ) + expect(classifyCodesignDisplayOutput('', null)).toBe('probe-failed') + }) + + it('does not read an unlinked diagnostic out of a successful display', () => { + expect( + classifyCodesignDisplayOutput(`Executable=${HELPER_PATH}\nNo such file or directory\n`, 0) + ).toBe('resolved') + }) +}) + +describe('getDaemonMacCodeIdentity', () => { + it('asks codesign to display the running pid and reads its stderr', async () => { + codesignReturns(`Executable=${HELPER_PATH}\n`, 0) + await expect(getDaemonMacCodeIdentity(3337)).resolves.toBe('resolved') + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ + program: '/usr/bin/codesign', + args: ['--display', '--verbose=1', '+3337'] + }) + ) + }) + + it('reports unresolvable when codesign cannot map the pid to on-disk code', async () => { + codesignReturns('+3337: No such file or directory\n', 1) + await expect(getDaemonMacCodeIdentity(3337)).resolves.toBe('unresolvable') + }) + + it('reprobes on every ask rather than reporting an earlier verdict', async () => { + codesignReturns(`Executable=${HELPER_PATH}\n`, 0) + await getDaemonMacCodeIdentity(3337) + await getDaemonMacCodeIdentity(3337) + expect(runProcessMock).toHaveBeenCalledTimes(2) + + codesignReturns('+3337: No such file or directory\n', 1) + await expect(getDaemonMacCodeIdentity(3337)).resolves.toBe('unresolvable') + }) + + it('discards a timed-out probe even when it printed a path first', async () => { + codesignReturns('Executable=/x\n', null, true) + await expect(getDaemonMacCodeIdentity(3337)).resolves.toBe('probe-failed') + }) + + it('coalesces concurrent asks about one pid into a single probe', async () => { + codesignReturns(`Executable=${HELPER_PATH}\n`, 0) + await expect( + Promise.all([getDaemonMacCodeIdentity(3337), getDaemonMacCodeIdentity(3337)]) + ).resolves.toEqual(['resolved', 'resolved']) + expect(runProcessMock).toHaveBeenCalledTimes(1) + }) + + it('fails open when codesign cannot be spawned, off macOS, or without a pid', async () => { + runProcessMock.mockRejectedValue(new Error('spawn ENOENT')) + await expect(getDaemonMacCodeIdentity(3337)).resolves.toBe('probe-failed') + + runProcessMock.mockClear() + await expect(getDaemonMacCodeIdentity(0)).resolves.toBe('probe-failed') + await expect(getDaemonMacCodeIdentity(null)).resolves.toBe('probe-failed') + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + await expect(getDaemonMacCodeIdentity(3337)).resolves.toBe('probe-failed') + expect(runProcessMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/daemon/daemon-mac-code-identity.ts b/src/main/daemon/daemon-mac-code-identity.ts new file mode 100644 index 00000000000..f89ddf7d2f6 --- /dev/null +++ b/src/main/daemon/daemon-mac-code-identity.ts @@ -0,0 +1,69 @@ +// Adapted from David Bebawy's PR #21826: `codesign --display +` is the probe that answers +// where a running pid's executable lives now. Measurement only; nothing reads the verdict. + +import { runProcess } from '../../shared/child-process/run-process' +import type { DaemonCodeIdentity } from '../../shared/daemon-adoption-telemetry' + +const CODESIGN_TIMEOUT_MS = 3_000 + +// An unlinked executable prints no `Executable=` and exits 1 with this (Darwin 25.5). The exiting- +// pid error ('host has no guest') means the path did resolve, so it must not match. +const UNLINKED_EXECUTABLE_PATTERN = /No such file or directory/ +// Squirrel parks the outgoing bundle under a `…ShipIt…` directory in $TMPDIR or ~/Library/Caches. +const PARKED_BUNDLE_PATTERN = /\/[^/]*ShipIt[^/]*\// + +export function classifyCodesignDisplayOutput( + output: string, + code: number | null +): DaemonCodeIdentity { + for (const line of output.split(/\r?\n/)) { + if (line.startsWith('Executable=')) { + const executablePath = line.slice('Executable='.length).trim() + if (executablePath.length > 0) { + return PARKED_BUNDLE_PATTERN.test(executablePath) ? 'parked' : 'resolved' + } + } + } + return code !== 0 && UNLINKED_EXECUTABLE_PATTERN.test(output) ? 'unresolvable' : 'probe-failed' +} + +async function probe(pid: number): Promise { + try { + const result = await runProcess({ + program: '/usr/bin/codesign', + args: ['--display', '--verbose=1', `+${pid}`], + timeoutMs: CODESIGN_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'pipe'] + }) + // A killed codesign can still have printed a path; that half-written display proves nothing. + if (result.timedOut) { + return 'probe-failed' + } + // codesign writes both the display fields and its diagnostics to stderr. + return classifyCodesignDisplayOutput(`${result.stderr}\n${result.stdout}`, result.code) + } catch { + return 'probe-failed' + } +} + +// Concurrent asks about one pid (a burst of spawns) share a probe; nothing outlives it. +let inFlight: { pid: number; pending: Promise } | null = null + +/** Read fresh on every ask: a parked bundle can be deleted mid-run, flipping `parked` to `unresolvable`. */ +export function getDaemonMacCodeIdentity( + pid: number | null | undefined +): Promise { + if (process.platform !== 'darwin' || !pid || !Number.isSafeInteger(pid) || pid <= 0) { + return Promise.resolve('probe-failed') + } + if (inFlight?.pid !== pid) { + const entry = { pid, pending: probe(pid) } + inFlight = entry + void entry.pending.then(() => { + if (inFlight === entry) { + inFlight = null + } + }) + } + return inFlight.pending +} diff --git a/src/main/daemon/daemon-provider-init.ts b/src/main/daemon/daemon-provider-init.ts index fa794257bda..c1d73d95a27 100644 --- a/src/main/daemon/daemon-provider-init.ts +++ b/src/main/daemon/daemon-provider-init.ts @@ -180,7 +180,7 @@ async function reportDaemonAdoption( () => null ) ]) - trackDaemonAdopted( + await trackDaemonAdopted( readDaemonPidRecord(getDaemonPidPath(runtimeDir)), tccAttribution, liveSessionCount diff --git a/src/main/ipc/filesystem-list-files-handler-name-filter.test.ts b/src/main/ipc/filesystem-list-files-handler-name-filter.test.ts new file mode 100644 index 00000000000..04c697d08dc --- /dev/null +++ b/src/main/ipc/filesystem-list-files-handler-name-filter.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { handlers, store, resetFilesystemIpcMocks } from './filesystem-test-harness' + +const { listQuickOpenFilesMock } = vi.hoisted(() => ({ listQuickOpenFilesMock: vi.fn() })) + +vi.mock('electron', async () => (await import('./filesystem-test-harness')).electronMock) +vi.mock('fs/promises', async () => (await import('./filesystem-test-harness')).fsPromisesMock) +vi.mock( + '../wsl-unc-delete', + async () => (await import('./filesystem-test-harness')).wslUncDeleteMock +) +vi.mock( + '../crash-reporting/crash-breadcrumb-store', + async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock +) +vi.mock( + '../local-downloaded-folder-promotion', + async () => (await import('./filesystem-test-harness')).folderPromotionMock +) +vi.mock( + '../git/status', + async () => (await import('./filesystem-test-harness')).gitStatusModuleMock +) +vi.mock( + '../git/check-ignored-paths', + async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock +) +vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock) +vi.mock( + '../providers/ssh-filesystem-dispatch', + async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock +) +vi.mock( + '../providers/ssh-git-dispatch', + async () => (await import('./filesystem-test-harness')).sshGitDispatchMock +) +vi.mock( + '../text-generation/commit-message-text-generation', + async () => (await import('./filesystem-test-harness')).textGenerationModuleMock +) +vi.mock( + '../text-generation/pull-request-context', + async () => (await import('./filesystem-test-harness')).pullRequestContextMock +) +vi.mock( + '../source-control/pull-request-template', + async () => (await import('./filesystem-test-harness')).pullRequestTemplateMock +) +vi.mock( + '../source-control/pull-request-linked-issue', + async () => (await import('./filesystem-test-harness')).pullRequestLinkedIssueMock +) +vi.mock('./filesystem-list-files', () => ({ listQuickOpenFiles: listQuickOpenFilesMock })) + +import { registerFilesystemHandlers } from './filesystem' + +function registerHandlers(): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: handlers under test only read repos and settings from the harness store. + registerFilesystemHandlers(store as never) +} + +describe('fs:listFiles local name filter', () => { + beforeEach(() => { + resetFilesystemIpcMocks() + listQuickOpenFilesMock.mockReset().mockResolvedValue([]) + }) + + it('filters the local scan with the Explorer word rule before the cap', async () => { + registerHandlers() + + await handlers.get('fs:listFiles')!(null, { + rootPath: '/repo', + maxResults: 20_001, + nameFilter: ' App Delegate ' + }) + + const [rootPath, , , , maxResults, , pathFilter] = listQuickOpenFilesMock.mock.calls[0] + expect([rootPath, maxResults]).toEqual(['/repo', 20_001]) + expect(pathFilter('ios/Notion Web Clipper/AppDelegate.swift')).toBe(true) + expect(pathFilter('ios/App.swift')).toBe(false) + }) + + it('lists unfiltered when the name filter is blank', async () => { + registerHandlers() + + await handlers.get('fs:listFiles')!(null, { rootPath: '/repo', nameFilter: ' ' }) + + expect(listQuickOpenFilesMock.mock.calls[0][6]).toBeUndefined() + }) + + it('refuses oversized name filters at the IPC boundary', async () => { + registerHandlers() + + await expect( + handlers.get('fs:listFiles')!(null, { rootPath: '/repo', nameFilter: 'x'.repeat(4096) }) + ).resolves.toEqual([]) + expect(listQuickOpenFilesMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/filesystem-list-files-name-filter.test.ts b/src/main/ipc/filesystem-list-files-name-filter.test.ts new file mode 100644 index 00000000000..3b397eb4b04 --- /dev/null +++ b/src/main/ipc/filesystem-list-files-name-filter.test.ts @@ -0,0 +1,172 @@ +import { execFile as execFileCallback, spawn, type SpawnOptions } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type * as GitRunner from '../git/runner' +import type * as GitFallback from './filesystem-list-files-git-fallback' +import { + pathMatchesFileNameFilterTokens, + splitFileNameFilterTokens +} from '../../shared/file-name-filter-tokens' + +const { wslAwareSpawnMock, listFilesWithGitSpy } = vi.hoisted(() => ({ + wslAwareSpawnMock: vi.fn(), + listFilesWithGitSpy: vi.fn() +})) + +vi.mock('../git/runner', async (importOriginal) => ({ + ...(await importOriginal()), + wslAwareSpawn: wslAwareSpawnMock +})) + +vi.mock('./filesystem-list-files-git-fallback', async (importOriginal) => { + const actual = await importOriginal() + listFilesWithGitSpy.mockImplementation(actual.listFilesWithGit) + return { ...actual, listFilesWithGit: listFilesWithGitSpy } +}) + +import { listQuickOpenFiles } from './filesystem-list-files' + +const execFile = promisify(execFileCallback) + +function makeStore(repoPath: string): Store { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: listing only reads registered repos and settings. + return { + getRepos: () => [ + { id: 'repo-1', path: repoPath, displayName: 'repo', badgeColor: '#000', addedAt: 0 } + ], + getSettings: () => ({}) + } as unknown as Store +} + +function nameFilter(query: string): (relativePath: string) => boolean { + const tokens = splitFileNameFilterTokens(query) + return (relativePath) => pathMatchesFileNameFilterTokens(relativePath, tokens) +} + +function spawnMissingRipgrep(): void { + wslAwareSpawnMock.mockImplementation( + (_command: string, _args: string[], options: SpawnOptions & { cwd?: string }) => + spawn('orca-definitely-missing-rg', [], { cwd: options.cwd, stdio: options.stdio }) + ) +} + +function fakeRipgrep(output: string, killSignal: NodeJS.Signals | null = null): EventEmitter { + const child = new EventEmitter() + const stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }) + Object.assign(child, { + stdout, + stderr: new EventEmitter(), + kill: vi.fn(), + exitCode: null, + signalCode: null, + pid: 1 + }) + setTimeout(() => { + stdout.emit('data', output) + child.emit('close', killSignal ? null : 0, killSignal) + }, 0) + return child +} + +describe('listQuickOpenFiles name filter', () => { + let tempDir: string | null = null + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }) + tempDir = null + } + vi.clearAllMocks() + }) + + it('counts only matches against the ripgrep cap', async () => { + wslAwareSpawnMock + .mockImplementationOnce(() => fakeRipgrep('a.ts\nb.ts\nc.ts\nios/AppDelegate.swift\n')) + .mockImplementationOnce(() => fakeRipgrep('')) + + const files = await listQuickOpenFiles( + '/repo', + makeStore('/repo'), + undefined, + undefined, + 2, + undefined, + nameFilter('app delegate') + ) + + expect(files).toEqual(['ios/AppDelegate.swift']) + }) + + it('filters the whole git listing when ripgrep is missing', async () => { + spawnMissingRipgrep() + tempDir = await mkdtemp(join(tmpdir(), 'orca-name-filter-')) + const repoPath = join(tempDir, 'repo') + await execFile('git', ['init', '-q', repoPath]) + for (const relPath of ['a.ts', 'b.ts', 'zz/Notion Web Clipper/AppDelegate.swift']) { + await mkdir(dirname(join(repoPath, relPath)), { recursive: true }) + await writeFile(join(repoPath, relPath), 'x') + } + await execFile('git', ['add', '.'], { cwd: repoPath }) + const store = makeStore(repoPath) + + await expect(listQuickOpenFiles(repoPath, store, undefined, undefined, 2)).resolves.toEqual([ + 'a.ts', + 'b.ts' + ]) + await expect( + listQuickOpenFiles(repoPath, store, undefined, undefined, 2, undefined, nameFilter('appdel')) + ).resolves.toEqual(['zz/Notion Web Clipper/AppDelegate.swift']) + }) + + it('rejects an over-budget filtered walk so the renderer keeps its capped listing', async () => { + spawnMissingRipgrep() + listFilesWithGitSpy.mockRejectedValueOnce(new Error('File listing exceeded 20001 files')) + + await expect( + listQuickOpenFiles( + '/folder', + makeStore('/folder'), + undefined, + undefined, + 5, + undefined, + nameFilter('target') + ) + ).rejects.toThrow() + expect(listFilesWithGitSpy).toHaveBeenCalledTimes(1) + expect(listFilesWithGitSpy.mock.calls[0][4]).toBeUndefined() + }) + + it('keeps primary matches when the ignored-file pass fails during a filtered scan', async () => { + wslAwareSpawnMock + .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n')) + .mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL')) + + await expect( + listQuickOpenFiles( + '/repo', + makeStore('/repo'), + undefined, + undefined, + 5, + undefined, + nameFilter('appdelegate') + ) + ).resolves.toEqual(['ios/AppDelegate.swift']) + }) + + it('still rejects an ignored-pass failure for unfiltered listings', async () => { + wslAwareSpawnMock + .mockImplementationOnce(() => fakeRipgrep('a.ts\n')) + .mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL')) + + await expect( + listQuickOpenFiles('/repo', makeStore('/repo'), undefined, undefined, 5) + ).rejects.toThrow('rg killed by SIGKILL') + }) +}) diff --git a/src/main/ipc/filesystem-list-files-without-ripgrep.ts b/src/main/ipc/filesystem-list-files-without-ripgrep.ts new file mode 100644 index 00000000000..b7e3cc1385f --- /dev/null +++ b/src/main/ipc/filesystem-list-files-without-ripgrep.ts @@ -0,0 +1,34 @@ +import { isQuickOpenReaddirBudgetError } from '../../shared/quick-open-readdir-walk' +import { buildInstallRgMessage } from '../../shared/quick-open-install-rg' +import { limitQuickOpenFilesBySerializedBytes } from '../../shared/quick-open-transport-budget' +import { listFilesWithGit } from './filesystem-list-files-git-fallback' + +/** Quick Open listing through git/readdir when ripgrep is unavailable. */ +export async function listFilesWithoutRipgrep(args: { + rootPath: string + excludePathPrefixes: readonly string[] + localGitOptions: { wslDistro?: string } + signal?: AbortSignal + maxResults?: number + maxSerializedBytes?: number + pathFilter?: (relativePath: string) => boolean +}): Promise { + const { rootPath, excludePathPrefixes, localGitOptions, signal, maxResults, pathFilter } = args + try { + // Why: these fallbacks cap scanned files, not matches, so a filtered listing scans everything. + // An over-budget readdir walk rejects, and the renderer falls back to its capped listing. + const files = pathFilter + ? (await listFilesWithGit(rootPath, excludePathPrefixes, localGitOptions, signal)) + .filter(pathFilter) + .slice(0, maxResults) + : await listFilesWithGit(rootPath, excludePathPrefixes, localGitOptions, signal, maxResults) + return args.maxSerializedBytes === undefined + ? files + : limitQuickOpenFilesBySerializedBytes(files, args.maxSerializedBytes) + } catch (err) { + if (!isQuickOpenReaddirBudgetError(err)) { + throw err + } + throw new Error(await buildInstallRgMessage(err)) + } +} diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 0707e1b3136..cbddb5c9c57 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -14,13 +14,11 @@ import { shouldExcludeQuickOpenRelPath, shouldIncludeQuickOpenPath } from '../../shared/quick-open-filter' -import { isQuickOpenReaddirBudgetError } from '../../shared/quick-open-readdir-walk' -import { buildInstallRgMessage } from '../../shared/quick-open-install-rg' import { limitQuickOpenFilesBySerializedBytes, serializedQuickOpenPathBytes } from '../../shared/quick-open-transport-budget' -import { listFilesWithGit } from './filesystem-list-files-git-fallback' +import { listFilesWithoutRipgrep } from './filesystem-list-files-without-ripgrep' import { absorbPendingRipgrepSpawnError, isRipgrepUnavailableExit, @@ -35,7 +33,9 @@ export async function listQuickOpenFiles( excludePaths?: string[], signal?: AbortSignal, maxResults?: number, - maxSerializedBytes?: number + maxSerializedBytes?: number, + /** Applied before `maxResults`, so the cap counts matches rather than scanned files. */ + pathFilter?: (relativePath: string) => boolean ): Promise { const authorizedRootPath = await resolveAuthorizedPath(rootPath, store) const localGitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -51,25 +51,16 @@ export async function listQuickOpenFiles( const excludePathPrefixes = buildExcludePathPrefixes(authorizedRootPath, excludePaths) const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro - const listWithoutRipgrep = async (): Promise => { - try { - const files = await listFilesWithGit( - authorizedRootPath, - excludePathPrefixes, - localGitOptions, - signal, - maxResults - ) - return maxSerializedBytes === undefined - ? files - : limitQuickOpenFilesBySerializedBytes(files, maxSerializedBytes) - } catch (err) { - if (!isQuickOpenReaddirBudgetError(err)) { - throw err - } - throw new Error(await buildInstallRgMessage(err)) - } - } + const listWithoutRipgrep = (): Promise => + listFilesWithoutRipgrep({ + rootPath: authorizedRootPath, + excludePathPrefixes, + localGitOptions, + signal, + maxResults, + maxSerializedBytes, + pathFilter + }) if ( wslDistroForOutput && !(await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)) @@ -126,6 +117,9 @@ export async function listQuickOpenFiles( if (shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)) { return false } + if (pathFilter && !pathFilter(relPath)) { + return false + } if (files.has(relPath)) { return false } @@ -303,7 +297,12 @@ export async function listQuickOpenFiles( (maxResults === undefined || files.size < maxResults) && (maxSerializedBytes === undefined || serializedBytes < maxSerializedBytes) ) { - await runRg(ignoredPass) + // Why: a filtered scan walks the whole tree; an ignored-pass timeout keeps primary matches. + await runRg(ignoredPass).catch((err: unknown) => { + if (!pathFilter || signal?.aborted || err instanceof RipgrepUnavailableError) { + throw err + } + }) } } } catch (err) { diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts index 2facbbfb445..2799ff76797 100644 --- a/src/main/ipc/filesystem/filesystem-search-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -24,6 +24,11 @@ import { import { checkRgAvailable } from '../rg-availability' import { resolveAuthorizedPath } from '../filesystem-auth' import { listQuickOpenFiles } from '../filesystem-list-files' +import { + isFileNameFilterQueryTooLarge, + pathMatchesFileNameFilterTokens, + splitFileNameFilterTokens +} from '../../../shared/file-name-filter-tokens' import { searchWithGitGrep } from '../filesystem-search-git' import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' import { QuickOpenPathRanker } from '../../../shared/quick-open-path-search' @@ -184,6 +189,8 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte requestToken?: string maxResults?: number searchQuery?: string + /** Local only: keep paths containing every whitespace-separated word, like the Explorer filter. */ + nameFilter?: string } ): Promise => { const controller = listFilesCancellations.begin(event, args.requestToken) @@ -221,12 +228,20 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte signal: controller?.signal }) } + if (args.nameFilter !== undefined && isFileNameFilterQueryTooLarge(args.nameFilter)) { + return [] + } + const nameFilterTokens = args.nameFilter ? splitFileNameFilterTokens(args.nameFilter) : [] return await listQuickOpenFiles( args.rootPath, store, args.excludePaths, controller?.signal, - args.maxResults + args.maxResults, + undefined, + nameFilterTokens.length > 0 + ? (relativePath) => pathMatchesFileNameFilterTokens(relativePath, nameFilterTokens) + : undefined ) } finally { listFilesCancellations.finish(event, args.requestToken, controller) diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts index 5e649f51b84..767ebed598f 100644 --- a/src/main/ipc/telemetry.ts +++ b/src/main/ipc/telemetry.ts @@ -27,6 +27,7 @@ const MAIN_OWNED_TELEMETRY_EVENTS = new Set([ 'daemon_adopted', 'daemon_audit_eligibility', 'daemon_pty_cwd_denied', + 'daemon_pty_cwd_readable', 'star_nag_outcome', 'feature_interaction_usage_bucket_reached' ]) diff --git a/src/main/muse/hook-config-json.test.ts b/src/main/muse/hook-config-json.test.ts new file mode 100644 index 00000000000..15cf94cf1cb --- /dev/null +++ b/src/main/muse/hook-config-json.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + MUSE_MANAGED_HOOK_ENV_VARS, + parseMuseSettingsText, + serializeMuseSettings +} from './hook-config-json' + +describe('muse hook-config-json', () => { + it('creates a fresh settings file with the required schema_version', () => { + const parsed = parseMuseSettingsText(serializeMuseSettings(null, '/x/muse-hooks.json'), 'test') + expect(parsed?.managed_hooks_env_vars).toEqual(MUSE_MANAGED_HOOK_ENV_VARS) + }) + + it('sets the pointer while preserving user keys and formatting', () => { + const original = '{\n "schema_version": 1,\n "model": "muse-spark-1.2"\n}\n' + const next = serializeMuseSettings(original, '/x/muse-hooks.json') + expect(next).toContain('"model": "muse-spark-1.2"') + expect(JSON.parse(next)).toMatchObject({ + schema_version: 1, + managed_hooks_path: '/x/muse-hooks.json' + }) + }) + + it('removes the pointer on remove while keeping user keys', () => { + const original = + '{\n "schema_version": 1,\n "managed_hooks_path": "/x/muse-hooks.json",\n "model": "muse-spark-1.2"\n}\n' + const next = serializeMuseSettings(original, undefined) + const parsed = parseMuseSettingsText(next, 'test') + expect(parsed?.managed_hooks_path).toBeUndefined() + expect(parsed?.model).toBe('muse-spark-1.2') + expect(parsed?.schema_version).toBe(1) + }) + + it('leaves already-converged text untouched', () => { + const original = JSON.stringify({ + schema_version: 1, + managed_hooks_path: '/x/muse-hooks.json', + managed_hooks_env_vars: MUSE_MANAGED_HOOK_ENV_VARS + }) + expect(serializeMuseSettings(original, '/x/muse-hooks.json')).toBe(original) + }) + + it('rejects malformed settings text', () => { + expect(parseMuseSettingsText('{oops', 'test')).toBeNull() + expect(parseMuseSettingsText('[1,2]', 'test')).toBeNull() + }) +}) diff --git a/src/main/muse/hook-config-json.ts b/src/main/muse/hook-config-json.ts new file mode 100644 index 00000000000..076f7f3dbbc --- /dev/null +++ b/src/main/muse/hook-config-json.ts @@ -0,0 +1,104 @@ +import { readFileSync } from 'node:fs' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { applyEdits, modify, parse as parseJsonc, type ParseError } from 'jsonc-parser' +import { isPlainObject } from '../agent-hooks/installer-utils' + +// Muse strips nonstandard environment variables from managed hooks unless allowlisted. +export const MUSE_MANAGED_HOOK_ENV_VARS = [ + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN', + 'ORCA_AGENT_HOOK_ENV', + 'ORCA_AGENT_HOOK_VERSION', + 'ORCA_AGENT_HOOK_TRANSPORT', + 'ORCA_AGENT_HOOK_ENDPOINT', + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_AGENT_LAUNCH_TOKEN', + // Why: Windows cmd AutoRun scripts commonly live under %USERPROFILE%; without it every hook exits 1. + 'USERPROFILE' +] as const + +export type MuseSettingsSource = { + text: string | null + config: Record +} + +export function parseMuseSettingsText( + text: string, + diagnosticName: string +): Record | null { + const errors: ParseError[] = [] + const parsed = parseJsonc(text, errors) + if (errors.length > 0) { + console.warn( + `Could not parse ${diagnosticName}: ${errors.map((e) => `offset ${e.offset} length ${e.length}`).join(', ')}` + ) + return null + } + if (parsed === undefined) { + return {} + } + return isPlainObject(parsed) ? parsed : null +} + +export function readMuseSettingsSource(configPath: string): MuseSettingsSource | null { + let text: string + try { + text = readFileSync(configPath, 'utf-8') + } catch (error) { + return isDefinitiveAbsence(error) ? { text: null, config: {} } : null + } + const config = parseMuseSettingsText(text, 'Muse settings.json') + return config === null ? null : { text, config } +} + +export function serializeMuseSettings( + originalText: string | null, + managedHooksPath: string | undefined +): string { + if (originalText === null) { + // Why: a fresh settings.json needs `"schema_version": 1` or every muse + // command fails with `malformed settings file`. + const config: Record = { schema_version: 1 } + if (managedHooksPath !== undefined) { + config.managed_hooks_path = managedHooksPath + config.managed_hooks_env_vars = MUSE_MANAGED_HOOK_ENV_VARS + } + return `${JSON.stringify(config, null, 2)}\n` + } + let text = originalText + const parsed = parseMuseSettingsText(originalText, 'Muse settings.json') + if (parsed?.schema_version === undefined) { + text = applyEdits( + text, + modify(text, ['schema_version'], 1, { formattingOptions: { insertSpaces: true, tabSize: 2 } }) + ) + } + const current = parseMuseSettingsText(text, 'Muse settings.json') + if (current?.managed_hooks_path !== managedHooksPath) { + text = applyEdits( + text, + // Why: `undefined` removes the key, which is how remove() drops the pointer. + modify(text, ['managed_hooks_path'], managedHooksPath, { + formattingOptions: { insertSpaces: true, tabSize: 2 } + }) + ) + } + if (managedHooksPath !== undefined) { + const existing = current?.managed_hooks_env_vars + const names = Array.isArray(existing) + ? existing.filter((value): value is string => typeof value === 'string') + : [] + const nextNames = [...new Set([...names, ...MUSE_MANAGED_HOOK_ENV_VARS])] + if (JSON.stringify(existing) !== JSON.stringify(nextNames)) { + text = applyEdits( + text, + modify(text, ['managed_hooks_env_vars'], nextNames, { + formattingOptions: { insertSpaces: true, tabSize: 2 } + }) + ) + } + } + return text +} diff --git a/src/main/muse/hook-service.test.ts b/src/main/muse/hook-service.test.ts new file mode 100644 index 00000000000..af04fc839e1 --- /dev/null +++ b/src/main/muse/hook-service.test.ts @@ -0,0 +1,140 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { parseMuseSettingsText } from './hook-config-json' +import { MuseHookService } from './hook-service' +import { MUSE_HOOK_EVENTS } from './hook-settings' + +// Why: getSharedManagedScriptPath() writes under homedir()/.orca and the +// Muse config resolves via XDG_CONFIG_HOME ?? ~/.config/muse. Point HOME +// at a temp dir and clear XDG_CONFIG_HOME so install/remove never touches the +// real ~/.orca or ~/.config/muse. os.homedir() resolves $HOME on POSIX. +let home: string +let originalHome: string | undefined +let originalXdg: string | undefined + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-muse-hook-')) + originalHome = process.env.HOME + originalXdg = process.env.XDG_CONFIG_HOME + process.env.HOME = home + delete process.env.XDG_CONFIG_HOME +}) + +afterEach(() => { + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + if (originalXdg === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = originalXdg + } + rmSync(home, { recursive: true, force: true }) +}) + +const configPath = (): string => join(home, '.config', 'muse', 'settings.json') +const managedHooksPath = (): string => join(home, '.orca', 'agent-hooks', 'muse-hooks.json') +const scriptPath = (): string => join(home, '.orca', 'agent-hooks', 'muse-hook.sh') + +describe('MuseHookService', () => { + it('reports not_installed before install', () => { + expect(new MuseHookService().getStatus().state).toBe('not_installed') + }) + + it('installs the managed hooks pointer, file, and script', () => { + const status = new MuseHookService().install() + expect(status.state).toBe('installed') + expect(status.managedHooksPresent).toBe(true) + + // The settings pointer aims at the Orca-owned managed file, and a fresh + // settings.json carries the schema_version muse requires. + const settings = parseMuseSettingsText(readFileSync(configPath(), 'utf-8'), 'test') + expect(settings?.managed_hooks_path).toBe(managedHooksPath()) + expect(settings?.schema_version).toBe(1) + expect(settings?.managed_hooks_env_vars).toContain('ORCA_PANE_KEY') + + const managedText = readFileSync(managedHooksPath(), 'utf-8') + expect(managedText).toContain('agent-hooks/muse-hook.sh') + expect(MUSE_HOOK_EVENTS.every((event) => managedText.includes(`"${event}"`))).toBe(true) + // The managed script must exist and POST to the muse hook endpoint. + const script = readFileSync(scriptPath(), 'utf-8') + expect(script).toContain('/hook/muse') + // Why: payload is piped to curl via stdin so it never lands on the curl + // command line (EDR oversized-command-line false positive). + expect(script).toContain('printf \'%s\' "$payload" | curl') + }) + + it('keeps user settings when installing, then drops only the pointer on remove', () => { + mkdirSync(join(home, '.config', 'muse'), { recursive: true }) + const userSettings = `{\n "schema_version": 1,\n "model": "muse-spark-1.2",\n "approval_mode": "never"\n}\n` + writeFileSync(configPath(), userSettings) + + const service = new MuseHookService() + expect(service.install().state).toBe('installed') + + const installed = readFileSync(configPath(), 'utf-8') + expect(installed).toContain('"model": "muse-spark-1.2"') + expect(installed).toContain('"approval_mode": "never"') + + // Reinstall must converge without duplicating the pointer. + service.install() + const reinstalled = readFileSync(configPath(), 'utf-8') + expect((reinstalled.match(/managed_hooks_path/g) ?? []).length).toBe(1) + + const removed = service.remove() + expect(removed.state).toBe('not_installed') + const afterRemove = parseMuseSettingsText(readFileSync(configPath(), 'utf-8'), 'test') + expect(afterRemove?.managed_hooks_path).toBeUndefined() + expect(afterRemove?.model).toBe('muse-spark-1.2') + }) + + it('reports not_installed when the pointer aims elsewhere', () => { + mkdirSync(join(home, '.config', 'muse'), { recursive: true }) + writeFileSync( + configPath(), + JSON.stringify({ schema_version: 1, managed_hooks_path: '/central/hooks.json' }) + ) + const status = new MuseHookService().getStatus() + expect(status.state).toBe('not_installed') + expect(status.detail).toContain('/central/hooks.json') + }) + + it('does not overwrite a user-managed hooks pointer during install', () => { + mkdirSync(join(home, '.config', 'muse'), { recursive: true }) + const userPath = '/user-owned/muse-hooks.json' + writeFileSync(configPath(), JSON.stringify({ schema_version: 1, managed_hooks_path: userPath })) + const status = new MuseHookService().install() + expect(status.state).toBe('not_installed') + expect(status.detail).toContain(userPath) + expect( + parseMuseSettingsText(readFileSync(configPath(), 'utf-8'), 'test')?.managed_hooks_path + ).toBe(userPath) + }) + + it('treats malformed managed hook entries as absent instead of throwing', () => { + mkdirSync(join(home, '.config', 'muse'), { recursive: true }) + mkdirSync(join(home, '.orca', 'agent-hooks'), { recursive: true }) + const managedPath = join(home, '.orca', 'agent-hooks', 'muse-hooks.json') + writeFileSync(configPath(), JSON.stringify({ schema_version: 1 })) + const service = new MuseHookService() + expect(service.install().state).toBe('installed') + // Hand-edited damage: null definition, non-array hooks, null entry, + // non-string command — status must degrade, never throw. + const damaged = parseMuseSettingsText(readFileSync(managedPath, 'utf-8'), 'test') + expect(damaged).not.toBeNull() + if (!damaged) { + throw new Error('expected generated Muse hooks') + } + damaged.hooks = { + ...(typeof damaged.hooks === 'object' && damaged.hooks !== null ? damaged.hooks : {}), + UserPromptSubmit: [null, { hooks: 'not-an-array' }, { hooks: [null, { command: 42 }] }] + } + writeFileSync(managedPath, JSON.stringify(damaged)) + expect(() => service.getStatus()).not.toThrow() + expect(service.getStatus().state).toBe('partial') + }) +}) diff --git a/src/main/muse/hook-service.ts b/src/main/muse/hook-service.ts new file mode 100644 index 00000000000..130e89fe0ca --- /dev/null +++ b/src/main/muse/hook-service.ts @@ -0,0 +1,310 @@ +import { existsSync, readFileSync, unlinkSync } from 'node:fs' +import type { SFTPWrapper } from 'ssh2' + +// Muse runs managed hooks with an explicit environment allowlist. The installer +// persists Orca's hook coordinates in that allowlist so events stay attributed. +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + buildWindowsAgentHookCurlPostCommand, + writeHooksJson, + writeManagedScript +} from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' +import { + readTextFileRemote, + writeManagedScriptRemote, + writeTextFileRemoteAtomic +} from '../agent-hooks/installer-utils-remote' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' +import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' +import { + buildMuseManagedHooksFile, + getMuseConfigPath, + getMuseManagedCommand, + getMuseManagedCommandMatcher, + getMuseManagedHooksPath, + getMuseManagedScriptPath, + getMuseRemoteConfigPath, + getMuseRemoteManagedCommand, + getMuseRemoteManagedHooksPath, + MUSE_HOOK_EVENTS, + readManagedMuseHookEvents +} from './hook-settings' +import { + MUSE_MANAGED_HOOK_ENV_VARS, + parseMuseSettingsText, + readMuseSettingsSource, + serializeMuseSettings +} from './hook-config-json' + +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + ...buildWindowsHookEnvironmentGuardLines(), + buildWindowsAgentHookCurlPostCommand('muse'), + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('muse'), + // Why: endpoint file holds the live port/token; PTYs that outlive an Orca restart carry stale env, so source it to reach the new server (else PTY env). + // Why: silence the `.` builtin (2>/dev/null + `|| :`) so a TOCTOU race can't leak shell parse errors into agent transcripts (fail-open). + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', + ' exit 0', + 'fi', + // Why: redirect on `fi` covers the whole if-statement (both transport branches); `|| spool` keeps the fail-open spool fallback. + ...buildPosixAgentHookPostCommand('muse').map((line, index, lines) => + index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line + ), + 'exit 0', + '' + ].join('\n') +} + +function readManagedHooksFile(managedHooksPath: string): string | null { + if (!existsSync(managedHooksPath)) { + return '' + } + try { + return readFileSync(managedHooksPath, 'utf-8') + } catch { + return null + } +} + +function buildStatus( + config: Record, + pointer: string | undefined, + managedHooksPath: string, + managedText: string | null, + configPath: string +): AgentHookInstallStatus { + const base = { agent: 'muse' as const, configPath } + if (managedText === null) { + return { + ...base, + state: 'error', + managedHooksPresent: false, + detail: 'Could not read Orca managed hooks file' + } + } + if (pointer !== managedHooksPath) { + return { + ...base, + state: 'not_installed', + managedHooksPresent: false, + detail: + pointer === undefined + ? null + : `managed_hooks_path points at ${pointer}, not the Orca managed hooks file` + } + } + const parsed = parseMuseSettingsText(managedText, 'Orca managed Muse hooks') + const present = readManagedMuseHookEvents(parsed, getMuseManagedCommandMatcher()) + const missing = MUSE_HOOK_EVENTS.filter((event) => !present.has(event)) + const configuredEnv = Array.isArray(config.managed_hooks_env_vars) + ? config.managed_hooks_env_vars.filter((value): value is string => typeof value === 'string') + : [] + const missingEnv = MUSE_MANAGED_HOOK_ENV_VARS.filter((name) => !configuredEnv.includes(name)) + let state: AgentHookInstallState + let detail: string | null + if (missing.length === 0 && missingEnv.length === 0) { + state = 'installed' + detail = null + } else if (present.size === 0) { + state = 'not_installed' + detail = null + } else { + state = 'partial' + detail = [ + missing.length > 0 ? `events: ${missing.join(', ')}` : null, + missingEnv.length > 0 ? `environment variables: ${missingEnv.join(', ')}` : null + ] + .filter(Boolean) + .join('; ') + } + return { ...base, state, managedHooksPresent: present.size > 0, detail } +} + +export class MuseHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getMuseManagedScriptPath(), getManagedScript()) + } + + getStatus(): AgentHookInstallStatus { + const configPath = getMuseConfigPath() + const managedHooksPath = getMuseManagedHooksPath() + const source = readMuseSettingsSource(configPath) + if (!source) { + return { + agent: 'muse', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not read Muse settings.json' + } + } + const pointer = + typeof source.config.managed_hooks_path === 'string' + ? source.config.managed_hooks_path + : undefined + return buildStatus( + source.config, + pointer, + managedHooksPath, + readManagedHooksFile(managedHooksPath), + configPath + ) + } + + install(): AgentHookInstallStatus { + const configPath = getMuseConfigPath() + const managedHooksPath = getMuseManagedHooksPath() + const source = readMuseSettingsSource(configPath) + if (!source) { + return { + agent: 'muse', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not read Muse settings.json' + } + } + const existingPointer = + typeof source.config.managed_hooks_path === 'string' + ? source.config.managed_hooks_path + : undefined + if (existingPointer !== undefined && existingPointer !== managedHooksPath) { + return { + agent: 'muse', + state: 'not_installed', + configPath, + managedHooksPresent: false, + detail: `managed_hooks_path points at ${existingPointer}; leaving the user's managed hooks untouched` + } + } + const scriptPath = getMuseManagedScriptPath() + const command = getMuseManagedCommand(scriptPath) + // Write the script and managed hooks file first so settings.json never points at missing files. + writeManagedScript(scriptPath, getManagedScript()) + writeHooksJson( + managedHooksPath, + { hooks: {} }, + { + serialized: buildMuseManagedHooksFile(command) + } + ) + const nextText = serializeMuseSettings(source.text, managedHooksPath) + if (source.text !== nextText) { + writeHooksJson(configPath, source.config, { serialized: nextText }) + } + return this.getStatus() + } + + // Install the Muse hook on an SSH execution host, where the shell contract is POSIX. + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const remoteConfigPath = getMuseRemoteConfigPath(remoteHome) + const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/muse-hook.sh` + const remoteManagedHooksPath = getMuseRemoteManagedHooksPath(remoteHome) + try { + const body = await readTextFileRemote(sftp, remoteConfigPath) + const config = body === null ? {} : parseMuseSettingsText(body, 'remote Muse settings.json') + if (!config) { + return { + agent: 'muse', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: 'Could not parse remote Muse settings.json' + } + } + const existingPointer = + typeof config.managed_hooks_path === 'string' ? config.managed_hooks_path : undefined + if (existingPointer !== undefined && existingPointer !== remoteManagedHooksPath) { + return { + agent: 'muse', + state: 'not_installed', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: `managed_hooks_path points at ${existingPointer}; leaving the user's managed hooks untouched` + } + } + const command = getMuseRemoteManagedCommand(remoteScriptPath) + // Write the script and managed hooks file first so settings.json never points at missing files. + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + await writeTextFileRemoteAtomic( + sftp, + remoteManagedHooksPath, + buildMuseManagedHooksFile(command) + ) + await writeTextFileRemoteAtomic( + sftp, + remoteConfigPath, + serializeMuseSettings(body, remoteManagedHooksPath) + ) + return { + agent: 'muse', + state: 'installed', + configPath: remoteConfigPath, + managedHooksPresent: true, + detail: null + } + } catch (err) { + return { + agent: 'muse', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + } + + remove(): AgentHookInstallStatus { + const configPath = getMuseConfigPath() + const managedHooksPath = getMuseManagedHooksPath() + const source = readMuseSettingsSource(configPath) + if (!source) { + return { + agent: 'muse', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not read Muse settings.json' + } + } + if (source.config.managed_hooks_path === managedHooksPath) { + const nextText = serializeMuseSettings(source.text, undefined) + if (source.text !== nextText) { + writeHooksJson(configPath, source.config, { serialized: nextText }) + } + } + try { + if (existsSync(managedHooksPath)) { + unlinkSync(managedHooksPath) + } + } catch { + // best effort + } + return this.getStatus() + } +} + +export const museHookService = new MuseHookService() diff --git a/src/main/muse/hook-settings.ts b/src/main/muse/hook-settings.ts new file mode 100644 index 00000000000..5b5fe3f63fd --- /dev/null +++ b/src/main/muse/hook-settings.ts @@ -0,0 +1,131 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + buildManagedCommandHook, + createManagedCommandMatcher, + getSharedManagedScriptPath, + isPlainObject, + wrapPosixHookCommand, + wrapWindowsHookCommand, + type HookDefinition +} from '../agent-hooks/installer-utils' + +const MUSE_SCRIPT_BASE = 'muse-hook' + +// Muse 1.3 emits Claude-shaped lifecycle events; absent matchers cover every tool. +// SubagentStart names the internal child sessions whose hooks must not drive pane status. +export const MUSE_HOOK_EVENTS = [ + 'SessionStart', + 'SubagentStart', + 'SessionEnd', + 'Notification', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest', + 'Stop', + 'StopFailure' +] as const + +export const MUSE_MANAGED_HOOKS_FILE_NAME = 'muse-hooks.json' + +function getMuseConfigDir(home: string): string { + // Why: honor XDG_CONFIG_HOME like the CLI does; default matches muse's own + // `~/.config/muse` resolution. + const xdg = process.env.XDG_CONFIG_HOME?.trim() + return xdg ? join(xdg, 'muse') : join(home, '.config', 'muse') +} + +export function getMuseConfigPath(): string { + return join(getMuseConfigDir(homedir()), 'settings.json') +} + +export function getMuseManagedScriptFileName(): string { + return process.platform === 'win32' ? `${MUSE_SCRIPT_BASE}.cmd` : `${MUSE_SCRIPT_BASE}.sh` +} + +export function getMuseManagedScriptPath(): string { + return getSharedManagedScriptPath(getMuseManagedScriptFileName()) +} + +export function getMuseManagedHooksPath(): string { + return getSharedManagedScriptPath(MUSE_MANAGED_HOOKS_FILE_NAME) +} + +export function getMuseRemoteConfigPath(remoteHome: string): string { + // Why: remote XDG_CONFIG_HOME is unknown over SFTP; default matches muse's own resolution. + return `${remoteHome.replace(/\/$/, '')}/.config/muse/settings.json` +} + +export function getMuseRemoteManagedHooksPath(remoteHome: string): string { + return `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/${MUSE_MANAGED_HOOKS_FILE_NAME}` +} + +export function getMuseManagedCommand(scriptPath: string): string { + return process.platform === 'win32' + ? wrapWindowsHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath) +} + +export function getMuseRemoteManagedCommand(scriptPath: string): string { + return wrapPosixHookCommand(scriptPath) +} + +// Why: the managed file is fully Orca-owned (muse runs it without a trust +// step via `managed_hooks_path`), so generate it wholesale — no user content +// to preserve, unlike an inline `hooks` block in settings.json. +export function buildMuseManagedHooksFile(command: string): string { + const hooks: Record = {} + for (const event of MUSE_HOOK_EVENTS) { + hooks[event] = [{ hooks: [buildManagedCommandHook(command)] }] + } + return `${JSON.stringify({ hooks }, null, 2)}\n` +} + +export function readManagedMuseHookEvents( + parsed: unknown, + isManagedCommand: (command: string | undefined) => boolean +): Set { + const present = new Set() + if (!isPlainObject(parsed) || !isPlainObject(parsed.hooks)) { + return present + } + for (const event of MUSE_HOOK_EVENTS) { + const definitions = parsed.hooks[event] + if (!Array.isArray(definitions)) { + continue + } + // Why: a hand-edited managed file can hold null definitions, non-array + // hook lists, or null entries — treat all of them as absent so status + // calculation never throws on user content. + if ( + definitions.some((definition) => + managedHookEntries(definition).some((hook) => isManagedCommand(hookEntryCommand(hook))) + ) + ) { + present.add(event) + } + } + return present +} + +export function getMuseManagedCommandMatcher(): (command: string | undefined) => boolean { + return createManagedCommandMatcher(getMuseManagedScriptFileName()) +} + +function managedHookEntries(definition: unknown): readonly unknown[] { + if (!isPlainObject(definition)) { + return [] + } + const hooks = definition.hooks + return Array.isArray(hooks) ? hooks : [] +} + +function hookEntryCommand(hook: unknown): string | undefined { + if (!isPlainObject(hook)) { + return undefined + } + const command = hook.command + return typeof command === 'string' ? command : undefined +} diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index 5cc0d089a59..7b49d746535 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -13,6 +13,7 @@ import { import { createHash } from 'node:crypto' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { pathToFileURL } from 'node:url' import { setAppEnvironment } from '../../shared/app-environment' const { getPathMock } = vi.hoisted(() => ({ @@ -288,6 +289,29 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { expect(pluginSource).toContain('messageID: part.messageID') }) + // Why: #22234 — OpenCode 2 installs under the plain `opencode` name, and its loader + // rejects a default export that only has server(). Asserting the emitted *source* is + // not enough; the installed file is what the v2 server validates, so load it. + it('installs a plugin whose default export satisfies both the v1 and v2 loaders', async () => { + const service = new OpenCodeHookService() + service.buildPtyEnv(daemonSessionId) + + const pluginPath = join(resolveOpenCodeConfigDirectory(), 'plugins', 'orca-opencode-status.js') + // Why: a .mjs copy so Node parses the installed file as ESM without a package.json. + const modulePath = join(userDataDir, `installed-opencode-plugin-${Date.now()}.mjs`) + writeFileSync(modulePath, readFileSync(pluginPath, 'utf8')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the assertions below validate the shape this names. + const module = (await import(pathToFileURL(modulePath).href)) as { + default?: { id?: unknown; server?: unknown; setup?: unknown } + } + + expect(module.default?.id).toBe('orca-opencode-status') + // v1 loader: "must default export an object with server()". + expect(module.default?.server).toBeTypeOf('function') + // v2 loader: "Plugin must export a default definition with an id and an effect or setup function." + expect(module.default?.setup).toBeTypeOf('function') + }) + it('clearPty leaves the shared OpenCode config dir off the teardown hot path', () => { const service = new OpenCodeHookService() service.buildPtyEnv(daemonSessionId) diff --git a/src/main/runtime/__fixtures__/muse-empty-folder-ready.meta.json b/src/main/runtime/__fixtures__/muse-empty-folder-ready.meta.json new file mode 100644 index 00000000000..56cd85bc144 --- /dev/null +++ b/src/main/runtime/__fixtures__/muse-empty-folder-ready.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-22T09:55:35.466Z", + "platform": "darwin", + "command": ["muse", "--provider", "echo", "--trust-workspace"], + "cols": 120, + "rows": 32, + "note": "Muse 1.3.0 empty folder ready; echo provider", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/muse-empty-folder-ready.txt b/src/main/runtime/__fixtures__/muse-empty-folder-ready.txt new file mode 100644 index 00000000000..7ec22ad0ee7 --- /dev/null +++ b/src/main/runtime/__fixtures__/muse-empty-folder-ready.txt @@ -0,0 +1,5 @@ +]10;?]11;?]4;0;?]4;1;?]4;2;?]4;3;?]4;4;?]4;5;?]4;6;?]4;7;?]4;8;?]4;9;?]4;10;?]4;11;?]4;12;?]4;13;?]4;14;?]4;15;?[?2004h[?1004h[0 q[?25l[>3u[?u + + + +7MMM8Muse Code1.3.0  Muse Code 1.3.0  ]0;muse-ready-workspace______── Voiceinput(⌥+vtostart) ────────────────────────────────────────────────────────────────────────────────────────❯────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────echo·/private/tmp/muse-ready-workspace______[?25h·Auto-review \ No newline at end of file diff --git a/src/main/runtime/muse-readiness-transcript.test.ts b/src/main/runtime/muse-readiness-transcript.test.ts new file mode 100644 index 00000000000..9e4fcdda6ef --- /dev/null +++ b/src/main/runtime/muse-readiness-transcript.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { createTranscriptPane } from './agent-transcript-pane-test-harness' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +describe('Muse readiness from captured terminal bytes', () => { + it('recognizes a ready folder workspace without a skills summary', async () => { + const data = readFileSync( + join(__dirname, '__fixtures__', 'muse-empty-folder-ready.txt'), + 'utf8' + ) + expect(data).toContain(String.fromCharCode(27)) + expect(data).not.toContain('Skills:') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'muse-first-class-workspace', + foregroundProcess: 'muse-bin-1.3.0-R3401.1', + launchAgent: 'muse', + data + }) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 10_000 }) + ).resolves.toMatchObject({ satisfied: true }) + }, 15_000) +}) diff --git a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts index ea6329db3e6..9d2ea33a6b8 100644 --- a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts +++ b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts @@ -6,7 +6,8 @@ import { buildPtyTerminalWaitResult, buildTerminalWaitResult } from './terminal- import type { AgentStatus } from '../../shared/agent-detection' import { detectExplicitIdleStatusFromTitle, - isKnownReadyPromptPreview + isKnownReadyPromptPreview, + isMuseReadyPromptPreview } from './terminal-wait-detection' import { buildTerminalWaitText } from './terminal-wait-tail-state' import { isTuiIdleSatisfied } from './tui-idle-evidence' @@ -110,6 +111,10 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc isKnownReadyPromptPreview( buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) ), + readMuseReadyBodyEvidence: () => + isMuseReadyPromptPreview( + buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) + ), agent: this.getPaneAgentForTuiIdle(leaf.ptyId), firstPartyStatus: (leaf.ptyId ? this.ptysById.get(leaf.ptyId)?.lastExplicitAgentStatus : null) ?? null, @@ -195,6 +200,10 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc isKnownReadyPromptPreview( buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) ), + readMuseReadyBodyEvidence: () => + isMuseReadyPromptPreview( + buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) + ), agent: this.getPaneAgentForTuiIdle(pty.ptyId), firstPartyStatus: pty.lastExplicitAgentStatus ?? null, quiescenceMs: TUI_IDLE_QUIESCENCE_MS diff --git a/src/main/runtime/runtime-terminal-idle-polls.ts b/src/main/runtime/runtime-terminal-idle-polls.ts index e1be9654611..8960dc79d26 100644 --- a/src/main/runtime/runtime-terminal-idle-polls.ts +++ b/src/main/runtime/runtime-terminal-idle-polls.ts @@ -2,7 +2,8 @@ import { isShellProcess, type AgentStatus } from '../../shared/agent-detection' import type { RuntimeTerminalWait } from '../../shared/runtime-types' import { detectTerminalWaitBlockedReason, - isKnownReadyPromptPreview + isKnownReadyPromptPreview, + isMuseReadyPromptPreview } from './terminal-wait-detection' import { buildPtyTerminalWaitBlockedResult, @@ -128,6 +129,7 @@ export class RuntimeTerminalIdlePolls { record: leaf, rendererTitle: leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId), readPositiveBodyEvidence: () => isKnownReadyPromptPreview(waitText), + readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText), agent, firstPartyStatus: this.deps.getFirstPartyAgentStatus(leaf.ptyId), quiescenceMs: this.deps.quiescenceMs @@ -195,6 +197,7 @@ export class RuntimeTerminalIdlePolls { readPositiveBodyEvidence: () => this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' || isKnownReadyPromptPreview(waitText), + readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText), agent, firstPartyStatus: this.deps.getFirstPartyAgentStatus(pty.ptyId), quiescenceMs: this.deps.quiescenceMs diff --git a/src/main/runtime/runtime-terminal-wait.ts b/src/main/runtime/runtime-terminal-wait.ts index 67acaf2a60b..23d112c8e9f 100644 --- a/src/main/runtime/runtime-terminal-wait.ts +++ b/src/main/runtime/runtime-terminal-wait.ts @@ -5,7 +5,8 @@ import type { import { hasAntigravityTerminalHeader } from './antigravity-terminal-readiness' import { detectTerminalWaitBlockedReason, - isKnownReadyPromptPreview + isKnownReadyPromptPreview, + isMuseReadyPromptPreview } from './terminal-wait-detection' import { buildPtyTerminalWaitBlockedResult, @@ -53,6 +54,7 @@ export class RuntimeTerminalWait { record: pty, readPositiveBodyEvidence: () => this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' || isKnownReadyPromptPreview(waitText), + readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText), agent: this.deps.getPaneAgent(pty.ptyId), firstPartyStatus: this.deps.getFirstPartyAgentStatus(pty.ptyId), quiescenceMs: this.deps.quiescenceMs @@ -64,6 +66,7 @@ export class RuntimeTerminalWait { record: leaf, rendererTitle: leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId), readPositiveBodyEvidence: () => isKnownReadyPromptPreview(waitText), + readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText), agent: this.deps.getPaneAgent(leaf.ptyId), firstPartyStatus: this.deps.getFirstPartyAgentStatus(leaf.ptyId), quiescenceMs: this.deps.quiescenceMs diff --git a/src/main/runtime/terminal-wait-detection.test.ts b/src/main/runtime/terminal-wait-detection.test.ts index 78d4945f2b2..842fa653eac 100644 --- a/src/main/runtime/terminal-wait-detection.test.ts +++ b/src/main/runtime/terminal-wait-detection.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { detectTerminalWaitBlockedReason, - isKnownReadyPromptPreview + isKnownReadyPromptPreview, + isMuseReadyPromptPreview } from './terminal-wait-detection' import { buildTerminalWaitText } from './terminal-wait-tail-state' @@ -506,3 +507,78 @@ describe('Antigravity readiness does not absorb its own startup dialog', () => { }) } }) + +// Real bytes: node-pty capture of `muse --provider echo --trust-workspace` at its ready +// prompt (banner, skills summary, `❯` composer, provider status line), plus the +// trust dialog from the same capture with no trust flag. +const MUSE_READY_SCREEN_ECHO = [ + ' Muse Code 1.3.0', + ' Skills: 77 loaded · 1 warning · 28 details hidden (ctrl+o to expand)', + '── Voice input (⌥ + v to start) ──────────────────────────────────────────', + '❯ ────────────────────────────────────────────────────────────────────', + ' echo · /private/tmp · YOLO' +] + +const MUSE_READY_SCREEN_META = [ + ' Muse Code 1.3.0', + ' Skills: 77 loaded · 1 warning · 28 details hidden (ctrl+o to expand)', + '── Voice input (⌥ + v to start) ──────────────────────────────────────────', + '❯ ────────────────────────────────────────────────────────────────────', + ' muse-spark-1.3 · max · ~/Downloads/interview-coach · YOLO' +] + +const MUSE_TRUST_DIALOG = [ + 'Do you trust this workspace?', + 'Workspace: /private/tmp', + 'Trusting allows project-local skills, rules, hooks, and plugin config to load before the model runs.', + 'Only trust this workspace when you trust its contents.', + '> 1 Trust and continue', + ' 2 Quit', + 'Use Up/Down or 1/2, then Enter. Esc quits.' +] + +describe('isMuseReadyPromptPreview', () => { + it('recognizes a Muse ready screen across providers', () => { + expect(isMuseReadyPromptPreview(waitTextFor(MUSE_READY_SCREEN_ECHO))).toBe(true) + expect(isMuseReadyPromptPreview(waitTextFor(MUSE_READY_SCREEN_META))).toBe(true) + }) + + it('tolerates ANSI styling around the ready markers', () => { + const esc = String.fromCharCode(27) + expect( + isMuseReadyPromptPreview( + waitTextFor([ + ` ${esc}[1m${esc}[38;2;204;211;219;49mMuse Code 1.3.0`, + `── Voice input (⌥ + v to start) ───`, + `${esc}[38;2;90;160;255;49m❯ ${esc}[39m${esc}[49m`, + ` echo · /private/tmp · ${esc}[38;2;243;139;168;49mYOLO` + ]) + ) + ).toBe(true) + }) + + it('refuses a bare Muse mention without its composer', () => { + expect(isMuseReadyPromptPreview(waitTextFor(['comparing Muse Code vs codex']))).toBe(false) + expect( + isMuseReadyPromptPreview(waitTextFor(['Muse Code 1.3.0', ' echo · /private/tmp · YOLO'])) + ).toBe(false) + }) + + it('refuses the Muse trust dialog, which carries no banner or composer', () => { + const waitText = waitTextFor(MUSE_TRUST_DIALOG) + expect(isMuseReadyPromptPreview(waitText)).toBe(false) + expect(detectTerminalWaitBlockedReason(waitText)).toBe('agent-trust-workspace') + }) + + it('dismisses a trust dialog once Muse paints its ready screen', () => { + const waitText = waitTextFor([...MUSE_TRUST_DIALOG, ...MUSE_READY_SCREEN_META]) + expect(detectTerminalWaitBlockedReason(waitText)).toBeNull() + expect(isMuseReadyPromptPreview(waitText)).toBe(true) + }) + + it('refuses a ready screen once a blocked dialog opens below it', () => { + const waitText = waitTextFor([...MUSE_READY_SCREEN_META, ...MUSE_TRUST_DIALOG]) + expect(detectTerminalWaitBlockedReason(waitText)).toBe('agent-trust-workspace') + expect(isMuseReadyPromptPreview(waitText)).toBe(false) + }) +}) diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index 34745b40c61..e2b22b10427 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -54,6 +54,18 @@ export function isKnownReadyPromptPreview(preview: string): boolean { return true } +// Why separate from isKnownReadyPromptPreview: that one settles tier 1 immediately, while +// a Muse ready screen only proves the TUI is up — the ranking holds it to quiescence. +export function isMuseReadyPromptPreview(preview: string): boolean { + const normalized = preview.toLowerCase() + const readyIndex = findMuseReadyPromptIndex(normalized) + if (readyIndex === null) { + return false + } + const blockedSignal = findTerminalWaitBlockedSignal(normalized) + return blockedSignal === null || blockedSignal.index <= readyIndex +} + export function detectTerminalWaitBlockedReason( preview: string ): RuntimeTerminalWaitBlockedReason | null { @@ -80,7 +92,8 @@ function findDismissedStartupModalIndex(normalized: string): number | null { const indexes = [ findCodexReadyPromptIndex(normalized), findAntigravityReadyPromptIndex(normalized), - findCursorActivePromptIndex(normalized) + findCursorActivePromptIndex(normalized), + findMuseReadyPromptIndex(normalized) ].filter((index): index is number => index !== null) return indexes.length > 0 ? Math.max(...indexes) : null } @@ -114,6 +127,19 @@ function findCursorReadyPromptIndex(normalized: string): number | null { return CURSOR_BUSY_SPINNER_RE.test(normalized.slice(activeIndex)) ? null : activeIndex } +// Why: Muse titles its OSC with the bare cwd and never updates it, so only the body can +// prove the TUI is up. The voice-input composer is present even without loaded skills. +function findMuseReadyPromptIndex(normalized: string): number | null { + const headerIndex = normalized.lastIndexOf('muse code') + if (headerIndex === -1) { + return null + } + const segment = normalized.slice(headerIndex) + return segment.includes('voice') && segment.includes('input') && segment.includes('❯') + ? headerIndex + : null +} + function findCodexReadyPromptIndex(normalized: string): number | null { const headerIndex = normalized.lastIndexOf('openai codex') if (headerIndex === -1) { diff --git a/src/main/runtime/terminal-wait-name-only-idle.test.ts b/src/main/runtime/terminal-wait-name-only-idle.test.ts index 70605598466..95cfa54be56 100644 --- a/src/main/runtime/terminal-wait-name-only-idle.test.ts +++ b/src/main/runtime/terminal-wait-name-only-idle.test.ts @@ -24,6 +24,15 @@ const QUIESCENCE_MS = 3000 const NAME_ONLY_TITLE = 'Codex' const EXPLICIT_IDLE_TITLE = 'Codex ready' const HANDLE = 'terminal-1' +// Real bytes: node-pty capture of `muse --provider echo --trust-workspace` at its ready +// prompt. Muse's OSC title is the bare cwd (`tmp`) and never changes. +const MUSE_READY_TAIL = [ + ' Muse Code 1.3.0', + ' Skills: 77 loaded · 1 warning · 28 details hidden (ctrl+o to expand)', + '── Voice input (⌥ + v to start) ──────────────────────────────────────────', + '❯ ────────────────────────────────────────────────────────────────────', + ' muse-spark-1.3 · max · ~/Downloads/interview-coach · YOLO' +] function createWait(options: { pty?: RuntimePtyWorktreeRecord @@ -198,6 +207,37 @@ describe('tui-idle evidence ranking', () => { await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4 + QUIESCENCE_MS) expect(settled).not.toHaveBeenCalled() }) + + // Why: Muse sets its OSC title to the bare cwd and never updates it, so the title + // lanes stay null and only the ready-screen body can settle the wait — but only once + // the stream goes quiet, so a mid-turn streaming pane never satisfies. + it('settles a Muse ready screen only once the stream goes quiet', async () => { + const pty = makeTuiIdlePty({ + lastAgentStatus: null, + lastOscTitle: 'tmp', + tailBuffer: [...MUSE_READY_TAIL] + }) + const { wait } = createWait({ pty, agent: 'muse' }) + const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 })) + + await advanceWhileStreaming(pty, 2) + expect(settled).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(QUIESCENCE_MS + POLL_INTERVAL_MS) + expect(settled).toHaveBeenCalledWith({ ok: expect.objectContaining({ satisfied: true }) }) + }) + + it('never settles another agent quoting Muse in its scrollback', async () => { + const pty = makeTuiIdlePty({ + lastAgentStatus: null, + lastOscTitle: 'Codex', + tailBuffer: [...MUSE_READY_TAIL], + lastOutputAt: Date.now() - QUIESCENCE_MS * 4 + }) + const { wait } = createWait({ pty, agent: 'codex' }) + const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 })) + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3 + QUIESCENCE_MS) + expect(settled).not.toHaveBeenCalled() + }) }) const E2E_WORKTREE_ID = 'repo-1::/tmp/name-only-idle' @@ -294,4 +334,13 @@ describe('tui-idle over the live OSC title pipeline', () => { runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) }) + + it('settles a quiet Muse ready screen over the live PTY pipeline', async () => { + const { runtime, handle } = await makeRuntime('muse') + runtime.onPtyData(E2E_PTY_ID, `${oscTitle('tmp')}${MUSE_READY_TAIL.join('\n')}\n`, Date.now()) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 15_000 }) + ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + }, 20_000) }) diff --git a/src/main/runtime/tui-idle-evidence.test.ts b/src/main/runtime/tui-idle-evidence.test.ts new file mode 100644 index 00000000000..d4c9adfa6fb --- /dev/null +++ b/src/main/runtime/tui-idle-evidence.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { + hasQuietMuseReadyPrompt, + isTuiIdleSatisfied, + type TuiIdleEvidenceRecord, + type TuiIdleSatisfactionInput +} from './tui-idle-evidence' + +const QUIESCENCE_MS = 3000 + +function record(overrides: Partial = {}): TuiIdleEvidenceRecord { + return { + lastAgentStatus: null, + lastOutputAt: Date.now() - QUIESCENCE_MS * 2, + lastOscTitle: 'tmp', + ...overrides + } +} + +function input(overrides: Partial = {}): TuiIdleSatisfactionInput { + return { + record: record(), + readPositiveBodyEvidence: () => false, + readMuseReadyBodyEvidence: () => true, + agent: 'muse', + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS, + ...overrides + } +} + +describe('hasQuietMuseReadyPrompt', () => { + it('settles a Muse ready screen once the stream has gone quiet', () => { + expect(hasQuietMuseReadyPrompt(record(), 'muse', () => true, QUIESCENCE_MS)).toBe(true) + }) + + it('refuses while the pane is still streaming', () => { + expect( + hasQuietMuseReadyPrompt( + record({ lastOutputAt: Date.now() }), + 'muse', + () => true, + QUIESCENCE_MS + ) + ).toBe(false) + }) + + it('refuses without an output clock, like the tier-3 lane', () => { + expect( + hasQuietMuseReadyPrompt(record({ lastOutputAt: null }), 'muse', () => true, QUIESCENCE_MS) + ).toBe(false) + }) + + it('refuses without a ready screen', () => { + expect(hasQuietMuseReadyPrompt(record(), 'muse', () => false, QUIESCENCE_MS)).toBe(false) + }) + + it('covers adopted panes that carry no launch metadata', () => { + expect(hasQuietMuseReadyPrompt(record(), null, () => true, QUIESCENCE_MS)).toBe(true) + expect(hasQuietMuseReadyPrompt(record(), undefined, () => true, QUIESCENCE_MS)).toBe(true) + }) + + it('refuses another agent quoting Muse in its scrollback', () => { + expect(hasQuietMuseReadyPrompt(record(), 'codex', () => true, QUIESCENCE_MS)).toBe(false) + }) +}) + +describe('isTuiIdleSatisfied muse lane', () => { + it('settles a quiet Muse pane with no title signal at all', () => { + expect(isTuiIdleSatisfied(input())).toBe(true) + }) + + it('lets a fresh first-party working status veto the Muse body', () => { + expect( + isTuiIdleSatisfied(input({ firstPartyStatus: { state: 'working', updatedAt: Date.now() } })) + ).toBe(false) + }) +}) diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 827760020f8..0ca43b15a6c 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -17,6 +17,8 @@ import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection' * * 1. POSITIVE — the agent states it is ready: an explicit idle marker in its own * title, or a known ready-prompt body. + * 1b. MUSE — Muse emits no title signal at all, so its ready-screen body stands in + * for the positive evidence, believed only once the stream has gone quiet. * 2. VETO — a fresh first-party agent status (OSC 9999) saying working/blocked/ * waiting. The agent's own account of itself outranks anything inferred. * 3. ABSENCE — a name-only title, or a quiet non-shell foreground process. A last @@ -119,12 +121,44 @@ export type TuiIdleSatisfactionInput = { * (~11us and a multi-KB string on a full tail); the title check below usually answers * first, and then none of that has to happen at all. */ readPositiveBodyEvidence: () => boolean + /** Tier 1b body evidence: a Muse ready screen. Thunk for the same reason as above. */ + readMuseReadyBodyEvidence: () => boolean agent: TuiAgent | null | undefined firstPartyStatus: FirstPartyAgentStatus quiescenceMs: number } -/** The one place the three tiers are combined; every satisfaction site routes here. */ +/** + * Tier 1b: a Muse ready screen in the body, believed only once the stream has gone quiet. + * + * Muse is the one agent with no title signal at all — its OSC title is the bare cwd and + * never changes — so neither the explicit-idle nor the sustained-title lane can fire. + * The ready screen proves the TUI is up; the quiescence demand keeps a mid-turn + * streaming pane from satisfying, mirroring the codex tier-3 lane's + * positive-evidence-plus-quiet shape. Scoped to Muse and agent-unknown panes: another + * agent's scrollback quoting Muse must not settle its wait. + */ +export function hasQuietMuseReadyPrompt( + record: TuiIdleEvidenceRecord, + agent: TuiAgent | null | undefined, + readBodyEvidence: () => boolean, + quiescenceMs: number +): boolean { + if (agent !== null && agent !== undefined && agent !== 'muse') { + return false + } + if (!readBodyEvidence()) { + return false + } + // Why: same rule as the tier-3 lane — without an output clock there is no + // corroboration available, so hold out instead of settling. + if (record.lastOutputAt === null) { + return false + } + return Date.now() - record.lastOutputAt >= quiescenceMs +} + +/** The one place the tiers are combined; every satisfaction site routes here. */ export function isTuiIdleSatisfied(input: TuiIdleSatisfactionInput): boolean { // Why the title before the body: both are tier 1, so either settles, but the title is a // memoized lookup and the body is a fresh multi-KB scan. Same verdict, cheaper order. @@ -134,5 +168,16 @@ export function isTuiIdleSatisfied(input: TuiIdleSatisfactionInput): boolean { if (hasFreshWorkingFirstPartyStatus(input.firstPartyStatus)) { return false } + // Why after the veto: a first-party working account outranks inferred body evidence. + if ( + hasQuietMuseReadyPrompt( + input.record, + input.agent, + input.readMuseReadyBodyEvidence, + input.quiescenceMs + ) + ) { + return true + } return hasSustainedTitleIdle(input.record, input.agent, input.quiescenceMs) } diff --git a/src/main/skills/skill-discovery-concurrency.test.ts b/src/main/skills/skill-discovery-concurrency.test.ts index a51d6dc9d52..782d1692dac 100644 --- a/src/main/skills/skill-discovery-concurrency.test.ts +++ b/src/main/skills/skill-discovery-concurrency.test.ts @@ -185,7 +185,7 @@ describe('bounded concurrent skill discovery', () => { const line = String(info.mock.calls.at(0)?.at(0)) // `present` is the signal that separates "big tree" from "big root set", and // is not derivable from the other counts. - expect(line).toContain('[skills] scan roots=24 present=3 walked=24 skills=3') + expect(line).toContain('[skills] scan roots=25 present=3 walked=25 skills=3') expect(line).toContain('home-claude') expect(line).not.toContain(home) expect(line).not.toContain(tmpdir()) diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index 09bf8e208c8..e9a1db8a4fd 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -232,6 +232,17 @@ export function buildSkillDiscoverySources( 'home', ['agent-skills'], 'aug' + ), + // Why: user skills live under XDG config home (`~/.config/muse/skills` by + // default); project skills are the canonical `.agents/skills` root already + // covered by home-agents/repo-agents, so no agent-specific repo source. + source( + 'home-muse', + 'Muse home', + pathApi.join(home, '.config', 'muse', 'skills'), + 'home', + ['agent-skills'], + 'muse' ) ] diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index acb49f500d3..b259bb9915e 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -130,6 +130,7 @@ export type FilesystemApi = { requestToken?: string maxResults?: number searchQuery?: string + nameFilter?: string }) => Promise cancelListFiles: (args: { requestToken: string }) => Promise search: (args: SearchOptions & { connectionId?: string }) => Promise diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index 2f702d9464f..d58dd17ff8f 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -136,6 +136,7 @@ export const fsApi = { requestToken?: string maxResults?: number searchQuery?: string + nameFilter?: string }): Promise => ipcRenderer.invoke('fs:listFiles', args), cancelListFiles: (args: { requestToken: string }): Promise => ipcRenderer.invoke('fs:cancelListFiles', args), diff --git a/src/relay/agent-hook-result-retry-scheduler.ts b/src/relay/agent-hook-result-retry-scheduler.ts index cc6b18c582e..3a6c9cd07fb 100644 --- a/src/relay/agent-hook-result-retry-scheduler.ts +++ b/src/relay/agent-hook-result-retry-scheduler.ts @@ -2,7 +2,6 @@ // when the hook fired, so re-read the same body on a timer and re-apply only if it changed. Both // timer families live in one owner so pane teardown and server stop tear both down in one ordered // place before the listener caches are cleared. -import { hasCodexTranscriptSubagents } from '../shared/agent-hook-listener/providers/codex-state' import { hasPendingAgentResultText, preparePendingGrokResultDiscovery @@ -11,13 +10,17 @@ import { normalizeHookPayload } from '../shared/agent-hook-listener' import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' import type { HookListenerState } from '../shared/agent-hook-listener/listener-state' import type { AgentHookSource } from '../shared/agent-hook-relay' +import { + shouldPollHookTranscript, + transcriptPollUpdate +} from '../shared/agent-hook-listener/transcript-poll-policy' import { CodexSubagentPollScheduler } from '../shared/codex-subagent-poll-scheduler' const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 const ASSISTANT_MESSAGE_RETRY_MS = 50 const CODEX_SUBAGENT_POLL_MS = 1_000 -type CodexSubagentPoll = { +type TranscriptPoll = { source: AgentHookSource body: unknown original: AgentHookEventPayload @@ -40,14 +43,14 @@ export type AgentHookResultRetryHost = { export class AgentHookResultRetryScheduler { private assistantMessageRetryTimers = new Map>() - private codexSubagentPollScheduler: CodexSubagentPollScheduler + private transcriptPollScheduler: CodexSubagentPollScheduler private host: AgentHookResultRetryHost constructor(host: AgentHookResultRetryHost) { this.host = host - this.codexSubagentPollScheduler = new CodexSubagentPollScheduler( + this.transcriptPollScheduler = new CodexSubagentPollScheduler( CODEX_SUBAGENT_POLL_MS, - (paneKey, poll) => this.runCodexSubagentPoll(paneKey, poll) + (paneKey, poll) => this.runTranscriptPoll(paneKey, poll) ) } @@ -56,7 +59,7 @@ export class AgentHookResultRetryScheduler { clearTimeout(timer) } this.assistantMessageRetryTimers.clear() - this.codexSubagentPollScheduler.clearAll() + this.transcriptPollScheduler.clearAll() } clearAssistantMessageRetry(paneKey: string): void { @@ -68,26 +71,26 @@ export class AgentHookResultRetryScheduler { this.assistantMessageRetryTimers.delete(paneKey) } - clearCodexSubagentPoll(paneKey: string): void { - this.codexSubagentPollScheduler.clear(paneKey) + clearTranscriptPoll(paneKey: string): void { + this.transcriptPollScheduler.clear(paneKey) } - scheduleCodexSubagentPoll( + scheduleTranscriptPoll( source: AgentHookSource, body: unknown, original: AgentHookEventPayload, env?: string, version?: string ): void { - // Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll. - if (source !== 'codex') { + // Why: a nested CLI of another kind inherits ORCA_PANE_KEY, so clearing here would silently end a live poll. + if (source !== 'codex' && source !== 'muse') { return } - this.codexSubagentPollScheduler.clear(original.paneKey) - if (!hasCodexTranscriptSubagents(this.host.state, original.paneKey)) { + this.transcriptPollScheduler.clear(original.paneKey) + if (!shouldPollHookTranscript(this.host.state, source, original)) { return } - this.codexSubagentPollScheduler.schedule(original.paneKey, { + this.transcriptPollScheduler.schedule(original.paneKey, { source, body, original, @@ -96,7 +99,7 @@ export class AgentHookResultRetryScheduler { }) } - private runCodexSubagentPoll(paneKey: string, poll: CodexSubagentPoll): void { + private runTranscriptPoll(paneKey: string, poll: TranscriptPoll): void { const { source, body, original, env, version } = poll // Keep the identity check at callback time: a newer event supersedes this // payload even when its pane still has transcript children. @@ -111,13 +114,12 @@ export class AgentHookResultRetryScheduler { if (!event) { return } - const subagentsChanged = - JSON.stringify(event.payload.subagents) !== JSON.stringify(original.payload.subagents) - const next = subagentsChanged ? event : original - if (subagentsChanged) { - this.host.applyEvent(event, source, env, version) + const update = transcriptPollUpdate(source, original, event) + const next = update ?? original + if (update) { + this.host.applyEvent(update, source, env, version) } - this.scheduleCodexSubagentPoll(source, body, next, env, version) + this.scheduleTranscriptPoll(source, body, next, env, version) } scheduleAssistantMessageRetry( diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index c3a99b8a20c..3ea7609b9d7 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -168,7 +168,7 @@ describe('RelayAgentHookServer', () => { internals = server as unknown as RelayServerInternals const retryScheduler = internals.retryScheduler const originalAssistantRetry = retryScheduler.scheduleAssistantMessageRetry.bind(retryScheduler) - const originalCodexRetry = retryScheduler.scheduleCodexSubagentPoll.bind(retryScheduler) + const originalTranscriptPoll = retryScheduler.scheduleTranscriptPoll.bind(retryScheduler) const assistantRetry = vi .spyOn(retryScheduler, 'scheduleAssistantMessageRetry') .mockImplementation((...args) => { @@ -176,10 +176,10 @@ describe('RelayAgentHookServer', () => { originalAssistantRetry(...args) }) const codexRetry = vi - .spyOn(retryScheduler, 'scheduleCodexSubagentPoll') + .spyOn(retryScheduler, 'scheduleTranscriptPoll') .mockImplementation((...args) => { order.push('codex-retry') - originalCodexRetry(...args) + originalTranscriptPoll(...args) }) await server.start() try { @@ -217,7 +217,7 @@ describe('RelayAgentHookServer', () => { const internals = server as unknown as RelayServerInternals const retryScheduler = internals.retryScheduler const assistantRetry = vi.spyOn(retryScheduler, 'scheduleAssistantMessageRetry') - const codexRetry = vi.spyOn(retryScheduler, 'scheduleCodexSubagentPoll') + const codexRetry = vi.spyOn(retryScheduler, 'scheduleTranscriptPoll') await server.start() try { const { port, token } = server.getCoordinates() diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 378f81cf022..e75b11a2450 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -225,7 +225,7 @@ export class RelayAgentHookServer { /** Drop a paneKey's cached entries on PTY exit so a terminated pane can't resurface as a ghost event on reconnect. */ clearPaneState(paneKey: string): void { this.retryScheduler.clearAssistantMessageRetry(paneKey) - this.retryScheduler.clearCodexSubagentPoll(paneKey) + this.retryScheduler.clearTranscriptPoll(paneKey) clearPaneCacheState(this.state, paneKey) this.lastEnvelopeMetaByPaneKey.delete(paneKey) } @@ -284,7 +284,7 @@ export class RelayAgentHookServer { const version = hookBodyVersion(hookBody) this.applyEvent(event, source, env, version) this.retryScheduler.scheduleAssistantMessageRetry(source, hookBody, event, env, version) - this.retryScheduler.scheduleCodexSubagentPoll(source, hookBody, event, env, version) + this.retryScheduler.scheduleTranscriptPoll(source, hookBody, event, env, version) } res.writeHead(204) res.end() diff --git a/src/renderer/src/components/quick-open-capped-local-listing.ts b/src/renderer/src/components/quick-open-capped-local-listing.ts new file mode 100644 index 00000000000..0a9c41f5f40 --- /dev/null +++ b/src/renderer/src/components/quick-open-capped-local-listing.ts @@ -0,0 +1,13 @@ +/** Last capped local listing; name filters re-list such workspaces on the host. */ +export type CappedLocalListing = { key: string; hostFilterFailed: boolean } + +export function nextCappedLocalListing( + current: CappedLocalListing | null, + key: string, + truncated: boolean +): CappedLocalListing | null { + // Why: a failed host filter stays failed so re-listing the same workspace does not retry it. + return truncated + ? { key, hostFilterFailed: current?.key === key && current.hostFilterFailed } + : null +} diff --git a/src/renderer/src/components/quick-open-file-list-host-name-filter.react.test.tsx b/src/renderer/src/components/quick-open-file-list-host-name-filter.react.test.tsx new file mode 100644 index 00000000000..f683cbea97d --- /dev/null +++ b/src/renderer/src/components/quick-open-file-list-host-name-filter.react.test.tsx @@ -0,0 +1,230 @@ +// @vitest-environment happy-dom + +import { act, createElement, useEffect } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../../../shared/quick-open-listing-limits' +import { useAppStore } from '@/store' +import { useRuntimeFileListForWorktree, type RuntimeFileListState } from './quick-open-file-list' + +const listRuntimeFilesMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/runtime/runtime-file-client', () => ({ + listRuntimeFiles: listRuntimeFilesMock, + cancelRuntimeFileList: vi.fn(), + searchRuntimeFilePaths: vi.fn() +})) + +const initialAppState = useAppStore.getInitialState() +const roots: Root[] = [] + +function makeProjectGroup(): ProjectGroup { + return { + id: 'group-1', + name: 'Platform', + parentPath: '/srv/platform', + connectionId: null, + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } +} + +function makeFolderWorkspace(): FolderWorkspace { + return { + id: 'folder-workspace-1', + projectGroupId: 'group-1', + name: 'Platform workspace', + folderPath: '/srv/platform', + connectionId: null, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } +} + +type ProbeProps = { + enabled: boolean + onState: (state: RuntimeFileListState) => void + query?: string + hostFilterWhenCapped?: boolean + worktreeId: string | null +} + +function HookProbe({ onState, ...args }: ProbeProps): null { + const state = useRuntimeFileListForWorktree(args) + useEffect(() => { + onState(state) + }) + return null +} + +async function flushEffects(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function waitForListRuntimeFilesCall(): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + await flushEffects() + if (listRuntimeFilesMock.mock.calls.length > 0) { + return + } + } + throw new Error('listRuntimeFiles was not called') +} + +async function renderProbe(args: ProbeProps): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render(createElement(HookProbe, args)) + }) + await flushEffects() + return root +} + +beforeEach(() => { + useAppStore.setState(initialAppState, true) + listRuntimeFilesMock.mockReset().mockResolvedValue(['packages/app/package.json']) +}) + +afterEach(async () => { + for (const root of roots) { + await act(async () => { + root.unmount() + }) + } + roots.length = 0 + useAppStore.setState(initialAppState, true) +}) + +describe('useRuntimeFileListForWorktree host name filter', () => { + it('re-lists a capped local workspace with the name filter applied on the host', async () => { + vi.useFakeTimers() + useAppStore.setState({ + folderWorkspaces: [makeFolderWorkspace()], + projectGroups: [makeProjectGroup()], + repos: [], + worktreesByRepo: {} + }) + listRuntimeFilesMock + .mockResolvedValueOnce( + Array.from({ length: QUICK_OPEN_LISTING_MAX_RESULTS }, (_, i) => `src/file-${i}.ts`) + ) + .mockResolvedValueOnce(['ios/AppDelegate.swift']) + const states: RuntimeFileListState[] = [] + + try { + await renderProbe({ + enabled: true, + onState: (state) => states.push(state), + query: 'AppDelegate', + hostFilterWhenCapped: true, + worktreeId: folderWorkspaceKey('folder-workspace-1') + }) + await flushEffects() + await act(async () => vi.advanceTimersByTimeAsync(120)) + await flushEffects() + + expect(listRuntimeFilesMock).toHaveBeenCalledTimes(2) + expect(listRuntimeFilesMock.mock.calls[0][1]).not.toHaveProperty('nameFilter') + expect(listRuntimeFilesMock.mock.calls[1][1]).toMatchObject({ nameFilter: 'appdelegate' }) + expect(states.at(-1)).toMatchObject({ + files: ['ios/AppDelegate.swift'], + loading: false, + truncated: false + }) + } finally { + vi.useRealTimers() + } + }) + + it('filters an uncapped local listing in the renderer without a host re-list', async () => { + useAppStore.setState({ + folderWorkspaces: [makeFolderWorkspace()], + projectGroups: [makeProjectGroup()], + repos: [], + worktreesByRepo: {} + }) + + await renderProbe({ + enabled: true, + onState: () => {}, + query: 'package', + hostFilterWhenCapped: true, + worktreeId: folderWorkspaceKey('folder-workspace-1') + }) + await waitForListRuntimeFilesCall() + await flushEffects() + + expect(listRuntimeFilesMock).toHaveBeenCalledTimes(1) + expect(listRuntimeFilesMock.mock.calls[0][1]).not.toHaveProperty('nameFilter') + }) + it('falls back to the capped listing and stops re-listing after a host filter failure', async () => { + vi.useFakeTimers() + useAppStore.setState({ + folderWorkspaces: [makeFolderWorkspace()], + projectGroups: [makeProjectGroup()], + repos: [], + worktreesByRepo: {} + }) + const capped = Array.from({ length: QUICK_OPEN_LISTING_MAX_RESULTS }, (_, i) => `f-${i}.ts`) + listRuntimeFilesMock.mockImplementation(async (_context, args: { nameFilter?: string }) => { + if (args.nameFilter) { + throw new Error('rg list timed out') + } + return capped + }) + const states: RuntimeFileListState[] = [] + const workspaceKey = folderWorkspaceKey('folder-workspace-1') + + try { + const root = await renderProbe({ + enabled: true, + onState: (state) => states.push(state), + query: 'f-1', + hostFilterWhenCapped: true, + worktreeId: workspaceKey + }) + await act(async () => vi.advanceTimersByTimeAsync(120)) + await flushEffects() + await act(async () => { + root.render( + createElement(HookProbe, { + enabled: true, + onState: (state: RuntimeFileListState) => states.push(state), + query: 'f-2', + hostFilterWhenCapped: true, + worktreeId: workspaceKey + }) + ) + }) + await act(async () => vi.advanceTimersByTimeAsync(120)) + await flushEffects() + + const nameFilters = listRuntimeFilesMock.mock.calls.map((call) => call[1].nameFilter) + expect(nameFilters.filter(Boolean)).toEqual(['f-1']) + expect(states.at(-1)).toMatchObject({ files: capped, loadError: null, truncated: true }) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/quick-open-file-list.ts b/src/renderer/src/components/quick-open-file-list.ts index 5812d9d4a48..cfb757d515c 100644 --- a/src/renderer/src/components/quick-open-file-list.ts +++ b/src/renderer/src/components/quick-open-file-list.ts @@ -11,7 +11,12 @@ import { listRuntimeFiles, searchRuntimeFilePaths } from '@/runtime/runtime-file-client' -import { createRuntimeRpcAbortError } from '@/runtime/abortable-runtime-environment-call' +import { debounceRuntimeFileRequest } from '@/runtime/runtime-file-request-debounce' +import { splitFileNameFilterTokens } from '../../../shared/file-name-filter-tokens' +import { + nextCappedLocalListing, + type CappedLocalListing +} from '@/components/quick-open-capped-local-listing' import { useAppStore } from '@/store' import { useWorktreesForRepo } from '@/store/selectors' import type { FileExplorerOperationOwner } from '@/components/right-sidebar/file-explorer-types' @@ -43,28 +48,6 @@ export function cleanRuntimeFileListError(error: unknown): string { return raw.replace(/^Error invoking remote method '[^']+':\s*Error:\s*/, '') } -function debounceRuntimeFilePathSearch( - delayMs: number, - signal: AbortSignal, - search: () => Promise<{ files: string[]; truncated: boolean }> -): Promise<{ files: string[]; truncated: boolean }> { - return new Promise((resolve, reject) => { - const onAbort = (): void => { - signal.removeEventListener('abort', onAbort) - window.clearTimeout(timer) - reject(createRuntimeRpcAbortError()) - } - const timer = window.setTimeout(() => { - signal.removeEventListener('abort', onAbort) - void search().then(resolve, reject) - }, delayMs) - signal.addEventListener('abort', onAbort, { once: true }) - if (signal.aborted) { - onAbort() - } - }) -} - export function isNestedWorktreePath(parentPath: string, childPath: string): boolean { const windowsPath = isWindowsAbsolutePathLike(parentPath) const parent = parentPath.replace(/[\\/]+$/, '').replace(/\\/g, '/') @@ -138,11 +121,14 @@ export function getNestedWorktreeExcludeRequest( export function useRuntimeFileListForWorktree({ enabled, worktreeId, - query + query, + hostFilterWhenCapped = false }: { enabled: boolean worktreeId: string | null query?: string + /** When a local listing hits its cap, re-list with `query` applied as the Explorer name filter on the host. */ + hostFilterWhenCapped?: boolean }): RuntimeFileListState { const worktree = useAppStore((state) => // Why: folder workspaces live behind getKnownWorktreeById, not worktreesByRepo. @@ -153,6 +139,7 @@ export function useRuntimeFileListForWorktree({ const [listing, setListing] = useState(NO_LISTING) const [loadingRequest, setLoadingRequest] = useState({ requestKey: '', loading: false }) const [loadError, setLoadError] = useState(null) + const [cappedLocalListing, setCappedLocalListing] = useState(null) const [listedOperationOwner, setListedOperationOwner] = useState({ kind: 'unresolved' }) @@ -196,18 +183,17 @@ export function useRuntimeFileListForWorktree({ (runtimeEnvironmentId !== null || connectionId !== undefined) && query !== undefined const remoteQuery = usesRuntimePathSearch ? query.trim() : '' const remoteQueryTooLarge = usesRuntimePathSearch && isQuickOpenRemoteQueryTooLarge(remoteQuery) - const requestKey = useMemo( - () => - `${worktreePath ?? ''}\n${operationOwnerKey}\n${excludeRequest.key}\n${activeTargetStatus ?? ''}${usesRuntimePathSearch ? `\n${remoteQuery}` : ''}`, - [ - activeTargetStatus, - excludeRequest.key, - operationOwnerKey, - remoteQuery, - usesRuntimePathSearch, - worktreePath - ] - ) + const listingKey = `${worktreePath ?? ''}\n${operationOwnerKey}\n${excludeRequest.key}\n${activeTargetStatus ?? ''}` + // Why: a capped listing can omit matches, so only then pay for a host scan per query. + const hostNameFilter = + hostFilterWhenCapped && + runtimeEnvironmentId === null && + connectionId === undefined && + cappedLocalListing?.key === listingKey && + !cappedLocalListing.hostFilterFailed + ? splitFileNameFilterTokens(query ?? '').join(' ') + : '' + const requestKey = `${listingKey}${usesRuntimePathSearch ? `\n${remoteQuery}` : ''}${hostNameFilter ? `\nname-filter\n${hostNameFilter}` : ''}` // Why: the render between a request change and the effect that starts the next request must // not show the previous listing, so a listing is only visible for the request that produced it. const currentListing = listing.requestKey === requestKey ? listing : NO_LISTING @@ -222,6 +208,7 @@ export function useRuntimeFileListForWorktree({ useEffect(() => { if (!enabled) { + setCappedLocalListing(null) setLoadingRequest({ requestKey, loading: false }) setListedOperationOwner({ kind: 'unresolved' }) return @@ -258,8 +245,23 @@ export function useRuntimeFileListForWorktree({ connectionId } + const listFiles = (nameFilter?: string) => + listRuntimeFiles(requestContext, { + rootPath: worktreePath, + excludePaths, + requestToken, + maxResults: QUICK_OPEN_LISTING_MAX_RESULTS, + ...(nameFilter ? { nameFilter } : {}), + signal: requestAbortController.signal + }).then((files) => ({ + // #12547: naming the cap is what makes a full page readable as "there is more". Reporting + // false unconditionally is what made the truncation silent — the host bounds the scan to + // the cap it is given, so a full page means there are more paths behind it. + files, + truncated: files.length >= QUICK_OPEN_LISTING_MAX_RESULTS + })) const request = usesRuntimePathSearch - ? debounceRuntimeFilePathSearch(120, requestAbortController.signal, () => + ? debounceRuntimeFileRequest(120, requestAbortController.signal, () => searchRuntimeFilePaths(requestContext, { query: remoteQuery, limit: 32, @@ -268,29 +270,29 @@ export function useRuntimeFileListForWorktree({ signal: requestAbortController.signal }) ) - : listRuntimeFiles(requestContext, { - rootPath: worktreePath, - excludePaths, - requestToken, - maxResults: QUICK_OPEN_LISTING_MAX_RESULTS, - signal: requestAbortController.signal - }).then((files) => ({ - // #12547: naming the cap is what makes a full page readable as "there is more". Reporting - // false unconditionally is what made the truncation silent — the host bounds the scan to - // the cap it is given, so a full page means there are more paths behind it. - files, - truncated: files.length >= QUICK_OPEN_LISTING_MAX_RESULTS - })) + : hostNameFilter + ? debounceRuntimeFileRequest(120, requestAbortController.signal, () => + listFiles(hostNameFilter) + ) + : listFiles() void request .then((result) => { if (!cancelled) { setListing({ requestKey, ...result }) setListedOperationOwner(requestOperationOwner) + if (!usesRuntimePathSearch && !hostNameFilter) { + setCappedLocalListing((current) => + nextCappedLocalListing(current, listingKey, result.truncated) + ) + } } }) .catch((error) => { - if (!cancelled) { + if (!cancelled && hostNameFilter) { + // Why: a failed host scan falls back to filtering the capped listing, not an error. + setCappedLocalListing((current) => current && { ...current, hostFilterFailed: true }) + } else if (!cancelled) { setListing(NO_LISTING) setLoadError(cleanRuntimeFileListError(error)) } @@ -317,6 +319,8 @@ export function useRuntimeFileListForWorktree({ operationOwnerKey, operationRouteAvailable, requestKey, + hostNameFilter, + listingKey, runtimeEnvironmentId, target.canList, worktreeId, diff --git a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts index 8189bcad262..3a118ba18c5 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts @@ -1,6 +1,11 @@ import { joinPath, normalizeRelativePath } from '@/lib/path' -import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' import { compareFileNames } from '../../../../shared/file-name-sort' +import { + FILE_NAME_FILTER_QUERY_MAX_BYTES, + isFileNameFilterQueryTooLarge, + pathMatchesFileNameFilterTokens, + splitFileNameFilterTokens +} from '../../../../shared/file-name-filter-tokens' import type { FileExplorerOperationOwner, TreeNode } from './file-explorer-types' import { createFileExplorerRowProjectionFromParts, @@ -16,7 +21,7 @@ export type FileExplorerNameFilterProjectionSource = { operationOwner?: FileExplorerOperationOwner } -export const FILE_EXPLORER_NAME_FILTER_QUERY_MAX_BYTES = 2 * 1024 +export const FILE_EXPLORER_NAME_FILTER_QUERY_MAX_BYTES = FILE_NAME_FILTER_QUERY_MAX_BYTES export function getNextNameFilterCollapsedPaths( collapsedPaths: ReadonlySet, @@ -48,61 +53,14 @@ export function isFileExplorerNameFilterQueryTooLarge( query: string | undefined, maxBytes = FILE_EXPLORER_NAME_FILTER_QUERY_MAX_BYTES ): boolean { - const value = query ?? '' - return isClipboardTextByteLengthOverLimit(value, maxBytes) + return isFileNameFilterQueryTooLarge(query ?? '', maxBytes) } export function getFileExplorerNameFilterTokens(query: string | undefined): string[] { if (isFileExplorerNameFilterQueryTooLarge(query)) { return [] } - return splitFileExplorerNameFilterTokens(query ?? '') -} - -// Why: accepted pasted file-filter queries are still on a renderer hot path; -// tokenize whitespace directly instead of allocating a regex split array. -function splitFileExplorerNameFilterTokens(query: string): string[] { - const tokens: string[] = [] - let tokenStart = -1 - for (let index = 0; index <= query.length; index += 1) { - const isEnd = index === query.length - if (!isEnd && !isFileExplorerNameFilterWhitespace(query.charCodeAt(index))) { - if (tokenStart === -1) { - tokenStart = index - } - continue - } - if (tokenStart !== -1) { - tokens.push(query.slice(tokenStart, index).toLocaleLowerCase()) - tokenStart = -1 - } - } - return tokens -} - -function isFileExplorerNameFilterWhitespace(code: number): boolean { - return ( - code === 32 || - (code >= 9 && code <= 13) || - code === 160 || - code === 5760 || - (code >= 8192 && code <= 8202) || - code === 8232 || - code === 8233 || - code === 8239 || - code === 8287 || - code === 12288 || - code === 65279 - ) -} - -function relativePathMatchesNameFilter(relativePath: string, tokens: readonly string[]): boolean { - if (tokens.length === 0) { - return true - } - // Why: callers pass already-normalized paths — lowercasing only, no second normalize per path per keystroke. - const haystack = relativePath.toLocaleLowerCase() - return tokens.every((token) => haystack.includes(token)) + return splitFileNameFilterTokens(query ?? '') } export function getFileExplorerNameFilterIgnoredQueryRelativePaths( @@ -122,7 +80,7 @@ export function getFileExplorerNameFilterIgnoredQueryRelativePaths( (relativePath) => Boolean(relativePath) && (showDotfiles || !isDotfileRelativePath(relativePath)) && - relativePathMatchesNameFilter(relativePath, tokens) + pathMatchesFileNameFilterTokens(relativePath, tokens) ) } @@ -188,7 +146,7 @@ export function createNameFilteredFileExplorerProjection({ if (!showGitIgnoredFiles && isPathIgnored(ignoredSet, relativePath)) { continue } - if (!relativePathMatchesNameFilter(relativePath, nameFilterTokens)) { + if (!pathMatchesFileNameFilterTokens(relativePath, nameFilterTokens)) { continue } diff --git a/src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.test.ts b/src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.test.ts index 1e5cb967149..459b8870898 100644 --- a/src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.test.ts +++ b/src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.test.ts @@ -38,7 +38,8 @@ describe('useFileExplorerNameFilter', () => { expect(useRuntimeFileListForWorktreeMock).toHaveBeenLastCalledWith({ enabled: true, worktreeId: 'worktree-1', - query: 'AppDelegate.swift' + query: 'AppDelegate.swift', + hostFilterWhenCapped: true }) expect(result.current.nameFilterSource?.query).toBe('AppDelegate.swift') }) diff --git a/src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.ts b/src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.ts index 7ec4f7dd072..b884fe73985 100644 --- a/src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.ts +++ b/src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.ts @@ -46,7 +46,8 @@ export function useFileExplorerNameFilter({ const nameFilterFiles = useRuntimeFileListForWorktree({ enabled: hasNameFilter && !nameFilterQueryTooLarge, worktreeId: activeWorktreeId, - query: nameFilterQuery + query: nameFilterQuery, + hostFilterWhenCapped: true }) const nameFilterSource = useMemo( () => diff --git a/src/renderer/src/components/skills/SkillInstallDialog.test.tsx b/src/renderer/src/components/skills/SkillInstallDialog.test.tsx index ce60f124d01..9a84d60ce5a 100644 --- a/src/renderer/src/components/skills/SkillInstallDialog.test.tsx +++ b/src/renderer/src/components/skills/SkillInstallDialog.test.tsx @@ -253,7 +253,9 @@ describe('SkillInstallDialog', () => { }) render( undefined} />) await inspectSkill() - await screen.findByRole('button', { name: 'Installing for: Codex' }) + // Canonical-root agents (for example Muse) are shown alongside the + // detected provider, so keep this assertion focused on the detected one. + await screen.findByRole('button', { name: /Installing for: Codex/ }) fireEvent.click(screen.getByRole('button', { name: 'Install skill' })) await waitFor(() => expect(skills.installShare).toHaveBeenCalled()) diff --git a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts index 3444bf412d6..452a4ff42ee 100644 --- a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts @@ -52,6 +52,15 @@ describe('terminal startup command classifier', () => { ) }) + it('recognizes Muse startup commands including the versioned binary', () => { + expect(isKnownTuiAgentTerminalStartupCommand('muse')).toBe(true) + expect( + isKnownTuiAgentTerminalStartupCommand('/Users/me/.local/bin/muse-bin-1.3.0-R3401.1') + ).toBe(true) + expect(isKnownTuiAgentTerminalStartupCommand('/usr/local/bin/not-muse')).toBe(false) + expect(isKnownTuiAgentTerminalStartupCommand('/usr/local/bin/muse-workbench')).toBe(false) + }) + it('bounds pathological single-token startup commands', () => { const split = vi.spyOn(String.prototype, 'split') const command = 'codex'.repeat(TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS) diff --git a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts index f171efc01cc..482cbee74db 100644 --- a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts +++ b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts @@ -60,7 +60,8 @@ export function isKnownTuiAgentTerminalStartupCommand(command: string): boolean return ( KNOWN_TUI_AGENT_EXECUTABLES.has(executable) || executable.startsWith('codex-') || - executable.startsWith('grok-') + executable.startsWith('grok-') || + executable.startsWith('muse-bin-') ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6a8d0b8a030..bf89d826a7f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -608,6 +608,7 @@ "fc80296033": "Devin", "da41abbdd4": "Ante", "060d152fb5": "Trae", + "muse_label": "Muse", "d443a47995": "Prime Agent", "mimo_code_label": "MiMo Code", "opencode2_label": "OpenCode 2" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 6e66e4aaf4d..11c1a3629f4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -383,7 +383,8 @@ "da41abbdd4": "Ante", "060d152fb5": "Trae", "d443a47995": "Prime Agent", - "mimo_code_label": "MiMo Code" + "mimo_code_label": "MiMo Code", + "muse_label": "Muse" }, "skill": { "cli": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index d8f5cca304b..fffd22f627d 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -460,7 +460,8 @@ "da41abbdd4": "Ante", "060d152fb5": "Trae", "d443a47995": "Prime Agent", - "mimo_code_label": "MiMo Code" + "mimo_code_label": "MiMo Code", + "muse_label": "Muse" }, "skill": { "cli": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4bfa562aa38..6c412084d99 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -383,7 +383,8 @@ "da41abbdd4": "Ante", "060d152fb5": "Trae", "d443a47995": "Prime Agent", - "mimo_code_label": "MiMo Code" + "mimo_code_label": "MiMo Code", + "muse_label": "Muse" }, "skill": { "cli": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c4c9fd6d334..84345b0628b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -386,7 +386,8 @@ "da41abbdd4": "Ante", "060d152fb5": "Trae", "d443a47995": "Prime Agent", - "mimo_code_label": "MiMo Code" + "mimo_code_label": "MiMo Code", + "muse_label": "Muse" }, "skill": { "cli": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d1b73cebc75..a88b55f476f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -386,7 +386,8 @@ "da41abbdd4": "Ante", "060d152fb5": "Trae", "d443a47995": "Prime Agent", - "mimo_code_label": "MiMo Code" + "mimo_code_label": "MiMo Code", + "muse_label": "Muse" }, "skill": { "cli": { diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index a67317d2a3a..32193c7e7b0 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -120,6 +120,13 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => faviconDomain: 'www.trae.cn', homepageUrl: 'https://docs.trae.cn/cli_get-started-with-trae-cli' }, + { + id: 'muse', + label: translate('auto.lib.agent.catalog.muse_label', 'Muse'), + cmd: 'muse', + faviconDomain: 'dev.meta.ai', + homepageUrl: 'https://dev.meta.ai/docs/muse-code' + }, { id: 'pi', label: translate('auto.lib.agent.catalog.302934c5d9', 'Pi'), diff --git a/src/renderer/src/lib/agent-favicon-assets.ts b/src/renderer/src/lib/agent-favicon-assets.ts index c1e8bcfc2ea..98ed4c95b0e 100644 --- a/src/renderer/src/lib/agent-favicon-assets.ts +++ b/src/renderer/src/lib/agent-favicon-assets.ts @@ -24,6 +24,7 @@ import qwenCodeUrl from '../../../shared/agent-icons/qwen-code.png?url' import rovoUrl from '../../../shared/agent-icons/rovo.png?url' import hermesUrl from '../../../shared/agent-icons/hermes.png?url' import devinUrl from '../../../shared/agent-icons/devin.png?url' +import museUrl from '../../../shared/agent-icons/muse.png?url' import openclawUrl from '../../../shared/agent-icons/openclaw.png?url' // Why: these agents have no hand-authored SVG glyph, so previously their icons @@ -59,5 +60,6 @@ export const AGENT_FAVICON_ASSETS: Partial> = { rovo: rovoUrl, hermes: hermesUrl, devin: devinUrl, + muse: museUrl, openclaw: openclawUrl } diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 3c1793e5e62..8a642c1dd50 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -135,7 +135,8 @@ const ICONABLE_AGENT_TYPES: Record = { grok: true, devin: true, ante: true, - trae: true + trae: true, + muse: true } // Why: return null (not a 'claude' fallback) for unknown so Codex panes don't flash the Claude icon before the hook fires. diff --git a/src/renderer/src/lib/tui-agent-startup.test.ts b/src/renderer/src/lib/tui-agent-startup.test.ts index ce948703c94..b4551ae8a23 100644 --- a/src/renderer/src/lib/tui-agent-startup.test.ts +++ b/src/renderer/src/lib/tui-agent-startup.test.ts @@ -144,6 +144,26 @@ describe('buildAgentStartupPlan', () => { ).toBe("traecli -- 'help me name this config'") }) + it('delivers the Muse prompt after its composer is ready', () => { + expect( + buildAgentStartupPlan({ + agent: 'muse', + prompt: 'Summarize the failing tests', + cmdOverrides: {}, + platform: 'linux' + }) + ).toEqual({ + agent: 'muse', + launchCommand: 'muse --trust-workspace', + expectedProcess: 'muse', + followupPrompt: 'Summarize the failing tests', + launchConfig: { + ...emptyLaunchConfig('muse'), + agentCommand: 'muse --trust-workspace' + } + }) + }) + it('passes the prompt to Prime Agent as a positional argv behind a `--` separator', () => { expect( buildAgentStartupPlan({ diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts index 1885d059b89..3fca694baa3 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { RESUMABLE_TUI_AGENTS } from '../../../shared/agent-session-resume' import { AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, RUNTIME_CAPABILITIES @@ -9,6 +10,13 @@ import { import { agentResumeHostAuthorityCapability } from './agent-resume-host-authority-capability' describe('agentResumeHostAuthorityCapability', () => { + it('gates Muse resume behind its own advertised capability', () => { + expect(agentResumeHostAuthorityCapability('muse')).toBe( + AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY + ) + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY) + }) + it('gates OpenCode 2 resume behind its own advertised capability', () => { expect(agentResumeHostAuthorityCapability('opencode2')).toBe( AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY @@ -58,6 +66,7 @@ describe('agentResumeHostAuthorityCapability', () => { devin: undefined, 'prime-agent': undefined, copilot: undefined, + muse: AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, omp: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, kimi: AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY }) diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts index cd2198e9db2..12c1b96efd6 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts @@ -2,6 +2,7 @@ import type { ResumableTuiAgent } from '../../../shared/agent-session-resume' import type { TuiAgent } from '../../../shared/tui-agent' import { AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, type RuntimeCapability @@ -31,6 +32,7 @@ const RESUME_HOST_AUTHORITY_CAPABILITY_BY_AGENT = { 'prime-agent': undefined, // Ungated to match how main shipped copilot resume; gating it is its own change. copilot: undefined, + muse: AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, omp: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, kimi: AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY } satisfies Record diff --git a/src/renderer/src/runtime/remote-agent-session-launch.test.ts b/src/renderer/src/runtime/remote-agent-session-launch.test.ts index a282b3b5878..d8cf0c542cb 100644 --- a/src/renderer/src/runtime/remote-agent-session-launch.test.ts +++ b/src/renderer/src/runtime/remote-agent-session-launch.test.ts @@ -45,23 +45,47 @@ describe('remote agent-session launch routing', () => { expect(legacy).not.toHaveBeenCalled() }) - it('falls back to legacy when an older host lacks the Kimi resume capability', async () => { - const hostAuthority = vi.fn().mockResolvedValue('structured') - const legacy = vi.fn().mockResolvedValue('legacy') - mocks.supportsCapability.mockResolvedValue(false) + it.each(['kimi', 'muse'] as const)( + 'falls back to legacy when an older host lacks the %s resume capability', + async (agent) => { + const hostAuthority = vi.fn().mockResolvedValue('structured') + const legacy = vi.fn().mockResolvedValue('legacy') + mocks.supportsCapability.mockResolvedValue(false) + + // Why: an old host rejects the widened agent enum with invalid_argument, which is not a + // fallback code — so the probe, not the error handler, has to keep the pane alive. + await expect( + runRemoteAgentSessionLaunch({ + environmentId: 'env-1', + hostAuthority, + hostAuthorityCapability: agentResumeHostAuthorityCapability(agent), + legacy + }) + ).resolves.toBe('legacy') + expect(mocks.supportsCapability).toHaveBeenCalledWith( + 'env-1', + `agent-session.${agent}-resume.v1` + ) + expect(hostAuthority).not.toHaveBeenCalled() + } + ) + + it('uses host authority when the host supports Muse resume', async () => { + const hostAuthority = vi.fn().mockResolvedValue('host') + const legacy = vi.fn() + mocks.supportsCapability.mockResolvedValue(true) - // Why: an old host rejects the widened agent enum with invalid_argument, which is not a - // fallback code — so the probe, not the error handler, has to keep the pane alive. await expect( runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority, - hostAuthorityCapability: agentResumeHostAuthorityCapability('kimi'), + hostAuthorityCapability: agentResumeHostAuthorityCapability('muse'), legacy }) - ).resolves.toBe('legacy') - expect(mocks.supportsCapability).toHaveBeenCalledWith('env-1', 'agent-session.kimi-resume.v1') - expect(hostAuthority).not.toHaveBeenCalled() + ).resolves.toBe('host') + expect(mocks.supportsCapability).toHaveBeenCalledWith('env-1', 'agent-session.muse-resume.v1') + expect(hostAuthority).toHaveBeenCalledOnce() + expect(legacy).not.toHaveBeenCalled() }) it('preserves the exact legacy path when the capability is absent', async () => { diff --git a/src/renderer/src/runtime/runtime-file-client-search-listing.test.ts b/src/renderer/src/runtime/runtime-file-client-search-listing.test.ts index 9126316665a..3bef7eed0b9 100644 --- a/src/renderer/src/runtime/runtime-file-client-search-listing.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-search-listing.test.ts @@ -506,6 +506,20 @@ describe('runtime file client', () => { }) }) + it('sends the Explorer name filter to local listings only', async () => { + fsListFiles.mockResolvedValue([]) + const local = { settings: {}, worktreeId: 'wt-1', worktreePath: '/repo' } + + await listRuntimeFiles(local, { rootPath: '/repo', nameFilter: 'AppDelegate' }) + await listRuntimeFiles( + { ...local, connectionId: 'ssh-1' }, + { rootPath: '/repo', nameFilter: 'AppDelegate' } + ) + + expect(fsListFiles.mock.calls[0][0]).toMatchObject({ nameFilter: 'AppDelegate' }) + expect(fsListFiles.mock.calls[1][0]).not.toHaveProperty('nameFilter') + }) + it('cancelRuntimeFileList aborts the IPC listing but not environment listings (#7721)', () => { cancelRuntimeFileList( { diff --git a/src/renderer/src/runtime/runtime-file-request-debounce.ts b/src/renderer/src/runtime/runtime-file-request-debounce.ts new file mode 100644 index 00000000000..3e2337336d2 --- /dev/null +++ b/src/renderer/src/runtime/runtime-file-request-debounce.ts @@ -0,0 +1,24 @@ +import { createRuntimeRpcAbortError } from './abortable-runtime-environment-call' + +/** Delays a runtime file request so typing settles before the host scans. */ +export function debounceRuntimeFileRequest( + delayMs: number, + signal: AbortSignal, + request: () => Promise +): Promise { + return new Promise((resolve, reject) => { + const onAbort = (): void => { + signal.removeEventListener('abort', onAbort) + window.clearTimeout(timer) + reject(createRuntimeRpcAbortError()) + } + const timer = window.setTimeout(() => { + signal.removeEventListener('abort', onAbort) + void request().then(resolve, reject) + }, delayMs) + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) { + onAbort() + } + }) +} diff --git a/src/renderer/src/runtime/runtime-file-search-client.ts b/src/renderer/src/runtime/runtime-file-search-client.ts index 8168b24bd54..4a23abf6689 100644 --- a/src/renderer/src/runtime/runtime-file-search-client.ts +++ b/src/renderer/src/runtime/runtime-file-search-client.ts @@ -52,6 +52,8 @@ export async function listRuntimeFiles( // the whole listing when no limit is named, so a caller that never states one cannot tell a // bound from a total. maxResults?: number + /** Local hosts only; SSH and runtime hosts list unfiltered. */ + nameFilter?: string signal?: AbortSignal } ): Promise { @@ -62,7 +64,8 @@ export async function listRuntimeFiles( connectionId: context.connectionId, excludePaths: args.excludePaths, requestToken: args.requestToken, - ...(args.maxResults === undefined ? {} : { maxResults: args.maxResults }) + ...(args.maxResults === undefined ? {} : { maxResults: args.maxResults }), + ...(args.nameFilter && !context.connectionId ? { nameFilter: args.nameFilter } : {}) }) } return callRuntimeRpc( diff --git a/src/shared/agent-headless-command.ts b/src/shared/agent-headless-command.ts index 9ff40e6af1d..74896e029b5 100644 --- a/src/shared/agent-headless-command.ts +++ b/src/shared/agent-headless-command.ts @@ -1,18 +1,20 @@ import { isAnteHeadlessOneShotCommand } from './ante-headless-command' +import { isMuseHeadlessOneShotCommand } from './muse-headless-command' import { isPrimeAgentHeadlessOneShotCommand } from './prime-agent-headless-command' import { isPrintModeHeadlessOneShotCommand } from './print-mode-headless-command' import type { TuiAgent } from './tui-agent' // Why: a table (not an if-chain) so adding an agent is one entry; Claude and Trae share -// the same `--print` one-shot contract, Ante's `--prompt` form and Prime Agent's -// `--mode` forms need their own matchers. +// the same `--print` one-shot contract, Ante's `--prompt` form, Prime Agent's +// `--mode` forms, and Muse's `exec` subcommand need their own matchers. const HEADLESS_ONE_SHOT_MATCHERS: Partial< Record boolean> > = { claude: isPrintModeHeadlessOneShotCommand, trae: isPrintModeHeadlessOneShotCommand, 'prime-agent': isPrimeAgentHeadlessOneShotCommand, - ante: isAnteHeadlessOneShotCommand + ante: isAnteHeadlessOneShotCommand, + muse: isMuseHeadlessOneShotCommand } export function isHeadlessOneShotAgentCommand(agent: TuiAgent, tokens: readonly string[]): boolean { diff --git a/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts b/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts index 601b8aea828..28d450ae0b3 100644 --- a/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts +++ b/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts @@ -286,4 +286,110 @@ describe('shared agent-hook-listener', () => { toolName: 'AskUserQuestion' }) }) + + // Why: Muse emits Claude-compatible hook payloads (captured from muse 1.3.0 hook stdin); + // normalize but attribute to Muse, including the Stop `last_assistant_message`. + it('normalizes Muse Claude-compatible lifecycle events as muse status', () => { + const base = { + session_id: '01a0caa3-0e77-7d41-bad7-46283a45633d', + turn_id: '2c040170-d894-4268-ad7b-1b2f9bf2e2e2', + cwd: '/tmp/ws', + transcript_path: null, + model: 'muse-spark-1.3', + permission_mode: 'default', + model_provider: 'meta' + } + // Why: this id is a real capture; keep the lookup off the developer's own Muse sessions. + vi.stubEnv('XDG_DATA_HOME', '/tmp/orca-muse-vendors-test-no-data') + const bash = { command: 'ls -la' } + const submitted = normalizeAndAccept(state, 'muse', { + ...base, + hook_event_name: 'UserPromptSubmit', + prompt: 'say hi again' + }) + normalizeAndAccept(state, 'muse', { + ...base, + hook_event_name: 'PreToolUse', + tool_name: 'bash', + tool_input: bash, + tool_use_id: 'call_1' + }) + // Why: auto-approved tools also emit PermissionRequest, so only Notification means a prompt. + const permissionRequest = normalizeAndAccept(state, 'muse', { + ...base, + hook_event_name: 'PermissionRequest', + tool_name: 'bash', + tool_input: bash + }) + const waiting = normalizeAndAccept(state, 'muse', { + ...base, + hook_event_name: 'Notification', + notification_type: 'permission_prompt', + title: 'ws — waiting for approval', + message: 'bash wants to run' + }) + const approved = normalizeAndAccept(state, 'muse', { + ...base, + hook_event_name: 'PostToolUse', + tool_name: 'bash', + tool_input: bash, + tool_use_id: 'call_1' + }) + const stopped = normalizeAndAccept(state, 'muse', { + ...base, + hook_event_name: 'Stop', + stop_hook_active: false, + last_assistant_message: 'echo: say hi again' + }) + + expect(submitted?.payload).toMatchObject({ + agentType: 'muse', + state: 'working', + prompt: 'say hi again' + }) + expect(permissionRequest).toBeNull() + expect(waiting?.payload).toMatchObject({ + agentType: 'muse', + state: 'waiting', + toolName: 'bash', + interactivePrompt: JSON.stringify({ approval: { tool: 'bash', summary: 'ls -la' } }) + }) + expect(approved?.payload.state).toBe('working') + expect(approved?.payload.interactivePrompt).toBeUndefined() + expect(stopped?.payload).toMatchObject({ + agentType: 'muse', + state: 'done', + lastAssistantMessage: 'echo: say hi again' + }) + // The Claude-shaped session_id is captured for provider-session resume. + expect(stopped?.providerSession).toMatchObject({ + key: 'session_id', + id: '01a0caa3-0e77-7d41-bad7-46283a45633d' + }) + }) + + it.each(['AskUserQuestion', 'request_user_input'])( + 'keeps Muse %s pending until answered', + (toolName) => { + const toolInput = { questions: [{ question: 'Which color?', options: [{ label: 'Blue' }] }] } + const pending = normalizeAndAccept(state, 'muse', { + hook_event_name: 'PreToolUse', + tool_name: toolName, + tool_input: toolInput + }) + expect(pending?.payload).toMatchObject({ + agentType: 'muse', + state: 'waiting', + interactivePrompt: JSON.stringify(toolInput) + }) + const answered = normalizeAndAccept(state, 'muse', { + hook_event_name: 'PostToolUse', + tool_name: toolName, + tool_input: toolInput, + tool_response: 'Blue' + }) + expect(answered?.payload.state).toBe('working') + expect(answered?.payload.interactivePrompt).toBeUndefined() + } + ) }) diff --git a/src/shared/agent-hook-listener-relay-dependency.test.ts b/src/shared/agent-hook-listener-relay-dependency.test.ts index e23277e9f18..81151f04e8a 100644 --- a/src/shared/agent-hook-listener-relay-dependency.test.ts +++ b/src/shared/agent-hook-listener-relay-dependency.test.ts @@ -125,9 +125,9 @@ describe('agent hook listener relay dependency boundary', () => { 'agent-hook-listener/hook-envelope.ts', 'agent-hook-listener/listener-limits.ts', 'agent-hook-listener/listener-state.ts', - 'agent-hook-listener/providers/codex-state.ts', 'agent-hook-listener/request-body.ts', - 'agent-hook-listener/source-routing.ts' + 'agent-hook-listener/source-routing.ts', + 'agent-hook-listener/transcript-poll-policy.ts' ]) expect( [...visited].some((file) => file.endsWith('/agent-hook-listener/provider-dispatch.ts')) diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index 5f442b1ff77..3e4ed0cae5d 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -10,6 +10,7 @@ import type { AgentStatusLegacyIngressCaller } from '../agent-status-legacy-ingr import type { ClaudeSubagentRoster } from '../claude-subagent-roster' import type { CodexSubagentRoster } from '../codex-subagent-roster' import type { CodexSubagentTranscriptState } from '../codex-subagent-transcript' +import type { MuseSessionLogState } from '../muse-session-log' import type { AgentHookEventPayload, ToolSnapshot } from './listener-event' import { moveOpenCodeSessionBindings, @@ -51,6 +52,8 @@ export type HookListenerState = { codexLeadStateByPaneKey: Map /** Newest Grok turn per pane, used to reject end reports that arrive after a replacement prompt. */ grokActiveTurnByPaneKey: Map + /** Muse child-session filter and session-log cursor per pane. */ + musePaneStateByPaneKey: Map /** * OpenCode session id -> owning pane, observed from the client side. The * shared v2 server stamps every post with its own frozen pane, so ingest @@ -62,6 +65,14 @@ export type HookListenerState = { lastLaunchTokenByPaneKey: Map } +export type MusePaneState = { + /** Internal reminder/subagent sessions; their hooks inherit the pane env and fire even after Stop. */ + childSessionIds: Set + log?: MuseSessionLogState + /** Muse emits PermissionRequest for auto-approved calls too; only Notification confirms a visible prompt. */ + pendingApproval?: { toolName?: string; toolInput?: unknown } +} + export type GrokActiveTurn = { promptId?: string sessionId?: string @@ -118,6 +129,7 @@ export function createHookListenerState( codexSubagentTranscriptByPaneKey: new Map(), codexLeadStateByPaneKey: new Map(), grokActiveTurnByPaneKey: new Map(), + musePaneStateByPaneKey: new Map(), opencodeSessionPaneBySessionId: new Map(), lastLaunchTokenByPaneKey: new Map() } @@ -206,6 +218,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): state.codexSubagentTranscriptByPaneKey.delete(paneKey) state.codexLeadStateByPaneKey.delete(paneKey) state.grokActiveTurnByPaneKey.delete(paneKey) + state.musePaneStateByPaneKey.delete(paneKey) unbindOpenCodeSessionsOfPane(state, paneKey) deletePaneScopedCacheEntry(state.lastLaunchTokenByPaneKey, paneKey) } @@ -282,6 +295,7 @@ export function movePaneCacheState( movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.grokActiveTurnByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.musePaneStateByPaneKey, fromPaneKey, toPaneKey) moveOpenCodeSessionBindings(state, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.lastLaunchTokenByPaneKey, fromPaneKey, toPaneKey) } diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts index fb3647e2651..9fb7255220d 100644 --- a/src/shared/agent-hook-listener/provider-dispatch.ts +++ b/src/shared/agent-hook-listener/provider-dispatch.ts @@ -22,6 +22,7 @@ import { normalizeCopilotEvent } from './providers/copilot-events' import { normalizeHermesEvent } from './providers/hermes-events' import { normalizeDevinEvent } from './providers/devin-events' import { normalizeKimiEvent } from './providers/kimi-events' +import { normalizeMuseEvent } from './providers/muse-events' export type ProviderDispatchResult = { payload: ParsedAgentStatusPayload | null @@ -149,6 +150,9 @@ export function normalizeProviderEvent(input: { case 'kimi': payload = normalizeKimiEvent(state, eventName, promptText, paneKey, hookPayload) break + case 'muse': + payload = normalizeMuseEvent(state, eventName, promptText, paneKey, hookPayload) + break } return { payload, resolvedPromptText, promptInteractionKey, hasTranscriptPromptEvidence } diff --git a/src/shared/agent-hook-listener/provider-event-routing.ts b/src/shared/agent-hook-listener/provider-event-routing.ts index 39fa150f34b..3b4132b1a6a 100644 --- a/src/shared/agent-hook-listener/provider-event-routing.ts +++ b/src/shared/agent-hook-listener/provider-event-routing.ts @@ -32,6 +32,9 @@ export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boo case 'kimi': // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too. return eventName === 'UserPromptSubmit' + case 'muse': + // Muse uses Claude-compatible lifecycle events. + return eventName === 'UserPromptSubmit' case 'codex': return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' case 'gemini': @@ -131,6 +134,9 @@ export function extractToolFields( // Why: Kimi Code uses Claude's tool_name/tool_input payload fields verbatim. // falls through case 'kimi': + // Muse uses Claude-compatible tool fields. + // falls through + case 'muse': return extractClaudeToolFields(eventName, hookPayload) case 'codex': return extractCodexToolFields(eventName, hookPayload) diff --git a/src/shared/agent-hook-listener/providers/muse-events.test.ts b/src/shared/agent-hook-listener/providers/muse-events.test.ts new file mode 100644 index 00000000000..5bfeac9a4fa --- /dev/null +++ b/src/shared/agent-hook-listener/providers/muse-events.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createHookListenerState, type HookListenerState } from '../listener-state' +import { normalizeAndAccept, PANE_KEY } from '../../agent-hook-listener-test-harness' + +const SESSION_ID = '01a0caa3-0e77-7d41-bad7-46283a45633d' +const TURN_ID = 'e495d1a5-59aa-47b4-8efb-a1bd75509afc' +const CHILD_ID = '1471f5e6-bd60-41a2-bfcf-c9086dbd4092' +const PROMPT_ID = '01a0caa3-a25a-7810-8229-4de04b2e7ca3' +const QUESTION = { + id: 'fav_color', + header: 'Color', + question: 'What is your favorite color?', + options: [{ label: 'Blue' }, { label: 'Green' }, { label: 'Red' }] +} + +const envelope = { + cwd: '/tmp/ws', + transcript_path: null, + model: 'muse-spark-1.3', + permission_mode: 'default', + model_provider: 'meta' +} +const main = { ...envelope, session_id: SESSION_ID, turn_id: TURN_ID } +const child = { ...envelope, session_id: CHILD_ID, turn_id: CHILD_ID } +const reminderInput = { decision: 'none', skill_id: 'bundled:grill' } + +function sessionLogLine(event: Record): string { + return `${JSON.stringify({ + schema_version: 1, + stream: { kind: 'session', id: SESSION_ID }, + record_type: 'event', + payload_type: 'runtime.session', + payload: { kind: 'run', run_id: TURN_ID, event } + })}\n` +} + +function childReminderHooks(): Record[] { + return [ + { + ...child, + hook_event_name: 'PreToolUse', + tool_name: 'submit_reminder_decision', + tool_input: reminderInput, + tool_use_id: 'call_child' + }, + { + ...child, + hook_event_name: 'PermissionRequest', + tool_name: 'submit_reminder_decision', + tool_input: reminderInput + }, + { + ...child, + hook_event_name: 'PostToolUseFailure', + tool_name: 'submit_reminder_decision', + tool_input: reminderInput, + error: + 'tool failed: invalid reminder decision payload: unexpected field `additionalProperties`' + }, + { ...child, hook_event_name: 'SubagentStop', subagent_id: 'skill-reminder' } + ] +} + +describe('Muse hook events', () => { + let state: HookListenerState + let dataHome: string + + beforeEach(() => { + state = createHookListenerState() + dataHome = mkdtempSync(join(tmpdir(), 'muse-events-')) + vi.stubEnv('XDG_DATA_HOME', dataHome) + }) + + afterEach(() => { + vi.unstubAllEnvs() + rmSync(dataHome, { recursive: true, force: true }) + }) + + function sessionLogPath(): string { + const date = new Date(Number.parseInt(SESSION_ID.replace(/-/g, '').slice(0, 12), 16)) + const dir = join( + dataHome, + 'muse', + 'sessions', + String(date.getFullYear()), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0'), + SESSION_ID + ) + mkdirSync(dir, { recursive: true }) + return join(dir, 'session.jsonl') + } + + it('drops reminder-subagent hooks announced by SubagentStart, even after Stop', () => { + normalizeAndAccept(state, 'muse', { + ...main, + hook_event_name: 'UserPromptSubmit', + prompt: 'list files' + }) + const start = normalizeAndAccept(state, 'muse', { + ...child, + hook_event_name: 'SubagentStart', + subagent_id: 'skill-reminder', + child_session_id: CHILD_ID + }) + expect(start).toBeNull() + for (const hook of childReminderHooks()) { + expect(normalizeAndAccept(state, 'muse', hook)).toBeNull() + } + + const stopped = normalizeAndAccept(state, 'muse', { + ...main, + hook_event_name: 'Stop', + stop_hook_active: false, + last_assistant_message: 'Here are the files.' + }) + expect(stopped?.payload.state).toBe('done') + for (const hook of childReminderHooks()) { + expect(normalizeAndAccept(state, 'muse', hook)).toBeNull() + } + expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload).toMatchObject({ + state: 'done', + lastAssistantMessage: 'Here are the files.' + }) + }) + + // Why: a SubagentStart that predates this listener (restart, relay reconnect) is never seen. + it('drops child-session hooks whose turn id equals their session id without SubagentStart', () => { + normalizeAndAccept(state, 'muse', { + ...main, + hook_event_name: 'Stop', + stop_hook_active: false, + last_assistant_message: 'done' + }) + for (const hook of childReminderHooks()) { + expect(normalizeAndAccept(state, 'muse', hook)).toBeNull() + } + expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state).toBe('done') + }) + + it('shows the approval card only once Muse notifies a permission prompt', () => { + const bash = { command: 'rm -rf build' } + normalizeAndAccept(state, 'muse', { + ...main, + hook_event_name: 'PreToolUse', + tool_name: 'bash', + tool_input: bash, + tool_use_id: 'call_1' + }) + expect( + normalizeAndAccept(state, 'muse', { + ...main, + hook_event_name: 'PermissionRequest', + tool_name: 'bash', + tool_input: bash + }) + ).toBeNull() + expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state).toBe('working') + + const waiting = normalizeAndAccept(state, 'muse', { + ...main, + hook_event_name: 'Notification', + notification_type: 'permission_prompt', + title: 'ws — waiting for approval', + message: 'bash wants to run rm -rf build' + }) + expect(waiting?.payload).toMatchObject({ + agentType: 'muse', + state: 'waiting', + toolName: 'bash', + interactivePrompt: JSON.stringify({ approval: { tool: 'bash', summary: 'rm -rf build' } }) + }) + + const approved = normalizeAndAccept(state, 'muse', { + ...main, + hook_event_name: 'PostToolUse', + tool_name: 'bash', + tool_input: bash, + tool_use_id: 'call_1' + }) + expect(approved?.payload.state).toBe('working') + expect(approved?.payload.interactivePrompt).toBeUndefined() + }) + + it('ignores Notification types other than permission_prompt', () => { + expect( + normalizeAndAccept(state, 'muse', { + ...main, + hook_event_name: 'Notification', + notification_type: 'idle_prompt', + message: 'Muse is waiting for your input' + }) + ).toBeNull() + }) + + it('reports a request_user_input question from the session log until it settles', () => { + const logPath = sessionLogPath() + const body = { ...main, hook_event_name: 'UserPromptSubmit', prompt: 'ask my favorite color' } + const working = normalizeAndAccept(state, 'muse', body) + expect(working?.payload.state).toBe('working') + + writeFileSync( + logPath, + sessionLogLine({ + kind: 'user_input_prompt_requested', + prompt_id: PROMPT_ID, + tool_name: 'request_user_input', + questions: [QUESTION] + }) + ) + const waiting = normalizeAndAccept(state, 'muse', body) + expect(waiting?.payload).toMatchObject({ + agentType: 'muse', + state: 'waiting', + toolName: 'request_user_input', + prompt: 'ask my favorite color', + interactivePrompt: JSON.stringify({ questions: [QUESTION] }) + }) + + appendFileSync( + logPath, + sessionLogLine({ + kind: 'user_input_prompt_settled', + prompt_id: PROMPT_ID, + outcome: 'answered', + answers: [{ id: 'fav_color', selected_label: 'Blue' }] + }) + ) + const answered = normalizeAndAccept(state, 'muse', body) + expect(answered?.payload.state).toBe('working') + expect(answered?.payload.interactivePrompt).toBeUndefined() + }) +}) diff --git a/src/shared/agent-hook-listener/providers/muse-events.ts b/src/shared/agent-hook-listener/providers/muse-events.ts new file mode 100644 index 00000000000..7c4889bb14a --- /dev/null +++ b/src/shared/agent-hook-listener/providers/muse-events.ts @@ -0,0 +1,158 @@ +import { isAskUserQuestionTool } from '../../agent-question-answered-intent' +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { createMuseSessionLogState, readMusePendingUserInput } from '../../muse-session-log' +import type { HookListenerState, MusePaneState } from '../listener-state' +import { + resolvePrompt, + resolveToolState, + shouldIgnoreCompactContinuationUserPromptSubmit +} from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { readString } from '../tool-input-preview' + +const MAX_TRACKED_CHILD_SESSIONS = 64 + +function getMusePaneState(state: HookListenerState, paneKey: string): MusePaneState { + let pane = state.musePaneStateByPaneKey.get(paneKey) + if (!pane) { + pane = { childSessionIds: new Set() } + state.musePaneStateByPaneKey.set(paneKey, pane) + } + return pane +} + +function rememberChildSession(pane: MusePaneState, childSessionId: string): void { + pane.childSessionIds.add(childSessionId) + if (pane.childSessionIds.size > MAX_TRACKED_CHILD_SESSIONS) { + const oldest = pane.childSessionIds.values().next().value + if (oldest !== undefined) { + pane.childSessionIds.delete(oldest) + } + } +} + +function isChildSessionEvent(pane: MusePaneState, hookPayload: Record): boolean { + const sessionId = readString(hookPayload, 'session_id') + if (!sessionId) { + return false + } + // Why: Muse 1.3 child sessions reuse their session id as turn id; that covers a child whose + // SubagentStart predates this listener (Orca restart, relay reconnect). + return pane.childSessionIds.has(sessionId) || readString(hookPayload, 'turn_id') === sessionId +} + +/** True while a Muse pane has a known session log the transcript poll can read. */ +export function hasMuseSessionLog(state: HookListenerState, paneKey: string): boolean { + return state.musePaneStateByPaneKey.get(paneKey)?.log !== undefined +} + +// Muse uses Claude-compatible hook events but retains its own agent identity. +export function normalizeMuseEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { + return null + } + + const pane = getMusePaneState(state, paneKey) + if (eventName === 'SubagentStart') { + const childSessionId = + readString(hookPayload, 'child_session_id') ?? readString(hookPayload, 'session_id') + if (childSessionId) { + rememberChildSession(pane, childSessionId) + } + return null + } + if (isChildSessionEvent(pane, hookPayload)) { + return null + } + const sessionId = readString(hookPayload, 'session_id') + if (sessionId && pane.log?.sessionId !== sessionId) { + pane.log = createMuseSessionLogState(sessionId) + } + + const toolName = readString(hookPayload, 'tool_name') + let toolEventName = eventName + let toolPayload = hookPayload + let stateName: 'working' | 'waiting' | 'done' + switch (eventName) { + case 'UserPromptSubmit': + case 'PostToolUse': + case 'PostToolUseFailure': + stateName = 'working' + pane.pendingApproval = undefined + break + case 'PreToolUse': + // Keep pendingApproval: the transcript poll replays this body while the approval is visible. + stateName = isAskUserQuestionTool(toolName) ? 'waiting' : 'working' + break + case 'PermissionRequest': + pane.pendingApproval = { toolName, toolInput: hookPayload.tool_input } + return null + case 'Notification': + if (hookPayload.notification_type !== 'permission_prompt') { + return null + } + stateName = 'waiting' + toolEventName = 'PermissionRequest' + toolPayload = { + ...hookPayload, + tool_name: pane.pendingApproval?.toolName, + tool_input: pane.pendingApproval?.toolInput + } + break + case 'Stop': + case 'StopFailure': + stateName = 'done' + pane.pendingApproval = undefined + break + default: + return null + } + + if (stateName === 'working' && pane.log) { + // Why: Muse fires no hook for `request_user_input`; its session log is the only structured signal. + const pendingInput = readMusePendingUserInput(pane.log, readString(hookPayload, 'turn_id')) + if (pendingInput) { + stateName = 'waiting' + toolEventName = 'PreToolUse' + toolPayload = { + ...hookPayload, + tool_name: 'request_user_input', + tool_input: { questions: pendingInput.questions } + } + } + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('muse', toolEventName, toolPayload), + { resetOnNewTurn: isNewTurnEvent('muse', eventName) } + ) + + const interrupted = + eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined + + return normalizeAgentStatusPayload({ + state: stateName, + // Why: Notification's `message` is status copy (" — waiting for approval"), not the user's prompt. + prompt: resolvePrompt(state, paneKey, eventName === 'Notification' ? '' : promptText, { + resetOnNewTurn: isNewTurnEvent('muse', eventName) + }), + agentType: 'muse', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, + interrupted + }) +} diff --git a/src/shared/agent-hook-listener/source-routing.ts b/src/shared/agent-hook-listener/source-routing.ts index b90da246254..818f4d9da8a 100644 --- a/src/shared/agent-hook-listener/source-routing.ts +++ b/src/shared/agent-hook-listener/source-routing.ts @@ -21,7 +21,8 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly> '/hook/copilot': 'copilot', '/hook/hermes': 'hermes', '/hook/devin': 'devin', - '/hook/kimi': 'kimi' + '/hook/kimi': 'kimi', + '/hook/muse': 'muse' }) export function resolveHookSource(pathname: string): AgentHookSource | null { diff --git a/src/shared/agent-hook-listener/transcript-poll-policy.ts b/src/shared/agent-hook-listener/transcript-poll-policy.ts new file mode 100644 index 00000000000..d27814cf928 --- /dev/null +++ b/src/shared/agent-hook-listener/transcript-poll-policy.ts @@ -0,0 +1,41 @@ +import type { AgentHookSource } from '../agent-hook-relay' +import type { AgentHookEventPayload } from './listener-event' +import type { HookListenerState } from './listener-state' +import { hasCodexTranscriptSubagents } from './providers/codex-state' +import { hasMuseSessionLog } from './providers/muse-events' + +/** Whether a pane's last hook body should be re-normalized on a timer to pick up transcript-only state. */ +export function shouldPollHookTranscript( + state: HookListenerState, + source: AgentHookSource, + event: AgentHookEventPayload +): boolean { + if (source === 'codex') { + return hasCodexTranscriptSubagents(state, event.paneKey) + } + if (source === 'muse') { + // Why: Muse's question tool fires no hook, so only its session log shows the wait and its answer. + return event.payload.state !== 'done' && hasMuseSessionLog(state, event.paneKey) + } + return false +} + +/** Returns the poll result to publish, or undefined when it carries nothing new. */ +export function transcriptPollUpdate( + source: AgentHookSource, + original: T, + polled: T +): T | undefined { + if (source === 'muse') { + const changed = + polled.payload.state !== original.payload.state || + polled.payload.interactivePrompt !== original.payload.interactivePrompt + // Why: a replayed UserPromptSubmit body is neither a newly sent prompt nor a turn boundary. + return changed + ? { ...polled, hasExplicitPrompt: undefined, hookEventName: undefined } + : undefined + } + const subagentsChanged = + JSON.stringify(polled.payload.subagents) !== JSON.stringify(original.payload.subagents) + return subagentsChanged ? polled : undefined +} diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 6abdcb56aa0..33bee532ad6 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -53,7 +53,8 @@ const AGENT_HOOK_SOURCES = [ 'copilot', 'hermes', 'devin', - 'kimi' + 'kimi', + 'muse' ] as const export type AgentHookSource = (typeof AGENT_HOOK_SOURCES)[number] diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index 248638c5079..d08223ed053 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -17,7 +17,8 @@ export const AGENT_HOOK_TARGETS = [ 'copilot', 'hermes', 'devin', - 'kimi' + 'kimi', + 'muse' ] as const export type AgentHookTarget = (typeof AGENT_HOOK_TARGETS)[number] diff --git a/src/shared/agent-icons/muse.png b/src/shared/agent-icons/muse.png new file mode 100644 index 00000000000..f223b279d4c Binary files /dev/null and b/src/shared/agent-icons/muse.png differ diff --git a/src/shared/agent-kind.ts b/src/shared/agent-kind.ts index 5eb151be3e8..fd1e369c767 100644 --- a/src/shared/agent-kind.ts +++ b/src/shared/agent-kind.ts @@ -51,7 +51,8 @@ const TUI_AGENT_KIND_BY_AGENT = { grok: 'grok', devin: 'devin', ante: 'ante', - trae: 'trae' + trae: 'trae', + muse: 'muse' } satisfies Record // Why: `satisfies Record` makes the lookup exhaustive at compile diff --git a/src/shared/agent-process-recognition.test.ts b/src/shared/agent-process-recognition.test.ts index 7a9e961e292..3375edf2c08 100644 --- a/src/shared/agent-process-recognition.test.ts +++ b/src/shared/agent-process-recognition.test.ts @@ -205,6 +205,68 @@ describe('agent process recognition', () => { }) }) + it('recognizes Muse by its muse binary', () => { + expect(recognizeAgentProcess('muse')).toEqual({ + agent: 'muse', + processName: 'muse' + }) + expect(recognizeAgentProcess('/Users/dev/.local/bin/muse')).toEqual({ + agent: 'muse', + processName: 'muse' + }) + expect(isExpectedAgentProcess('/Users/dev/.local/bin/muse', 'muse')).toBe(true) + expect(isRecognizedAgentType('muse')).toBe(true) + }) + + it('recognizes Muse by its versioned muse-bin sibling binary', () => { + // Why: the `muse` launcher execs `muse-bin-` (a 242MB sibling), + // so the live foreground process carries the versioned name — truncated + // to `muse-bin-1.0.3-R` in macOS comm output (verified on-device). + expect(recognizeAgentProcess('muse-bin-1.0.3-R2198.1')).toEqual({ + agent: 'muse', + processName: 'muse-bin-1.0.3-r2198.1' + }) + expect(recognizeAgentProcess('muse-bin-1.0.3-R')).toEqual({ + agent: 'muse', + processName: 'muse-bin-1.0.3-r' + }) + expect(recognizeAgentProcess('/Users/dev/.local/bin/muse-bin-1.0.3-R2198.1')).toEqual({ + agent: 'muse', + processName: 'muse-bin-1.0.3-r2198.1' + }) + }) + + it('does not recognize Muse headless exec commands as interactive agents', () => { + expect(recognizeAgentProcessFromCommandLine('muse exec "summarize this diff"')).toBeNull() + expect( + recognizeAgentProcessFromCommandLine('muse exec --json "review this" > result.jsonl') + ).toBeNull() + // Why: a bare `exec` token dispatches as the subcommand even past `--` + // (verified: `muse -- resume` still resumes), so this errors instead of + // hosting a pane — never an interactive agent. + expect(recognizeAgentProcessFromCommandLine('muse -- exec "summarize this diff"')).toBeNull() + // Why: a whole-prompt `muse 'exec'` takes the exec missing-prompt error + // path, not a TUI, so filtering it is correct. + expect(recognizeAgentProcessFromCommandLine("muse 'exec'")).toBeNull() + // Why: `muse resume` reopens the interactive TUI, so it still hosts a live session. + expect(recognizeAgentProcessFromCommandLine('muse resume')).toEqual({ + agent: 'muse', + processName: 'muse' + }) + // Why: `muse -- resume` still dispatches to the resume subcommand (verified + // against muse 1.0.3), which reopens the interactive TUI. + expect(recognizeAgentProcessFromCommandLine('muse -- resume')).toEqual({ + agent: 'muse', + processName: 'muse' + }) + // Why: the prompt is one quoted argv, so it never equals the bare `exec` + // token — this is the interactive pane Orca itself launches. + expect(recognizeAgentProcessFromCommandLine('muse -- "exec the release notes"')).toEqual({ + agent: 'muse', + processName: 'muse' + }) + }) + it('recognizes Mistral Vibe by its installed executable and legacy alias', () => { expect(recognizeAgentProcess('/home/dev/.local/bin/vibe')).toEqual({ agent: 'mistral-vibe', @@ -434,4 +496,53 @@ describe('agent process recognition', () => { processName: 'grok-0.2.51' }) }) + + it('recognizes the versioned Muse binary execed by the launcher', () => { + expect(recognizeAgentProcess('muse-bin-1.3.0-r3401.1')).toEqual({ + agent: 'muse', + processName: 'muse-bin-1.3.0-r3401.1' + }) + expect( + recognizeAgentProcessFromCommandLine('/Users/dev/.local/bin/muse-bin-1.3.0-R3401.1') + ).toEqual({ + agent: 'muse', + processName: 'muse-bin-1.3.0-r3401.1' + }) + // Why: Linux truncates comm to 15 chars, so `muse-bin-1.0.3-R…` rows still match. + expect(recognizeAgentProcess('muse-bin-1.0.3-R')).toEqual({ + agent: 'muse', + processName: 'muse-bin-1.0.3-r' + }) + expect(recognizeAgentProcess('muse-workbench')).toBeNull() + expect(isRecognizedAgentType('muse-bin-1.3.0-R3401.1')).toBe(true) + expect(isExpectedAgentProcess('muse', 'muse')).toBe(true) + expect(isExpectedAgentProcess('muse-bin-1.3.0-R3401.1', 'muse')).toBe(true) + expect(isExpectedAgentProcess('/Users/dev/.local/bin/muse-bin-1.3.0-R3401.1', 'muse')).toBe( + true + ) + expect(isExpectedAgentProcess('not-muse', 'muse')).toBe(false) + expect(isExpectedAgentProcess('muse-workbench', 'muse')).toBe(false) + }) + + it('does not recognize Muse headless exec runs as interactive agents', () => { + expect(recognizeAgentProcessFromCommandLine('muse exec "summarize this diff"')).toBeNull() + expect( + recognizeAgentProcessFromCommandLine( + '/Users/dev/.local/bin/muse-bin-1.3.0-R3401.1 exec --session-id abc "hi"' + ) + ).toBeNull() + // Why: `exec` past any position is never the TUI — `muse exec` dispatches headless + // while `muse exec` fails fast with an arg error (verified on Muse 1.3.0). + expect(recognizeAgentProcessFromCommandLine('muse -- exec "summarize this diff"')).toBeNull() + expect(recognizeAgentProcessFromCommandLine('muse --yolo exec "hi"')).toBeNull() + expect(recognizeAgentProcessFromCommandLine('muse -- yolo')).toEqual({ + agent: 'muse', + processName: 'muse' + }) + // Why: subcommand dispatch is case-sensitive, so an uppercase prompt is the TUI. + expect(recognizeAgentProcessFromCommandLine("muse 'EXEC'")).toEqual({ + agent: 'muse', + processName: 'muse' + }) + }) }) diff --git a/src/shared/agent-process-recognition.ts b/src/shared/agent-process-recognition.ts index bfc8a6f240f..baf15a63759 100644 --- a/src/shared/agent-process-recognition.ts +++ b/src/shared/agent-process-recognition.ts @@ -94,6 +94,12 @@ function agentForNormalizedProcess(normalized: string): TuiAgent | undefined { if (normalized.startsWith('grok-')) { return PROCESS_TO_AGENT.get('grok') } + // Why: the `muse` launcher script execs a versioned `muse-bin-` binary, so + // the foreground name never equals `muse` itself. The `muse-bin-` prefix also covers + // comm-truncated rows (`muse-bin-1.0.3-R`) without matching unrelated `muse-*` tools. + if (normalized.startsWith('muse-bin-')) { + return PROCESS_TO_AGENT.get('muse') + } return undefined } @@ -162,10 +168,6 @@ function isInterpreterProcessName(normalized: string): boolean { return STATIC_INTERPRETER_PROCESS_NAMES.has(normalized) || PYTHON_PROCESS_RE.test(normalized) } -const isPythonProcessName = (normalized: string): boolean => PYTHON_PROCESS_RE.test(normalized) - -const optionName = (token: string): string => token.split('=', 1)[0] ?? '' - function findInterpreterEntrypointToken(tokens: string[], firstNormalized: string): string | null { if (!isInterpreterProcessName(firstNormalized)) { return null @@ -175,11 +177,11 @@ function findInterpreterEntrypointToken(tokens: string[], firstNormalized: strin if (token === '--') { continue } - if (isPythonProcessName(firstNormalized) && token === '-m') { + if (PYTHON_PROCESS_RE.test(firstNormalized) && token === '-m') { return tokens[index + 1] ?? null } if (token.startsWith('-')) { - const name = optionName(token) + const name = token.split('=', 1)[0] ?? '' if (INTERPRETER_OPTIONS_WITH_INLINE_SOURCE.has(name)) { return null } @@ -255,6 +257,10 @@ function recognizePythonEntrypoint( return recognizeAgentProcess(entrypoint) ?? recognizePythonScriptEntrypoint(entrypoint) } +// Why: `muse` execs a versioned `muse-bin-` binary (see above), so the +// exact-name check never matches and readiness/follow-up delivery would stall. +// Scoped to muse: a generic `-suffix` rule would misclassify short agent names +// (see the ante-obsidian test). export function isExpectedAgentProcess( processName: string | null | undefined, expectedProcess: string @@ -266,7 +272,8 @@ export function isExpectedAgentProcess( } return ( normalizedProcess === normalizedExpected || - normalizedProcess.startsWith(`${normalizedExpected}.`) + normalizedProcess.startsWith(`${normalizedExpected}.`) || + (normalizedExpected === 'muse' && normalizedProcess.startsWith('muse-bin-')) ) } @@ -306,7 +313,7 @@ export function recognizeAgentProcessFromCommandLine( if (!entrypoint) { return null } - const viaEntrypoint = isPythonProcessName(firstNormalized) + const viaEntrypoint = PYTHON_PROCESS_RE.test(firstNormalized) ? recognizePythonEntrypoint(tokens, entrypoint) : (recognizeAgentProcess(entrypoint) ?? recognizeNodeScriptEntrypoint(entrypoint)) if ( diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 56aa3952e00..0a0ab7d443a 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -17,7 +17,8 @@ export const RESUMABLE_TUI_AGENTS = [ 'omp', 'prime-agent', 'copilot', - 'kimi' + 'kimi', + 'muse' ] as const satisfies readonly TuiAgent[] export type ResumableTuiAgent = (typeof RESUMABLE_TUI_AGENTS)[number] @@ -200,6 +201,10 @@ export function extractAgentProviderSession( const id = readSessionId(payload, ['session_id']) return id ? { key: 'session_id', id } : null } + case 'muse': { + const id = readSessionId(payload, ['session_id']) + return id ? withTranscriptPath({ key: 'session_id', id }, payload) : null + } case 'antigravity': { const id = readSessionId(payload, ['conversationId']) return id ? { key: 'conversation_id', id } : null @@ -298,5 +303,7 @@ export function getAgentResumeArgv( // Why: Kimi resumes by id with --session; sessions are work-dir-scoped (enforced by callers). case 'kimi': return providerSession.key === 'session_id' ? ['kimi', '--session', id] : null + case 'muse': + return providerSession.key === 'session_id' ? ['muse', 'resume', id] : null } } diff --git a/src/shared/agent-type-label.ts b/src/shared/agent-type-label.ts index 51f1695f508..662a0434bf5 100644 --- a/src/shared/agent-type-label.ts +++ b/src/shared/agent-type-label.ts @@ -25,7 +25,8 @@ const WELL_KNOWN_LABELS: Record = { devin: 'Devin', ante: 'Ante', trae: 'Trae', - kimi: 'Kimi' + kimi: 'Kimi', + muse: 'Muse' } export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string { diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts index 2ebb5d59794..da8ced39e0e 100644 --- a/src/shared/ai-vault-resume-command.test.ts +++ b/src/shared/ai-vault-resume-command.test.ts @@ -152,6 +152,17 @@ describe('buildAiVaultResumeCommand', () => { }) ).toBe("cd '/Users/ada/repo' && prime-agent --resume 'dddddddd-eeee-4fff-8aaa-111111111111'") }) + + it('resumes Muse by session id in the session cwd', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'muse', + sessionId: 'eeeeeeee-ffff-4000-baaa-222222222222', + cwd: '/Users/ada/repo', + platform: 'darwin' + }) + ).toBe("cd '/Users/ada/repo' && muse resume 'eeeeeeee-ffff-4000-baaa-222222222222'") + }) }) describe('buildAiVaultResumeShellCommand env removal', () => { diff --git a/src/shared/ai-vault-resume-command.ts b/src/shared/ai-vault-resume-command.ts index da4cd29e038..fbd0658def1 100644 --- a/src/shared/ai-vault-resume-command.ts +++ b/src/shared/ai-vault-resume-command.ts @@ -212,6 +212,10 @@ function buildAgentResumeInvocation( return `${baseCommand} --session ${sessionArg}` case 'copilot': return `${baseCommand} --resume=${sessionArg}` + // Why: `muse resume ` reopens the session (resume is workspace-scoped, + // so the cwd prefix from buildAiVaultResumeCommand is required). + case 'muse': + return `${baseCommand} resume ${sessionArg}` case 'cline': return `${baseCommand} --id ${sessionArg}` case 'claude': diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 24567e82fbc..b2635555bf6 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -20,7 +20,8 @@ export const AI_VAULT_AGENTS = [ 'devin', 'droid', 'cline', - 'kimi' + 'kimi', + 'muse' ] as const satisfies readonly TuiAgent[] // Why: the aiVault.listSessions RPC schema CLAMPS scopePaths to this bound @@ -66,7 +67,8 @@ export const AI_VAULT_AGENT_LABELS = { devin: 'Devin', droid: 'Droid', cline: 'Cline', - kimi: 'Kimi' + kimi: 'Kimi', + muse: 'Muse' } as const satisfies Record export type AiVaultSessionPreviewMessage = { diff --git a/src/shared/codex-rollout-jsonl-cursor.ts b/src/shared/codex-rollout-jsonl-cursor.ts index df47d5c3e68..57f68df3b54 100644 --- a/src/shared/codex-rollout-jsonl-cursor.ts +++ b/src/shared/codex-rollout-jsonl-cursor.ts @@ -17,8 +17,12 @@ export function record(value: unknown): JsonRecord | undefined { return typeof value === 'object' && value !== null ? (value as JsonRecord) : undefined } -/** Returns undefined when the file is unreadable, distinguishing a vanished rollout from one with no new lines. */ -export function readJsonlCursor(cursor: JsonlCursor): JsonRecord[] | undefined { +/** Returns undefined when the file is unreadable, distinguishing a vanished rollout from one with no new lines. + * `lineFilter` skips JSON.parse for raw lines the caller can reject by substring. */ +export function readJsonlCursor( + cursor: JsonlCursor, + lineFilter?: (line: string) => boolean +): JsonRecord[] | undefined { if (!cursor.filePath) { return undefined } @@ -63,7 +67,10 @@ export function readJsonlCursor(cursor: JsonlCursor): JsonRecord[] | undefined { } const records: JsonRecord[] = [] for (const line of lines) { - if (Buffer.byteLength(line, 'utf8') > TRANSCRIPT_LINE_MAX_BYTES) { + if ( + (lineFilter && !lineFilter(line)) || + Buffer.byteLength(line, 'utf8') > TRANSCRIPT_LINE_MAX_BYTES + ) { continue } try { diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts index c304e129331..fee9a9f1046 100644 --- a/src/shared/commit-message-agent-spec.test.ts +++ b/src/shared/commit-message-agent-spec.test.ts @@ -37,6 +37,7 @@ describe('COMMIT_MESSAGE_AGENT_SPECS', () => { 'copilot', 'cursor', 'kimi', + 'muse', 'omp', 'opencode', 'opencode2', @@ -71,6 +72,26 @@ describe('COMMIT_MESSAGE_AGENT_SPECS', () => { expect(args).toEqual(expect.arrayContaining(['--model', 'kimi-code/kimi-for-coding'])) }) + it('uses Muse exec for non-interactive Source Control AI generation', () => { + const spec = COMMIT_MESSAGE_AGENT_SPECS.muse + expect(spec).toBeDefined() + expect(spec?.promptDelivery).toBe('argv') + expect(spec?.buildArgs({ prompt: 'Write a concise commit message', model: 'default' })).toEqual( + [ + 'exec', + '--no-session-log', + '--approval-mode', + 'never', + '--disable-sandbox', + '--disable-shell', + '--disable-write', + '--disable-web-tools', + '--', + 'Write a concise commit message' + ] + ) + }) + it('uses the provider-qualified Kimi model id accepted by the CLI', () => { expect(COMMIT_MESSAGE_AGENT_SPECS.kimi?.models.map((m) => m.id)).toEqual([ 'default', diff --git a/src/shared/commit-message-agent-specs-secondary.ts b/src/shared/commit-message-agent-specs-secondary.ts index 53992143d19..80dfe37212f 100644 --- a/src/shared/commit-message-agent-specs-secondary.ts +++ b/src/shared/commit-message-agent-specs-secondary.ts @@ -110,6 +110,33 @@ export function buildSecondaryCommitMessageAgentSpecs({ ], defaultModelId: 'default' }, + muse: { + id: 'muse', + label: 'Muse', + binary: 'muse', + // Muse's `exec` subcommand accepts a positional prompt. Keep Source + // Control AI one-shot and workspace-read-only, matching the other text + // generators rather than launching the interactive TUI. + promptDelivery: 'argv', + buildArgs: ({ prompt, model, thinkingLevel }) => [ + 'exec', + '--no-session-log', + '--approval-mode', + 'never', + '--disable-sandbox', + '--disable-shell', + '--disable-write', + '--disable-web-tools', + ...(model && model !== 'default' ? ['--model', model] : []), + ...(thinkingLevel ? ['--reasoning-effort', thinkingLevel] : []), + '--', + prompt + ], + singletonOptions: [['--model'], ['--reasoning-effort']], + modelSource: 'static', + models: [{ id: 'default', label: 'Config default' }], + defaultModelId: 'default' + }, copilot: { id: 'copilot', label: 'GitHub Copilot', diff --git a/src/shared/commit-message-plan.test.ts b/src/shared/commit-message-plan.test.ts index 2d58a5cf6e7..9e499ae4a95 100644 --- a/src/shared/commit-message-plan.test.ts +++ b/src/shared/commit-message-plan.test.ts @@ -318,6 +318,31 @@ describe('planCommitMessageGeneration', () => { }) }) + it('plans Muse exec with a positional prompt and no workspace side effects', () => { + const result = planCommitMessageGeneration({ agentId: 'muse', model: 'default' }, 'PROMPT') + + expect(result).toEqual({ + ok: true, + plan: { + binary: 'muse', + args: [ + 'exec', + '--no-session-log', + '--approval-mode', + 'never', + '--disable-sandbox', + '--disable-shell', + '--disable-write', + '--disable-web-tools', + '--', + 'PROMPT' + ], + stdinPayload: null, + label: 'Muse' + } + }) + }) + it('uses preset agent command overrides as the spawn command prefix', () => { const result = planCommitMessageGeneration( { diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index 9bf262ef45a..be50ca8464c 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -137,6 +137,7 @@ describe('getDefaultSettings', () => { codex: '--dangerously-bypass-approvals-and-sandbox', gemini: '--yolo', cursor: '--yolo', + muse: '--yolo', copilot: '--yolo', grok: '--permission-mode bypassPermissions' }) diff --git a/src/shared/daemon-adoption-telemetry.test.ts b/src/shared/daemon-adoption-telemetry.test.ts index 93a79f94a67..2dd01b79d70 100644 --- a/src/shared/daemon-adoption-telemetry.test.ts +++ b/src/shared/daemon-adoption-telemetry.test.ts @@ -68,13 +68,15 @@ describe('daemon_adopted / daemon_pty_cwd_denied schemas', () => { const adopted = { app_version_match: 'different', spawner_path_class: 'updater-cache', + code_identity: 'unresolvable', tcc_attribution: 'intact', live_session_count_bucket: '2-5' } const denied = { cwd_class: 'documents', app_version_match: 'different', - spawner_path_class: 'updater-cache' + spawner_path_class: 'updater-cache', + code_identity: 'parked' } it('accepts the enum payloads', () => { @@ -101,6 +103,9 @@ describe('daemon_adopted / daemon_pty_cwd_denied schemas', () => { expect( eventSchemas.daemon_pty_cwd_denied.safeParse({ ...denied, cwd_class: 'Documents' }).success ).toBe(false) + expect( + eventSchemas.daemon_adopted.safeParse({ ...adopted, code_identity: 'severed' }).success + ).toBe(false) }) }) diff --git a/src/shared/daemon-adoption-telemetry.ts b/src/shared/daemon-adoption-telemetry.ts index 9cdc31299bc..149dc4da910 100644 --- a/src/shared/daemon-adoption-telemetry.ts +++ b/src/shared/daemon-adoption-telemetry.ts @@ -22,6 +22,21 @@ export type DaemonSpawnerPathClass = (typeof DAEMON_SPAWNER_PATH_CLASSES)[number export const DAEMON_TCC_ATTRIBUTION_VALUES = ['intact', 'severed', 'unknown'] as const +/** + * Where macOS says the daemon pid's own executable is now (#21826). Measurement only. + * `resolved`: an existing file outside a ShipIt directory (not a claim it is the installed app). + * `parked`: inside a Squirrel `…ShipIt…` directory, where an update moves the outgoing bundle. + * `unresolvable`: the file is gone, which is where tccd loses the daemon's code identity. + * `probe-failed`: not macOS, no pid, no codesign, timeout, or unrecognised output. + */ +export const DAEMON_CODE_IDENTITY_VALUES = [ + 'resolved', + 'parked', + 'unresolvable', + 'probe-failed' +] as const +export type DaemonCodeIdentity = (typeof DAEMON_CODE_IDENTITY_VALUES)[number] + /** Which macOS-protected folder class the denied cwd falls under. */ export const DAEMON_PTY_CWD_CLASSES = [ 'documents', diff --git a/src/shared/file-name-filter-tokens.ts b/src/shared/file-name-filter-tokens.ts new file mode 100644 index 00000000000..a9d70b91cc4 --- /dev/null +++ b/src/shared/file-name-filter-tokens.ts @@ -0,0 +1,61 @@ +import { isClipboardTextByteLengthOverLimit } from './clipboard-text' + +/** Explorer name-filter matching, shared so the host can filter a scan exactly like the renderer. */ + +export const FILE_NAME_FILTER_QUERY_MAX_BYTES = 2 * 1024 + +export function isFileNameFilterQueryTooLarge( + query: string, + maxBytes = FILE_NAME_FILTER_QUERY_MAX_BYTES +): boolean { + return isClipboardTextByteLengthOverLimit(query, maxBytes) +} + +// Why: accepted pasted file-filter queries are still on a renderer hot path; +// tokenize whitespace directly instead of allocating a regex split array. +export function splitFileNameFilterTokens(query: string): string[] { + const tokens: string[] = [] + let tokenStart = -1 + for (let index = 0; index <= query.length; index += 1) { + const isEnd = index === query.length + if (!isEnd && !isFileNameFilterWhitespace(query.charCodeAt(index))) { + if (tokenStart === -1) { + tokenStart = index + } + continue + } + if (tokenStart !== -1) { + tokens.push(query.slice(tokenStart, index).toLowerCase()) + tokenStart = -1 + } + } + return tokens +} + +function isFileNameFilterWhitespace(code: number): boolean { + return ( + code === 32 || + (code >= 9 && code <= 13) || + code === 160 || + code === 5760 || + (code >= 8192 && code <= 8202) || + code === 8232 || + code === 8233 || + code === 8239 || + code === 8287 || + code === 12288 || + code === 65279 + ) +} + +export function pathMatchesFileNameFilterTokens( + relativePath: string, + tokens: readonly string[] +): boolean { + if (tokens.length === 0) { + return true + } + // Why: locale-independent so host and renderer agree; callers pass already-normalized paths. + const haystack = relativePath.toLowerCase() + return tokens.every((token) => haystack.includes(token)) +} diff --git a/src/shared/muse-headless-command.ts b/src/shared/muse-headless-command.ts new file mode 100644 index 00000000000..ed1e3018267 --- /dev/null +++ b/src/shared/muse-headless-command.ts @@ -0,0 +1,9 @@ +// Why: `muse exec` runs one prompt headlessly and exits, so a pane running it +// must not classify as the interactive Muse TUI. `exec` matches past any position: +// `muse exec …` dispatches headless while `muse exec …` fails fast with an +// arg error — neither ever hosts the TUI. The match stays case-sensitive because +// subcommand dispatch is (`muse 'EXEC'` is a TUI prompt), and a quoted TUI prompt +// never splits into an `exec` token on its own. +export function isMuseHeadlessOneShotCommand(tokens: readonly string[]): boolean { + return tokens.slice(1).some((token) => token === 'exec') +} diff --git a/src/shared/muse-session-log.test.ts b/src/shared/muse-session-log.test.ts new file mode 100644 index 00000000000..dc82815cd3d --- /dev/null +++ b/src/shared/muse-session-log.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + createMuseSessionLogState, + findMuseSessionLogPath, + readMusePendingUserInput +} from './muse-session-log' + +const SESSION_ID = '01a0caa3-0e77-7d41-bad7-46283a45633d' +const PROMPT_ID = '01a0caa3-a25a-7810-8229-4de04b2e7ca3' +const QUESTION = { + id: 'fav_color', + header: 'Color', + question: 'What is your favorite color?', + options: [{ label: 'Blue' }, { label: 'Green' }, { label: 'Red' }] +} + +function logLine(event: Record): string { + return `${JSON.stringify({ + schema_version: 1, + stream: { kind: 'session', id: SESSION_ID }, + record_type: 'event', + payload_type: 'runtime.session', + payload: { kind: 'run', run_id: 'fdada6f1-d403-41d8-b610-52f1d8489334', event } + })}\n` +} + +const requested = (promptId = PROMPT_ID): string => + logLine({ + kind: 'user_input_prompt_requested', + prompt_id: promptId, + tool_name: 'request_user_input', + questions: [QUESTION] + }) +const settled = (promptId = PROMPT_ID): string => + logLine({ kind: 'user_input_prompt_settled', prompt_id: promptId, outcome: 'answered' }) + +function localShard(sessionId: string): string[] { + const hex = sessionId.replace(/-/g, '').slice(0, 12) + const date = new Date(Number.parseInt(hex, 16)) + return [ + String(date.getFullYear()), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0') + ] +} + +describe('muse session log', () => { + let sessionsDir: string + let logPath: string + + beforeEach(() => { + sessionsDir = mkdtempSync(join(tmpdir(), 'muse-session-log-')) + const dir = join(sessionsDir, ...localShard(SESSION_ID), SESSION_ID) + mkdirSync(dir, { recursive: true }) + logPath = join(dir, 'session.jsonl') + }) + + afterEach(() => { + rmSync(sessionsDir, { recursive: true, force: true }) + }) + + it('finds the log in the date shard named by the UUIDv7 timestamp', () => { + writeFileSync(logPath, '') + expect(findMuseSessionLogPath(SESSION_ID, sessionsDir)).toBe(logPath) + }) + + it('returns undefined for a missing log or a non-v7 session id', () => { + expect(findMuseSessionLogPath(SESSION_ID, sessionsDir)).toBeUndefined() + expect( + findMuseSessionLogPath('1471f5e6-bd60-41a2-bfcf-c9086dbd4092', sessionsDir) + ).toBeUndefined() + }) + + it('reads prompts batched inside a retained_frame', () => { + const frame = (line: string): string => + `${JSON.stringify({ record_type: 'retained_frame', children: [{ record_json: line.trim() }] })}\n` + writeFileSync(logPath, frame(requested())) + const log = createMuseSessionLogState(SESSION_ID) + expect(readMusePendingUserInput(log, undefined, sessionsDir)?.promptId).toBe(PROMPT_ID) + appendFileSync(logPath, frame(settled())) + expect(readMusePendingUserInput(log, undefined, sessionsDir)).toBeUndefined() + }) + + it('ignores a prompt left open by an earlier run', () => { + writeFileSync(logPath, requested()) + const log = createMuseSessionLogState(SESSION_ID) + expect( + readMusePendingUserInput(log, 'fdada6f1-d403-41d8-b610-52f1d8489334', sessionsDir)?.promptId + ).toBe(PROMPT_ID) + expect( + readMusePendingUserInput(log, '11111111-2222-4333-8444-555555555555', sessionsDir) + ).toBeUndefined() + }) + + it('returns the open prompt and clears it once settled', () => { + writeFileSync(logPath, requested()) + const log = createMuseSessionLogState(SESSION_ID) + expect(readMusePendingUserInput(log, undefined, sessionsDir)).toEqual({ + promptId: PROMPT_ID, + runId: 'fdada6f1-d403-41d8-b610-52f1d8489334', + questions: [QUESTION] + }) + // Re-reading with no new bytes keeps the prompt pending. + expect(readMusePendingUserInput(log, undefined, sessionsDir)?.promptId).toBe(PROMPT_ID) + + appendFileSync(logPath, settled()) + expect(readMusePendingUserInput(log, undefined, sessionsDir)).toBeUndefined() + }) + + it('holds a partial trailing line until it is completed', () => { + const line = requested() + writeFileSync(logPath, line.slice(0, 40)) + const log = createMuseSessionLogState(SESSION_ID) + expect(readMusePendingUserInput(log, undefined, sessionsDir)).toBeUndefined() + + appendFileSync(logPath, line.slice(40)) + expect(readMusePendingUserInput(log, undefined, sessionsDir)?.questions).toEqual([QUESTION]) + }) + + it('reports the newest of several open prompts', () => { + const second = '01a0caa4-0000-7000-8000-000000000001' + writeFileSync(logPath, `${requested()}${requested(second)}`) + const log = createMuseSessionLogState(SESSION_ID) + expect(readMusePendingUserInput(log, undefined, sessionsDir)?.promptId).toBe(second) + + appendFileSync(logPath, settled(second)) + expect(readMusePendingUserInput(log, undefined, sessionsDir)?.promptId).toBe(PROMPT_ID) + }) + + it('locates a log created after the first read', () => { + const log = createMuseSessionLogState(SESSION_ID) + expect(readMusePendingUserInput(log, undefined, sessionsDir)).toBeUndefined() + + writeFileSync(logPath, requested()) + expect(readMusePendingUserInput(log, undefined, sessionsDir)?.promptId).toBe(PROMPT_ID) + }) +}) diff --git a/src/shared/muse-session-log.ts b/src/shared/muse-session-log.ts new file mode 100644 index 00000000000..1b4d90977c4 --- /dev/null +++ b/src/shared/muse-session-log.ts @@ -0,0 +1,140 @@ +import { existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + readJsonlCursor, + record, + type JsonlCursor, + type JsonRecord +} from './codex-rollout-jsonl-cursor' + +// Why: Muse stores sessions under /muse/sessions (default +// ~/.local/share/muse/sessions), sharded by the host's local start date: +// /YYYY/MM/DD//session.jsonl. No upstream override variable exists. +export function resolveMuseSessionsDir(override?: string): string { + if (override?.trim()) { + return override.trim() + } + const dataHome = process.env.XDG_DATA_HOME?.trim() || join(homedir(), '.local', 'share') + return join(dataHome, 'muse', 'sessions') +} + +const UUID_V7 = /^([0-9a-f]{8})-([0-9a-f]{4})-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const DAY_MS = 24 * 60 * 60 * 1000 + +function dayParts(date: Date, utc: boolean): string[] { + const year = utc ? date.getUTCFullYear() : date.getFullYear() + const month = (utc ? date.getUTCMonth() : date.getMonth()) + 1 + const day = utc ? date.getUTCDate() : date.getDate() + return [String(year), String(month).padStart(2, '0'), String(day).padStart(2, '0')] +} + +/** Locates a live session's log from its UUIDv7 id, whose timestamp names the date shard. */ +export function findMuseSessionLogPath( + sessionId: string, + sessionsDir = resolveMuseSessionsDir() +): string | undefined { + const match = UUID_V7.exec(sessionId) + if (!match) { + return undefined + } + const startedAt = Number.parseInt(`${match[1]}${match[2]}`, 16) + const candidates = new Set() + // Why: the shard uses Muse's local zone, which can differ from ours (relay, TZ env), so probe neighbors. + for (const offset of [0, -DAY_MS, DAY_MS]) { + const date = new Date(startedAt + offset) + for (const utc of [false, true]) { + candidates.add(join(sessionsDir, ...dayParts(date, utc), sessionId, 'session.jsonl')) + } + } + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate + } + } + return undefined +} + +export type MuseUserInputQuestion = Record + +export type MusePendingUserInput = { + promptId: string + /** Muse run that asked; equals the hook payload's `turn_id`. */ + runId?: string + questions: MuseUserInputQuestion[] +} + +export type MuseSessionLogState = { + sessionId: string + cursor: JsonlCursor + pending: Map +} + +const USER_INPUT_PROMPT_MARKER = '"user_input_prompt_' + +export function createMuseSessionLogState(sessionId: string): MuseSessionLogState { + return { sessionId, cursor: { offset: 0, carry: '' }, pending: new Map() } +} + +/** Muse batches some records into a `retained_frame` whose `children[].record_json` hold them as strings. */ +export function unwrapMuseLogRecords(line: JsonRecord): JsonRecord[] { + if (!Array.isArray(line.children)) { + return [line] + } + const records: JsonRecord[] = [] + for (const child of line.children) { + const raw: unknown = record(child)?.record_json + let value: unknown = raw + try { + value = typeof raw === 'string' ? JSON.parse(raw) : raw + } catch { + value = undefined + } + const parsed = record(value) + if (parsed) { + records.push(parsed) + } + } + return records +} + +function applyUserInputRecord(log: MuseSessionLogState, entry: JsonRecord): void { + const payload = record(entry.payload) + const event = record(payload?.event) + const promptId = typeof event?.prompt_id === 'string' ? event.prompt_id : undefined + if (!event || !promptId) { + return + } + if (event.kind === 'user_input_prompt_requested') { + const questions = Array.isArray(event.questions) + ? event.questions.flatMap((question: unknown) => { + const item = record(question) + return item ? [item] : [] + }) + : [] + const runId = typeof payload?.run_id === 'string' ? payload.run_id : undefined + log.pending.delete(promptId) + log.pending.set(promptId, { promptId, runId, questions }) + } else if (event.kind === 'user_input_prompt_settled') { + log.pending.delete(promptId) + } +} + +/** Advances the log and returns the newest unanswered `request_user_input` prompt of `turnId`'s run. */ +export function readMusePendingUserInput( + log: MuseSessionLogState, + turnId: string | undefined, + sessionsDir?: string +): MusePendingUserInput | undefined { + log.cursor.filePath ??= findMuseSessionLogPath(log.sessionId, sessionsDir) + // Why: most log lines are large model/tool records; parse only the two event kinds we read. + const lines = readJsonlCursor(log.cursor, (line) => line.includes(USER_INPUT_PROMPT_MARKER)) + for (const line of lines ?? []) { + for (const entry of unwrapMuseLogRecords(line)) { + applyUserInputRecord(log, entry) + } + } + // Why: a question left open by a crash or interrupt stays in the log; only the live turn's can block. + const pending = Array.from(log.pending.values()) + return pending.findLast((prompt) => !turnId || !prompt.runId || prompt.runId === turnId) +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index e3b822323f6..b39f34d237c 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -220,6 +220,7 @@ export const AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY = export const AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY = 'agent-session.kimi-resume.v1' as const export const AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY = 'agent-session.opencode2-resume.v1' as const +export const AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY = 'agent-session.muse-resume.v1' as const // Why: older runtimes strip mutation owner fields, so clients must fence writes before RPC. export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-ownership.v1' as const export const FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE = @@ -367,6 +368,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY, GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY, diff --git a/src/shared/skill-install-providers.ts b/src/shared/skill-install-providers.ts index 6341b0a80cf..98fbda587b8 100644 --- a/src/shared/skill-install-providers.ts +++ b/src/shared/skill-install-providers.ts @@ -15,6 +15,7 @@ export type SkillInstallProviderId = | 'trae' | 'grok' | 'aug' + | 'muse' export type SkillInstallProviderDefinition = { id: SkillInstallProviderId @@ -80,6 +81,13 @@ export const SKILL_INSTALL_PROVIDERS: readonly SkillInstallProviderDefinition[] displayName: 'Augment', globalSegments: ['.augment', 'skills'], workspaceSegments: ['.augment', 'skills'] + }, + // Why: Muse reads the canonical .agents/skills root at both scopes. + { + id: 'muse', + displayName: 'Muse', + globalSegments: null, + workspaceSegments: null } ] diff --git a/src/shared/skills-cli-agent-keys.ts b/src/shared/skills-cli-agent-keys.ts index 6f0ac5cd8de..f99b1f1c248 100644 --- a/src/shared/skills-cli-agent-keys.ts +++ b/src/shared/skills-cli-agent-keys.ts @@ -51,7 +51,8 @@ export const SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT = { devin: 'devin', ante: null, // Why: Orca detects trae by `traecli`, an alias only TRAE CN ships. - trae: 'trae-cn' + trae: 'trae-cn', + muse: null } satisfies Record /** diff --git a/src/shared/source-control-ai-action-recipes.test.ts b/src/shared/source-control-ai-action-recipes.test.ts index 66310f28420..352f9cfa337 100644 --- a/src/shared/source-control-ai-action-recipes.test.ts +++ b/src/shared/source-control-ai-action-recipes.test.ts @@ -427,7 +427,7 @@ describe('source-control AI action recipes', () => { ).toEqual({ ok: false, error: - 'Agent "aider" does not support Source Control AI commit messages. Supported agents: OMP, Claude, Codex, OpenCode, OpenCode 2, Pi, Amp, Cursor, Kimi, GitHub Copilot, Antigravity, or Custom command.' + 'Agent "aider" does not support Source Control AI commit messages. Supported agents: OMP, Claude, Codex, OpenCode, OpenCode 2, Pi, Amp, Cursor, Kimi, Muse, GitHub Copilot, Antigravity, or Custom command.' }) }) }) diff --git a/src/shared/telemetry-daemon-event-schemas.ts b/src/shared/telemetry-daemon-event-schemas.ts index 5ed87e15d1a..26762e5a52e 100644 --- a/src/shared/telemetry-daemon-event-schemas.ts +++ b/src/shared/telemetry-daemon-event-schemas.ts @@ -16,6 +16,7 @@ import { } from './daemon-audit-eligibility' import { DAEMON_ADOPTED_APP_VERSION_MATCH, + DAEMON_CODE_IDENTITY_VALUES, DAEMON_PTY_CWD_CLASSES, DAEMON_SPAWNER_PATH_CLASSES, DAEMON_TCC_ATTRIBUTION_VALUES @@ -56,25 +57,27 @@ export const mainThreadHangDetectedSchema = z }) .strict() +// Where the daemon came from; `code_identity` is #21826's unlinked-executable theory under measurement. +const daemonOriginProps = { + app_version_match: z.enum(DAEMON_ADOPTED_APP_VERSION_MATCH), + spawner_path_class: z.enum(DAEMON_SPAWNER_PATH_CLASSES), + code_identity: z.enum(DAEMON_CODE_IDENTITY_VALUES) +} + // Why: #17696 — a macOS app adopting a daemon from an earlier bundle is invisible to // `daemon_lifecycle` (nothing is replaced). Once per macOS launch that adopts; enum-only. export const daemonAdoptedSchema = z .object({ - app_version_match: z.enum(DAEMON_ADOPTED_APP_VERSION_MATCH), - spawner_path_class: z.enum(DAEMON_SPAWNER_PATH_CLASSES), + ...daemonOriginProps, tcc_attribution: z.enum(DAEMON_TCC_ATTRIBUTION_VALUES), live_session_count_bucket: z.enum(DAEMON_LIFECYCLE_SESSION_BUCKETS) }) .strict() -// Why: the #17696 symptom itself — the daemon spawned a terminal into a cwd it cannot read while -// the app can. Emitted only on that proven divergence, so a missing or app-unreadable cwd never counts. -export const daemonPtyCwdDeniedSchema = z - .object({ - cwd_class: z.enum(DAEMON_PTY_CWD_CLASSES), - app_version_match: z.enum(DAEMON_ADOPTED_APP_VERSION_MATCH), - spawner_path_class: z.enum(DAEMON_SPAWNER_PATH_CLASSES) - }) +// Why: `daemon_pty_cwd_denied` is the #17696 symptom — the daemon cannot read a cwd the app can, +// on proven divergence only. `daemon_pty_cwd_readable` is its control, on TCC-gated folders only. +export const daemonPtyCwdVerdictSchema = z + .object({ cwd_class: z.enum(DAEMON_PTY_CWD_CLASSES), ...daemonOriginProps }) .strict() // Why: STA-7948 — `daemon_pty_cwd_denied` counts the failure; this counts how often a user is diff --git a/src/shared/telemetry-event-registry.ts b/src/shared/telemetry-event-registry.ts index 068efb6f38d..810ba8b35d1 100644 --- a/src/shared/telemetry-event-registry.ts +++ b/src/shared/telemetry-event-registry.ts @@ -18,7 +18,7 @@ import { daemonAuditEligibilitySchema, daemonFolderAccessNoticeSchema, daemonLifecycleSchema, - daemonPtyCwdDeniedSchema, + daemonPtyCwdVerdictSchema, daemonStartFailedSchema, mainThreadHangDetectedSchema, remoteOutboundBudgetCloseSchema, @@ -126,7 +126,8 @@ export const eventSchemas = { main_thread_hang_detected: mainThreadHangDetectedSchema, daemon_lifecycle: daemonLifecycleSchema, daemon_adopted: daemonAdoptedSchema, - daemon_pty_cwd_denied: daemonPtyCwdDeniedSchema, + daemon_pty_cwd_denied: daemonPtyCwdVerdictSchema, + daemon_pty_cwd_readable: daemonPtyCwdVerdictSchema, daemon_folder_access_notice: daemonFolderAccessNoticeSchema, daemon_audit_eligibility: daemonAuditEligibilitySchema, runtime_rpc_start_failed: runtimeRpcStartFailedSchema, diff --git a/src/shared/telemetry-property-schemas.ts b/src/shared/telemetry-property-schemas.ts index 035262cd6d8..81613deff8f 100644 --- a/src/shared/telemetry-property-schemas.ts +++ b/src/shared/telemetry-property-schemas.ts @@ -46,6 +46,7 @@ export const AGENT_KIND_VALUES = [ 'devin', 'ante', 'trae', + 'muse', 'other' ] as const export const agentKindSchema = z.enum(AGENT_KIND_VALUES) diff --git a/src/shared/tui-agent-config.test.ts b/src/shared/tui-agent-config.test.ts index 3ada94778b0..d60562981c0 100644 --- a/src/shared/tui-agent-config.test.ts +++ b/src/shared/tui-agent-config.test.ts @@ -23,7 +23,8 @@ describe('TUI_AGENT_CONFIG', () => { 'claude-agent-teams': { launchCmd: 'orca claude-teams', expectedProcess: 'claude' }, kiro: { launchCmd: 'kiro-cli chat --tui', expectedProcess: 'kiro-cli' }, 'command-code': { launchCmd: 'command-code --trust' }, - hermes: { launchCmd: 'hermes --tui' } + hermes: { launchCmd: 'hermes --tui' }, + muse: { launchCmd: 'muse --trust-workspace' } } for (const [agent, expected] of Object.entries(overrides)) { expect(TUI_AGENT_CONFIG[agent as TuiAgent]).toMatchObject(expected) diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 256f7d30bec..cbdd8a3a065 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -308,6 +308,12 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { draftPasteReadySignal: 'grok-composer-prompt', ctrlEnterEncoding: 'csi-u' }, + muse: { + detectCmd: 'muse', + launchCmd: 'muse --trust-workspace', + // Muse 1.3 treats subcommand-shaped prompts as commands even after `--`. + promptInjectionMode: 'stdin-after-start' + }, devin: { detectCmd: 'devin', // Why: `devin -- ` auto-submits immediately (docs.devin.ai/cli), so start the REPL with no argv prompt. diff --git a/src/shared/tui-agent-display-names.ts b/src/shared/tui-agent-display-names.ts index 9c71a8f420f..4acb76e61af 100644 --- a/src/shared/tui-agent-display-names.ts +++ b/src/shared/tui-agent-display-names.ts @@ -13,6 +13,7 @@ export const TUI_AGENT_DISPLAY_NAMES: Record = { devin: 'Devin', ante: 'Ante', trae: 'Trae', + muse: 'Muse', autohand: 'Autohand Code', opencode: 'OpenCode', opencode2: 'OpenCode 2', diff --git a/src/shared/tui-agent-permissions.test.ts b/src/shared/tui-agent-permissions.test.ts index e1fa10e1993..3a5570be5c3 100644 --- a/src/shared/tui-agent-permissions.test.ts +++ b/src/shared/tui-agent-permissions.test.ts @@ -66,6 +66,23 @@ describe('tui agent permissions', () => { ) }) + it('switches Muse between yolo and manual arguments', () => { + expect( + applyAgentPermissionMode({ + mode: 'yolo', + agentDefaultArgs: { muse: '' }, + agentDefaultEnv: {} + }).agentDefaultArgs.muse + ).toBe('--yolo') + expect( + applyAgentPermissionMode({ + mode: 'manual', + agentDefaultArgs: { muse: '--yolo' }, + agentDefaultEnv: {} + }).agentDefaultArgs.muse + ).toBe('') + }) + it('resolves custom Codex permission arguments as mixed', () => { expect( resolveTuiAgentPermissionMode({ diff --git a/src/shared/tui-agent-permissions.ts b/src/shared/tui-agent-permissions.ts index 6fa64aa0458..e45adeea0d7 100644 --- a/src/shared/tui-agent-permissions.ts +++ b/src/shared/tui-agent-permissions.ts @@ -20,6 +20,7 @@ export const YOLO_TUI_AGENT_ARGS: Partial> = { continue: '--allow "*"', cursor: '--yolo', kimi: '--yolo', + muse: '--yolo', 'mistral-vibe': '--agent auto-approve', 'qwen-code': '--approval-mode yolo', rovo: '--yolo', diff --git a/src/shared/tui-agent-selection.ts b/src/shared/tui-agent-selection.ts index 8090cd88611..4afacfd9e21 100644 --- a/src/shared/tui-agent-selection.ts +++ b/src/shared/tui-agent-selection.ts @@ -15,6 +15,7 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [ 'mimo-code', 'ante', 'trae', + 'muse', 'pi', 'omp', 'prime-agent', diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 3e95d9212a0..31690889e9f 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -369,6 +369,50 @@ describe('tui agent startup plans', () => { }) }) + it.each([ + ['yolo', 'linux', 'posix', undefined, "muse --trust-workspace '--yolo'"], + ['manual', 'linux', 'posix', { muse: '' }, 'muse --trust-workspace'], + ['yolo', 'darwin', 'posix', undefined, "muse --trust-workspace '--yolo'"], + ['manual', 'darwin', 'posix', { muse: '' }, 'muse --trust-workspace'], + ['yolo', 'win32', 'powershell', undefined, "muse --trust-workspace '--yolo'"], + ['manual', 'win32', 'powershell', { muse: '' }, 'muse --trust-workspace'], + ['yolo', 'win32', 'cmd', undefined, 'muse --trust-workspace "--yolo"'], + ['manual', 'win32', 'cmd', { muse: '' }, 'muse --trust-workspace'] + ] as const)( + 'launches Muse in %s mode on %s/%s before delivering its prompt', + (_, platform, shell, defaults, command) => { + const plan = buildAgentStartupPlan({ + agent: 'muse', + prompt: 'fix it', + cmdOverrides: {}, + platform, + shell, + agentArgs: resolveTuiAgentLaunchArgs('muse', defaults) + }) + + expect(plan).toMatchObject({ + agent: 'muse', + launchCommand: command, + expectedProcess: 'muse', + followupPrompt: 'fix it' + }) + } + ) + + it.each(['exec', 'resume', '--help'])( + 'delivers the reserved Muse prompt %s as text', + (prompt) => { + const plan = buildAgentStartupPlan({ + agent: 'muse', + prompt, + cmdOverrides: {}, + platform: 'linux' + }) + expect(plan?.launchCommand).toBe('muse --trust-workspace') + expect(plan?.followupPrompt).toBe(prompt) + } + ) + it('leaves Claude command overrides untouched', () => { const plan = buildAgentStartupPlan({ agent: 'claude', diff --git a/src/shared/tui-agent.ts b/src/shared/tui-agent.ts index 0c2362bdd66..6bf80af5002 100644 --- a/src/shared/tui-agent.ts +++ b/src/shared/tui-agent.ts @@ -38,4 +38,5 @@ export type TuiAgent = | 'devin' // Devin CLI | 'ante' // Ante (Antigma Labs) | 'trae' // Trae CLI + | 'muse' // Muse (Meta `muse` CLI) | 'prime-agent' // Prime Agent (Prime Intellect)