diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22dc140d4..2960d1d8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,7 +122,10 @@ jobs: name: Common checks (fast lane) needs: scope runs-on: ubuntu-latest - timeout-minutes: 15 + # A policy or ignore-file change forces a complete repository-history + # Gitleaks scan. Keep this lane bounded, but allow that audit to finish on + # the full monorepo instead of failing at the generic 15-minute job limit. + timeout-minutes: 35 steps: - name: Check out repository without retained credentials uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 @@ -149,10 +152,15 @@ jobs: if: needs.scope.outputs.execution-tier == 'complete' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} + # Scan the immutable PR head so reviewed Gitleaks fingerprints remain + # stable; the full reachable history scan is still required. + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} shell: bash run: | set -euo pipefail - scan_plan="$(node scripts/ci/secret-scan.mjs --event="$GITHUB_EVENT_NAME" --base="$BASE_SHA" --head=HEAD)" + # Full-history scans can exceed ten minutes on the monorepo. The + # job timeout above remains the outer bound. + scan_plan="$(node scripts/ci/secret-scan.mjs --event="$GITHUB_EVENT_NAME" --base="$BASE_SHA" --head="$HEAD_SHA")" echo "Secret scan plan: $scan_plan" log_opts="$(SCAN_PLAN="$scan_plan" node --input-type=module -e 'console.log(JSON.parse(process.env.SCAN_PLAN).logOpts)')" docker run --rm \ @@ -162,7 +170,7 @@ jobs: --log-opts="$log_opts" \ --config /repo/.gitleaks.toml \ --gitleaks-ignore-path /repo/.gitleaksignore \ - --timeout 600 \ + --timeout 1800 \ /repo - name: Check pull request whitespace diff --git a/.gitignore b/.gitignore index ccba03249..ec5ea5279 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ WORKFLOW.md .playwright-cli/ logs/ output/ +# Node diagnostic reports can contain process credentials and local paths. +/packages/daemon/report.*.json # system .DS_Store diff --git a/.gitleaksignore b/.gitleaksignore index a17cd78a6..34c18997b 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -97,3 +97,199 @@ de71ccccd55f21e909cb597347bcc6e1f81103e3:packages/daemon/test/media-native-trans # daemon shard log, not credentials (reviewed 2026-10-29, gitleaks v8.30.1). 98218ae6fd093c12d537720a51b628733f417db0:shard2-log.txt:generic-api-key:12276 98218ae6fd093c12d537720a51b628733f417db0:shard2-log.txt:generic-api-key:12276 + +# Exact historical QA evidence hashes and synthetic identifiers reviewed on 2026-10-31 with Gitleaks v8.30.1; no provider credentials. +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/prepare-office-p3-memory-delta-packet-20261025.mjs:generic-api-key:54 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:296 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:429 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:445 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:520 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:659 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:810 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:938 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1037 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1148 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1161 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1162 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1170 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1403 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1562 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1612 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1656 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1658 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1663 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1665 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1668 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1680 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1722 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1885 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1886 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1887 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1888 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:304 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:437 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:453 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:528 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:667 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:818 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:946 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1045 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1156 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1169 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1170 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1178 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1409 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1568 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1618 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1662 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1664 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1669 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1671 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1674 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1686 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1728 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1772 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1773 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1774 +de0f23875634a29c7e1c76ded90aee56c0a7c24c:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1775 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:275 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:408 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:424 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:499 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:638 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:789 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:917 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1016 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1127 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1140 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1141 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1149 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1380 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1539 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1589 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1633 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1635 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1640 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1642 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1645 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1657 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1699 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:2254 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:2255 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:2256 +7170fb80a68a36426fa7432b63c3f7a680eca809:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:2257 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:232 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:365 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:381 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:456 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:595 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:746 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:874 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:973 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1084 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1097 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1098 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1106 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1337 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1496 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1546 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1590 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1592 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1597 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1599 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1602 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1614 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1656 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:2115 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:2116 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:2117 +f1bb3b147c4eb07350e4e704fbe23e74f4947686:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:2118 +69a7a32af6bbdceba41d2fca63f52f51e32598e9:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1631 +69a7a32af6bbdceba41d2fca63f52f51e32598e9:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1632 +69a7a32af6bbdceba41d2fca63f52f51e32598e9:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1633 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:88 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:88 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:88 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:88 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:88 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:88 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:88 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:88 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:88 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:88 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:88 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:88 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:88 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:88 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:88 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:88 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:88 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:88 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:90 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:90 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:90 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:90 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:90 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:90 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:90 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:90 +7784c3831e7b153660a1c12ebbe11644735937d4:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:90 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/p3-standard-dist-full-20261025.log:generic-api-key:10101 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/p3-standard-dist-full-20261025.log:generic-api-key:10101 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/p3-standard-dist-full-20261025.log:generic-api-key:10101 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/p3-standard-dist-full-20261025.log:generic-api-key:10121 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/p3-standard-dist-full-20261025.log:generic-api-key:10121 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/p3-standard-dist-full-20261025.log:generic-api-key:10121 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/p3-standard-dist-full-20261025.log:generic-api-key:10482 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/p3-standard-dist-full-20261025.log:generic-api-key:10582 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/p3-standard-dist-full-20261025.log:generic-api-key:10582 +93de59fbbed0e02ec68c7b41b5afcb6d16c9f87d:output/e6-20260928/p3-standard-dist-full-20261025.log:generic-api-key:10721 + +# Exact historical false positives reviewed on 2026-11-02 with Gitleaks v8.30.1. +# These are test fixtures, public extension keys, or SHA-256 review records; keep fingerprints exact. +097fecf41b9b446d12507a92d6fde49c30cad147:apps/desktop/tests/host-capability-daemon-client.test.mjs:generic-api-key:100 +097fecf41b9b446d12507a92d6fde49c30cad147:apps/desktop/tests/host-capability-runtime.test.mjs:generic-api-key:176 +097fecf41b9b446d12507a92d6fde49c30cad147:apps/desktop/tests/host-local-preview-manager.test.mjs:generic-api-key:119 +097fecf41b9b446d12507a92d6fde49c30cad147:packages/core/test/approval-policy.test.mjs:generic-api-key:140 +097fecf41b9b446d12507a92d6fde49c30cad147:packages/core/test/approval-policy.test.mjs:generic-api-key:144 +097fecf41b9b446d12507a92d6fde49c30cad147:packages/core/test/approval-policy.test.mjs:generic-api-key:147 +097fecf41b9b446d12507a92d6fde49c30cad147:packages/core/test/approval-policy.test.mjs:generic-api-key:150 +097fecf41b9b446d12507a92d6fde49c30cad147:packages/core/test/teammate-direct-input.test.mjs:generic-api-key:133 +097fecf41b9b446d12507a92d6fde49c30cad147:packages/daemon/test/host-capability-bridge-controller.test.mjs:generic-api-key:63 +11d853eca620e5d8dbf8f42f9ea538319aeb004f:apps/chrome-extension/manifest.json:generic-api-key:9 +42556e44de4ea88b872ea93056e2e59cda9b67ad:packages/daemon/test/channel-context-processing.test.mjs:curl-auth-header:310 +4333d73adab18d61c004df95c6f44b74997b68a1:apps/desktop/tests/host-capability-daemon-client.test.mjs:generic-api-key:100 +4333d73adab18d61c004df95c6f44b74997b68a1:apps/desktop/tests/host-capability-runtime.test.mjs:generic-api-key:176 +4333d73adab18d61c004df95c6f44b74997b68a1:apps/desktop/tests/host-local-preview-manager.test.mjs:generic-api-key:119 +4333d73adab18d61c004df95c6f44b74997b68a1:packages/core/test/approval-policy.test.mjs:generic-api-key:140 +4333d73adab18d61c004df95c6f44b74997b68a1:packages/core/test/approval-policy.test.mjs:generic-api-key:144 +4333d73adab18d61c004df95c6f44b74997b68a1:packages/core/test/approval-policy.test.mjs:generic-api-key:147 +4333d73adab18d61c004df95c6f44b74997b68a1:packages/core/test/approval-policy.test.mjs:generic-api-key:150 +4333d73adab18d61c004df95c6f44b74997b68a1:packages/core/test/teammate-direct-input.test.mjs:generic-api-key:133 +4333d73adab18d61c004df95c6f44b74997b68a1:packages/daemon/test/host-capability-bridge-controller.test.mjs:generic-api-key:63 +57071bf2b9c45463ca52cef0f2ef2b03035d1c33:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1250 +57071bf2b9c45463ca52cef0f2ef2b03035d1c33:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1251 +57071bf2b9c45463ca52cef0f2ef2b03035d1c33:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1252 +57071bf2b9c45463ca52cef0f2ef2b03035d1c33:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1253 +66db1e3d061e5e0746d44b26cd867b4185a75414:packages/providers/test/media-adapter-contract.test.mjs:generic-api-key:371 +6ddd5e6b75e314cc0fbeee370991c70c7383a0ff:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1250 +6ddd5e6b75e314cc0fbeee370991c70c7383a0ff:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1251 +6ddd5e6b75e314cc0fbeee370991c70c7383a0ff:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1252 +6ddd5e6b75e314cc0fbeee370991c70c7383a0ff:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1253 +743812b31d78ee29df0c446b7168c8b851afd0b9:docs/design/mobile-conversation-ui2-detailed-design.md:generic-api-key:810 +7e42d463bb7fb34cc1c00916ec075fca26a0b635:apps/web/e2e/support/mock-daemon.ts:generic-api-key:7710 +bbd35c2aa6c323fc56a2cad9612dadf17c1a7295:packages/core/test/approval-policy.test.mjs:generic-api-key:132 +be7eebe28643d9257f7779d5755b27096855629d:apps/chrome-extension/manifest.json:generic-api-key:9 +c1005d333b58fee902f55e7caf91752fd6e7a8bc:packages/providers/test/media-adapter-contract.test.mjs:generic-api-key:354 +c41a3ecf43be043c9ce9717e0821ef6cefe00a67:packages/providers/test/media-adapter-contract.test.mjs:generic-api-key:166 +c41a3ecf43be043c9ce9717e0821ef6cefe00a67:packages/providers/test/media-adapter-contract.test.mjs:generic-api-key:215 +c557de47b71e63f59d131a8be395744d256af600:packages/daemon/test/provider-runtime-consumers.test.mjs:generic-api-key:203 +c7e5f841d0fcf5193e6d84eb92507a1f7603802c:packages/providers/test/media-adapter-contract.test.mjs:generic-api-key:290 +d1e6c5a639c2e543cdc09e76c81794265f5c6d43:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1558 +d1e6c5a639c2e543cdc09e76c81794265f5c6d43:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1559 +d1e6c5a639c2e543cdc09e76c81794265f5c6d43:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1560 +d7c79c255009c5767e7931786da8ba86fcc972d7:apps/chrome-extension/manifest.json:generic-api-key:9 +da396aaf35c5ebe8302ff2fb33ef483676f02e00:packages/daemon/test/automation-context-item-budget.test.mjs:generic-api-key:356 +da396aaf35c5ebe8302ff2fb33ef483676f02e00:packages/daemon/test/workspace-snapshot-queue-busy-request.test.mjs:generic-api-key:114 +e949cc3527114979c2717b4afcca389a9658609e:packages/daemon/test/approval-policy-run.test.mjs:generic-api-key:321 +f9f73f6cb5c4e5c13cc16758fb318a69a9de7bae:packages/providers/test/media-adapter-contract.test.mjs:generic-api-key:26 diff --git a/.gstack-redact-allowlist b/.gstack-redact-allowlist new file mode 100644 index 000000000..6433258d5 --- /dev/null +++ b/.gstack-redact-allowlist @@ -0,0 +1,17 @@ +# Negative login-validation fixture, not a usable account or credential. +# packages/daemon/test/codex-login.test.mjs:102 asserts that this URL is rejected. +# Preserve its exact appearance in immutable full-regression logs; no pattern wildcard. +https://user:pass@auth.openai.com + +# Negative marketplace URL-validation fixtures, not usable credentials. +# skills-marketplace-fetch.test.mjs rejects these reserved .test hosts before +# sending credentials; the second span is the literal source template, not a port. +# Exact scanner spans only; no wildcard, global skip, or runtime/test rewrite. +https://user:password@preview.a11.test +https://user:password@preview.a11.test:${fixture.port} + +# Negative URL-scope fixture; localhost credentials are intentionally invalid and +# used only to assert that conversation reading scopes strip URL auth/query data. +# apps/web/test/conversation-reading-storage.test.mjs:18 +# Scanner span ends at the localhost authority before the path/query. +http://user:secret@localhost:4234 diff --git a/apps/desktop/main/cua-driver/coordinator.ts b/apps/desktop/main/cua-driver/coordinator.ts index 26d5c8bb3..352d4870c 100644 --- a/apps/desktop/main/cua-driver/coordinator.ts +++ b/apps/desktop/main/cua-driver/coordinator.ts @@ -169,6 +169,7 @@ export class CuaDriverDesktopCoordinator { private proxy: CuaDriverMcpProxy | undefined; private leasePublisher: CuaDriverLeasePublisher | undefined; private stopped = false; + private hostDraining = false; private cleanupDebt = false; private requestedEnabled = false; private requestedRevision = 0; @@ -276,6 +277,22 @@ export class CuaDriverDesktopCoordinator { this.proxy?.closeCallGate(reason); } + /** + * Terminal Host admission fence. Do not revoke or stop these resources until + * the original Host has actually exited and shutdown performs final cleanup. + */ + beginHostDrain(reason: string): void { + if (this.hostDraining) return; + this.hostDraining = true; + this.requestedEnabled = false; + this.requestedRevision += 1; + this.pendingRecoveryAction = undefined; + this.pendingWindowsSessionSuspension = undefined; + this.windowsSessionResumeHintPending = false; + this.proxy?.beginHostDrain(reason); + this.leasePublisher?.beginHostDrain(); + } + /** Host lock/suspend fence. Retains requested intent while retiring this generation. */ suspendForSystem(reason: string): Promise { if (this.stopped || !this.requestedEnabled) { @@ -297,6 +314,9 @@ export class CuaDriverDesktopCoordinator { /** Validate, bind and publish one generation. Child spawn remains lazy until initialize. */ enable(): Promise { + if (this.hostDraining) { + return Promise.reject(new Error("cua_driver_host_draining")); + } if (this.stopped) { return Promise.reject(new Error("cua_driver_coordinator_stopped")); } @@ -309,6 +329,9 @@ export class CuaDriverDesktopCoordinator { } private enableForRevision(requestedRevision: number): Promise { + if (this.hostDraining) { + return Promise.reject(new Error("cua_driver_host_draining")); + } if (this.stopped) { return Promise.reject(new Error("cua_driver_coordinator_stopped")); } @@ -354,6 +377,9 @@ export class CuaDriverDesktopCoordinator { } private beginEnable(): Promise { + if (this.hostDraining) { + return Promise.reject(new Error("cua_driver_host_draining")); + } if (this.enablePromise) { return this.enablePromise; } @@ -370,6 +396,9 @@ export class CuaDriverDesktopCoordinator { } disable(reason = "disabled"): Promise { + if (this.retainTransportWhileHostDrains()) { + return Promise.reject(new Error("cua_driver_host_draining")); + } this.requestedRevision += 1; this.requestedEnabled = false; this.recoveryAttempt = 0; @@ -400,6 +429,9 @@ export class CuaDriverDesktopCoordinator { } private beginDisable(reason: string): Promise { + if (this.retainTransportWhileHostDrains()) { + return Promise.resolve(); + } if (this.disablePromise) { return this.disablePromise; } @@ -491,6 +523,7 @@ export class CuaDriverDesktopCoordinator { requireReleaseEvidence: this.options.requireReleaseEvidence ?? this.options.packaged ?? false, }; const lock = await readCuaDriverProviderLock(paths.lockPath, lockOptions); + this.assertHostNotDraining(); const artifacts = await verifyCuaDriverProviderArtifacts({ platform: this.options.platform, architecture: this.options.architecture, @@ -499,11 +532,13 @@ export class CuaDriverDesktopCoordinator { platformIdentityVerifier: this.platformIdentityVerifier, requirePlatformIdentityVerification: this.options.packaged === true, }); + this.assertHostNotDraining(); const stateRoot = await createCuaDriverStateRoot({ userDataPath: this.options.userDataPath, platform: this.options.platform, platformProtector: this.stateRootProtector, }); + this.assertHostNotDraining(); this.resourcePaths = paths; this.artifacts = artifacts; this.stateRoot = stateRoot; @@ -561,7 +596,7 @@ export class CuaDriverDesktopCoordinator { } }, onTerminal: ({ reason, unexpected, preSpawnFailureKind }) => { - if (this.host?.generation !== generation) { + if (this.hostDraining || this.host?.generation !== generation) { return; } telemetry.emit(unexpected ? "error" : "info", "cua_driver.coordinator.host_terminal", { generation, reason }); @@ -599,8 +634,9 @@ export class CuaDriverDesktopCoordinator { } }, onManagedReadinessChanged: (message) => { + if (this.hostDraining) return; const readiness = this.readinessController.acceptChanged?.(readinessContext(), message); - if (!readiness || this.host?.generation !== generation) { + if (this.hostDraining || !readiness || this.host?.generation !== generation) { return; } if (readiness.ready && this.windowsSessionSuspended) { @@ -639,7 +675,9 @@ export class CuaDriverDesktopCoordinator { } }, onInitialized: async () => { + if (this.hostDraining) return { callGate: "closed" as const }; const readiness = await this.readinessController.check(readinessContext()); + if (this.hostDraining) return { callGate: "closed" as const }; if (this.host?.generation !== generation) { throw new Error("cua_driver_readiness_generation_stale"); } @@ -656,6 +694,7 @@ export class CuaDriverDesktopCoordinator { ? { windowsReadiness: readiness.windowsReadiness } : {}), }); + if (this.hostDraining) return { callGate: "closed" as const }; } const readinessAction = classifyCuaDriverInitialReadiness(readiness); if (readinessAction !== "open_call_gate") { @@ -705,6 +744,7 @@ export class CuaDriverDesktopCoordinator { }); this.proxy = proxy; const endpoint = await proxy.start(); + this.assertHostNotDraining(); if (this.windowsSessionSuspended) { return this.retainSuspendedGeneration(generation); } @@ -751,6 +791,7 @@ export class CuaDriverDesktopCoordinator { }); this.leasePublisher = publisher; await publisher.publish(); + this.assertHostNotDraining(); if (this.windowsSessionSuspended) { return this.retainSuspendedGeneration(generation); } @@ -766,6 +807,9 @@ export class CuaDriverDesktopCoordinator { }, }); } catch (error) { + if (this.hostDraining) { + throw error; + } if (this.windowsSessionSuspended) { return this.retainSuspendedGeneration(generation); } @@ -777,9 +821,12 @@ export class CuaDriverDesktopCoordinator { } private async retainSuspendedGeneration(generation: number): Promise { + this.assertHostNotDraining(); const reason = this.windowsSessionSuspensionReason; await this.fenceProviderBeforeSessionBoundary(reason, generation); + this.assertHostNotDraining(); await this.cleanupPartiallyStarted(reason); + this.assertHostNotDraining(); return this.publishSnapshot({ lifecycle: "included", providerState: "interactive_session_required", @@ -789,9 +836,11 @@ export class CuaDriverDesktopCoordinator { } private async disableInternal(reason: string): Promise { + if (this.retainTransportWhileHostDrains()) return; if (this.enablePromise) { await this.enablePromise.catch(() => undefined); } + if (this.retainTransportWhileHostDrains()) return; const publisher = this.leasePublisher; const proxy = this.proxy; const host = this.host; @@ -800,6 +849,7 @@ export class CuaDriverDesktopCoordinator { if (publisher || proxy || host) { this.publishSnapshot({ lifecycle: "stopping", providerState: "desktop_unavailable", generation: this.generation, reason }); } + if (this.retainTransportWhileHostDrains()) return; let leaseError: unknown; let cleanupError: unknown; try { @@ -807,11 +857,13 @@ export class CuaDriverDesktopCoordinator { } catch (error) { leaseError = error; } + if (this.retainTransportWhileHostDrains()) return; try { await proxy?.close(reason); } catch (error) { cleanupError = cleanupError ?? error; } + if (this.retainTransportWhileHostDrains()) return; if (host) { try { await host.stop(reason); @@ -843,11 +895,13 @@ export class CuaDriverDesktopCoordinator { } private async cleanupPartiallyStarted(reason: string): Promise { + if (this.retainTransportWhileHostDrains()) return; const results = await Promise.allSettled([ this.leasePublisher?.revoke(), this.proxy?.close(reason), this.host?.stop(reason), ]); + if (this.retainTransportWhileHostDrains()) return; if (results.some((result) => result.status === "rejected")) { this.cleanupDebt = true; return; @@ -968,8 +1022,10 @@ export class CuaDriverDesktopCoordinator { private startWindowsSessionSuspensionCleanup(reason: string, generation: number): void { const pending = Promise.resolve().then(async () => { + if (this.hostDraining) return; try { await this.fenceProviderBeforeSessionBoundary(reason, generation); + if (this.hostDraining) return; await this.beginDisable(reason); if (this.requestedEnabled && !this.stopped && !this.cleanupDebt && this.windowsSessionSuspended) { this.publishSnapshot({ @@ -990,12 +1046,14 @@ export class CuaDriverDesktopCoordinator { } private async fenceProviderBeforeSessionBoundary(reason: string, generation: number): Promise { + if (this.hostDraining) return; const host = this.host; if (!host || host.generation !== generation) return; if (typeof host.fenceInput !== "function") return; try { await host.fenceInput(); } catch (error) { + if (this.hostDraining) return; const fenceReason = error instanceof CuaDriverInputFenceError && error.fenceCode === "timeout" ? "locked_control_input_fence_timeout" : "locked_control_input_unavailable"; @@ -1130,7 +1188,7 @@ export class CuaDriverDesktopCoordinator { generation: number, providerState: Extract, ): void { - if (this.host?.generation !== generation) { + if (this.hostDraining || this.host?.generation !== generation) { return; } this.proxy?.closeCallGate(reason); @@ -1217,10 +1275,19 @@ export class CuaDriverDesktopCoordinator { } private isCurrentEnableIntent(requestedRevision: number): boolean { - return this.requestedEnabled && this.requestedRevision === requestedRevision; + return !this.hostDraining && this.requestedEnabled && this.requestedRevision === requestedRevision; + } + + private retainTransportWhileHostDrains(): boolean { + return this.hostDraining && !this.stopped; + } + + private assertHostNotDraining(): void { + if (this.hostDraining) throw new Error("cua_driver_host_draining"); } private publishSnapshot(input: Omit & { readonly generation?: number }): CuaDriverCoordinatorSnapshot { + if (this.retainTransportWhileHostDrains()) return this.snapshotValue; const integrity: CuaDriverIntegrityStatus = input.integrity ?? (input.providerState === "corrupt" ? "failed" diff --git a/apps/desktop/main/cua-driver/lease-publisher.ts b/apps/desktop/main/cua-driver/lease-publisher.ts index afeea1d01..a7b2f3bea 100644 --- a/apps/desktop/main/cua-driver/lease-publisher.ts +++ b/apps/desktop/main/cua-driver/lease-publisher.ts @@ -127,6 +127,7 @@ export class CuaDriverLeasePublisher { private revokePromise: Promise | undefined; private active = false; private closed = false; + private hostDraining = false; private abortController = new AbortController(); private lastLeaseValue: DesktopCuaDriverLeaseV1 | undefined; private operationEpoch = 0; @@ -146,9 +147,19 @@ export class CuaDriverLeasePublisher { return this.lastLeaseValue; } + /** Stop advertising new work without revoking the original transport. */ + beginHostDrain(): void { + this.hostDraining = true; + this.active = false; + this.clearTimer(); + } + publishReadinessDetails( details: CuaDriverLeaseReadinessDetails, ): Promise { + if (this.hostDraining) { + return Promise.reject(new CuaDriverLeasePublisherError("lease_publisher_draining")); + } if (this.closed) { return Promise.reject(new CuaDriverLeasePublisherError("lease_publisher_closed")); } @@ -160,6 +171,9 @@ export class CuaDriverLeasePublisher { } publish(): Promise { + if (this.hostDraining) { + return Promise.reject(new CuaDriverLeasePublisherError("lease_publisher_draining")); + } if (this.closed) { return Promise.reject(new CuaDriverLeasePublisherError("lease_publisher_closed")); } @@ -216,7 +230,7 @@ export class CuaDriverLeasePublisher { generation: lease.generation, error: error instanceof Error ? error.message : String(error), }); - if (!this.closed && epoch === this.operationEpoch) { + if (!this.closed && !this.hostDraining && epoch === this.operationEpoch) { try { this.options.onLeaseLost?.(error); } catch { @@ -225,7 +239,7 @@ export class CuaDriverLeasePublisher { } throw error; } - this.active = true; + this.active = !this.hostDraining; this.lastLeaseValue = lease; this.scheduleRenewal(); this.telemetry.emit("info", "cua_driver.lease.published", { generation: lease.generation, expiresAt: lease.expiresAt }); @@ -234,7 +248,7 @@ export class CuaDriverLeasePublisher { private scheduleRenewal(): void { this.clearTimer(); - if (!this.active || this.closed) { + if (!this.active || this.closed || this.hostDraining) { return; } this.timer = setTimeout(() => { diff --git a/apps/desktop/main/cua-driver/mcp-proxy.ts b/apps/desktop/main/cua-driver/mcp-proxy.ts index a63c7ded0..21ec74436 100644 --- a/apps/desktop/main/cua-driver/mcp-proxy.ts +++ b/apps/desktop/main/cua-driver/mcp-proxy.ts @@ -109,6 +109,7 @@ export class CuaDriverMcpProxy { private readonly server: Server; private readonly token: string; private readonly sockets = new Set(); + private readonly activeClientRequests = new Map(); private endpointValue: CuaDriverMcpProxyEndpoint | undefined; private startPromise: Promise | undefined; private closePromise: Promise | undefined; @@ -123,6 +124,7 @@ export class CuaDriverMcpProxy { private sseDrainListener: (() => void) | undefined; private removeHostListener: (() => void) | undefined; private closed = false; + private hostDraining = false; private callGateOpen = false; private callGateClosedReason: string | undefined; private closingEpoch = 0; @@ -169,9 +171,15 @@ export class CuaDriverMcpProxy { }); } + /** Irreversible admission fence; accepted exchanges retain their original host. */ + beginHostDrain(reason = "host_draining"): void { + this.hostDraining = true; + this.closeCallGate(reason); + } + /** Re-open one retained same-generation session after readiness recovers. */ openCallGate(): void { - if (this.closed) { + if (this.closed || this.hostDraining) { return; } const wasOpen = this.callGateOpen; @@ -186,6 +194,9 @@ export class CuaDriverMcpProxy { } start(): Promise { + if (this.hostDraining) { + return Promise.reject(new CuaDriverMcpProxyError("mcp_host_draining")); + } if (this.endpointValue) { return Promise.resolve(this.endpointValue); } @@ -311,6 +322,10 @@ export class CuaDriverMcpProxy { const isInitialize = "method" in message && message.method === "initialize"; const requestSessionId = readSessionId(request); if (isInitialize) { + if (this.hostDraining) { + this.writeJson(response, 503, { error: "mcp_host_draining" }); + return; + } const initializeEpoch = this.closingEpoch; this.assertOpenEpoch(initializeEpoch); if (requestSessionId) { @@ -356,13 +371,18 @@ export class CuaDriverMcpProxy { try { disposition = await this.options.onInitialized?.(); } catch (error) { - await this.options.host.stop("readiness_failed").catch(() => undefined); - throw error; + if (!this.hostDraining) { + await this.options.host.stop("readiness_failed").catch(() => undefined); + throw error; + } + // Readiness no longer admits work while draining. Its late failure + // must not tear down the host of an already accepted exchange. + disposition = { callGate: "closed" }; } // Final async-boundary fence before the session and authorization // response become visible to the daemon. this.assertOpenEpoch(initializeEpoch); - this.callGateOpen = disposition?.callGate !== "closed"; + this.callGateOpen = !this.hostDraining && disposition?.callGate !== "closed"; this.callGateClosedReason = this.callGateOpen ? undefined : this.callGateClosedReason ?? "readiness_pending"; @@ -385,7 +405,15 @@ export class CuaDriverMcpProxy { this.writeJson(response, 409, { error: "mcp_session_required" }); return; } - if (!this.callGateOpen && !isInitializedNotification) { + const isRetainedClientReply = this.hostDraining && !("method" in message); + const isActiveCancellation = this.hostDraining + && "method" in message + && message.method === "notifications/cancelled" + && !("id" in message) + && isRecord(message.params) + && "requestId" in message.params + && this.activeClientRequests.has(message.params.requestId as string | number | null); + if (!this.callGateOpen && !isRetainedClientReply && !isActiveCancellation && (!isInitializedNotification || this.hostDraining)) { if ("id" in message) { this.writeJson(response, 200, { jsonrpc: "2.0", @@ -401,7 +429,7 @@ export class CuaDriverMcpProxy { } return; } - const responseValue = await this.options.host.exchange(message); + const responseValue = await this.exchangeAcceptedMessage(message); if (responseValue === undefined) { response.writeHead(202, { "mcp-session-id": this.sessionId, "cache-control": "no-store" }); response.end(); @@ -410,6 +438,21 @@ export class CuaDriverMcpProxy { } } + private async exchangeAcceptedMessage(message: JsonRpcClientMessage): Promise { + if (!("method" in message) || !("id" in message)) { + return this.options.host.exchange(message); + } + const id = message.id; + this.activeClientRequests.set(id, (this.activeClientRequests.get(id) ?? 0) + 1); + try { + return await this.options.host.exchange(message); + } finally { + const remaining = (this.activeClientRequests.get(id) ?? 1) - 1; + if (remaining === 0) this.activeClientRequests.delete(id); + else this.activeClientRequests.set(id, remaining); + } + } + private handleGet(request: IncomingMessage, response: ServerResponse): void { if (!this.sessionId || !this.sessionInitialized || readSessionId(request) !== this.sessionId) { this.writeJson(response, 409, { error: "mcp_session_required" }); @@ -429,6 +472,11 @@ export class CuaDriverMcpProxy { this.enqueueSseFrame(": ling-cua-driver\n\n"); response.once("close", () => { if (this.sseResponse === response) { + if (this.hostDraining) { + this.clearSseQueue(); + this.sseResponse = undefined; + return; + } if (this.retainGatedSession && this.initializeResult !== undefined) { this.detachGatedSession(); this.telemetry.emit("info", "cua_driver.proxy.gated_session_detached", { @@ -452,6 +500,10 @@ export class CuaDriverMcpProxy { this.writeJson(response, 409, { error: "mcp_session_required" }); return; } + if (this.hostDraining) { + this.writeJson(response, 409, { error: "mcp_host_draining" }); + return; + } if (this.retainGatedSession && this.initializeResult !== undefined) { this.detachGatedSession(); response.writeHead(204, { "cache-control": "no-store" }); @@ -578,6 +630,13 @@ export class CuaDriverMcpProxy { } private revokeSseSession(reason: string): void { + if (this.hostDraining) { + const response = this.sseResponse; + this.clearSseQueue(); + this.sseResponse = undefined; + response?.end(); + return; + } this.invalidateSession(reason); void this.options.host.stop(reason).catch(() => undefined); } diff --git a/apps/desktop/main/daemon-lifecycle.ts b/apps/desktop/main/daemon-lifecycle.ts index 318e92150..5f6e7242a 100644 --- a/apps/desktop/main/daemon-lifecycle.ts +++ b/apps/desktop/main/daemon-lifecycle.ts @@ -2,6 +2,8 @@ import { spawn, type ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; import { Readable } from "node:stream"; import { readExecutionEngineHostAuthority, type ExecutionEngineHostAuthority } from "@ling-stack/core/execution-engine-host-authority"; +import { DAEMON_RESOURCE_BOOTSTRAP_TIMEOUT_MS, DaemonResourceBootstrapError, readDaemonResourceBootstrap } from "@ling-stack/core/daemon-resource-bootstrap"; +import { observeDaemonShutdown, type DaemonShutdownPhase } from "@ling-stack/core/daemon-shutdown-observer"; import { access, mkdir } from "node:fs/promises"; import { connect } from "node:net"; import { resolve as resolvePath } from "node:path"; @@ -20,16 +22,15 @@ import { import { deriveMobileAccessManagementCapability } from "./mobile-access/authority.js"; const DEFAULT_DAEMON_STARTUP_TIMEOUT_MS = 20_000; -// Packaged startup verifies the complete bundled native/skill inputs before -// publishing readiness or FD3 authority; observed starts take 32–52 seconds. -const PACKAGED_DAEMON_STARTUP_TIMEOUT_MS = 120_000; export type DesktopDaemonLifecycleErrorCode = | "bridge_incompatible" | "child_exited" | "start_timeout" | "resource_missing" - | "spawn_failed"; + | "spawn_failed" + | "stop_blocked" + | "stop_unconfirmed"; export type DesktopDaemonPortOccupant = "foreign_service" | "ling_daemon"; type DaemonProbeResult = "compatible" | "incompatible" | "occupied" | "unavailable"; @@ -47,10 +48,24 @@ export interface DesktopDaemonOwnedExit { readonly signal: NodeJS.Signals | null; } +export type DesktopDaemonStopResult = Readonly< + | { kind: "owned_stopped"; pid: number | undefined; code: 0; signal: null } + | { kind: "not_owned" } +>; + export type DesktopDaemonUnexpectedExit = DesktopDaemonOwnedExit & { readonly baseUrl: string; }; +interface OwnedDaemonShutdown { + readonly child: ChildProcess; + readonly listeners: Set<() => void>; + stopRequested: boolean; + phase?: DaemonShutdownPhase; + exit?: DesktopDaemonOwnedExit; + disposeObserver?: () => void; +} + export class DesktopDaemonLifecycleError extends Error { constructor( readonly code: DesktopDaemonLifecycleErrorCode, @@ -101,26 +116,25 @@ export class DesktopDaemonLifecycle { private readonly defaultWorkspaceCwd: string; private readonly cuaDriverFeatureFlags: CuaDriverFeatureFlags; private readonly bundledCuaDriverGateEnabled: boolean; - private readonly startupTimeoutMs: number; private child: ChildProcess | undefined; + private ownedShutdown: OwnedDaemonShutdown | undefined; + private ownedStopResult: DesktopDaemonStopResult | undefined; private engineAuthority: { readonly child: ChildProcess; - readonly channel: Readable | undefined; readonly result: Promise; + readonly abort: AbortController; } | undefined; private statusValue: DesktopDaemonStatus; private cuaDriverAuthorityTupleValue: DesktopDaemonCuaDriverAuthorityTuple | undefined; private cuaDriverAuthorityGeneration = 0; private ensureStartedPromise: Promise | undefined; private restartPromise: Promise | undefined; - private stopOwnedDaemonPromise: Promise | undefined; + private stopOwnedDaemonPromise: Promise | undefined; private startupGeneration = 0; private statusGeneration = 0; constructor(private readonly options: DesktopDaemonLifecycleOptions) { this.fetchImpl = options.fetch ?? globalThis.fetch; - this.startupTimeoutMs = options.startupTimeoutMs - ?? (options.packaged ? PACKAGED_DAEMON_STARTUP_TIMEOUT_MS : DEFAULT_DAEMON_STARTUP_TIMEOUT_MS); this.defaultWorkspaceCwd = resolvePath(options.defaultWorkspaceCwd); this.cuaDriverFeatureFlags = options.cuaDriverFeatureFlags ?? resolveDesktopCuaDriverFeatureFlags(); this.bundledCuaDriverGateEnabled = desktopBundledCuaDriverGateEnabled( @@ -163,6 +177,12 @@ export class DesktopDaemonLifecycle { } ensureStarted(): Promise { + if (this.stopOwnedDaemonPromise) { + return this.stopOwnedDaemonPromise.then(() => this.ensureStarted()); + } + if (this.ownedShutdown?.stopRequested || this.ownedShutdown?.phase) { + return Promise.reject(this.stopError(this.ownedShutdown)); + } if ( this.statusValue.state === "connected" && !this.stopOwnedDaemonPromise @@ -203,6 +223,7 @@ export class DesktopDaemonLifecycle { const existingProbe = await this.probeDaemon(); this.assertStartupCurrent(startupGeneration); if (existingProbe === "compatible") { + this.ownedStopResult = undefined; this.options.onDiagnostic?.("daemon.reused", { baseUrl: this.options.baseUrl }); return this.update({ state: "connected", baseUrl: this.options.baseUrl, ownedByDesktop: false }); } @@ -257,6 +278,8 @@ export class DesktopDaemonLifecycle { LING_STACK_PORT: url.port || (url.protocol === "https:" ? "443" : "80"), LING_STACK_DEFAULT_WORKSPACE_CWD: this.defaultWorkspaceCwd, LING_STACK_ENGINE_AUTHORITY_FD: "3", + LING_STACK_RESOURCE_BOOTSTRAP_FD: this.options.packaged ? "4" : undefined, + LING_STACK_SHUTDOWN_OBSERVER_FD: "5", // Desktop is the trusted local host for command execution. The // daemon still applies its approval policy and persists validated // command evidence; this only makes the bash surface available to @@ -277,23 +300,43 @@ export class DesktopDaemonLifecycle { } catch (error) { this.failOwnedStartup("spawn_failed", "Desktop could not launch the local daemon process.", error); } - if (!this.isStartupCurrent(startupGeneration)) { - await this.stopChild(child); - throw new Error("daemon_start_cancelled"); - } - this.options.onDiagnostic?.("daemon.spawned", { - pid: child.pid ?? null, baseUrl: this.options.baseUrl, startupTimeoutMs: this.startupTimeoutMs, - }); + this.options.onDiagnostic?.("daemon.spawned", { pid: child.pid ?? null, baseUrl: this.options.baseUrl }); this.child = child; - const startupDeadline = Date.now() + this.startupTimeoutMs; - const pipe = child.stdio?.[3]; - const channel = pipe instanceof Readable ? pipe : undefined; + this.ownedStopResult = undefined; + const ownedShutdown: OwnedDaemonShutdown = { child, listeners: new Set(), stopRequested: false }; + this.ownedShutdown = ownedShutdown; + const shutdownChannel = (child.stdio as readonly unknown[] | undefined)?.[5]; + if (shutdownChannel instanceof Readable && child.pid) { + ownedShutdown.disposeObserver = observeDaemonShutdown({ channel: shutdownChannel, pid: child.pid, + onPhase: phase => { + if (this.ownedShutdown !== ownedShutdown || ownedShutdown.exit) return; + if (phase === "draining") { + this.startupGeneration += 1; + this.ensureStartedPromise = undefined; + this.clearCuaDriverAuthorityTuple(); + } + ownedShutdown.phase = phase; + this.publishStopping(ownedShutdown, phase === "blocked" ? "stop_blocked" : undefined); + for (const changed of ownedShutdown.listeners) changed(); + }, + onUnavailable: code => { + if (this.ownedShutdown !== ownedShutdown || ownedShutdown.exit) return; + // EOF can precede a successful child exit. It is never success, but + // neither should it overtake that actual exit with a false failure. + this.options.onDiagnostic?.("daemon.shutdown_observer_unavailable", { code, pid: child.pid }); + }, + }); + } + const channel = child.stdio?.[3]; + const startupAbort = new AbortController(); + const serviceStartupTimeoutMs = this.options.startupTimeoutMs ?? DEFAULT_DAEMON_STARTUP_TIMEOUT_MS; this.engineAuthority = { child, - channel, - result: channel && child.pid + abort: startupAbort, + result: channel instanceof Readable && child.pid ? readExecutionEngineHostAuthority({ channel, baseUrl: this.options.baseUrl, pid: child.pid, - timeoutMs: Math.max(0, startupDeadline - Date.now()) }).catch(() => undefined) + timeoutMs: serviceStartupTimeoutMs + (this.options.packaged ? DAEMON_RESOURCE_BOOTSTRAP_TIMEOUT_MS : 0), + signal: startupAbort.signal }).catch(() => undefined) : Promise.resolve(undefined), }; let resolveChildExit: (exit: DesktopDaemonOwnedExit) => void = () => undefined; @@ -306,10 +349,19 @@ export class DesktopDaemonLifecycle { resolveChildError = resolve; }); child.once("error", (error) => { + if (this.child === child && (ownedShutdown.stopRequested || ownedShutdown.phase)) { + this.publishStopping(ownedShutdown, "stop_unconfirmed"); + for (const changed of ownedShutdown.listeners) changed(); + resolveChildError(error); + return; + } + startupAbort.abort(); childSpawnError = error; if (this.child === child) { this.child = undefined; - this.clearExecutionEngineAuthority(); + ownedShutdown.disposeObserver?.(); + this.ownedShutdown = undefined; + this.engineAuthority = undefined; this.update({ state: "disconnected", baseUrl: this.options.baseUrl, @@ -325,13 +377,36 @@ export class DesktopDaemonLifecycle { resolveChildError(error); }); child.once("exit", (code, signal) => { + startupAbort.abort(); const exit = { code, signal } satisfies DesktopDaemonOwnedExit; + ownedShutdown.exit = exit; + ownedShutdown.disposeObserver?.(); if (this.child !== child) { resolveChildExit(exit); return; } + if (ownedShutdown.stopRequested || ownedShutdown.phase) { + if (code === 0 && signal === null) { + // Keep the actual exit receipt for an explicit retry after a finite + // observation timeout. A disconnected status alone is not proof. + this.ownedStopResult = Object.freeze({ kind: "owned_stopped", pid: child.pid, code: 0, signal: null }); + this.child = undefined; + this.ownedShutdown = undefined; + this.engineAuthority = undefined; + this.clearCuaDriverAuthorityTuple(); + this.update({ state: "disconnected", baseUrl: this.options.baseUrl, ownedByDesktop: false }); + } else { + // Keep a tombstone owner: an abnormal exit while stopping is not a + // stopped receipt and must not enable an automatic successor. + this.publishStopping(ownedShutdown, "stop_unconfirmed"); + } + for (const changed of ownedShutdown.listeners) changed(); + resolveChildExit(exit); + return; + } this.child = undefined; - this.clearExecutionEngineAuthority(); + this.ownedShutdown = undefined; + this.engineAuthority = undefined; this.clearCuaDriverAuthorityTuple(); this.update({ state: "disconnected", @@ -356,22 +431,61 @@ export class DesktopDaemonLifecycle { resolveChildExit(exit); }); - // Readiness and FD3 share the same bounded verification window. Neither - // readiness nor authority is granted merely because this budget is longer. - while (Date.now() < startupDeadline) { + if (!this.isStartupCurrent(startupGeneration)) { + await this.stopChild(child); + throw new Error("daemon_start_cancelled"); + } + + if (this.options.packaged) { + this.options.onDiagnostic?.("daemon.resources_preparing", { pid: child.pid ?? null }); + const resourceChannel = child.stdio?.[4]; + try { + if (!(resourceChannel instanceof Readable) || !child.pid) { + throw new DaemonResourceBootstrapError("daemon_resource_bootstrap_unavailable"); + } + await readDaemonResourceBootstrap({ channel: resourceChannel, pid: child.pid, signal: startupAbort.signal }); + this.assertStartupCurrent(startupGeneration); + if (childSpawnError || this.child !== child || child.exitCode !== null || child.signalCode !== null) { + throw new Error("daemon_resource_bootstrap_child_failed"); + } + } catch (error) { + this.assertStartupCurrent(startupGeneration); + if (childSpawnError) throw new DesktopDaemonLifecycleError("spawn_failed", this.options.baseUrl); + if (this.child !== child || child.exitCode !== null || child.signalCode !== null) { + throw new DesktopDaemonLifecycleError("child_exited", this.options.baseUrl); + } + const timedOut = error instanceof DaemonResourceBootstrapError && error.code === "daemon_resource_bootstrap_timeout"; + await this.stopOwnedDaemon(); + this.options.onDiagnostic?.(timedOut ? "daemon.resources_timeout" : "daemon.resources_failed", { + pid: child.pid ?? null, + }); + const code = timedOut ? "start_timeout" : "resource_missing"; + this.update({ state: "disconnected", baseUrl: this.options.baseUrl, ownedByDesktop: false, + reason: code, message: timedOut ? "Packaged resource authentication exceeded its bootstrap deadline." + : "Packaged resources did not provide a valid bootstrap receipt." }); + throw new DesktopDaemonLifecycleError(code, this.options.baseUrl); + } + this.options.onDiagnostic?.("daemon.resources_ready", { pid: child.pid }); + } + + // Keep ordinary startup bounded. Callers that intentionally exercise a + // translated cold-start path may supply a larger budget without relaxing + // native product behavior. + const deadline = Date.now() + serviceStartupTimeoutMs; + while (Date.now() < deadline) { this.assertStartupCurrent(startupGeneration); const readiness = await Promise.race([ this.probeDaemon().then((probe) => ({ kind: "probe" as const, probe })), childExit.then((exit) => ({ kind: "child_exit" as const, exit })), childError.then((error) => ({ kind: "child_error" as const, error })), ]); + this.assertStartupCurrent(startupGeneration); if (readiness.kind === "child_error") { throw new DesktopDaemonLifecycleError("spawn_failed", this.options.baseUrl); } if (readiness.kind === "child_exit") { throw new DesktopDaemonLifecycleError("child_exited", this.options.baseUrl); } - this.assertStartupCurrent(startupGeneration); const probe = readiness.probe; if (probe === "compatible") { if (childSpawnError) { @@ -400,13 +514,13 @@ export class DesktopDaemonLifecycle { childExit.then((exit) => ({ kind: "child_exit" as const, exit })), childError.then((error) => ({ kind: "child_error" as const, error })), ]); + this.assertStartupCurrent(startupGeneration); if (pause.kind === "child_error") { throw new DesktopDaemonLifecycleError("spawn_failed", this.options.baseUrl); } if (pause.kind === "child_exit") { throw new DesktopDaemonLifecycleError("child_exited", this.options.baseUrl); } - this.assertStartupCurrent(startupGeneration); } if (childSpawnError) { throw new DesktopDaemonLifecycleError("spawn_failed", this.options.baseUrl); @@ -489,6 +603,7 @@ export class DesktopDaemonLifecycle { continue; } if (probe === "compatible") { + this.ownedStopResult = undefined; return this.update({ state: "connected", baseUrl: this.options.baseUrl, @@ -521,7 +636,7 @@ export class DesktopDaemonLifecycle { } } - stopOwnedDaemon(): Promise { + stopOwnedDaemon(): Promise { this.clearCuaDriverAuthorityTuple(); const stopStatusGeneration = ++this.statusGeneration; this.startupGeneration += 1; @@ -529,9 +644,11 @@ export class DesktopDaemonLifecycle { let pending = this.stopOwnedDaemonPromise; if (!pending) { const child = this.child; - this.child = undefined; - this.clearExecutionEngineAuthority(); - pending = child ? this.stopChild(child) : Promise.resolve(); + let resolve!: (result: DesktopDaemonStopResult) => void; + let reject!: (error: unknown) => void; + pending = new Promise((done, failed) => { resolve = done; reject = failed; }); + // State listeners are synchronous and may request stop again. Install + // single-flight before stopChild publishes state or sends any signal. this.stopOwnedDaemonPromise = pending; pending.then( () => { @@ -545,25 +662,80 @@ export class DesktopDaemonLifecycle { } }, ); + try { + void (child + ? this.stopChild(child).then((): DesktopDaemonStopResult => Object.freeze({ + kind: "owned_stopped", pid: child.pid, code: 0, signal: null, + })) + : Promise.resolve(this.ownedStopResult ?? Object.freeze({ kind: "not_owned" } as const))) + .then(resolve, reject); + } + catch (error) { reject(error); } } - return pending.then(() => { + return pending.then(result => { if (stopStatusGeneration === this.statusGeneration) { this.update({ state: "disconnected", baseUrl: this.options.baseUrl, ownedByDesktop: false }); } + return result; }); } private async stopChild(child: ChildProcess): Promise { - if (child.exitCode !== null || child.signalCode !== null) { - return; - } - child.kill("SIGTERM"); - const exited = await waitForExit(child, this.options.shutdownTimeoutMs ?? 3_000); - if (!exited && child.exitCode === null && child.signalCode === null) { - this.options.onDiagnostic?.("daemon.force_killed", { pid: child.pid ?? null }); - child.kill("SIGKILL"); - await waitForExit(child, 1_000); + const owner = this.ownedShutdown; + if (!owner || owner.child !== child) throw new DesktopDaemonLifecycleError("stop_unconfirmed", this.options.baseUrl); + if (owner.exit) { + if (owner.exit.code === 0 && owner.exit.signal === null) return; + throw this.stopError(owner); } + if (owner.phase === "blocked" || this.statusValue.reason === "stop_unconfirmed") throw this.stopError(owner); + const signalNeeded = !owner.stopRequested && !owner.phase; + owner.stopRequested = true; + this.publishStopping(owner); + await new Promise((resolve, reject) => { + let finished = false; + const finish = (error?: DesktopDaemonLifecycleError): void => { + if (finished) return; + finished = true; + clearTimeout(timer); + owner.listeners.delete(changed); + if (error) reject(error); else resolve(); + }; + const changed = (): void => { + if (owner.exit?.code === 0 && owner.exit.signal === null) finish(); + else if (owner.exit || owner.phase === "blocked" || this.statusValue.reason === "stop_unconfirmed") finish(this.stopError(owner)); + }; + const timer = setTimeout(() => { + this.publishStopping(owner, "stop_unconfirmed"); + finish(this.stopError(owner)); + }, this.options.shutdownTimeoutMs ?? 3_000); + owner.listeners.add(changed); + if (signalNeeded) { + try { + if (!child.kill("SIGTERM")) { + this.publishStopping(owner, "stop_unconfirmed"); + finish(this.stopError(owner)); + } + } catch { + this.publishStopping(owner, "stop_unconfirmed"); + finish(this.stopError(owner)); + } + } + changed(); + }); + } + + private stopError(owner: OwnedDaemonShutdown): DesktopDaemonLifecycleError { + return new DesktopDaemonLifecycleError(owner.phase === "blocked" ? "stop_blocked" : "stop_unconfirmed", this.options.baseUrl); + } + + private publishStopping(owner: OwnedDaemonShutdown, reason?: "stop_blocked" | "stop_unconfirmed"): void { + if (this.ownedShutdown !== owner) return; + this.update({ state: reason ? "blocked" : "draining", baseUrl: this.options.baseUrl, ownedByDesktop: true, + ...(reason ? { reason } : {}), + message: reason === "stop_blocked" ? "Host stop was refused; original ownership is retained." + : reason === "stop_unconfirmed" ? "Host stop is unconfirmed; automatic restart is withheld." + : "Host is draining its original executions.", + }); } private isStartupCurrent(startupGeneration: number): boolean { @@ -572,6 +744,7 @@ export class DesktopDaemonLifecycle { private assertStartupCurrent(startupGeneration: number): void { if (!this.isStartupCurrent(startupGeneration)) { + if (this.ownedShutdown?.stopRequested || this.ownedShutdown?.phase) throw this.stopError(this.ownedShutdown); throw new Error("daemon_start_cancelled"); } } @@ -659,14 +832,6 @@ export class DesktopDaemonLifecycle { this.cuaDriverAuthorityTupleValue = undefined; } - private clearExecutionEngineAuthority(): void { - const authority = this.engineAuthority; - this.engineAuthority = undefined; - // Release the pending reader immediately, including when startup is - // cancelled before the child has completed packaged verification. - authority?.channel?.destroy(); - } - private async probeConfiguredPort(): Promise { const url = new URL(this.options.baseUrl); const probePort = this.options.probePort ?? defaultProbePort; @@ -685,7 +850,17 @@ export class DesktopDaemonLifecycle { private update(status: DesktopDaemonStatus): DesktopDaemonStatus { this.statusValue = status; - this.events.emit("status", status); + // Observers cannot prevent SIGTERM/stop waiting, crash a timeout callback, + // or hide the same Host fact from the remaining observers. + for (const listener of this.events.rawListeners("status")) { + try { + listener.call(this.events, status); + } catch { + try { + this.options.onDiagnostic?.("daemon.status_observer_failed", { state: status.state }); + } catch { /* Observer diagnostics do not change the stop proof. */ } + } + } return status; } } @@ -713,7 +888,7 @@ function defaultSpawnDaemon(input: { return spawn(input.executable, [...input.args], { cwd: input.cwd, env: input.env, - stdio: ["ignore", "ignore", "ignore", "pipe"], + stdio: ["ignore", "ignore", "ignore", "pipe", input.env.LING_STACK_RESOURCE_BOOTSTRAP_FD === "4" ? "pipe" : "ignore", "pipe"], windowsHide: true, }); } @@ -768,30 +943,13 @@ function lifecycleErrorMessage( return `daemon_resource_missing: ${baseUrl}`; case "spawn_failed": return `daemon_spawn_failed: ${baseUrl}`; + case "stop_blocked": + return `daemon_stop_blocked: ${baseUrl}`; + case "stop_unconfirmed": + return `daemon_stop_unconfirmed: ${baseUrl}`; } } function wait(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } - -function waitForExit(child: ChildProcess, timeoutMs: number): Promise { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve(true); - } - return new Promise((resolve) => { - let settled = false; - const finish = (exited: boolean): void => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - child.off("exit", onExit); - resolve(exited); - }; - const onExit = (): void => finish(true); - const timer = setTimeout(() => finish(false), timeoutMs); - child.once("exit", onExit); - }); -} diff --git a/apps/desktop/main/daemon-startup-recovery.ts b/apps/desktop/main/daemon-startup-recovery.ts index c21240abf..928089fc7 100644 --- a/apps/desktop/main/daemon-startup-recovery.ts +++ b/apps/desktop/main/daemon-startup-recovery.ts @@ -39,6 +39,9 @@ export async function ensureDesktopDaemonWithStartupRecovery(input: { if (!input.allowInteractiveRecovery || !(error instanceof DesktopDaemonLifecycleError)) { throw error; } + // A retained original Host is not a recoverable startup failure; retry + // must not create a successor or offer a quit that discards stop proof. + if (error.code === "stop_blocked" || error.code === "stop_unconfirmed") throw error; if (error.code === "bridge_incompatible") { const decision = await input.prompt({ baseUrl: error.baseUrl, diff --git a/apps/desktop/main/desktop-runtime.ts b/apps/desktop/main/desktop-runtime.ts index da00a27f8..c761bcc79 100644 --- a/apps/desktop/main/desktop-runtime.ts +++ b/apps/desktop/main/desktop-runtime.ts @@ -32,9 +32,11 @@ import { } from "./cua-driver/platform-binding.js"; import type { CuaDriverDesktopPlatformOwner } from "./cua-driver/platform-owner.js"; import { createCuaDriverTelemetry } from "./cua-driver/telemetry.js"; -import type { - DesktopDaemonCuaDriverAuthorityTuple, - DesktopDaemonLifecycle, +import { + DesktopDaemonLifecycleError, + type DesktopDaemonCuaDriverAuthorityTuple, + type DesktopDaemonLifecycle, + type DesktopDaemonStopResult, } from "./daemon-lifecycle.js"; import { createLockedControlCapabilityAttestor } from "./locked-computer-control/capability-attestor.js"; import { createLockedControlDaemonClient } from "./locked-computer-control/daemon-client.js"; @@ -84,6 +86,7 @@ export interface DesktopRuntimeCuaDriverCoordinator { resumeForSystem?(): Promise; readonly snapshot?: CuaDriverCoordinatorSnapshot; closeCallGateForSecurityBoundary?(reason: string): void; + beginHostDrain?(reason: string): void; shutdown(): Promise; } @@ -159,6 +162,12 @@ export class DesktopRuntime { private restartPromise: Promise | undefined; private stopDaemonStatusObserver: (() => void) | undefined; private shutdownRequested = false; + private hostDraining = false; + private hostStopConfirmed = false; + private hostStopResult: DesktopDaemonStopResult | undefined; + private hostDrainFailure: unknown; + private prepareShutdownPromise: Promise | undefined; + private shutdownCleanupStarted = false; private shutdownPromise: Promise | undefined; private stopPowerMonitor: (() => void) | undefined; private systemSessionLocked = false; @@ -194,11 +203,11 @@ export class DesktopRuntime { }); this.desktopInstanceId = options.desktopInstanceId ?? `desktop-${randomBytes(16).toString("hex")}`; - if (desktopBundledCuaDriverGateEnabled(this.featureFlags, this.platform)) { - this.stopDaemonStatusObserver = options.daemon.onStatusChanged((status) => { - this.handleDaemonStatusChanged(status); - }); - } + // Host admission is not a Computer Use feature. Native execution must + // observe the same stop fence even when the CUA provider is disabled. + this.stopDaemonStatusObserver = options.daemon.onStatusChanged((status) => { + this.handleDaemonStatusChanged(status); + }); // Task continuation is independent from Cua Driver readiness. Even when a // provider owner fails to compose, Desktop must still publish lock state. if (options.powerMonitor) { @@ -211,8 +220,11 @@ export class DesktopRuntime { } async getExecutionEngineAuthority(): ReturnType { + this.assertCanStart(); if (!this.options.daemon.getExecutionEngineAuthority) throw new Error("engine_host_authority_unavailable"); - return this.options.daemon.getExecutionEngineAuthority(); + const authority = await this.options.daemon.getExecutionEngineAuthority(); + this.assertCanStart(); + return authority; } get cuaDriverCoordinator(): DesktopRuntimeCuaDriverCoordinator | undefined { @@ -353,6 +365,9 @@ export class DesktopRuntime { if (this.shutdownRequested) { return Promise.reject(new DesktopRuntimeError("shutdown_requested")); } + if (this.hostDraining) { + return Promise.reject(this.hostDrainFailure ?? new DesktopRuntimeError("shutdown_requested")); + } if (this.ensureStartedPromise) { return this.ensureStartedPromise; } @@ -376,7 +391,7 @@ export class DesktopRuntime { return pending; } - /** Revoke the old coordinator before asking the daemon for a fresh tuple. */ + /** Drain the original Host before retiring its transport or starting a successor. */ restart(): Promise { if (this.shutdownRequested) { return Promise.reject(new DesktopRuntimeError("shutdown_requested")); @@ -385,34 +400,33 @@ export class DesktopRuntime { return this.restartPromise; } const pending = this.enqueue(async () => { - this.assertCanStart(); - let firstError: unknown; + let successorStarted = false; try { + this.assertShutdownNotRequested(); + await this.awaitOriginalHostStop(); + this.hostStopConfirmed = true; + this.assertShutdownNotRequested(); await this.shutdownCoordinator("daemon_restart"); + this.assertShutdownNotRequested(); + this.hostDraining = false; + this.hostDrainFailure = undefined; + this.hostStopConfirmed = false; + this.hostStopResult = undefined; + successorStarted = true; + const status = await this.options.daemon.restart(); + this.assertCanStart(); + await this.syncLockedTaskContinuationState(); + await this.reconcileCoordinator(); + return status; } catch (error) { - firstError = error; - } - this.assertCanStart(); - let status: DesktopDaemonStatus | undefined; - try { - status = await this.options.daemon.restart(); - } catch (error) { - firstError ??= error; + // A failed stop or cleanup retains its exact owner and original error. + // Only an explicit retry may recheck that owner; never start a successor. + if (!successorStarted) this.hostDrainFailure = error; + throw error; } - this.assertCanStart(); - if (firstError !== undefined) { - // A fresh daemon handshake fences the old lease, but an incompletely - // stopped local coordinator must block creation of another provider. - throw firstError; - } - if (!status) { - throw new Error("desktop_runtime_restart_status_missing"); - } - await this.syncLockedTaskContinuationState(); - await this.reconcileCoordinator(); - return status; }); this.restartPromise = pending; + this.beginHostDrain("daemon_restart"); void pending.finally(() => { if (this.restartPromise === pending) { this.restartPromise = undefined; @@ -447,7 +461,7 @@ export class DesktopRuntime { } suspendComputerControlForSystem(reason: string): Promise { - if (this.shutdownRequested) return Promise.resolve(); + if (this.shutdownRequested || this.hostDraining) return Promise.resolve(); if (reason === "system_lock") { this.systemSessionLocked = true; } else { @@ -462,6 +476,7 @@ export class DesktopRuntime { ? lockedPolicy.handleLock() : lockedPolicy.handleSuspend() : this.enqueue(async () => { + if (this.shutdownRequested || this.hostDraining) return; const coordinator = this.coordinatorValue; if (!coordinator) return; if (coordinator.suspendForSystem) { @@ -474,7 +489,7 @@ export class DesktopRuntime { } resumeComputerControlForSystem(kind: "unlock" | "resume" = "unlock"): Promise { - if (this.shutdownRequested) return Promise.resolve(); + if (this.shutdownRequested || this.hostDraining) return Promise.resolve(); const boundaryWasActive = kind === "unlock" ? this.systemSessionLocked : this.systemSuspended; @@ -494,6 +509,7 @@ export class DesktopRuntime { } if (this.systemSessionLocked || this.systemSuspended) return continuationSync; const controlResume = this.enqueue(async () => { + if (this.shutdownRequested || this.hostDraining) return; const coordinator = this.coordinatorValue; if (!coordinator) { await this.reconcileCoordinator(); @@ -508,19 +524,40 @@ export class DesktopRuntime { return Promise.all([controlResume, continuationSync]).then(() => undefined); } - /** - * Cleanup is serialized and best-effort-complete: provider/coordinator first, - * then the Desktop-owned daemon even when the earlier cleanup rejects. - */ + /** Admission closes synchronously; accepted calls retain all owners until real stop proof. */ + prepareShutdown(): Promise { + if (this.prepareShutdownPromise) return this.prepareShutdownPromise; + this.shutdownRequested = true; + const pending = this.enqueue(async () => { + // An in-flight restart may already have proved the original stop, but + // resets this bit before it attempts to create any successor. + if (!this.hostStopConfirmed) { + await this.awaitOriginalHostStop(); + this.hostStopConfirmed = true; + } + }); + this.prepareShutdownPromise = pending; + this.beginHostDrain("desktop_quit"); + void pending.catch(() => { + // A finite observation timeout is not a permanent failure: a later + // explicit exit may observe the original child's clean exit. Keep the + // admission fence, but do not permanently cache this rejected observation. + if (this.prepareShutdownPromise === pending) this.prepareShutdownPromise = undefined; + }); + return pending; + } + + /** After original Host stop proof, finish all ordinary cleanup and preserve its first error. */ shutdown(): Promise { if (this.shutdownPromise) { return this.shutdownPromise; } - this.shutdownRequested = true; - this.stopObservingDaemonStatus(); - this.stopObservingPowerMonitor(); - this.stopLockedTaskContinuationHeartbeat(); - const pending = this.enqueue(async () => { + const prepared = this.prepareShutdown(); + const pending = prepared.then(() => this.enqueue(async () => { + this.shutdownCleanupStarted = true; + this.stopObservingDaemonStatus(); + this.stopObservingPowerMonitor(); + this.stopLockedTaskContinuationHeartbeat(); let firstError: unknown; const attempt = async (operation: () => void | Promise): Promise => { try { @@ -532,21 +569,27 @@ export class DesktopRuntime { await attempt(() => this.shutdownCoordinator("desktop_quit")); await attempt(() => this.options.platformOwner?.dispose()); await attempt(() => this.enqueueLockedTaskContinuation(async () => { + // Owned Host shutdown has already cleared its in-memory continuation + // lease. Only a reused, still-independent Host needs the release RPC. + if (this.hostStopResult?.kind === "owned_stopped") return; await this.lockedTaskContinuationClient.release?.({ workspaceCwd: this.workspaceCwd, desktopInstanceId: this.desktopInstanceId, }); })); - await attempt(() => this.options.daemon.stopOwnedDaemon()); if (firstError !== undefined) { throw firstError; } - }); + })); this.shutdownPromise = pending; + void pending.catch(() => { + if (!this.hostStopConfirmed && this.shutdownPromise === pending) this.shutdownPromise = undefined; + }); return pending; } private async reconcileCoordinator(): Promise { + this.assertCanStart(); const gateEnabled = desktopBundledCuaDriverGateEnabled(this.featureFlags, this.platform); if (!gateEnabled) { // Closed gates preserve the current preview/external runtime exactly and @@ -709,6 +752,8 @@ export class DesktopRuntime { authority: DesktopDaemonCuaDriverAuthorityTuple, platform: BundledCuaDriverPlatform, ): Promise { + if (this.hostDraining) coordinator.beginHostDrain?.("host_draining"); + this.assertCanStart(); let snapshot: CuaDriverCoordinatorSnapshot | undefined; let enableFailed = false; let enableError: unknown; @@ -756,6 +801,7 @@ export class DesktopRuntime { private async reassertSystemBoundaryAfterEnable( coordinator: DesktopRuntimeCuaDriverCoordinator, ): Promise { + if (this.shutdownRequested || this.hostDraining) return; const reason = this.systemSessionLocked ? "system_lock" : this.systemSuspended @@ -829,6 +875,7 @@ export class DesktopRuntime { }), closeCallGate: (reason) => coordinator.closeCallGateForSecurityBoundary?.(reason), retireNormalForLock: async (reason) => { + if (this.shutdownRequested || this.hostDraining) return; if (!coordinator.suspendForSystem) throw new Error("locked_control_runtime_unavailable"); await coordinator.suspendForSystem( reason === "locked_control_suspend" ? "system_suspend" : "system_lock", @@ -849,8 +896,9 @@ export class DesktopRuntime { }, async stopLockedGeneration() {}, resumeNormal: async () => { - if (this.systemSessionLocked || this.systemSuspended) return; + if (this.shutdownRequested || this.hostDraining || this.systemSessionLocked || this.systemSuspended) return; await coordinator.resumeForSystem?.(); + if (this.shutdownRequested || this.hostDraining) return; const reason = this.systemSessionLocked ? "system_lock" : this.systemSuspended @@ -929,10 +977,18 @@ export class DesktopRuntime { if (this.coordinatorValue !== coordinator) { return; } + if (this.hostDraining && !this.hostStopConfirmed) { + coordinator.beginHostDrain?.(reason); + return; + } this.options.onDiagnostic?.("desktop_runtime.cua_driver_shutdown", { reason }); const lockedPolicy = this.lockedControlPolicyCoordinator; - this.lockedControlPolicyCoordinator = undefined; await lockedPolicy?.shutdown(); + if (this.hostDraining && !this.hostStopConfirmed) { + coordinator.beginHostDrain?.(reason); + return; + } + if (this.lockedControlPolicyCoordinator === lockedPolicy) this.lockedControlPolicyCoordinator = undefined; await coordinator.shutdown(); if (this.coordinatorValue === coordinator) { this.coordinatorValue = undefined; @@ -942,9 +998,21 @@ export class DesktopRuntime { } private handleDaemonStatusChanged(status: DesktopDaemonStatus): void { + if (status.ownedByDesktop && (status.state === "draining" || status.state === "blocked")) { + this.beginHostDrain(`daemon_status_${status.state}`); + return; + } if (status.state === "connected" || this.shutdownRequested) { return; } + if (this.hostDraining) { + if (status.state !== "disconnected" || status.ownedByDesktop || status.reason || this.restartPromise) return; + // The lifecycle only releases its owned stop fence on an actual clean + // exit. Do not confuse a disconnected probe or abnormal exit with this. + this.hostDraining = false; + this.hostDrainFailure = undefined; + this.hostStopConfirmed = true; + } const coordinator = this.coordinatorValue; if (!coordinator) { // Initial checking/starting events must never enqueue cleanup for a @@ -953,7 +1021,7 @@ export class DesktopRuntime { } const reason = `daemon_status_${status.state}`; void this.enqueue(async () => { - if (this.shutdownRequested || this.coordinatorValue !== coordinator) { + if (this.shutdownRequested || this.hostDraining || this.coordinatorValue !== coordinator) { return; } await this.shutdownCoordinatorInstance(coordinator, reason); @@ -965,6 +1033,40 @@ export class DesktopRuntime { }); } + private beginHostDrain(reason: string): void { + if (!this.hostDraining) { + this.hostDraining = true; + this.hostStopConfirmed = false; + this.hostStopResult = undefined; + this.hostDrainFailure = undefined; + } + this.coordinatorValue?.beginHostDrain?.(reason); + } + + private async awaitOriginalHostStop(): Promise { + try { + const result = await this.options.daemon.stopOwnedDaemon(); + if (result?.kind !== "not_owned" + && !(result?.kind === "owned_stopped" && result.code === 0 && result.signal === null)) { + throw new DesktopDaemonLifecycleError("stop_unconfirmed", this.options.daemonBaseUrl); + } + this.hostStopResult = result; + } catch (error) { + if (error instanceof DesktopDaemonLifecycleError + && (error.code === "stop_blocked" || error.code === "stop_unconfirmed")) throw error; + // An unknown stop failure is still missing stop proof, not an ordinary + // post-stop cleanup error that may authorize process exit. + try { + this.options.onDiagnostic?.("desktop_runtime.daemon_stop_unconfirmed", { + error: error instanceof Error ? error.message : String(error), + }); + } catch { + // Observers cannot change the lifecycle classification of missing proof. + } + throw new DesktopDaemonLifecycleError("stop_unconfirmed", this.options.daemonBaseUrl); + } + } + private stopObservingDaemonStatus(): void { const stopObserver = this.stopDaemonStatusObserver; this.stopDaemonStatusObserver = undefined; @@ -1092,9 +1194,9 @@ export class DesktopRuntime { } private ensureLockedTaskContinuationHeartbeat(): void { - if (this.lockedTaskContinuationHeartbeat || this.shutdownRequested) return; + if (this.lockedTaskContinuationHeartbeat || this.shutdownCleanupStarted) return; this.lockedTaskContinuationHeartbeat = setInterval(() => { - if (this.shutdownRequested) return; + if (this.shutdownCleanupStarted) return; void this.syncLockedTaskContinuationState().catch(() => undefined); }, LOCKED_TASK_CONTINUATION_HEARTBEAT_MS); if (typeof this.lockedTaskContinuationHeartbeat.unref === "function") { @@ -1125,6 +1227,13 @@ export class DesktopRuntime { } private assertCanStart(): void { + this.assertShutdownNotRequested(); + if (this.hostDraining) { + throw this.hostDrainFailure ?? new DesktopRuntimeError("shutdown_requested"); + } + } + + private assertShutdownNotRequested(): void { if (this.shutdownRequested) { throw new DesktopRuntimeError("shutdown_requested"); } diff --git a/apps/desktop/main/entry.ts b/apps/desktop/main/entry.ts index 271de9ed3..998f14262 100644 --- a/apps/desktop/main/entry.ts +++ b/apps/desktop/main/entry.ts @@ -12,6 +12,7 @@ const scope = initializeDesktopQaLaunchProfile({ getUserDataPath: () => app.getPath("userData"), setUserDataPath: path => app.setPath("userData", path), }); -if (scope) console.info(JSON.stringify({ event: "desktop.owned_synthetic_qa_profile", ...scope })); +if (scope) console.info(JSON.stringify({ event: scope.synthetic + ? "desktop.owned_synthetic_qa_profile" : "desktop.owned_budgeted_qa_profile", ...scope })); await import("./main.js"); diff --git a/apps/desktop/main/host-capabilities/runtime.ts b/apps/desktop/main/host-capabilities/runtime.ts index ee43266a4..f0c7becef 100644 --- a/apps/desktop/main/host-capabilities/runtime.ts +++ b/apps/desktop/main/host-capabilities/runtime.ts @@ -22,6 +22,10 @@ const RESULT_ACK_ATTEMPTS = 3; export class HostCapabilityRuntime { private abortController: AbortController | undefined; + private claimAbortController: AbortController | undefined; + private claimsPaused = false; + private pauseRevision = 0; + private acceptedCommand = false; private runPromise: Promise | undefined; private heartbeat: ReturnType | undefined; private client: HostCapabilityDaemonTransport | undefined; @@ -41,9 +45,10 @@ export class HostCapabilityRuntime { }) {} async start(): Promise { - if (this.runPromise) return; + if (this.runPromise || this.abortController || this.claimsPaused) return; this.generation = Math.max(this.generation + 1, this.now()); this.abortController = new AbortController(); + this.claimAbortController = new AbortController(); this.client = this.options.daemonClientFactory?.(this.generation) ?? new HostCapabilityDaemonClient({ daemonBaseUrl: this.options.daemonBaseUrl, bridgeToken: this.options.bridgeToken, @@ -51,13 +56,19 @@ export class HostCapabilityRuntime { generation: this.generation, }); const signal = this.abortController.signal; - await this.publishAvailability(signal).catch((error) => { - if (!signal.aborted) this.diagnostic("host_capability.availability_failed", error); + const claimSignal = this.claimAbortController.signal; + await this.publishAvailability(claimSignal).catch((error) => { + if (!claimSignal.aborted) this.diagnostic("host_capability.availability_failed", error); }); - this.runPromise = this.runClaimLoop(signal); + if (signal.aborted || this.claimsPaused) return; + this.runPromise = this.runClaimLoop(signal, claimSignal); this.heartbeat = setInterval(() => { - void this.publishAvailability(signal).catch((error) => { - if (!signal.aborted) this.diagnostic("host_capability.heartbeat_failed", error); + if (signal.aborted || (this.claimsPaused && !this.acceptedCommand)) return; + // Availability is also the authority lease for an accepted result. Do + // not cancel that lease merely because no new commands may be claimed. + const availabilitySignal = this.acceptedCommand ? signal : claimSignal; + void this.publishAvailability(availabilitySignal).catch((error) => { + if (!availabilitySignal.aborted) this.diagnostic("host_capability.heartbeat_failed", error); }); }, this.options.heartbeatMs ?? AVAILABILITY_HEARTBEAT_MS); } @@ -66,20 +77,34 @@ export class HostCapabilityRuntime { await this.stopInternal(true); } + /** Fence this generation's admission without aborting an accepted effect/result. */ + pauseNewClaims(): void { + this.pauseRevision += 1; + this.claimsPaused = true; + this.claimAbortController?.abort(); + if (!this.acceptedCommand) this.stopHeartbeat(); + } + /** * Rebind the host bridge after the daemon authority is recreated. The * desktop-owned preview manager remains reusable across a daemon restart; * closing it here would permanently disable subsequent previews. */ async restart(): Promise { - if (!this.runPromise && !this.abortController) return; + if (!this.runPromise && !this.abortController && !this.claimsPaused) return; + const revision = this.pauseRevision; await this.stopInternal(false); + // Only an explicit new-generation rebind may reopen admission. A newer + // drain observed while the old generation was stopping still wins. + if (revision !== this.pauseRevision) return; + this.claimsPaused = false; await this.start(); } private async stopInternal(closePreviewManager: boolean): Promise { - if (this.heartbeat) clearInterval(this.heartbeat); - this.heartbeat = undefined; + this.stopHeartbeat(); + this.claimAbortController?.abort(); + this.claimAbortController = undefined; this.abortController?.abort(); this.abortController = undefined; await this.runPromise?.catch(() => undefined); @@ -90,26 +115,50 @@ export class HostCapabilityRuntime { } } - private async runClaimLoop(signal: AbortSignal): Promise { - while (!signal.aborted) { + private async runClaimLoop(signal: AbortSignal, claimSignal: AbortSignal): Promise { + while (!signal.aborted && !this.claimsPaused) { try { - const command = await this.client?.claim(signal); + const command = await this.client?.claim(claimSignal); if (!command || signal.aborted) continue; - await this.execute(command, signal); + if (this.claimsPaused || claimSignal.aborted) { + this.claimedNotStarted(command.id); + return; + } + this.acceptedCommand = true; + try { await this.execute(command, signal); } + finally { + this.acceptedCommand = false; + if (this.claimsPaused) this.stopHeartbeat(); + } } catch (error) { - if (signal.aborted) return; + if (signal.aborted || this.claimsPaused) return; this.diagnostic("host_capability.claim_failed", error); if (error instanceof HostCapabilityDaemonClientError && error.daemonCode === "host_capability_authority_invalid") { - await this.publishAvailability(signal).catch((recoveryError) => { - if (!signal.aborted) this.diagnostic("host_capability.availability_failed", recoveryError); + await this.publishAvailability(claimSignal).catch((recoveryError) => { + if (!claimSignal.aborted) this.diagnostic("host_capability.availability_failed", recoveryError); }); } - await delay(this.options.errorBackoffMs ?? ERROR_BACKOFF_MS, signal); + await delay(this.options.errorBackoffMs ?? ERROR_BACKOFF_MS, claimSignal); } } } + private stopHeartbeat(): void { + if (this.heartbeat) clearInterval(this.heartbeat); + this.heartbeat = undefined; + } + + private claimedNotStarted(commandId: string): void { + // At most one claim is outstanding. A lost/late claim is not an effect or + // a Host stop receipt; no begin/result/requeue is manufactured here. + try { + this.options.onDiagnostic?.("host_capability.claimed_not_started", { + commandId, effectState: "not_started", hostState: "unknown", + }); + } catch { /* Observation cannot reopen admission or retire the owner. */ } + } + private async execute(command: HostCapabilityBridgeCommandV1, signal: AbortSignal): Promise { const client = this.client; if (!client) return; diff --git a/apps/desktop/main/main.ts b/apps/desktop/main/main.ts index 4c2d755f3..add7a57ff 100644 --- a/apps/desktop/main/main.ts +++ b/apps/desktop/main/main.ts @@ -91,7 +91,7 @@ import { type DesktopStartupOpenAttempt, } from "./startup-lifecycle.js"; import { createMainWindow } from "./window-factory.js"; -import type { DesktopDisplayWorkArea, DesktopRect } from "../shared/desktop-capabilities.js"; +import type { DesktopDaemonStatus, DesktopDisplayWorkArea, DesktopRect } from "../shared/desktop-capabilities.js"; import { builtInDesktopRendererThemeInput } from "../shared/renderer-theme.js"; import { createDesktopThemePackageFirstPaintState } from "../shared/theme-package-first-paint.js"; @@ -444,6 +444,9 @@ async function openMainWindowOnce(attempt: DesktopStartupOpenAttempt): Promise { const host = hostCapabilityRuntime; const office = officeDriverRuntime; @@ -2008,6 +2019,9 @@ async function waitForSmokeCondition( async function shutdown(): Promise { const activeMainWindowOpen = mainWindowOpenCoordinator.stopAcceptingAndWait(); + // A refused/unconfirmed Host stop must leave its window, IPC and execution + // transports alive. Do not enter best-effort teardown before this barrier. + await desktopRuntime?.prepareShutdown(); let firstCleanupError: unknown; let cleanupFailed = false; const attemptCleanup = async (cleanup: () => void | Promise): Promise => { diff --git a/apps/desktop/main/office-driver/runtime.ts b/apps/desktop/main/office-driver/runtime.ts index c6353d74e..b7b4004cb 100644 --- a/apps/desktop/main/office-driver/runtime.ts +++ b/apps/desktop/main/office-driver/runtime.ts @@ -31,6 +31,10 @@ export class OfficeDriverRuntime { private helper: OfficeDriverHelper | undefined; private client: OfficeDriverDaemonTransport | undefined; private abortController: AbortController | undefined; + private claimAbortController: AbortController | undefined; + private claimsPaused = false; + private pauseRevision = 0; + private acceptedCommand = false; private runPromise: Promise | undefined; private helperRecoveryPromise: Promise | undefined; private heartbeat: ReturnType | undefined; @@ -52,12 +56,15 @@ export class OfficeDriverRuntime { }) {} async start(): Promise { - if (this.runPromise) return; + if (this.runPromise || this.abortController || this.claimsPaused) return; const platform = readPlatform(this.options.platform ?? process.platform); if (!platform) return; this.platformValue = platform; this.generation = Math.max(this.generation + 1, this.now()); this.abortController = new AbortController(); + this.claimAbortController = new AbortController(); + const signal = this.abortController.signal; + const claimSignal = this.claimAbortController.signal; this.helper = this.options.helper ?? new OfficeDriverHelperProcess({ executablePath: this.options.executablePath, recoveryRoot: this.options.recoveryRoot, @@ -71,27 +78,38 @@ export class OfficeDriverRuntime { }); try { await this.helper.start(); - await this.refreshAvailability(platform); + if (signal.aborted || this.claimsPaused) return; + await this.refreshAvailability(platform, claimSignal); } catch (error) { + if (signal.aborted || this.claimsPaused) return; this.options.onDiagnostic?.("office_driver.start_failed", { error: safeErrorCode(error) }); - await this.publishSyntheticFailure(platform, safeErrorCode(error)); + await this.publishSyntheticFailure(platform, safeErrorCode(error), claimSignal); } - const signal = this.abortController.signal; - this.runPromise = this.runClaimLoop(signal); + if (signal.aborted || this.claimsPaused) return; + this.runPromise = this.runClaimLoop(signal, claimSignal); this.heartbeat = setInterval(() => { - void this.refreshAvailability(platform).catch((error) => { + if (signal.aborted || (this.claimsPaused && !this.acceptedCommand)) return; + const availabilitySignal = this.acceptedCommand ? signal : claimSignal; + // A draining accepted command needs its original authority lease, not a + // new helper health operation or helper-recovery side effect. + const publication = this.claimsPaused + ? this.renewAcceptedAvailability(signal) + : this.refreshAvailability(platform, availabilitySignal); + void publication.catch((error) => { + if (availabilitySignal.aborted) return; const errorCode = safeErrorCode(error); this.options.onDiagnostic?.("office_driver.heartbeat_failed", { error: errorCode }); - if (!shouldRecoverHelper(errorCode)) return; + if (this.claimsPaused || !shouldRecoverHelper(errorCode)) return; return this.recoverHelper().catch((recoveryError) => - this.publishSyntheticFailure(platform, safeErrorCode(recoveryError))); + this.publishSyntheticFailure(platform, safeErrorCode(recoveryError), claimSignal)); }); }, AVAILABILITY_HEARTBEAT_MS); } async stop(): Promise { - if (this.heartbeat) clearInterval(this.heartbeat); - this.heartbeat = undefined; + this.stopHeartbeat(); + this.claimAbortController?.abort(); + this.claimAbortController = undefined; this.abortController?.abort(); this.abortController = undefined; await this.helperRecoveryPromise?.catch(() => undefined); @@ -104,34 +122,70 @@ export class OfficeDriverRuntime { this.platformValue = undefined; } + /** Fence this generation's admission, retaining accepted helper/result owners. */ + pauseNewClaims(): void { + this.pauseRevision += 1; + this.claimsPaused = true; + this.claimAbortController?.abort(); + if (!this.acceptedCommand) this.stopHeartbeat(); + } + /** Rebind the desktop driver lease after the daemon authority is recreated. */ async restart(): Promise { - if (!this.runPromise && !this.abortController) return; + if (!this.runPromise && !this.abortController && !this.claimsPaused) return; + const revision = this.pauseRevision; await this.stop(); + if (revision !== this.pauseRevision) return; + this.claimsPaused = false; await this.start(); } - private async runClaimLoop(signal: AbortSignal): Promise { - while (!signal.aborted) { + private async runClaimLoop(signal: AbortSignal, claimSignal: AbortSignal): Promise { + while (!signal.aborted && !this.claimsPaused) { + let accepted = false; try { - const command = await this.client?.claim(signal); + const command = await this.client?.claim(claimSignal); if (!command || signal.aborted) continue; + if (this.claimsPaused || claimSignal.aborted) { + this.claimedNotStarted(command.id); + return; + } + accepted = true; + this.acceptedCommand = true; const result = await this.execute(command); await this.client?.complete(result, signal); } catch (error) { - if (signal.aborted) return; + if (signal.aborted || (this.claimsPaused && !accepted)) return; const code = safeErrorCode(error); this.options.onDiagnostic?.("office_driver.claim_failed", { error: code }); - if (code === "office_driver_authority_invalid" && this.platformValue) { - await this.refreshAvailability(this.platformValue).catch((recoveryError) => { - if (!signal.aborted) this.options.onDiagnostic?.("office_driver.availability_failed", { error: safeErrorCode(recoveryError) }); + if (!this.claimsPaused && code === "office_driver_authority_invalid" && this.platformValue) { + await this.refreshAvailability(this.platformValue, claimSignal).catch((recoveryError) => { + if (!claimSignal.aborted) this.options.onDiagnostic?.("office_driver.availability_failed", { error: safeErrorCode(recoveryError) }); }); } - await delay(ERROR_BACKOFF_MS, signal); + if (!this.claimsPaused) await delay(ERROR_BACKOFF_MS, claimSignal); + } finally { + if (accepted) { + this.acceptedCommand = false; + if (this.claimsPaused) this.stopHeartbeat(); + } } } } + private stopHeartbeat(): void { + if (this.heartbeat) clearInterval(this.heartbeat); + this.heartbeat = undefined; + } + + private claimedNotStarted(commandId: string): void { + try { + this.options.onDiagnostic?.("office_driver.claimed_not_started", { + commandId, effectState: "not_started", hostState: "unknown", + }); + } catch { /* No fabricated completion or change of Host authority. */ } + } + private async execute(command: OfficeDriverRequest): Promise { try { const helperCommand = toHelperCommand(command, this.generation); @@ -159,16 +213,17 @@ export class OfficeDriverRuntime { const helper = this.helper; const platform = this.platformValue; const signal = this.abortController?.signal; - if (!helper || !platform || signal?.aborted) return; + if (!helper || !platform || signal?.aborted || this.claimsPaused) return; const recovery = (async () => { await helper.stop().catch(() => undefined); - if (signal?.aborted) return; + if (signal?.aborted || this.claimsPaused) return; await helper.start(); if (signal?.aborted) { await helper.stop().catch(() => undefined); return; } - await this.refreshAvailability(platform); + if (this.claimsPaused) return; + await this.refreshAvailability(platform, this.claimAbortController?.signal ?? signal); this.options.onDiagnostic?.("office_driver.recovered"); })(); this.helperRecoveryPromise = recovery; @@ -179,10 +234,10 @@ export class OfficeDriverRuntime { } } - private async refreshAvailability(platform: OfficeDriverPlatform): Promise { + private async refreshAvailability(platform: OfficeDriverPlatform, signal?: AbortSignal): Promise { const helper = this.helper; const client = this.client; - if (!helper || !client) return; + if (!helper || !client || signal?.aborted || this.claimsPaused) return; const id = randomUUID(); const result = await helper.call({ protocol: OFFICE_DRIVER_PROTOCOL, @@ -192,6 +247,9 @@ export class OfficeDriverRuntime { deadlineAt: new Date(Date.now() + 5_000).toISOString(), idempotencyKey: `driver-status:${this.generation}:${Math.floor(Date.now() / AVAILABILITY_HEARTBEAT_MS)}`, }); + // driver_status may have been in flight when admission closed. It is not + // permission to publish a fresh idle lease or recover/restart the helper. + if (signal?.aborted || this.claimsPaused) return; if (!result.ok) throw new Error(result.error?.code ?? "office_driver_status_failed"); const status = readHelperStatus(result.result); const availability = parseOfficeDriverAvailability({ @@ -204,14 +262,20 @@ export class OfficeDriverRuntime { }); this.availability = availability; try { - await client.publishAvailability(availability, this.abortController?.signal); + await client.publishAvailability(availability, signal); } catch (error) { - if (this.abortController?.signal.aborted) throw error; + if (signal?.aborted) throw error; throw new Error("office_driver_daemon_unavailable"); } } - private async publishSyntheticFailure(platform: OfficeDriverPlatform, errorCode: string): Promise { + private async renewAcceptedAvailability(signal: AbortSignal): Promise { + if (signal.aborted || !this.acceptedCommand || !this.availability) return; + await this.client?.publishAvailability({ ...this.availability, checkedAt: new Date().toISOString() }, signal); + } + + private async publishSyntheticFailure(platform: OfficeDriverPlatform, errorCode: string, signal?: AbortSignal): Promise { + if (signal?.aborted || this.claimsPaused) return; const availability = parseOfficeDriverAvailability({ protocol: OFFICE_DRIVER_PROTOCOL, generation: this.generation, @@ -227,7 +291,7 @@ export class OfficeDriverRuntime { checkedAt: new Date().toISOString(), }); this.availability = availability; - await this.client?.publishAvailability(availability, this.abortController?.signal).catch(() => undefined); + await this.client?.publishAvailability(availability, signal).catch(() => undefined); } private now(): number { diff --git a/apps/desktop/main/qa-launch-profile.ts b/apps/desktop/main/qa-launch-profile.ts index 6a42d0a66..23e5b3087 100644 --- a/apps/desktop/main/qa-launch-profile.ts +++ b/apps/desktop/main/qa-launch-profile.ts @@ -8,11 +8,13 @@ const osEnvironmentNames = new Set([ ]); const markerFields = ["schemaVersion", "kind", "synthetic", "modelExecution", "humanConfirmation", "workspaceCwd", "userDataDir", "lingHome"]; -/** Scope declaration only: neither package nor profile markers authorize human decisions or tools. */ +/** Baseline namespace only: never use this marker as a model-run or human-decision receipt. */ export interface DesktopQaLaunchProfile { readonly schemaVersion: 1; readonly purpose: "synthetic-human-evaluation"; - readonly synthetic: true; + readonly synthetic: boolean; + /** Startup selection only, never evidence that any model invocation happened. */ + readonly modelExecution?: "host-authorized-budgeted"; readonly profileCwd: string; readonly userDataDir: string; readonly workspaceCwd: string; @@ -73,10 +75,12 @@ export function initializeDesktopQaLaunchProfile(options: DesktopQaLaunchOptions profileCwd: profileArgument, userDataDir: join(profileArgument, "user-data"), workspaceCwd: join(profileArgument, "workspace"), lingHome: join(profileArgument, "ling-home"), daemonBaseUrl: "http://127.0.0.1:17928" } as const); validateQaArguments(options.argv, profile, options.applicationPath); + let harnessSelection: unknown; try { const paths = [profile.profileCwd, profile.userDataDir, profile.workspaceCwd, profile.lingHome]; const before = paths.map(path => privateDirectory(path, userId)); validateProfileMarker(profile, userId); + harnessSelection = readQaHarnessSelection(profile, userId); paths.forEach((path, index) => sameIdentity(before[index], privateDirectory(path, userId))); } catch { throw new Error("desktop_qa_profile_invalid"); } @@ -88,13 +92,19 @@ export function initializeDesktopQaLaunchProfile(options: DesktopQaLaunchOptions LING_STACK_DAEMON_URL: profile.daemonBaseUrl, LING_STACK_HARNESS_CONTINUATIONS_MODE: "enforce", LING_STACK_LOCAL_WS_RPC: "false", + ...(harnessSelection === undefined ? {} : { + LING_STACK_HARNESS_TOOL_BROKER_MODE: "enforce", + LING_STACK_HARNESS_DELEGATIONS_MODE: "enforce", + LING_STACK_QA_HARNESS_SELECTION: JSON.stringify(harnessSelection), + }), }); try { options.setUserDataPath(profile.userDataDir); if (options.getUserDataPath() !== profile.userDataDir) throw new Error("invalid"); } catch { throw new Error("desktop_qa_user_data_invalid"); } - initializedQaProfile = profile; - return profile; + initializedQaProfile = harnessSelection === undefined ? profile : Object.freeze({ ...profile, + synthetic: false, modelExecution: "host-authorized-budgeted" as const }); + return initializedQaProfile; } function parseProfileArgument(argv: readonly string[]): string | undefined { @@ -140,8 +150,9 @@ function privateDirectory(path: string, userId: number): BigIntStats { return stat; } -function validateProfileMarker(profile: DesktopQaLaunchProfile, userId: number): void { - const path = join(profile.profileCwd, "qa-profile.json"), before = lstatSync(path, { bigint: true }); +/** Both policy files are Host startup input, never read from workspace/model metadata. */ +function readPrivateQaJson(path: string, userId: number): unknown { + const before = lstatSync(path, { bigint: true }); const validFile = (stat: BigIntStats): void => { if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== BigInt(userId) || (stat.mode & 0o7777n) !== 0o600n || stat.nlink !== 1n || stat.size <= 0n || stat.size > 4096n) throw new Error("invalid"); @@ -164,6 +175,11 @@ function validateProfileMarker(profile: DesktopQaLaunchProfile, userId: number): const after = fstatSync(fd, { bigint: true }); validFile(after); sameIdentity(opened, after); sameIdentity(after, lstatSync(path, { bigint: true })); } finally { closeSync(fd); } + return value; +} + +function validateProfileMarker(profile: DesktopQaLaunchProfile, userId: number): void { + const value = readPrivateQaJson(join(profile.profileCwd, "qa-profile.json"), userId); if (!exactRecord(value, markerFields) || value.schemaVersion !== 1 || value.kind !== "synthetic-human-qa-profile" || value.synthetic !== true || value.modelExecution !== false || value.humanConfirmation !== false || value.workspaceCwd !== profile.workspaceCwd || value.userDataDir !== profile.userDataDir || value.lingHome !== profile.lingHome) { @@ -171,6 +187,29 @@ function validateProfileMarker(profile: DesktopQaLaunchProfile, userId: number): } } +function readQaHarnessSelection(profile: DesktopQaLaunchProfile, userId: number): unknown { + const path = join(profile.profileCwd, "qa-harness.json"); + try { lstatSync(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + const value = readPrivateQaJson(path, userId); + if (!exactRecord(value, ["schemaVersion", "kind", "modelExecution", "workspaceCwd", "selectedWorkflowIds", "maxDepth", "rootBudget"]) + || value.schemaVersion !== 1 || value.kind !== "isolated-qa-delegation-selection" + || value.modelExecution !== "host-authorized-budgeted" + || value.workspaceCwd !== profile.workspaceCwd || !Array.isArray(value.selectedWorkflowIds) + || value.selectedWorkflowIds.length < 1 || value.selectedWorkflowIds.length > 100 + || value.selectedWorkflowIds.some(id => typeof id !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,255}$/u.test(id)) + || new Set(value.selectedWorkflowIds).size !== value.selectedWorkflowIds.length + || !positive(value.maxDepth) || value.maxDepth > 16 + || !exactRecord(value.rootBudget, ["tokenBudget", "wallTimeBudgetMs"]) + || !positive(value.rootBudget.tokenBudget) || !positive(value.rootBudget.wallTimeBudgetMs)) throw new Error("invalid"); + return value; +} + +function positive(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value > 0; } + function sameIdentity(left: BigIntStats, right: BigIntStats): void { for (const key of ["dev", "ino", "uid", "mode", "nlink", "size", "mtimeNs", "ctimeNs"] as const) if (left[key] !== right[key]) throw new Error("invalid"); } diff --git a/apps/desktop/main/startup-lifecycle.ts b/apps/desktop/main/startup-lifecycle.ts index 1c571f384..e3055f997 100644 --- a/apps/desktop/main/startup-lifecycle.ts +++ b/apps/desktop/main/startup-lifecycle.ts @@ -1,3 +1,5 @@ +import { DesktopDaemonLifecycleError } from "./daemon-lifecycle.js"; + export interface DesktopExitLifecycleOptions { readonly shutdown: () => Promise; readonly quit: () => void; @@ -116,16 +118,28 @@ export function createDesktopExitLifecycle( const requestExit = (exitCode = 0): Promise => { requestedExitCode = mergeExitCode(requestedExitCode, exitCode); - exitPromise ??= (async () => { + // Install the single flight before shutdown or diagnostic callbacks can + // synchronously request exit again. + exitPromise ??= Promise.resolve().then(async () => { try { await options.shutdown(); } catch (error) { - requestedExitCode = mergeExitCode(requestedExitCode, 1); + const retainedHost = error instanceof DesktopDaemonLifecycleError + && (error.code === "stop_blocked" || error.code === "stop_unconfirmed"); try { options.onShutdownError?.(error); } catch { - // Diagnostics must not prevent the process from exiting after cleanup. + // Diagnostics cannot authorize exit or change the Host stop result. } + if (retainedHost) { + // A finite observation failure is not a permanent stopped verdict. + // A later explicit quit may recheck the same original Host; this + // does not itself retry, release resources or start a successor. + exitPromise = undefined; + requestedExitCode = 0; + return; + } + requestedExitCode = mergeExitCode(requestedExitCode, 1); } cleanupComplete = true; if (requestedExitCode === 0) { @@ -133,7 +147,7 @@ export function createDesktopExitLifecycle( } else { options.exit(requestedExitCode); } - })(); + }); return exitPromise; }; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0604b1eda..16d9f0c05 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -92,6 +92,7 @@ }, "files": [ "dist/**/*", + "!dist/daemon/bundled{,/**/*}", "package.json", "!dist/distribution{,/**/*}", "!dist/office-local{,/**/*}", diff --git a/apps/desktop/preload/main-window.ts b/apps/desktop/preload/main-window.ts index edeb6623b..694c9ca1b 100644 --- a/apps/desktop/preload/main-window.ts +++ b/apps/desktop/preload/main-window.ts @@ -10,10 +10,11 @@ import type { ComputerUsePreviewSummary, ComputerControlPermissionKind, DesktopNavigationIntent, + DesktopDaemonStatus, DesktopWindowState, LingDesktopCapabilities, } from "../shared/desktop-capabilities.js"; -import { isDesktopNavigationIntent } from "../shared/desktop-capabilities.js"; +import { isDesktopNavigationIntent, isDesktopDaemonStatus } from "../shared/desktop-capabilities.js"; import type { DesktopThemePackageArchiveRequest, DesktopThemePackageDeleteRequest, @@ -154,6 +155,20 @@ const capabilities: LingDesktopCapabilities = { daemon: { getStatus: () => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.daemonStatus), restart: () => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.daemonRestart), + onStatusChanged: listener => subscribeWithInitialRead({ + read: async () => { + const status: unknown = await ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.daemonStatus); + return isDesktopDaemonStatus(status) && status.baseUrl === daemonBaseUrl ? status : undefined; + }, + subscribe: onStatus => { + const wrapped = (_event: Electron.IpcRendererEvent, status: unknown): void => { + if (isDesktopDaemonStatus(status) && status.baseUrl === daemonBaseUrl) onStatus(status); + }; + ipcRenderer.on(DESKTOP_IPC_CHANNELS.daemonStatus, wrapped); + return () => { ipcRenderer.off(DESKTOP_IPC_CHANNELS.daemonStatus, wrapped); }; + }, + onValue: status => { if (status) listener(status); }, + }), }, mobileAccess: { status: () => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.mobileAccessStatus), diff --git a/apps/desktop/scripts/cua-driver-package-atomic-publish.mjs b/apps/desktop/scripts/cua-driver-package-atomic-publish.mjs index f71e74f20..ab27dfc2d 100644 --- a/apps/desktop/scripts/cua-driver-package-atomic-publish.mjs +++ b/apps/desktop/scripts/cua-driver-package-atomic-publish.mjs @@ -17,6 +17,7 @@ import { } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify, TextDecoder } from "node:util"; +import { createNativeParentLeaseStreamWriter } from "./cua-driver-parent-lease-stream-writer.mjs"; const execFile = promisify(execFileCallback); const NATIVE_SOURCE_PATH = fileURLToPath( @@ -154,6 +155,21 @@ export async function withAtomicPublishParentLeaseForTest(input, operation) { return runAtomicPublishParentLeaseOperation(lease, operation); } +/** Controlled stream/child seam; never accepted by the production entry point. */ +export async function withAtomicPublishParentLeaseChildForTest(input, operation) { + requireParentLeaseOperation(operation); + const parentRoot = await requireCanonicalParent(input?.parentRoot); + const lease = await acquireAtomicPublishParentLease({ + assertPublisherIdentity: async () => {}, + nativePublisher: Object.freeze({ path: "test-only-controlled-parent-lease-holder" }), + operationTimeoutMs: requireParentLeaseOperationTimeout(input?.operationTimeoutMs), + parentRoot, + spawnProcess: async () => input.child, + timeoutMs: requireParentLeaseTimeout(input?.timeoutMs), + }); + return runAtomicPublishParentLeaseOperation(lease, operation); +} + export function encodeNativeParentLeaseCallFrameForTest( requestId, operation, @@ -596,6 +612,7 @@ async function acquireAtomicPublishParentLease({ acceptingCalls: false, assertPublisherIdentity, child, + invalidationDrain: undefined, lineReader, lost, lostError: undefined, @@ -612,6 +629,15 @@ async function acquireAtomicPublishParentLease({ stderr, termination, }; + capability.writer = createNativeParentLeaseStreamWriter({ + stream: child.stdin, + createFailure: cause => new CuaDriverAtomicPublishError( + "staging_parent_lock_protocol_invalid", + "failed to write a native parent lease protocol frame", + { cause, outcome: "ambiguous" }, + ), + onFailure: error => invalidateNativeParentLeaseCapability(capability, error), + }); void termination.then((state) => { if (!capability.releaseRequested && capability.state !== "released") { markNativeParentLeaseLost( @@ -619,6 +645,7 @@ async function acquireAtomicPublishParentLease({ parentLeaseLostError(state, stderr()), ); } + capability.writer.disposeAfterTermination(); }); let lockedReceipt; @@ -643,6 +670,11 @@ async function acquireAtomicPublishParentLease({ const assertHeld = () => { if (capability.lostError !== undefined) throw capability.lostError; }; + const assertHeldAfterDrain = async () => { + if (capability.lostError === undefined) return; + await invalidateNativeParentLeaseCapability(capability, capability.lostError); + throw capability.lostError; + }; const guard = async (operationFactory) => { if (typeof operationFactory !== "function") { throw new CuaDriverAtomicPublishError( @@ -670,6 +702,7 @@ async function acquireAtomicPublishParentLease({ if (drainedError !== undefined) { attachParentLeaseOperationError(capability.lostError, drainedError); } + await invalidateNativeParentLeaseCapability(capability, capability.lostError); // The lease-loss classification remains authoritative after draining, // while any identity-bound cleanup metadata from the drained operation // remains attached for explicit recovery. @@ -697,19 +730,18 @@ async function acquireAtomicPublishParentLease({ guard, public: publicLease, release: async () => { - assertHeld(); + await assertHeldAfterDrain(); capability.acceptingCalls = false; nativeParentLeaseCapabilities.delete(publicLease); await capability.queue; - assertHeld(); + await assertHeldAfterDrain(); capability.releaseRequested = true; capability.state = "releasing"; const requestId = capability.nextRequestId; capability.nextRequestId += 1; let releaseFailure; try { - await writeNativeParentLeaseFrame( - child, + await capability.writer.write( encodeNativeParentLeaseReleaseFrame(requestId), { end: true }, ); @@ -821,8 +853,7 @@ async function executeNativeParentLeaseOperation( ); } try { - await writeNativeParentLeaseFrame( - capability.child, + await capability.writer.write( encodeNativeParentLeaseCallFrame(requestId, operation, args), ); const line = await readParentLeaseProtocolLine({ @@ -859,22 +890,28 @@ function markNativeParentLeaseLost(capability, error) { return error; } -async function invalidateNativeParentLeaseCapability(capability, error) { - markNativeParentLeaseLost(capability, error); - if (capability.child.exitCode === null && capability.child.signalCode === null) { - try { - capability.child.kill("SIGKILL"); - } catch (invalidationError) { - attachParentLeaseInvalidationError(error, invalidationError); - } - } - // Safety boundary: never return control while an invalidated holder could - // still own the OS lease or finish a native mutation. The holder's own - // operation watchdog is bounded, but this await intentionally is not. - const state = await capability.termination; - if (state.error !== undefined) { - attachParentLeaseInvalidationError(error, state.error); +function invalidateNativeParentLeaseCapability(capability, error) { + const failure = markNativeParentLeaseLost(capability, error); + if (capability.invalidationDrain === undefined) { + // The writer error event, guarded operation and release may all observe the + // same loss. They must share termination/drain, not signal a holder twice. + capability.invalidationDrain = Promise.resolve().then(async () => { + if (capability.child.exitCode === null && capability.child.signalCode === null) { + try { + capability.child.kill("SIGKILL"); + } catch (invalidationError) { + attachParentLeaseInvalidationError(failure, invalidationError); + } + } + // Never return while the invalidated holder could still own the OS lease + // or finish a mutation. Keep the existing intentionally unbounded drain. + const state = await capability.termination; + if (state.error !== undefined) { + attachParentLeaseInvalidationError(failure, state.error); + } + }); } + return capability.invalidationDrain; } async function spawnNativePublisherProcess(nativePublisher, args, options) { @@ -1121,29 +1158,6 @@ function containsLoneSurrogate(value) { return false; } -function writeNativeParentLeaseFrame(child, frame, { end = false } = {}) { - return new Promise((resolve, reject) => { - const onError = (cause) => { - child.stdin.off("error", onError); - reject(new CuaDriverAtomicPublishError( - "staging_parent_lock_protocol_invalid", - "failed to write a native parent lease protocol frame", - { cause, outcome: "ambiguous" }, - )); - }; - child.stdin.once("error", onError); - const onComplete = () => { - child.stdin.off("error", onError); - resolve(); - }; - if (end) { - child.stdin.end(frame, onComplete); - } else { - child.stdin.write(frame, onComplete); - } - }); -} - function parentLeaseProtocolError(message, cause) { return new CuaDriverAtomicPublishError( "staging_parent_lock_protocol_invalid", diff --git a/apps/desktop/scripts/cua-driver-parent-lease-stream-writer.mjs b/apps/desktop/scripts/cua-driver-parent-lease-stream-writer.mjs new file mode 100644 index 000000000..496d605c2 --- /dev/null +++ b/apps/desktop/scripts/cua-driver-parent-lease-stream-writer.mjs @@ -0,0 +1,103 @@ +/** + * Own stdin error handling for one native parent-lease holder. Authority and + * holder termination remain with the caller; a failed frame cannot settle + * until that caller's invalidation/drain promise has completed. + */ +export function createNativeParentLeaseStreamWriter({ stream, createFailure, onFailure }) { + const pending = new Set(); + let failure; + let failureDrain; + let closed = stream.closed === true; + + const cleanup = () => { + stream.off("error", onError); + stream.off("close", onClose); + }; + const rejectAfterDrain = (request) => { + void failureDrain.then(() => request.reject(failure)); + }; + const fail = (cause) => { + if (failure !== undefined) return failure; + failure = createFailure(cause); + // Invoke synchronously so capability invalidation precedes any later + // callback or queued write. Keep this stream's error sink through close: + // Writable calls callback(error) before it emits its error event. + try { + failureDrain = Promise.resolve(onFailure(failure)); + } catch (invalidationError) { + failureDrain = Promise.reject(invalidationError); + } + failureDrain = failureDrain.catch((invalidationError) => { + if (Object.isExtensible(failure)) { + Object.defineProperty(failure, "invalidationError", { + configurable: true, + value: invalidationError, + }); + } + }); + for (const request of pending) rejectAfterDrain(request); + return failure; + }; + const onError = (cause) => { fail(cause); }; + const onClose = () => { + closed = true; + if (pending.size > 0) fail(prematureCloseError()); + cleanup(); + }; + if (!closed) { + stream.on("error", onError); + stream.once("close", onClose); + } + + return Object.freeze({ + write: (frame, { end = false } = {}) => new Promise((resolve, reject) => { + let settled = false; + const settle = (callback, value) => { + if (settled) return; + settled = true; + pending.delete(request); + callback(value); + }; + const request = { + reject: error => settle(reject, error), + resolve: () => settle(resolve), + }; + pending.add(request); + if (failure !== undefined) { + rejectAfterDrain(request); + return; + } + if (closed || stream.destroyed) { + fail(stream.errored ?? prematureCloseError()); + return; + } + const complete = (cause) => { + if (cause !== undefined && cause !== null) { + fail(cause); + } else if (failure !== undefined) { + rejectAfterDrain(request); + } else { + request.resolve(); + } + }; + try { + if (end) stream.end(frame, complete); + else stream.write(frame, complete); + } catch (cause) { + fail(cause); + } + }), + // Call only after the owned holder has terminated. Do not remove the error + // listener on child 'exit': its stdin may still deliver error/close later. + disposeAfterTermination: () => { + if (closed) cleanup(); + else if (!stream.destroyed) stream.destroy(); + }, + }); +} + +function prematureCloseError() { + return Object.assign(new Error("native parent lease stdin closed before frame completion"), { + code: "ERR_STREAM_PREMATURE_CLOSE", + }); +} diff --git a/apps/desktop/scripts/cua-driver-signing/artifact-build-completed.mjs b/apps/desktop/scripts/cua-driver-signing/artifact-build-completed.mjs index 76c93aa73..046e3172f 100644 --- a/apps/desktop/scripts/cua-driver-signing/artifact-build-completed.mjs +++ b/apps/desktop/scripts/cua-driver-signing/artifact-build-completed.mjs @@ -2,10 +2,30 @@ import { isCompanionBlockmap, runArtifactBuildCompleted } from "./hooks.mjs"; import { extractCuaDriverFinalArtifact } from "./final-artifact-extractor.mjs"; import { verifyOfficeFinalArtifact } from "../packaged-office-native-package-checks.mjs"; -export default async function artifactBuildCompleted(context) { +// Builder queues ZIP work internally, so concurrency.jobs=1 does not prevent +// its final verifier from overlapping DMG. Keep both verifiers AND their +// awaited cleanup in one lane per actual Packager. A rejected lane stays +// rejected: unknown extraction/cleanup state cannot admit another artifact. +const verificationLanes = new WeakMap(); + +async function verifyArtifact(context) { // Share Cua's original metadata-only skip; actual ZIP/DMG still verify first. if (!isCompanionBlockmap(context)) await verifyOfficeFinalArtifact(context); return runArtifactBuildCompleted(context, { extractArtifact: extractCuaDriverFinalArtifact, }); } + +export default async function artifactBuildCompleted(context) { + const packager = context?.packager; + if (!packager || (typeof packager !== "object" && typeof packager !== "function")) { + throw new TypeError("artifact_verification_packager_required"); + } + const previous = verificationLanes.get(packager) ?? Promise.resolve(); + const result = previous.then(() => verifyArtifact(context)); + verificationLanes.set(packager, result); + // The returned promise and queued callers still reject with the original + // error. Observe the stored tail too, in case no later callback arrives. + void result.catch(() => {}); + return result; +} diff --git a/apps/desktop/scripts/cua-driver-signing/dmg-attachment-custody.mjs b/apps/desktop/scripts/cua-driver-signing/dmg-attachment-custody.mjs index 9e032e615..852fc8076 100644 --- a/apps/desktop/scripts/cua-driver-signing/dmg-attachment-custody.mjs +++ b/apps/desktop/scripts/cua-driver-signing/dmg-attachment-custody.mjs @@ -189,7 +189,7 @@ function normalizeInventory(value, snapshotPath) { // An unrelated APFS image can include both a backing disk and a synthesized // container disk. Keep every entity for collision checks; only our snapshot // may supply the single whole-device lineage used to authorize detach. - const entities = normalizeEntities(image, imagePath !== snapshotPath); + const entities = normalizeEntities(image, { allowMultipleWholeDevices: imagePath !== snapshotPath }); for (const entity of entities) { requireCondition(!devices.has(entity.device), "inventory_duplicate"); devices.add(entity.device); @@ -198,14 +198,15 @@ function normalizeInventory(value, snapshotPath) { mountPoints.add(entity.mountPoint); } } + const wholeDevices = entities.filter(entity => WHOLE_DEVICE_PATTERN.test(entity.device)); return Object.freeze({ imagePath, entities, - wholeDevice: entities.find(entity => WHOLE_DEVICE_PATTERN.test(entity.device)).device, + wholeDevice: wholeDevices.length === 1 ? wholeDevices[0].device : undefined, ...(Object.hasOwn(image, "hdid-pid") ? { helperPid: image["hdid-pid"] } : {}), ownerUid: image["owner-uid"] }); }); return Object.freeze(images); } -function normalizeEntities(value, allowMultipleWholeDevices = false) { +function normalizeEntities(value, { allowMultipleWholeDevices = false } = {}) { requireCondition(record(value) && Object.hasOwn(value, "system-entities") && Array.isArray(value["system-entities"]) && value["system-entities"].length > 0 && value["system-entities"].length <= MAX_ENTITIES, "entities_invalid"); @@ -230,7 +231,8 @@ function normalizeEntities(value, allowMultipleWholeDevices = false) { return Object.freeze({ device, ...(hasMountPoint ? { mountPoint: entity["mount-point"] } : {}) }); }); const wholeDevices = entities.filter(entity => WHOLE_DEVICE_PATTERN.test(entity.device)); - requireCondition(allowMultipleWholeDevices ? wholeDevices.length > 0 : wholeDevices.length === 1, "whole_device_invalid"); + requireCondition(wholeDevices.length > 0 && (allowMultipleWholeDevices || wholeDevices.length === 1), + "whole_device_invalid"); requireCondition(entities.every(entity => wholeDevices.some(whole => entity.device === whole.device || entity.device.startsWith(`${whole.device}s`))), "whole_device_invalid"); return Object.freeze(entities.sort((left, right) => left.device.localeCompare(right.device))); diff --git a/apps/desktop/scripts/cua-driver-signing/final-artifact-extractor.mjs b/apps/desktop/scripts/cua-driver-signing/final-artifact-extractor.mjs index 8b9ecab44..b1e0689d7 100644 --- a/apps/desktop/scripts/cua-driver-signing/final-artifact-extractor.mjs +++ b/apps/desktop/scripts/cua-driver-signing/final-artifact-extractor.mjs @@ -745,10 +745,13 @@ export async function runBoundedCommand(command, args, options = {}) { } return new Promise((settle, reject) => { let inputError; + const startedAt = new Date().toISOString(); + const started = performance.now(); + const timeoutMs = options.timeoutMs ?? COMMAND_TIMEOUT_MS; const child = execFile(command, args, { encoding: "utf8", maxBuffer: COMMAND_MAX_BUFFER_BYTES, - timeout: options.timeoutMs ?? COMMAND_TIMEOUT_MS, + timeout: timeoutMs, windowsHide: true, }, (error, stdout, stderr) => { const boundedOutput = typeof stdout === "string" && typeof stderr === "string" @@ -757,18 +760,40 @@ export async function runBoundedCommand(command, args, options = {}) { if (error || inputError || !boundedOutput) { const cause = error && inputError ? new AggregateError([error, inputError], "command_and_stdin_failed") : error ?? inputError; + const outputComplete = boundedOutput && !inputError && Number.isInteger(error?.code) + && error.killed !== true && !error.signal; + // Persist only fixed transport metadata, never argv, input or output + // contents. The normal Error message must survive CLI/Builder logging, + // which does not necessarily serialize a nested cause or hidden receipt. + const diagnostic = Object.freeze({ + command: basename(command), + operation: basename(command) === "hdiutil" && ["info", "attach", "detach"].includes(args[0]) + ? args[0] : null, + pid: Number.isInteger(child.pid) ? child.pid : null, + startedAt, + elapsedMs: Math.max(0, Math.round(performance.now() - started)), + timeoutMs, + exitCode: Number.isInteger(error?.code) ? error.code : null, + errorCode: typeof error?.code === "string" ? error.code : null, + signal: error?.signal ?? null, + killed: error?.killed === true, + stdinFailed: Boolean(inputError), + stdoutBytes: typeof stdout === "string" ? Buffer.byteLength(stdout, "utf8") : null, + stderrBytes: typeof stderr === "string" ? Buffer.byteLength(stderr, "utf8") : null, + outputComplete, + }); const failure = contractError( "final_artifact_command_failed", - `${basename(command)} failed with ${String(error?.code ?? "unknown")}`, + `${basename(command)} failed with ${String(error?.code ?? "unknown")} ${JSON.stringify(diagnostic)}`, cause, ); + Object.defineProperty(failure, "commandDiagnostic", { value: diagnostic }); Object.defineProperty(failure, "commandReceipt", { value: Object.freeze({ stdout: boundedOutput ? stdout : "", stderr: boundedOutput ? stderr : "", // Only a completed nonzero exit can retain a complete partial-attach // receipt. Timeout, signal, pipe failure and maxBuffer are unknown. - outputComplete: boundedOutput && !inputError && Number.isInteger(error?.code) - && error.killed !== true && !error.signal, + outputComplete, }) }); reject(failure); } else { diff --git a/apps/desktop/scripts/package-macos-with-office.mjs b/apps/desktop/scripts/package-macos-with-office.mjs index 6076f09d7..820554cff 100644 --- a/apps/desktop/scripts/package-macos-with-office.mjs +++ b/apps/desktop/scripts/package-macos-with-office.mjs @@ -10,7 +10,7 @@ import { resolveSigningProfile } from "./cua-driver-signing/signing-contract.mjs import { assertPackagedOfficeSigningConfig, assertPackagedOfficeSigningEnvironment } from "./packaged-office-signing-inputs.mjs"; import { buildHumanEvaluationHelper } from "./build-human-evaluation-helper.mjs"; -import { readPackagedOfficeHumanQaSelection, selectPackagedOfficeBuildLane } from "./packaged-human-qa-lane.mjs"; +import { readPackagedOfficeBuildLane, selectPackagedOfficeBuildLane } from "./packaged-human-qa-lane.mjs"; const desktopRoot = realpathSync(fileURLToPath(new URL("../", import.meta.url))); const builderArguments = Object.freeze(["--mac", "dmg", "zip", "--arm64"]); @@ -19,7 +19,7 @@ let busy = false, configSequence = 0; /** Fixed same-process macOS lane; there is no CLI config/target/publication override. */ export async function packageMacosWithOffice() { if (busy) throw new Error("packaged_office_native_package_busy"); - const humanQa = readPackagedOfficeHumanQaSelection(process.env); + const admittedLane = readPackagedOfficeBuildLane(process.env); busy = true; try { if (process.platform !== "darwin" || process.arch !== "arm64" @@ -35,7 +35,7 @@ export async function packageMacosWithOffice() { assertPackagedOfficeSigningEnvironment(process.env); await runCuaDriverElectronPackageCli(["--platform", "darwin", "--", ...builderArguments], process.env, { async runBuilder({ appRoot, args, environment }) { - if (readPackagedOfficeHumanQaSelection(environment) !== humanQa) { + if (readPackagedOfficeBuildLane(environment) !== admittedLane) { throw new Error("packaged_human_qa_selection_changed"); } if (realpathSync(appRoot) !== desktopRoot @@ -55,7 +55,7 @@ export async function packageMacosWithOffice() { const configUrl = new URL("./cua-driver-electron-builder.config.mjs", import.meta.url); configUrl.searchParams.set("officeInvocation", String(++configSequence)); const { default: originalConfig } = await import(configUrl.href); - if (readPackagedOfficeHumanQaSelection(process.env) !== humanQa) { + if (readPackagedOfficeBuildLane(process.env) !== admittedLane) { throw new Error("packaged_human_qa_selection_changed"); } const config = selectPackagedOfficeBuildLane(originalConfig, process.env); diff --git a/apps/desktop/scripts/packaged-gstack-resources.mjs b/apps/desktop/scripts/packaged-gstack-resources.mjs index ccc77fee7..cdc3b870e 100644 --- a/apps/desktop/scripts/packaged-gstack-resources.mjs +++ b/apps/desktop/scripts/packaged-gstack-resources.mjs @@ -34,7 +34,7 @@ export function packagedGstackBuilderConfig() { { from: active.browser.root, to: "gstack-browser", filter: ["**/*"] }, ...retainedFiles]; // FileMatcher expands macros in both paths. Authenticated paths must stay literal. if (extraResources.some(entry => [entry.from, entry.to].some(path => path.includes("${")))) { - throw new Error("packaged_gstack_resource_paths_must_be_literal"); + throw new Error("packaged_gstack_builder_path_macro_unsupported"); } return { extraResources, extraMetadata: { lingPackagedGstack: active.identity }, diff --git a/apps/desktop/scripts/packaged-human-qa-lane.mjs b/apps/desktop/scripts/packaged-human-qa-lane.mjs index b2d700633..11aba7cd2 100644 --- a/apps/desktop/scripts/packaged-human-qa-lane.mjs +++ b/apps/desktop/scripts/packaged-human-qa-lane.mjs @@ -1,5 +1,5 @@ /** - * Select the one fixed, isolated Human QA identity. This is not an approval + * Select one of the fixed, isolated Human QA identities. This is not an approval * issuer or caller-provided Builder config. All original publication, resource, * signature and final-artifact checks remain on the same awaited build path. * @@ -8,15 +8,26 @@ * @returns {Record} Original config, or the fixed QA-only variant. */ export function selectPackagedOfficeBuildLane(config, environment) { - if (!readPackagedOfficeHumanQaSelection(environment)) return config; + const lane = readPackagedOfficeBuildLane(environment); + if (lane === "ordinary") return config; if (config.appId !== "com.lingstack.desktop" || config.productName !== "Ling Stack") { throw new Error("packaged_human_qa_base_invalid"); } + // Fixed successors must never overwrite Apps still owned by retained Hosts. + // No caller-controlled name, path, config or signing policy is accepted. + const successor = lane === "human-qa-r03-successor"; + const observation = lane === "human-qa-r03-observation"; + const integrated = lane === "human-qa-integrated-main"; return { ...config, - appId: "com.lingstack.desktop.human-qa", - productName: "Ling Stack Human QA", - directories: { ...config.directories, output: "release/human-qa" }, + appId: integrated ? "com.lingstack.desktop.human-qa.integrated-main" + : observation ? "com.lingstack.desktop.human-qa.r03-observation" + : successor ? "com.lingstack.desktop.human-qa.r03-successor" : "com.lingstack.desktop.human-qa", + productName: integrated ? "Ling Stack Integrated Main QA" + : observation ? "Ling Stack R03 Observation QA" : successor ? "Ling Stack R03 Successor QA" : "Ling Stack Human QA", + directories: { ...config.directories, output: integrated ? "release/human-qa-integrated-main" + : observation ? "release/human-qa-r03-observation" + : successor ? "release/human-qa-r03-successor" : "release/human-qa" }, extraMetadata: { ...config.extraMetadata, // A package identity/scope marker, never proof of a human decision. @@ -27,9 +38,15 @@ export function selectPackagedOfficeBuildLane(config, environment) { /** Validate before any native/package side effects; presence is intentional. */ export function readPackagedOfficeHumanQaSelection(environment) { - if (!Object.hasOwn(environment, "LING_DESKTOP_PACKAGE_HUMAN_QA")) return false; - if (environment.LING_DESKTOP_PACKAGE_HUMAN_QA !== "1") { - throw new Error("packaged_human_qa_lane_invalid"); - } - return true; + return readPackagedOfficeBuildLane(environment) !== "ordinary"; +} + +/** Exact lane identity, not just a boolean, must survive asynchronous build boundaries. */ +export function readPackagedOfficeBuildLane(environment) { + if (!Object.hasOwn(environment, "LING_DESKTOP_PACKAGE_HUMAN_QA")) return "ordinary"; + if (environment.LING_DESKTOP_PACKAGE_HUMAN_QA === "1") return "human-qa"; + if (environment.LING_DESKTOP_PACKAGE_HUMAN_QA === "retained-host-r03") return "human-qa-r03-successor"; + if (environment.LING_DESKTOP_PACKAGE_HUMAN_QA === "retained-host-r03-observation") return "human-qa-r03-observation"; + if (environment.LING_DESKTOP_PACKAGE_HUMAN_QA === "integrated-main") return "human-qa-integrated-main"; + throw new Error("packaged_human_qa_lane_invalid"); } diff --git a/apps/desktop/scripts/packaged-office-native-asar-checks.mjs b/apps/desktop/scripts/packaged-office-native-asar-checks.mjs index 9a58a25c8..6ea0e0240 100644 --- a/apps/desktop/scripts/packaged-office-native-asar-checks.mjs +++ b/apps/desktop/scripts/packaged-office-native-asar-checks.mjs @@ -6,6 +6,7 @@ import { readActiveOriginalPackagedOfficeDaemonBundle } from "./build-packaged-d import { readActiveOriginalPackagedOfficeNativePublication } from "./build-packaged-office-native.mjs"; const cliEntry = "dist/daemon/cli.mjs"; +const developmentBundledRoot = "dist/daemon/bundled"; const maximumArchiveBytes = 2n * 1024n * 1024n * 1024n; const maximumHeaderBytes = 16 * 1024 * 1024; const maximumCliBytes = 64 * 1024 * 1024; @@ -148,7 +149,8 @@ export function verifyOriginalOfficeDaemonInApp(appPath) { const expected = inspectHeader(raw.header, budget).sort(); const entries = listPackage(archive).map(path => path.replace(/^\//u, "")).sort(); requireAsar(entries.length === expected.length && entries.every((path, index) => path === expected[index]) - && entries.filter(path => path === cliEntry).length === 1); + && entries.filter(path => path === cliEntry).length === 1 + && entries.every(path => path !== developmentBundledRoot && !path.startsWith(developmentBundledRoot + "/"))); for (const path of ["dist", "dist/daemon"]) { const metadata = statFile(archive, path, false); requireAsar(Object.hasOwn(metadata, "files") && !Object.hasOwn(metadata, "link") && metadata.unpacked !== true); diff --git a/apps/desktop/scripts/packaged-office-native-review-inputs.mjs b/apps/desktop/scripts/packaged-office-native-review-inputs.mjs index 36c20abb1..5606a3190 100644 --- a/apps/desktop/scripts/packaged-office-native-review-inputs.mjs +++ b/apps/desktop/scripts/packaged-office-native-review-inputs.mjs @@ -31,12 +31,8 @@ * using Node's native TypeScript stripping (fixed build runtime Node 22.22.1). * This does not attest the ordinary developer lane's existing dist or vendor * generated files. Source config changes require a new explicit review. - * Human QA's two early-entry sources are explicit non-Office-build review inputs: - * their inclusion pins the QA isolation code, not the entire generated Main graph. - * - * Local service IPC is an explicit root. Its Main, preload and Web wiring are - * exact nonrecursive review inputs; pinning Main does not include its whole graph. - * Packaged daemon lifecycle is also an exact input for startup/FD3 cancellation. + * Human QA's early-entry/lifecycle sources are explicit non-Office-build review + * inputs: their inclusion pins QA isolation/startup, not the entire Main graph. * * A graph change requires an explicit review of this fixed list. It must not * silently discover, regenerate, approve, or refresh digests during a build. @@ -47,6 +43,7 @@ export const PACKAGED_OFFICE_NATIVE_AUTHORED_REVIEW_ROOTS = Object.freeze([ "apps/desktop/scripts/engine-supervisor-package.mjs", "apps/desktop/scripts/build-packaged-daemon.mjs", "apps/desktop/scripts/build-packaged-office-native.mjs", + "apps/desktop/scripts/stage-development-bundled-plugins.mjs", "apps/desktop/scripts/build-human-evaluation-helper.mjs", "apps/desktop/scripts/build-restricted-command-helper.mjs", "apps/desktop/scripts/cua-driver-builder-environment.mjs", @@ -83,10 +80,100 @@ export const PACKAGED_OFFICE_NATIVE_AUTHORED_REVIEW_ROOTS = Object.freeze([ "packages/daemon/src/runtime/sandbox/packaged-office-native-published-review.ts", "packages/daemon/src/runtime/sandbox/packaged-office-native-resources.ts", "packages/daemon/src/runtime/sandbox/protected-host-office-native-identity.ts", - "apps/desktop/main/local-services.ts", ]); export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ + // Shared HTTP/upgrade authentication; this entry does not approve new bytes. + "packages/daemon/src/server/request-auth.ts", + // Explicit managed-engine runtime closure; listing alone grants no approval. + "packages/contracts/src/engine-lifecycle.ts", + "packages/daemon/src/features/execution-engines/engine-scanner.ts", + "packages/daemon/src/features/execution-engines/managed-engine-installer.ts", + // Fixed inline write worker and shared bounded observer sink; listing grants no approval. + "packages/daemon/src/runtime/acp/host-text-write-worker.ts", + "packages/daemon/src/features/runs/run-execution-sink.ts", + // Explicit current-main feature closure; content review and QA binding remain separate gates. + "packages/contracts/src/artifacts.ts", + "packages/contracts/src/diagnostics.ts", + "packages/contracts/src/im-destinations.ts", + "packages/contracts/src/marketplace.ts", + "packages/contracts/src/notifications.ts", + "packages/daemon/src/features/diagnostics/routes.ts", + "packages/daemon/src/features/im-destinations/registry.ts", + "packages/daemon/src/features/im-destinations/routes.ts", + "packages/daemon/src/features/notifications/hub.ts", + "packages/daemon/src/features/notifications/routes.ts", + "packages/daemon/src/features/skills/marketplace-route.ts", + // Explicit pending-operation closure; listing these inputs grants no approval. + "packages/core/src/channel-pending-operation.ts", + "packages/core/src/storage/sqlite/channel-pending-operation-migration.ts", + "packages/core/src/storage/sqlite/channel-pending-authority.ts", + "packages/daemon/src/features/channels/channel-pending-operation.ts", + // Host-issued channel dependency scope; listing alone grants no approval. + "packages/daemon/src/features/channels/channel-command-input-policy.ts", + // FD5 observer and its CLI signal owner are explicit, independently reviewed inputs. + "packages/core/src/daemon-shutdown-observer.ts", + "packages/daemon/src/runtime/signal-shutdown.ts", + // Explicit source-bound command cwd delta; listing is not review approval. + "packages/daemon/src/features/runs/workflow-command-cwd.ts", + "packages/daemon/src/runtime/sandbox/restricted-command-directory.ts", + "packages/core/src/storage/sqlite/work-summary-migration.ts", + "packages/core/src/storage/sqlite/work-summary-store.ts", + "packages/core/src/string-boundaries.ts", + "packages/daemon/src/features/work-summary/cache.ts", + "packages/daemon/src/features/work-summary/git-summary.ts", + "packages/daemon/src/features/work-summary/pull-requests.ts", + "packages/daemon/src/features/work-summary/routes.ts", + "packages/daemon/src/features/work-summary/service.ts", + "packages/daemon/src/shared/html-text.ts", + // Explicit P3 read-only context-compaction projection; listing is not approval. + "packages/daemon/src/features/conversations/context-compaction-projection.ts", + // Explicit cold resource-bootstrap delta; listing alone grants no approval. + "apps/desktop/main/daemon-lifecycle.ts", + // Explicit shutdown/lease-consumer delta, independently reviewed before binding. + "apps/desktop/main/desktop-runtime.ts", + "packages/core/src/daemon-resource-bootstrap.ts", + // Explicit graceful-tree/final-settlement/terminal-handoff review delta. + // Merely listing these inputs grants no approval; current bytes need review. + "packages/core/src/run-final-settlement-types.ts", + "packages/core/src/run-suspension-activation-types.ts", + "packages/core/src/run-suspension-delegated-types.ts", + "packages/core/src/run-suspension-start-types.ts", + "packages/core/src/run-suspension-stop-types.ts", + "packages/core/src/run-suspension-types.ts", + "packages/core/src/storage/sqlite/delegation-resume-lineage.ts", + "packages/core/src/storage/sqlite/run-compaction-suspension-effects.ts", + "packages/core/src/storage/sqlite/run-compaction-suspension-lineage.ts", + "packages/core/src/storage/sqlite/run-compaction-suspension-witness.ts", + "packages/core/src/storage/sqlite/run-final-settlement-handoff.ts", + "packages/core/src/storage/sqlite/run-final-settlement-migration.ts", + "packages/core/src/storage/sqlite/run-final-settlement-records.ts", + "packages/core/src/storage/sqlite/run-final-settlement-store.ts", + "packages/core/src/storage/sqlite/run-suspension-activation-migration.ts", + "packages/core/src/storage/sqlite/run-suspension-activation-records.ts", + "packages/core/src/storage/sqlite/run-suspension-activation-store.ts", + "packages/core/src/storage/sqlite/run-suspension-delegated-migration.ts", + "packages/core/src/storage/sqlite/run-suspension-delegated-records.ts", + "packages/core/src/storage/sqlite/run-suspension-delegated-store.ts", + "packages/core/src/storage/sqlite/run-suspension-dependencies.ts", + "packages/core/src/storage/sqlite/run-suspension-migration.ts", + "packages/core/src/storage/sqlite/run-suspension-queued-proof.ts", + "packages/core/src/storage/sqlite/run-suspension-start-migration.ts", + "packages/core/src/storage/sqlite/run-suspension-start-records.ts", + "packages/core/src/storage/sqlite/run-suspension-start-store.ts", + "packages/core/src/storage/sqlite/run-suspension-stop-migration.ts", + "packages/core/src/storage/sqlite/run-suspension-stop-records.ts", + "packages/core/src/storage/sqlite/run-suspension-stop-store.ts", + "packages/core/src/storage/sqlite/run-suspension-stopped-handoff.ts", + "packages/core/src/storage/sqlite/run-suspension-store.ts", + "packages/core/src/storage/sqlite/run-suspension-witness.ts", + "packages/daemon/src/features/harness/continuations/final-settlement-observation.ts", + "packages/daemon/src/features/harness/continuations/root-suspension-dispatch.ts", + "packages/daemon/src/features/harness/continuations/run-compaction-suspension.ts", + "packages/daemon/src/features/harness/continuations/run-graceful-suspension.ts", + "packages/daemon/src/runtime/delegated-terminal-handoff.ts", + "packages/daemon/src/runtime/run-shutdown-coordinator.ts", + "packages/daemon/src/runtime/run-suspension-order.ts", "apps/desktop/scripts/packaged-gstack-resources.mjs", "scripts/gstack/executable-bundle.mjs", "scripts/gstack/upstream-lock.json", @@ -102,6 +189,10 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "apps/desktop/package.json", "apps/desktop/scripts/build-packaged-daemon.mjs", "apps/desktop/scripts/build-packaged-office-native.mjs", + "apps/desktop/scripts/stage-development-bundled-plugins.mjs", + "packages/daemon/src/features/plugins/bundled/cua-driver/.ling-plugin/plugin.json", + "packages/daemon/src/features/plugins/bundled/cua-driver/mcp-server.json", + "packages/daemon/src/features/plugins/bundled/cua-driver/README.md", "apps/desktop/scripts/build-restricted-command-helper.mjs", "apps/desktop/scripts/cua-driver-binary-identity.mjs", "apps/desktop/scripts/cua-driver-builder-environment.mjs", @@ -116,6 +207,7 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "apps/desktop/scripts/cua-driver-package-staging-source.mjs", "apps/desktop/scripts/cua-driver-package-staging-validation.mjs", "apps/desktop/scripts/cua-driver-package-staging.mjs", + "apps/desktop/scripts/cua-driver-parent-lease-stream-writer.mjs", "apps/desktop/scripts/cua-driver-signing/after-pack.mjs", "apps/desktop/scripts/cua-driver-signing/after-sign.mjs", "apps/desktop/scripts/cua-driver-signing/artifact-build-completed.mjs", @@ -265,6 +357,7 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/core/src/harness/common.ts", "packages/core/src/harness/continuations.ts", "packages/core/src/harness/delegations.ts", + "packages/core/src/harness/delegation-authority.ts", "packages/core/src/harness/evaluation-json.ts", "packages/core/src/harness/evaluation-qa.ts", "packages/core/src/harness/human-evaluation-read.ts", @@ -334,10 +427,11 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/core/src/remote-access-types.ts", "packages/core/src/run-progress-checkpoint-lineage.ts", "packages/core/src/run-progress-checkpoint-types.ts", + "packages/core/src/run-terminal-intent-types.ts", "packages/core/src/run-types.ts", - "packages/core/src/runner-ids.ts", "packages/core/src/runtime-paths.ts", "packages/core/src/runtime-permissions.ts", + "packages/core/src/runtime-resource-renewal.ts", "packages/core/src/runtime-supervisor-types.ts", "packages/core/src/runtime-trace-projection.ts", "packages/core/src/runtime-trace-types.ts", @@ -418,6 +512,7 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/core/src/storage/sqlite/channel-team-snapshot-mappers.ts", "packages/core/src/storage/sqlite/channel-team-snapshot-store.ts", "packages/core/src/storage/sqlite/channel-workflow-mappers.ts", + "packages/core/src/storage/sqlite/channel-workflow-pre-approval-store.ts", "packages/core/src/storage/sqlite/channel-workflow-start-store.ts", "packages/core/src/storage/sqlite/channel-workflow-store.ts", "packages/core/src/storage/sqlite/chrome-browser-credential-store.ts", @@ -436,6 +531,7 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/core/src/storage/sqlite/conversation-asset-store.ts", "packages/core/src/storage/sqlite/conversation-context-store.ts", "packages/core/src/storage/sqlite/conversation-context-summary-store.ts", + "packages/core/src/storage/sqlite/conversation-empty-fork-migration.ts", "packages/core/src/storage/sqlite/conversation-item-store.ts", "packages/core/src/storage/sqlite/conversation-media-execution-admission.ts", "packages/core/src/storage/sqlite/conversation-prepared-input-store.ts", @@ -566,6 +662,8 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/core/src/storage/sqlite/run-progress-checkpoint-effects.ts", "packages/core/src/storage/sqlite/run-progress-checkpoint-migration.ts", "packages/core/src/storage/sqlite/run-progress-checkpoint-store.ts", + "packages/core/src/storage/sqlite/run-terminal-intent-migration.ts", + "packages/core/src/storage/sqlite/run-terminal-intent-store.ts", "packages/core/src/storage/sqlite/run-context-view-lineage-migration.ts", "packages/core/src/storage/sqlite/run-evaluation-mappers.ts", "packages/core/src/storage/sqlite/run-evaluation-migration.ts", @@ -626,6 +724,8 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/core/src/storage/sqlite/terminal-report-mappers.ts", "packages/core/src/storage/sqlite/terminal-report-publication-store.ts", "packages/core/src/storage/sqlite/terminal-report-store.ts", + "packages/core/src/storage/sqlite/user-input-admission-policy.ts", + "packages/core/src/storage/sqlite/user-input-answer-authority.ts", "packages/core/src/storage/sqlite/user-input-continuation-envelope.ts", "packages/core/src/storage/sqlite/user-input-continuation-records.ts", "packages/core/src/storage/sqlite/user-input-continuation-store.ts", @@ -638,9 +738,12 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/core/src/storage/sqlite/user-input-host-continuation-store.ts", "packages/core/src/storage/sqlite/user-input-media-settlements.ts", "packages/core/src/storage/sqlite/user-input-migration.ts", + "packages/core/src/storage/sqlite/user-input-prestart-recovery-store.ts", + "packages/core/src/storage/sqlite/user-input-run-created-replay.ts", "packages/core/src/storage/sqlite/user-input-preparation-store.ts", "packages/core/src/storage/sqlite/user-input-records.ts", "packages/core/src/storage/sqlite/user-input-source.ts", + "packages/core/src/storage/sqlite/user-input-startup-failure.ts", "packages/core/src/storage/sqlite/user-input-store.ts", "packages/core/src/storage/sqlite/user-input-transcript-store.ts", "packages/core/src/storage/sqlite/user-input-wait-cancellation-store.ts", @@ -649,6 +752,7 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/core/src/storage/sqlite/user-input-wait-projection.ts", "packages/core/src/storage/sqlite/user-input-wait-source.ts", "packages/core/src/storage/sqlite/user-input-wait-store.ts", + "packages/core/src/storage/sqlite/user-input-workflow-admission.ts", "packages/core/src/storage/sqlite/user-profile-store.ts", "packages/core/src/storage/sqlite/work-item-store.ts", "packages/core/src/storage/sqlite/workspace-binding-parsers.ts", @@ -675,10 +779,12 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/core/src/work-item-types.ts", "packages/core/src/workflow-completion-read-set.ts", "packages/core/src/workflow-graph-types.ts", + "packages/core/src/workflow-pre-gate.ts", "packages/core/src/workflow-step-start-owner.ts", "packages/core/src/workspace-types.ts", "packages/daemon/src/cli.ts", "packages/daemon/src/config.ts", + "packages/daemon/src/runtime/packaged-qa-harness-bootstrap.ts", "packages/daemon/src/errors.ts", "packages/daemon/src/features/ai-councils/routes.ts", "packages/daemon/src/features/ai-councils/schemas.ts", @@ -699,9 +805,10 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/daemon/src/features/channels/ai-prompts.ts", "packages/daemon/src/features/channels/ai-replies.ts", "packages/daemon/src/features/channels/attachments.ts", + "packages/daemon/src/features/channels/channel-branch-origin.ts", "packages/daemon/src/features/channels/channel-context-inspect.ts", "packages/daemon/src/features/channels/channel-conversation-api.ts", - "packages/daemon/src/features/channels/conversation-runtime-metrics-scope.ts", + "packages/daemon/src/features/channels/channel-intent-diagnostics.ts", "packages/daemon/src/features/channels/channel-intent-selector.ts", "packages/daemon/src/features/channels/file-request-content.ts", "packages/daemon/src/features/channels/channel-mention-materializer.ts", @@ -792,6 +899,7 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/daemon/src/features/channels/team-metrics.ts", "packages/daemon/src/features/channels/team-patterns.ts", "packages/daemon/src/features/channels/team-snapshots.ts", + "packages/daemon/src/features/channels/terminal-report-command-evidence.ts", "packages/daemon/src/features/channels/terminal-report-coordinator.ts", "packages/daemon/src/features/channels/terminal-report-eligibility.ts", "packages/daemon/src/features/channels/terminal-report-evidence.ts", @@ -814,12 +922,14 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/daemon/src/features/channels/workflow-operation-ownership.ts", "packages/daemon/src/features/channels/workflow-participant-provider.ts", "packages/daemon/src/features/channels/workflow-participant-run-profile.ts", + "packages/daemon/src/features/channels/workflow-pre-approval.ts", "packages/daemon/src/features/channels/workflow-reconciliation.ts", "packages/daemon/src/features/channels/workflow-selector.ts", "packages/daemon/src/features/channels/workflow-source-turn.ts", "packages/daemon/src/features/channels/workflow-step-finalizer.ts", "packages/daemon/src/features/channels/workflow-step-narration.ts", "packages/daemon/src/features/channels/workflow-step-packets.ts", + "packages/daemon/src/features/channels/workflow-step-prompt.ts", "packages/daemon/src/features/channels/workflow-step-requirement.ts", "packages/daemon/src/features/channels/workflow-step-run-policy.ts", "packages/daemon/src/features/channels/workflow-step-user-input-history.ts", @@ -845,7 +955,6 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/daemon/src/features/control-plane/routes.ts", "packages/daemon/src/features/control-plane/snapshots.ts", "packages/daemon/src/features/conversations/active-reply-target.ts", - "packages/daemon/src/features/conversations/context-compaction-projection.ts", "packages/daemon/src/features/conversations/conversation-tone.ts", "packages/daemon/src/features/conversations/materialization.ts", "packages/daemon/src/features/conversations/message-edits.ts", @@ -867,12 +976,11 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/daemon/src/features/conversations/normal-execution-context-snapshots.ts", "packages/daemon/src/features/conversations/normal-narration-publication.ts", "packages/daemon/src/features/conversations/reasoning-summary-projection.ts", - "packages/daemon/src/features/conversations/all-turns.ts", - "packages/daemon/src/features/conversations/runtime-metrics-projection.ts", "packages/daemon/src/features/conversations/routes.ts", "packages/daemon/src/features/conversations/runtime-projection.ts", "packages/daemon/src/features/conversations/submission-receipts.ts", "packages/daemon/src/features/conversations/supersession-projection.ts", + "packages/daemon/src/features/conversations/user-input-answer-route.ts", "packages/daemon/src/features/conversations/user-input-transcript.ts", "packages/daemon/src/features/external-work/actions.ts", "packages/daemon/src/features/external-work/background-scheduler.ts", @@ -903,6 +1011,7 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/daemon/src/features/harness/evaluations/task-worker-input.ts", "packages/daemon/src/features/harness/evaluations/task-worker-prepared.ts", "packages/daemon/src/features/harness/evaluations/task-worker.ts", + "packages/daemon/src/features/harness/evaluations/task-worker-composition.ts", "packages/daemon/src/features/harness/evaluations/qa-model-json.ts", "packages/daemon/src/features/harness/evaluations/qa-model-preparation.ts", "packages/daemon/src/features/harness/evaluations/qa-model-owned-batch.ts", @@ -915,6 +1024,7 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/daemon/src/features/harness/evaluations/human-evaluation-service.ts", "packages/daemon/src/features/harness/index.ts", "packages/daemon/src/features/harness/tool-execution/broker.ts", + "packages/daemon/src/features/harness/tool-execution/broker-command-result.ts", "packages/daemon/src/features/harness/tool-execution/executors/file-mutation.ts", "packages/daemon/src/features/harness/tool-execution/native-file-mutation-completion.ts", "packages/daemon/src/features/harness/tool-execution/recovery.ts", @@ -1075,7 +1185,9 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/daemon/src/features/runs/approval-auto-review-recovery.ts", "packages/daemon/src/features/runs/approval-command-rules.ts", "packages/daemon/src/features/runs/approval-continuation-tool.ts", + "packages/daemon/src/features/runs/approval-decision-projection.ts", "packages/daemon/src/features/runs/approval-event-state.ts", + "packages/daemon/src/features/runs/approval-postcommit-evidence.ts", "packages/daemon/src/features/runs/approval-resume-preparation.ts", "packages/daemon/src/features/runs/approval-postcommit-effects.ts", "packages/daemon/src/features/runs/approval-postcommit-recovery.ts", @@ -1116,6 +1228,7 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/daemon/src/features/runs/provider-persistence.ts", "packages/daemon/src/features/runs/providers.ts", "packages/daemon/src/features/runs/public-artifacts.ts", + "packages/daemon/src/features/runs/public-execution-snapshots.ts", "packages/daemon/src/features/runs/queue.ts", "packages/daemon/src/features/runs/restricted-command-dispatch.ts", "packages/daemon/src/features/runs/risk-review-approval-adapter.ts", @@ -1133,6 +1246,7 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/daemon/src/features/runs/thread-hard-delete-route.ts", "packages/daemon/src/features/runs/thread-routes.ts", "packages/daemon/src/features/runs/threads.ts", + "packages/daemon/src/features/runs/workflow-step-input-turn.ts", "packages/daemon/src/features/runs/tool-context-budget.ts", "packages/daemon/src/features/runs/tool-surfaces.ts", "packages/daemon/src/features/runs/traces-routes.ts", @@ -1142,9 +1256,19 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/daemon/src/features/runs/unified-exec-raw-source.ts", "packages/daemon/src/features/runs/unified-exec-request.ts", "packages/daemon/src/features/runs/unified-exec.ts", + "packages/daemon/src/features/runs/user-input-answer-dispatch.ts", + "packages/daemon/src/features/runs/user-input-answer-routes.ts", "packages/daemon/src/features/runs/user-input-cancellation-guard.ts", + "packages/daemon/src/features/runs/user-input-continuation-context.ts", + "packages/daemon/src/features/runs/user-input-continuation-receiver.ts", + "packages/daemon/src/features/runs/user-input-delivery-worker.ts", + "packages/daemon/src/features/runs/user-input-execution-preparation.ts", "packages/daemon/src/features/runs/user-input-local-writer-scope.ts", "packages/daemon/src/features/runs/user-input-pause-coordinator.ts", + "packages/daemon/src/features/runs/user-input-prepared-request.ts", + "packages/daemon/src/features/runs/user-input-source-execution.ts", + "packages/daemon/src/features/runs/user-input-source-policy.ts", + "packages/daemon/src/features/runs/user-input-workflow-terminal.ts", "packages/daemon/src/features/runs/work-units.ts", "packages/daemon/src/features/runs/workflow-operation-effect-gate.ts", "packages/daemon/src/features/runs/workflow-operation-snapshot.ts", @@ -1237,11 +1361,15 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/daemon/src/runtime/sandbox/core-original-process-bridge.ts", "packages/daemon/src/runtime/sandbox/docker.ts", "packages/daemon/src/runtime/sandbox/electron-node-resources.ts", + "packages/daemon/src/runtime/sandbox/external-workflow-request-runtime.ts", + "packages/daemon/src/runtime/sandbox/external-workflow-request-source.ts", + "packages/daemon/src/runtime/sandbox/external-workflow-request-worker-source.ts", "packages/daemon/src/runtime/sandbox/macos-native-command.ts", "packages/daemon/src/runtime/sandbox/macos-sandbox-backend.ts", "packages/daemon/src/runtime/sandbox/macos-seatbelt-profile.ts", "packages/daemon/src/runtime/sandbox/native-input-capture-protocol.ts", "packages/daemon/src/runtime/sandbox/native-input-capture.ts", + "packages/daemon/src/runtime/sandbox/node-command-inputs.ts", "packages/daemon/src/runtime/sandbox/packaged-codesign-held-file.ts", "packages/daemon/src/runtime/sandbox/packaged-codesign-host-source.ts", "packages/daemon/src/runtime/sandbox/packaged-codesign-native-protocol.ts", @@ -1264,6 +1392,7 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/daemon/src/runtime/store-registry.ts", "packages/daemon/src/runtime/supervisor.ts", "packages/daemon/src/runtime/work-units.ts", + "packages/daemon/src/runtime/workspace-runtime-owner.ts", "packages/daemon/src/server/router.ts", "packages/daemon/src/server/routes.ts", "packages/daemon/src/server/rpc-outbound.ts", @@ -1436,6 +1565,7 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/runner-pi/src/provider-request-auth.ts", "packages/runner-pi/src/reasoning-summary.ts", "packages/runner-pi/src/resource-loader.ts", + "packages/runner-pi/src/runtime-suspension.ts", "packages/runner-pi/src/session-continuation.ts", "packages/runner-pi/src/session-factory.ts", "packages/runner-pi/src/session-transcript.ts", @@ -1445,183 +1575,187 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/runner-pi/src/types.ts", "packages/runner-pi/src/user-input-request.ts", "packages/runner-pi/src/utils.ts", - "pnpm-lock.yaml", - "packages/browser-host/tsconfig.json", - "packages/contracts/tsconfig.json", - "packages/core/tsconfig.json", - "packages/daemon/package.json", - "packages/daemon/tsconfig.json", - "packages/localization/tsconfig.json", - "packages/plugin-sdk/tsconfig.json", - "packages/providers/tsconfig.json", - "packages/remote-protocol/tsconfig.json", - "packages/runner-pi/tsconfig.json", - // Reviewed literal authored edges added by native-engine, user-input and - // work-summary integration. Explicit data only; builds never discover inputs. "packages/contracts/src/execution-engines.ts", "packages/contracts/src/runner-acceptance.ts", "packages/core/src/approval-decisions.ts", + "packages/core/src/delegation-completion-barrier.ts", + "packages/core/src/delegation-completion-evidence.ts", + "packages/core/src/delegation-host-store-bridge.ts", "packages/core/src/execution-engine-host-authority.ts", "packages/core/src/execution-engine-profiles.ts", + "packages/core/src/harness/delegation-lifecycle-types.ts", + "packages/core/src/harness/delegation-write-types.ts", + "packages/core/src/harness/delegation-write-validation.ts", + "packages/core/src/harness/reliability.ts", "packages/core/src/native-artifacts.ts", "packages/core/src/native-execution-types.ts", "packages/core/src/native-openclaw-session.ts", "packages/core/src/storage/execution-engine-profile-store.ts", + "packages/core/src/storage/sqlite/delegation-budget-migration.ts", + "packages/core/src/storage/sqlite/delegation-completion-evidence.ts", + "packages/core/src/storage/sqlite/delegation-instruction-migration.ts", + "packages/core/src/storage/sqlite/delegation-lifecycle-store.ts", + "packages/core/src/storage/sqlite/delegation-stopped-handoff.ts", + "packages/core/src/storage/sqlite/delegation-write-store.ts", + "packages/core/src/storage/sqlite/harness-artifact-retention-store.ts", + "packages/core/src/storage/sqlite/harness-artifact-recovery-pins.ts", + "packages/core/src/storage/sqlite/harness-projection-checkpoint-store.ts", + "packages/core/src/storage/sqlite/harness-reliability-migration.ts", + "packages/core/src/storage/sqlite/harness-retention-index-migration.ts", + "packages/core/src/storage/sqlite/harness-recovery-index-migration.ts", "packages/core/src/storage/sqlite/host-attempt-native-coverage.ts", - "packages/core/src/storage/sqlite/native-approval-wait.ts", + "packages/core/src/storage/sqlite/host-attempt-read-scope.ts", "packages/core/src/storage/sqlite/native-execution-migration.ts", "packages/core/src/storage/sqlite/native-execution-store.ts", - "packages/core/src/storage/sqlite/native-run-artifacts.ts", - "packages/core/src/storage/sqlite/native-run-output.ts", - "packages/core/src/storage/sqlite/user-input-admission-policy.ts", - "packages/core/src/storage/sqlite/user-input-startup-failure.ts", - "packages/core/src/storage/sqlite/user-input-workflow-admission.ts", - "packages/core/src/storage/sqlite/work-summary-migration.ts", - "packages/core/src/storage/sqlite/work-summary-store.ts", - "packages/core/src/string-boundaries.ts", + "packages/core/src/storage/sqlite/run-ancestry.ts", + "packages/daemon/src/features/channels/terminal-report-delegation-evidence.ts", "packages/daemon/src/features/conversations/conversation-execution-selection.ts", "packages/daemon/src/features/conversations/conversation-run-request.ts", "packages/daemon/src/features/conversations/native-response-preferences.ts", - "packages/daemon/src/features/execution-engines/acp-connection.ts", - "packages/daemon/src/features/execution-engines/acp-probe-transport.ts", + "packages/daemon/src/features/execution-engines/engine-secret-store.ts", + "packages/daemon/src/features/execution-engines/feature.ts", + "packages/daemon/src/features/harness/delegations/completion-wait.ts", + "packages/daemon/src/features/harness/delegations/delegated-run-dispatch.ts", + "packages/daemon/src/features/harness/delegations/delegated-run-recovery.ts", + "packages/daemon/src/features/harness/delegations/provider-budget-host.ts", + "packages/daemon/src/features/harness/delegations/runner-tools.ts", + "packages/daemon/src/features/harness/delegations/runtime-registry.ts", + "packages/daemon/src/features/harness/delegations/store-composition.ts", + "packages/daemon/src/features/harness/evaluations/qa-model-pilot-runner.ts", + "packages/daemon/src/features/harness/reliability/artifact-retention-service.ts", + "packages/daemon/src/features/harness/reliability/lifecycle-coordinator.ts", + "packages/daemon/src/features/harness/reliability/projection-reconciler.ts", + "packages/daemon/src/features/runs/approval-action-policy.ts", + "packages/daemon/src/features/runs/existing-idempotent-run.ts", + "packages/daemon/src/features/runs/native-approval-events.ts", + "packages/daemon/src/features/runs/native-run-dispatcher.ts", + "packages/daemon/src/features/runs/preparation-thread-failure.ts", + "packages/daemon/src/features/runs/run-ready.ts", + "packages/daemon/src/runtime/adapters/native-artifact-files.ts", + "packages/daemon/src/runtime/conversation-execution-route.ts", + "packages/daemon/src/runtime/native-runners.ts", + "packages/daemon/src/runtime/run-execution-supervision.ts", + "packages/runner-pi/src/delegation-instruction.ts", + "packages/runner-pi/src/extensions/delegation.ts", + "packages/runner-pi/src/provider-budget.ts", + "packages/core/src/storage/sqlite/delegation-budget-rows.ts", + "packages/core/src/storage/sqlite/harness-reliability-schema.ts", + "packages/core/src/storage/sqlite/native-approval-wait.ts", + "packages/core/src/storage/sqlite/native-run-artifacts.ts", + "packages/core/src/storage/sqlite/native-run-output.ts", "packages/daemon/src/features/execution-engines/authority.ts", + "packages/daemon/src/features/execution-engines/connection-probe.ts", + "packages/daemon/src/features/execution-engines/controller.ts", + "packages/daemon/src/features/execution-engines/installation-probe.ts", + "packages/daemon/src/features/execution-engines/login-manager.ts", + "packages/daemon/src/features/execution-engines/run-target-dispatcher.ts", + "packages/daemon/src/features/harness/delegations/host-authority.ts", + "packages/daemon/src/features/harness/delegations/price-registry.ts", + "packages/daemon/src/features/runs/native-conversation-selection.ts", + "packages/daemon/src/runtime/runner-acceptance-store.ts", + "packages/daemon/src/features/execution-engines/acp-connection.ts", "packages/daemon/src/features/execution-engines/claude-connection.ts", "packages/daemon/src/features/execution-engines/claude-environment.ts", - "packages/daemon/src/features/execution-engines/claude-probe-process.ts", "packages/daemon/src/features/execution-engines/codex-auth-state.ts", "packages/daemon/src/features/execution-engines/codex-connection.ts", - "packages/daemon/src/features/execution-engines/codex-login.ts", - "packages/daemon/src/features/execution-engines/connection-probe.ts", - "packages/daemon/src/features/execution-engines/controller.ts", - "packages/daemon/src/features/execution-engines/engine-secret-store.ts", - "packages/daemon/src/features/execution-engines/feature.ts", - "packages/daemon/src/features/execution-engines/installation-probe.ts", "packages/daemon/src/features/execution-engines/kimi-auth-state.ts", "packages/daemon/src/features/execution-engines/kimi-connection.ts", "packages/daemon/src/features/execution-engines/kimi-native-executable.ts", "packages/daemon/src/features/execution-engines/kimi-probe-isolation.ts", - "packages/daemon/src/features/execution-engines/kimi-terminal-login.ts", - "packages/daemon/src/features/execution-engines/login-manager.ts", "packages/daemon/src/features/execution-engines/native-admission.ts", - "packages/daemon/src/features/execution-engines/native-auth-file.ts", "packages/daemon/src/features/execution-engines/native-auth-home.ts", "packages/daemon/src/features/execution-engines/native-login.ts", "packages/daemon/src/features/execution-engines/native-secrets.ts", "packages/daemon/src/features/execution-engines/openclaw-environment.ts", "packages/daemon/src/features/execution-engines/owned-native-process.ts", - "packages/daemon/src/features/execution-engines/run-target-dispatcher.ts", "packages/daemon/src/features/execution-engines/secret-profile.ts", "packages/daemon/src/features/execution-engines/windows-supervisor.ts", - "packages/daemon/src/features/runs/approval-action-policy.ts", - "packages/daemon/src/features/runs/existing-idempotent-run.ts", - "packages/daemon/src/features/runs/native-approval-events.ts", - "packages/daemon/src/features/runs/native-conversation-selection.ts", - "packages/daemon/src/features/runs/native-run-dispatcher.ts", - "packages/daemon/src/features/runs/preparation-thread-failure.ts", - "packages/daemon/src/features/runs/run-ready.ts", - "packages/daemon/src/features/runs/user-input-answer-dispatch.ts", - "packages/daemon/src/features/runs/user-input-answer-routes.ts", - "packages/daemon/src/features/runs/user-input-continuation-context.ts", - "packages/daemon/src/features/runs/user-input-continuation-receiver.ts", - "packages/daemon/src/features/runs/user-input-delivery-worker.ts", - "packages/daemon/src/features/runs/user-input-execution-preparation.ts", - "packages/daemon/src/features/runs/user-input-prepared-request.ts", - "packages/daemon/src/features/runs/user-input-source-execution.ts", - "packages/daemon/src/features/runs/user-input-source-policy.ts", - "packages/daemon/src/features/runs/user-input-workflow-terminal.ts", - "packages/daemon/src/features/work-summary/cache.ts", - "packages/daemon/src/features/work-summary/git-summary.ts", - "packages/daemon/src/features/work-summary/pull-requests.ts", - "packages/daemon/src/features/work-summary/routes.ts", - "packages/daemon/src/features/work-summary/service.ts", + "packages/daemon/src/runtime/acp/journaled-lifecycle.ts", + "packages/daemon/src/runtime/acp/kimi-owned-execution.ts", + "packages/daemon/src/runtime/acp/openclaw-owned-execution.ts", + "packages/daemon/src/runtime/adapters/codex/journaled-lifecycle.ts", + "packages/daemon/src/runtime/adapters/codex/owned-execution.ts", + "packages/daemon/src/runtime/runner-acceptance.ts", + "packages/daemon/src/features/execution-engines/acp-probe-transport.ts", + "packages/daemon/src/features/execution-engines/claude-probe-process.ts", + "packages/daemon/src/features/execution-engines/codex-login.ts", + "packages/daemon/src/features/execution-engines/kimi-terminal-login.ts", + "packages/daemon/src/features/execution-engines/native-auth-file.ts", "packages/daemon/src/runtime/acp/approval-journal.ts", - "packages/daemon/src/runtime/acp/content.ts", "packages/daemon/src/runtime/acp/event-normalizer.ts", - "packages/daemon/src/runtime/acp/host-files.ts", - "packages/daemon/src/runtime/acp/host-requests.ts", - "packages/daemon/src/runtime/acp/host-terminal-process.ts", "packages/daemon/src/runtime/acp/host-terminals.ts", - "packages/daemon/src/runtime/acp/host-text-read.ts", - "packages/daemon/src/runtime/acp/host-text-write.ts", - "packages/daemon/src/runtime/acp/journaled-lifecycle.ts", "packages/daemon/src/runtime/acp/kimi-action-preparation.ts", - "packages/daemon/src/runtime/acp/kimi-bash-actions.ts", "packages/daemon/src/runtime/acp/kimi-execution-home.ts", - "packages/daemon/src/runtime/acp/kimi-file-actions.ts", "packages/daemon/src/runtime/acp/kimi-host-files.ts", "packages/daemon/src/runtime/acp/kimi-host-terminals.ts", - "packages/daemon/src/runtime/acp/kimi-model-configuration.ts", - "packages/daemon/src/runtime/acp/kimi-model-transport.ts", - "packages/daemon/src/runtime/acp/kimi-oauth-credentials.ts", - "packages/daemon/src/runtime/acp/kimi-owned-execution.ts", "packages/daemon/src/runtime/acp/live-client.ts", "packages/daemon/src/runtime/acp/live-errors.ts", - "packages/daemon/src/runtime/acp/live-stream.ts", "packages/daemon/src/runtime/acp/openclaw-action-preparation.ts", - "packages/daemon/src/runtime/acp/openclaw-owned-execution.ts", - "packages/daemon/src/runtime/acp/session-metadata.ts", "packages/daemon/src/runtime/acp/session-settings.ts", - "packages/daemon/src/runtime/acp/tool-observation.ts", "packages/daemon/src/runtime/adapters/codex/action-preparation.ts", "packages/daemon/src/runtime/adapters/codex/approval-journal.ts", "packages/daemon/src/runtime/adapters/codex/approval-wait.ts", - "packages/daemon/src/runtime/adapters/codex/duplex.ts", "packages/daemon/src/runtime/adapters/codex/event-normalizer.ts", "packages/daemon/src/runtime/adapters/codex/file-change-context.ts", - "packages/daemon/src/runtime/adapters/codex/journaled-lifecycle.ts", "packages/daemon/src/runtime/adapters/codex/live-client.ts", + "packages/daemon/src/runtime/adapters/native-artifact-import.ts", + "packages/daemon/src/runtime/adapters/native-stream-redaction.ts", + "packages/daemon/src/runtime/sandbox/native-process-launch.ts", + "packages/daemon/src/runtime/acp/content.ts", + "packages/daemon/src/runtime/acp/host-files.ts", + "packages/daemon/src/runtime/acp/host-requests.ts", + "packages/daemon/src/runtime/acp/host-terminal-process.ts", + "packages/daemon/src/runtime/acp/host-text-read.ts", + "packages/daemon/src/runtime/acp/host-text-write.ts", + "packages/daemon/src/runtime/acp/kimi-bash-actions.ts", + "packages/daemon/src/runtime/acp/kimi-file-actions.ts", + "packages/daemon/src/runtime/acp/kimi-model-configuration.ts", + "packages/daemon/src/runtime/acp/kimi-model-transport.ts", + "packages/daemon/src/runtime/acp/live-stream.ts", + "packages/daemon/src/runtime/acp/session-metadata.ts", + "packages/daemon/src/runtime/acp/tool-observation.ts", + "packages/daemon/src/runtime/adapters/codex/duplex.ts", "packages/daemon/src/runtime/adapters/codex/local-environment.ts", "packages/daemon/src/runtime/adapters/codex/observed-tools.ts", - "packages/daemon/src/runtime/adapters/codex/owned-execution.ts", "packages/daemon/src/runtime/adapters/codex/permission-preparation.ts", "packages/daemon/src/runtime/adapters/codex/tool-content.ts", "packages/daemon/src/runtime/adapters/codex/tool-output.ts", - "packages/daemon/src/runtime/adapters/native-artifact-files.ts", - "packages/daemon/src/runtime/adapters/native-artifact-import.ts", "packages/daemon/src/runtime/adapters/native-command-policy.ts", "packages/daemon/src/runtime/adapters/native-image-sanitizer.ts", + "packages/daemon/src/runtime/acp/kimi-oauth-credentials.ts", "packages/daemon/src/runtime/adapters/native-model-relay.ts", - "packages/daemon/src/runtime/adapters/native-stream-redaction.ts", "packages/daemon/src/runtime/adapters/native-tool-content.ts", - "packages/daemon/src/runtime/conversation-execution-route.ts", - "packages/daemon/src/runtime/native-runners.ts", - "packages/daemon/src/runtime/run-execution-supervision.ts", - "packages/daemon/src/runtime/runner-acceptance-store.ts", - "packages/daemon/src/runtime/runner-acceptance.ts", - "packages/daemon/src/runtime/sandbox/native-process-launch.ts", - "packages/daemon/src/shared/html-text.ts", + "pnpm-lock.yaml", + "packages/browser-host/tsconfig.json", + "packages/contracts/tsconfig.json", + "packages/core/tsconfig.json", + "packages/daemon/package.json", + "packages/daemon/tsconfig.json", + "packages/localization/tsconfig.json", + "packages/plugin-sdk/tsconfig.json", + "packages/providers/tsconfig.json", + "packages/remote-protocol/tsconfig.json", + "packages/runner-pi/tsconfig.json", + // Reviewed literal authored edges added by native-engine, user-input and + // work-summary integration. Explicit data only; builds never discover inputs. // Reviewed private installer tooling and fixed first-use model acquisition inputs. "apps/desktop/scripts/distribution/installer-builder.mjs", "apps/desktop/scripts/distribution/LingShell.nsh", "scripts/gstack/testsavant-model-cache.mjs", "scripts/gstack/testsavant-model-pin.json", - - // Explicit channel-session, inherited runtime and local-service closure inputs. - "apps/desktop/main/ipc.ts", - "apps/desktop/main/local-services.ts", - "apps/desktop/main/daemon-lifecycle.ts", - "apps/desktop/main/main.ts", - "apps/desktop/preload/main-window.ts", - "apps/desktop/shared/desktop-capabilities.ts", - "apps/desktop/shared/ipc-channels.ts", - "apps/desktop/shared/local-service-capabilities.ts", - "apps/web/src/api/client.ts", + // Explicitly reviewed mainline runtime edges, 2026-10-29. Keep existing inputs + // and the 41 authored roots; packaging never discovers or approves this list. "packages/contracts/src/artifacts.ts", - "packages/contracts/src/channel-sessions-validation.ts", "packages/contracts/src/deliverables.ts", "packages/contracts/src/engine-lifecycle.ts", "packages/contracts/src/im-destinations.ts", "packages/contracts/src/marketplace.ts", "packages/contracts/src/notifications.ts", - "packages/core/src/channel-session-types.ts", - "packages/core/src/storage/sqlite/channel-session-admission.ts", - "packages/core/src/storage/sqlite/channel-session-lineage.ts", - "packages/core/src/storage/sqlite/channel-session-migration.ts", - "packages/core/src/storage/sqlite/channel-session-notification.ts", - "packages/core/src/storage/sqlite/channel-session-store.ts", + "packages/core/src/runner-ids.ts", "packages/core/src/storage/sqlite/runner-id-migration.ts", "packages/core/src/storage/sqlite/workspace-permission-migration.ts", - "packages/daemon/src/features/channels/session-routes.ts", + "packages/daemon/src/features/conversations/all-turns.ts", + "packages/daemon/src/features/conversations/runtime-metrics-projection.ts", "packages/daemon/src/features/deliverables/builders.ts", "packages/daemon/src/features/deliverables/routes.ts", "packages/daemon/src/features/deliverables/spreadsheet-extract.ts", diff --git a/apps/desktop/scripts/packaged-office-native-review.mjs b/apps/desktop/scripts/packaged-office-native-review.mjs index 6ae55da44..0a7338423 100644 --- a/apps/desktop/scripts/packaged-office-native-review.mjs +++ b/apps/desktop/scripts/packaged-office-native-review.mjs @@ -80,7 +80,11 @@ export function readReviewedPackagedOfficeSource() { exactKeys(review.headers, ["selector", "environment", "sha256"]); requireReview(review.headers.selector === "local_environment_path" && review.headers.environment === "LING_OFFICE_NODE_HEADERS"); - const observed = observeOfficeNativeToolchain(); + // Reject stale authored inputs before even invoking trusted OS discovery. + // The common checks below still recheck them after the reviewed version probe. + assertDigests(nativeRoot, review.sourceSha256, OFFICE_LOCAL_NATIVE_REVIEW_SOURCE_INPUTS, 4 * 1024 * 1024, 16 * 1024 * 1024); + assertDigests(repositoryRoot, review.packagingSha256, packagingPaths); + const observed = observeOfficeNativeToolchain({ profiles: review.toolchains, headerDigests: review.headers.sha256 }); assertOfficeNativeToolchainProfile(review.toolchains, review.headers.sha256, observed); // Expanded paths/facts are transient observations. The approved JSON stays // static and its digest continues to bind the same-process publication. diff --git a/apps/desktop/scripts/packaged-office-native-toolchain.mjs b/apps/desktop/scripts/packaged-office-native-toolchain.mjs index 17e2d6d54..3e1063266 100644 --- a/apps/desktop/scripts/packaged-office-native-toolchain.mjs +++ b/apps/desktop/scripts/packaged-office-native-toolchain.mjs @@ -1,7 +1,7 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { lstatSync, readFileSync, realpathSync } from "node:fs"; -import { dirname, isAbsolute, join, resolve } from "node:path"; +import { isAbsolute, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; export const OFFICE_NATIVE_HEADER_NAMES = Object.freeze([ @@ -38,17 +38,18 @@ function absolutePath(value) { /** * Read build facts, never approve them. Paths are observations; a committed * source review must separately match the exact compiler/SDK/header identities. - * The one environment input chooses a local header location, never header bytes. + * Paths from xcrun/header environment are untrusted observations, not execution + * authority. Without a reviewed profile this reads identities only; the version + * fields stay null. A matching compiler/SDK/header identity is required before + * the discovered compiler may execute even its --version probe. */ -export function observeOfficeNativeToolchain() { +export function observeOfficeNativeToolchain({ profiles, headerDigests } = {}) { requireToolchain(process.platform === "darwin" && process.arch === "arm64"); const environment = { PATH: "/usr/bin:/bin:/usr/sbin:/sbin" }; if (Object.hasOwn(process.env, "DEVELOPER_DIR")) environment.DEVELOPER_DIR = absolutePath(process.env.DEVELOPER_DIR); const compilerPath = absolutePath(command("/usr/bin/xcrun", ["--find", "clang++"], environment)); const sdkRoot = absolutePath(command("/usr/bin/xcrun", ["--sdk", "macosx", "--show-sdk-path"], environment)); - const headerRoot = absolutePath(process.env.LING_OFFICE_NODE_HEADERS ?? resolve(dirname(process.execPath), "../include/node")); - const compilerVersion = command(compilerPath, ["--version"], environment); - requireToolchain(compilerVersion.length > 0 && compilerVersion.length <= 4096); + const headerRoot = absolutePath(process.env.LING_OFFICE_NODE_HEADERS); const sdkBytes = bytes(join(sdkRoot, "SDKSettings.json"), 8 * 1024 * 1024); let sdkSettings; try { sdkSettings = JSON.parse(sdkBytes.toString("utf8")); } @@ -56,15 +57,35 @@ export function observeOfficeNativeToolchain() { requireToolchain(typeof sdkSettings.CanonicalName === "string" && /^macosx\d+\.\d+(?:\.\d+)?$/u.test(sdkSettings.CanonicalName) && typeof sdkSettings.Version === "string" && /^\d+\.\d+(?:\.\d+)?$/u.test(sdkSettings.Version)); const compilerSha256 = hash(bytes(compilerPath, 512 * 1024 * 1024)); - return Object.freeze({ + const observedHeaders = Object.freeze({ root: headerRoot, sha256: Object.freeze(Object.fromEntries(OFFICE_NATIVE_HEADER_NAMES + .map(name => [name, hash(bytes(join(headerRoot, name), 8 * 1024 * 1024))]))) }); + const identity = { compilerSha256, sdkCanonicalName: sdkSettings.CanonicalName, + sdkVersion: sdkSettings.Version, sdkSettingsSha256: hash(sdkBytes) }; + let compilerVersion = null; + if (profiles !== undefined || headerDigests !== undefined) { + const candidate = Array.isArray(profiles) && profiles.find(profile => profile && typeof profile === "object" + && Object.keys(identity).every(key => profile[key] === identity[key])); + requireToolchain(candidate); + // Validate the complete policy, all profiles and actual headers before any + // compiler call. The reviewed first line is only a provisional comparison; + // the real version is checked again after executing the admitted compiler. + assertOfficeNativeToolchainProfile(profiles, headerDigests, { headers: observedHeaders, + profile: { ...identity, compilerVersionFirstLine: candidate.compilerVersionFirstLine } }); + requireToolchain(hash(bytes(compilerPath, 512 * 1024 * 1024)) === compilerSha256); + compilerVersion = command(compilerPath, ["--version"], environment); + requireToolchain(compilerVersion.length > 0 && compilerVersion.length <= 4096); + requireToolchain(hash(bytes(compilerPath, 512 * 1024 * 1024)) === compilerSha256 + && hash(bytes(join(sdkRoot, "SDKSettings.json"), 8 * 1024 * 1024)) === identity.sdkSettingsSha256 + && OFFICE_NATIVE_HEADER_NAMES.every(name => hash(bytes(join(headerRoot, name), 8 * 1024 * 1024)) === observedHeaders.sha256[name])); + } + const observation = Object.freeze({ compiler: Object.freeze({ path: compilerPath, version: compilerVersion, sha256: compilerSha256 }), sdk: Object.freeze({ root: sdkRoot, settingsSha256: hash(sdkBytes) }), - headers: Object.freeze({ root: headerRoot, sha256: Object.freeze(Object.fromEntries(OFFICE_NATIVE_HEADER_NAMES - .map(name => [name, hash(bytes(join(headerRoot, name), 8 * 1024 * 1024))]))) }), - profile: Object.freeze({ compilerVersionFirstLine: compilerVersion.split(/\r?\n/u)[0], - compilerSha256, - sdkCanonicalName: sdkSettings.CanonicalName, sdkVersion: sdkSettings.Version, sdkSettingsSha256: hash(sdkBytes) }), + headers: observedHeaders, + profile: Object.freeze({ compilerVersionFirstLine: compilerVersion?.split(/\r?\n/u)[0] ?? null, ...identity }), }); + if (profiles !== undefined || headerDigests !== undefined) assertOfficeNativeToolchainProfile(profiles, headerDigests, observation); + return observation; } /** Exact reviewed tool pair; no first-seen approval, version ranges, or path trust. */ diff --git a/apps/desktop/scripts/qa-packaged-sse-supervisor.mjs b/apps/desktop/scripts/qa-packaged-sse-supervisor.mjs new file mode 100644 index 000000000..74434245a --- /dev/null +++ b/apps/desktop/scripts/qa-packaged-sse-supervisor.mjs @@ -0,0 +1,133 @@ +// Owned QA topology only. Import actual packaged lifecycle/resource/profile checks. +// No production listener, HTTP control endpoint, CDP, permission override or token creation. +import assert from "node:assert/strict"; +import { spawn, execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { connect } from "node:net"; +import { resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { createQaSseFaultRelay } from "./qa-sse-fault-relay.mjs"; +import { buildQaGuiEnvironment, readExistingPrivateToken } from "./qa-sse-supervisor-support.mjs"; + +const root = resolve(import.meta.dirname, "../../.."); +const app = resolve(root, "apps/desktop/release/human-qa/mac-arm64/Ling Stack Human QA.app"); +const executable = resolve(app, "Contents/MacOS/Ling Stack Human QA"); +const resourcesRoot = resolve(app, "Contents/Resources"); +const appPath = resolve(resourcesRoot, "app.asar"); +const profilePath = resolve(root, "output/e6-20260928/h09-packaged-human-qa-20261010/profile"); +const expectedSha = "4db75650e50300c45fdc033f715ef231fa41dc1db0d670eaf1f34c1dfeb70c35"; +assert.equal(realpathSync(process.execPath), realpathSync(executable), "Run with the frozen packaged Electron runtime"); +assert.ok(process.versions.electron, "Packaged Electron ASAR support is required"); +// Electron's patched fs treats app.asar itself as a virtual directory. Hash the +// raw archive with original-fs; keep normal ASAR support for all packaged imports. +const originalFs = (await import("original-fs")).default; +assert.equal(createHash("sha256").update(await originalFs.promises.readFile(appPath)).digest("hex"), expectedSha, "Frozen QA package changed"); +await promisify(execFile)("/usr/bin/codesign", ["--verify", "--deep", "--strict", app], { timeout: 20000 }); +const mainArgs = [`--ling-desktop-owned-qa-profile=${profilePath}`, `--user-data-dir=${resolve(profilePath, "user-data")}`]; +const packaged = async name => import(pathToFileURL(resolve(appPath, `dist/main/${name}.js`)).href); +const { initializeDesktopQaLaunchProfile, readDesktopLaunchPackageMetadata } = await packaged("qa-launch-profile"); +let userData; +const profile = initializeDesktopQaLaunchProfile({ argv: [executable, ...mainArgs], environment: process.env, + packaged: true, packageMetadata: readDesktopLaunchPackageMetadata(appPath), applicationPath: appPath, + userId: process.getuid(), getUserDataPath: () => userData, setUserDataPath: path => { userData = path; } }); +assert.ok(profile); +const { resolveDesktopResourcePaths } = await packaged("resource-paths"); +const { resolveDesktopCuaDriverPackagedStartupAuthority } = await packaged("cua-driver/packaged-startup"); +const { DesktopDaemonLifecycle } = await packaged("daemon-lifecycle"); +const resources = await resolveDesktopResourcePaths({ isPackaged: true, getAppPath: () => appPath }, resourcesRoot); +const authority = await resolveDesktopCuaDriverPackagedStartupAuthority({ packaged: true, appPath, resourcesPath: resourcesRoot, platform: process.platform }); +assert.equal(authority.failure, undefined, "Actual package feature authority must validate; never disable its gate"); +const bridgeToken = await readExistingPrivateToken(resolve(profile.userDataDir, "desktop-bridge-token")); +for (const port of [17928, 17930]) assert.equal(await listening(port), false, `QA port ${port} must be free; normally Quit the existing QA App first`); +const output = await mkdtemp(resolve(root, "output/e6-20260928/r02-dual-stream-transport-20261026/topology-")); +const records = []; +const state = { kind: "owned-packaged-backend-with-loopback-transport-relay", output, + asarSha256: expectedSha, backendPort: 17930, relayPort: 17928, + workspaceCwd: profile.workspaceCwd, profilePath, mainArgs, + ownership: "QA supervisor owns backend; Main reuses authenticated relay; not Main-owned shutdown evidence", + gateSource: "actual packaged authority", engineFdValidated: false, bridgeAuthenticated: false, + bridgeTokenCreatedOrRotated: false, status: "starting", backendPid: null, desktopPid: null }; +let relay, desktop, desktopExited = false, snapshot = 0; +const lifecycle = new DesktopDaemonLifecycle({ baseUrl: "http://127.0.0.1:17930", daemonEntryPath: resources.daemonEntryPath, + defaultWorkspaceCwd: profile.workspaceCwd, desktopBridgeToken: bridgeToken, browserHostPath: resources.browserHostPath, + packaged: true, executablePath: executable, engineSupervisorPath: resources.engineSupervisorPath, + imageSanitizerPath: resources.imageSanitizerPath, cuaDriverFeatureFlags: authority.featureFlags, + onDiagnostic(event, details = {}) { + const entry = { at: new Date().toISOString(), event }; + for (const name of ["pid", "baseUrl", "code", "signal"]) if (details[name] !== undefined) entry[name] = details[name]; + records.push(entry); + if (event === "daemon.spawned") state.backendPid = details.pid; + }, +}); +const input = createInterface({ input: process.stdin }); +try { + const status = await lifecycle.ensureStarted(); + assert.equal(status.state, "connected"); assert.equal(status.ownedByDesktop, true); + await lifecycle.getExecutionEngineAuthority(); // Consume the production FD3-bound receipt, never print it. + state.engineFdValidated = true; + relay = await createQaSseFaultRelay({ upstreamPort: 17930, port: 17928 }); + const handshake = await fetch("http://127.0.0.1:17928/computer-use/desktop-bridge/handshake", { + headers: { "X-Ling-Desktop-Bridge-Token": bridgeToken }, signal: AbortSignal.timeout(5000) }); + assert.equal(handshake.status, 200); + const response = await handshake.json(); + assert.equal(response.routeTokenAccepted, true); + assert.equal(response.defaultWorkspaceCwd, profile.workspaceCwd); + state.bridgeAuthenticated = true; + desktop = spawn(executable, mainArgs, { cwd: profile.workspaceCwd, env: buildQaGuiEnvironment(process.env), stdio: "ignore" }); + state.desktopPid = desktop.pid; + desktop.once("exit", (code, signal) => { + desktopExited = true; + state.desktopExit = { code, signal }; + records.push({ event: "qa-main-exited", code, signal, at: new Date().toISOString() }); + }); + desktop.once("error", () => { desktopExited = true; state.status = "main-spawn-failed"; }); + state.status = "backend-and-relay-ready-main-spawned-GUI-not-yet-observed"; + await save("startup"); + console.log(JSON.stringify({ ...state })); + for await (const line of input) { + const command = line.trim(); + try { + if (command === "fault") { const cut = relay.disconnect(); await save("fault"); console.log(JSON.stringify({ command, cut, output })); } + else if (command === "restore") { relay.restore(); await save("restore"); console.log(JSON.stringify({ command, output })); } + else if (command === "inspect") { await save("inspect"); console.log(JSON.stringify({ command, desktopExited, backend: lifecycle.status, + streams: relay.liveStreams(), recentTransport: relay.records.slice(-12), output })); } + else if (command === "close") { + assert.equal(desktopExited, true, "Normally Quit exact QA Main before closing its independently owned backend"); + break; + } else throw new Error("Only inspect/fault/restore/close commands are supported"); + } catch (error) { console.log(JSON.stringify({ status: "command-rejected", message: error.message })); } + } + assert.equal(desktopExited, true, "Supervisor stdin closed while QA Main remains live; leave owned topology intact, do not kill Main"); + await relay.close(); + await lifecycle.stopOwnedDaemon(); + // An exit event is not proof of GUI Quit; keep code/signal and neutral wording. + state.status = "main-exited-and-supervisor-owned-backend-stopped"; + await save("closed"); + console.log(JSON.stringify({ status: state.status, output })); +} catch (error) { + process.exitCode = 1; + state.status = "incomplete"; + state.error = { name: error.name, message: String(error.message).replace(/\bsk-[\w-]+/gu, "[redacted]") }; + if (!desktop || desktopExited) { await relay?.close(); await lifecycle.stopOwnedDaemon(); } + await save("incomplete"); + console.log(JSON.stringify({ status: state.status, error: state.error, output, mainStillLive: !!desktop && !desktopExited })); +} finally { input.close(); } + +async function save(label) { + const body = JSON.stringify({ ...state, capturedAt: new Date().toISOString(), lifecycle: records, transport: relay?.records ?? [] }, null, 2) + "\n"; + if (/\bsk-[A-Za-z0-9_-]{12,}|Bearer\s+[A-Za-z0-9_.-]{12,}/u.test(body)) throw new Error("Private data refused"); + await writeFile(resolve(output, `${String(++snapshot).padStart(3, "0")}-${label}.json`), body, { flag: "wx", mode: 0o600 }); +} +async function listening(port) { + return new Promise((resolvePromise, reject) => { + const socket = connect({ host: "127.0.0.1", port }); + socket.setTimeout(1500); + socket.once("connect", () => { socket.destroy(); resolvePromise(true); }); + socket.once("error", error => { socket.destroy(); error.code === "ECONNREFUSED" ? resolvePromise(false) : reject(error); }); + socket.once("timeout", () => { socket.destroy(); reject(new Error(`QA port ${port} state unknown`)); }); + }); +} diff --git a/apps/desktop/scripts/qa-sse-fault-relay.mjs b/apps/desktop/scripts/qa-sse-fault-relay.mjs new file mode 100644 index 000000000..8c92f5e96 --- /dev/null +++ b/apps/desktop/scripts/qa-sse-fault-relay.mjs @@ -0,0 +1,147 @@ +// QA-only loopback transport fault. Commands are in-process, never HTTP admin routes. +// Forwarded headers/bodies are not retained; records contain bounded transport metadata only. +import assert from "node:assert/strict"; +import { createServer, request as requestHttp } from "node:http"; + +export async function createQaSseFaultRelay({ upstreamPort, port = 0 }) { + assert.ok(Number.isInteger(upstreamPort) && upstreamPort > 0 && upstreamPort < 65536); + assert.ok(Number.isInteger(port) && port >= 0 && port < 65536 && port !== upstreamPort); + let sequence = 0; + let holding = false; + let closing = false; + const streams = new Map(); + const sockets = new Set(); + const outbound = new Set(); + const records = []; + function record(event, fields = {}) { + assert.ok(records.length < 10000, "QA relay record budget exhausted"); + records.push({ at: new Date().toISOString(), event, ...fields }); + } + function streamKind(method, pathname) { + if (method === "GET" && pathname === "/control-plane/stream") return "control"; + if (method === "POST" && pathname === "/conversations/stream") return "execution"; + return undefined; + } + function cursor(value) { + if (typeof value !== "string" || !/^\d+$/u.test(value)) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : undefined; + } + function observeControl(state, bytes) { + state.controlBuffer += state.decoder.decode(bytes, { stream: true }).replace(/\r\n/gu, "\n"); + if (state.controlBuffer.length > 65536) { + record("control-frame-budget-exceeded", { id: state.id }); + state.controlBuffer = ""; + return; + } + let end; + while ((end = state.controlBuffer.indexOf("\n\n")) !== -1) { + const frame = state.controlBuffer.slice(0, end); + state.controlBuffer = state.controlBuffer.slice(end + 2); + const lines = frame.split("\n"); + const event = lines.find(line => line.startsWith("event:"))?.slice(6).trim(); + if (!["control_plane_event", "control_plane_replay_ready", "control_plane_snapshot_required"].includes(event)) continue; + let data; + try { data = JSON.parse(lines.filter(line => line.startsWith("data:")).map(line => line.slice(5).trimStart()).join("\n")); } + catch { continue; } + if (!data || typeof data !== "object" || Array.isArray(data)) continue; + const fields = { id: state.id, type: event }; + for (const key of ["seq", "afterSeq", "oldestAvailableSeq", "latestSeq", "nextAfterSeq"]) { + if (Number.isSafeInteger(data[key]) && data[key] >= 0) fields[key] = data[key]; + } + if (["complete", "snapshot_required"].includes(data.disposition)) fields.disposition = data.disposition; + record("control-frame", fields); + } + } + const server = createServer((incoming, response) => { + if (closing) { incoming.resume(); response.writeHead(503).end(); return; } + const url = new URL(incoming.url, "http://127.0.0.1"); + const kind = streamKind(incoming.method, url.pathname); + const afterSeq = kind === "control" ? cursor(url.searchParams.get("afterSeq")) : undefined; + if (kind && holding) { + record("stream-refused-during-fault", { kind, ...(afterSeq === undefined ? {} : { afterSeq }) }); + incoming.resume(); + response.writeHead(503, { "Access-Control-Allow-Origin": "*", "Cache-Control": "no-store" }).end(); + return; + } + const id = ++sequence; + const state = kind ? { id, kind, response, upstream: null, remote: null, ready: false, + ...(afterSeq === undefined ? {} : { afterSeq }), bytesReceived: 0, + ...(kind === "control" ? { controlBuffer: "", decoder: new TextDecoder() } : {}) } : undefined; + if (state) { streams.set(id, state); record("stream-request", { id, kind, ...(afterSeq === undefined ? {} : { afterSeq }) }); } + const upstream = requestHttp({ hostname: "127.0.0.1", port: upstreamPort, + path: incoming.url, method: incoming.method, + headers: { ...incoming.headers, host: `127.0.0.1:${upstreamPort}` } }, remote => { + if (state) { + state.remote = remote; + state.ready = remote.statusCode === 200 && remote.headers["content-type"]?.startsWith("text/event-stream") === true; + record("stream-response", { id, kind, status: remote.statusCode, ready: state.ready }); + remote.on("data", bytes => { + state.bytesReceived += bytes.length; + if (kind === "control") observeControl(state, bytes); + }); + } + remote.once("error", () => response.destroy()); + remote.once("aborted", () => response.destroy()); + response.writeHead(remote.statusCode, remote.headers); + remote.pipe(response); + }); + outbound.add(upstream); + if (state) state.upstream = upstream; + upstream.once("close", () => outbound.delete(upstream)); + upstream.once("error", () => { + if (state) record("stream-upstream-error", { id, kind }); + if (!response.headersSent) response.writeHead(502, { "Access-Control-Allow-Origin": "*" }).end(); + else response.destroy(); + }); + response.once("close", () => { + upstream.destroy(); + if (state) { + state.remote?.destroy(); + streams.delete(id); + record("stream-closed", { id, kind, bytesReceived: state.bytesReceived }); + } + }); + incoming.once("aborted", () => upstream.destroy()); + incoming.pipe(upstream); + }); + server.on("connection", socket => { sockets.add(socket); socket.once("close", () => sockets.delete(socket)); }); + server.on("upgrade", (_request, socket) => socket.destroy()); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => { server.removeListener("error", reject); resolve(); }); + }); + return { + port: server.address().port, + records, + liveStreams: () => [...streams.values()].map(({ id, kind, ready, afterSeq, bytesReceived }) => + ({ id, kind, ready, ...(afterSeq === undefined ? {} : { afterSeq }), bytesReceived })), + disconnect({ requiredKinds = ["control", "execution"] } = {}) { + assert.equal(closing, false); + assert.equal(holding, false, "No repeated fault without restoration"); + assert.ok(Array.isArray(requiredKinds) && requiredKinds.length > 0 + && requiredKinds.every(kind => ["control", "execution"].includes(kind)), "Explicit valid stream kinds required"); + const current = [...streams.values()].filter(stream => stream.ready); + for (const kind of requiredKinds) assert.ok(current.some(stream => stream.kind === kind), `A live successful ${kind} SSE is required before fault`); + holding = true; + record("fault-start", { streams: current.map(({ id, kind, afterSeq, bytesReceived }) => + ({ id, kind, ...(afterSeq === undefined ? {} : { afterSeq }), bytesReceived })) }); + for (const stream of streams.values()) { stream.remote?.destroy(); stream.upstream.destroy(); stream.response.destroy(); } + return current.map(({ id, kind }) => ({ id, kind })); + }, + restore() { + assert.equal(closing, false); + assert.equal(holding, true, "Restoration requires an actual prior fault"); + holding = false; + record("fault-restored"); + }, + async close() { + if (closing) return; + closing = true; + for (const request of outbound) request.destroy(); + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + record("relay-closed"); + }, + }; +} diff --git a/apps/desktop/scripts/qa-sse-supervisor-support.mjs b/apps/desktop/scripts/qa-sse-supervisor-support.mjs new file mode 100644 index 000000000..f37d39430 --- /dev/null +++ b/apps/desktop/scripts/qa-sse-supervisor-support.mjs @@ -0,0 +1,29 @@ +// QA startup identity reader. It never creates, rotates or logs bridge tokens. +import assert from "node:assert/strict"; +import { constants, lstatSync, realpathSync } from "node:fs"; +import { open } from "node:fs/promises"; + +export function buildQaGuiEnvironment(environment) { + const child = { ...environment }; + // The supervisor needs Node mode; the actual packaged Main must not inherit it. + delete child.ELECTRON_RUN_AS_NODE; + return child; +} + +export async function readExistingPrivateToken(path) { + const before = lstatSync(path, { bigint: true }); + const valid = stat => stat.isFile() && !stat.isSymbolicLink() && stat.uid === BigInt(process.getuid()) + && (stat.mode & 0o7777n) === 0o600n && stat.nlink === 1n && stat.size >= 32n && stat.size <= 256n; + assert.ok(valid(before) && realpathSync(path) === path, "Existing private QA bridge identity required"); + const fd = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const opened = await fd.stat({ bigint: true }); + assert.ok(valid(opened) && opened.ino === before.ino && opened.dev === before.dev); + const value = await fd.readFile("utf8"); + const after = await fd.stat({ bigint: true }); + assert.ok(valid(after) && after.size === opened.size && after.mtimeNs === opened.mtimeNs && after.ctimeNs === opened.ctimeNs); + // A value-bearing AssertionError would expose private input on failed startup. + if (!/^[A-Za-z0-9_-]{32,256}$/u.test(value.trim())) throw new Error("QA_BRIDGE_IDENTITY_FORMAT_INVALID"); + return value.trim(); + } finally { await fd.close(); } +} diff --git a/apps/desktop/scripts/stage-development-bundled-plugins.mjs b/apps/desktop/scripts/stage-development-bundled-plugins.mjs index c0f5cc6e3..7ec3abaa8 100644 --- a/apps/desktop/scripts/stage-development-bundled-plugins.mjs +++ b/apps/desktop/scripts/stage-development-bundled-plugins.mjs @@ -1,19 +1,121 @@ -import { copyFile, mkdir, readFile } from "node:fs/promises"; +import { copyFile, mkdir, mkdtemp, readFile, rename, rm } from "node:fs/promises"; import { dirname, isAbsolute, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const configuration = JSON.parse(await readFile(resolve(desktopRoot, "package.json"), "utf8")); -const resource = configuration.build.extraResources.find(entry => entry.to === "bundled"); -if (!resource || !Array.isArray(resource.filter)) throw new Error("bundled_plugin_metadata_missing"); +const scriptPath = fileURLToPath(import.meta.url); +const defaultDesktopRoot = resolve(dirname(scriptPath), ".."); +const productionFileSystem = Object.freeze({ copyFile, mkdir, mkdtemp, readFile, rename, rm }); -// esbuild relocates import.meta.url; retain the existing module-relative lookup for development. -for (const path of resource.filter) { - if (typeof path !== "string" || isAbsolute(path) || path.split(/[\\/]/u).includes("..")) { - throw new Error("bundled_plugin_metadata_path_invalid"); +/** Replace the development-only bundled metadata tree with the configured exact inventory. */ +export async function stageDevelopmentBundledPlugins(input = {}) { + return stageDevelopmentBundledPluginsWithFileSystem(input, productionFileSystem); +} + +/** Test-only filesystem seam for deterministic cutover and rollback failures. */ +export function createDevelopmentBundledPluginStagerForTest(overrides = {}) { + const fileSystem = Object.freeze({ ...productionFileSystem, ...overrides }); + return input => stageDevelopmentBundledPluginsWithFileSystem(input, fileSystem); +} + +async function stageDevelopmentBundledPluginsWithFileSystem(input, fileSystem) { + const desktopRoot = resolve(input.desktopRoot ?? defaultDesktopRoot); + const configuration = JSON.parse(await fileSystem.readFile(resolve(desktopRoot, "package.json"), "utf8")); + const resources = configuration?.build?.extraResources?.filter(entry => entry?.to === "bundled") ?? []; + const resource = resources.length === 1 ? resources[0] : undefined; + if (!resource || typeof resource.from !== "string" || !Array.isArray(resource.filter)) { + throw new Error("bundled_plugin_metadata_missing"); + } + + const inventory = requireExactInventory(resource.filter); + const destinationRoot = resolve(desktopRoot, "dist/daemon/bundled"); + await fileSystem.mkdir(dirname(destinationRoot), { recursive: true }); + const workspace = await fileSystem.mkdtemp(resolve(desktopRoot, ".bundled-plugin-stage-")); + const nextRoot = resolve(workspace, "next"); + const previousRoot = resolve(workspace, "previous"); + let result; + let operationError; + try { + await fileSystem.mkdir(nextRoot, { recursive: true }); + for (const path of inventory) { + const target = resolve(nextRoot, path); + await fileSystem.mkdir(dirname(target), { recursive: true }); + await fileSystem.copyFile(resolve(desktopRoot, resource.from, path), target); + } + await replaceDirectoryWithRollback({ destinationRoot, fileSystem, nextRoot, previousRoot }); + result = Object.freeze({ destinationRoot, fileCount: inventory.length }); + } catch (error) { + operationError = error; + } + + const retainWorkspace = operationError?.code === "bundled_plugin_stage_rollback_failed"; + if (retainWorkspace) { + operationError.recoveryPath = workspace; } - const target = resolve(desktopRoot, "dist/daemon/bundled", path); - await mkdir(dirname(target), { recursive: true }); - await copyFile(resolve(desktopRoot, resource.from, path), target); + try { + if (!retainWorkspace) await fileSystem.rm(workspace, { recursive: true, force: true }); + } catch (cleanupError) { + if (operationError !== undefined) { + throw new AggregateError( + [operationError, cleanupError], + "bundled_plugin_stage_cleanup_failed", + { cause: operationError }, + ); + } + throw cleanupError; + } + if (operationError !== undefined) throw operationError; + return result; +} + +function requireExactInventory(paths) { + const inventory = []; + const seen = new Set(); + for (const path of paths) { + const segments = typeof path === "string" ? path.split(/[\\/]/u) : []; + if ( + typeof path !== "string" + || isAbsolute(path) + || segments.some(segment => !segment || segment === "." || segment === "..") + ) { + throw new Error("bundled_plugin_metadata_path_invalid"); + } + const normalized = segments.join("/"); + if (seen.has(normalized)) throw new Error("bundled_plugin_metadata_path_duplicate"); + seen.add(normalized); + inventory.push(normalized); + } + return inventory; +} + +async function replaceDirectoryWithRollback({ destinationRoot, fileSystem, nextRoot, previousRoot }) { + let previousMoved = false; + try { + await fileSystem.rename(destinationRoot, previousRoot); + previousMoved = true; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + + try { + await fileSystem.rename(nextRoot, destinationRoot); + } catch (publicationError) { + if (!previousMoved) throw publicationError; + try { + await fileSystem.rename(previousRoot, destinationRoot); + } catch (rollbackError) { + const error = new AggregateError( + [publicationError, rollbackError], + "bundled_plugin_stage_rollback_failed", + { cause: publicationError }, + ); + error.code = "bundled_plugin_stage_rollback_failed"; + throw error; + } + throw publicationError; + } +} + +if (process.argv[1] && resolve(process.argv[1]) === scriptPath) { + const result = await stageDevelopmentBundledPlugins(); + console.log(`Staged ${result.fileCount} bundled plugin metadata files for development.`); } -console.log(`Staged ${resource.filter.length} bundled plugin metadata files for development.`); diff --git a/apps/desktop/shared/desktop-capabilities.ts b/apps/desktop/shared/desktop-capabilities.ts index 799310ac1..c67024591 100644 --- a/apps/desktop/shared/desktop-capabilities.ts +++ b/apps/desktop/shared/desktop-capabilities.ts @@ -40,7 +40,7 @@ export function isComputerUsePreviewLocale(value: unknown): value is ComputerUse } export interface DesktopDaemonStatus { - readonly state: "checking" | "starting" | "connected" | "disconnected"; + readonly state: "checking" | "starting" | "connected" | "disconnected" | "draining" | "blocked"; readonly baseUrl: string; readonly ownedByDesktop: boolean; readonly reason?: @@ -48,10 +48,27 @@ export interface DesktopDaemonStatus { | "start_timeout" | "child_exited" | "resource_missing" - | "spawn_failed"; + | "spawn_failed" + | "stop_blocked" + | "stop_unconfirmed"; readonly message?: string; } +export function isDesktopDaemonStatus(value: unknown): value is DesktopDaemonStatus { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const status = value as Record; + if (!["checking", "starting", "connected", "disconnected", "draining", "blocked"].includes(String(status.state)) + || typeof status.baseUrl !== "string" || status.baseUrl.length > 2048 + || typeof status.ownedByDesktop !== "boolean" + || status.message !== undefined && (typeof status.message !== "string" || status.message.length > 2048)) return false; + if (status.reason !== undefined && !["bridge_incompatible", "start_timeout", "child_exited", "resource_missing", + "spawn_failed", "stop_blocked", "stop_unconfirmed"].includes(String(status.reason))) return false; + if (status.state === "draining") return status.ownedByDesktop === true && status.reason === undefined; + if (status.state === "blocked") return status.ownedByDesktop === true + && (status.reason === "stop_blocked" || status.reason === "stop_unconfirmed"); + return status.reason !== "stop_blocked" && status.reason !== "stop_unconfirmed"; +} + export interface DesktopMobileAccessStatus { readonly enabled: boolean; readonly listener: { readonly host: "127.0.0.1"; readonly port: number; readonly listening: boolean }; @@ -280,6 +297,7 @@ export interface LingDesktopCapabilities { readonly daemon: { getStatus(): Promise; restart(): Promise; + onStatusChanged(listener: (status: DesktopDaemonStatus) => void): () => void; }; readonly mobileAccess?: DesktopMobileAccessCapabilities; readonly executionEngines?: DesktopExecutionEngineCapabilities; diff --git a/apps/desktop/tests/bundled-plugin-resources.test.mjs b/apps/desktop/tests/bundled-plugin-resources.test.mjs index 35f29c49f..c5d6d3528 100644 --- a/apps/desktop/tests/bundled-plugin-resources.test.mjs +++ b/apps/desktop/tests/bundled-plugin-resources.test.mjs @@ -1,17 +1,21 @@ import assert from "node:assert/strict"; -import { cp, mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { execFile as execFileCallback } from "node:child_process"; +import { cp, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, relative, resolve } from "node:path"; +import { basename, dirname, relative, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import test from "node:test"; +import { promisify } from "node:util"; import { DESKTOP_BUNDLED_PLUGINS_RESOURCE_DIRECTORY, resolveBundledPackageRoot, } from "../../../packages/daemon/dist/features/plugins/bundled-index.js"; +import { createDevelopmentBundledPluginStagerForTest } from "../scripts/stage-development-bundled-plugins.mjs"; const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const repositoryRoot = resolve(desktopRoot, "../.."); const sourceRoot = resolve(repositoryRoot, "packages/daemon/src/features/plugins/bundled"); +const execFile = promisify(execFileCallback); test("Desktop package copies only the Cua Driver plugin shell metadata", async () => { const desktopPackage = JSON.parse(await readFile(resolve(desktopRoot, "package.json"), "utf8")); @@ -34,6 +38,11 @@ test("Desktop package copies only the Cua Driver plugin shell metadata", async ( "mcp-server.json", ]); assert.equal(sourceFiles.some(looksLikeBinaryOrCredential), false); + assert.equal( + desktopPackage.build.files.includes("!dist/daemon/bundled{,/**/*}"), + true, + "development-only staged metadata must not enter app.asar outside the reviewed extraResources lane", + ); }); test("packaged daemon resolves the copied Cua Driver manifest from Electron resources", async () => { @@ -101,6 +110,100 @@ test("Desktop development build stages bundled metadata after emitting the daemo /node scripts\/build-packaged-daemon\.mjs && node scripts\/stage-development-bundled-plugins\.mjs/u); }); +test("development bundled-plugin staging replaces the destination with the exact reviewed inventory", async () => { + const destinationRoot = resolve(desktopRoot, "dist/daemon/bundled"); + const foreignRoot = resolve(destinationRoot, "foreign-plugin"); + const foreignPath = resolve(foreignRoot, "unreviewed.txt"); + await mkdir(foreignRoot, { recursive: true }); + await writeFile(foreignPath, "UNREVIEWED FOREIGN FILE\n"); + try { + await execFile(process.execPath, [resolve(desktopRoot, "scripts/stage-development-bundled-plugins.mjs")], { + cwd: repositoryRoot, + }); + await assert.rejects(readFile(foreignPath), error => error?.code === "ENOENT"); + assert.deepEqual( + (await readdir(destinationRoot, { recursive: true })).map(path => path.replaceAll("\\", "/")).sort(), + ["cua-driver", "cua-driver/.ling-plugin", "cua-driver/.ling-plugin/plugin.json", "cua-driver/README.md", "cua-driver/mcp-server.json"], + ); + } finally { + await rm(foreignRoot, { recursive: true, force: true }); + } +}); + +test("development bundled-plugin staging restores the previous tree when atomic cutover fails", async t => { + const fixtureRoot = await mkdtemp(resolve(tmpdir(), "ling-bundled-stage-rollback-")); + const sourceRoot = resolve(fixtureRoot, "source/cua-driver"); + const destinationRoot = resolve(fixtureRoot, "dist/daemon/bundled"); + await mkdir(sourceRoot, { recursive: true }); + await mkdir(destinationRoot, { recursive: true }); + await writeFile(resolve(sourceRoot, "manifest.json"), "NEW REVIEWED METADATA\n"); + await writeFile(resolve(destinationRoot, "previous.txt"), "PREVIOUS EXACT TREE\n"); + await writeFile(resolve(fixtureRoot, "package.json"), JSON.stringify({ + build: { extraResources: [{ from: "source", to: "bundled", filter: ["cua-driver/manifest.json"] }] }, + })); + + const injectedError = Object.assign(new Error("injected_cutover_failure"), { code: "EIO" }); + const stage = createDevelopmentBundledPluginStagerForTest({ + rename: async (from, to) => { + if (to === destinationRoot && basename(from) === "next") throw injectedError; + return rename(from, to); + }, + }); + try { + await assert.rejects(stage({ desktopRoot: fixtureRoot }), error => error === injectedError); + assert.equal(await readFile(resolve(destinationRoot, "previous.txt"), "utf8"), "PREVIOUS EXACT TREE\n"); + await assert.rejects(readFile(resolve(destinationRoot, "cua-driver/manifest.json")), error => error?.code === "ENOENT"); + assert.deepEqual((await readdir(fixtureRoot)).filter(name => name.startsWith(".bundled-plugin-stage-")), []); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } + t.diagnostic("Injected the failure after previous-tree quarantine and before next-tree publication; rollback restored the original bytes."); +}); + +test("development bundled-plugin staging retains recovery bytes if publication and rollback both fail", async () => { + const fixtureRoot = await mkdtemp(resolve(tmpdir(), "ling-bundled-stage-recovery-")); + const sourceRoot = resolve(fixtureRoot, "source/cua-driver"); + const destinationRoot = resolve(fixtureRoot, "dist/daemon/bundled"); + await mkdir(sourceRoot, { recursive: true }); + await mkdir(destinationRoot, { recursive: true }); + await writeFile(resolve(sourceRoot, "manifest.json"), "NEW REVIEWED METADATA\n"); + await writeFile(resolve(destinationRoot, "previous.txt"), "PREVIOUS EXACT TREE\n"); + await writeFile(resolve(fixtureRoot, "package.json"), JSON.stringify({ + build: { extraResources: [{ from: "source", to: "bundled", filter: ["cua-driver/manifest.json"] }] }, + })); + + const publicationError = Object.assign(new Error("injected_cutover_failure"), { code: "EIO" }); + const rollbackError = Object.assign(new Error("injected_rollback_failure"), { code: "EACCES" }); + let renameCount = 0; + const stage = createDevelopmentBundledPluginStagerForTest({ + rename: async (from, to) => { + renameCount += 1; + if (renameCount === 2) throw publicationError; + if (renameCount === 3) throw rollbackError; + return rename(from, to); + }, + }); + let observed; + try { + await assert.rejects(stage({ desktopRoot: fixtureRoot }), error => { + observed = error; + return error?.code === "bundled_plugin_stage_rollback_failed" + && error.errors?.[0] === publicationError + && error.errors?.[1] === rollbackError; + }); + assert.equal( + await readFile(resolve(observed.recoveryPath, "previous/previous.txt"), "utf8"), + "PREVIOUS EXACT TREE\n", + ); + assert.equal( + await readFile(resolve(observed.recoveryPath, "next/cua-driver/manifest.json"), "utf8"), + "NEW REVIEWED METADATA\n", + ); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } +}); + async function listFiles(root) { const entries = await readdir(root, { withFileTypes: true }); const files = []; diff --git a/apps/desktop/tests/capability-admission-drain.regression-1.test.mjs b/apps/desktop/tests/capability-admission-drain.regression-1.test.mjs new file mode 100644 index 000000000..fae835aa5 --- /dev/null +++ b/apps/desktop/tests/capability-admission-drain.regression-1.test.mjs @@ -0,0 +1,400 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import vm from "node:vm"; +import test from "node:test"; +import { transform } from "esbuild"; +import { OFFICE_DRIVER_PROTOCOL } from "@ling-stack/contracts"; +import { HostCapabilityRuntime } from "../dist/main/host-capabilities/runtime.js"; +import { OfficeDriverRuntime } from "../dist/main/office-driver/runtime.js"; +import { HostCapabilityBridgeController } from "../../../packages/daemon/dist/features/runs/host-capability-bridge-controller.js"; +import { OfficeDriverController } from "../../../packages/daemon/dist/features/office-documents/driver-controller.js"; + +// R03: forced Promise interleavings and virtual interval ticks only. These tests +// never start Electron, a native helper, an HTTP server, a real Store or model. +for (const kind of ["host", "office"]) { + test(`${kind}: idle drain stops claims/availability but retains execution owners`, async t => { + t.mock.timers.enable({ apis: ["setInterval"] }); + const f = fixture(t, kind); + await f.runtime.start(); + const before = f.publications.length; + assert.equal(typeof f.runtime.pauseNewClaims, "function"); + for (let i = 0; i < 8; i++) f.runtime.pauseNewClaims(); + assert.equal(f.claimSignals[0].aborted, true); + await flush(); + t.mock.timers.tick(20_000); + await flush(); + await f.runtime.start(); + assert.equal(f.claimSignals.length, 1); + assert.equal(f.publications.length, before); + assert.equal(f.clients(), 1); + assert.equal(f.stops(), 0); + assert.equal(f.previewCloses(), 0); + }); + + test(`${kind}: a claim returned after the drain fence cannot start an effect or fabricate a result`, async t => { + const f = fixture(t, kind, { lateClaim: true }); + await f.runtime.start(); + assert.equal(typeof f.runtime.pauseNewClaims, "function"); + for (let i = 0; i < 8; i++) f.runtime.pauseNewClaims(); + f.claim.resolve(f.command); + await flush(); + assert.equal(f.effects(), 0); + assert.equal(f.completions.length, 0); + assert.equal(f.claimSignals.length, 1); + const late = f.diagnostics.filter(([event]) => event.endsWith(".claimed_not_started")); + assert.equal(late.length, 1); + assert.equal(late[0][1].commandId, "accepted-command"); + assert.equal(late[0][1].effectState, "not_started"); + assert.equal(late[0][1].hostState, "unknown"); + assert.equal(f.stops(), 0); + assert.equal(f.previewCloses(), 0); + }); + + test(`${kind}: accepted execution keeps its lifetime signal and lease heartbeat across drain`, async t => { + t.mock.timers.enable({ apis: ["setInterval"] }); + const f = fixture(t, kind, { deliver: true, holdEffect: true }); + await f.runtime.start(); + await f.effectEntered.promise; + assert.equal(typeof f.runtime.pauseNewClaims, "function"); + f.runtime.pauseNewClaims(); + const before = f.publications.length; + t.mock.timers.tick(15_000); + await flush(); + assert.ok(f.publications.length > before, "the 12-second authority lease must not expire merely because claims paused"); + assert.equal(new Set(f.publications.map(value => value.generation)).size, 1); + assert.ok(f.publicationSignals.slice(before).every(signal => !signal.aborted)); + assert.equal(f.stops(), 0); + assert.equal(f.previewCloses(), 0); + f.effect.resolve(); + await until(() => f.completions.length === 1); + assert.equal(f.completions[0].ok, true); + assert.equal(f.completionSignals[0].aborted, false); + assert.notEqual(f.completionSignals[0], f.claimSignals[0]); + assert.equal(f.claimSignals[0].aborted, true); + await flush(); + const settled = f.publications.length; + t.mock.timers.tick(20_000); + await flush(); + assert.equal(f.publications.length, settled, "an idle paused runtime must stop renewing availability"); + assert.equal(f.claimSignals.length, 1); + assert.equal(f.effects(), 1); + }); + + test(`${kind}: drain during result acknowledgement does not abort the accepted receipt`, async t => { + const f = fixture(t, kind, { deliver: true, holdCompletion: true }); + await f.runtime.start(); + await f.completionEntered.promise; + assert.equal(typeof f.runtime.pauseNewClaims, "function"); + f.runtime.pauseNewClaims(); + assert.equal(f.completionSignals[0].aborted, false); + assert.equal(f.claimSignals[0].aborted, true); + f.completion.resolve(); + await flush(); + assert.equal(f.completions.length, 1); + assert.equal(f.completions[0].ok, true); + assert.equal(f.effects(), 1); + assert.equal(f.stops(), 0); + assert.equal(f.previewCloses(), 0); + }); + + test(`${kind}: drain during startup cannot install a later claim loop or heartbeat`, async t => { + t.mock.timers.enable({ apis: ["setInterval"] }); + const f = fixture(t, kind, { holdStartup: true }); + const starting = f.runtime.start(); + void starting.catch(() => undefined); + await f.startEntered.promise; + assert.equal(typeof f.runtime.pauseNewClaims, "function"); + f.runtime.pauseNewClaims(); + f.start.resolve(); + await starting; + const before = f.publications.length; + t.mock.timers.tick(20_000); + await flush(); + assert.equal(f.claimSignals.length, 0); + assert.equal(f.publications.length, before); + assert.equal(f.stops(), 0); + assert.equal(f.previewCloses(), 0); + }); + + test(`${kind}: explicit new-generation rebind works but a newer drain while rebinding wins`, async t => { + const f = fixture(t, kind, { lateClaim: true }); + await f.runtime.start(); + f.runtime.pauseNewClaims(); + const rebinding = f.runtime.restart(); + f.runtime.pauseNewClaims(); + f.claim.resolve(); + await rebinding; + assert.equal(f.clients(), 1, "a newer drain cannot be cleared by a stale rebind"); + await f.runtime.start(); + assert.equal(f.clients(), 1); + }); + + test(`${kind}: a fresh Host rebind can recover after a newer drain supersedes an earlier rebind`, async t => { + const f = fixture(t, kind, { lateClaim: true }); + await f.runtime.start(); + const originalGeneration = f.publications[0].generation; + f.runtime.pauseNewClaims(); + const supersededRebind = f.runtime.restart(); + f.runtime.pauseNewClaims(); + f.claim.resolve(); + await supersededRebind; + await f.runtime.start(); + assert.equal(f.clients(), 1, "neither a superseded rebind nor plain start may clear the newer drain"); + assert.equal(f.effects(), 0); + assert.equal(f.completions.length, 0); + + // The fixture now represents Main's explicit rebind after a separate, + // successful fresh-Host handshake. It does not authorize restart while + // the original Host's stop/effect state is unknown, or on connected alone. + await f.runtime.restart(); + assert.equal(f.clients(), 2, "the caller's later authorized rebind must not remain permanently inactive"); + assert.equal(f.claimSignals.length, 2); + assert.equal(f.claimSignals[1].aborted, false); + assert.ok(f.publications.at(-1).generation > originalGeneration); + assert.equal(f.effects(), 0, "rebind must not replay a command"); + assert.equal(f.completions.length, 0, "rebind must not fabricate a result"); + }); + + test(`${kind}: fresh Host rebind can reopen the prior generation's paused claim loop`, async t => { + const f = fixture(t, kind); + await f.runtime.start(); + f.runtime.pauseNewClaims(); + await f.runtime.restart(); + assert.equal(f.clients(), 2); + assert.equal(f.claimSignals.length, 2); + assert.equal(f.claimSignals[1].aborted, false); + assert.notEqual(f.publications[0].generation, f.publications.at(-1).generation); + }); + + test(`${kind}: a real in-memory controller accepts the retained receipt after its original 12s TTL`, async t => { + t.mock.timers.enable({ apis: ["setInterval"] }); + let clock = Date.now(), authority; + const controller = kind === "host" + ? new HostCapabilityBridgeController({ now: () => clock }) + : new OfficeDriverController({ now: () => clock }); + const effect = deferred(), entered = deferred(); + const transport = { + async publishAvailability(value) { + authority = { desktopInstanceId: value.desktopInstanceId, generation: value.generation }; + controller.publishAvailability(value); + }, + async claim(signal) { return await controller.claim(authority, signal); }, + async begin(id) { controller.begin(authority, id); }, + async complete(result) { controller.complete(authority, result); }, + }; + const shared = { daemonBaseUrl: "http://127.0.0.1:1", bridgeToken: "fixture", desktopInstanceId: "ttl-fixture", + daemonClientFactory: () => transport, now: () => clock }; + const runtime = kind === "host" ? new HostCapabilityRuntime({ ...shared, + previewManager: { + async launch() { entered.resolve(); await effect.promise; return { window: {}, close() {} }; }, + async close() {}, + }, + }) : new OfficeDriverRuntime({ ...shared, executablePath: "/unused", recoveryRoot: "/unused", platform: "darwin", + helper: { + async start() {}, async stop() {}, + async call(command) { + if (command.method === "list_sessions") { + entered.resolve(); await effect.promise; + return { protocol: OFFICE_DRIVER_PROTOCOL, id: command.id, ok: true, result: [] }; + } + return { protocol: OFFICE_DRIVER_PROTOCOL, id: command.id, ok: true, result: { + helperVersion: "fixture", state: "limited", capabilities: ["excel:sessions"], + officeInstalled: true, applicationRunning: true, permissionGranted: true, + } }; + }, + }, + }); + t.after(async () => { effect.resolve(); controller.close(); await runtime.stop(); }); + await runtime.start(); + const execution = kind === "host" ? controller.executeOpenLocalTarget({ + path: "index.html", absolutePath: "/tmp/r03-fixture/index.html", workspaceRoot: "/tmp/r03-fixture", + presentation: "interactive_html", runId: "run-1", attemptId: "attempt-1", toolCallId: "tool-1", + approvalId: "approval-1", idempotencyKey: "ttl-fixture", + }) : controller.execute({ method: "list_sessions", params: {}, idempotencyKey: "ttl-fixture" }); + void execution.catch(() => undefined); + await entered.promise; + runtime.pauseNewClaims(); + for (let i = 0; i < 3; i++) { + clock += 5_000; t.mock.timers.tick(5_000); await flush(); + } + assert.equal(controller.snapshot().connected, true, "the original controller authority must remain current after 15 seconds"); + effect.resolve(); + const result = await execution; + if (kind === "host") assert.equal(result.status, "launched"); + else assert.deepEqual(result, []); + }); +} + +test("office: an in-flight idle status response cannot publish or recover the helper after drain", async t => { + t.mock.timers.enable({ apis: ["setInterval"] }); + const f = fixture(t, "office", { holdStatus: true }); + await f.runtime.start(); + const before = f.publications.length; + t.mock.timers.tick(5_000); + await f.statusEntered.promise; + assert.equal(typeof f.runtime.pauseNewClaims, "function"); + f.runtime.pauseNewClaims(); + f.status.resolve(); + await flush(); + assert.equal(f.publications.length, before); + assert.equal(f.stops(), 0); + assert.equal(f.clients(), 1); +}); + +test("office: an in-flight idle status failure cannot trigger helper recovery after drain", async t => { + t.mock.timers.enable({ apis: ["setInterval"] }); + const f = fixture(t, "office", { holdStatus: true }); + await f.runtime.start(); + const before = f.publications.length; + t.mock.timers.tick(5_000); + await f.statusEntered.promise; + f.runtime.pauseNewClaims(); + f.status.reject(new Error("office_driver_helper_timeout")); + await flush(); + assert.equal(f.publications.length, before); + assert.equal(f.stops(), 0); +}); + +test("office: accepted failure after drain retains its error without starting helper recovery", async t => { + const f = fixture(t, "office", { deliver: true, holdEffect: true }); + await f.runtime.start(); + await f.effectEntered.promise; + assert.equal(typeof f.runtime.pauseNewClaims, "function"); + f.runtime.pauseNewClaims(); + f.effect.reject(new Error("office_driver_exited")); + await until(() => f.completions.length === 1); + assert.equal(f.completions[0].ok, false); + assert.equal(f.completions[0].error.code, "office_driver_exited"); + assert.equal(f.completionSignals[0].aborted, false); + assert.equal(f.stops(), 0); +}); + +test("host: paused accepted acknowledgement failures retain the existing bounded result behavior", async t => { + const f = fixture(t, "host", { deliver: true, holdEffect: true, rejectCompletions: true }); + await f.runtime.start(); + await f.effectEntered.promise; + assert.equal(typeof f.runtime.pauseNewClaims, "function"); + f.runtime.pauseNewClaims(); + f.effect.resolve(); + await until(() => f.completions.length === 6); + await flush(); + assert.deepEqual(f.completions.map(result => result.ok), [true, true, true, false, false, false]); + assert.ok(f.completions.slice(3).every(result => result.error.code === "host_capability_daemon_request_failed")); + assert.ok(f.completionSignals.every(signal => !signal.aborted)); + assert.equal(f.claimSignals.length, 1); + assert.equal(f.launchCloses(), 1); + assert.equal(f.previewCloses(), 0); +}); + +test("Main subscribes capability admission outside renderer IPC and only owned drain closes it", async () => { + const source = await readFile(new URL("../main/main.ts", import.meta.url), "utf8"); + assert.ok(/desktopRuntime\.onStatusChanged\(pauseDesktopCapabilityNewClaims\)/u.test(source)); + const begin = source.indexOf("function pauseDesktopCapabilityNewClaims("); + assert.ok(begin > 0); + const end = source.indexOf("\nasync function restartDesktopCapabilityRuntimes", begin); + assert.ok(end > begin); + const compiled = await transform(source.slice(begin, end), { loader: "ts", format: "cjs" }); + const calls = []; + const context = vm.createContext({ + hostCapabilityRuntime: { pauseNewClaims() { calls.push("host"); } }, + officeDriverRuntime: { pauseNewClaims() { calls.push("office"); } }, + }); + const pause = vm.runInContext(`${compiled.code}\npauseDesktopCapabilityNewClaims;`, context); + for (const state of ["connected", "disconnected", "checking", "starting"]) pause({ state, ownedByDesktop: true }); + for (const state of ["draining", "blocked"]) pause({ state, ownedByDesktop: false }); + assert.deepEqual(calls, []); + pause({ state: "draining", ownedByDesktop: true }); + assert.deepEqual(calls, ["host", "office"], "the admission fence must be synchronous"); + pause({ state: "blocked", ownedByDesktop: true }); + assert.deepEqual(calls, ["host", "office", "host", "office"]); + pause({ state: "connected", ownedByDesktop: true }); + assert.deepEqual(calls, ["host", "office", "host", "office"], "connected alone must not reopen a drain fence"); +}); + +function fixture(t, kind, options = {}) { + const claim = deferred(), effect = deferred(), completion = deferred(), start = deferred(), status = deferred(); + const effectEntered = deferred(), completionEntered = deferred(), startEntered = deferred(), statusEntered = deferred(); + const publications = [], publicationSignals = [], claimSignals = [], completions = [], completionSignals = [], diagnostics = []; + let clients = 0, stops = 0, previewCloses = 0, launchCloses = 0, effects = 0, statusCalls = 0; + const command = kind === "host" ? { + protocol: "ling-host-capability-bridge/v1", id: "accepted-command", capabilityId: "host.open_local_target", + path: "index.html", absolutePath: "/tmp/r03-fixture/index.html", workspaceRoot: "/tmp/r03-fixture", + presentation: "interactive_html", runId: "run-1", attemptId: "attempt-1", toolCallId: "tool-1", + approvalId: "approval-1", deadlineAt: "2099-01-01T00:00:00.000Z", idempotencyKey: "fixture-1", + } : { + protocol: OFFICE_DRIVER_PROTOCOL, id: "accepted-command", method: "driver_status", params: {}, + deadlineAt: "2099-01-01T00:00:00.000Z", idempotencyKey: "fixture-1", + }; + const helperStatus = { helperVersion: "fixture", state: "limited", capabilities: ["excel:sessions"], + officeInstalled: true, applicationRunning: true, permissionGranted: true }; + const transport = { + async publishAvailability(value, signal) { + publications.push(value); publicationSignals.push(signal); + if (kind === "host" && options.holdStartup && publications.length === 1) { + startEntered.resolve(); await start.promise; + } + }, + async claim(signal) { + claimSignals.push(signal); + if (claimSignals.length === 1 && options.deliver) return command; + if (claimSignals.length === 1 && options.lateClaim) return await claim.promise; + if (signal.aborted) return undefined; + await new Promise(resolve => signal.addEventListener("abort", resolve, { once: true })); + return undefined; + }, + async begin(_id, signal) { assert.equal(signal.aborted, false); }, + async complete(result, signal) { + completions.push(result); completionSignals.push(signal); completionEntered.resolve(); + if (options.holdCompletion) await completion.promise; + if (options.rejectCompletions) throw new Error("host_capability_daemon_request_failed"); + }, + }; + const shared = { daemonBaseUrl: "http://127.0.0.1:1", bridgeToken: "fixture", desktopInstanceId: "fixture", + daemonClientFactory: () => { clients++; return transport; }, + onDiagnostic: (...event) => diagnostics.push(event) }; + const runtime = kind === "host" ? new HostCapabilityRuntime({ ...shared, heartbeatMs: 4_000, errorBackoffMs: 1, + previewManager: { + async launch() { + effects++; effectEntered.resolve(); if (options.holdEffect) await effect.promise; + return { window: {}, close() { launchCloses++; } }; + }, + async close() { previewCloses++; }, + }, + }) : new OfficeDriverRuntime({ ...shared, platform: "darwin", executablePath: "/unused", recoveryRoot: "/unused", + helper: { + async start() { if (options.holdStartup) { startEntered.resolve(); await start.promise; } }, + async stop() { stops++; }, + async call(value) { + if (value.id === command.id) { + effects++; effectEntered.resolve(); if (options.holdEffect) await effect.promise; + } else { + statusCalls++; + if (options.holdStatus && statusCalls > 1) { statusEntered.resolve(); await status.promise; } + } + return { protocol: OFFICE_DRIVER_PROTOCOL, id: value.id, ok: true, result: helperStatus }; + }, + }, + }); + t.after(async () => { + claim.resolve(); effect.resolve(); completion.resolve(); start.resolve(); status.resolve(); + await runtime.stop(); + }); + return { runtime, command, publications, publicationSignals, claimSignals, completions, completionSignals, diagnostics, + claim, effect, completion, start, status, effectEntered, completionEntered, startEntered, statusEntered, + clients: () => clients, stops: () => stops, previewCloses: () => previewCloses, launchCloses: () => launchCloses, effects: () => effects }; +} + +function deferred() { + let resolve, reject; + const promise = new Promise((done, failed) => { resolve = done; reject = failed; }); + return { promise, resolve, reject }; +} + +async function flush() { for (let i = 0; i < 3; i++) await new Promise(resolve => setImmediate(resolve)); } +async function until(predicate) { + for (let i = 0; i < 100; i++) { + if (predicate()) return; + await new Promise(resolve => setTimeout(resolve, 1)); + } + assert.fail("Fixture did not reach the expected capability result"); +} diff --git a/apps/desktop/tests/cua-driver-artifact-build-completed.regression-1.test.mjs b/apps/desktop/tests/cua-driver-artifact-build-completed.regression-1.test.mjs new file mode 100644 index 000000000..d0cf6509a --- /dev/null +++ b/apps/desktop/tests/cua-driver-artifact-build-completed.regression-1.test.mjs @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { setImmediate as nextTurn } from "node:timers/promises"; +import test from "node:test"; + +let fixtureId = 0; +const deferred = () => { + let resolve; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +}; + +// Actual authored hook, synthetic Office/CUA boundaries. No real archives, +// signing, native build, filesystem deletion, or approval record changes. +async function fixture(t) { + const id = ++fixtureId; + const symbolName = `ling.artifact-hook.synthetic.${id}`; + const symbol = Symbol.for(symbolName); + const state = { + async office() {}, + async cua(context) { return context.file; }, + async extract() { throw new Error("synthetic_extraction_not_expected"); }, + }; + globalThis[symbol] = state; + t.after(() => { delete globalThis[symbol]; }); + const url = new URL(`../scripts/cua-driver-signing/artifact-build-completed.mjs?synthetic=${id}`, import.meta.url).href; + const bridge = `const state = globalThis[Symbol.for(${JSON.stringify(symbolName)})];\n`; + const sources = new Map([ + ["./hooks.mjs", bridge + "export function isCompanionBlockmap(c) { return c.file.endsWith('.blockmap'); } export function runArtifactBuildCompleted(...args) { return state.cua(...args); }"], + ["./final-artifact-extractor.mjs", bridge + "export function extractCuaDriverFinalArtifact(...args) { return state.extract(...args); }"], + ["../packaged-office-native-package-checks.mjs", bridge + "export function verifyOfficeFinalArtifact(...args) { return state.office(...args); }"], + ]); + const loader = registerHooks({ resolve(specifier, context, nextResolve) { + if (context.parentURL === url && sources.has(specifier)) { + return { shortCircuit: true, url: "data:text/javascript," + encodeURIComponent(sources.get(specifier)) }; + } + return nextResolve(specifier, context); + } }); + t.after(() => loader.deregister()); + return { state, hook: (await import(url)).default }; +} + +test("eight simultaneous artifacts wait for both Office and CUA cleanup before the next extraction", async t => { + const { state, hook } = await fixture(t); + const packager = {}; + const office = Array.from({ length: 8 }, deferred); + const cua = Array.from({ length: 8 }, deferred); + const calls = []; + let active = 0, maximumActive = 0; + state.office = async context => { + active++; + maximumActive = Math.max(maximumActive, active); + calls.push(`office:${context.index}`); + await office[context.index].promise; + calls.push(`office-cleaned:${context.index}`); + }; + state.cua = async (context, options) => { + assert.equal(typeof options.extractArtifact, "function"); + calls.push(`cua:${context.index}`); + await cua[context.index].promise; + calls.push(`cua-cleaned:${context.index}`); + active--; + return context.file; + }; + const results = Array.from({ length: 8 }, (_, index) => hook({ packager, index, file: `${index}.${index % 2 ? "dmg" : "zip"}` })); + try { + await nextTurn(); + assert.deepEqual(calls, ["office:0"], "a second container must not be extracted while the first Office verification is live"); + for (let index = 0; index < 8; index++) { + office[index].resolve(); + await nextTurn(); + assert.equal(calls.at(-1), `cua:${index}`, "Office completion alone must not release the artifact lane"); + assert.equal(calls.filter(value => value.startsWith("office:")).length, index + 1); + cua[index].resolve(); + assert.equal(await results[index], `${index}.${index % 2 ? "dmg" : "zip"}`); + await nextTurn(); + } + assert.equal(active, 0); + assert.equal(maximumActive, 1); + } finally { + for (const gate of [...office, ...cua]) gate.resolve(); + await Promise.allSettled(results); + } +}); + +test("verification or awaited cleanup failure prevents queued and later artifacts, without poisoning another Packager", async t => { + for (const phase of ["office-verification", "office-cleanup", "cua-verification", "cua-cleanup"]) { + await t.test(phase, async t => { + const { state, hook } = await fixture(t); + const packager = {}, otherPackager = {}; + const error = new Error(`injected_${phase}`); + const calls = []; + state.office = async context => { + calls.push(`office:${context.file}`); + if (context.packager === packager && phase.startsWith("office")) { + if (phase.endsWith("cleanup")) await nextTurn(); + throw error; + } + }; + state.cua = async context => { + calls.push(`cua:${context.file}`); + if (context.packager === packager && phase.startsWith("cua")) { + if (phase.endsWith("cleanup")) await nextTurn(); + throw error; + } + return context.file; + }; + const settled = await Promise.allSettled(Array.from({ length: 8 }, (_, index) => hook({ packager, file: `${index}.zip` }))); + assert.ok(settled.every(result => result.status === "rejected" && result.reason === error)); + assert.deepEqual(calls, phase.startsWith("office") ? ["office:0.zip"] : ["office:0.zip", "cua:0.zip"]); + await assert.rejects(hook({ packager, file: "later.dmg" }), cause => cause === error); + assert.equal(await hook({ packager: otherPackager, file: "independent.zip" }), "independent.zip"); + }); + } +}); + +test("another Packager can verify while the first is waiting, and metadata-only blockmap skip stays intact", async t => { + const { state, hook } = await fixture(t); + const packager = {}, otherPackager = {}, gate = deferred(); + const calls = []; + state.office = async context => { + calls.push(context.file); + if (context.packager === packager) await gate.promise; + }; + const pending = hook({ packager, file: "pending.zip" }); + try { + await nextTurn(); + assert.equal(await hook({ packager: otherPackager, file: "other.dmg" }), "other.dmg"); + assert.equal(await hook({ packager: otherPackager, file: "other.dmg.blockmap" }), "other.dmg.blockmap"); + assert.deepEqual(calls, ["pending.zip", "other.dmg"]); + } finally { + gate.resolve(); + await pending; + } +}); + +test("missing or invalid Packager identity cannot bypass the shared verification lane", async t => { + const { state, hook } = await fixture(t); + let calls = 0; + state.office = async () => { calls++; }; + state.cua = async () => { calls++; }; + for (const packager of [undefined, null, false, "shared", 1]) { + await assert.rejects(hook({ packager, file: "unowned.zip" }), /artifact_verification_packager_required/u); + } + assert.equal(calls, 0); +}); diff --git a/apps/desktop/tests/cua-driver-dmg-attachment-custody.test.mjs b/apps/desktop/tests/cua-driver-dmg-attachment-custody.test.mjs index c30b9dd7c..189099f8b 100644 --- a/apps/desktop/tests/cua-driver-dmg-attachment-custody.test.mjs +++ b/apps/desktop/tests/cua-driver-dmg-attachment-custody.test.mjs @@ -214,6 +214,56 @@ test("devices already present before attach cannot become new original lineage", assert.equal(detachCalls(f).length, 0); }); +// Regression: P3-DMG-001 — an unrelated APFS image can expose multiple whole disks. +// Found by /qa on 2026-10-24 +// Report: output/e6-20260928/p3-dmg-inventory-repair-20261024.md +test("unrelated multi-disk images remain visible without authorizing their detach", async () => { + const background = image({ imagePath: "/tmp/EXPLICIT-SYNTHETIC-background.sparsebundle", + ownEntities: [...entities("/dev/disk900"), ...entities("/dev/disk901", "/tmp/background-volume")] }); + const f = fixture({ info() { return { images: [background, ...f.inventory] }; } }); + const attached = await f.custody.attach(); + assert.equal(attached.binding.wholeDevice, DEVICE); + await f.custody.detach(); + assert.deepEqual(detachCalls(f).map(call => call.args), [["detach", DEVICE]]); + assert.equal(f.custody.receipt().status, "detached"); +}); + +test("multi-disk background images still enforce mount and device conflicts", async t => { + await t.test("occupied-mountpoint", async () => { + const background = image({ imagePath: "/tmp/EXPLICIT-SYNTHETIC-background.sparsebundle", + ownEntities: [...entities("/dev/disk900"), ...entities("/dev/disk901", MOUNT_POINT)] }); + const f = fixture({ before: [background] }); + await rejectsCode(f.custody.attach(), "already_present"); + assert.equal(f.custody.receipt().attempted, false); + assert.equal(detachCalls(f).length, 0); + }); + await t.test("previously-present-device", async () => { + const background = image({ imagePath: "/tmp/EXPLICIT-SYNTHETIC-background.sparsebundle", + ownEntities: [...entities("/dev/disk900"), ...entities(DEVICE, "/tmp/background-volume")] }); + const f = fixture({ before: [background] }); + await rejectsCode(f.custody.attach(), "device_reused"); + assert.equal(Object.hasOwn(f.custody.receipt(), "binding"), false); + assert.equal(detachCalls(f).length, 0); + }); + await t.test("foreign-partition-without-whole-device", async () => { + const background = image({ imagePath: "/tmp/EXPLICIT-SYNTHETIC-background.sparsebundle", + ownEntities: [...entities("/dev/disk900"), ...entities("/dev/disk901"), + { "dev-entry": "/dev/disk902s1" }] }); + const f = fixture({ before: [background] }); + await rejectsCode(f.custody.attach(), "whole_device_invalid"); + assert.equal(f.custody.receipt().attempted, false); + assert.equal(detachCalls(f).length, 0); + }); + await t.test("replacement-contains-original-device", async () => { + const f = fixture(); + await f.custody.attach(); + f.inventory = [image({ imagePath: "/tmp/EXPLICIT-SYNTHETIC-background.sparsebundle", + ownEntities: [...entities("/dev/disk900"), ...entities(DEVICE, "/tmp/background-volume")] })]; + await rejectsCode(f.custody.detach(), "association_mismatch"); + assert.equal(detachCalls(f).length, 0); + }); +}); + test("after-info must match every original attach entity, never only the first mount", async t => { const cases = [ ["extra-entity", own => ({ ...own, "system-entities": [...own["system-entities"], { "dev-entry": `${DEVICE}s2` }] })], diff --git a/apps/desktop/tests/cua-driver-final-artifact-command.test.mjs b/apps/desktop/tests/cua-driver-final-artifact-command.test.mjs index 6174f1d52..a6325c12a 100644 --- a/apps/desktop/tests/cua-driver-final-artifact-command.test.mjs +++ b/apps/desktop/tests/cua-driver-final-artifact-command.test.mjs @@ -30,6 +30,21 @@ test("completed nonzero exit retains complete bounded output without turning fai stdout: "partial receipt", stderr: "nonzero diagnostic", outputComplete: true, }); assert.equal(Object.isFrozen(error.commandReceipt), true); + assert.equal(error.commandDiagnostic.command, process.execPath.split("/").at(-1)); + assert.equal(error.commandDiagnostic.exitCode, 7); + assert.equal(error.commandDiagnostic.signal, null); + assert.equal(error.commandDiagnostic.killed, false); + assert.equal(error.commandDiagnostic.outputComplete, true); + assert.equal(error.commandDiagnostic.timeoutMs, 120_000); + assert.equal(error.commandDiagnostic.stdoutBytes, Buffer.byteLength(error.commandReceipt.stdout)); + assert.equal(error.commandDiagnostic.stderrBytes, Buffer.byteLength(error.commandReceipt.stderr)); + assert.ok(Number.isInteger(error.commandDiagnostic.pid) && error.commandDiagnostic.pid > 0); + assert.ok(Number.isFinite(error.commandDiagnostic.elapsedMs) && error.commandDiagnostic.elapsedMs >= 0); + assert.ok(Number.isFinite(Date.parse(error.commandDiagnostic.startedAt))); + assert.equal(Object.isFrozen(error.commandDiagnostic), true); + assert.ok(error.message.includes(JSON.stringify(error.commandDiagnostic))); + assert.equal(error.message.includes("partial receipt"), false); + assert.equal(error.message.includes("nonzero diagnostic"), false); return true; }); }); @@ -44,6 +59,12 @@ for (const [label, script, options] of [ await assert.rejects(runBoundedCommand(process.execPath, ["-e", script], options), error => { assert.equal(error.code, "final_artifact_command_failed"); assert.equal(error.commandReceipt.outputComplete, false); + assert.equal(error.commandDiagnostic.outputComplete, false); + if (label === "timeout" || label === "signal") { + assert.equal(error.commandDiagnostic.signal, "SIGTERM"); + assert.equal(error.commandDiagnostic.killed, label === "timeout"); + assert.equal(error.commandDiagnostic.timeoutMs, options.timeoutMs ?? 120_000); + } return true; }); }); @@ -53,6 +74,9 @@ test("spawn failure is unknown, not a complete output receipt", async () => { await assert.rejects(runBoundedCommand("/explicit-test-only/missing-command", []), error => { assert.equal(error.cause.code, "ENOENT"); assert.equal(error.commandReceipt.outputComplete, false); + assert.equal(error.commandDiagnostic.pid, null); + assert.equal(error.commandDiagnostic.exitCode, null); + assert.equal(error.commandDiagnostic.errorCode, "ENOENT"); return true; }); }); diff --git a/apps/desktop/tests/cua-driver-host-drain.regression.test.mjs b/apps/desktop/tests/cua-driver-host-drain.regression.test.mjs new file mode 100644 index 000000000..fc09d4c5c --- /dev/null +++ b/apps/desktop/tests/cua-driver-host-drain.regression.test.mjs @@ -0,0 +1,301 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { CuaDriverDesktopCoordinator } from "../dist/main/cua-driver/coordinator.js"; +import { CuaDriverLeasePublisher } from "../dist/main/cua-driver/lease-publisher.js"; +import { CuaDriverMcpProxy, deriveCuaDriverWorkspaceBinding } from "../dist/main/cua-driver/mcp-proxy.js"; +import { createA34CoordinatorArtifactFixture } from "./support/a34-coordinator-artifact-fixture.mjs"; + +test("host drain cannot be reopened by an initialize already awaiting readiness", async (t) => { + const entered = deferred(); + const readiness = deferred(); + const fixture = await createProxy(t, { + async onInitialized() { + entered.resolve(); + await readiness.promise; + return { callGate: "open" }; + }, + }); + const initializing = fixture.post(initialize(1)); + await entered.promise; + fixture.proxy.beginHostDrain("host_draining"); + readiness.resolve(); + const initialized = await initializing; + assert.equal(initialized.status, 200); + const sessionId = initialized.headers.get("mcp-session-id"); + const called = await fixture.post({ jsonrpc: "2.0", id: 2, method: "tools/call" }, sessionId); + assert.equal((await called.json()).error?.message, "mcp_call_gate_closed"); + assert.equal(fixture.stopReasons.length, 0); +}); + +test("host drain preserves accepted exchange and server replies but rejects new calls and initialize", async (t) => { + const accepted = deferred(); + const result = deferred(); + const fixture = await createProxy(t, { + async exchange(message) { + if (message.id === "in-flight") { + accepted.resolve(); + await result.promise; + return { jsonrpc: "2.0", id: message.id, result: "completed-on-original-host" }; + } + }, + }); + const initialized = await fixture.post(initialize(1)); + const sessionId = initialized.headers.get("mcp-session-id"); + const inFlight = fixture.post({ jsonrpc: "2.0", id: "in-flight", method: "tools/call" }, sessionId); + await accepted.promise; + fixture.proxy.beginHostDrain("host_draining"); + fixture.proxy.openCallGate(); + await assert.rejects(fixture.proxy.start(), /mcp_host_draining/); + const denied = await fixture.post({ jsonrpc: "2.0", id: 3, method: "tools/list" }, sessionId); + assert.equal((await denied.json()).error?.message, "mcp_call_gate_closed"); + const reinitialize = await fixture.post(initialize(4)); + assert.equal(reinitialize.status, 503); + assert.equal((await reinitialize.json()).error, "mcp_host_draining"); + const beforeNotification = fixture.messages.length; + await fixture.post({ jsonrpc: "2.0", method: "notifications/initialized" }, sessionId); + assert.equal(fixture.messages.length, beforeNotification); + await fixture.post({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: "not-accepted" } }, sessionId); + assert.equal(fixture.messages.length, beforeNotification); + const cancellation = { jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: "in-flight" } }; + assert.equal((await fixture.post(cancellation, sessionId)).status, 202); + assert.deepEqual(fixture.messages.at(-1), cancellation); + const serverReply = { jsonrpc: "2.0", id: "server-request", result: "accepted" }; + assert.equal((await fixture.post(serverReply, sessionId)).status, 202); + assert.deepEqual(fixture.messages.at(-1), serverReply); + assert.deepEqual(fixture.stopReasons, []); + result.resolve(); + assert.equal((await (await inFlight).json()).result, "completed-on-original-host"); + assert.equal(fixture.proxy.activeSessionId, sessionId); + const afterCompletion = fixture.messages.length; + await fixture.post(cancellation, sessionId); + assert.equal(fixture.messages.length, afterCompletion); +}); + +test("host drain retains the provider and session across SSE loss and session DELETE", async (t) => { + const fixture = await createProxy(t); + const initialized = await fixture.post(initialize(1)); + const sessionId = initialized.headers.get("mcp-session-id"); + const stream = await fetch(fixture.endpoint.url, { headers: fixture.headers(sessionId) }); + const reader = stream.body.getReader(); + await reader.read(); + const closed = new Promise((resolve) => fixture.proxy.sseResponse.once("close", resolve)); + fixture.proxy.beginHostDrain("host_draining"); + await reader.cancel(); + await closed; + assert.deepEqual(fixture.stopReasons, []); + assert.equal(fixture.proxy.activeSessionId, sessionId); + const deleted = await fetch(fixture.endpoint.url, { method: "DELETE", headers: fixture.headers(sessionId) }); + assert.equal(deleted.status, 409); + assert.equal((await deleted.json()).error, "mcp_host_draining"); + assert.deepEqual(fixture.stopReasons, []); + assert.equal(fixture.proxy.activeSessionId, sessionId); +}); + +test("ordinary security gate closure does not inherit host-drain reply exceptions", async (t) => { + const fixture = await createProxy(t); + const initialized = await fixture.post(initialize(1)); + const sessionId = initialized.headers.get("mcp-session-id"); + const before = fixture.messages.length; + fixture.proxy.closeCallGate("system_locked"); + const reply = await fixture.post({ jsonrpc: "2.0", id: "server-request", result: "not-admitted" }, sessionId); + assert.equal((await reply.json()).error?.message, "mcp_call_gate_closed"); + assert.equal(fixture.messages.length, before); +}); + +test("host drain does not stop the provider when late readiness rejects or SSE overflows", async (t) => { + const entered = deferred(); + const readiness = deferred(); + const fixture = await createProxy(t, { + async onInitialized() { + entered.resolve(); + await readiness.promise; + throw new Error("late_readiness_failed"); + }, + }); + const initializing = fixture.post(initialize(1)); + await entered.promise; + fixture.proxy.beginHostDrain("host_draining"); + readiness.resolve(); + const response = await initializing; + assert.equal(response.status, 200); + assert.ok(response.headers.get("mcp-session-id")); + fixture.proxy.revokeSseSession("mcp_server_backpressure_exceeded"); + assert.deepEqual(fixture.stopReasons, []); + assert.ok(fixture.proxy.activeSessionId); +}); + +test("lease drain stops renewal without revoking or turning a late failure into recovery", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const first = deferred(); + const calls = []; + let lost = 0; + const publisher = new CuaDriverLeasePublisher({ + lease: leaseTemplate(), + ttlMs: 5_000, + renewIntervalMs: 1_000, + telemetry: { emit() {} }, + onLeaseLost() { lost += 1; }, + client: { + async publish(_lease, signal) { calls.push({ kind: "publish", signal }); await first.promise; }, + async revoke() { calls.push({ kind: "revoke" }); }, + }, + }); + const publishing = publisher.publish(); + publisher.beginHostDrain(); + assert.equal(calls[0].signal.aborted, false); + first.resolve(); + await publishing; + t.mock.timers.tick(20_000); + assert.equal(calls.length, 1); + await assert.rejects(publisher.publish(), /lease_publisher_draining/); + await assert.rejects(publisher.publishReadinessDetails({ integrity: "verified" }), /lease_publisher_draining/); + assert.equal(lost, 0); + await publisher.revoke(); + assert.deepEqual(calls.map((call) => call.kind), ["publish", "revoke"]); +}); + +test("lease drain retains the original failure without dispatching lease-lost recovery", async () => { + const pending = deferred(); + let lost = 0; + const publisher = new CuaDriverLeasePublisher({ + lease: leaseTemplate(), telemetry: { emit() {} }, + onLeaseLost() { lost += 1; }, + client: { + async publish() { await pending.promise; throw new Error("late_network_failure"); }, + async revoke() {}, + }, + }); + const publishing = publisher.publish(); + publisher.beginHostDrain(); + pending.resolve(); + await assert.rejects(publishing, /late_network_failure/); + assert.equal(lost, 0); +}); + +test("coordinator drain latches pending real lazy enable without destructive cleanup or successor", { + skip: process.platform !== "darwin", +}, async (t) => { + const fixture = await createA34CoordinatorArtifactFixture(t); + const entered = deferred(); + const publishing = deferred(); + const calls = []; + const coordinator = createCoordinator({ + ...fixture, + processLifecycle: { + lifetimeBoundary: "test-fixture", + spawn() { throw new Error("synthetic_payload_must_not_spawn"); }, + async terminate() { calls.push("terminate"); }, + }, + leaseClient: { + async publish() { calls.push("publish"); entered.resolve(); await publishing.promise; }, + async revoke() { calls.push("revoke"); }, + }, + }); + t.after(async () => { publishing.resolve(); await coordinator.shutdown(); }); + const enabling = coordinator.enable(); + await entered.promise; + const host = coordinator.providerHost; + const proxy = coordinator.proxy; + coordinator.beginHostDrain("host_draining"); + publishing.resolve(); + await assert.rejects(enabling, /cua_driver_host_draining/); + await assert.rejects(coordinator.enable(), /cua_driver_host_draining/); + await assert.rejects(coordinator.restart(), /cua_driver_host_draining/); + await assert.rejects(coordinator.disable(), /cua_driver_host_draining/); + coordinator.scheduleRecovery("late_lease_failure", host.generation); + coordinator.scheduleTerminalFailure("late_readiness_failure", host.generation, "platform_unavailable"); + assert.deepEqual(calls, ["publish"]); + assert.equal(coordinator.providerHost, host); + assert.equal(coordinator.proxy, proxy); + assert.ok(proxy.endpoint); + assert.equal(coordinator.generation, 1); + await coordinator.shutdown(); + assert.deepEqual(calls, ["publish", "revoke"]); + assert.equal(coordinator.providerHost, undefined); +}); + +test("coordinator drain fences a disable already waiting on lease revoke", async () => { + const coordinator = createCoordinator(); + const entered = deferred(); + const revoked = deferred(); + const calls = []; + coordinator.host = { generation: 1, async stop() { calls.push("stop"); } }; + coordinator.proxy = { closeCallGate() {}, beginHostDrain() { calls.push("drain-proxy"); }, async close() { calls.push("close"); } }; + coordinator.leasePublisher = { + beginHostDrain() { calls.push("drain-lease"); }, + async revoke() { calls.push("revoke"); entered.resolve(); await revoked.promise; }, + }; + const disabling = coordinator.disable(); + await entered.promise; + coordinator.beginHostDrain("host_draining"); + revoked.resolve(); + await disabling; + assert.deepEqual(calls, ["revoke", "drain-proxy", "drain-lease"]); + assert.ok(coordinator.providerHost); + await coordinator.shutdown(); + assert.deepEqual(calls, ["revoke", "drain-proxy", "drain-lease", "revoke", "close", "stop"]); +}); + +async function createProxy(t, overrides = {}) { + const messages = []; + const stopReasons = []; + const host = { + onServerMessage() { return () => {}; }, + async exchange(message) { + messages.push(message); + const override = await overrides.exchange?.(message); + if (override !== undefined) return override; + if (!("method" in message) || !("id" in message)) return undefined; + return { jsonrpc: "2.0", id: message.id, result: { capabilities: {}, serverInfo: { name: "fixture", version: "1" } } }; + }, + async stop(reason) { stopReasons.push(reason); }, + }; + const context = { ...leaseTemplate(), bridgeToken: "test-bridge-secret" }; + const proxy = new CuaDriverMcpProxy({ host, context, onInitialized: overrides.onInitialized, telemetry: { emit() {} } }); + t.after(() => proxy.close("test-final-cleanup")); + const endpoint = await proxy.start(); + const headers = (sessionId) => ({ + authorization: `Bearer ${endpoint.authorizationToken}`, + "content-type": "application/json", + "x-ling-cua-driver-generation": String(context.generation), + "x-ling-cua-driver-workspace-sha256": deriveCuaDriverWorkspaceBinding(context.workspaceCwd), + "x-ling-cua-driver-authority-epoch": context.daemonAuthorityEpoch, + "x-ling-cua-driver-instance-epoch": String(context.desktopInstanceEpoch), + "x-ling-cua-driver-bridge-connection": context.bridgeConnectionId, + ...(sessionId ? { "mcp-session-id": sessionId } : {}), + }); + return { proxy, endpoint, headers, messages, stopReasons, + post: (message, sessionId) => fetch(endpoint.url, { method: "POST", headers: headers(sessionId), body: JSON.stringify(message) }), + }; +} + +function initialize(id) { + return { jsonrpc: "2.0", id, method: "initialize", params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "test", version: "1" } } }; +} + +function leaseTemplate() { + return { + schemaVersion: 1, providerId: "cua-driver", desktopInstanceId: "desktop-drain-fixture", desktopInstanceEpoch: 1, + bridgeConnectionId: "bridge-drain-fixture", generation: 1, daemonAuthorityEpoch: "authority-drain-fixture", + workspaceCwd: "/workspace", endpoint: "http://127.0.0.1:1234/mcp", providerVersion: "0.8.3", + distributionRevision: "ling.1", integrationMode: "embedded-stdio", lockDigest: "a".repeat(64), + providerSealDigest: "b".repeat(64), platform: "darwin", architecture: "arm64", + }; +} + +function createCoordinator(overrides = {}) { + const workspaceCwd = overrides.workspaceCwd ?? "/workspace"; + return new CuaDriverDesktopCoordinator({ + platform: "darwin", architecture: "arm64", resourcesPath: "/not-used", userDataPath: "/not-used", + daemonBaseUrl: "http://127.0.0.1:17891", telemetry: { emit() {} }, + featureFlags: { bundledEmbedded: { darwin: true, win32: false }, packagedProvider: { darwin: true, win32: false } }, + ...overrides, workspaceCwd, + bridgeContext: { bridgeToken: "test-bridge-secret", daemonAuthorityEpoch: "authority-drain-fixture", bridgeConnectionId: "bridge-drain-fixture", desktopInstanceEpoch: 1, workspaceCwd }, + }); +} + +function deferred() { + let resolve; + const promise = new Promise((resolvePromise) => { resolve = resolvePromise; }); + return { promise, resolve }; +} diff --git a/apps/desktop/tests/cua-driver-parent-lease-controlled-transport.test.mjs b/apps/desktop/tests/cua-driver-parent-lease-controlled-transport.test.mjs new file mode 100644 index 000000000..07a49bcfa --- /dev/null +++ b/apps/desktop/tests/cua-driver-parent-lease-controlled-transport.test.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + atomicPublishDirectoryNoReplace, + CuaDriverAtomicPublishError, + withAtomicPublishParentLeaseChildForTest, +} from "../scripts/cua-driver-package-atomic-publish.mjs"; +import { + ControlledLeaseChild, + deferred, + nextEventLoopTurn, +} from "./support/cua-driver-controlled-lease-streams.mjs"; + +for (const release of [false, true]) { + test(`C001 controlled real capability ${release ? "release/end" : "CALL/write"} failure invalidates and drains before rejecting`, async (t) => { + const fixture = await createFixture(t); + const cause = Object.assign(new Error("controlled capability EPIPE"), { code: "EPIPE" }); + let publicLease; + let settled = false; + const running = fixture.withLease(async (lease) => { + publicLease = lease; + return release ? "operation returned before release" : fixture.publish(lease); + }); + const rejected = assert.rejects(running, error => { + assertFailure(error, cause); + return true; + }); + void running.then(() => { settled = true; }, () => { settled = true; }); + const request = await fixture.child.stdin.nextWrite(); + assert.match(request.frame, release ? /\trelease\t/ : /\tcall\t/); + request.complete(cause); + await nextEventLoopTurn(); + assert.equal(publicLease.signal.aborted, true, "real capability must be invalidated immediately"); + assert.deepEqual(fixture.child.killCalls, ["SIGKILL"]); + assert.equal(settled, false, "termination was requested, not confirmed"); + assert.throws(publicLease.assertHeld, error => { + assertFailure(error, cause); + return true; + }); + await assert.rejects(fixture.publish(publicLease), error => { + assert.equal(error.code, "staging_parent_lock_capability_required"); + return true; + }); + fixture.child.stdin.emit("error", new Error("duplicate late stdin error")); + assert.deepEqual(fixture.child.killCalls, ["SIGKILL"]); + fixture.child.finishExit(); + await rejected; + await nextEventLoopTurn(); + assert.equal(fixture.child.stdin.closed, true); + assert.equal(fixture.child.stdin.listenerCount("error"), 0); + assert.equal(fixture.child.stdin.destroyCalls, 1); + assert.equal(fixture.child.stdin.requests.length, 0, "invalid capability cannot issue another frame"); + }); +} + +test("C001 controlled idle stdin error cannot bypass holder drain through guard/release", async (t) => { + const fixture = await createFixture(t); + const entered = deferred(); + const finishOperation = deferred(); + const cause = new Error("controlled idle stream failure"); + let settled = false; + const running = fixture.withLease(async (lease) => { + entered.resolve(lease); + await finishOperation.promise; + return "narrative only"; + }); + const rejected = assert.rejects(running, error => { + assertFailure(error, cause); + return true; + }); + void running.then(() => { settled = true; }, () => { settled = true; }); + const lease = await entered.promise; + fixture.child.stdin.emit("error", cause); + await nextEventLoopTurn(); + assert.equal(lease.signal.aborted, true); + finishOperation.resolve(); + await nextEventLoopTurn(); + assert.equal(settled, false, "guard must await both operation and holder termination"); + assert.deepEqual(fixture.child.killCalls, ["SIGKILL"]); + fixture.child.finishExit(); + await rejected; + assert.equal(fixture.child.stdin.requests.length, 0); +}); + +test("C001 controlled real capability normal CALL plus release stays successful", async (t) => { + const fixture = await createFixture(t); + const running = fixture.withLease(lease => fixture.publish(lease)); + const call = await fixture.child.stdin.nextWrite(); + fixture.child.receipt(call, "committed"); + call.complete(); + const release = await fixture.child.stdin.nextWrite(); + assert.match(release.frame, /\trelease\t/); + fixture.child.receipt(release, "released"); + release.complete(); + await nextEventLoopTurn(); + fixture.child.finishExit({ code: 0, signal: null }); + const result = await running; + assert.equal(result.outcome, "committed"); + assert.equal(result.operationOwnerPid, fixture.child.pid); + assert.deepEqual(fixture.child.killCalls, []); + await nextEventLoopTurn(); + assert.equal(fixture.child.stdin.listenerCount("error"), 0); +}); + +async function createFixture(t) { + const parentRoot = await mkdtemp(join(await realpath(tmpdir()), "ling-c001-controlled-")); + const child = new ControlledLeaseChild(); + t.after(async () => { + child.finishExit(); + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + await rm(parentRoot, { recursive: true, force: true }); + }); + return { + child, + withLease: operation => withAtomicPublishParentLeaseChildForTest({ + parentRoot, child, timeoutMs: 500, operationTimeoutMs: 500, + }, operation), + publish: lease => atomicPublishDirectoryNoReplace({ + parentRoot, + sourceName: "source", + destinationName: "destination", + expectedParentIdentity: "1:1", + expectedSourceIdentity: "1:2", + }, lease), + }; +} + +function assertFailure(error, cause) { + assert.ok(error instanceof CuaDriverAtomicPublishError); + assert.equal(error.code, "staging_parent_lock_protocol_invalid"); + assert.equal(error.outcome, "ambiguous"); + assert.equal(error.cause, cause); +} diff --git a/apps/desktop/tests/cua-driver-parent-lease-stream-writer.test.mjs b/apps/desktop/tests/cua-driver-parent-lease-stream-writer.test.mjs new file mode 100644 index 000000000..316fbf80f --- /dev/null +++ b/apps/desktop/tests/cua-driver-parent-lease-stream-writer.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import test from "node:test"; + +import { CuaDriverAtomicPublishError } from "../scripts/cua-driver-package-atomic-publish.mjs"; +import { createNativeParentLeaseStreamWriter } from "../scripts/cua-driver-parent-lease-stream-writer.mjs"; +import { + ControlledLeaseWritable, + deferred, + nextEventLoopTurn, +} from "./support/cua-driver-controlled-lease-streams.mjs"; + +for (const end of [false, true]) { + test(`C001 deterministic ${end ? "end" : "write"}: callback(error) then error preserves cause and waits for drain`, async () => { + const fixture = createFixture(); + const cause = Object.assign(new Error("controlled broken pipe"), { code: "EPIPE" }); + const pending = fixture.writer.write("frame\n", { end }); + const rejection = assert.rejects(pending, error => { + assertFailure(error, cause); + return true; + }); + let settlements = 0; + void pending.then(() => { settlements += 1; }, () => { settlements += 1; }); + const request = await fixture.stream.nextWrite(); + request.complete(cause); + await nextEventLoopTurn(); + assert.equal(fixture.failures.length, 1); + assert.equal(fixture.stream.listenerCount("error"), 1, + "callback failure must not remove the listener before the following error event"); + assert.equal(settlements, 0, "typed reject must await authority drain"); + fixture.stream.emit("error", new Error("duplicate stream error")); + assert.equal(fixture.failures.length, 1, "the first failure is authoritative"); + fixture.drain.resolve(); + await rejection; + await nextEventLoopTurn(); + assert.equal(settlements, 1); + const closed = once(fixture.stream, "close"); + fixture.writer.disposeAfterTermination(); + await closed; + assert.equal(fixture.stream.listenerCount("error"), 0); + }); +} + +test("C001 deterministic synchronous write throw rejects once with its original cause", async () => { + const fixture = createFixture(); + const cause = new Error("controlled synchronous throw"); + fixture.stream.write = () => { throw cause; }; + const rejected = assert.rejects(fixture.writer.write("frame"), error => { + assertFailure(error, cause); + return true; + }); + assert.equal(fixture.failures.length, 1); + fixture.stream.emit("error", new Error("error after synchronous throw")); + fixture.drain.resolve(); + await rejected; + fixture.writer.disposeAfterTermination(); +}); + +test("C001 deterministic close before completion fails instead of resolving a late callback", async () => { + const fixture = createFixture(); + const pending = fixture.writer.write("frame"); + const rejected = assert.rejects(pending, error => { + assertFailure(error); + assert.equal(error.cause.code, "ERR_STREAM_PREMATURE_CLOSE"); + return true; + }); + const request = await fixture.stream.nextWrite(); + fixture.stream.emit("close"); + request.complete(); + fixture.drain.resolve(); + await rejected; + assert.equal(fixture.failures.length, 1); + assert.equal(fixture.stream.listenerCount("error"), 0); +}); + +test("C001 deterministic idle error invalidates a previously successful writer and prevents future writes", async () => { + const fixture = createFixture(); + const first = fixture.writer.write("first"); + (await fixture.stream.nextWrite()).complete(); + await first; + const cause = new Error("idle stdin failure"); + fixture.stream.emit("error", cause); + const rejected = assert.rejects(fixture.writer.write("never sent"), error => { + assertFailure(error, cause); + return true; + }); + assert.equal(fixture.stream.requests.length, 0); + fixture.drain.resolve(); + await rejected; + fixture.writer.disposeAfterTermination(); +}); + +test("C001 deterministic two pending frames share a single failure/drain and never fulfill", async () => { + const fixture = createFixture(); + const cause = new Error("first frame failed"); + const outcomes = Promise.allSettled([ + fixture.writer.write("first"), + fixture.writer.write("second"), + ]); + (await fixture.stream.nextWrite()).complete(cause); + await nextEventLoopTurn(); + assert.equal(fixture.failures.length, 1); + fixture.drain.resolve(); + const results = await outcomes; + assert.deepEqual(results.map(result => result.status), ["rejected", "rejected"]); + assert.equal(results[0].reason, results[1].reason); + assertFailure(results[0].reason, cause); + fixture.writer.disposeAfterTermination(); +}); + +test("C001 deterministic successful write/end controls keep listener through termination, then clean up", async () => { + const fixture = createFixture(); + const first = fixture.writer.write("first\n"); + (await fixture.stream.nextWrite()).complete(); + await first; + const last = fixture.writer.write("release\n", { end: true }); + (await fixture.stream.nextWrite()).complete(); + await last; + assert.equal(fixture.failures.length, 0); + assert.equal(fixture.stream.listenerCount("error"), 1); + const closed = once(fixture.stream, "close"); + fixture.writer.disposeAfterTermination(); + await closed; + fixture.writer.disposeAfterTermination(); + assert.equal(fixture.stream.destroyCalls, 1); + assert.equal(fixture.stream.listenerCount("error"), 0); +}); + +function createFixture() { + const stream = new ControlledLeaseWritable(); + const drain = deferred(); + const failures = []; + const writer = createNativeParentLeaseStreamWriter({ + stream, + createFailure: cause => new CuaDriverAtomicPublishError( + "staging_parent_lock_protocol_invalid", + "controlled native parent lease frame failure", + { cause, outcome: "ambiguous" }, + ), + onFailure: error => { failures.push(error); return drain.promise; }, + }); + return { stream, drain, failures, writer }; +} + +function assertFailure(error, cause) { + assert.ok(error instanceof CuaDriverAtomicPublishError); + assert.equal(error.code, "staging_parent_lock_protocol_invalid"); + assert.equal(error.outcome, "ambiguous"); + if (cause !== undefined) assert.equal(error.cause, cause); +} diff --git a/apps/desktop/tests/daemon-lifecycle.regression-1.test.mjs b/apps/desktop/tests/daemon-lifecycle.regression-1.test.mjs new file mode 100644 index 000000000..4f2318f23 --- /dev/null +++ b/apps/desktop/tests/daemon-lifecycle.regression-1.test.mjs @@ -0,0 +1,244 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import test from "node:test"; +import { DesktopDaemonLifecycle } from "../dist/main/daemon-lifecycle.js"; + +// Regression: E6-R03 — stop observation must retain its owner, never authorize force-kill/restart. +// Found by /qa on 2026-10-26 +// Report: output/e6-20260928/r03-daemon-restart-packaged-20261026/repair-status.md +// Value: protects=owned blocked/unconfirmed Host cannot be killed or replaced; +// fails_when=stop clears owner early, ignores FD5 or escalates after 3s; +// why_new=existing tests expect forced startup cleanup and never project Host stop facts; +// seam=existing spawnDaemon; no_claim=actual packaged GUI (controlled child/health). +function fixture(t, outcome, beforeReady) { + const children = [], diagnostics = [], unexpected = []; + let active, external = false; + const workspace = "/tmp/ling-e6-r03-owned-observer"; + const lifecycle = new DesktopDaemonLifecycle({ + baseUrl: "http://127.0.0.1:17991", daemonEntryPath: new URL("../package.json", import.meta.url).pathname, + defaultWorkspaceCwd: workspace, shutdownTimeoutMs: 0, + probePort: async () => "free", + fetch: async () => { + if (!external && (!active || active.exitCode !== null || active.signalCode !== null || active.stopping)) throw new Error("offline"); + await beforeReady?.(); + return new Response(JSON.stringify({ service: "ling-stack-daemon", defaultWorkspaceCwd: workspace })); + }, + spawnDaemon: input => { + const child = new EventEmitter(); + Object.assign(child, { pid: 42000 + children.length, exitCode: null, signalCode: null, killSignals: [], + stdio: [null, null, null, new PassThrough(), null, new PassThrough()], env: input.env }); + child.finish = (code, signal = null) => { + child.exitCode = code; child.signalCode = signal; + child.emit("exit", code, signal); + }; + child.phase = phase => child.stdio[5].write(`${JSON.stringify({ schemaVersion: 1, pid: child.pid, phase })}\n`); + child.kill = signal => { + child.killSignals.push(signal); + if (signal === "SIGKILL") { queueMicrotask(() => child.finish(null, signal)); return true; } + child.stopping = true; + if (outcome === "blocked") { child.phase("draining"); child.phase("blocked"); } + if (outcome === "closed") { + child.phase("draining"); child.stdio[5].end(); + queueMicrotask(() => child.finish(0)); + } + return true; + }; + children.push(child); active = child; + return child; + }, + onUnexpectedExit: value => unexpected.push(value), + onDiagnostic: (event, details) => diagnostics.push({ event, details }), + }); + t.after(() => { + for (const child of children) { + if (child.exitCode === null && child.signalCode === null) child.finish(0); + for (const io of child.stdio) io?.destroy(); + } + }); + return { lifecycle, children, unexpected, diagnostics, reuseExternal: () => { external = true; } }; +} + +test("stop receipts distinguish unowned, confirmed clean stop, repeat and reused successor", async t => { + const f = fixture(t, "closed"); + assert.deepEqual(await f.lifecycle.stopOwnedDaemon(), { kind: "not_owned" }); + await f.lifecycle.ensureStarted(); + const receipt = await f.lifecycle.stopOwnedDaemon(); + assert.deepEqual(receipt, { kind: "owned_stopped", pid: f.children[0].pid, code: 0, signal: null }); + assert.deepEqual(await f.lifecycle.stopOwnedDaemon(), receipt); + f.reuseExternal(); + assert.equal((await f.lifecycle.ensureStarted()).ownedByDesktop, false); + assert.deepEqual(await f.lifecycle.stopOwnedDaemon(), { kind: "not_owned" }); +}); + +test("late clean exit retains actual stop receipt for explicit retry without a second signal", async t => { + const f = fixture(t, "pending"); + await f.lifecycle.ensureStarted(); + await assert.rejects(f.lifecycle.stopOwnedDaemon(), error => error?.code === "stop_unconfirmed"); + f.children[0].finish(0); + assert.deepEqual(await f.lifecycle.stopOwnedDaemon(), { + kind: "owned_stopped", pid: f.children[0].pid, code: 0, signal: null, + }); + assert.deepEqual(f.children[0].killSignals, ["SIGTERM"]); +}); + +test("old stop receipt cannot apply to a new owned Host", async t => { + const f = fixture(t, "closed"); + await f.lifecycle.ensureStarted(); + const first = await f.lifecycle.stopOwnedDaemon(); + await f.lifecycle.ensureStarted(); + const second = await f.lifecycle.stopOwnedDaemon(); + assert.notEqual(second.pid, first.pid); + assert.deepEqual(second, { kind: "owned_stopped", pid: f.children[1].pid, code: 0, signal: null }); +}); + +test("blocked original Host retains ownership and rejects ensure/restart without a successor", async t => { + const f = fixture(t, "blocked"); + await f.lifecycle.ensureStarted(); + await assert.rejects(f.lifecycle.stopOwnedDaemon(), error => error?.code === "stop_blocked"); + const child = f.children[0]; + assert.equal(child.env.LING_STACK_SHUTDOWN_OBSERVER_FD, "5"); + assert.deepEqual(child.killSignals, ["SIGTERM"]); + assert.equal(f.lifecycle.status.state, "blocked"); + assert.equal(f.lifecycle.status.ownedByDesktop, true); + assert.equal(f.lifecycle.status.reason, "stop_blocked"); + await assert.rejects(f.lifecycle.ensureStarted(), error => error?.code === "stop_blocked"); + await assert.rejects(f.lifecycle.restart(), error => error?.code === "stop_blocked"); + await assert.rejects(f.lifecycle.stopOwnedDaemon(), error => error?.code === "stop_blocked"); + assert.equal(f.children.length, 1); + assert.deepEqual(child.killSignals, ["SIGTERM"]); + assert.deepEqual(f.unexpected, []); +}); + +test("observation timeout retains live child and cannot trigger SIGKILL or a successor", async t => { + const f = fixture(t, "pending"); + await f.lifecycle.ensureStarted(); + await assert.rejects(f.lifecycle.stopOwnedDaemon(), error => error?.code === "stop_unconfirmed"); + const child = f.children[0]; + assert.equal(child.exitCode, null); + assert.deepEqual(child.killSignals, ["SIGTERM"]); + assert.equal(f.lifecycle.status.ownedByDesktop, true); + assert.equal(f.lifecycle.status.reason, "stop_unconfirmed"); + await assert.rejects(f.lifecycle.restart(), error => error?.code === "stop_unconfirmed"); + assert.equal(f.children.length, 1); + assert.deepEqual(child.killSignals, ["SIGTERM"]); + child.finish(0); + assert.equal(f.lifecycle.status.state, "disconnected"); + await f.lifecycle.ensureStarted(); + assert.equal(f.children.length, 2, "only actual clean exit allows an explicit successor"); +}); + +test("externally signalled original Host projects phases without sending another signal", async t => { + const f = fixture(t, "pending"); + await f.lifecycle.ensureStarted(); + const child = f.children[0]; + child.phase("draining"); + assert.equal(f.lifecycle.status.state, "draining"); + await assert.rejects(f.lifecycle.ensureStarted(), error => error?.code === "stop_unconfirmed"); + child.phase("blocked"); + await assert.rejects(f.lifecycle.restart(), error => error?.code === "stop_blocked"); + assert.deepEqual(child.killSignals, []); + assert.deepEqual(f.unexpected, []); +}); + +test("pipe EOF before clean exit cannot prevent successful stop or trigger automatic recovery", async t => { + const f = fixture(t, "closed"); + await f.lifecycle.ensureStarted(); + await f.lifecycle.stopOwnedDaemon(); + assert.deepEqual(f.children[0].killSignals, ["SIGTERM"]); + assert.deepEqual(f.unexpected, []); + assert.equal(f.lifecycle.status.state, "disconnected"); + await f.lifecycle.restart(); + assert.equal(f.children.length, 2); +}); + +test("nonzero/signal exit during stop remains unconfirmed and never invokes recovery", async t => { + const f = fixture(t, "pending"); + await f.lifecycle.ensureStarted(); + const pending = f.lifecycle.stopOwnedDaemon(); + f.children[0].finish(null, "SIGKILL"); + await assert.rejects(pending, error => error?.code === "stop_unconfirmed"); + await assert.rejects(f.lifecycle.ensureStarted(), error => error?.code === "stop_unconfirmed"); + assert.equal(f.lifecycle.status.ownedByDesktop, true); + assert.deepEqual(f.unexpected, []); + assert.equal(f.children.length, 1); +}); + +test("synchronous status reentry joins the original stop before another state event", async t => { + const f = fixture(t, "pending"); + await f.lifecycle.ensureStarted(); + let count = 0, reentered; + f.lifecycle.onStatusChanged(status => { + if (status.state === "draining" && ++count === 1) { + reentered = f.lifecycle.stopOwnedDaemon(); + } + }); + await assert.rejects(f.lifecycle.stopOwnedDaemon(), error => error?.code === "stop_unconfirmed"); + await assert.rejects(reentered, error => error?.code === "stop_unconfirmed"); + assert.equal(count, 1); + assert.deepEqual(f.children[0].killSignals, ["SIGTERM"]); +}); + +test("startup ready cannot overwrite an original Host draining event", async t => { + let release, entered; + const readiness = new Promise(resolve => { entered = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + const f = fixture(t, "pending", async () => { entered(); await gate; }); + const starting = f.lifecycle.ensureStarted(); + await readiness; + f.children[0].phase("draining"); + release(); + await assert.rejects(starting, error => error?.code === "stop_unconfirmed"); + assert.equal(f.lifecycle.status.state, "draining"); + assert.deepEqual(f.children[0].killSignals, []); + assert.equal(f.children.length, 1); +}); + +test("throwing status observer cannot prevent original Host stop or hide its result from other observers", async t => { + const f = fixture(t, "closed"); + await f.lifecycle.ensureStarted(); + const states = []; + const unsubscribe = f.lifecycle.onStatusChanged(() => { throw new Error("observer_failed"); }); + const stopRecording = f.lifecycle.onStatusChanged(status => { states.push(status.state); }); + try { + await f.lifecycle.stopOwnedDaemon(); + assert.deepEqual(f.children[0].killSignals, ["SIGTERM"]); + assert.equal(f.children[0].exitCode, 0); + assert.ok(states.includes("draining")); + assert.ok(states.includes("disconnected")); + assert.ok(f.diagnostics.some(value => value.event === "daemon.status_observer_failed")); + } finally { unsubscribe(); stopRecording(); } +}); + +test("throwing timeout observer cannot crash the process or turn unconfirmed stop into success", async t => { + const f = fixture(t, "pending"); + await f.lifecycle.ensureStarted(); + const unsubscribe = f.lifecycle.onStatusChanged(status => { + if (status.state === "blocked") throw new Error("timeout_observer_failed"); + }); + try { + await assert.rejects(f.lifecycle.stopOwnedDaemon(), error => error?.code === "stop_unconfirmed"); + assert.equal(f.lifecycle.status.ownedByDesktop, true); + assert.equal(f.children[0].exitCode, null); + assert.deepEqual(f.children[0].killSignals, ["SIGTERM"]); + } finally { unsubscribe(); } +}); + +for (const outcome of ["error", "abnormal_exit"]) { + test(`startup ${outcome} after FD5 draining retains the stop classification`, async t => { + let entered; + const readiness = new Promise(resolve => { entered = resolve; }); + const f = fixture(t, "pending", async () => { entered(); await new Promise(() => {}); }); + const starting = f.lifecycle.ensureStarted(); + const rejected = assert.rejects(starting, error => error?.code === "stop_unconfirmed"); + await readiness; + f.children[0].phase("draining"); + if (outcome === "error") f.children[0].emit("error", new Error("late_child_error")); + else f.children[0].finish(1); + await rejected; + assert.equal(f.lifecycle.status.ownedByDesktop, true); + assert.equal(f.lifecycle.status.reason, "stop_unconfirmed"); + assert.deepEqual(f.unexpected, []); + assert.equal(f.children.length, 1); + }); +} diff --git a/apps/desktop/tests/daemon-lifecycle.regression-2.test.mjs b/apps/desktop/tests/daemon-lifecycle.regression-2.test.mjs new file mode 100644 index 000000000..9347e0245 --- /dev/null +++ b/apps/desktop/tests/daemon-lifecycle.regression-2.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { DesktopDaemonLifecycle } from "../dist/main/daemon-lifecycle.js"; +import { DesktopRuntime } from "../dist/main/desktop-runtime.js"; +import { createLockedTaskContinuationDaemonClient } from "../dist/main/locked-task-continuation/daemon-client.js"; + +// Regression: E6-R03 — default Desktop spawn must preserve FD3/FD4 holes and private FD5. +// Found by /qa on 2026-10-26 +// Report: output/e6-20260928/r03-daemon-restart-packaged-20261026/repair-status.md +// Value: protects=production spawn FD5 wiring and real blocked child retention; +// fails_when=FD5 is absent/shifted or a failed stop kills/replaces its child; +// why_new=regression-1 controls child/health rather than testing default spawn; +// seam=none; no_claim=GUI/core effect proof (isolated worker with no Store/model). +for (const outcome of ["blocked", "closed"]) test(`default spawn observes a real ${outcome} Host`, { timeout: 8000 }, async t => { + const parent = await mkdtemp(join(tmpdir(), "ling-r03-default-spawn-")); + const workspace = join(parent, outcome); + await mkdir(workspace); + const reservation = createServer(); + reservation.listen(0, "127.0.0.1"); + await once(reservation, "listening"); + const port = reservation.address().port; + await new Promise((resolve, reject) => reservation.close(error => error ? reject(error) : resolve())); + const diagnostics = [], unexpected = []; + const lifecycle = new DesktopDaemonLifecycle({ + baseUrl: `http://127.0.0.1:${port}`, + daemonEntryPath: fileURLToPath(new URL("./support/r03-owned-host-worker.mjs", import.meta.url)), + defaultWorkspaceCwd: workspace, startupTimeoutMs: 2500, shutdownTimeoutMs: 1000, + onDiagnostic: (event, details) => diagnostics.push({ event, details }), + onUnexpectedExit: exit => unexpected.push(exit), + }); + let child, rawKill; + t.after(async () => { + // Exact child handle from this fixture only; no user App/Store is touched. + const owned = child ?? lifecycle.child; + if (owned && owned.exitCode === null && owned.signalCode === null) { + const exited = once(owned, "exit"); + (rawKill ?? owned.kill.bind(owned))("SIGKILL"); + await exited; + } + await rm(parent, { recursive: true }); + }); + assert.equal((await lifecycle.ensureStarted()).state, "connected"); + child = lifecycle.child; + assert.ok(child?.pid); + const signals = []; + rawKill = child.kill.bind(child); + child.kill = signal => { signals.push(signal); return rawKill(signal); }; + if (outcome === "blocked") { + await assert.rejects(lifecycle.stopOwnedDaemon(), error => error?.code === "stop_blocked"); + assert.equal(child.exitCode, null); + assert.equal(child.signalCode, null); + assert.equal(lifecycle.child, child); + assert.equal(lifecycle.status.reason, "stop_blocked"); + await assert.rejects(lifecycle.restart(), error => error?.code === "stop_blocked"); + assert.equal(diagnostics.filter(x => x.event === "daemon.spawned").length, 1); + } else { + const continuation = createLockedTaskContinuationDaemonClient({ daemonBaseUrl: `http://127.0.0.1:${port}`, bridgeToken: "fixture" }); + let releases = 0; + const runtime = new DesktopRuntime({ + daemon: lifecycle, daemonBaseUrl: `http://127.0.0.1:${port}`, workspaceCwd: workspace, + bridgeToken: "fixture", resourcesRoot: parent, userDataPath: parent, + platform: "darwin", architecture: "arm64", readSystemSessionLocked: async () => false, + featureFlags: { bundledEmbedded: { darwin: false, win32: false }, packagedProvider: { darwin: false, win32: false } }, + lockedTaskContinuationClient: { + async publish() {}, + async release(input) { releases++; await continuation.release(input); }, + }, + }); + await runtime.ensureStarted(); + await Promise.all(Array.from({ length: 8 }, () => runtime.shutdown())); + assert.equal(releases, 0, "the real stopped endpoint must not receive a release RPC"); + await assert.rejects(fetch(`http://127.0.0.1:${port}/health`)); + assert.equal(child.exitCode, 0); + assert.equal(child.signalCode, null); + assert.equal(lifecycle.status.state, "disconnected"); + } + assert.deepEqual(signals, ["SIGTERM"]); + assert.deepEqual(unexpected, []); +}); diff --git a/apps/desktop/tests/daemon-lifecycle.test.mjs b/apps/desktop/tests/daemon-lifecycle.test.mjs index 4027319d0..c7b78165a 100644 --- a/apps/desktop/tests/daemon-lifecycle.test.mjs +++ b/apps/desktop/tests/daemon-lifecycle.test.mjs @@ -9,6 +9,131 @@ import { DesktopDaemonLifecycle } from "../dist/main/daemon-lifecycle.js"; const existingEntryPath = fileURLToPath(import.meta.url); +function resourceBootstrapFixture(t, options = {}) { + const child = createFakeChild(); + const authority = new PassThrough(); + const resources = new PassThrough(); + child.stdio = [null, null, null, authority, resources]; + const spawned = deferred(); + const prepared = deferred(); + const diagnostics = []; + let healthChecks = 0; + const lifecycle = new DesktopDaemonLifecycle({ + baseUrl: "http://127.0.0.1:17991", daemonEntryPath: existingEntryPath, + defaultWorkspaceCwd: "/tmp/ling-desktop-workspace", packaged: true, + probePort: async () => "free", + fetch: async () => { + healthChecks++; + if (healthChecks === 1 || options.neverReady) throw new TypeError("fetch failed"); + return daemonHealth("/tmp/ling-desktop-workspace"); + }, + spawnDaemon: input => { spawned.resolve(input); return child; }, + onDiagnostic: (event, details) => { + diagnostics.push({ event, details }); + if (event === "daemon.resources_ready") prepared.resolve(); + }, + }); + t.after(() => lifecycle.stopOwnedDaemon()); + return { child, authority, resources, spawned, prepared, diagnostics, lifecycle, + healthChecks: () => healthChecks, + acknowledge: () => resources.end(JSON.stringify({ schemaVersion: 1, phase: "resources_ready", pid: child.pid })), + }; +} + +test("packaged resource bootstrap does not consume service readiness or FD3 authority time", async t => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"] }); + const input = resourceBootstrapFixture(t); + const pending = input.lifecycle.ensureStarted(); + const spawned = await input.spawned.promise; + await new Promise(resolve => setImmediate(resolve)); + assert.equal(input.lifecycle.status.state, "starting", "HTTP health cannot bypass resource authentication"); + assert.equal(input.healthChecks(), 1, "no service probe before the resource receipt"); + assert.equal(spawned.env.LING_STACK_RESOURCE_BOOTSTRAP_FD, "4"); + t.mock.timers.tick(30_000); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(input.lifecycle.status.state, "starting"); + assert.deepEqual(input.child.killSignals, []); + input.acknowledge(); + input.authority.end(JSON.stringify(engineAuthorityFixture(input.child.pid))); + assert.equal((await pending).state, "connected"); + assert.deepEqual(await input.lifecycle.getExecutionEngineAuthority(), engineAuthorityFixture(input.child.pid)); + assert.equal(input.resources.destroyed, true); +}); + +test("packaged resource bootstrap retains the ordinary 20-second service deadline after its receipt", async t => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"] }); + const input = resourceBootstrapFixture(t, { neverReady: true }); + const pending = assert.rejects(input.lifecycle.ensureStarted(), { code: "start_timeout" }); + await input.spawned.promise; + t.mock.timers.tick(30_000); + input.acknowledge(); + await input.prepared.promise; + await new Promise(resolve => setImmediate(resolve)); + t.mock.timers.tick(20_000); + await new Promise(resolve => setImmediate(resolve)); + await pending; + assert.deepEqual(input.child.killSignals, ["SIGTERM"]); + assert.equal(input.authority.destroyed, true); +}); + +test("packaged resource bootstrap has a fixed deadline, not an activity-renewed lease", async t => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"] }); + const input = resourceBootstrapFixture(t); + const pending = assert.rejects(input.lifecycle.ensureStarted(), { code: "start_timeout" }); + await input.spawned.promise; + input.resources.write('{"schemaVersion":1,'); + t.mock.timers.tick(59_000); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(input.child.killSignals, []); + input.resources.write('"phase":"resources_ready",'); + t.mock.timers.tick(1_000); + await pending; + assert.equal(input.healthChecks(), 1); + assert.equal(input.diagnostics.some(item => item.event === "daemon.resources_timeout"), true); + assert.equal(input.resources.destroyed, true); + assert.equal(input.authority.destroyed, true); +}); + +test("packaged resource bootstrap rejects missing, corrupt or wrong-child receipts without probing service", async t => { + for (const scenario of ["missing", "invalid", "pid", "phase", "oversize", "closed"]) { + await t.test(scenario, async t => { + const input = resourceBootstrapFixture(t); + if (scenario === "missing") input.child.stdio.pop(); + const pending = assert.rejects(input.lifecycle.ensureStarted(), { code: "resource_missing" }); + await input.spawned.promise; + if (scenario === "invalid") input.resources.end("not-json"); + if (scenario === "pid") input.resources.end(JSON.stringify({ schemaVersion: 1, phase: "resources_ready", pid: 999 })); + if (scenario === "phase") input.resources.end(JSON.stringify({ schemaVersion: 1, phase: "still_working", pid: 42 })); + if (scenario === "oversize") input.resources.end("x".repeat(1025)); + if (scenario === "closed") input.resources.destroy(); + await pending; + assert.equal(input.healthChecks(), 1); + assert.deepEqual(input.child.killSignals, ["SIGTERM"]); + assert.equal(input.authority.destroyed, true); + }); + } +}); + +test("packaged resource bootstrap cancels and drains both private pipes when stopping or the child fails", async t => { + for (const scenario of ["stop", "exit", "spawn-error"]) { + await t.test(scenario, async t => { + const input = resourceBootstrapFixture(t); + const pending = assert.rejects(input.lifecycle.ensureStarted(), scenario === "stop" + ? /daemon_start_cancelled/ : { code: scenario === "exit" ? "child_exited" : "spawn_failed" }); + await input.spawned.promise; + if (scenario === "stop") await input.lifecycle.stopOwnedDaemon(); + if (scenario === "exit") { input.child.exitCode = 1; input.child.emit("exit", 1, null); } + if (scenario === "spawn-error") input.child.emit("error", new Error("fixture spawn failure")); + await pending; + assert.equal(input.healthChecks(), 1); + for (const pipe of [input.authority, input.resources]) { + assert.equal(pipe.destroyed, true); + assert.equal(pipe.listenerCount("data"), 0); + } + }); + } +}); + test("Desktop engine authority is memory-only and belongs to its live owned child", async (t) => { const child = createFakeChild(); const channel = new PassThrough(); @@ -222,6 +347,9 @@ test("Desktop daemon starts in the selected workspace and stops the owned child" test("packaged Desktop marks only its fixed child bootstrap as packaged", async () => { const child = createFakeChild(); + const resources = new PassThrough(); + child.stdio = [null, null, null, null, resources]; + resources.end(JSON.stringify({ schemaVersion: 1, phase: "resources_ready", pid: child.pid })); const inputs = []; let healthChecks = 0; const lifecycle = new DesktopDaemonLifecycle({ @@ -290,7 +418,7 @@ test("unexpected owned child exit emits one recovery signal while intentional st assert.deepEqual(intentionalExits, []); }); -test("Desktop daemon escalates to SIGKILL when startup cleanup cannot stop the child", async () => { +test("Desktop retains startup ownership when cleanup cannot confirm the original stop", async () => { const child = createFakeChild({ exitOnTerm: false }); const diagnostics = []; const lifecycle = new DesktopDaemonLifecycle({ @@ -305,10 +433,20 @@ test("Desktop daemon escalates to SIGKILL when startup cleanup cannot stop the c onDiagnostic: (event) => diagnostics.push(event), }); - await assert.rejects(lifecycle.ensureStarted(), /daemon_start_timeout/); + // R03 changes the former forced-cleanup contract: startup timeout cannot + // prove that a spawned Host never served work. It must not authorize SIGKILL. + await assert.rejects(lifecycle.ensureStarted(), error => error?.code === "stop_unconfirmed"); - assert.deepEqual(child.killSignals, ["SIGTERM", "SIGKILL"]); - assert.ok(diagnostics.includes("daemon.force_killed")); + assert.deepEqual(child.killSignals, ["SIGTERM"]); + assert.equal(diagnostics.includes("daemon.force_killed"), false); + assert.equal(child.exitCode, null); + assert.equal(child.signalCode, null); + assert.equal(lifecycle.status.ownedByDesktop, true); + assert.equal(lifecycle.status.reason, "stop_unconfirmed"); + await assert.rejects(lifecycle.restart(), error => error?.code === "stop_unconfirmed"); + assert.deepEqual(child.killSignals, ["SIGTERM"]); + child.exitCode = 0; + child.emit("exit", 0, null); }); test("missing daemon resources produce a typed recoverable startup failure", async () => { @@ -925,8 +1063,8 @@ test("an older stop cannot overwrite a newer owned daemon connection", async () assert.deepEqual(oldChild.killSignals, ["SIGTERM"]); const restarting = lifecycle.ensureStarted(); - oldChild.signalCode = "SIGTERM"; - oldChild.emit("exit", null, "SIGTERM"); + oldChild.exitCode = 0; + oldChild.emit("exit", 0, null); const restarted = await restarting; assert.equal(restarted.state, "connected"); assert.equal(restarted.ownedByDesktop, true); @@ -988,8 +1126,8 @@ test("ensureStarted waits for an in-flight stop before probing daemon health", a assert.equal(restartSettled, false); oldChildAlive = false; - oldChild.signalCode = "SIGTERM"; - oldChild.emit("exit", null, "SIGTERM"); + oldChild.exitCode = 0; + oldChild.emit("exit", 0, null); const restarted = await restarting; await stoppingOldChild; @@ -1032,8 +1170,8 @@ test("concurrent stopOwnedDaemon calls share the same child termination", async assert.deepEqual(child.killSignals, ["SIGTERM"]); assert.equal(secondStopSettled, false, "all stop callers must await the same child exit barrier"); - child.signalCode = "SIGTERM"; - child.emit("exit", null, "SIGTERM"); + child.exitCode = 0; + child.emit("exit", 0, null); await Promise.all([firstStop, secondStop]); assert.equal(lifecycle.status.state, "disconnected"); }); @@ -1086,8 +1224,8 @@ test("restart waits for an in-flight owned stop before probing daemon health", a assert.equal(restartSettled, false); oldChildAlive = false; - oldChild.signalCode = "SIGTERM"; - oldChild.emit("exit", null, "SIGTERM"); + oldChild.exitCode = 0; + oldChild.emit("exit", 0, null); const restarted = await restarting; await stoppingOldChild; @@ -1136,8 +1274,8 @@ test("concurrent restart calls share one stop and one replacement startup", asyn await new Promise((resolve) => setImmediate(resolve)); oldChildAlive = false; - oldChild.signalCode = "SIGTERM"; - oldChild.emit("exit", null, "SIGTERM"); + oldChild.exitCode = 0; + oldChild.emit("exit", 0, null); const [firstStatus, secondStatus] = await Promise.all([firstRestart, secondRestart]); assert.equal(sharedRestart, true, "restart callers must receive the same in-flight operation"); @@ -1335,8 +1473,11 @@ function createFakeChild(options = {}) { kill(signal = "SIGTERM") { this.killSignals.push(signal); if (signal === "SIGKILL" || options.exitOnTerm !== false) { - this.signalCode = signal; - queueMicrotask(() => this.emit("exit", null, signal)); + // Model a handled SIGTERM only after actual successful Host cleanup. + // Abrupt/signal exits remain separately covered by R03 regression-1. + this.exitCode = signal === "SIGKILL" ? null : 0; + this.signalCode = signal === "SIGKILL" ? signal : null; + queueMicrotask(() => this.emit("exit", this.exitCode, this.signalCode)); } return true; }, diff --git a/apps/desktop/tests/desktop-runtime.regression-1.test.mjs b/apps/desktop/tests/desktop-runtime.regression-1.test.mjs new file mode 100644 index 000000000..3a314278b --- /dev/null +++ b/apps/desktop/tests/desktop-runtime.regression-1.test.mjs @@ -0,0 +1,305 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { DesktopRuntime } from "../dist/main/desktop-runtime.js"; +import { DesktopDaemonLifecycleError } from "../dist/main/daemon-lifecycle.js"; + +// Regression: E6-R03 — retain accepted Computer Use until the original Host +// supplies its real stop proof. Fixtures control barriers, not wall-clock waits. +// No packaged GUI, provider, model, tool or real Store is exercised here. +const baseUrl = "http://127.0.0.1:1"; +const workspaceCwd = "/tmp/ling-r03-runtime-fixture"; + +test("owned draining/blocked synchronously fences admission without retiring the coordinator", async () => { + const f = fixture(); + await f.runtime.ensureStarted(); + f.daemon.emit("draining"); + assert.deepEqual(f.events, ["coordinator.drain:daemon_status_draining"]); + f.daemon.emit("blocked", "stop_blocked"); + await flush(); + assert.equal(f.runtime.cuaDriverCoordinator, f.coordinator); + assert.equal(f.coordinator.shutdownCalls, 0); + assert.equal(f.disposals(), 0); + assert.equal(f.releases(), 0); + await f.runtime.shutdown(); +}); + +test("prepareShutdown is single-flight and keeps resources until original Host stop succeeds", async () => { + const stop = deferred(); + const f = fixture({ stop: async () => { await stop.promise; } }); + await f.runtime.ensureStarted(); + const first = f.runtime.prepareShutdown(); + assert.deepEqual(f.events, ["coordinator.drain:desktop_quit"]); + const pending = Array.from({ length: 8 }, () => f.runtime.prepareShutdown()); + assert.ok(pending.every(value => value === first)); + await flush(); + assert.equal(f.daemon.stopCalls, 1); + assert.equal(f.coordinator.shutdownCalls, 0); + assert.equal(f.disposals(), 0); + assert.equal(f.releases(), 0); + await assert.rejects(f.runtime.ensureStarted(), error => error?.code === "shutdown_requested"); + stop.resolve(); + await Promise.all([first, ...pending]); + assert.equal(f.coordinator.shutdownCalls, 0, "preflight is not final resource cleanup"); + await f.runtime.shutdown(); + assert.deepEqual(f.events, [ + "coordinator.drain:desktop_quit", "daemon.stop.begin", "daemon.stop.end", + "coordinator.shutdown", "platform.dispose", + ]); + assert.equal(f.daemon.stopCalls, 1); +}); + +test("unconfirmed stop retains every owner and an explicit retry accepts late clean stop proof", async () => { + const stopError = new DesktopDaemonLifecycleError("stop_unconfirmed", baseUrl); + let clean = false; + const f = fixture({ stop: async () => { if (!clean) throw stopError; } }); + await f.runtime.ensureStarted(); + await assert.rejects(f.runtime.shutdown(), error => error === stopError); + assert.equal(f.runtime.cuaDriverCoordinator, f.coordinator); + assert.equal(f.coordinator.shutdownCalls, 0); + assert.equal(f.disposals(), 0); + assert.equal(f.releases(), 0); + await assert.rejects(f.runtime.restart(), error => error?.code === "shutdown_requested"); + assert.equal(f.daemon.restartCalls, 0); + clean = true; + f.daemon.emit("disconnected", undefined, false); + await f.runtime.shutdown(); + assert.equal(f.daemon.stopCalls, 2, "explicit retry must recheck the original owner, not reuse a rejected preflight"); + assert.equal(f.coordinator.shutdownCalls, 1); + assert.equal(f.disposals(), 1); + assert.equal(f.releases(), 0, "the stopped Host has already discarded its in-memory lease"); +}); + +test("shutdown never calls an endpoint after its owned Host has really stopped", async () => { + let endpointOpen = true; + const f = fixture({ + stop: async () => { endpointOpen = false; }, + release: async () => { if (!endpointOpen) throw new Error("fixture_endpoint_closed"); }, + }); + await f.runtime.ensureStarted(); + await Promise.all(Array.from({ length: 8 }, () => f.runtime.shutdown())); + assert.equal(endpointOpen, false); + assert.equal(f.daemon.stopCalls, 1); + assert.equal(f.coordinator.shutdownCalls, 1); + assert.equal(f.disposals(), 1); + assert.equal(f.releases(), 0); +}); + +test("reused Host still receives lease release and its transport failure is not suppressed", async () => { + const error = new Error("fixture_reused_host_release_failed"); + const f = fixture({ owned: false, release: async () => { throw error; } }); + await f.runtime.ensureStarted(); + await assert.rejects(f.runtime.shutdown(), actual => actual === error); + assert.equal(f.releases(), 1); + assert.equal(f.disposals(), 1); +}); + +test("reused Host lease is released exactly once after ordinary cleanup", async () => { + const f = fixture({ owned: false }); + await f.runtime.ensureStarted(); + await Promise.all(Array.from({ length: 8 }, () => f.runtime.shutdown())); + assert.equal(f.releases(), 1); + assert.deepEqual(f.events.slice(-3), ["coordinator.shutdown", "platform.dispose", "continuation.release"]); +}); + +test("missing stop disposition cannot stand in for actual stop proof", async () => { + const f = fixture({ stopResult: undefined }); + await f.runtime.ensureStarted(); + await assert.rejects(f.runtime.shutdown(), error => error?.code === "stop_unconfirmed"); + assert.equal(f.coordinator.shutdownCalls, 0); + assert.equal(f.disposals(), 0); + assert.equal(f.releases(), 0); +}); + +test("owned drain during in-flight enable cannot turn authority loss into destructive cleanup", async () => { + const enabling = deferred(), enabled = deferred(); + const f = fixture({ enable: async () => { enabling.resolve(); await enabled.promise; } }); + const starting = f.runtime.ensureStarted(); + await enabling.promise; + f.daemon.cuaDriverAuthorityTuple = undefined; + f.daemon.emit("draining"); + const rejected = assert.rejects(starting, error => error?.code === "shutdown_requested"); + enabled.resolve(); + await rejected; + await flush(); + assert.equal(f.runtime.cuaDriverCoordinator, f.coordinator); + assert.equal(f.coordinator.shutdownCalls, 0); + await f.runtime.shutdown(); +}); + +test("an already queued system suspension cannot retire a Host-draining coordinator", async () => { + const enabling = deferred(), enabled = deferred(); + const f = fixture({ enable: async () => { enabling.resolve(); await enabled.promise; } }); + const starting = f.runtime.ensureStarted(); + await enabling.promise; + const suspension = f.runtime.suspendComputerControlForSystem("system_lock"); + f.daemon.emit("draining"); + const rejected = assert.rejects(starting, error => error?.code === "shutdown_requested"); + enabled.resolve(); + await Promise.all([rejected, suspension]); + assert.equal(f.coordinator.suspensions, 0); + assert.equal(f.coordinator.shutdownCalls, 0); + await f.runtime.shutdown(); +}); + +test("restart waits for real original stop and old coordinator cleanup before starting a successor", async () => { + const stop = deferred(), cleanup = deferred(); + const f = fixture({ stop: async () => { await stop.promise; }, cleanup: async () => { await cleanup.promise; } }); + await f.runtime.ensureStarted(); + const restarting = f.runtime.restart(); + await flush(); + assert.deepEqual(f.events, ["coordinator.drain:daemon_restart", "daemon.stop.begin"]); + assert.equal(f.daemon.restartCalls, 0); + assert.equal(f.coordinator.shutdownCalls, 0); + stop.resolve(); + await flush(); + assert.equal(f.coordinator.shutdownCalls, 1); + assert.equal(f.daemon.restartCalls, 0); + cleanup.resolve(); + await restarting; + assert.deepEqual(f.events.slice(0, 5), [ + "coordinator.drain:daemon_restart", "daemon.stop.begin", "daemon.stop.end", + "coordinator.shutdown", "daemon.restart", + ]); + await f.runtime.shutdown(); +}); + +test("blocked restart retains transport and cannot call daemon restart", async () => { + const stopError = new DesktopDaemonLifecycleError("stop_blocked", baseUrl); + let blocked = true; + const f = fixture({ stop: async () => { if (blocked) throw stopError; } }); + await f.runtime.ensureStarted(); + await assert.rejects(f.runtime.restart(), error => error === stopError); + assert.equal(f.runtime.cuaDriverCoordinator, f.coordinator); + assert.equal(f.coordinator.shutdownCalls, 0); + assert.equal(f.daemon.restartCalls, 0); + blocked = false; + await f.runtime.shutdown(); +}); + +test("ordinary cleanup failure keeps its original error and never starts a successor", async () => { + const cleanupError = new Error("fixture_cleanup_failed"); + let fail = true; + const f = fixture({ cleanup: async () => { if (fail) throw cleanupError; } }); + await f.runtime.ensureStarted(); + await assert.rejects(f.runtime.restart(), error => error === cleanupError); + assert.equal(f.daemon.stopCalls, 1); + assert.equal(f.daemon.restartCalls, 0); + assert.equal(f.runtime.cuaDriverCoordinator, f.coordinator); + fail = false; + await f.runtime.shutdown(); +}); + +test("an unknown stop rejection remains typed missing proof and cannot enter ordinary cleanup", async () => { + const cause = new Error("fixture_stop_transport_failed"); + let fail = true; + const f = fixture({ stop: async () => { if (fail) throw cause; } }); + await f.runtime.ensureStarted(); + await assert.rejects(f.runtime.shutdown(), error => error instanceof DesktopDaemonLifecycleError && error.code === "stop_unconfirmed"); + assert.equal(f.coordinator.shutdownCalls, 0); + assert.equal(f.disposals(), 0); + assert.equal(f.releases(), 0); + fail = false; + await f.runtime.shutdown(); +}); + +test("a late engine authority response cannot authorize new work after Host drain", async () => { + const result = deferred(); + const f = fixture(); + f.daemon.getExecutionEngineAuthority = async () => result.promise; + await f.runtime.ensureStarted(); + const authority = f.runtime.getExecutionEngineAuthority(); + f.daemon.emit("draining"); + const rejected = assert.rejects(authority, error => error?.code === "shutdown_requested"); + result.resolve({ fixture: true }); + await rejected; + await assert.rejects(f.runtime.getExecutionEngineAuthority(), error => error?.code === "shutdown_requested"); + await f.runtime.shutdown(); +}); + +// R03-OBS: Host lifecycle admission is independent of the optional CUA feature. +test("disabled Computer Use still fences execution authority on owned Host drain", async () => { + const f = fixture({ featureFlags: { bundledEmbedded: { darwin: false, win32: false }, packagedProvider: { darwin: false, win32: false } } }); + f.daemon.getExecutionEngineAuthority = async () => ({ fixture: true }); + f.daemon.emit("draining"); + await assert.rejects(f.runtime.getExecutionEngineAuthority(), error => error?.code === "shutdown_requested"); + await f.runtime.shutdown(); +}); + +test("a failed preflight retains the continuation heartbeat until confirmed cleanup", async t => { + t.mock.timers.enable({ apis: ["setInterval"] }); + let fail = true; + const f = fixture({ stop: async () => { if (fail) throw new DesktopDaemonLifecycleError("stop_blocked", baseUrl); } }); + await f.runtime.ensureStarted(); + const before = f.publications(); + await assert.rejects(f.runtime.prepareShutdown(), error => error?.code === "stop_blocked"); + t.mock.timers.tick(5_000); + await flush(); + assert.equal(f.publications(), before + 1); + assert.equal(f.releases(), 0); + fail = false; + await f.runtime.shutdown(); + const final = f.publications(); + t.mock.timers.tick(5_000); + await flush(); + assert.equal(f.publications(), final); +}); + +function fixture(options = {}) { + const events = [], listeners = new Set(); + let disposed = 0, released = 0, published = 0; + const makeAuthority = epoch => Object.freeze({ daemonAuthorityEpoch: "fixture", bridgeConnectionId: `bridge-${epoch}`, + desktopInstanceEpoch: epoch, workspaceCwd, readinessDetailsV1: false }); + const daemon = { + status: { state: "connected", baseUrl, ownedByDesktop: options.owned !== false }, + cuaDriverAuthorityTuple: makeAuthority(1), stopCalls: 0, restartCalls: 0, + async ensureStarted() { return this.status; }, + async stopOwnedDaemon() { + this.stopCalls++; events.push("daemon.stop.begin"); + await options.stop?.(); + events.push("daemon.stop.end"); + return Object.hasOwn(options, "stopResult") ? options.stopResult + : options.owned === false ? { kind: "not_owned" } + : { kind: "owned_stopped", pid: 42000, code: 0, signal: null }; + }, + async restart() { + this.restartCalls++; events.push("daemon.restart"); + this.cuaDriverAuthorityTuple = makeAuthority(1 + this.restartCalls); + this.status = { state: "connected", baseUrl, ownedByDesktop: true }; + return this.status; + }, + onStatusChanged(listener) { listeners.add(listener); return () => listeners.delete(listener); }, + emit(state, reason, ownedByDesktop = true) { + this.status = { state, baseUrl, ownedByDesktop, ...(reason ? { reason } : {}) }; + for (const listener of listeners) listener(this.status); + }, + }; + const coordinator = { + shutdownCalls: 0, suspensions: 0, + async enable() { await options.enable?.(); return { lifecycle: "active", providerState: "desktop_unavailable", generation: 1 }; }, + beginHostDrain(reason) { if (!this.draining) { this.draining = true; events.push(`coordinator.drain:${reason}`); } }, + closeCallGateForSecurityBoundary() {}, + async suspendForSystem() { this.suspensions++; }, + async shutdown() { this.shutdownCalls++; events.push("coordinator.shutdown"); await options.cleanup?.(); }, + }; + const runtime = new DesktopRuntime({ + daemon, daemonBaseUrl: baseUrl, bridgeToken: "fixture", workspaceCwd, + resourcesRoot: "/tmp/ling-r03-runtime-resources", userDataPath: "/tmp/ling-r03-runtime-data", + platform: "darwin", architecture: "arm64", readSystemSessionLocked: async () => false, + featureFlags: options.featureFlags ?? { bundledEmbedded: { darwin: true, win32: false }, packagedProvider: { darwin: true, win32: false } }, + coordinatorFactory: () => coordinator, + platformOwner: { platform: "darwin", async start() {}, async dispose() { disposed++; events.push("platform.dispose"); } }, + lockedTaskContinuationClient: { + async publish() { published++; }, + async release() { released++; events.push("continuation.release"); await options.release?.(); }, + }, + }); + return { runtime, daemon, coordinator, events, disposals: () => disposed, releases: () => released, publications: () => published }; +} + +function deferred() { + let resolve; + const promise = new Promise(settle => { resolve = settle; }); + return { promise, resolve }; +} + +const flush = () => new Promise(resolve => setImmediate(resolve)); diff --git a/apps/desktop/tests/desktop-runtime.test.mjs b/apps/desktop/tests/desktop-runtime.test.mjs index 7b74438db..b81709786 100644 --- a/apps/desktop/tests/desktop-runtime.test.mjs +++ b/apps/desktop/tests/desktop-runtime.test.mjs @@ -608,7 +608,7 @@ test("closed gate never composes a provider even when the daemon returned a type assert.equal(status.state, "connected"); assert.equal(factoryCalls, 0); assert.equal(bindingFactoryCalls, 0); - assert.equal(daemon.statusListenerCount, 0, "closed gates do not add provider lifecycle observers"); + assert.equal(daemon.statusListenerCount, 1, "Host drain must fence execution authority even with Computer Use disabled"); assert.equal(runtime.cuaDriverCoordinator, undefined); }); @@ -1011,7 +1011,7 @@ test("a lock received before provider composition still blocks a later resume hi await runtime.shutdown(); }); -test("shutdown continues to the owned daemon after coordinator cleanup fails", async () => { +test("shutdown confirms original daemon stop before coordinator cleanup and preserves cleanup errors", async () => { const events = []; const cleanupError = new Error("coordinator cleanup failed"); const daemon = fakeDaemon(authority("connection-a", 1), { @@ -1033,7 +1033,7 @@ test("shutdown continues to the owned daemon after coordinator cleanup fails", a await assert.rejects(runtime.shutdown(), (error) => error === cleanupError); await assert.rejects(runtime.ensureStarted(), (error) => error?.code === "shutdown_requested"); - assert.deepEqual(events, ["coordinator.shutdown", "daemon.stop"]); + assert.deepEqual(events, ["daemon.stop", "coordinator.shutdown"]); assert.equal(daemon.stopCalls, 1); }); @@ -1057,8 +1057,9 @@ test("concurrent and repeated shutdown calls share one coordinator and daemon cl await runtime.ensureStarted(); const firstShutdown = runtime.shutdown(); - assert.equal(daemon.statusListenerCount, 0, "shutdown synchronously removes the internal status observer"); + assert.equal(daemon.statusListenerCount, 1, "stop observation remains until real Host stop proof"); await shutdownStarted.promise; + assert.equal(daemon.statusListenerCount, 0, "confirmed stop permits removing the internal observer"); daemon.emitStatus({ state: "disconnected", baseUrl: "http://127.0.0.1:17891", ownedByDesktop: false }); const secondShutdown = runtime.shutdown(); @@ -1070,7 +1071,7 @@ test("concurrent and repeated shutdown calls share one coordinator and daemon cl assert.equal(daemon.stopCalls, 1); }); -test("restart still rotates daemon authority when old coordinator cleanup fails", async () => { +test("restart retains old coordinator and cannot start a successor when cleanup fails", async () => { const cleanupError = new Error("old coordinator cleanup failed"); const daemon = fakeDaemon(authority("connection-a", 1), { onRestart: () => { @@ -1096,7 +1097,8 @@ test("restart still rotates daemon authority when old coordinator cleanup fails" await assert.rejects(runtime.restart(), (error) => error === cleanupError); await assert.rejects(runtime.ensureStarted(), (error) => error === cleanupError); - assert.equal(daemon.restartCalls, 1, "daemon handshake must still fence the old lease"); + assert.equal(daemon.stopCalls, 1, "the old Host must really stop before local cleanup"); + assert.equal(daemon.restartCalls, 0, "incomplete local cleanup cannot authorize a successor"); assert.equal(factoryCalls, 1, "an incompletely stopped coordinator blocks replacement"); assert.ok(runtime.cuaDriverCoordinator, "failed cleanup remains retained for retry instead of being forgotten"); }); @@ -1132,7 +1134,7 @@ test("shutdown fences an in-flight startup and still stops the daemon", async () assert.deepEqual(events, ["daemon.start.begin", "daemon.start.end", "daemon.stop"]); }); -test("shutdown racing availability publication revokes the composed coordinator before daemon stop", async () => { +test("shutdown racing availability publication retains the composed coordinator until daemon stop", async () => { const enableStarted = deferred(); const releaseEnable = deferred(); const events = []; @@ -1164,8 +1166,8 @@ test("shutdown racing availability publication revokes the composed coordinator assert.deepEqual(events, [ "coordinator.enable.begin", "coordinator.enable.end", - "coordinator.shutdown", "daemon.stop", + "coordinator.shutdown", ]); }); @@ -1205,6 +1207,7 @@ test("shutdown fences an in-flight restart before it can compose replacement aut await rejectedRestart; await shuttingDown; assert.deepEqual(events, [ + "daemon.stop", "coordinator.shutdown", "daemon.restart.begin", "daemon.restart.end", @@ -1212,7 +1215,7 @@ test("shutdown fences an in-flight restart before it can compose replacement aut ]); assert.equal(factoryCalls, 1, "shutdown must prevent replacement coordinator construction"); assert.equal(daemon.restartCalls, 1); - assert.equal(daemon.stopCalls, 1); + assert.equal(daemon.stopCalls, 2, "shutdown must stop the in-flight restart's successor too"); assert.equal(runtime.cuaDriverCoordinator, undefined); }); @@ -1511,6 +1514,7 @@ function fakeDaemon(initialAuthority, options = {}) { async stopOwnedDaemon() { this.stopCalls += 1; options.onStop?.(); + return { kind: "owned_stopped", pid: 42000, code: 0, signal: null }; }, onStatusChanged(listener) { statusListeners.add(listener); diff --git a/apps/desktop/tests/main-shutdown-preflight.regression-1.test.mjs b/apps/desktop/tests/main-shutdown-preflight.regression-1.test.mjs new file mode 100644 index 000000000..af1290319 --- /dev/null +++ b/apps/desktop/tests/main-shutdown-preflight.regression-1.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import vm from "node:vm"; +import test from "node:test"; +import { transform } from "esbuild"; +import { DesktopDaemonLifecycleError } from "../dist/main/daemon-lifecycle.js"; + +// Executes main.ts's actual shutdown body with resource fakes, not a duplicate +// shutdown implementation. No Electron instance, real data or GUI is touched. +const source = await readFile(new URL("../main/main.ts", import.meta.url), "utf8"); +const offset = source.indexOf("async function shutdown(): Promise {"); +assert.ok(offset > 0); +const compiled = await transform(source.slice(offset), { loader: "ts", format: "cjs" }); + +for (const code of ["stop_blocked", "stop_unconfirmed"]) { + test(`main preflight ${code} preserves IPC, window and execution owners`, async () => { + const failure = new DesktopDaemonLifecycleError(code, "http://127.0.0.1:17928"); + const f = fixture(async () => { throw failure; }); + await assert.rejects(f.shutdown(), error => error === failure); + assert.deepEqual(f.events, ["stop-opens", "host-preflight"]); + assert.equal(f.context.mainWindow, f.window); + assert.equal(f.context.desktopRuntime, f.runtime); + assert.equal(f.context.computerUse, f.computerUse); + assert.equal(f.context.officeDriverRuntime, f.office); + }); +} + +test("main does not destroy any owned transport before Host preflight resolves", async () => { + let release; + const barrier = new Promise(resolve => { release = resolve; }); + const f = fixture(() => barrier); + const stopping = f.shutdown(); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(f.events, ["stop-opens", "host-preflight"]); + release(); + await stopping; + assert.ok(f.events.indexOf("computer-use") > f.events.indexOf("host-preflight")); + assert.ok(f.events.indexOf("runtime-cleanup") > f.events.indexOf("office")); + assert.equal(f.context.mainWindow, undefined); + assert.equal(f.context.desktopRuntime, undefined); +}); + +function fixture(prepare) { + const events = []; + const window = { isDestroyed: () => false, destroy() { events.push("window"); }, + webContents: { session: { closeAllConnections() { events.push("http"); } } } }; + const runtime = { async prepareShutdown() { events.push("host-preflight"); await prepare(); }, + async shutdown() { events.push("runtime-cleanup"); } }; + const computerUse = { async dispose() { events.push("computer-use"); } }; + const office = { async stop() { events.push("office"); } }; + const context = vm.createContext({ + mainWindowOpenCoordinator: { stopAcceptingAndWait() { events.push("stop-opens"); return Promise.resolve(); } }, + mainWindow: window, loadingMainWindow: undefined, desktopRuntime: runtime, computerUse, + officeDriverRuntime: office, hostCapabilityRuntime: { async stop() { events.push("host-capabilities"); } }, + unregisterLockedControlPreview() { events.push("preview-ipc"); }, + windowsProviderSmokeCommandReader: undefined, macosTccRevocationCommandReader: undefined, + unregisterThemePackageIpc() { events.push("theme-ipc"); }, + humanEvaluation: { async dispose() { events.push("human-evaluation"); } }, + unregisterIpc() { events.push("ipc"); }, captureSmokeTargetWindows: [], + realCaptureSmokeDescriptorRegistry: undefined, realCaptureSmokeProjectionClient: undefined, + }); + const shutdown = vm.runInContext(`${compiled.code}\nshutdown;`, context); + return { context, shutdown, events, window, runtime, computerUse, office }; +} diff --git a/apps/desktop/tests/package-macos-with-office.test.mjs b/apps/desktop/tests/package-macos-with-office.test.mjs index 3d47d1d45..34d5a8df6 100644 --- a/apps/desktop/tests/package-macos-with-office.test.mjs +++ b/apps/desktop/tests/package-macos-with-office.test.mjs @@ -227,6 +227,136 @@ test("fixed Office package entry regression (test-only loader, no native/signatu assert.equal(f.state.buildCalls.length, 0); }); + await t.test("retained-host successor keeps original publication, ad-hoc signer and no-publish path", async t => { + const f = await fixture(t, { + derivedEnvironment: { LING_DESKTOP_PACKAGE_HUMAN_QA: "retained-host-r03" }, + topLevelConfig: { appId: "com.lingstack.desktop", productName: "Ling Stack", directories: { output: "release" } }, + }); + process.env.LING_DESKTOP_PACKAGE_HUMAN_QA = "retained-host-r03"; + await f.api.packageMacosWithOffice(); + assert.equal(f.state.tokens.length, 1); + assert.equal(f.state.humanBuildCalls, 1); + assert.equal(f.state.buildCalls.length, 1); + const { config } = f.state.buildCalls[0]; + assert.equal(config.appId, "com.lingstack.desktop.human-qa.r03-successor"); + assert.equal(config.productName, "Ling Stack R03 Successor QA"); + assert.equal(config.directories.output, "release/human-qa-r03-successor"); + assert.equal(config.mac.identity, "-"); + assert.equal(config.mac.sign, undefined); + assert.equal(config.extraMetadata.lingHumanQaPackage.release, false); + assert.equal(f.state.active, undefined); + }); + + await t.test("retained-host observation uses a third fixed identity through the original publication and signing path", async t => { + const f = await fixture(t, { + derivedEnvironment: { LING_DESKTOP_PACKAGE_HUMAN_QA: "retained-host-r03-observation" }, + topLevelConfig: { appId: "com.lingstack.desktop", productName: "Ling Stack", directories: { output: "release" } }, + }); + process.env.LING_DESKTOP_PACKAGE_HUMAN_QA = "retained-host-r03-observation"; + await f.api.packageMacosWithOffice(); + assert.equal(f.state.tokens.length, 1); + assert.equal(f.state.humanBuildCalls, 1); + assert.equal(f.state.buildCalls.length, 1); + const { config } = f.state.buildCalls[0]; + assert.equal(config.appId, "com.lingstack.desktop.human-qa.r03-observation"); + assert.equal(config.productName, "Ling Stack R03 Observation QA"); + assert.equal(config.directories.output, "release/human-qa-r03-observation"); + assert.equal(config.mac.identity, "-"); + assert.equal(config.mac.sign, undefined); + assert.deepEqual(config.extraMetadata.lingHumanQaPackage, { + schemaVersion: 1, purpose: "synthetic-human-evaluation", release: false, + }); + assert.equal(f.state.active, undefined); + }); + + await t.test("integrated-main QA preserves original publication, signer, final hooks and no-publish path", async t => { + const f = await fixture(t, { + derivedEnvironment: { LING_DESKTOP_PACKAGE_HUMAN_QA: "integrated-main" }, + topLevelConfig: { appId: "com.lingstack.desktop", productName: "Ling Stack", directories: { output: "release" } }, + }); + process.env.LING_DESKTOP_PACKAGE_HUMAN_QA = "integrated-main"; + await f.api.packageMacosWithOffice(); + assert.equal(f.state.tokens.length, 1); + assert.equal(f.state.humanBuildCalls, 1); + assert.equal(f.state.buildCalls.length, 1); + const { config } = f.state.buildCalls[0]; + assert.equal(config.appId, "com.lingstack.desktop.human-qa.integrated-main"); + assert.equal(config.productName, "Ling Stack Integrated Main QA"); + assert.equal(config.directories.output, "release/human-qa-integrated-main"); + assert.equal(config.mac.identity, "-"); + assert.equal(config.mac.sign, undefined); + assert.equal(config.extraMetadata.lingHumanQaPackage.release, false); + assert.equal(f.state.active, undefined); + }); + + await t.test("integrated-main and every retained lane reject derived or config-time identity substitution", async t => { + for (const retained of ["1", "retained-host-r03", "retained-host-r03-observation"]) { + for (const [admitted, replacement] of [["integrated-main", retained], [retained, "integrated-main"]]) { + await t.test(`${admitted} to ${replacement} before native`, async t => { + const f = await fixture(t, { derivedEnvironment: { LING_DESKTOP_PACKAGE_HUMAN_QA: replacement } }); + process.env.LING_DESKTOP_PACKAGE_HUMAN_QA = admitted; + await assert.rejects(f.api.packageMacosWithOffice(), /human_qa_selection_changed/u); + assert.equal(f.state.tokens.length, 0); + assert.equal(f.state.buildCalls.length, 0); + }); + await t.test(`${admitted} to ${replacement} after config`, async t => { + const f = await fixture(t, { + derivedEnvironment: { LING_DESKTOP_PACKAGE_HUMAN_QA: admitted }, + onConfig: () => { process.env.LING_DESKTOP_PACKAGE_HUMAN_QA = replacement; }, + topLevelConfig: { appId: "com.lingstack.desktop", productName: "Ling Stack" }, + }); + process.env.LING_DESKTOP_PACKAGE_HUMAN_QA = admitted; + await assert.rejects(f.api.packageMacosWithOffice(), /human_qa_selection_changed/u); + assert.equal(f.state.tokens.length, 1); + assert.equal(f.state.buildCalls.length, 0); + assert.equal(f.state.active, undefined); + }); + } + } + }); + + await t.test("derived environment cannot replace the observation lane with a retained successor", async t => { + const f = await fixture(t, { derivedEnvironment: { LING_DESKTOP_PACKAGE_HUMAN_QA: "retained-host-r03" } }); + process.env.LING_DESKTOP_PACKAGE_HUMAN_QA = "retained-host-r03-observation"; + await assert.rejects(f.api.packageMacosWithOffice(), /human_qa_selection_changed/u); + assert.equal(f.state.tokens.length, 0); + assert.equal(f.state.buildCalls.length, 0); + }); + + await t.test("config-time switching into observation closes publication without Builder", async t => { + const f = await fixture(t, { + derivedEnvironment: { LING_DESKTOP_PACKAGE_HUMAN_QA: "1" }, + onConfig: () => { process.env.LING_DESKTOP_PACKAGE_HUMAN_QA = "retained-host-r03-observation"; }, + topLevelConfig: { appId: "com.lingstack.desktop", productName: "Ling Stack" }, + }); + process.env.LING_DESKTOP_PACKAGE_HUMAN_QA = "1"; + await assert.rejects(f.api.packageMacosWithOffice(), /human_qa_selection_changed/u); + assert.equal(f.state.tokens.length, 1); + assert.equal(f.state.buildCalls.length, 0); + assert.equal(f.state.active, undefined); + }); + + await t.test("derived environment cannot switch between two QA identities", async t => { + const f = await fixture(t, { derivedEnvironment: { LING_DESKTOP_PACKAGE_HUMAN_QA: "retained-host-r03" } }); + process.env.LING_DESKTOP_PACKAGE_HUMAN_QA = "1"; + await assert.rejects(f.api.packageMacosWithOffice(), /human_qa_selection_changed/u); + assert.equal(f.state.tokens.length, 0); + assert.equal(f.state.buildCalls.length, 0); + }); + + await t.test("config-time switching QA identities closes publication without Builder", async t => { + const f = await fixture(t, { + derivedEnvironment: { LING_DESKTOP_PACKAGE_HUMAN_QA: "1" }, + onConfig: () => { process.env.LING_DESKTOP_PACKAGE_HUMAN_QA = "retained-host-r03"; }, + topLevelConfig: { appId: "com.lingstack.desktop", productName: "Ling Stack" }, + }); + process.env.LING_DESKTOP_PACKAGE_HUMAN_QA = "1"; + await assert.rejects(f.api.packageMacosWithOffice(), /human_qa_selection_changed/u); + assert.equal(f.state.tokens.length, 1); + assert.equal(f.state.buildCalls.length, 0); + assert.equal(f.state.active, undefined); + }); + await t.test("derived environment cannot change the admitted lane", async t => { const f = await fixture(t, { derivedEnvironment: { LING_DESKTOP_PACKAGE_HUMAN_QA: "1" } }); await assert.rejects(f.api.packageMacosWithOffice(), /human_qa_selection_changed/u); diff --git a/apps/desktop/tests/packaged-gstack-resources.test.mjs b/apps/desktop/tests/packaged-gstack-resources.test.mjs index 82837cfc3..5e390a73f 100644 --- a/apps/desktop/tests/packaged-gstack-resources.test.mjs +++ b/apps/desktop/tests/packaged-gstack-resources.test.mjs @@ -13,7 +13,7 @@ const builderRequire = createRequire(require.resolve("electron-builder/package.j const { getFileMatchers, copyFiles } = builderRequire("app-builder-lib/out/fileMatcher.js"); const { expandMacro } = builderRequire("app-builder-lib/out/util/macroExpander.js"); -async function fixture(t, { rootName = "bundle", retainedPrefix = "runtime" } = {}) { +async function fixture(t, { rootName = "bundle", retainedPrefix = "runtime", additionalSkippedPaths = [] } = {}) { const work = await realpath(await mkdtemp(join(tmpdir(), "ling-desktop-gs-"))); t.after(() => rm(work, { recursive: true, force: true })); const root = join(work, rootName); await mkdir(root); @@ -25,7 +25,7 @@ async function fixture(t, { rootName = "bundle", retainedPrefix = "runtime" } = } await writeFile(join(root, "LICENSE"), "MIT fixture\n"); // Builder's directory walker skips these names before applying glob filters. - for (const path of [`${retainedPrefix}/node_modules/tr46/lib/.gitkeep`, "runtime/.DS_Store", "runtime/.gitkeep/retained.txt"]) { + for (const path of [`${retainedPrefix}/node_modules/tr46/lib/.gitkeep`, "runtime/.DS_Store", "runtime/.gitkeep/retained.txt", ...additionalSkippedPaths]) { await mkdir(dirname(join(root, path)), { recursive: true }); await writeFile(join(root, path), path.endsWith(".gitkeep") ? "" : "authenticated resource bytes"); } @@ -84,13 +84,71 @@ test("authenticated resource paths cannot become Builder macros", async t => { for (const options of [{ rootName: "bundle-${platform}" }, { retainedPrefix: "runtime/${platform}" }]) { const f = await fixture(t, options); await f.module.preparePackagedGstackBuild({ required: true, selectionPath: f.selectionPath }); - assert.throws(() => f.module.packagedGstackBuilderConfig(), /resource_paths_must_be_literal/); + assert.throws(() => f.module.packagedGstackBuilderConfig(), /packaged_gstack_builder_path_macro_unsupported/); } }); test("package declarations without a selected build fail instead of silently skipping verification", async t => { const f = await fixture(t); await assert.rejects(f.module.verifyPackagedGstack({ packager: { config: { extraMetadata: { lingPackagedGstack: f.identity } } } }), /selection_missing/); }); +test("actual Builder copy preserves manifest-authenticated files omitted by its directory walker", async t => { + const f = await fixture(t); + await f.module.preparePackagedGstackBuild({ required: true, selectionPath: f.selectionPath }); + const config = f.module.packagedGstackBuilderConfig(); + const require = createRequire(import.meta.url); + const builderRequire = createRequire(require.resolve("electron-builder/package.json")); + const { FileMatcher, copyFiles } = builderRequire("app-builder-lib/out/fileMatcher.js"); + const { expandMacro } = builderRequire("app-builder-lib/out/util/macroExpander.js"); + const context = { electronPlatformName: "darwin", appOutDir: join(f.work, "actual-builder"), + packager: { appInfo: { productFilename: "Ling" }, config } }; + const resources = join(context.appOutDir, "Ling.app/Contents/Resources"); + await copyFiles(config.extraResources.map(item => new FileMatcher(item.from, + join(resources, item.to), value => expandMacro(value, process.arch, context.packager.appInfo), item.filter)), undefined, false); + for (const path of ["runtime/node_modules/tr46/lib/.gitkeep", "runtime/.DS_Store"]) { + assert.deepEqual(await readFile(join(resources, "gstack", path)), await readFile(join(f.root, path))); + } + await f.module.verifyPackagedGstack(context); + await writeFile(join(resources, "gstack/runtime/node_modules/tr46/lib/.gitkeep"), "changed"); + await assert.rejects(f.module.verifyPackagedGstack(context), /bundle_resource_checksum_mismatch/); +}); +test("Builder paths reject macro syntax before copying authenticated literal files", async t => { + const require = createRequire(import.meta.url); + const builderRequire = createRequire(require.resolve("electron-builder/package.json")); + const { FileMatcher } = builderRequire("app-builder-lib/out/fileMatcher.js"); + const { expandMacro } = builderRequire("app-builder-lib/out/util/macroExpander.js"); + const prior = process.env.GSTACK_BUILDER_ROUTE; + process.env.GSTACK_BUILDER_ROUTE = "../../../outside"; + t.after(() => { + if (prior === undefined) delete process.env.GSTACK_BUILDER_ROUTE; + else process.env.GSTACK_BUILDER_ROUTE = prior; + }); + for (const path of ["runtime/${platform}/.gitkeep", "runtime/${arch}/.DS_Store", + "runtime/${env.GSTACK_BUILDER_ROUTE}/.gitkeep", "runtime/${missing}/.gitkeep"]) { + await t.test(path, async t => { + const f = await fixture(t, { additionalSkippedPaths: [path] }); + await f.module.preparePackagedGstackBuild({ required: true, selectionPath: f.selectionPath }); + const resources = join(f.work, "package/Resources"); + const literalFrom = join(f.root, path), literalTo = join(resources, "gstack", path); + const matcher = () => new FileMatcher(literalFrom, literalTo, + value => expandMacro(value, process.arch, {})); + if (path.includes("${missing}")) assert.throws(matcher, /macro missing is not defined/); + else { + const expanded = matcher(); + assert.notEqual(expanded.from, literalFrom); + assert.notEqual(expanded.to, literalTo); + } + // A valid authenticated manifest must not turn literal bytes into Builder + // instructions. Reject before handing any mapping to the copy boundary. + assert.throws(() => f.module.packagedGstackBuilderConfig(), /packaged_gstack_builder_path_macro_unsupported/); + await assert.rejects(readFile(join(f.work, "outside/.gitkeep")), { code: "ENOENT" }); + }); + } + await t.test("selected root is also a Builder path field", async t => { + const f = await fixture(t, { rootName: "bundle-${platform}" }); + await f.module.preparePackagedGstackBuild({ required: true, selectionPath: f.selectionPath }); + assert.throws(() => f.module.packagedGstackBuilderConfig(), /packaged_gstack_builder_path_macro_unsupported/); + }); +}); test("actual esbuild embeds exactly the authenticated identity module", async t => { const f = await fixture(t); const entry = fileURLToPath(new URL("../../../packages/daemon/dist/features/skills/packaged-identity.js", import.meta.url)); diff --git a/apps/desktop/tests/packaged-human-qa-lane.test.mjs b/apps/desktop/tests/packaged-human-qa-lane.test.mjs index 44f5d8811..ad4017e2d 100644 --- a/apps/desktop/tests/packaged-human-qa-lane.test.mjs +++ b/apps/desktop/tests/packaged-human-qa-lane.test.mjs @@ -40,3 +40,88 @@ test("QA selection rejects unsupported values and foreign base identities", asyn /packaged_human_qa_base_invalid/u); } }); + +test("retained-host R03 successor has fixed disjoint output and preserves the ordinary QA lane", async () => { + const { readPackagedOfficeBuildLane, readPackagedOfficeHumanQaSelection, selectPackagedOfficeBuildLane } = await import(helperUrl.href); + const environment = { LING_DESKTOP_PACKAGE_HUMAN_QA: "retained-host-r03" }; + const snapshot = structuredClone(base); + const selected = selectPackagedOfficeBuildLane(base, environment); + const ordinaryQa = selectPackagedOfficeBuildLane(base, { LING_DESKTOP_PACKAGE_HUMAN_QA: "1" }); + assert.equal(readPackagedOfficeBuildLane(environment), "human-qa-r03-successor"); + assert.equal(readPackagedOfficeHumanQaSelection(environment), true); + assert.equal(selected.appId, "com.lingstack.desktop.human-qa.r03-successor"); + assert.equal(selected.productName, "Ling Stack R03 Successor QA"); + assert.equal(selected.directories.output, "release/human-qa-r03-successor"); + for (const field of ["appId", "productName"]) assert.notEqual(selected[field], ordinaryQa[field]); + assert.notEqual(selected.directories.output, ordinaryQa.directories.output); + for (const field of ["afterPack", "afterSign", "artifactBuildCompleted", "mac", "files", "extraResources", "electronFuses"]) { + assert.deepEqual(selected[field], base[field]); + } + assert.deepEqual(selected.extraMetadata.lingHumanQaPackage, ordinaryQa.extraMetadata.lingHumanQaPackage); + assert.deepEqual(base, snapshot); +}); + +test("retained-host R03 observation has a fixed third output without changing either retained QA identity", async () => { + const { readPackagedOfficeBuildLane, readPackagedOfficeHumanQaSelection, selectPackagedOfficeBuildLane } = await import(helperUrl.href); + const environment = { LING_DESKTOP_PACKAGE_HUMAN_QA: "retained-host-r03-observation" }; + const snapshot = structuredClone(base); + const selected = selectPackagedOfficeBuildLane(base, environment); + assert.equal(readPackagedOfficeBuildLane(environment), "human-qa-r03-observation"); + assert.equal(readPackagedOfficeHumanQaSelection(environment), true); + assert.equal(selected.appId, "com.lingstack.desktop.human-qa.r03-observation"); + assert.equal(selected.productName, "Ling Stack R03 Observation QA"); + assert.equal(selected.directories.output, "release/human-qa-r03-observation"); + for (const selector of ["1", "retained-host-r03"]) { + const retained = selectPackagedOfficeBuildLane(base, { LING_DESKTOP_PACKAGE_HUMAN_QA: selector }); + for (const field of ["appId", "productName"]) assert.notEqual(selected[field], retained[field]); + assert.notEqual(selected.directories.output, retained.directories.output); + assert.deepEqual(selected.extraMetadata.lingHumanQaPackage, retained.extraMetadata.lingHumanQaPackage); + } + for (const field of ["afterPack", "afterSign", "artifactBuildCompleted", "mac", "files", "extraResources", "electronFuses"]) { + assert.deepEqual(selected[field], base[field]); + } + assert.equal(selected.directories.buildResources, base.directories.buildResources); + assert.deepEqual(base, snapshot); + for (const changed of [{ appId: "foreign.app" }, { productName: "Foreign" }]) { + assert.throws(() => selectPackagedOfficeBuildLane({ ...base, ...changed }, environment), /packaged_human_qa_base_invalid/u); + } +}); + +test("QA lane accepts no caller-chosen output or successor name", async () => { + const { readPackagedOfficeBuildLane } = await import(helperUrl.href); + assert.equal(readPackagedOfficeBuildLane({}), "ordinary"); + assert.equal(readPackagedOfficeBuildLane({ LING_DESKTOP_PACKAGE_HUMAN_QA: "1" }), "human-qa"); + for (const value of ["retained-host-r03/../human-qa", "release/human-qa-r03-successor", "r03", "retained-host-r04", + "retained-host-r03-observation/../human-qa", "release/human-qa-r03-observation", "human-qa-r03-observation", "retained-host-r03-observation "]) { + assert.throws(() => readPackagedOfficeBuildLane({ LING_DESKTOP_PACKAGE_HUMAN_QA: value }), /human_qa_lane_invalid/u); + } +}); + +test("integrated-main QA has a fourth fixed identity without replacing retained Hosts' Apps", async () => { + const { readPackagedOfficeBuildLane, readPackagedOfficeHumanQaSelection, selectPackagedOfficeBuildLane } = await import(helperUrl.href); + const environment = { LING_DESKTOP_PACKAGE_HUMAN_QA: "integrated-main" }; + const snapshot = structuredClone(base); + const selected = selectPackagedOfficeBuildLane(base, environment); + assert.equal(readPackagedOfficeBuildLane(environment), "human-qa-integrated-main"); + assert.equal(readPackagedOfficeHumanQaSelection(environment), true); + assert.equal(selected.appId, "com.lingstack.desktop.human-qa.integrated-main"); + assert.equal(selected.productName, "Ling Stack Integrated Main QA"); + assert.equal(selected.directories.output, "release/human-qa-integrated-main"); + for (const selector of ["1", "retained-host-r03", "retained-host-r03-observation"]) { + const retained = selectPackagedOfficeBuildLane(base, { LING_DESKTOP_PACKAGE_HUMAN_QA: selector }); + for (const field of ["appId", "productName"]) assert.notEqual(selected[field], retained[field]); + assert.notEqual(selected.directories.output, retained.directories.output); + assert.deepEqual(selected.extraMetadata.lingHumanQaPackage, retained.extraMetadata.lingHumanQaPackage); + } + for (const field of ["afterPack", "afterSign", "artifactBuildCompleted", "mac", "files", "extraResources", "electronFuses"]) { + assert.deepEqual(selected[field], base[field]); + } + assert.equal(selected.directories.buildResources, base.directories.buildResources); + assert.deepEqual(base, snapshot); + for (const changed of [{ appId: "foreign.app" }, { productName: "Foreign" }]) { + assert.throws(() => selectPackagedOfficeBuildLane({ ...base, ...changed }, environment), /packaged_human_qa_base_invalid/u); + } + for (const value of ["integrated-main ", "integrated-main/../human-qa", "human-qa-integrated-main", "release/human-qa-integrated-main"]) { + assert.throws(() => readPackagedOfficeBuildLane({ LING_DESKTOP_PACKAGE_HUMAN_QA: value }), /human_qa_lane_invalid/u); + } +}); diff --git a/apps/desktop/tests/packaged-office-channel-branch-inputs.regression-1.test.mjs b/apps/desktop/tests/packaged-office-channel-branch-inputs.regression-1.test.mjs new file mode 100644 index 000000000..048388cc5 --- /dev/null +++ b/apps/desktop/tests/packaged-office-channel-branch-inputs.regression-1.test.mjs @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS } from "../scripts/packaged-office-native-review-inputs.mjs"; + +// Regression: C03-PACKAGED-CLOSURE — explicitly review newly imported branch modules. +// Found by /qa on 2026-10-25. +// Report: output/e6-20260928/p3-bash-cwd-contract-live-20261025/c03-scope-findings.md +// Fixed-input coverage only; not approval, build or packaged GUI acceptance. +for (const path of [ + "packages/core/src/storage/sqlite/conversation-empty-fork-migration.ts", + "packages/daemon/src/features/channels/channel-branch-origin.ts", +]) { + test(`Office fixed closure includes ${path}`, () => { + assert.ok(PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS.includes(path), `unreviewed runtime input: ${path}`); + }); +} diff --git a/apps/desktop/tests/packaged-office-daemon-emitted.test.mjs b/apps/desktop/tests/packaged-office-daemon-emitted.test.mjs index 10dc80324..aedfdd759 100644 --- a/apps/desktop/tests/packaged-office-daemon-emitted.test.mjs +++ b/apps/desktop/tests/packaged-office-daemon-emitted.test.mjs @@ -11,7 +11,6 @@ import { build } from "esbuild"; const script = fileURLToPath(new URL("../scripts/build-packaged-daemon.mjs", import.meta.url)); const sourceScript = fileURLToPath(new URL("../scripts/packaged-office-daemon-source-inputs.mjs", import.meta.url)); -const reviewInputsScript = fileURLToPath(new URL("../scripts/packaged-office-native-review-inputs.mjs", import.meta.url)); const hash = bytes => createHash("sha256").update(bytes).digest("hex"); let fixtureNumber = 0; function newFile(path, bytes) { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); writeFileSync(path, bytes, { mode: 0o600 }); } @@ -23,7 +22,6 @@ async function fixture(t) { const modulePath = join(root, "apps/desktop/scripts/build-packaged-daemon.mjs"); mkdirSync(dirname(modulePath), { recursive: true }); copyFileSync(script, modulePath); copyFileSync(sourceScript, join(dirname(modulePath), "packaged-office-daemon-source-inputs.mjs")); - copyFileSync(reviewInputsScript, join(dirname(modulePath), "packaged-office-native-review-inputs.mjs")); const runtime = join(root, "packages/daemon/dist/runtime/sandbox"); const sourceRuntime = join(root, "packages/daemon/src/runtime/sandbox"); const codesignModule = join(sourceRuntime, "packaged-codesign-published-review.ts"); @@ -57,6 +55,7 @@ async function fixture(t) { const codesign = Object.freeze({ synthetic: "codesign publication (not approval)" }); const state = { active: publication, original: publication, token, publication, codesignToken, codesign, sourceReview, nativeBuilds: 0, codesignBuilds: 0, buildCompleted: false, builds: [], outputs: [], afterBuild: undefined, afterOriginalRead: undefined, + gstackPreparations: [], readOriginal(receipt) { if (receipt !== token || !state.original) throw new Error("synthetic_original_token_invalid"); state.afterOriginalRead?.(); return state.original; @@ -83,13 +82,11 @@ async function fixture(t) { export function readOriginalRestrictedCommandBuildPublication(receipt) { if (receipt !== state.codesignToken) throw new Error("synthetic_codesign_token_invalid"); return state.codesign; }`], - ["./packaged-office-native-review.mjs", bridge + ` - export function readReviewedPackagedOfficeSource() { return state.sourceReview; } - export function readLocalPackagedOfficeSource() { throw new Error("synthetic_emitted_fixture_requires_release_review"); }`], - // Gstack resource/identity validation has its own fixtures; keep these tests focused on Office output custody. - ["./packaged-gstack-resources.mjs", ` - export async function preparePackagedGstackBuild() { return undefined; } - export function packagedGstackIdentityPlugin() { + ["./packaged-office-native-review.mjs", bridge + "export function readReviewedPackagedOfficeSource() { return state.sourceReview; }"], + ["./packaged-gstack-resources.mjs", bridge + ` + export async function preparePackagedGstackBuild(options) { state.gstackPreparations.push(options); return undefined; } + export function packagedGstackIdentityPlugin({ identity }) { + if (identity !== undefined) throw new Error("synthetic_gstack_identity_unexpected"); return { plugin: { name: "synthetic-gstack-identity", setup() {} }, assertIncluded() {} }; }`], ]); @@ -109,11 +106,6 @@ async function fixture(t) { delete process.env.LING_PACKAGED_CODESIGN_REVIEW_REQUIRED; t.after(() => { if (priorIntent === undefined) delete process.env.LING_PACKAGED_CODESIGN_REVIEW_REQUIRED; else process.env.LING_PACKAGED_CODESIGN_REVIEW_REQUIRED = priorIntent; }); - // Exercise the real exact-reviewed source projection with the explicitly synthetic review above. - const priorReleaseIntent = process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED; - process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED = "1"; - t.after(() => { if (priorReleaseIntent === undefined) delete process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED; - else process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED = priorReleaseIntent; }); return { root, api: await import(url), state, publication, token, entry: sourceEntry, cliPath, officeModule, officeMarker, codesignModule, codesignMarker }; } @@ -141,6 +133,7 @@ test("Office emitted CLI pins: real esbuild, synthetic publication/signature, no assert.equal(pins.evidenceKind, "original_emitted_cli_byte_pins_only"); assert.equal(pins.path, f.cliPath); assert.equal(pins.sizeBytes, readFileSync(f.cliPath).length); assert.equal(pins.sha256, hash(readFileSync(f.cliPath))); assert.deepEqual(readFileSync(f.cliPath), f.state.outputs[0][0]); assert.equal(f.state.builds[0].write, false); + assert.deepEqual(f.state.gstackPreparations, [{ required: true }]); assert.doesNotMatch(readFileSync(f.cliPath, "utf8"), /NO_PACKAGED_(?:OFFICE_NATIVE|CODESIGN)_REVIEW/u); assert.equal("bytes" in pins, false); assert.equal("token" in pins, false); assert.equal("authority" in pins, false); assert.equal(f.api.readActiveOriginalPackagedOfficeDaemonBundle.length, 0); diff --git a/apps/desktop/tests/packaged-office-daemon-source-inputs.test.mjs b/apps/desktop/tests/packaged-office-daemon-source-inputs.test.mjs index 2a3126c01..6b8779670 100644 --- a/apps/desktop/tests/packaged-office-daemon-source-inputs.test.mjs +++ b/apps/desktop/tests/packaged-office-daemon-source-inputs.test.mjs @@ -18,7 +18,279 @@ const repositoryRoot = realpathSync(fileURLToPath(new URL("../../../", import.me const workspaceNames = ["browser-host", "contracts", "core", "daemon", "localization", "plugin-sdk", "providers", "remote-protocol", "runner-pi"]; const reviewedLeaf = "packages/core/src/original-office-host-bridge.ts"; const compiledLeaf = "packages/core/dist/original-office-host-bridge.js"; +const currentMainFeatureClosureInputs = [ + "packages/daemon/src/server/request-auth.ts", + "packages/contracts/src/artifacts.ts", + "packages/contracts/src/diagnostics.ts", + "packages/contracts/src/im-destinations.ts", + "packages/contracts/src/marketplace.ts", + "packages/contracts/src/notifications.ts", + "packages/daemon/src/features/diagnostics/routes.ts", + "packages/daemon/src/features/im-destinations/registry.ts", + "packages/daemon/src/features/im-destinations/routes.ts", + "packages/daemon/src/features/notifications/hub.ts", + "packages/daemon/src/features/notifications/routes.ts", + "packages/daemon/src/features/skills/marketplace-route.ts", +]; +test("Office fixed closure pins current-main notification, diagnostics and marketplace authored inputs", () => { + for (const path of currentMainFeatureClosureInputs) { + assert.equal(PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS.filter(input => input === path).length, + 1, `current-main authored input must occur exactly once: ${path}`); + } +}); +test("Office fixed closure pins the shared Host workflow prompt projector", () => { + assert.ok(PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS.includes( + "packages/daemon/src/features/channels/workflow-step-prompt.ts")); +}); +test("Office fixed closure pins the shared read-only P3 compaction projection", () => { + assert.ok(PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS.includes( + "packages/daemon/src/features/conversations/context-compaction-projection.ts")); +}); +test("Office fixed closure pins project inputs and public execution projections", () => { + for (const path of [ + "packages/daemon/src/runtime/sandbox/node-command-inputs.ts", + "packages/daemon/src/features/runs/public-execution-snapshots.ts", + "packages/daemon/src/features/channels/channel-intent-diagnostics.ts", + "packages/daemon/src/features/channels/terminal-report-command-evidence.ts", + ]) { + assert.ok(PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS.includes(path), `missing reviewed P3 input: ${path}`); + } +}); +test("Office fixed closure pins the cold-start protocol and Desktop stage controller", () => { + for (const path of ["apps/desktop/main/daemon-lifecycle.ts", "packages/core/src/daemon-resource-bootstrap.ts", + "packages/core/src/execution-engine-host-authority.ts", "packages/core/src/bundled-skill-source.ts", + "packages/core/package.json", "packages/daemon/src/cli.ts"]) { + assert.ok(PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS.includes(path), `missing reviewed bootstrap input: ${path}`); + } +}); +const requiredQuestionAndApprovalClosureInputs = [ + "packages/core/src/storage/sqlite/user-input-admission-policy.ts", + "packages/core/src/storage/sqlite/user-input-answer-authority.ts", + "packages/core/src/storage/sqlite/user-input-prestart-recovery-store.ts", + "packages/core/src/storage/sqlite/user-input-run-created-replay.ts", + "packages/core/src/storage/sqlite/user-input-startup-failure.ts", + "packages/core/src/storage/sqlite/user-input-workflow-admission.ts", + "packages/daemon/src/features/conversations/user-input-answer-route.ts", + "packages/daemon/src/features/runs/approval-decision-projection.ts", + "packages/daemon/src/features/runs/approval-postcommit-evidence.ts", + "packages/daemon/src/features/runs/user-input-answer-dispatch.ts", + "packages/daemon/src/features/runs/user-input-answer-routes.ts", + "packages/daemon/src/features/runs/user-input-continuation-context.ts", + "packages/daemon/src/features/runs/user-input-continuation-receiver.ts", + "packages/daemon/src/features/runs/user-input-delivery-worker.ts", + "packages/daemon/src/features/runs/user-input-execution-preparation.ts", + "packages/daemon/src/features/runs/user-input-prepared-request.ts", + "packages/daemon/src/features/runs/user-input-source-execution.ts", + "packages/daemon/src/features/runs/user-input-source-policy.ts", + "packages/daemon/src/features/runs/user-input-workflow-terminal.ts", + "packages/daemon/src/features/runs/workflow-step-input-turn.ts", +]; +const requiredNativeExecutionAndDelegationClosureInputs = [ + "packages/contracts/src/execution-engines.ts", + "packages/contracts/src/runner-acceptance.ts", + "packages/core/src/approval-decisions.ts", + "packages/core/src/delegation-completion-barrier.ts", + "packages/core/src/delegation-completion-evidence.ts", + "packages/core/src/delegation-host-store-bridge.ts", + "packages/core/src/execution-engine-host-authority.ts", + "packages/core/src/execution-engine-profiles.ts", + "packages/core/src/harness/delegation-lifecycle-types.ts", + "packages/core/src/harness/delegation-write-types.ts", + "packages/core/src/harness/delegation-write-validation.ts", + "packages/core/src/harness/reliability.ts", + "packages/core/src/native-artifacts.ts", + "packages/core/src/native-execution-types.ts", + "packages/core/src/native-openclaw-session.ts", + "packages/core/src/run-terminal-intent-types.ts", + "packages/core/src/storage/execution-engine-profile-store.ts", + "packages/core/src/storage/sqlite/delegation-budget-migration.ts", + "packages/core/src/storage/sqlite/delegation-budget-rows.ts", + "packages/core/src/storage/sqlite/delegation-completion-evidence.ts", + "packages/core/src/storage/sqlite/delegation-instruction-migration.ts", + "packages/core/src/storage/sqlite/delegation-lifecycle-store.ts", + "packages/core/src/storage/sqlite/delegation-stopped-handoff.ts", + "packages/core/src/storage/sqlite/delegation-write-store.ts", + "packages/core/src/storage/sqlite/harness-artifact-retention-store.ts", + "packages/core/src/storage/sqlite/harness-artifact-recovery-pins.ts", + "packages/core/src/storage/sqlite/harness-projection-checkpoint-store.ts", + "packages/core/src/storage/sqlite/harness-reliability-migration.ts", + "packages/core/src/storage/sqlite/harness-retention-index-migration.ts", + "packages/core/src/storage/sqlite/harness-recovery-index-migration.ts", + "packages/core/src/storage/sqlite/harness-reliability-schema.ts", + "packages/core/src/storage/sqlite/host-attempt-native-coverage.ts", + "packages/core/src/storage/sqlite/host-attempt-read-scope.ts", + "packages/core/src/storage/sqlite/native-execution-migration.ts", + "packages/core/src/storage/sqlite/native-execution-store.ts", + "packages/core/src/storage/sqlite/native-approval-wait.ts", + "packages/core/src/storage/sqlite/native-run-artifacts.ts", + "packages/core/src/storage/sqlite/native-run-output.ts", + "packages/core/src/storage/sqlite/run-terminal-intent-migration.ts", + "packages/core/src/storage/sqlite/run-terminal-intent-store.ts", + "packages/core/src/storage/sqlite/run-ancestry.ts", + "packages/daemon/src/features/channels/terminal-report-delegation-evidence.ts", + "packages/daemon/src/features/conversations/conversation-execution-selection.ts", + "packages/daemon/src/features/conversations/conversation-run-request.ts", + "packages/daemon/src/features/conversations/native-response-preferences.ts", + "packages/daemon/src/features/execution-engines/engine-secret-store.ts", + "packages/daemon/src/features/execution-engines/feature.ts", + "packages/daemon/src/features/execution-engines/authority.ts", + "packages/daemon/src/features/execution-engines/connection-probe.ts", + "packages/daemon/src/features/execution-engines/controller.ts", + "packages/daemon/src/features/execution-engines/installation-probe.ts", + "packages/daemon/src/features/execution-engines/login-manager.ts", + "packages/daemon/src/features/execution-engines/run-target-dispatcher.ts", + "packages/daemon/src/features/execution-engines/acp-connection.ts", + "packages/daemon/src/features/execution-engines/acp-probe-transport.ts", + "packages/daemon/src/features/execution-engines/claude-connection.ts", + "packages/daemon/src/features/execution-engines/claude-environment.ts", + "packages/daemon/src/features/execution-engines/claude-probe-process.ts", + "packages/daemon/src/features/execution-engines/codex-auth-state.ts", + "packages/daemon/src/features/execution-engines/codex-connection.ts", + "packages/daemon/src/features/execution-engines/codex-login.ts", + "packages/daemon/src/features/execution-engines/kimi-auth-state.ts", + "packages/daemon/src/features/execution-engines/kimi-connection.ts", + "packages/daemon/src/features/execution-engines/kimi-native-executable.ts", + "packages/daemon/src/features/execution-engines/kimi-probe-isolation.ts", + "packages/daemon/src/features/execution-engines/kimi-terminal-login.ts", + "packages/daemon/src/features/execution-engines/native-admission.ts", + "packages/daemon/src/features/execution-engines/native-auth-home.ts", + "packages/daemon/src/features/execution-engines/native-auth-file.ts", + "packages/daemon/src/features/execution-engines/native-login.ts", + "packages/daemon/src/features/execution-engines/native-secrets.ts", + "packages/daemon/src/features/execution-engines/openclaw-environment.ts", + "packages/daemon/src/features/execution-engines/owned-native-process.ts", + "packages/daemon/src/features/execution-engines/secret-profile.ts", + "packages/daemon/src/features/execution-engines/windows-supervisor.ts", + "packages/daemon/src/features/harness/delegations/completion-wait.ts", + "packages/daemon/src/features/harness/delegations/delegated-run-dispatch.ts", + "packages/daemon/src/features/harness/delegations/delegated-run-recovery.ts", + "packages/daemon/src/features/harness/delegations/provider-budget-host.ts", + "packages/daemon/src/features/harness/delegations/runner-tools.ts", + "packages/daemon/src/features/harness/delegations/runtime-registry.ts", + "packages/daemon/src/features/harness/delegations/store-composition.ts", + "packages/daemon/src/features/harness/delegations/host-authority.ts", + "packages/daemon/src/features/harness/delegations/price-registry.ts", + "packages/daemon/src/features/harness/evaluations/qa-model-pilot-runner.ts", + "packages/daemon/src/features/harness/reliability/artifact-retention-service.ts", + "packages/daemon/src/features/harness/reliability/lifecycle-coordinator.ts", + "packages/daemon/src/features/harness/reliability/projection-reconciler.ts", + "packages/daemon/src/features/runs/approval-action-policy.ts", + "packages/daemon/src/features/runs/existing-idempotent-run.ts", + "packages/daemon/src/features/runs/native-approval-events.ts", + "packages/daemon/src/features/runs/native-run-dispatcher.ts", + "packages/daemon/src/features/runs/native-conversation-selection.ts", + "packages/daemon/src/features/runs/preparation-thread-failure.ts", + "packages/daemon/src/features/runs/run-ready.ts", + "packages/daemon/src/runtime/adapters/native-artifact-files.ts", + "packages/daemon/src/runtime/conversation-execution-route.ts", + "packages/daemon/src/runtime/native-runners.ts", + "packages/daemon/src/runtime/run-execution-supervision.ts", + "packages/daemon/src/runtime/runner-acceptance-store.ts", + "packages/daemon/src/runtime/acp/journaled-lifecycle.ts", + "packages/daemon/src/runtime/acp/approval-journal.ts", + "packages/daemon/src/runtime/acp/content.ts", + "packages/daemon/src/runtime/acp/event-normalizer.ts", + "packages/daemon/src/runtime/acp/host-files.ts", + "packages/daemon/src/runtime/acp/host-requests.ts", + "packages/daemon/src/runtime/acp/host-terminal-process.ts", + "packages/daemon/src/runtime/acp/host-terminals.ts", + "packages/daemon/src/runtime/acp/host-text-read.ts", + "packages/daemon/src/runtime/acp/host-text-write.ts", + "packages/daemon/src/runtime/acp/kimi-bash-actions.ts", + "packages/daemon/src/runtime/acp/kimi-action-preparation.ts", + "packages/daemon/src/runtime/acp/kimi-execution-home.ts", + "packages/daemon/src/runtime/acp/kimi-file-actions.ts", + "packages/daemon/src/runtime/acp/kimi-host-files.ts", + "packages/daemon/src/runtime/acp/kimi-host-terminals.ts", + "packages/daemon/src/runtime/acp/kimi-model-configuration.ts", + "packages/daemon/src/runtime/acp/kimi-model-transport.ts", + "packages/daemon/src/runtime/acp/kimi-oauth-credentials.ts", + "packages/daemon/src/runtime/acp/live-client.ts", + "packages/daemon/src/runtime/acp/live-errors.ts", + "packages/daemon/src/runtime/acp/live-stream.ts", + "packages/daemon/src/runtime/acp/openclaw-action-preparation.ts", + "packages/daemon/src/runtime/acp/session-metadata.ts", + "packages/daemon/src/runtime/acp/session-settings.ts", + "packages/daemon/src/runtime/acp/tool-observation.ts", + "packages/daemon/src/runtime/acp/kimi-owned-execution.ts", + "packages/daemon/src/runtime/acp/openclaw-owned-execution.ts", + "packages/daemon/src/runtime/adapters/codex/journaled-lifecycle.ts", + "packages/daemon/src/runtime/adapters/codex/action-preparation.ts", + "packages/daemon/src/runtime/adapters/codex/approval-journal.ts", + "packages/daemon/src/runtime/adapters/codex/approval-wait.ts", + "packages/daemon/src/runtime/adapters/codex/duplex.ts", + "packages/daemon/src/runtime/adapters/codex/event-normalizer.ts", + "packages/daemon/src/runtime/adapters/codex/file-change-context.ts", + "packages/daemon/src/runtime/adapters/codex/live-client.ts", + "packages/daemon/src/runtime/adapters/codex/local-environment.ts", + "packages/daemon/src/runtime/adapters/codex/observed-tools.ts", + "packages/daemon/src/runtime/adapters/codex/owned-execution.ts", + "packages/daemon/src/runtime/adapters/codex/permission-preparation.ts", + "packages/daemon/src/runtime/adapters/codex/tool-content.ts", + "packages/daemon/src/runtime/adapters/codex/tool-output.ts", + "packages/daemon/src/runtime/adapters/native-artifact-import.ts", + "packages/daemon/src/runtime/adapters/native-command-policy.ts", + "packages/daemon/src/runtime/adapters/native-image-sanitizer.ts", + "packages/daemon/src/runtime/adapters/native-model-relay.ts", + "packages/daemon/src/runtime/adapters/native-tool-content.ts", + "packages/daemon/src/runtime/adapters/native-stream-redaction.ts", + "packages/daemon/src/runtime/runner-acceptance.ts", + "packages/daemon/src/runtime/sandbox/native-process-launch.ts", + "packages/runner-pi/src/delegation-instruction.ts", + "packages/runner-pi/src/extensions/delegation.ts", + "packages/runner-pi/src/provider-budget.ts", + "packages/runner-pi/src/runtime-suspension.ts", +]; +const gracefulTreeTypeOnlyInputs = [ + "packages/core/src/run-final-settlement-types.ts", +]; +// These contracts also export Error classes consumed by the SQLite writers. +const gracefulTreeRuntimeInputs = [ + "packages/core/src/run-suspension-activation-types.ts", + "packages/core/src/run-suspension-delegated-types.ts", + "packages/core/src/run-suspension-start-types.ts", + "packages/core/src/run-suspension-stop-types.ts", + "packages/core/src/run-suspension-types.ts", + "packages/core/src/storage/sqlite/delegation-resume-lineage.ts", + "packages/core/src/storage/sqlite/run-compaction-suspension-effects.ts", + "packages/core/src/storage/sqlite/run-compaction-suspension-lineage.ts", + "packages/core/src/storage/sqlite/run-compaction-suspension-witness.ts", + "packages/core/src/storage/sqlite/run-final-settlement-handoff.ts", + "packages/core/src/storage/sqlite/run-final-settlement-migration.ts", + "packages/core/src/storage/sqlite/run-final-settlement-records.ts", + "packages/core/src/storage/sqlite/run-final-settlement-store.ts", + "packages/core/src/storage/sqlite/run-suspension-activation-migration.ts", + "packages/core/src/storage/sqlite/run-suspension-activation-records.ts", + "packages/core/src/storage/sqlite/run-suspension-activation-store.ts", + "packages/core/src/storage/sqlite/run-suspension-delegated-migration.ts", + "packages/core/src/storage/sqlite/run-suspension-delegated-records.ts", + "packages/core/src/storage/sqlite/run-suspension-delegated-store.ts", + "packages/core/src/storage/sqlite/run-suspension-dependencies.ts", + "packages/core/src/storage/sqlite/run-suspension-migration.ts", + "packages/core/src/storage/sqlite/run-suspension-queued-proof.ts", + "packages/core/src/storage/sqlite/run-suspension-start-migration.ts", + "packages/core/src/storage/sqlite/run-suspension-start-records.ts", + "packages/core/src/storage/sqlite/run-suspension-start-store.ts", + "packages/core/src/storage/sqlite/run-suspension-stop-migration.ts", + "packages/core/src/storage/sqlite/run-suspension-stop-records.ts", + "packages/core/src/storage/sqlite/run-suspension-stop-store.ts", + "packages/core/src/storage/sqlite/run-suspension-stopped-handoff.ts", + "packages/core/src/storage/sqlite/run-suspension-store.ts", + "packages/core/src/storage/sqlite/run-suspension-witness.ts", + "packages/daemon/src/features/harness/continuations/final-settlement-observation.ts", + "packages/daemon/src/features/harness/continuations/root-suspension-dispatch.ts", + "packages/daemon/src/features/harness/continuations/run-compaction-suspension.ts", + "packages/daemon/src/features/harness/continuations/run-graceful-suspension.ts", + "packages/daemon/src/runtime/delegated-terminal-handoff.ts", + "packages/daemon/src/runtime/run-shutdown-coordinator.ts", + "packages/daemon/src/runtime/run-suspension-order.ts", +]; const newAuthoredRuntimeLeaves = [ + "packages/daemon/src/runtime/acp/host-text-write-worker.ts", + "packages/daemon/src/features/runs/run-execution-sink.ts", + ...gracefulTreeRuntimeInputs, + "packages/daemon/src/runtime/packaged-qa-harness-bootstrap.ts", + "packages/daemon/src/runtime/workspace-runtime-owner.ts", "packages/daemon/src/features/runs/approval-event-state.ts", "packages/daemon/src/features/runs/approval-resume-preparation.ts", "packages/core/src/storage/sqlite/teammate-direct-approval-continuation-proof.ts", @@ -79,6 +351,7 @@ const newAuthoredRuntimeLeaves = [ "packages/core/src/storage/sqlite/delegation-migration.ts", "packages/core/src/storage/sqlite/delegation-mappers.ts", "packages/core/src/storage/sqlite/delegation-store.ts", + "packages/core/src/harness/delegation-authority.ts", "packages/core/src/storage/sqlite/harness-diagnostics-mappers.ts", "packages/core/src/storage/sqlite/harness-diagnostics-store.ts", "packages/core/src/storage/sqlite/harness-diagnostics-validation.ts", @@ -108,6 +381,7 @@ const newAuthoredRuntimeLeaves = [ "packages/daemon/src/features/harness/evaluations/task-worker-input.ts", "packages/daemon/src/features/harness/evaluations/task-worker-prepared.ts", "packages/daemon/src/features/harness/evaluations/task-worker.ts", + "packages/daemon/src/features/harness/evaluations/task-worker-composition.ts", "packages/core/src/harness/evaluation-qa.ts", "packages/core/src/storage/sqlite/run-evaluation-qa-validation.ts", "packages/core/src/storage/sqlite/run-evaluation-qa-mappers.ts", @@ -130,13 +404,17 @@ const newAuthoredRuntimeLeaves = [ "packages/daemon/src/features/harness/evaluations/human-evaluation-routes.ts", "packages/daemon/src/features/harness/evaluations/human-evaluation-service.ts", "packages/daemon/src/features/harness/evaluations/human-evaluation-report.ts", + ...requiredQuestionAndApprovalClosureInputs, + ...requiredNativeExecutionAndDelegationClosureInputs.filter(name => name !== "packages/core/src/run-terminal-intent-types.ts"), ]; // This contract is exported with `export type *`; esbuild correctly erases it. // It remains an explicit reviewed source and gets every fail-closed check below. const newAuthoredTypeOnlyInputs = ["packages/contracts/src/harness-evaluation-qa.ts", "packages/contracts/src/harness-human-evaluation.ts", - "packages/contracts/src/harness-human-evaluation-read.ts", "packages/core/src/harness/human-evaluation-read.ts"]; + "packages/contracts/src/harness-human-evaluation-read.ts", "packages/core/src/harness/human-evaluation-read.ts", + "packages/core/src/run-terminal-intent-types.ts", ...gracefulTreeTypeOnlyInputs]; const reviewedNativeInputs = ["apps/desktop/scripts/build-human-evaluation-helper.mjs", "apps/desktop/scripts/native/human-evaluation-darwin.swift"]; -const newAuthoredRequiredInputs = [...newAuthoredRuntimeLeaves, ...newAuthoredTypeOnlyInputs, ...reviewedNativeInputs]; +const newAuthoredRequiredInputs = [...newAuthoredRuntimeLeaves, ...newAuthoredTypeOnlyInputs, + ...reviewedNativeInputs, ...currentMainFeatureClosureInputs]; const staleSentinel = "__LING_EXPLICIT_STALE_DIST_RED__"; const authoredSentinel = "__LING_EXPLICIT_REVIEWED_AUTHORED_SOURCE_GREEN__"; const hash = bytes => createHash("sha256").update(bytes).digest("hex"); @@ -152,6 +430,44 @@ test("Office packaging review requires every new browser lifecycle, Pi, progress } }); +test("Office fixed review closure explicitly pins required-question and approval authored sources", () => { + assert.equal(requiredQuestionAndApprovalClosureInputs.length, 20); + for (const name of requiredQuestionAndApprovalClosureInputs) { + assert.equal(PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS.includes(name), true, + `Required-question or approval source must be an explicit fixed review input: ${name}`); + } +}); + +test("Office fixed review closure explicitly pins native execution and dynamic delegation authored sources", () => { + assert.equal(requiredNativeExecutionAndDelegationClosureInputs.length, 154); + for (const name of requiredNativeExecutionAndDelegationClosureInputs) { + assert.equal(PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS.includes(name), true, + `Native execution or delegation source must be an explicit fixed review input: ${name}`); + } +}); + +test("Office review closure pins the Broker command result trust boundary", () => { + assert.equal(PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS.includes( + "packages/daemon/src/features/harness/tool-execution/broker-command-result.ts"), true); +}); + +// Regression: P3-MAIN-CLOSURE — imported main leaves must be reviewed authored inputs. +// Found by /qa independent AST audit on 2026-10-24 +// Report: output/e6-20260928/p3-main-office-closure-audit-20261024.md +test("Office review closure pins main work-summary and text-boundary runtime leaves", () => { + for (const path of [ + "packages/core/src/storage/sqlite/work-summary-migration.ts", + "packages/core/src/storage/sqlite/work-summary-store.ts", + "packages/core/src/string-boundaries.ts", + "packages/daemon/src/features/work-summary/cache.ts", + "packages/daemon/src/features/work-summary/git-summary.ts", + "packages/daemon/src/features/work-summary/pull-requests.ts", + "packages/daemon/src/features/work-summary/routes.ts", + "packages/daemon/src/features/work-summary/service.ts", + "packages/daemon/src/shared/html-text.ts", + ]) assert.equal(PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS.includes(path), true, path); +}); + test("Office fixed fresh daemon must not consume a stale compiled bridge with unchanged reviewed TS (real esbuild, synthetic native only)", { skip: process.platform !== "darwin" || process.arch !== "arm64", }, async t => { diff --git a/apps/desktop/tests/packaged-office-engine-lifecycle-inputs.regression-1.test.mjs b/apps/desktop/tests/packaged-office-engine-lifecycle-inputs.regression-1.test.mjs new file mode 100644 index 000000000..927aa182a --- /dev/null +++ b/apps/desktop/tests/packaged-office-engine-lifecycle-inputs.regression-1.test.mjs @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS } from "../scripts/packaged-office-native-review-inputs.mjs"; + +// Regression: new literal runtime imports must be explicitly reviewed, never discovered by a build. +// Fixed-input coverage only: this test grants no Office approval or packaged acceptance. +for (const path of [ + "packages/contracts/src/engine-lifecycle.ts", + "packages/daemon/src/features/execution-engines/engine-scanner.ts", + "packages/daemon/src/features/execution-engines/managed-engine-installer.ts", +]) { + test(`Office fixed closure uniquely includes ${path}`, () => { + assert.equal(PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS.filter(candidate => candidate === path).length, 1, + `Unreviewed or duplicate lifecycle runtime input: ${path}`); + }); +} diff --git a/apps/desktop/tests/packaged-office-native-asar-checks.test.mjs b/apps/desktop/tests/packaged-office-native-asar-checks.test.mjs index cd5af34ed..12818a449 100644 --- a/apps/desktop/tests/packaged-office-native-asar-checks.test.mjs +++ b/apps/desktop/tests/packaged-office-native-asar-checks.test.mjs @@ -110,6 +110,12 @@ test("actual local Office ASAR contracts (synthetic original readers, NOT packag replaceHeader(f, header => { header.files.alias = { link: "../OFFICE-LOCAL/office-local.node" }; }); assertInvalid(f); }); + await t.test("development bundled metadata inside app.asar is rejected", async t => { + const f = await fixture(t); + newFile(join(f.source, "dist/daemon/bundled/foreign-plugin/unreviewed.txt"), "UNREVIEWED FOREIGN FILE\n"); + await f.repack(); + assertInvalid(f); + }); await t.test("same-size unrelated files do not count as a duplicate Office addon", async t => { const f = await fixture(t); newFile(join(f.source, "unrelated-data"), Buffer.alloc(f.addon.length, 65)); await f.repack(); newFile(join(f.archive + ".unpacked", "unrelated-data"), Buffer.alloc(f.addon.length, 66)); diff --git a/apps/desktop/tests/packaged-office-native-publication.test.mjs b/apps/desktop/tests/packaged-office-native-publication.test.mjs index 0ad6ec86d..da3de0fdb 100644 --- a/apps/desktop/tests/packaged-office-native-publication.test.mjs +++ b/apps/desktop/tests/packaged-office-native-publication.test.mjs @@ -51,6 +51,10 @@ const addedDependencyGroups = Object.freeze({ "apps/desktop/scripts/packaged-codesign-review-publication.mjs", "apps/desktop/scripts/packaged-office-native-asar-checks.mjs", "apps/desktop/scripts/packaged-office-native-review-inputs.mjs", + "apps/desktop/scripts/stage-development-bundled-plugins.mjs", + "packages/daemon/src/features/plugins/bundled/cua-driver/.ling-plugin/plugin.json", + "packages/daemon/src/features/plugins/bundled/cua-driver/mcp-server.json", + "packages/daemon/src/features/plugins/bundled/cua-driver/README.md", ], helper: [ "native/sandbox/Cargo.toml", "native/sandbox/Cargo.lock", "native/sandbox/rust-toolchain.toml", @@ -81,6 +85,7 @@ const addedDependencyGroups = Object.freeze({ "packages/core/src/storage/sqlite/recovery-discovery-validation.ts", ], packaging: [ + "apps/desktop/scripts/cua-driver-parent-lease-stream-writer.mjs", "apps/desktop/scripts/packaged-office-signing-inputs.mjs", "apps/desktop/scripts/cua-driver-package-staging-contract.mjs", "apps/desktop/scripts/cua-driver-package-staging-files.mjs", @@ -141,12 +146,32 @@ test("Office recovery discovery validation remains an explicit packaging source "packages/core/src/storage/sqlite/recovery-discovery-validation.ts")); }); +test("Host private shutdown observer and signal handling remain explicit reviewed runtime inputs", () => { + for (const path of ["packages/core/src/daemon-shutdown-observer.ts", "packages/daemon/src/runtime/signal-shutdown.ts"]) { + assert.ok(PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS.includes(path), path); + } +}); + +test("C001 parent-lease stream writer remains an explicit packaging source input", () => { + assert.ok(PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS.includes( + "apps/desktop/scripts/cua-driver-parent-lease-stream-writer.mjs")); +}); + +test("Office bundled-plugin staging code and copied metadata remain in the fixed review closure", () => { + const stageScript = "apps/desktop/scripts/stage-development-bundled-plugins.mjs"; + const metadata = [ + "packages/daemon/src/features/plugins/bundled/cua-driver/.ling-plugin/plugin.json", + "packages/daemon/src/features/plugins/bundled/cua-driver/mcp-server.json", + "packages/daemon/src/features/plugins/bundled/cua-driver/README.md", + ]; + assert.ok(PACKAGED_OFFICE_NATIVE_AUTHORED_REVIEW_ROOTS.includes(stageScript)); + for (const path of [stageScript, ...metadata]) { + assert.ok(PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS.includes(path), path); + } +}); + /** Disposable, explicitly synthetic compiler/signature fixture. It never approves product sources. */ -async function fixture(t, { portable = false } = {}) { - const prior = process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED; - process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED = "1"; - t.after(() => { if (prior === undefined) delete process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED; - else process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED = prior; }); +async function fixture(t, { portable = false, onCompilerVersion } = {}) { const root = realpathSync(mkdtempSync(join(tmpdir(), "ling-office-publication-test-"))); t.after(() => rmSync(root, { recursive: true, force: true })); const scripts = join(root, "apps/desktop/scripts"), nativeRoot = join(root, "native/office-local"); @@ -198,7 +223,7 @@ async function fixture(t, { portable = false } = {}) { } const reviewPath = join(nativeRoot, "reviewed-packaged-source-v1.json"); writeFileSync(reviewPath, JSON.stringify(review)); - let signCalls = 0, verifyCalls = 0; + let signCalls = 0, verifyCalls = 0, compilerCalls = 0, discoveryCalls = 0; const loader = registerHooks({ load(url, context, nextLoad) { if (url === pathToFileURL(join(nativeRoot, "scripts/build.mjs")).href) { return { format: "module", shortCircuit: true, source: ` @@ -225,8 +250,15 @@ async function fixture(t, { portable = false } = {}) { t.after(() => Object.defineProperty(process, key, original)); } const mock = t.mock.method(childProcess, "spawnSync", (command, argv) => { - if (portable && command === "/usr/bin/xcrun") return { status: 0, stdout: argv.includes("--find") ? compilerPath : sdkRoot }; - if (portable && command === compilerPath) return { status: 0, stdout: buildReview.compiler.version }; + if (portable && command === "/usr/bin/xcrun") { + discoveryCalls++; + return { status: 0, stdout: argv.includes("--find") ? compilerPath : sdkRoot }; + } + if (portable && command === compilerPath) { + compilerCalls++; + const version = onCompilerVersion?.({ root, compilerPath, sdkRoot, headersRoot, version: buildReview.compiler.version }); + return { status: 0, stdout: version ?? buildReview.compiler.version }; + } assert.equal(command, "/usr/bin/codesign"); if (argv[0] === "--force") { signCalls++; @@ -239,10 +271,66 @@ async function fixture(t, { portable = false } = {}) { const api = await import(pathToFileURL(producerModule).href); const reviewApi = await import(pathToFileURL(reviewModule).href); return { root, api, reviewApi, review, reviewPath, paths, stage: join(root, "apps/desktop/dist/office-local"), - counts: () => ({ signCalls, verifyCalls }) }; + counts: () => ({ signCalls, verifyCalls }), + toolchainCounts: () => ({ compilerCalls, discoveryCalls }) }; } test("Office publication private origin and final-byte contracts (synthetic compiler/signature, not packaged validation)", async t => { + await t.test("portable reader rejects stale authored closure before any tool discovery or compiler execution", async t => { + const f = await fixture(t, { portable: true }); + f.review.packagingSha256[f.paths[0]] = "0".repeat(64); + writeFileSync(f.reviewPath, JSON.stringify(f.review)); + assert.throws(() => f.reviewApi.readReviewedPackagedOfficeSource(), /source_review_invalid/u); + assert.equal(f.toolchainCounts().discoveryCalls, 0); + assert.equal(f.toolchainCounts().compilerCalls, 0); + assert.equal(f.counts().signCalls, 0); + }); + await t.test("portable reader rejects unreviewed compiler bytes before invoking that compiler", async t => { + const f = await fixture(t, { portable: true }); + writeFileSync(join(f.root, "toolchain/clang++"), "UNREVIEWED SYNTHETIC COMPILER BYTES"); + assert.throws(() => f.reviewApi.readReviewedPackagedOfficeSource(), /toolchain_invalid/u); + assert.equal(f.toolchainCounts().compilerCalls, 0); + assert.equal(f.counts().signCalls, 0); + }); + await t.test("portable reader rejects invalid profiles, SDK and header identities without executing a compiler", async t => { + const f = await fixture(t, { portable: true }), original = structuredClone(f.review); + for (const mutate of [ + row => { row.toolchains = []; }, + row => { row.toolchains[0].sdkSettingsSha256 = "0".repeat(64); }, + row => { row.headers.sha256["node_api.h"] = "0".repeat(64); }, + ]) { + const row = structuredClone(original); mutate(row); + writeFileSync(f.reviewPath, JSON.stringify(row)); + assert.throws(() => f.reviewApi.readReviewedPackagedOfficeSource(), /toolchain_invalid/u); + assert.equal(f.toolchainCounts().compilerCalls, 0); + } + assert.equal(f.counts().signCalls, 0); + }); + await t.test("unapproved toolchain observation reads identity without executing a candidate compiler", async t => { + const f = await fixture(t, { portable: true }); + const api = await import(pathToFileURL(join(f.root, "apps/desktop/scripts/packaged-office-native-toolchain.mjs")).href); + const observed = api.observeOfficeNativeToolchain(); + assert.equal(observed.compiler.version, null); + assert.equal(observed.profile.compilerVersionFirstLine, null); + assert.equal(f.toolchainCounts().compilerCalls, 0); + assert.equal(f.counts().signCalls, 0); + }); + await t.test("portable reader rejects compiler, SDK, headers and authored inputs changed during an admitted version probe", async t => { + for (const [name, mutate, error] of [ + ["compiler", value => { writeFileSync(value.compilerPath, "CHANGED DURING VERSION PROBE"); }, /toolchain_invalid/u], + ["SDK", value => { writeFileSync(join(value.sdkRoot, "SDKSettings.json"), "CHANGED DURING VERSION PROBE"); }, /toolchain_invalid/u], + ["headers", value => { writeFileSync(join(value.headersRoot, "node_api.h"), "CHANGED DURING VERSION PROBE"); }, /toolchain_invalid/u], + ["authored closure", value => { writeFileSync(join(value.root, PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS[0]), "CHANGED DURING VERSION PROBE"); }, /source_review_invalid/u], + ["reported version", () => "Apple clang version 99.0.0 (clang-9900.1.0)", /toolchain_invalid/u], + ]) { + await t.test(name, async t => { + const f = await fixture(t, { portable: true, onCompilerVersion: mutate }); + assert.throws(() => f.reviewApi.readReviewedPackagedOfficeSource(), error); + assert.equal(f.toolchainCounts().compilerCalls, 1); + assert.equal(f.counts().signCalls, 0); + }); + } + }); await t.test("portable exact tool profile preserves original publication and immutable source approvals", async t => { const f = await fixture(t, { portable: true }), row = readFileSync(f.reviewPath); const first = f.reviewApi.readReviewedPackagedOfficeSource(); diff --git a/apps/desktop/tests/qa-launch-entry-order.test.mjs b/apps/desktop/tests/qa-launch-entry-order.test.mjs index 910ea46a5..93e24afcb 100644 --- a/apps/desktop/tests/qa-launch-entry-order.test.mjs +++ b/apps/desktop/tests/qa-launch-entry-order.test.mjs @@ -9,7 +9,7 @@ const entry = new URL("../dist/main/entry.js", import.meta.url); let sequence = 0; /** Real emitted entry/QA leaf; synthetic Electron/Main. No GUI, Store or human confirmation. */ -async function fixture(t, { qa = true, profileArgument = true } = {}) { +async function fixture(t, { qa = true, profileArgument = true, selected = false } = {}) { const root = realpathSync(mkdtempSync(join(tmpdir(), "ling-human-qa-entry-order-"))); t.after(() => rmSync(root, { recursive: true, force: true })); const profile = join(root, "profile"), appPath = join(root, "app"); @@ -19,6 +19,10 @@ async function fixture(t, { qa = true, profileArgument = true } = {}) { modelExecution: false, humanConfirmation: false, workspaceCwd: join(profile, "workspace"), userDataDir: join(profile, "user-data"), lingHome: join(profile, "ling-home") }; writeFileSync(join(profile, "qa-profile.json"), JSON.stringify(marker), { mode: 0o600 }); + if (selected) writeFileSync(join(profile, "qa-harness.json"), JSON.stringify({ schemaVersion: 1, + kind: "isolated-qa-delegation-selection", modelExecution: "host-authorized-budgeted", + workspaceCwd: marker.workspaceCwd, selectedWorkflowIds: ["qa-entry-workflow"], maxDepth: 2, + rootBudget: { tokenBudget: 4096, wallTimeBudgetMs: 60000 } }), { mode: 0o600 }); writeFileSync(join(appPath, "package.json"), JSON.stringify({ name: "synthetic-entry-test-only", ...(qa ? { lingHumanQaPackage: { schemaVersion: 1, purpose: "synthetic-human-evaluation", release: false } } : {}) }), { mode: 0o600 }); const originalArgv = process.argv, originalEnvironment = { ...process.env }, originalInfo = console.info; @@ -59,8 +63,9 @@ async function fixture(t, { qa = true, profileArgument = true } = {}) { }; console.info = value => { const record = JSON.parse(value); - assert.equal(record.event, "desktop.owned_synthetic_qa_profile"); - assert.equal(record.synthetic, true); + assert.equal(record.event, selected ? "desktop.owned_budgeted_qa_profile" : "desktop.owned_synthetic_qa_profile"); + assert.equal(record.synthetic, !selected); + assert.equal(record.modelExecution, selected ? "host-authorized-budgeted" : undefined); assert.equal(Object.hasOwn(record, "humanConfirmation"), false); assert.equal(Object.hasOwn(record, "secret"), false); state.scopeLogs++; @@ -91,6 +96,13 @@ test("real emitted entry scopes actual process before dynamic Main evaluation", assert.equal(f.state.registrationOutcome, "skipped_owned_qa"); }); +test("explicit budgeted QA entry log never claims its combined scope is synthetic-only", async t => { + const f = await fixture(t, { selected: true }); + await f.load(); + assert.equal(f.state.mainImports, 1); + assert.equal(f.state.scopeLogs, 1); +}); + test("real emitted normal entry preserves inherited configuration and userData", async t => { const f = await fixture(t, { qa: false, profileArgument: false }); await f.load(); diff --git a/apps/desktop/tests/qa-launch-profile.test.mjs b/apps/desktop/tests/qa-launch-profile.test.mjs index a16932a10..827f7cd22 100644 --- a/apps/desktop/tests/qa-launch-profile.test.mjs +++ b/apps/desktop/tests/qa-launch-profile.test.mjs @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { initializeDesktopQaLaunchProfile, readDesktopLaunchPackageMetadata } from "../dist/main/qa-launch-profile.js"; +import { readPackagedQaHarnessSelection } from "../../../packages/daemon/dist/runtime/packaged-qa-harness-bootstrap.js"; const packageMarker = { schemaVersion: 1, purpose: "synthetic-human-evaluation", release: false }; const packageMetadata = { name: "owned-qa", lingHumanQaPackage: packageMarker }; @@ -57,6 +58,73 @@ test("valid explicit packaged QA fixes scope and clears every non-OS/locale inhe assert.equal("humanConfirmation" in scope, false); assert.equal("authorization" in scope, false); }); +function delegationSelection(f, changes = {}) { + return { schemaVersion: 1, kind: "isolated-qa-delegation-selection", modelExecution: "host-authorized-budgeted", workspaceCwd: f.marker.workspaceCwd, + selectedWorkflowIds: ["workflow-qa-explicit"], maxDepth: 2, + rootBudget: { tokenBudget: 4096, wallTimeBudgetMs: 60000 }, ...changes }; +} + +test("only an explicit private QA selection enables bounded workflow delegation before Main", t => { + const f = fixture(t), selection = delegationSelection(f); + writeFileSync(join(f.profile, "qa-harness.json"), JSON.stringify(selection), { mode: 0o600 }); + f.environment.LING_STACK_QA_HARNESS_SELECTION = "inherited-untrusted-value"; + const scope = initializeDesktopQaLaunchProfile(f.options); + assert.equal(scope.synthetic, false, "combined bounded-model scope must not claim synthetic-only execution"); + assert.equal(scope.modelExecution, "host-authorized-budgeted"); + assert.equal(f.environment.LING_STACK_HARNESS_TOOL_BROKER_MODE, "enforce"); + assert.equal(f.environment.LING_STACK_HARNESS_DELEGATIONS_MODE, "enforce"); + assert.deepEqual(JSON.parse(f.environment.LING_STACK_QA_HARNESS_SELECTION), selection); + assert.equal(f.environment.LING_STACK_HARNESS_EVALUATIONS_MODE, undefined); + assert.equal(f.environment.OPENAI_API_KEY, undefined, "selection is not permission to inherit a credential"); +}); + +test("Desktop private selection reaches the typed Daemon resolver without broadening the policy", t => { + const f = fixture(t), selection = delegationSelection(f); + writeFileSync(join(f.profile, "qa-harness.json"), JSON.stringify(selection), { mode: 0o600 }); + initializeDesktopQaLaunchProfile(f.options); + // These two transport flags are added by the packaged Daemon launcher, not + // supplied by the workspace policy or inherited from the daily app process. + const harness = readPackagedQaHarnessSelection({ ...f.environment, + LING_STACK_PACKAGED_DESKTOP: "1", LING_STACK_PORT: "17928", LING_STACK_HOST: "127.0.0.1" }); + assert.deepEqual(harness, { continuations: { mode: "enforce" }, toolBroker: { mode: "enforce" }, + delegations: { mode: "enforce", maxDepth: selection.maxDepth, selectedWorkflowIds: selection.selectedWorkflowIds, + rootBudget: selection.rootBudget } }); + assert.equal(Object.hasOwn(harness, "evaluations"), false); + assert.equal(Object.hasOwn(harness, "sandbox"), false); +}); + +for (const [label, changes] of [ + ["foreign workspace", { workspaceCwd: "/daily" }], + ["empty workflow selection", { selectedWorkflowIds: [] }], + ["wildcard workflow", { selectedWorkflowIds: ["*"] }], + ["duplicate workflow", { selectedWorkflowIds: ["workflow-qa", "workflow-qa"] }], + ["unbounded token budget", { rootBudget: { wallTimeBudgetMs: 60000 } }], + ["missing deadline", { rootBudget: { tokenBudget: 4096 } }], + ["unpriced monetary budget", { rootBudget: { tokenBudget: 4096, wallTimeBudgetMs: 60000, costBudgetMicros: 1 } }], + ["extra permission", { fullAccess: true }], + ["depth escalation", { maxDepth: 17 }], + ["missing execution declaration", { modelExecution: undefined }], + ["synthetic-only execution declaration", { modelExecution: false }], +]) test(`QA delegation selection rejects ${label} without mutating launch state`, t => { + const f = fixture(t), before = { ...f.environment }; + writeFileSync(join(f.profile, "qa-harness.json"), JSON.stringify(delegationSelection(f, changes)), { mode: 0o600 }); + assert.throws(() => initializeDesktopQaLaunchProfile(f.options), /desktop_qa_/u); + assert.deepEqual(f.environment, before); assert.equal(f.setCalls, 0); +}); + +for (const kind of ["public", "symlink", "hardlink"]) test(`QA selection rejects a ${kind} policy file before launch mutation`, t => { + const f = fixture(t), before = { ...f.environment }, path = join(f.profile, "qa-harness.json"); + const text = JSON.stringify(delegationSelection(f)); + if (kind === "symlink") { + const target = join(f.parent, "policy-target"); writeFileSync(target, text, { mode: 0o600 }); symlinkSync(target, path); + } else { + writeFileSync(path, text, { mode: kind === "public" ? 0o644 : 0o600 }); + if (kind === "hardlink") linkSync(path, join(f.parent, "policy-alias")); + } + assert.throws(() => initializeDesktopQaLaunchProfile(f.options), /desktop_qa_/u); + assert.deepEqual(f.environment, before); assert.equal(f.setCalls, 0); +}); + for (const [label, mutate] of [ ["normal package with QA flag", f => { f.options.packageMetadata = { name: "ordinary" }; }], ["unpackaged developer process with QA flag", f => { f.options.packaged = false; }], diff --git a/apps/desktop/tests/qa-sse-fault-relay.test.mjs b/apps/desktop/tests/qa-sse-fault-relay.test.mjs new file mode 100644 index 000000000..f640c9e4a --- /dev/null +++ b/apps/desktop/tests/qa-sse-fault-relay.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { mkdtemp, readFile, realpath, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createQaSseFaultRelay } from "../scripts/qa-sse-fault-relay.mjs"; +import { buildQaGuiEnvironment, readExistingPrivateToken } from "../scripts/qa-sse-supervisor-support.mjs"; + +// QA transport regression: a control-only cut does not prove execution SSE loss. +// No model, GUI or actual tool execution is represented by this loopback fixture. +async function fixture(t) { + let executions = 0; + const upstream = createServer((request, response) => { + const url = new URL(request.url, "http://127.0.0.1"); + if (request.method === "GET" && url.pathname === "/control-plane/stream") { + response.writeHead(200, { "Content-Type": "text/event-stream" }); + response.write('event: control_plane_replay_ready\ndata: {"disposition":"complete","nextAfterSeq":17,"private":"never-retained"}\n\n'); + } else if (request.method === "POST" && url.pathname === "/conversations/stream") { + executions += 1; + request.resume(); + response.writeHead(200, { "Content-Type": "text/event-stream" }); + response.write('event: ready\ndata: {"private":"never-retained"}\n\n'); + } else { request.resume(); response.writeHead(200).end(JSON.stringify({ executions })); } + }); + await new Promise(resolve => upstream.listen(0, "127.0.0.1", resolve)); + const relay = await createQaSseFaultRelay({ upstreamPort: upstream.address().port }); + t.after(async () => { await relay.close(); upstream.closeAllConnections(); await new Promise(resolve => upstream.close(resolve)); }); + const base = `http://127.0.0.1:${relay.port}`; + const connect = async (kind, afterSeq = 12) => { + const response = await fetch(kind === "control" ? `${base}/control-plane/stream?afterSeq=${afterSeq}&private=never-retained` : `${base}/conversations/stream`, { + ...(kind === "execution" ? { method: "POST", body: "never-retained" } : {}), + headers: { "X-Fixture-Private": "never-retained" }, signal: AbortSignal.timeout(5000), + }); + assert.equal(response.status, 200); + const reader = response.body.getReader(); + assert.equal((await reader.read()).done, false); + return reader; + }; + return { relay, base, connect, state: async () => (await fetch(`${base}/health`)).json() }; +} + +test("fault requires both established SSE routes, then breaks both and refuses new executions while held", async t => { + const f = await fixture(t); + assert.throws(() => f.relay.disconnect(), /live successful control/); + const control = await f.connect("control"); + assert.throws(() => f.relay.disconnect(), /live successful execution/); + assert.equal((await f.state()).executions, 0); + const execution = await f.connect("execution"); + const before = f.relay.liveStreams(); + assert.deepEqual(before.map(stream => stream.kind), ["control", "execution"]); + assert.ok(before.every(stream => stream.ready && stream.bytesReceived > 0)); + assert.equal((await f.state()).executions, 1); + const cut = f.relay.disconnect(); + assert.deepEqual(cut, before.map(({ id, kind }) => ({ id, kind }))); + await assert.rejects(control.read()); + await assert.rejects(execution.read()); + assert.throws(() => f.relay.disconnect(), /repeated fault/); + assert.equal((await fetch(`${f.base}/control-plane/stream?afterSeq=18`)).status, 503); + assert.equal((await fetch(`${f.base}/conversations/stream`, { method: "POST", body: "not-forwarded" })).status, 503); + assert.equal((await f.state()).executions, 1, "held POST must not reach the execution handler"); + f.relay.restore(); + assert.throws(() => f.relay.restore(), /prior fault/); + const restored = await f.connect("control", 18); + assert.ok(f.relay.liveStreams().some(stream => stream.kind === "control" && stream.afterSeq === 18 && !cut.some(old => old.id === stream.id))); + assert.ok(f.relay.records.some(row => row.event === "control-frame" && row.type === "control_plane_replay_ready" && row.nextAfterSeq === 17)); + assert.equal((await f.state()).executions, 1, "restoring transport must not replay a POST"); + await restored.cancel(); +}); + +test("transport records exclude bodies, headers, arbitrary query values and SSE payload text", async t => { + const f = await fixture(t); + const control = await f.connect("control"); + const execution = await f.connect("execution"); + await control.cancel(); await execution.cancel(); + assert.ok(!JSON.stringify(f.relay.records).includes("never-retained")); + assert.ok(f.relay.records.some(row => row.event === "stream-request" && row.afterSeq === 12)); + assert.ok(f.relay.records.some(row => row.event === "control-frame" && row.nextAfterSeq === 17)); +}); + +test("relay rejects invalid or self-loop upstream ports", async () => { + for (const upstreamPort of [0, -1, 65536, 1.5, "17930"]) { + await assert.rejects(createQaSseFaultRelay({ upstreamPort })); + } + await assert.rejects(createQaSseFaultRelay({ upstreamPort: 17930, port: 17930 })); +}); + +// Regression: R02-QA-001 — malformed private identity must not appear in exceptions. +// Found by /qa independent review on 2026-10-26; synthetic token file, not live profile. +test("private QA identity reader rejects malformed contents without exposing them", async () => { + const directory = await realpath(await mkdtemp(join(tmpdir(), "ling-r02-token-regression-"))); + const path = join(directory, "synthetic-identity"); + const malformed = "fixture_private_value_that_must_not_be_exposed!"; + await writeFile(path, malformed, { flag: "wx", mode: 0o600 }); + await assert.rejects(readExistingPrivateToken(path), error => { + assert.ok(!String(error.stack).includes(malformed), "exception stack must not expose private input"); + assert.ok(!JSON.stringify(error).includes(malformed), "exception properties must not expose private input"); + return true; + }); + assert.equal(await readFile(path, "utf8"), malformed, "rejection must not rotate or rewrite identity"); +}); + +test("private QA identity reader accepts existing protected valid input without writing it", async () => { + const directory = await realpath(await mkdtemp(join(tmpdir(), "ling-r02-token-happy-"))); + const path = join(directory, "synthetic-identity"); + const valid = "synthetic_only_not_a_real_bridge_identity_20261026"; + await writeFile(path, valid, { flag: "wx", mode: 0o600 }); + assert.equal(await readExistingPrivateToken(path), valid); + assert.equal(await readFile(path, "utf8"), valid); +}); + +// Regression: R02-QA-002 — the Node-mode supervisor must launch a real GUI child. +// Found by /qa on 2026-10-26; report r02-recovery-successor-20261026. +test("GUI child does not inherit supervisor Node mode or mutate its parent environment", () => { + const parent = Object.freeze({ ELECTRON_RUN_AS_NODE: "1", PATH: "/synthetic/bin", LING_QA_SYNTHETIC: "retained" }); + const child = buildQaGuiEnvironment(parent); + assert.equal(Object.hasOwn(child, "ELECTRON_RUN_AS_NODE"), false); + assert.deepEqual(child, { PATH: "/synthetic/bin", LING_QA_SYNTHETIC: "retained" }); + assert.equal(parent.ELECTRON_RUN_AS_NODE, "1"); + assert.notEqual(child, parent); + assert.deepEqual(buildQaGuiEnvironment(Object.freeze({ PATH: "/synthetic/bin" })), { PATH: "/synthetic/bin" }); +}); diff --git a/apps/desktop/tests/startup-lifecycle.regression-1.test.mjs b/apps/desktop/tests/startup-lifecycle.regression-1.test.mjs new file mode 100644 index 000000000..f00d64c58 --- /dev/null +++ b/apps/desktop/tests/startup-lifecycle.regression-1.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { DesktopDaemonLifecycleError } from "../dist/main/daemon-lifecycle.js"; +import { createDesktopExitLifecycle } from "../dist/main/startup-lifecycle.js"; + +// R03: a refused/unconfirmed Host stop is not an ordinary cleanup failure. +// This executes the production exit lifecycle; it is not packaged GUI evidence. +for (const code of ["stop_blocked", "stop_unconfirmed"]) { + test(`exit retains the application when the owned Host reports ${code}`, async () => { + const failure = new DesktopDaemonLifecycleError(code, "http://127.0.0.1:17928"); + const events = []; + let calls = 0; + const lifecycle = createDesktopExitLifecycle({ + async shutdown() { calls += 1; throw failure; }, + quit() { events.push("quit"); }, + exit(code) { events.push(`exit:${code}`); }, + onShutdownError(error) { assert.equal(error, failure); events.push("diagnostic"); }, + }); + const first = lifecycle.requestExit(); + assert.equal(lifecycle.requestExit(), first); + await first; + assert.equal(lifecycle.isCleanupComplete(), false); + assert.equal(lifecycle.isExitRequested(), false); + assert.deepEqual(events, ["diagnostic"]); + await lifecycle.requestExit(1); + assert.equal(calls, 2, "an explicit later quit must recheck the original stop barrier"); + assert.deepEqual(events, ["diagnostic", "diagnostic"]); + }); +} + +test("a diagnostic exception cannot turn refused stop into process exit", async () => { + let exits = 0; + const lifecycle = createDesktopExitLifecycle({ + async shutdown() { throw new DesktopDaemonLifecycleError("stop_blocked", "http://127.0.0.1:17928"); }, + quit() { exits += 1; }, exit() { exits += 1; }, + onShutdownError() { throw new Error("diagnostic_failed"); }, + }); + await lifecycle.requestExit(); + assert.equal(exits, 0); + assert.equal(lifecycle.isCleanupComplete(), false); +}); + +test("only a fresh successful stop barrier permits explicit quit after an observation failure", async () => { + const events = []; + let stopped = false; + const lifecycle = createDesktopExitLifecycle({ + async shutdown() { + events.push("preflight"); + if (!stopped) throw new DesktopDaemonLifecycleError("stop_unconfirmed", "http://127.0.0.1:17928"); + events.push("cleanup"); + }, + quit() { events.push("quit"); }, exit(code) { events.push(`exit:${code}`); }, + }); + await lifecycle.requestExit(); + assert.deepEqual(events, ["preflight"]); + stopped = true; + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(events, ["preflight"], "a late stop receipt cannot trigger automatic exit"); + await lifecycle.requestExit(); + assert.deepEqual(events, ["preflight", "preflight", "cleanup", "quit"]); + assert.equal(lifecycle.isCleanupComplete(), true); +}); + +test("synchronous exit callbacks join the installed single flight", async () => { + let calls = 0, reentered; + const lifecycle = createDesktopExitLifecycle({ + shutdown() { calls += 1; reentered = lifecycle.requestExit(); return Promise.resolve(); }, + quit() {}, exit() {}, + }); + const first = lifecycle.requestExit(); + await first; + assert.equal(reentered, first); + assert.equal(calls, 1); +}); + +for (const failure of [new Error("cleanup_failed"), { code: "stop_blocked" }, + new DesktopDaemonLifecycleError("spawn_failed", "http://127.0.0.1:17928")]) { + test(`ordinary cleanup failure retains exit-1 semantics: ${failure.message ?? "untyped code"}`, async () => { + const events = []; + const lifecycle = createDesktopExitLifecycle({ + async shutdown() { throw failure; }, + quit() { events.push("quit"); }, exit(code) { events.push(code); }, + }); + await lifecycle.requestExit(); + assert.deepEqual(events, [1]); + assert.equal(lifecycle.isCleanupComplete(), true); + }); +} diff --git a/apps/desktop/tests/support/cua-driver-controlled-lease-streams.mjs b/apps/desktop/tests/support/cua-driver-controlled-lease-streams.mjs new file mode 100644 index 000000000..73da7d9d2 --- /dev/null +++ b/apps/desktop/tests/support/cua-driver-controlled-lease-streams.mjs @@ -0,0 +1,99 @@ +import { EventEmitter, once } from "node:events"; +import { PassThrough, Writable } from "node:stream"; + +/** Real Writable machinery with explicitly controlled _write completion. */ +export class ControlledLeaseWritable extends Writable { + constructor() { + super({ autoDestroy: false }); + this.requests = []; + this.destroyCalls = 0; + } + + _write(frame, encoding, callback) { + const request = { frame: frame.toString(), complete: callback }; + this.requests.push(request); + this.emit("requestAvailable"); + } + + _destroy(error, callback) { + this.destroyCalls += 1; + callback(error); + } + + async nextWrite() { + if (this.requests.length === 0) await once(this, "requestAvailable"); + const request = this.requests.shift(); + if (request === undefined) throw new Error("controlled writer request missing"); + return request; + } +} + +/** No OS process or signal: observes the production child lifecycle port. */ +export class ControlledLeaseChild extends EventEmitter { + constructor() { + super(); + this.pid = 910_001; + this.exitCode = null; + this.signalCode = null; + this.stdin = new ControlledLeaseWritable(); + this.stdout = new PassThrough(); + this.stderr = new PassThrough(); + this.killCalls = []; + this.stdout.write(`${JSON.stringify({ + schemaVersion: 1, + outcome: "locked", + platform: process.platform === "darwin" ? "darwin" : "win32", + reason: "locked", + nativeCode: 0, + parentIdentity: "1:1", + lockScope: "parent", + recoveredAbandonedLock: false, + })}\n`); + } + + kill(signal) { + this.killCalls.push(signal); + this.emit("terminationRequested"); + return true; + } + + finishExit({ code = null, signal = "SIGKILL" } = {}) { + if (this.exitCode !== null || this.signalCode !== null) return; + this.exitCode = code; + this.signalCode = signal; + this.stdout.end(); + this.stderr.end(); + this.emit("exit", code, signal); + } + + receipt(request, outcome) { + const fields = request.frame.trimEnd().split("\t"); + const release = fields[1] === "release"; + this.stdout.write(`${JSON.stringify({ + schemaVersion: 1, + outcome, + platform: process.platform === "darwin" ? "darwin" : "win32", + reason: outcome, + nativeCode: 0, + requestId: Number(fields[2]), + operation: release ? "release" : fields[3], + operationOwnerPid: this.pid, + ...(release ? { + parentIdentity: "1:1", + lockScope: "parent", + recoveredAbandonedLock: false, + } : {}), + })}\n`); + } +} + +export function deferred() { + let resolve; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +/** Flush explicit Promise/nextTick callbacks, not an OS timing assumption. */ +export function nextEventLoopTurn() { + return new Promise(resolve => setImmediate(resolve)); +} diff --git a/apps/desktop/tests/support/r03-owned-host-worker.mjs b/apps/desktop/tests/support/r03-owned-host-worker.mjs new file mode 100644 index 000000000..bc7799286 --- /dev/null +++ b/apps/desktop/tests/support/r03-owned-host-worker.mjs @@ -0,0 +1,36 @@ +import { createServer } from "node:http"; +import { basename } from "node:path"; +import { createDaemonShutdownReporter } from "../../../../packages/core/dist/daemon-shutdown-observer.js"; +import { createDaemonSignalShutdown } from "../../../../packages/daemon/dist/runtime/signal-shutdown.js"; + +// Isolated, no Store/provider/tools. Exercises production Desktop spawn stdio +// and real signals against an owned loopback service, not packaged acceptance. +const workspace = process.env.LING_STACK_DEFAULT_WORKSPACE_CWD; +const reporter = createDaemonShutdownReporter({ fd: Number(process.env.LING_STACK_SHUTDOWN_OBSERVER_FD) }); +delete process.env.LING_STACK_SHUTDOWN_OBSERVER_FD; +const server = createServer((req, res) => { + if (req.url !== "/health") { res.writeHead(404).end(); return; } + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ service: "ling-stack-daemon", defaultWorkspaceCwd: workspace })); +}); +await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(Number(process.env.LING_STACK_PORT), "127.0.0.1", resolve); +}); +let retained; +const stop = createDaemonSignalShutdown({ + onDraining: () => reporter.publish("draining"), + stop: async () => { + const closed = new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + server.closeAllConnections(); + await closed; + if (basename(workspace) === "blocked") throw new Error("fixture_stop_unverified"); + }, + exit: code => process.exit(code), + onBlocked: () => { + retained ??= setInterval(() => undefined, 60000); + reporter.publish("blocked"); + }, +}); +process.on("SIGTERM", () => { void stop(); }); +process.on("SIGINT", () => { void stop(); }); diff --git a/apps/web/e2e/conversation-channel-capability-gate.spec.ts b/apps/web/e2e/conversation-channel-capability-gate.spec.ts new file mode 100644 index 000000000..cdb474105 --- /dev/null +++ b/apps/web/e2e/conversation-channel-capability-gate.spec.ts @@ -0,0 +1,144 @@ +import { expect, test, type Page } from "@playwright/test"; +import { writeFile } from "node:fs/promises"; +import { mockDaemon } from "./support/mock-daemon"; +import { createBaseState, isRecord } from "./support/mock-state"; +import { openEngineeringChannel } from "./support/test-helpers"; + +const proposalId = "proposal_capability_recovery"; +const channelId = "channel_engineering"; + +function createCapabilityState() { + const state = createBaseState({ approvalStatus: "expired" }); + state.runs = []; + state.tasks = []; + state.approvals = []; + state.channelMessages = [{ + id: "message_capability_recovery", workspaceId: "workspace_default", channelId, + kind: "message", intent: "execute", authorType: "local-user", authorId: "local-user", + body: "Write the requested report once.", mentions: [], attachmentIds: [], linkPreviewIds: [], + status: "ready", createdAt: "2026-10-29T00:00:00.000Z", updatedAt: "2026-10-29T00:00:00.000Z", + }]; + state.channelExecutionProposals = [{ + id: proposalId, revision: 1, workspaceId: "workspace_default", channelId, + sourceMessageId: "message_capability_recovery", proposedByTeammateId: "ling_agent", + title: "Write the report", prompt: "Write report.txt with the requested content.", + assigneeTeammateId: "ling_agent", acceptanceCriteria: ["The report contains the requested text."], + collaborationSteps: [{ teammateId: "ling_agent", action: "coordinated", summary: "Write the report once." }], + requiredTools: ["write"], status: "draft", + createdAt: "2026-10-29T00:00:01.000Z", updatedAt: "2026-10-29T00:00:01.000Z", + }]; + return state; +} + +async function useEnglish(page: Page) { + await page.addInitScript(() => localStorage.setItem("ling-stack.locale", "en-US")); +} + +test("missing channel capability blocks Allow until an explicit same-channel settings edit", async ({ page }, testInfo) => { + await useEnglish(page); + const state = createCapabilityState(); + const patches: Array<{ channelId: string; body: unknown }> = []; + const confirmations: string[] = []; + const runtimeErrors: string[] = []; + page.on("pageerror", error => runtimeErrors.push(error.message)); + page.on("request", request => { + const path = new URL(request.url()).pathname; + const channel = /\/channels\/([^/]+)$/.exec(path); + if (request.method() === "PATCH" && channel) patches.push({ channelId: channel[1], body: request.postDataJSON() }); + if (request.method() === "POST" && path.endsWith(`/channel-proposals/${proposalId}/confirm-workflow`)) confirmations.push(proposalId); + }); + await mockDaemon(page, state); + // Keep the server boundary explicit: a client click cannot grant the missing tool. + await page.route(`**/channel-proposals/${proposalId}/confirm-workflow?**`, async route => { + const channel = state.channels.find(item => isRecord(item) && item.id === channelId); + if (!isRecord(channel) || !Array.isArray(channel.allowedTools) || !channel.allowedTools.includes("write")) { + await route.fulfill({ status: 400, json: { error: "channel_tools_missing", message: "Channel missing tools: write" } }); + return; + } + await route.fallback(); + }); + await page.goto("/"); + await openEngineeringChannel(page); + const gate = page.locator(`[data-channel-execution-proposal-id="${proposalId}"][data-conversation-decision-kind="task_proposal"]`); + const allow = gate.getByRole("button", { name: "Allow", exact: true }); + await expect(gate).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("initial-gate.png") }); + await writeFile(testInfo.outputPath("initial-gate.json"), JSON.stringify({ + proposalId, channelId, allowDisabled: await allow.isDisabled(), patches, confirmations, + }, null, 2)); + await expect(allow).toBeDisabled(); + await expect(gate).toContainText("Missing tools"); + await expect(gate).toContainText("write"); + await expect(gate.getByRole("button", { name: "Deny", exact: true })).toBeEnabled(); + await allow.evaluate((button: HTMLButtonElement) => button.click()); + expect(confirmations).toEqual([]); + expect(patches).toEqual([]); + + await gate.getByRole("button", { name: "Channel settings", exact: true }).click(); + const settings = page.getByRole("region", { name: "Channel settings", exact: true }); + await expect(settings).toBeVisible(); + await expect(page.locator(".channel-settings-top-back")).toBeVisible(); + const general = page.getByRole("tab", { name: "General", exact: true }); + await general.click(); + const write = settings.getByRole("switch", { name: "Use capability in channel: Write files", exact: true }); + await expect(write).toHaveAttribute("aria-checked", "false"); + // Exceed the existing 700 ms autosave debounce without editing anything. + await page.waitForTimeout(850); + expect(patches).toEqual([]); + expect(confirmations).toEqual([]); + + await write.click(); + await expect.poll(() => patches.length).toBe(1); + await expect.poll(() => { + const channel = state.channels.find(item => isRecord(item) && item.id === channelId); + return isRecord(channel) && Array.isArray(channel.allowedTools) && channel.allowedTools.includes("write"); + }).toBe(true); + expect(patches[0].channelId).toBe(channelId); + expect(patches[0].body).toMatchObject({ allowedTools: ["read", "grep", "find", "ls", "write"] }); + expect(confirmations).toEqual([]); + await page.getByRole("button", { name: "Back to channel", exact: true }).click(); + await expect(settings).toHaveCount(0); + await expect(gate).toBeVisible(); + await expect(allow).toBeEnabled(); + await expect(gate).not.toContainText("Missing tools"); + await expect(gate.getByRole("button", { name: "Channel settings", exact: true })).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath("restored-gate.png") }); + await allow.click(); + await expect.poll(() => confirmations.length).toBe(1); + await expect.poll(() => { + const proposal = state.channelExecutionProposals.find(item => isRecord(item) && item.id === proposalId); + return isRecord(proposal) ? proposal.status : undefined; + }).toBe("accepted"); + expect(state.channelExecutionProposals).toHaveLength(1); + expect(state.channelWorkflows).toHaveLength(1); + expect(patches).toHaveLength(1); + expect(runtimeErrors).toEqual([]); + await writeFile(testInfo.outputPath("recovery-evidence.json"), JSON.stringify({ + proposalId, channelId, patches, confirmations, runtimeErrors, + proposal: state.channelExecutionProposals[0], workflowCount: state.channelWorkflows.length, + }, null, 2)); +}); + +test("missing channel capability leaves Deny available without modifying channel tools", async ({ page }) => { + await useEnglish(page); + const state = createCapabilityState(); + let channelPatches = 0; + let confirmations = 0; + page.on("request", request => { + const path = new URL(request.url()).pathname; + if (request.method() === "PATCH" && /\/channels\/[^/]+$/.test(path)) channelPatches++; + if (request.method() === "POST" && path.endsWith("/confirm-workflow")) confirmations++; + }); + await mockDaemon(page, state); + await page.goto("/"); + await openEngineeringChannel(page); + const gate = page.locator('[data-conversation-decision-kind="task_proposal"]'); + await expect(gate.getByRole("button", { name: "Allow", exact: true })).toBeDisabled(); + await gate.getByRole("button", { name: "Deny", exact: true }).click(); + await expect.poll(() => { + const proposal = state.channelExecutionProposals[0]; + return isRecord(proposal) ? proposal.status : undefined; + }).toBe("cancelled"); + expect(channelPatches).toBe(0); + expect(confirmations).toBe(0); +}); diff --git a/apps/web/e2e/conversation-command-detail.spec.ts b/apps/web/e2e/conversation-command-detail.spec.ts new file mode 100644 index 000000000..9de33c209 --- /dev/null +++ b/apps/web/e2e/conversation-command-detail.spec.ts @@ -0,0 +1,114 @@ +import { expect, test, type Page } from "@playwright/test"; +import type { ConversationTimelineCommandExecutionItem } from "@ling-stack/conversation-ui-core"; + +async function mount(page: Page, item: Partial) { + await page.addInitScript(() => { + localStorage.setItem("ling-stack.locale", "en-US"); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: async (value: string) => { + (window as Window & { commandCopied?: string }).commandCopied = value; + } } }); + }); + await page.goto("/e2e/support/p2-reading.html?manual"); + await page.evaluate(async input => { + const path = "/e2e/support/command-detail-harness.tsx"; + window.commandDetailHarness = (await import(/* @vite-ignore */ path)).mountCommandDetailFixture(input); + }, item); + return page.locator('[data-conversation-timeline-kind="commandExecution"]'); +} + +test("command-only history exposes and copies the complete multiline command", async ({ page }, info) => { + const command = `python3 - <<'PY'\nprint('quoted \\"text\\" and $HOME stay literal')\n${"# long command ".repeat(35)}\nPY`; + const row = await mount(page, { command }); + const disclosure = row.locator(".conversation-timeline-activity-main"); + await expect(disclosure).toHaveAttribute("aria-expanded", "false"); + await disclosure.click(); + await expect(row.getByLabel("Command", { exact: true })).toHaveText(command); + await row.getByRole("button", { name: "Copy: Command", exact: true }).click(); + expect(await page.evaluate(() => (window as Window & { commandCopied?: string }).commandCopied)).toBe(command); + await expect(row.getByRole("button", { name: "Command copied" })).toBeVisible(); + await page.setViewportSize({ width: 390, height: 844 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + await page.screenshot({ path: info.outputPath("command-multiline-mobile.png") }); +}); + +test("only owned log evidence becomes an action and keeps exact operation lineage", async ({ page }) => { + const log = { kind: "artifact" as const, id: "stdout-log" }; + const row = await mount(page, { outputTruncated: true, stdoutArtifactId: log.id, + stderrArtifactId: "missing-stderr", sourceRefs: [log, { kind: "artifact", id: "unrelated-log" }], exitCode: 0, + outputTail: [{ eventSeq: 1, stream: "stderr", text: "A warning, not failure", redacted: false }] }); + await row.locator(".conversation-timeline-activity-main").click(); + await expect(row).toHaveAttribute("data-conversation-timeline-status", "succeeded"); + await expect(row).toContainText("exit 0"); + await expect(row.getByRole("button", { name: /Open full log/u })).toHaveCount(1); + await row.getByRole("button", { name: /Open full log/u }).click(); + expect(await page.evaluate(() => window.commandDetailHarness.opened().map(value => JSON.parse(value)))).toEqual([ + { ref: log, actionId: "action-detail", runId: "run-detail" }, + ]); +}); + +const failureCases: readonly Partial[] = [ + { status: "failed", exitCode: 7 }, + { status: "failed", timedOut: true, signal: "SIGTERM" }, + { status: "cancelled", signal: "SIGINT" }, +]; +for (const input of failureCases) { + test(`command ${input.timedOut ? "timeout" : input.signal ?? "nonzero exit"} remains visible with unavailable log feedback`, async ({ page }) => { + const row = await mount(page, { ...input, outputTruncated: true, stdoutArtifactId: "orphan-log" }); + await expect(row.locator(".conversation-timeline-activity-main")).toHaveAttribute("aria-expanded", "true"); + await expect(row).toHaveAttribute("data-conversation-timeline-status", input.status); + await expect(row).toContainText("Full output is unavailable for this command."); + await expect(row.getByRole("button", { name: /Open full log/u })).toHaveCount(0); + if ("exitCode" in input) await expect(row).toContainText("exit 7"); + if (input.timedOut) await expect(row).toContainText("Timed out"); + if (input.signal) await expect(row).toContainText(input.signal); + }); +} + +test("saved disclosure returns after remount but cannot conceal a new failure", async ({ page }) => { + let row = await mount(page, { command: "node --test" }); + await row.locator(".conversation-timeline-activity-main").click(); + row = await mount(page, { command: "node --test" }); + await expect(row.locator(".conversation-timeline-activity-main")).toHaveAttribute("aria-expanded", "true"); + await row.locator(".conversation-timeline-activity-main").click(); + row = await mount(page, { command: "node --test", status: "failed", exitCode: 2 }); + await expect(row.locator(".conversation-timeline-activity-main")).toHaveAttribute("aria-expanded", "true"); + await expect(row).toContainText("exit 2"); +}); + +for (const outcome of ["succeeded", "failed"] as const) { + test(`restored pending collapse reveals running and ${outcome} updates`, async ({ page }) => { + let row = await mount(page, { command: "node --test" }); + await row.locator(".conversation-timeline-activity-main").click(); + await row.locator(".conversation-timeline-activity-main").click(); + row = await mount(page, { command: "node --test", status: "pending" }); + const disclosure = row.locator(".conversation-timeline-activity-main"); + await expect(disclosure).toHaveAttribute("aria-expanded", "false"); + await page.evaluate(() => window.commandDetailHarness.update({ status: "running", revision: 2 })); + await expect(disclosure).toHaveAttribute("aria-expanded", "true"); + await expect(row.getByLabel("Command", { exact: true })).toHaveText("node --test"); + await page.evaluate(status => window.commandDetailHarness.update({ status, revision: 3, + exitCode: status === "succeeded" ? 0 : 2 }), outcome); + await expect(disclosure).toHaveAttribute("aria-expanded", outcome === "succeeded" ? "false" : "true"); + if (outcome === "failed") await expect(row).toContainText("exit 2"); + }); +} + +test("more files survives virtual remount and a page reload", async ({ page }) => { + await page.goto("/e2e/support/p2-reading.html?manual"); + const mountFiles = () => page.evaluate(async () => { + const path = "/e2e/support/command-detail-harness.tsx"; + window.responseFilesHarness = (await import(/* @vite-ignore */ path)).mountResponseFilesFixture(); + }); + await mountFiles(); + const files = page.locator("[data-conversation-response-files]"); + await expect(files.locator(".conversation-response-file")).toHaveCount(3); + await files.locator(".conversation-response-files-more").click(); + await expect(files.locator(".conversation-response-file")).toHaveCount(5); + await page.evaluate(() => window.responseFilesHarness.show(false)); + await expect(files).toHaveCount(0); + await page.evaluate(() => window.responseFilesHarness.show(true)); + await expect(files.locator(".conversation-response-file")).toHaveCount(5); + await page.reload(); + await mountFiles(); + await expect(files.locator(".conversation-response-file")).toHaveCount(5); +}); diff --git a/apps/web/e2e/conversation-private-files-persistence.spec.ts b/apps/web/e2e/conversation-private-files-persistence.spec.ts new file mode 100644 index 000000000..95cba6299 --- /dev/null +++ b/apps/web/e2e/conversation-private-files-persistence.spec.ts @@ -0,0 +1,234 @@ +import { expect, test, type Page } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { buildMockConversationDetail, mockDaemon } from "./support/mock-daemon"; +import { createBaseState, createChatProvider, createMockTeammate, cwd } from "./support/mock-state"; +import { triggerFocusRevalidation } from "./support/focus-revalidation"; + +const teammateId = "teammate_files_reader"; +const directThreadId = "dm_files_reader"; +const canonicalThreadId = "thread_files_reader"; +const turnId = "turn_files_reader"; +const runId = "run_files_reader"; +const now = "2026-10-29T00:00:00.000Z"; +const paths = ["a.txt", "b.txt", "c.txt", "d.txt"]; + +/** Real AppShell/private feed, projector, view store, retention and review dock. */ +async function installPrivateFilesFixture(page: Page, initiallyEmpty = false) { + const state = createBaseState({ approvalStatus: "expired" }); + state.providers = [createChatProvider({ id: "provider_files", model: "fixture-model", + lastCheck: { ok: true, code: "ready", message: "Fixture provider ready", checkedAt: now } })]; + state.aiTeammates = [createMockTeammate({ id: teammateId, displayName: "Files Reader", providerPresetId: "provider_files" })]; + const directThread = { id: directThreadId, workspaceId: "workspace_default", teammateId, + title: "Files Reader", createdAt: now, updatedAt: now }; + state.teammateDirectThreads[teammateId] = directThread; + const thread = { id: canonicalThreadId, workspaceId: "workspace_default", teammateId, + kind: "teammate_direct", title: "Files Reader canonical", objective: "Write four files", status: "open", + createdAt: now, updatedAt: now }; + state.threads.push(thread); + state.threads.push({ id: "thread_files_other", workspaceId: "workspace_default", kind: "conversation", + title: "Ordinary reading", objective: "Read an ordinary conversation", status: "succeeded", + lastRunId: "run_files_other", createdAt: now, updatedAt: now }); + state.turns.push({ id: "turn_files_other", workspaceId: "workspace_default", threadId: "thread_files_other", + kind: "initial_request", status: "succeeded", title: "Ordinary reading", userInstruction: "Ordinary request", + lastRunId: "run_files_other", createdByActor: "local-user", createdAt: now, updatedAt: now }); + state.turnRunLinks.push({ turnId: "turn_files_other", runId: "run_files_other", attemptNumber: 1, role: "primary", createdAt: now }); + state.runs.push({ id: "run_files_other", status: "succeeded", cwd, threadId: "thread_files_other", turnId: "turn_files_other", + taskId: "task_files_other", providerPresetId: "provider_files", providerName: "fixture", providerModel: "fixture-model", + taskSnapshot: { title: "Ordinary reading" }, providerSnapshot: {}, requestSnapshot: {}, eventLogPath: "fixture/other.jsonl", + createdAt: now, startedAt: now, updatedAt: now, endedAt: now }); + state.eventsByRun.run_files_other = [{ id: "event_files_other", runId: "run_files_other", seq: 1, ts: now, + type: "assistant_text_delta", data: { text: "Ordinary response." } }]; + state.turns.push({ id: turnId, workspaceId: "workspace_default", threadId: canonicalThreadId, + teammateId, kind: "teammate_direct_reply", status: "running", title: "Write four files", + userInstruction: "Write four files", sourceMessageId: "files-user", sourceItemId: "files-user-item", + createdByActor: "local-user", createdAt: now, updatedAt: now }); + state.turnRunLinks.push({ runId, turnId, attemptNumber: 1, role: "primary", createdAt: now }); + state.runs.push({ id: runId, threadId: canonicalThreadId, teammateId, status: "running", cwd, + taskId: "direct:files", taskSnapshot: { title: "Write four files" }, providerSnapshot: {}, requestSnapshot: {}, + providerName: "fixture", providerModel: "fixture-model", workUnitId: "wu_files", + eventLogPath: "fixture/files.jsonl", createdAt: now, updatedAt: now, startedAt: now }); + const messageBase = { workspaceId: "workspace_default", threadId: directThreadId, teammateId, + attachmentIds: [], linkPreviewIds: [], fileRefs: [], createdAt: now, updatedAt: now, revision: 1 }; + const user = { ...messageBase, id: "files-user", authorType: "local-user", authorId: "local-user", + body: "Write four files", status: "ready" }; + let reply = { ...messageBase, id: "files-reply", authorType: "ai-teammate", authorId: teammateId, + body: "Writing the four files.", status: "pending", linkedRunId: runId, + narrationSegments: [{ content: "Writing the four files.", firstEventId: "files-evt-1", lastEventId: "files-evt-1", + startEventSequence: 1, endEventSequence: 1, completed: true }] }; + let published = !initiallyEmpty; + state.teammateDirectMessages = published ? [user, reply] : []; + const item = (id: string, sequence: number, kind: string, payload: Record, domainRefs: unknown[]) => ({ + schemaVersion: 1, id, workspaceId: "workspace_default", threadId: canonicalThreadId, turnId, + sequence, kind, actorType: kind === "user_message" ? "user" : "host", actorId: "local-user", + visibility: "thread_participants", status: "committed", revision: 1, + domainRefs, payload, createdAt: now, updatedAt: now, committedAt: now, + }); + const userItem = item("files-user-item", 1, "user_message", { body: user.body, format: "markdown" }, [{ type: "message", id: user.id }]); + let settled = false; + let holdCanonical = false; + const canonicalWaiters = new Set<() => void>(); + let canonicalBlocked = false; + let holdWorkspace = initiallyEmpty; + let workspaceBlocked = false; + const workspaceWaiters = new Set<() => void>(); + const initialMessageWaiters = new Set<() => void>(); + let sendCount = 0; + await mockDaemon(page, state); + await page.route(`**/teammates/${teammateId}/direct-messages?**`, async route => { + if (route.request().method() !== "GET") return route.fallback(); + // An initial metadata request can reach the handler after the first send. + // The independent canonical reader may finish before workspace metadata. + if (initiallyEmpty && !published) await new Promise(resolve => initialMessageWaiters.add(resolve)); + if (holdCanonical) { + canonicalBlocked = true; + await new Promise(resolve => { canonicalWaiters.add(resolve); }); + } + const detail = buildMockConversationDetail(state, cwd, thread); + await route.fulfill({ json: { cwd, teammateId, thread: directThread, + messages: published ? [user, reply] : [], generatedMedia: [], hasMore: false, + timeline: published ? { ...detail, messages: [], conversationItems: [userItem, + ...(settled ? paths.map((path, index) => item(`files-tool-${index}`, index + 2, "tool_call", + { toolCallId: `files-call-${index}`, toolName: "write" }, [{ type: "run", id: runId }])) : [])] } : undefined, + } }); + }); + await page.route(`**/teammates/${teammateId}/direct-workspace?**`, async route => { + if (holdWorkspace) { + workspaceBlocked = true; + await new Promise(resolve => workspaceWaiters.add(resolve)); + } + await route.fallback(); + }); + const review = { scope: { kind: "turn", id: turnId }, workspaceId: "workspace_default", threadId: canonicalThreadId, + runIds: [runId], revision: "files-review-v1", state: "exact", coveredPaths: paths, additions: 4, deletions: 0, + files: paths.map(path => ({ id: `review:${path}`, path, kind: "added", state: "exact", additions: 1, deletions: 0, + diff: `diff --git a/${path} b/${path}\n--- /dev/null\n+++ b/${path}\n@@ -0,0 +1 @@\n+content for ${path}\n` })) }; + await page.route(`**/turns/${turnId}/workspace-changes?**`, async route => route.fulfill({ json: { + cwd, turnId, state: "applied", canUndo: false, canReapply: false, updatedAt: now, + files: paths.map(path => ({ path, operationCount: 1, additions: 1, deletions: 0 })), + summary: { fileCount: 4, addCount: 4, updateCount: 0, deleteCount: 0, moveCount: 0, addedLines: 4, removedLines: 0 }, + review, + } })); + function settle() { + settled = true; + reply = { ...reply, status: "ready", body: "Created all four files.", revision: 2, + narrationSegments: [...reply.narrationSegments, { content: "Created all four files.", firstEventId: "files-evt-10", lastEventId: "files-evt-10", + startEventSequence: 10, endEventSequence: 10, completed: true }] }; + state.teammateDirectMessages = [user, reply]; + const run = state.runs.find(run => run.id === runId)!; + run.status = "succeeded"; + state.turns.find(turn => turn.id === turnId)!.status = "succeeded"; + state.executionActions.push(...paths.map((path, index) => ({ + id: `files-action-${index}`, workspaceId: "workspace_default", threadId: canonicalThreadId, runId, + attemptId: "attempt_files", workUnitId: "wu_files", participantId: teammateId, workflowStepId: undefined, + toolCallId: `files-call-${index}`, sourceEventSequence: index + 2, revision: 1, + actionType: "file_write", risk: "low", status: "succeeded", title: `Write ${path}`, summary: `Created ${path}`, + sourceRefs: [{ kind: "conversation_item", id: `files-tool-${index}` }], + targetRefs: [{ type: "file", id: path, path }], previewArtifactIds: [], resultArtifactIds: [], + createdAt: now, updatedAt: now, completedAt: now, + }))); + } + await page.route(`**/teammates/${teammateId}/direct-messages/stream?**`, async route => { + sendCount++; + published = true; + const pending = { ...reply, body: "", status: "pending", narrationSegments: [] }; + settle(); + initialMessageWaiters.forEach(release => release()); + initialMessageWaiters.clear(); + const events = [ + { event: "ready", data: { message: user, aiReply: pending } }, + { event: "run_linked", data: { messageId: reply.id, linkedRunId: runId } }, + { event: "delta", data: { messageId: reply.id, body: "Writing the four files.", delta: "Writing the four files.", narrationSegments: reply.narrationSegments.slice(0, 1) } }, + { event: "done", data: { message: user, aiReply: reply } }, + ]; + await route.fulfill({ status: 200, contentType: "text/event-stream", + body: events.map(event => `event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`).join("") }); + }); + return { + state, + settle, + sendCount: () => sendCount, + workspaceBlocked: () => workspaceBlocked, + releaseWorkspace() { holdWorkspace = false; workspaceWaiters.forEach(release => release()); workspaceWaiters.clear(); }, + hold() { holdCanonical = true; canonicalBlocked = false; }, + blocked() { return canonicalBlocked; }, + release() { holdCanonical = false; canonicalWaiters.forEach(release => release()); canonicalWaiters.clear(); }, + }; +} + +async function readFileView(page: Page) { + return page.locator("[data-conversation-response-files]").evaluate(element => ({ + responseId: element.getAttribute("data-conversation-response-id"), + fileCount: element.querySelectorAll(".conversation-response-file").length, + snapshots: JSON.parse(localStorage.getItem("ling-stack.conversation-reading.v1") ?? "null"), + })); +} + +for (const initiallyEmpty of [false, true]) { +test(`first private live file expansion survives Diff and a delayed canonical remount${initiallyEmpty ? " before workspace identity arrives" : ""}`, async ({ page }, testInfo) => { + test.setTimeout(45_000); + await page.addInitScript(() => localStorage.setItem("ling-stack.locale", "en-US")); + const fixture = await installPrivateFilesFixture(page, initiallyEmpty); + await page.goto("/"); + const sidebar = page.getByRole("complementary", { name: /Sidebar display/ }); + const openPrivate = () => sidebar.getByRole("button", { name: "Files Reader", exact: true }).click(); + await openPrivate(); + const feed = page.getByLabel("Private message feed", { exact: true }); + if (initiallyEmpty) { + await expect.poll(() => fixture.workspaceBlocked()).toBe(true); + const composer = page.getByLabel("Private chat composer", { exact: true }); + await composer.getByRole("textbox").fill("Write four files"); + await composer.getByRole("button", { name: "Send", exact: true }).click(); + await expect.poll(() => fixture.sendCount()).toBe(1); + } else { + await expect(feed).toContainText("Writing the four files."); + fixture.settle(); + await triggerFocusRevalidation(page); + } + const files = feed.locator("[data-conversation-response-files]"); + await expect(files.locator(".conversation-response-file")).toHaveCount(3); + await files.locator(".conversation-response-files-more").click(); + await expect(files.locator(".conversation-response-file")).toHaveCount(4); + const before = await readFileView(page); + await files.getByRole("button", { name: "d.txt", exact: true }).click(); + const dock = page.getByLabel("Private chat workspace dock"); + await expect(dock).toContainText("content for d.txt"); + await dock.getByRole("button", { name: "Close", exact: true }).click(); + await expect(dock).toHaveCount(0); + fixture.hold(); + await sidebar.getByRole("button", { name: /Open recent conversation \d+/ }).filter({ hasText: "Ordinary reading" }).click(); + await expect(page.getByLabel("Conversation page")).toBeVisible(); + fixture.releaseWorkspace(); + await openPrivate(); + await expect.poll(() => fixture.blocked()).toBe(true); + fixture.release(); + await expect(files).toBeVisible(); + const after = await readFileView(page); + const evidence = testInfo.outputPath("first-live-and-canonical-return.json"); + await mkdir(dirname(evidence), { recursive: true }); + await writeFile(evidence, JSON.stringify({ initiallyEmpty, before, after }, null, 2)); + await testInfo.attach("first-live-and-canonical-return.json", { path: evidence, contentType: "application/json" }); + expect(after.responseId).toBe(before.responseId); + await expect(files.locator(".conversation-response-file")).toHaveCount(4); + await expect(files.locator(".conversation-response-files-more")).toHaveAttribute("aria-expanded", "true"); + const expectedScope = JSON.stringify(["http://127.0.0.1:17891/", cwd, `teammate:${teammateId}`, directThreadId]); + for (const view of [before, after]) { + const entries = view.snapshots?.entries as Array<[string, { disclosures: unknown[] }]> | undefined; + expect(entries?.find(([scope]) => scope === expectedScope)?.[1].disclosures) + .toContainEqual(["block", `response-files:${before.responseId}`, true]); + } + // The early fallback must not overwrite a later explicit user preference. + await files.locator(".conversation-response-files-more").click(); + await expect(files.locator(".conversation-response-file")).toHaveCount(3); + await sidebar.getByRole("button", { name: /Open recent conversation \d+/ }).filter({ hasText: "Ordinary reading" }).click(); + await expect(page.getByLabel("Conversation page")).toBeVisible(); + await openPrivate(); + await expect(files.locator(".conversation-response-file")).toHaveCount(3); + await expect(files.locator(".conversation-response-files-more")).toHaveAttribute("aria-expanded", "false"); + await files.locator(".conversation-response-files-more").click(); + await page.reload(); + await openPrivate(); + await expect(files.locator(".conversation-response-file")).toHaveCount(4); + expect((await readFileView(page)).responseId).toBe(before.responseId); +}); +} diff --git a/apps/web/e2e/conversation-reading-persistence.spec.ts b/apps/web/e2e/conversation-reading-persistence.spec.ts new file mode 100644 index 000000000..4f2fcc1bb --- /dev/null +++ b/apps/web/e2e/conversation-reading-persistence.spec.ts @@ -0,0 +1,118 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; +import type { ReadingHarness } from "./support/p2-reading-harness"; + +const storageKey = "ling-stack.conversation-reading.v1"; + +async function mount(page: Page, navigate = true) { + if (navigate) await page.goto("/e2e/support/p2-reading.html?manual"); + await page.evaluate(async () => { + const path = "/e2e/support/p2-reading-harness.tsx"; + const fixture = await import(/* @vite-ignore */ path); + window.p2ReadingHarness = fixture.mountReadingFixture(120) as ReadingHarness; + }); + const feed = page.getByLabel("Conversation messages", { exact: true }); + await expect(feed).toBeVisible(); + await expect.poll(() => feed.evaluate(el => el.scrollHeight - el.clientHeight)).toBeGreaterThan(1_000); + return feed; +} + +async function anchor(feed: Locator) { + return feed.evaluate(el => { + const rect = el.getBoundingClientRect(); + const row = Array.from(el.querySelectorAll("[data-conversation-row-key]")) + .find(row => row.getBoundingClientRect().bottom > rect.top + 12 && row.getBoundingClientRect().top < rect.bottom - 12)!; + return { key: row.dataset.conversationRowKey!, offset: row.getBoundingClientRect().top - rect.top }; + }); +} + +async function expectRestored(feed: Locator, saved: Awaited>) { + await expect.poll(() => feed.evaluate((el, saved) => { + const row = Array.from(el.querySelectorAll("[data-conversation-row-key]")) + .find(row => row.dataset.conversationRowKey === saved.key); + return row ? Math.abs(row.getBoundingClientRect().top - el.getBoundingClientRect().top - saved.offset) : Infinity; + }, saved)).toBeLessThanOrEqual(2); +} + +async function readHistory(page: Page, feed: Locator) { + await expect.poll(() => feed.evaluate(el => el.scrollHeight - el.clientHeight - el.scrollTop)).toBeLessThanOrEqual(2); + await feed.evaluate(el => { + el.style.overflowAnchor = "none"; + el.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -240 })); + el.scrollTop = el.scrollHeight * 0.4; + }); + await expect(page.getByRole("button", { name: "Jump to latest message" })).toBeVisible(); + await expect.poll(() => page.evaluate(key => { + const data = JSON.parse(localStorage.getItem(key) ?? "null"); + return data?.entries?.some((entry: [string, { viewport?: { following: boolean } }]) => entry[1].viewport?.following === false); + }, storageKey)).toBe(true); + return anchor(feed); +} + +test("virtualized reading position returns after A to B to A and a page reload", async ({ page }) => { + const feed = await mount(page); + const saved = await readHistory(page, feed); + await page.evaluate(() => window.p2ReadingHarness.switchThread()); + await expect.poll(() => feed.evaluate(el => el.scrollHeight - el.clientHeight - el.scrollTop)).toBeLessThanOrEqual(2); + await page.evaluate(() => window.p2ReadingHarness.returnToThread()); + await expectRestored(feed, saved); + await page.reload(); + await mount(page, false); + await expectRestored(feed, saved); +}); + +test("same thread in another workspace starts at latest and preserves the first workspace reader", async ({ page }) => { + const feed = await mount(page); + const saved = await readHistory(page, feed); + await page.evaluate(() => window.p2ReadingHarness.workspace("/different-workspace")); + await expect.poll(() => feed.evaluate(el => el.scrollHeight - el.clientHeight - el.scrollTop)).toBeLessThanOrEqual(2); + await page.evaluate(() => window.p2ReadingHarness.workspace("/p2-fixture")); + await expectRestored(feed, saved); +}); + +test("explicit latest replaces saved history and survives reload", async ({ page }) => { + const feed = await mount(page); + await readHistory(page, feed); + await page.getByRole("button", { name: "Jump to latest message" }).click(); + await expect.poll(() => feed.evaluate(el => el.scrollHeight - el.clientHeight - el.scrollTop)).toBeLessThanOrEqual(2); + await page.reload(); + await mount(page, false); + await expect.poll(() => feed.evaluate(el => el.scrollHeight - el.clientHeight - el.scrollTop)).toBeLessThanOrEqual(2); + await expect(page.getByRole("button", { name: "Jump to latest message" })).toHaveCount(0); +}); + +test("a missing saved row and corrupt storage fall back without blocking reading", async ({ page }) => { + const feed = await mount(page); + await readHistory(page, feed); + await page.evaluate(key => { + // Stop the real writer before injecting a stale snapshot; pagehide would + // otherwise correctly replace the test's edited storage with live state. + window.p2ReadingHarness.dispose(); + const data = JSON.parse(localStorage.getItem(key)!); + for (const entry of data.entries) if (entry[1].viewport?.anchor) entry[1].viewport.anchor.rowKey = "deleted-row"; + localStorage.setItem(key, JSON.stringify(data)); + }, storageKey); + await page.reload(); + await mount(page, false); + await expect.poll(() => feed.evaluate(el => el.scrollHeight - el.clientHeight - el.scrollTop)).toBeLessThanOrEqual(2); + await page.evaluate(key => { + window.p2ReadingHarness.dispose(); + localStorage.setItem(key, "{broken"); + }, storageKey); + await page.reload(); + await mount(page, false); + await expect.poll(() => feed.evaluate(el => el.scrollHeight - el.clientHeight - el.scrollTop)).toBeLessThanOrEqual(2); +}); + +test("another window saving latest does not move an active history reader", async ({ page, context }) => { + const feed = await mount(page); + const saved = await readHistory(page, feed); + const other = await context.newPage(); + const otherFeed = await mount(other); + await expectRestored(otherFeed, saved); + await other.getByRole("button", { name: "Jump to latest message" }).click(); + await expect.poll(() => otherFeed.evaluate(el => el.scrollHeight - el.clientHeight - el.scrollTop)).toBeLessThanOrEqual(2); + await expectRestored(feed, saved); + await page.evaluate(() => window.p2ReadingHarness.append()); + await expectRestored(feed, saved); + await other.close(); +}); diff --git a/apps/web/e2e/conversation-terminal-report-display.spec.ts b/apps/web/e2e/conversation-terminal-report-display.spec.ts index 5daea3849..b8764070a 100644 --- a/apps/web/e2e/conversation-terminal-report-display.spec.ts +++ b/apps/web/e2e/conversation-terminal-report-display.spec.ts @@ -1,10 +1,16 @@ import { expect, test } from "@playwright/test"; import { mockDaemon } from "./support/mock-daemon"; -import { createBaseState } from "./support/mock-state"; +import { createBaseState, cwd } from "./support/mock-state"; import { attachCommittedChannelReportItem, completedTerminalReport, installChannelWorkCellFixture, installNormalWorkCellFixture, installTeammateWorkCellFixture } from "./support/conversation-work-cell-fixture"; import { openEngineeringChannel } from "./support/test-helpers"; // API fixtures exercise production adapters/renderers; they are not native QA. +// The 2026-10-27 execution-detail ownership contract replaces the old full-report ending. +test.beforeEach(async ({ page }) => { + // The channel read boundary rejects an empty cwd; own the synthetic workspace + // instead of depending on the developer's VITE default or persisted browser state. + await page.addInitScript((workspaceCwd) => localStorage.setItem("ling-stack.workspaceCwd", workspaceCwd), cwd); +}); for (const surface of ["normal", "private", "channel"] as const) { for (const status of ["succeeded", "failed", "blocked", "cancelled", "partially_succeeded"] as const) { test(`${surface} ${status} separates final narration from report evidence`, async ({ page }, testInfo) => { @@ -16,7 +22,20 @@ for (const surface of ["normal", "private", "channel"] as const) { const teammate = surface === "private" ? installTeammateWorkCellFixture(state) : undefined; if (surface === "channel") { const { threadId, runId } = installChannelWorkCellFixture(state, "report-display"); - const turnId = state.turnRunLinks.find((link) => link.runId === runId)!.turnId; + const originalTurnId = state.turnRunLinks.find((link) => link.runId === runId)!.turnId; + const sourceMessage = state.channelMessages.find((message) => message.linkedRunId === runId)!; + // The mock channel API derives canonical user-item turns from message ids. + // Keep the WorkCell, Run and published report on that exact same turn; + // production deliberately rejects a different turn in the same thread. + const turnId = `turn_${sourceMessage.id}`; + // Run/Turn do not have a partially_succeeded state. This fixture retains + // successful command evidence, while the failed Run/Turn reports partial work. + const runStatus = status === "blocked" || status === "partially_succeeded" ? "failed" : status; + const turnStatus = status === "partially_succeeded" ? "failed" : status; + state.runs = state.runs.map((run) => run.id === runId ? { ...run, turnId, status: runStatus } : run); + state.turns = state.turns.map((turn) => turn.id === originalTurnId ? { ...turn, id: turnId, status: turnStatus } : turn); + state.turnRunLinks = state.turnRunLinks.map((link) => link.runId === runId ? { ...link, turnId } : link); + state.threads = state.threads.map((thread) => thread.id === threadId ? { ...thread, status: turnStatus } : thread); state.terminalReports = [{ ...completedTerminalReport({ id: "report-channel-display", threadId, turnId, runId, title: "Completed" }), channelId: "channel_engineering" }]; } const original = state.terminalReports[0]!; @@ -35,7 +54,7 @@ for (const surface of ["normal", "private", "channel"] as const) { } state.terminalReports = [{ ...original, outcome: { ...original.outcome, status, summary: "REPORT_SECOND_SUMMARY_SENTINEL" }, - verification: [{ id: "check-display", title: "Verified command", status: "passed", command: "node --test", sourceRefs: [] }], + verification: [{ id: "check-display", title: "Verified command", status: "passed", command: "node --test", durationMs: 12345, sourceRefs: [] }], }]; state.conversationItems = state.conversationItems?.map((item) => item.id === original.resultItemId ? { ...item, payload: { ...item.payload, outcomeStatus: status } } : item); @@ -68,8 +87,13 @@ for (const surface of ["normal", "private", "channel"] as const) { } else { const detail = rows.locator('[data-conversation-timeline-kind="terminalReport"]'); await expect(detail).toBeVisible(); - await expect(detail.locator(".conversation-timeline-activity-main")).toHaveAttribute("aria-expanded", "true"); - await expect(detail).toContainText("REPORT_SECOND_SUMMARY_SENTINEL"); + await expect(detail).toHaveAttribute("data-conversation-timeline-status", status); + await expect(detail.locator(".conversation-timeline-activity-detail")).toHaveCount(0); + await expect(detail).not.toContainText("REPORT_SECOND_SUMMARY_SENTINEL"); + await expect(detail).not.toContainText("Execution details"); + await expect(detail).not.toContainText("Verified command"); + await expect(detail).not.toContainText("node --test"); + await expect(detail).not.toContainText("12.3s"); } await expect(rows.filter({ hasText: "generation 1" })).toHaveCount(0); const process = rows.locator('[data-conversation-process-header][aria-expanded="false"]'); @@ -107,7 +131,7 @@ for (const attention of ["skipped-check", "pending-risk"] as const) { const report = page.locator('[data-conversation-terminal-response]').first(); await expect(report).toBeVisible(); await expect(report.locator('.conversation-timeline-activity-main')).toHaveAttribute("aria-expanded", "true"); - await expect(report).toContainText("Needs attention"); + await expect(report).toContainText("Open Issues"); await expect(report).toContainText(attention === "skipped-check" ? "Manual smoke not run" : "Approval still needed"); await expect(report.locator('[data-conversation-timeline-status]')).toHaveAttribute("data-conversation-timeline-status", "succeeded"); await page.screenshot({ path: testInfo.outputPath(`${attention}-retained.png`) }); diff --git a/apps/web/e2e/desktop-channel-bootstrap.regression-1.spec.ts b/apps/web/e2e/desktop-channel-bootstrap.regression-1.spec.ts new file mode 100644 index 000000000..e2ee21b8b --- /dev/null +++ b/apps/web/e2e/desktop-channel-bootstrap.regression-1.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from "@playwright/test"; +import { mockDaemon } from "./support/mock-daemon"; +import { createBaseState, cwd, isRecord } from "./support/mock-state"; + +// Regression: E6-CHANNEL-BOOT — desktop startup discarded the old observation +// without a fresh committed-owner read. API fixture, not packaged macOS evidence. +// Found by /qa on 2026-10-28 +// Report: output/e6-20260928/channel-policy-cold-start-empty-channels-root-cause-20261028.md +test("desktop cold start and reload hydrate existing channels without navigation or focus", async ({ page }, testInfo) => { + await page.addInitScript(() => { + localStorage.setItem("ling-stack.locale", "en-US"); + localStorage.setItem("ling-stack.workspaceCwd", "/tmp/stale-browser-workspace"); + Object.defineProperty(window, "lingDesktop", { configurable: true, value: { + platform: "macos", daemonBaseUrl: "http://127.0.0.1:17891", + daemon: { onStatusChanged(listener: (status: unknown) => void) { + listener({ state: "connected", baseUrl: "http://127.0.0.1:17891", ownedByDesktop: true }); + return () => {}; + } }, + } }); + }); + const state = createBaseState({ approvalStatus: "expired" }); + const engineering = state.channels.find(channel => isRecord(channel) && channel.name === "Engineering"); + expect(isRecord(engineering)).toBe(true); + state.channels.push({ ...engineering as Record, id: "channel_boot_existing", name: "Existing Bootstrap Channel" }); + const initialRuns = state.runs.length, mutations: string[] = [], pageErrors: string[] = []; + page.on("pageerror", error => pageErrors.push(error.message)); + page.on("request", request => { + if (request.method() === "POST" && ["/channels", "/runs", "/conversations/turns"].includes(new URL(request.url()).pathname)) { + mutations.push(request.url()); + } + }); + await mockDaemon(page, state, { defaultWorkspaceCwd: cwd }); + let healthReads = 0; + let releaseLateHealth!: () => void; + let lateHealthReady = new Promise(resolve => { releaseLateHealth = resolve; }); + let markLateHealthReturned!: () => void; + let lateHealthReturned = new Promise(resolve => { markLateHealthReturned = resolve; }); + await page.route(url => url.origin === "http://127.0.0.1:17891" && url.pathname === "/health", async route => { + const index = ++healthReads; + if (index === 2) await lateHealthReady; + await route.fallback(); + if (index === 2) markLateHealthReturned(); + }); + let release!: () => void; + let ready = new Promise(resolve => { release = resolve; }); + const reads: string[] = []; + await page.route(url => url.origin === "http://127.0.0.1:17891" && url.pathname === "/channels", async route => { + reads.push(new URL(route.request().url()).searchParams.get("cwd") ?? ""); + await ready; + await route.fallback(); + }); + for (const phase of ["cold-start", "reload"]) { + if (phase === "cold-start") await page.goto("/"); + else { + ready = new Promise(resolve => { release = resolve; }); + healthReads = 0; + lateHealthReady = new Promise(resolve => { releaseLateHealth = resolve; }); + lateHealthReturned = new Promise(resolve => { markLateHealthReturned = resolve; }); + await page.reload(); + } + await expect.poll(() => healthReads).toBe(2); + expect(pageErrors).toEqual([]); + await expect.poll(() => reads.length).toBe(phase === "cold-start" ? 1 : 2); + expect(reads.at(-1)).toBe(cwd, "real committed-owner request, not preinitialized mock settings"); + release(); + await expect(page.getByRole("button", { name: "Engineering", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Existing Bootstrap Channel", exact: true })).toBeVisible(); + // StrictMode's second startup effect completes only after hydration. Let + // fetch continuations and React's paint run, not an arbitrary sleep. + releaseLateHealth(); + await lateHealthReturned; + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.getByRole("button", { name: "Engineering", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Existing Bootstrap Channel", exact: true })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath(`${phase}-existing-channels.png`) }); + } + expect(reads).toEqual([cwd, cwd]); + expect(mutations).toEqual([]); + expect(state.runs).toHaveLength(initialRuns); + expect(pageErrors).toEqual([]); +}); diff --git a/apps/web/e2e/p2-surface-reading.spec.ts b/apps/web/e2e/p2-surface-reading.spec.ts index 9633d8fe1..8a905e6b2 100644 --- a/apps/web/e2e/p2-surface-reading.spec.ts +++ b/apps/web/e2e/p2-surface-reading.spec.ts @@ -159,7 +159,7 @@ test("private virtualized prepend retains the visible row instead of a height es } }); -async function openChannelFixture(page: Page, surface: "channel_root" | "branch") { +async function openChannelFixture(page: Page, surface: "channel_root" | "branch", messageCount = 40) { await page.addInitScript(() => localStorage.setItem("ling-stack.locale", "en-US")); const state = createBaseState({ approvalStatus: "expired" }); state.channelMessages = []; state.runs = []; state.tasks = []; state.approvals = []; @@ -177,30 +177,82 @@ async function openChannelFixture(page: Page, surface: "channel_root" | "branch" state.channelBranches = [{ id: "p2-branch", workspaceId, channelId, rootThreadId: threadId, name: "P2 Branch", status: "active", createdAt: now, updatedAt: now }]; } - const items = Array.from({ length: 40 }, (_, index) => ({ + const items = Array.from({ length: messageCount }, (_, index) => ({ schemaVersion: 1, workspaceId, channelId, threadId, turnId: `p2-turn-${index}`, id: `p2-channel-${index}`, sequence: index + 1, kind: "assistant_message", actorType: "teammate", actorId: "engineer", visibility: "channel", status: "committed", revision: 1, - domainRefs: [], createdAt: now, updatedAt: now, committedAt: now, + domainRefs: [], createdAt: new Date(Date.UTC(2026, 9, 14, 0, index)).toISOString(), + updatedAt: new Date(Date.UTC(2026, 9, 14, 0, index)).toISOString(), committedAt: now, payload: { body: `Reading history ${index}\n\n${"Stable channel reading position. ".repeat(20)}`, format: "markdown", deliveryStatus: "ready", mentions: [], attachmentIds: [], linkPreviewIds: [], fileRefs: [] }, })); await mockDaemon(page, state); await page.route("**/channels/channel_engineering/items**", async route => { - const selected = new URL(route.request().url()).searchParams.get("threadId") ?? rootId; + const query = new URL(route.request().url()).searchParams; + const selected = query.get("threadId") ?? rootId; if (selected !== threadId) return route.fallback(); - await route.fulfill({ json: { cwd, channelId, thread, items, turns: [], turnContextAssignments: [], - userInputs: [], decorations: [], generatedMedia: [], hasMore: false } }); + const before = Number(query.get("beforeSequence") ?? Infinity); + const limit = Number(query.get("limit") ?? 200); + const eligible = items.filter(item => item.sequence < before); + const pageItems = eligible.slice(-limit); + const hasMore = eligible.length > pageItems.length; + await route.fulfill({ json: { cwd, channelId, thread, items: pageItems, turns: [], turnContextAssignments: [], + userInputs: [], decorations: [], generatedMedia: [], hasMore, + nextBeforeSequence: hasMore ? pageItems[0]?.sequence : undefined } }); }); await page.goto("/"); await page.getByRole("complementary", { name: /Sidebar display/ }) .getByRole("button", { name: surface === "branch" ? "P2 Branch" : "Engineering", exact: true }).click(); const feed = page.getByLabel("Channel message feed", { exact: true }); - await expect(feed.getByText(/Reading history 39\b/)).toBeAttached(); + await expect(feed.getByText(new RegExp(`Reading history ${messageCount - 1}\\b`))).toBeAttached(); await settleScroll(feed); return feed; } +test("private saved reader loads older history after a page reload", async ({ page }) => { + const { feed } = await openPrivateFixture(page, 130); + await feed.evaluate(el => { + el.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -240 })); + el.scrollTop = 60; + }); + await expect(feed.getByText(/Reading history 0\b/)).toBeAttached(); + await settleScroll(feed); + await feed.evaluate(el => { + el.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -120 })); + el.scrollTop = 200; + }); + await settleScroll(feed); + const before = await readAnchor(feed); + await page.reload(); + const navigation = page.getByRole("complementary", { name: /Sidebar display/ }); + await navigation.getByRole("button", { name: "P2 Reader", exact: true }).click(); + await expect(feed).toBeVisible(); + await expect.poll(async () => Math.abs(await anchorOffset(feed, before.key) - before.offset)).toBeLessThanOrEqual(2); +}); + +for (const surface of ["channel_root", "branch"] as const) { + test(`${surface} saved reader reloads an older page and retains row offset`, async ({ page }) => { + const feed = await openChannelFixture(page, surface, 240); + await feed.evaluate(el => { + el.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -240 })); + el.scrollTop = 60; + }); + await expect(feed.getByText(/Reading history 0\b/)).toBeAttached(); + await settleScroll(feed); + await feed.evaluate(el => { + el.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -120 })); + el.scrollTop = 200; + }); + await settleScroll(feed); + const before = await readAnchor(feed); + await page.reload(); + await page.getByRole("complementary", { name: /Sidebar display/ }) + .getByRole("button", { name: surface === "branch" ? "P2 Branch" : "Engineering", exact: true }).click(); + await expect(feed).toBeVisible(); + await expect.poll(async () => Math.abs(await anchorOffset(feed, before.key) - before.offset)).toBeLessThanOrEqual(2); + }); +} + for (const surface of ["channel_root", "branch"] as const) { test(`${surface} reader retains row offset through content and viewport resize`, async ({ page }) => { const feed = await openChannelFixture(page, surface); @@ -267,6 +319,12 @@ test("topic reader follows the tail, preserves reading reflow, and resumes expli await expect.poll(async () => Math.abs(await anchorOffset(feed, before.key) - before.offset)).toBeLessThanOrEqual(2); await mkdir(evidenceDir, { recursive: true }); await page.screenshot({ path: `${evidenceDir}topic-reading-reflow.png`, animations: "disabled" }); + await panel.getByRole("button", { name: "Close topic", exact: true }).click(); + await expect(panel).toHaveCount(0); + await root.hover(); + await root.getByRole("button", { name: "Reply in topic", exact: true }).click(); + await expect(panel).toBeVisible(); + await expect.poll(async () => Math.abs(await anchorOffset(feed, before.key) - before.offset)).toBeLessThanOrEqual(2); await panel.getByRole("button", { name: "Jump to latest message" }).click(); await expect.poll(() => feed.evaluate(el => el.scrollHeight - el.clientHeight - el.scrollTop)).toBeLessThanOrEqual(2); await panel.getByRole("button", { name: "Close topic", exact: true }).click(); diff --git a/apps/web/e2e/private-approval-live-context.spec.ts b/apps/web/e2e/private-approval-live-context.spec.ts new file mode 100644 index 000000000..7dfca2038 --- /dev/null +++ b/apps/web/e2e/private-approval-live-context.spec.ts @@ -0,0 +1,75 @@ +import { expect, test } from "@playwright/test"; +import type { TeammateDirectMessage } from "@ling-stack/contracts"; +import { installTeammateWorkCellFixture } from "./support/conversation-work-cell-fixture"; +import { mockDaemon, cwd } from "./support/mock-daemon"; +import { createBaseState } from "./support/mock-state"; +import { createApproval } from "./support/approvals-fixture"; + +for (const scenario of [ + { missing: "work-unit" }, + { missing: "run-and-work-unit" }, + { missing: "run-and-work-unit", invalid: "timeline-owner" }, + { missing: "run-and-work-unit", invalid: "work-unit-owner" }, +] as const) { +test(`private approval with ${scenario.missing} absent and ${"invalid" in scenario ? scenario.invalid : "owned"} live context`, async ({ page }, testInfo) => { + await page.addInitScript(() => localStorage.setItem("ling-stack.locale", "en-US")); + const state = createBaseState({ approvalStatus: "expired" }); + const owner = installTeammateWorkCellFixture(state); + const run = state.runs[0]!; + const messages = state.teammateDirectMessages as TeammateDirectMessage[]; + const assistant = messages.find(message => message.authorType === "ai-teammate")!; + const user = messages.find(message => message.authorType === "local-user")!; + Object.assign(run, { workUnitId: "private-live-unit", status: "waiting_for_approval", endedAt: undefined }); + state.workUnits = [{ + id: run.workUnitId!, workspaceId: user.workspaceId, kind: "teammate_direct_reply", + ownerType: "teammate_direct_message", ownerId: user.id, teammateId: owner.teammateId, + linkedRunId: run.id, linkedMessageId: assistant.id, idempotencyKey: "private-live-unit", + status: "waiting_for_approval", priority: 0, attemptCount: 1, createdByActor: "local-user", + request: { threadId: run.threadId, sourceMessageId: user.id }, timeoutPolicy: {}, + createdAt: run.createdAt, updatedAt: run.updatedAt, + }]; + state.terminalReports = []; + state.executionActions = []; + if ("invalid" in scenario && scenario.invalid === "timeline-owner") { + const thread = state.threads.find(thread => thread.id === run.threadId)!; + thread.teammateId = "another-teammate"; + } + if ("invalid" in scenario && scenario.invalid === "work-unit-owner") { + state.workUnits[0]!.teammateId = "another-teammate"; + } + const approval = createApproval({ id: "private-live-approval", runId: run.id, actionType: "write", + channelId: undefined, payload: { type: "file_write", path: "proof/cents.mjs" }, + availableDecisions: ["approve_once", "deny"] }); + state.approvals = [approval]; + await mockDaemon(page, state); + // The private endpoint retains the current Host snapshot. Only global list + // reads are stale, as when this task starts after application bootstrap. + await page.route(url => url.pathname === "/work-units", route => route.fulfill({ json: { cwd, workUnits: [] } })); + if (scenario.missing === "run-and-work-unit") { + await page.route(url => url.pathname === "/runs" && url.searchParams.has("cwd"), + route => route.fulfill({ json: { cwd, runs: [] } })); + } + await page.goto("/"); + const runtimeRead = page.waitForResponse(response => new URL(response.url()).pathname === `/teammates/${owner.teammateId}/direct-runtime`); + await page.getByRole("button", { name: owner.displayName, exact: true }).click(); + await runtimeRead; + const gate = page.getByRole("region", { name: "Allow this action?", exact: true }); + if ("invalid" in scenario) { + await expect(page.getByLabel("Private chat composer", { exact: true })).toBeVisible(); + await expect(page.getByText(user.body, { exact: true })).toBeVisible(); + await expect(gate).toHaveCount(0); + expect(state.approvalDecisionRequests).toHaveLength(0); + return; + } + await expect(gate).toHaveCount(1); + await expect(gate).toHaveAttribute("data-surface", "teammate_direct"); + await expect(gate).toContainText("proof/cents.mjs"); + await expect(gate.getByRole("button", { name: "Allow once", exact: true })).toBeEnabled(); + await expect(gate.getByRole("button", { name: "Deny", exact: true })).toBeEnabled(); + await page.screenshot({ path: testInfo.outputPath("owned-private-pending.png") }); + await gate.getByRole("button", { name: "Allow once", exact: true }).click(); + await expect.poll(() => approval.status).toBe("approved"); + await expect(gate).toHaveCount(0); + expect(state.approvalDecisionRequests).toHaveLength(1); +}); +} diff --git a/apps/web/e2e/support/command-detail-harness.tsx b/apps/web/e2e/support/command-detail-harness.tsx new file mode 100644 index 000000000..d32ffa534 --- /dev/null +++ b/apps/web/e2e/support/command-detail-harness.tsx @@ -0,0 +1,58 @@ +import { createRoot } from "react-dom/client"; +import type { ConversationTimelineCommandExecutionItem } from "@ling-stack/conversation-ui-core"; +import { I18nProvider } from "../../src/i18n.js"; +import { ConversationViewProvider } from "../../src/shared/conversation/view-store/conversation-view-provider.js"; +import { ConversationTimelineCommandRenderer } from "../../src/shared/conversation/timeline/ConversationTimelineCommandRenderer.js"; +import { ConversationTimelineResponseFiles } from "../../src/shared/conversation/timeline/ConversationTimelineResponseFiles.js"; +import type { ConversationTimelineResponse } from "../../src/shared/conversation/timeline/timeline-response-presentation.js"; + +/** Production command renderer; callback receipt is a fixture, not a Host launch. */ +export function mountCommandDetailFixture(input: Partial) { + const container = document.createElement("main"); + container.style.cssText = "padding:24px;max-width:700px"; + container.dataset.conversationTimeline = "true"; + document.body.append(container); + const root = createRoot(container); + let item: ConversationTimelineCommandExecutionItem = { + id: "command-detail", kind: "commandExecution", title: "Run command", revision: 1, + threadSequence: 1, partIndex: 0, status: "succeeded", turnId: "turn-detail", + runId: "run-detail", actionId: "action-detail", participantId: "ling-agent", + command: "node --test", risk: "low", sourceRefs: [], outputTail: [], outputTruncated: false, + ...input, + }; + const opened: string[] = []; + const render = () => root.render( + { + opened.push(JSON.stringify({ ref, actionId: owner?.actionId, runId: owner?.runId })); + }} /> + ); + render(); + return { opened: () => opened, + update: (patch: Partial) => { item = { ...item, ...patch }; render(); }, + dispose: () => { root.unmount(); container.remove(); } }; +} + +declare global { interface Window { commandDetailHarness: ReturnType; } } + +export function mountResponseFilesFixture() { + const container = document.createElement("main"); + document.body.append(container); + const root = createRoot(container); + const response: ConversationTimelineResponse = { + id: "response-files-fixture", messages: [], copyText: "", logicalMessageCount: 0, settled: true, + items: [{ id: "files-change", kind: "fileChange", revision: 1, threadSequence: 1, partIndex: 0, + status: "succeeded", actionId: "action-files", title: "Edit files", sourceRefs: [], + files: Array.from({ length: 5 }, (_, index) => ({ kind: "modified", fileRef: { + kind: "file", id: `file-${index}`, path: `src/changed-${index}.ts`, visibility: "conversation", + } })), + }], + }; + const render = (visible: boolean) => root.render( + + {visible ? : null} + + ); + render(true); + return { show: render, dispose: () => { root.unmount(); container.remove(); } }; +} +declare global { interface Window { responseFilesHarness: ReturnType; } } diff --git a/apps/web/e2e/support/mock-daemon.ts b/apps/web/e2e/support/mock-daemon.ts index 5e22bf6dd..fc542a98e 100644 --- a/apps/web/e2e/support/mock-daemon.ts +++ b/apps/web/e2e/support/mock-daemon.ts @@ -8051,6 +8051,13 @@ export async function handleDaemonRoute( await route.fulfill({ json: { cwd, settings: state.workspaceSettings } }); return; } + if (request.method() === "GET" && path === "/workspace/work-summary") { + await route.fulfill({ json: { + cwd, sessions: [], workspaces: [], pullRequests: [], truncated: false, + checkedAt: "2026-10-29T00:00:00.000Z", + } }); + return; + } if (request.method() === "POST" && path === "/workspace-files/capabilities") { const body = await request.postDataJSON() as unknown; const issued = issueMockWorkspaceFileCapability(state, body); diff --git a/apps/web/e2e/support/p2-reading-harness.tsx b/apps/web/e2e/support/p2-reading-harness.tsx index 3b21fb646..a25a3100d 100644 --- a/apps/web/e2e/support/p2-reading-harness.tsx +++ b/apps/web/e2e/support/p2-reading-harness.tsx @@ -19,6 +19,8 @@ export function mountReadingFixture(messageCount = 36) { document.body.append(container); const root = createRoot(container); let threadId = "p2-reading-thread"; + let workspaceCwd = "/p2-fixture"; + let workspaceCwd = "/p2-fixture"; let searchActive = false; let messages: readonly ConversationHomeMessage[] = Array.from({ length: messageCount }, (_, index) => ({ id: `reading-message-${index}`, @@ -33,7 +35,7 @@ export function mountReadingFixture(messageCount = 36) { root.render( { + threadId = "p2-reading-thread"; + messages = messages.map(message => ({ ...message, id: message.id.replace(/^other-/, ""), threadId })); + searchActive = false; + render(); + }, + workspace: (cwd: string) => { workspaceCwd = cwd; render(); }, + returnToThread: () => { + threadId = "p2-reading-thread"; + messages = messages.map(message => ({ ...message, id: message.id.replace(/^other-/, ""), threadId })); + searchActive = false; + render(); + }, + workspace: (cwd: string) => { workspaceCwd = cwd; render(); }, dispose: () => { root.unmount(); container.remove(); }, }; } diff --git a/apps/web/e2e/teammates.spec.ts b/apps/web/e2e/teammates.spec.ts index 40a73e6a9..51acf423d 100644 --- a/apps/web/e2e/teammates.spec.ts +++ b/apps/web/e2e/teammates.spec.ts @@ -1192,13 +1192,13 @@ test("AI teammate private workspace sends blocked chat replies and persists sett await expect(privateFeed).toBeVisible(); await expect(privateFeed).toHaveAttribute("data-conversation-feed-window-owner", "timeline"); // Private chat owns one Timeline root. This fixture intentionally omits the - // action/attempt lineage needed to claim command execution, so it must fail + // admitted Run ownership needed to load execution evidence, so it must fail // closed as a diagnostic instead of recreating the legacy WorkCell batch. const privateTimeline = privateFeed.locator('[data-conversation-timeline][data-conversation-timeline-scope="private"]'); await expect(privateTimeline).toHaveCount(1); const lineageDiagnostic = privateTimeline.locator('[data-conversation-timeline-kind="diagnostic"][data-conversation-timeline-status="blocked"]'); await expect(lineageDiagnostic).toHaveCount(1); - await expect(lineageDiagnostic).toContainText("Command batch lineage is incomplete."); + await expect(lineageDiagnostic).toContainText("Execution evidence is missing required lineage."); const linkedRunPrivateRow = page.locator(".teammate-message-row.ai-teammate").filter({ hasText: "Private acceptance criteria summary." }); await expect(linkedRunPrivateRow.getByText("Private acceptance criteria summary.", { exact: true })).toHaveCount(1); await expect(privateTimeline.locator(".conversation-command-batch")).toHaveCount(0); @@ -1281,7 +1281,8 @@ test("AI teammate private workspace sends blocked chat replies and persists sett await expect(pendingPrivateRow).toHaveClass(/message-bubble/); await expect(pendingPrivateRow).toHaveClass(/message-bubble-peer/); await expect(pendingPrivateRow).toHaveClass(/message-bubble-pending/); - await expect(pendingPrivateRow.locator(".message-pending-spinner")).toBeVisible(); + await expect(pendingPrivateRow.getByRole("status")).toHaveText("AI teammate is replying..."); + await expect(pendingPrivateRow.locator(".conversation-activity-label")).toHaveAttribute("data-activity-motion", "running"); await expect(pendingPrivateRow.locator(".status-badge")).toHaveCount(0); await page.locator(".teammate-direct-composer input[type=file]").setInputFiles({ name: "private-notes.md", @@ -1292,12 +1293,12 @@ test("AI teammate private workspace sends blocked chat replies and persists sett await expect(privateToolbar.getByRole("button", { name: "Private attachments: 1" })).toBeVisible(); const privateComposer = page.locator(".teammate-direct-composer"); - await privateComposer.getByRole("button", { name: /Selected permission preset Workspace write/ }).click(); - await page.getByRole("menuitemradio", { name: /Read only/ }).click(); + await privateComposer.getByRole("button", { name: /Selected permission preset/ }).click(); + await page.getByRole("menuitemradio", { name: "Request approval", exact: true }).click(); await page.getByPlaceholder("Message this AI teammate privately").fill("Please refine the private workspace acceptance criteria."); await page.getByPlaceholder("Message this AI teammate privately").press("Enter"); - await expect.poll(() => state.lastTeammateDirectMessageRequest?.authorizationMode).toBe("smart"); - await expect.poll(() => state.lastTeammateDirectMessageRequest?.workspaceAccessMode).toBe("read_only"); + await expect.poll(() => state.lastTeammateDirectMessageRequest?.authorizationMode).toBe("ask"); + await expect.poll(() => state.lastTeammateDirectMessageRequest?.workspaceAccessMode).toBe("workspace_write"); await expect(page.getByText("Please refine the private workspace acceptance criteria.")).toBeVisible(); await expect(privateFeed.getByText("AI teammate is blocked: No provider preset is selected for this teammate.")).toBeVisible(); const privateMessageRow = page.locator(".teammate-message-row").filter({ hasText: "Please refine the private workspace acceptance criteria." }); @@ -1456,11 +1457,13 @@ test("AI teammate private workspace sends blocked chat replies and persists sett await page.getByRole("button", { name: "Back to private chat" }).click(); await expect(page.getByRole("heading", { name: "Renamed Engineer", level: 1 })).toBeVisible(); await privateComposer.getByRole("button", { name: /Selected permission preset/ }).click(); - await page.getByRole("menuitemradio", { name: /Read only/ }).click(); + await page.getByRole("menuitemradio", { name: "Help me approve", exact: true }).click(); await page.getByPlaceholder("Message this AI teammate privately").fill("Use the newly saved model."); await page.getByPlaceholder("Message this AI teammate privately").press("Enter"); await expect(page.getByLabel("Private chat", { exact: true }).getByText("Private reply: I will refine this into a scoped task.")).toBeVisible(); - await expect.poll(() => state.lastTeammateDirectMessageRequest?.permissionPreset).toBe("read_only"); + await expect.poll(() => state.lastTeammateDirectMessageRequest?.authorizationMode).toBe("smart"); + await expect.poll(() => state.lastTeammateDirectMessageRequest?.workspaceAccessMode).toBe("workspace_write"); + await expect.poll(() => state.lastTeammateDirectMessageRequest?.permissionPreset).toBe("workspace_collaboration"); await page.getByRole("complementary", { name: /Sidebar display|侧栏显示/u }).getByRole("button", { name: "Design" }).click(); await openChannelTeammateManager(page); diff --git a/apps/web/e2e/ui2-channel-task-environment.spec.ts b/apps/web/e2e/ui2-channel-task-environment.spec.ts index 059228968..da158e4ce 100644 --- a/apps/web/e2e/ui2-channel-task-environment.spec.ts +++ b/apps/web/e2e/ui2-channel-task-environment.spec.ts @@ -97,11 +97,9 @@ test("channel task detail uses the UI 2.0 inspector contract", async ({ page }) await expect(task.locator(".ling-ui-inspector-section")).toHaveCount(2); await expect(task.locator(".ling-ui-segmented-control")).not.toBeVisible(); await task.getByText("Permissions", { exact: true }).click(); - await expect(task.locator(".ling-ui-segmented-control")).toBeVisible(); - await expect(task.getByRole("radio")).toHaveCount(4); - for (const label of ["Read only", "Workspace write", "Full access", "Custom"]) { - await expect(task.getByRole("radio", { name: label })).toBeVisible(); - } + await expect(task.locator(".ling-ui-segmented-control")).toHaveCount(0); + await expect(task.getByRole("radio")).toHaveCount(0); + await expect(task.getByRole("button", { name: "Sandbox settings", exact: true })).toBeVisible(); await expect(task.getByRole("button", { name: "Run", exact: true })).toHaveClass(/ling-ui-button/u); await expect(task.locator(".ling-ui-inline-feedback[data-tone='danger']")).toBeVisible(); diff --git a/apps/web/e2e/ui2-compact-permission-parity.spec.ts b/apps/web/e2e/ui2-compact-permission-parity.spec.ts new file mode 100644 index 000000000..125727563 --- /dev/null +++ b/apps/web/e2e/ui2-compact-permission-parity.spec.ts @@ -0,0 +1,128 @@ +import { expect, test } from "@playwright/test"; +import { mockDaemon } from "./support/mock-daemon"; +import { createBaseState, createChatProvider, createMockTeammate, isRecord } from "./support/mock-state"; +import { openEngineeringChannel, openTaskFromAttentionQueue } from "./support/test-helpers"; + +for (const [surface, finalMode] of [ + ["normal", "smart"], ["normal", "always"], + ["channel", "smart"], ["channel", "always"], + ["private", "smart"], ["private", "always"], +] as const) { + test(`${surface} has one compact permission control and sends ${finalMode} with its sandbox`, async ({ page }) => { + await page.addInitScript(() => localStorage.setItem("ling-stack.locale", "en-US")); + const state = createBaseState({ approvalStatus: "approved" }); + state.providers = [createChatProvider({ id: "provider_compact_permissions", model: "gpt-5.5", + lastCheck: { ok: true, code: "ready", message: "ready", checkedAt: "2026-10-28T00:00:00.000Z" } })]; + state.workspaceSettings = { ...state.workspaceSettings, defaultProviderPresetId: "provider_compact_permissions", + defaultAuthorizationMode: "ask", defaultWorkspaceAccessMode: "read_only" }; + state.aiTeammates = [createMockTeammate({ id: "compact_permissions_teammate", displayName: "Compact Permissions Teammate", + providerPresetId: "provider_compact_permissions" })]; + state.channels = state.channels.map((channel) => isRecord(channel) + ? { ...channel, defaultProviderPresetId: "provider_compact_permissions" } : channel); + state.tasks = state.tasks.map((task) => isRecord(task) && task.id === "task_inspect" + ? { ...task, status: "todo", currentRunId: undefined } : task); + state.runs = []; + await mockDaemon(page, state); + await page.goto("/"); + const sidebar = page.getByRole("complementary", { name: /Sidebar display|侧栏显示/u }); + if (surface === "normal") await sidebar.getByRole("button", { name: "New conversation", exact: true }).click(); + else if (surface === "channel") await openEngineeringChannel(page); + else await sidebar.getByRole("button", { name: "Compact Permissions Teammate", exact: true }).click(); + const composer = page.getByLabel(surface === "normal" ? "New conversation composer" + : surface === "channel" ? "Channel composer" : "Private chat composer", { exact: true }); + await expect(composer).toBeVisible(); + await expect(composer.locator(".conversation-permission-trigger")).toHaveCount(1); + await expect(composer.getByRole("button", { name: /Selected authorization mode/ })).toHaveCount(0); + const trigger = composer.getByRole("button", { name: /Selected permission preset/ }); + await trigger.click(); + await expect(page.getByRole("menuitemradio", { name: "Request approval", exact: true })).toBeVisible(); + await expect(page.getByRole("menuitemradio", { name: "Help me approve", exact: true })).toBeVisible(); + const fullAccessOption = page.getByRole("menuitemradio", { name: "Full access permissions", exact: true }); + await expect(fullAccessOption).toBeVisible(); + await expect(fullAccessOption).toHaveAttribute("aria-description", "After confirmation, this conversation uses automatic approvals and Full Access. System permissions and Host deny rules still apply."); + await expect(page.getByRole("menuitemradio", { name: /Read.only|Workspace write|Workspace collaboration/i })).toHaveCount(0); + await fullAccessOption.click(); + const dialog = page.getByRole("dialog", { name: "Confirm full access" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText("Confirmation enables automatic approvals and Full Access for this conversation, including access outside the workspace. System permissions and Host deny rules still apply.", { exact: true })).toBeVisible(); + await dialog.getByRole("button", { name: "Cancel", exact: true }).last().click(); + await expect(trigger).toHaveText("Request approval"); + await trigger.click(); + await fullAccessOption.click(); + await dialog.getByRole("button", { name: "Start with full access", exact: true }).click(); + await expect(trigger).toHaveText("Full access"); + await expect(trigger).toHaveAccessibleName(/Confirmed for this conversation/); + if (finalMode === "smart") { + await trigger.click(); + await page.getByRole("menuitemradio", { name: "Help me approve", exact: true }).click(); + await expect(trigger).toHaveText("Help me approve"); + await expect(trigger).toHaveAttribute("title", /Read.only/i); + } + await expect(composer.locator(".conversation-permission-trigger")).toHaveCount(1); + // Inspect the real adapter payload, not only the menu label. All HTTP/SSE + // replies here are mocked; this is not packaged-client or model acceptance. + const expectedPermissions = { authorizationMode: finalMode, + workspaceAccessMode: finalMode === "always" ? "full_access" : "read_only", + ...(finalMode === "always" ? { permissionConfirmation: { confirmed: true, mode: "full_access", reason: "user_confirmed_full_access" } } : {}) }; + if (surface === "channel") { + await openTaskFromAttentionQueue(page, "Inspect workspace"); + await page.getByRole("article", { name: "Task detail", exact: true }).getByRole("button", { name: "Run", exact: true }).click(); + await expect.poll(() => state.runs[0]?.requestSnapshot).toMatchObject(expectedPermissions); + } else { + const request = page.waitForRequest((request) => request.method() === "POST" + && new URL(request.url()).pathname.endsWith(surface === "normal" ? "/conversations/stream" : "/direct-messages/stream")); + await composer.getByRole("textbox").fill(`Inspect ${surface} permission scope`); + await composer.getByRole("button", { name: "Send", exact: true }).click(); + expect((await request).postDataJSON()).toMatchObject(expectedPermissions); + } + }); +} + +for (const viewport of ["desktop", "mobile"] as const) { +test(`Chinese full access (${viewport}) uses a long menu label, a short trigger and explicit permission consequences`, async ({ page }, testInfo) => { + if (viewport === "mobile") await page.setViewportSize({ width: 390, height: 844 }); + await page.addInitScript(() => localStorage.setItem("ling-stack.locale", "zh-CN")); + await mockDaemon(page, createBaseState({ approvalStatus: "approved" })); + await page.goto("/"); + await page.getByRole("complementary", { name: /Sidebar display|侧栏显示/u }) + .getByRole("button", { name: "新建对话", exact: true }).click(); + const trigger = page.getByLabel("新建对话输入框", { exact: true }).locator(".conversation-permission-trigger"); + await trigger.click(); + const option = page.getByRole("menuitemradio", { name: "完全访问权限", exact: true }); + await expect(option).toHaveAttribute("aria-description", "确认后,本会话同时启用自动审批和完全访问。系统权限和 Host 拒绝规则仍然有效。"); + await page.screenshot({ path: testInfo.outputPath("full-access-menu.png"), animations: "disabled" }); + await option.click(); + const dialog = page.getByRole("dialog", { name: "确认完全访问" }); + await expect(dialog.getByText("确认后,本会话同时启用自动审批和完全访问,可访问工作区外文件。系统权限和 Host 拒绝规则仍然有效。", { exact: true })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("full-access-confirmation.png"), animations: "disabled" }); + await dialog.getByRole("button", { name: "以完全访问启动", exact: true }).click(); + await expect(trigger).toHaveText("完全访问"); + await expect(trigger).toHaveAccessibleName(/本对话已确认/); +}); +} + +test("the task inspector links to Settings and all three composers inherit the saved sandbox", async ({ page }) => { + await page.addInitScript(() => localStorage.setItem("ling-stack.locale", "en-US")); + const state = createBaseState({ approvalStatus: "expired" }); + state.aiTeammates = [createMockTeammate({ id: "settings_scope_teammate", displayName: "Settings Scope Teammate" })]; + await mockDaemon(page, state); + await page.goto("/"); + await openTaskFromAttentionQueue(page, "Inspect workspace"); + const task = page.getByRole("article", { name: "Task detail", exact: true }); + await task.getByText("Permissions", { exact: true }).click(); + await expect(task.getByRole("radio")).toHaveCount(0); + await task.getByRole("button", { name: "Sandbox settings", exact: true }).click(); + const sandbox = page.locator("[data-ui2-default-authorization]").getByRole("radiogroup", { name: "Sandbox settings" }); + await sandbox.getByRole("radio", { name: "Read only", exact: true }).click(); + await expect.poll(() => state.workspaceSettings.defaultWorkspaceAccessMode).toBe("read_only"); + await page.getByRole("button", { name: "Back to app", exact: true }).click(); + await expect(page.getByLabel("Channel composer", { exact: true }).getByRole("button", { name: /Selected permission preset/ })) + .toHaveAttribute("title", /Read.only/i); + const sidebar = page.getByRole("complementary", { name: /Sidebar display|侧栏显示/u }); + await sidebar.getByRole("button", { name: "New conversation", exact: true }).click(); + await expect(page.getByLabel("New conversation composer", { exact: true }).getByRole("button", { name: /Selected permission preset/ })) + .toHaveAttribute("title", /Read.only/i); + await sidebar.getByRole("button", { name: "Settings Scope Teammate", exact: true }).click(); + await expect(page.getByLabel("Private chat composer", { exact: true }).getByRole("button", { name: /Selected permission preset/ })) + .toHaveAttribute("title", /Read.only/i); +}); diff --git a/apps/web/e2e/ui2-full-access-confirmation-parity.spec.ts b/apps/web/e2e/ui2-full-access-confirmation-parity.spec.ts index 2a6af54d5..decd36b09 100644 --- a/apps/web/e2e/ui2-full-access-confirmation-parity.spec.ts +++ b/apps/web/e2e/ui2-full-access-confirmation-parity.spec.ts @@ -51,7 +51,7 @@ test("channel permission selection confirms its conversation while an unowned ta await selectFullAccess(page, permissionTrigger); await confirmFullAccessSelection(page); - await expect(permissionTrigger).toContainText("Full access · Confirmed for this conversation"); + await expect(permissionTrigger).toHaveAccessibleName(/Full access · Confirmed for this conversation/); await openTaskFromAttentionQueue(page, "Inspect workspace"); const runButton = page.locator("article.task-detail").getByRole("button", { name: "Run", exact: true }); @@ -72,7 +72,7 @@ test("channel permission selection confirms its conversation while an unowned ta mode: "full_access", reason: "user_confirmed_full_access", }); - await expect(permissionTrigger).toContainText("Full access · Confirmed for this conversation"); + await expect(permissionTrigger).toHaveAccessibleName(/Full access · Confirmed for this conversation/); await runButton.click(); await expect(fullAccessDialog(page)).toHaveCount(0); await expect.poll(() => state.runs.length).toBe(2); @@ -287,7 +287,7 @@ test("normal conversation distinguishes inherited always authorization from full await expect(trigger).toHaveText("Always authorize"); await trigger.click(); const menu = page.getByRole("menu").filter({ has: page.getByRole("menuitemradio", { name: "Request approval", exact: true }) }); - await expect(menu.getByRole("menuitemradio", { name: "Full access", exact: true })).not.toBeChecked(); + await expect(menu.getByRole("menuitemradio", { name: "Full access permissions", exact: true })).not.toBeChecked(); await menu.getByRole("menuitemradio", { name: "Request approval", exact: true }).click(); await composer.getByRole("textbox", { name: "Conversation message" }).fill("Keep the configured read-only sandbox"); const request = page.waitForRequest((candidate) => candidate.method() === "POST" && new URL(candidate.url()).pathname.endsWith("/conversations/stream")); @@ -341,7 +341,7 @@ test("teammate private chat confirms explicit selection per conversation", async expect(directSendCount).toBe(0); await selectFullAccess(page, permissionTrigger); await confirmFullAccessSelection(page); - await expect(permissionTrigger).toContainText("Full access · Confirmed for this conversation"); + await expect(permissionTrigger).toHaveAccessibleName(/Full access · Confirmed for this conversation/); await captureP6FeedbackEvidence( page, "shell/full-access-confirmation-parity", @@ -363,7 +363,7 @@ test("teammate private chat confirms explicit selection per conversation", async reason: "user_confirmed_full_access", }); - await expect(permissionTrigger).toContainText("Full access · Confirmed for this conversation"); + await expect(permissionTrigger).toHaveAccessibleName(/Full access · Confirmed for this conversation/); await input.fill("Continue with the same teammate"); await sendButton.click(); await expect(fullAccessDialog(page)).toHaveCount(0); @@ -382,7 +382,7 @@ test("teammate private chat confirms explicit selection per conversation", async const restoredPermissionTrigger = restoredComposer.getByRole("button", { name: /Selected permission preset/ }); await selectFullAccess(page, restoredPermissionTrigger); await expect(fullAccessDialog(page)).toHaveCount(0); - await expect(restoredPermissionTrigger).toContainText("Full access · Confirmed for this conversation"); + await expect(restoredPermissionTrigger).toHaveAccessibleName(/Full access · Confirmed for this conversation/); await restoredComposer.getByRole("textbox").fill("Continue after restarting the app"); await restoredComposer.getByRole("button", { name: "Send" }).click(); await expect(fullAccessDialog(page)).toHaveCount(0); @@ -399,7 +399,7 @@ test("teammate private chat confirms explicit selection per conversation", async const nextPermissionTrigger = nextComposer.getByRole("button", { name: /Selected permission preset/ }); await selectFullAccess(page, nextPermissionTrigger); await confirmFullAccessSelection(page); - await expect(nextPermissionTrigger).toContainText("Full access · Confirmed for this conversation"); + await expect(nextPermissionTrigger).toHaveAccessibleName(/Full access · Confirmed for this conversation/); await nextComposer.getByRole("textbox").fill("Start with the second teammate"); await nextComposer.getByRole("button", { name: "Send" }).click(); await expect(fullAccessDialog(page)).toHaveCount(0); @@ -462,7 +462,7 @@ test("teammate private chat confirms inherited full access on the first run", as mode: "full_access", reason: "user_confirmed_full_access", }); - await expect(permissionTrigger).toContainText("Full access · Confirmed for this conversation"); + await expect(permissionTrigger).toHaveAccessibleName(/Full access · Confirmed for this conversation/); await expectNoDocumentOverflow(page); expect(runtimeErrors).toEqual([]); }); diff --git a/apps/web/e2e/ui2-normal-conversation-feed-composer.spec.ts b/apps/web/e2e/ui2-normal-conversation-feed-composer.spec.ts index b956503f8..fb8257257 100644 --- a/apps/web/e2e/ui2-normal-conversation-feed-composer.spec.ts +++ b/apps/web/e2e/ui2-normal-conversation-feed-composer.spec.ts @@ -139,9 +139,10 @@ test("normal conversation composer uses Radix menus and preserves draft, upload, await expect(permissionMenu).toHaveCount(0); await expect(permissionTrigger).toBeFocused(); - await expect(composer.getByRole("button", { name: /Selected thinking mode|Selected reasoning summary|Selected authorization mode/ })).toHaveCount(0); + await expect(composer.getByRole("button", { name: /^(Selected thinking mode|Selected reasoning summary|Selected authorization mode)/ })).toHaveCount(0); const modelTrigger = composer.getByRole("button", { name: "Selected model gpt-5.5" }); - await expect(modelTrigger).toContainText("Off"); + await expect(modelTrigger).toContainText("Thinking off"); + await expect(modelTrigger).toHaveAccessibleName("Selected model gpt-5.5 · Selected thinking mode Off"); await modelTrigger.click(); const reasoningPanel = page.getByRole("dialog", { name: "Thinking mode" }); const slider = reasoningPanel.getByRole("slider", { name: "Thinking mode" }); @@ -152,7 +153,7 @@ test("normal conversation composer uses Radix menus and preserves draft, upload, await slider.press("ArrowLeft"); await expect(slider).toHaveAttribute("aria-valuetext", "High"); await expect(modelTrigger).toContainText("High"); - await expect(modelTrigger).toHaveAccessibleName("Selected model gpt-5.5 · High"); + await expect(modelTrigger).toHaveAccessibleName("Selected model gpt-5.5 · Selected thinking mode High"); await expectCompactMutedComposerControl(permissionTrigger); await expectCompactMutedComposerControl(modelTrigger); await captureEvidence(page, "1440x960-ling-light-normal-composer-reasoning.png"); diff --git a/apps/web/e2e/ui2-shell-feedback-permission.spec.ts b/apps/web/e2e/ui2-shell-feedback-permission.spec.ts index a3749d34d..87565f90b 100644 --- a/apps/web/e2e/ui2-shell-feedback-permission.spec.ts +++ b/apps/web/e2e/ui2-shell-feedback-permission.spec.ts @@ -109,16 +109,16 @@ test("new conversation metadata controls stay quiet and compact without a config await page.goto("/"); const home = page.getByLabel("对话首页"); - const permission = home.getByRole("button", { name: "已选择权限预设 帮我审批" }); + const authorization = home.getByRole("button", { name: "已选择授权模式 帮我审批" }); const model = home.locator(".conversation-model-trigger"); const shortcut = home.getByRole("button", { name: "创建 Team" }); - await expect(permission).toBeVisible(); + await expect(authorization).toBeVisible(); await expect(model).toBeVisible(); - await expect(permission).toContainText("帮我审批"); + await expect(authorization).toContainText("帮我审批"); await expect(model).toContainText("未选择聊天模型"); const [permissionStyle, modelStyle, shortcutFontSize] = await Promise.all([ - quietControlStyle(permission), + quietControlStyle(authorization), quietControlStyle(model), shortcut.evaluate((element) => getComputedStyle(element).fontSize), ]); diff --git a/apps/web/e2e/workspace.spec.ts b/apps/web/e2e/workspace.spec.ts index 7dad72098..1c23be056 100644 --- a/apps/web/e2e/workspace.spec.ts +++ b/apps/web/e2e/workspace.spec.ts @@ -277,8 +277,8 @@ test("workspace task run sends the selected session permission mode", async ({ p await openTaskFromAttentionQueue(page, "Inspect workspace"); const taskDetail = page.locator("article.task-detail"); await taskDetail.getByText("Permissions", { exact: true }).click(); - await expect(taskDetail.getByRole("radiogroup", { name: "Permission preset" })).toBeVisible(); - await taskDetail.getByRole("radio", { name: "Workspace write" }).click(); + await expect(taskDetail.getByRole("radiogroup", { name: "Permission preset" })).toHaveCount(0); + await expect(taskDetail.getByRole("button", { name: "Sandbox settings", exact: true })).toBeVisible(); await taskDetail.getByRole("button", { name: "Run", exact: true }).click(); await expect.poll(() => { @@ -325,21 +325,23 @@ test("workspace full access task run requires confirmation and sends confirmatio await openTaskFromAttentionQueue(page, "Inspect workspace"); const taskDetail = page.locator("article.task-detail"); await taskDetail.getByText("Permissions", { exact: true }).click(); - await taskDetail.getByRole("radio", { name: "Full access" }).click(); + const permissionTrigger = page.getByLabel("Channel composer", { exact: true }).getByRole("button", { name: /Selected permission preset/ }); + await permissionTrigger.click(); + await page.getByRole("menuitemradio", { name: "Full access permissions", exact: true }).click(); const selectionSheet = page.getByRole("dialog", { name: "Confirm full access" }); await expect(selectionSheet).toBeVisible(); await selectionSheet.getByRole("button", { name: "Cancel" }).last().click(); await expect(selectionSheet).toHaveCount(0); - const previousAccess = taskDetail.getByRole("radio", { name: "Workspace write" }); - await expect(previousAccess).toHaveAttribute("aria-checked", "true"); - await expect(previousAccess).toBeFocused(); + await expect(permissionTrigger).toHaveText("Help me approve"); + await expect(permissionTrigger).toBeFocused(); expect(state.runs).toHaveLength(0); - await taskDetail.getByRole("radio", { name: "Full access" }).click(); + await permissionTrigger.click(); + await page.getByRole("menuitemradio", { name: "Full access permissions", exact: true }).click(); await expect(selectionSheet).toBeVisible(); await expect(selectionSheet).toHaveClass(/ling-ui-dialog-content/u); await selectionSheet.getByRole("button", { name: "Start with full access" }).click(); - await expect(taskDetail.getByRole("radio", { name: "Full access" })).toHaveAttribute("aria-checked", "true"); + await expect(permissionTrigger).toHaveAccessibleName(/Full access · Confirmed for this conversation/); await taskDetail.getByRole("button", { name: "Run", exact: true }).click(); await expect(selectionSheet).toHaveCount(0); diff --git a/apps/web/src/api/channels.ts b/apps/web/src/api/channels.ts index 76e371582..4b97722b1 100644 --- a/apps/web/src/api/channels.ts +++ b/apps/web/src/api/channels.ts @@ -45,6 +45,7 @@ import type { ChannelWorkflowStepListResponse, ChannelWorkflowStepMutationResponse, ChannelWorkflowStepRetryResponse, + ChannelWorkflowStepStartResponseContext, ChannelWorkflowStepStartResponse, ChannelWorkflowTemplateMutationResponse, ChannelWorkspaceCreateWorktreeRequest, @@ -70,7 +71,7 @@ import type { CreateChannelWorkflowRequest, CreateChannelWorkflowStepRequest, CreateChannelWorkflowTemplateCompileRequest, - StartChannelWorkflowStepRequest, + CreateTaskChannelWorkflowStepStartRequest, ListChannelConversationItemsOptions, Task, TaskListResponse, @@ -531,9 +532,10 @@ export async function updateChannelWorkflowStep( export async function startChannelWorkflowStep( cwd: string, workflowStepId: string, - input: StartChannelWorkflowStepRequest, + input: CreateTaskChannelWorkflowStepStartRequest, + expected: Omit, ): Promise { - return daemonClient.startChannelWorkflowStep(cwd, workflowStepId, input); + return daemonClient.startChannelWorkflowStep(cwd, workflowStepId, input, expected); } export async function retryChannelWorkflowStep(cwd: string, workflowStepId: string): Promise { diff --git a/apps/web/src/api/conversations.ts b/apps/web/src/api/conversations.ts index 4e4a46680..b72ce3aef 100644 --- a/apps/web/src/api/conversations.ts +++ b/apps/web/src/api/conversations.ts @@ -4,10 +4,11 @@ import type { ConversationStreamRequest, EditConversationMessageRequest, EditConversationMessageResponse, -} from "@ling-stack/contracts"; + UserInputAnswerAcceptedResponseV1, + UserInputAnswerCommandV1, +} from "../types.js"; import { daemonClient } from "./client.js"; -import { authorizationAndAccessFromLegacyPermission } from "@ling-stack/core/session-permissions"; -import type { ConversationSubmissionQuery, ConversationSubmissionReceipt } from "@ling-stack/contracts"; +import type { ConversationSubmissionQuery, ConversationSubmissionReceipt } from "../types.js"; export function getConversationSubmission(cwd: string, query: ConversationSubmissionQuery): Promise { return daemonClient.getConversationSubmission(cwd, query); @@ -19,20 +20,17 @@ export async function getConversation(cwd: string, threadId: string): Promise { - const legacy = authorizationAndAccessFromLegacyPermission(input.permissionMode, input.permissionPreset); - const { permissionMode: _permissionMode, permissionPreset: _permissionPreset, ...request } = input; - return daemonClient.streamConversation(cwd, { - ...request, - schemaVersion: 2, - authorizationMode: input.authorizationMode ?? legacy.authorizationMode, - workspaceAccessMode: input.workspaceAccessMode ?? legacy.workspaceAccessMode, - }); + return daemonClient.streamConversation(cwd, input); } export async function editConversationMessage(cwd: string, input: EditConversationMessageRequest): Promise { return daemonClient.editConversationMessage(cwd, input); } +export async function submitUserInputAnswer(cwd: string, input: UserInputAnswerCommandV1): Promise { + return daemonClient.submitUserInputAnswer(cwd, input); +} + export async function uploadConversationAttachment( cwd: string, file: File, diff --git a/apps/web/src/api/run-request-builders.ts b/apps/web/src/api/run-request-builders.ts index f87a7579c..619dbb9a4 100644 --- a/apps/web/src/api/run-request-builders.ts +++ b/apps/web/src/api/run-request-builders.ts @@ -11,7 +11,6 @@ import { readOptionalString, readRecord, readRunTask, - readStringArray, readToolArray, removeUndefined, } from "./shared.js"; @@ -104,7 +103,21 @@ function resolveChannelExecutionCwd(channel: Channel, workspaceCwd: string): str return channel.boundPath || workspaceCwd; } +function isWorkflowRun(run: RunSession): boolean { + return Boolean(run.workflowStepId || readOptionalString(readRecord(run.requestSnapshot), "workflowStepId")); +} + +/** Workflow retries belong to the Host step controller, never a reconstructed task. */ +export function canRerunSnapshot(run: RunSession | undefined): boolean { + if (!run || isWorkflowRun(run)) return false; + return Boolean((run.providerPresetId ?? readOptionalString(readRecord(run.requestSnapshot), "providerPresetId")) + && readOptionalString(readRecord(run.taskSnapshot), "prompt")); +} + export function buildRerunRequest(run: RunSession): unknown { + if (isWorkflowRun(run)) { + throw new Error("Retry this workflow step through its channel workflow controls, not its public snapshot."); + } const snapshot = readRecord(run.requestSnapshot); const providerPresetId = run.providerPresetId ?? readOptionalString(snapshot, "providerPresetId"); if (!providerPresetId) { @@ -127,8 +140,7 @@ export function buildRerunRequest(run: RunSession): unknown { ...permissions, thinkingLevel: readOptionalString(snapshot, "thinkingLevel") ?? "medium", lingConversationTone: readOptionalString(snapshot, "lingConversationTone"), - systemPrompt: readOptionalString(snapshot, "systemPrompt"), - appendSystemPrompt: readStringArray(snapshot, "appendSystemPrompt"), + // Private system hydration is Host-owned and is not replayed from cached public DTOs. agentDir: readOptionalString(snapshot, "agentDir"), }); } diff --git a/apps/web/src/api/runs.ts b/apps/web/src/api/runs.ts index 8d64d5bb5..ec0893a78 100644 --- a/apps/web/src/api/runs.ts +++ b/apps/web/src/api/runs.ts @@ -70,13 +70,13 @@ export async function listExecutionActions( return daemonClient.listExecutionActions(cwd, options); } -export async function submitUserInputAnswer( +export async function submitRunUserInputAnswer( cwd: string, threadId: string, requestId: string, input: UserInputAnswerSubmissionV1, ): Promise { - return daemonClient.submitUserInputAnswer(cwd, threadId, requestId, input); + return daemonClient.submitRunUserInputAnswer(cwd, threadId, requestId, input); } export async function listLingThreads(cwd: string, options: LingThreadListRequest = {}): Promise { diff --git a/apps/web/src/app/AppConnectionStatus.tsx b/apps/web/src/app/AppConnectionStatus.tsx index 78df57b99..5ccc4bc3c 100644 --- a/apps/web/src/app/AppConnectionStatus.tsx +++ b/apps/web/src/app/AppConnectionStatus.tsx @@ -1,17 +1,34 @@ import { RotateCw } from "lucide-react"; import { useI18n } from "../i18n.js"; import { Button, InlineFeedback } from "../shared/ui/index.js"; +import type { DesktopDaemonStatus } from "../../../desktop/shared/desktop-capabilities.js"; export function AppConnectionStatus({ offline, refreshing, onRetry, + desktopStatus, }: { readonly offline: boolean; readonly refreshing: boolean; readonly onRetry: () => void; + readonly desktopStatus?: DesktopDaemonStatus; }): React.JSX.Element | null { const { t } = useI18n(); + if (desktopStatus?.ownedByDesktop && (desktopStatus.state === "draining" || desktopStatus.state === "blocked")) { + const draining = desktopStatus.state === "draining"; + const refused = desktopStatus.reason === "stop_blocked"; + return ( +
+ + {t(draining ? "Waiting for the original Host to finish. Existing executions and their evidence are preserved." + : refused ? "Stop proof is unavailable. The original Host is retained; automatic restart is withheld." + : "The original Host has not confirmed a clean stop. No replacement will be started.")} + +
+ ); + } if (!offline) return null; return ( diff --git a/apps/web/src/app/AppShell.tsx b/apps/web/src/app/AppShell.tsx index 6b5833002..92f9a42cc 100644 --- a/apps/web/src/app/AppShell.tsx +++ b/apps/web/src/app/AppShell.tsx @@ -1,6 +1,7 @@ import { useChannelSessionsController } from "./use-channel-sessions-controller.js"; import { channelSessionThreadId, isChannelSessionDraft } from "./channel-session-state.js"; import { WorkSummaryProvider } from "../features/workspace/work-summary/WorkSummaryProvider.js"; +import { DaemonObservationProvider, useDaemonObservationState, DaemonObservationDeferredError } from "../shared/conversation/observation/daemon-observation.js"; import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import { PanelRightClose, @@ -81,6 +82,7 @@ import { effectiveApprovalStatus, } from "../lib/approvals.js"; import { buildInlineApprovalViews } from "../lib/inline-approvals.js"; +import { approvalRecoveryNotice } from "../lib/approval-recovery.js"; import { channelToForm, defaultChannelCreateForm, @@ -105,6 +107,7 @@ import { useAutomationsDomain } from "../domains/automations/use-automations-dom import { DesktopComputerUseBridge } from "./DesktopComputerUseBridge.js"; import { AppPresentationSurfaceBridge } from "./AppPresentationSurfaceBridge.js"; import { AppConnectionStatus } from "./AppConnectionStatus.js"; +import { useDesktopDaemonStatus } from "./use-desktop-daemon-status.js"; import { AppShellOverlayBoundary } from "./AppShellOverlayBoundary.js"; import { DesktopWindowChrome } from "./DesktopWindowChrome.js"; import { DesktopTitlebarProvider, DesktopTitlebarSurface } from "./DesktopTitlebarSurface.js"; @@ -265,7 +268,7 @@ export function AppShell(): React.JSX.Element { const [channelSidebarOpen, setChannelSidebarOpen] = useState(() => !narrowViewport); const [selectedChannelBranchId, setSelectedChannelBranchId] = useState(""); const [daemonHealthy, setDaemonHealthy] = useState(); - const focusRevalidationToken = useFocusRevalidationToken({ enabled: daemonHealthy === true }); + const desktopDaemonStatus = useDesktopDaemonStatus(); const [daemonHealthRefreshing, setDaemonHealthRefreshing] = useState(false); const [daemonFeatures, setDaemonFeatures] = useState({ channels: { autoRoute: false }, @@ -306,6 +309,10 @@ export function AppShell(): React.JSX.Element { buildTaskRunRequest, daemonBaseUrl, } = useRunCommandController(); + const daemonObservation = useDaemonObservationState(desktopDaemonStatus, daemonBaseUrl, form.cwd); + const daemonObservationRef = useRef(daemonObservation); + daemonObservationRef.current = daemonObservation; + const focusRevalidationToken = useFocusRevalidationToken({ enabled: daemonHealthy === true && !daemonObservation.paused }); const { getDaemonHealth } = useSettingsCommandController(); const slashCommands = useSlashCommandRegistry({ workspaceCwd: form.cwd.trim(), @@ -329,6 +336,7 @@ export function AppShell(): React.JSX.Element { } = useWorkspaceCommandController(); const workspaceDomain = useWorkspaceDomain({ cwd: form.cwd.trim(), + observation: daemonObservation, initialWorkspaceCwd, selectedChannelBranchId, translate: t, @@ -472,6 +480,7 @@ export function AppShell(): React.JSX.Element { : undefined; const conversationDomain = useConversationDomain({ cwd: form.cwd.trim(), + observation: daemonObservation, translate: t, }); const { @@ -811,6 +820,7 @@ export function AppShell(): React.JSX.Element { } = durableWorkItemsDomain.actions; const runtimeDomain = useRuntimeDomain({ cwd: form.cwd.trim(), + observation: daemonObservation, onWorkUnitActionComplete: async (workUnitId, action) => { if (action === "cancel" && run?.workUnitId === workUnitId) { abortCurrentRun(); @@ -850,6 +860,7 @@ export function AppShell(): React.JSX.Element { } = settingsDomain.actions; const runsDomain = useRunsDomain({ cwd: form.cwd.trim(), + observation: daemonObservation, confirmFullAccessRunIfNeeded, setSelectedWorkUnitDetail, refreshApprovals, @@ -1272,6 +1283,12 @@ export function AppShell(): React.JSX.Element { () => deriveChannelRuns(selectedChannelId, tasks, runs, run), [run, runs, selectedChannelId, tasks], ); + const approvalRefreshOwnerRef = useRef({ cwd: form.cwd.trim(), channelId: selectedChannelId }); + if (approvalRefreshOwnerRef.current.cwd !== form.cwd.trim() || approvalRefreshOwnerRef.current.channelId !== selectedChannelId) { + // Replace the identity, including when the user later returns to this scope. + approvalRefreshOwnerRef.current = { cwd: form.cwd.trim(), channelId: selectedChannelId }; + } + const approvalRefreshOwner = approvalRefreshOwnerRef.current; const channelRunArtifactsKey = useMemo( () => channelRunsForArtifacts.map((item) => `${item.id}:${item.updatedAt}`).join("|"), [channelRunsForArtifacts], @@ -1703,6 +1720,7 @@ export function AppShell(): React.JSX.Element { createTaskFromContextSummary, runActivityTriageAction, } = useChannelActionsController({ + observation: daemonObservation, form, setForm, channels, @@ -1803,6 +1821,7 @@ export function AppShell(): React.JSX.Element { const controlPlaneInvalidation = useControlPlaneInvalidation({ daemonBaseUrl, daemonHealthy, + observation: daemonObservation, localWebSocketRpcEnabled: daemonFeatures.transports?.localWebSocketRpc === true, workspaceCwd: form.cwd.trim(), selectedChannelId, @@ -1810,6 +1829,7 @@ export function AppShell(): React.JSX.Element { channelRunsForArtifacts, setSkillLibraryRefreshToken, refreshRuns, + refreshConversationThreads, refreshSelectedConversation, refreshRuntimeState, refreshApprovals, @@ -1871,12 +1891,28 @@ export function AppShell(): React.JSX.Element { useShellErrorFeedback({ dismissToast, items: shellErrorFeedbackItems, showToast }); useEffect(() => { - void refreshHealth(); void refreshProviderTemplates(); void refreshProviders(); void refreshAiTeammates(); }, []); + useEffect(() => { + // Retry invalidated startup reads from the next live observation. Once + // healthy, cwd changes must not reapply the Host's default workspace. + if (daemonObservation.paused || daemonHealthy === true) return; + void refreshHealth(); + }, [daemonObservation.epoch, daemonObservation.paused]); + + useEffect(() => { + const cwd = form.cwd.trim(); + if (!cwd || daemonHealthy !== true || daemonObservation.paused || daemonObservation.workspaceCwd !== cwd) return; + // Health may change cwd before React commits the new observation. Hydrate + // guarded workspace reads from that committed owner, never the old render. + void refreshChannels(cwd); + void refreshAiCouncils(cwd); + void refreshUsedMemoryAudit(cwd); + }, [form.cwd, daemonHealthy, daemonObservation.epoch, daemonObservation.paused]); + useEffect(() => { if (activeView === "workspace") { void refreshChannels(); @@ -1996,34 +2032,34 @@ export function AppShell(): React.JSX.Element { }, [channelBranches, channelsLoading, selectedChannelBranchId]); useEffect(() => { - if (activeView !== "workspace") { + if (activeView !== "workspace" || daemonObservation.paused) { return; } void refreshChannelRunArtifacts(form.cwd.trim(), selectedChannelId, channelRunsForArtifacts); - }, [activeView, channelRunArtifactsKey, form.cwd, selectedChannelId]); + }, [activeView, channelRunArtifactsKey, form.cwd, selectedChannelId, daemonObservation.paused]); useEffect(() => { - if (activeView !== "workspace") { + if (activeView !== "workspace" || daemonObservation.paused) { return; } void refreshChannelExecutionActions(form.cwd.trim(), selectedChannelId, channelRunsForArtifacts); - }, [activeView, channelRunArtifactsKey, form.cwd, selectedChannelId]); + }, [activeView, channelRunArtifactsKey, form.cwd, selectedChannelId, daemonObservation.paused]); useEffect(() => { const cwd = form.cwd.trim(); const hasActiveVisibleRun = channelRunsForArtifacts.some((candidate) => candidate.status === "queued" || candidate.status === "running" || candidate.status === "waiting_for_approval", ); - if (activeView !== "workspace" || !cwd || !selectedChannelId || (!hasActiveVisibleRun && !hasLiveWorkflowTimeline)) { + if (daemonObservation.paused || activeView !== "workspace" || !cwd || !selectedChannelId || (!hasActiveVisibleRun && !hasLiveWorkflowTimeline)) { return undefined; } const interval = window.setInterval(() => { - if (document.visibilityState === "visible") { + if (document.visibilityState === "visible" && !daemonObservationRef.current.paused) { void refreshChannelExecutionActions(cwd, selectedChannelId, channelRunsForArtifacts); } }, 750); return () => window.clearInterval(interval); - }, [activeView, channelRunArtifactsKey, form.cwd, selectedChannelId, hasLiveWorkflowTimeline]); + }, [activeView, channelRunArtifactsKey, form.cwd, selectedChannelId, hasLiveWorkflowTimeline, daemonObservation.paused]); useEffect(() => { if (activeView !== "workspace") { @@ -2034,22 +2070,18 @@ export function AppShell(): React.JSX.Element { useEffect(() => { const cwd = form.cwd.trim(); - if (activeView !== "workspace" || isRunning || !workspaceTimelineRunId || !cwd) { + if (daemonObservationRef.current.paused || activeView !== "workspace" || isRunning || !workspaceTimelineRunId || !cwd) { return; } - const loadKey = `${cwd}::${workspaceTimelineRunId}`; + const loadKey = `${cwd}::${workspaceTimelineRunId}::${daemonObservation.epoch}`; if (workspaceTimelineLoadRef.current === loadKey) { return; } - if (run?.id === workspaceTimelineRunId && events.length > 0) { - workspaceTimelineLoadRef.current = loadKey; - return; - } workspaceTimelineLoadRef.current = loadKey; - void loadRun(workspaceTimelineRunId, cwd); - }, [activeView, events.length, form.cwd, isRunning, run?.id, workspaceTimelineRunId]); + void loadRun(workspaceTimelineRunId, cwd).catch(() => undefined); + }, [activeView, form.cwd, isRunning, workspaceTimelineRunId, daemonObservation.epoch, daemonObservation.paused]); useEffect(() => { const syncKey = `${form.cwd}::${selectedChannelId}`; @@ -2089,9 +2121,11 @@ export function AppShell(): React.JSX.Element { }, [approvals, selectedApprovalId]); async function refreshHealth(): Promise { + const observationEpoch = daemonObservationRef.current.epoch; setDaemonHealthRefreshing(true); try { const health = await getDaemonHealth(); + if (daemonObservationRef.current.epoch !== observationEpoch) return; setDaemonHealthy(health.ok); setDaemonFeatures(health.features ?? { channels: { autoRoute: false }, @@ -2108,7 +2142,7 @@ export function AppShell(): React.JSX.Element { if (savedWorkspaceCwd) { clearSavedWorkspaceCwd(); } - if (form.cwd.trim() !== defaultWorkspaceCwd) { + if (daemonObservationRef.current.workspaceCwd !== defaultWorkspaceCwd) { updateWorkspaceCwd(defaultWorkspaceCwd); } } else { @@ -2125,19 +2159,17 @@ export function AppShell(): React.JSX.Element { } void Promise.all([ refreshProviders(defaultWorkspaceCwd), - refreshChannels(defaultWorkspaceCwd), refreshAiTeammates(defaultWorkspaceCwd), - refreshAiCouncils(defaultWorkspaceCwd), refreshMcp(defaultWorkspaceCwd), refreshRuns(defaultWorkspaceCwd), refreshApprovals(defaultWorkspaceCwd), refreshRuntimeState(defaultWorkspaceCwd), - refreshUsedMemoryAudit(defaultWorkspaceCwd), refreshWorkItems(defaultWorkspaceCwd), loadStartupStatus(defaultWorkspaceCwd), ]).catch(() => undefined); } } catch { + if (daemonObservationRef.current.epoch !== observationEpoch) return; setDaemonHealthy(false); setDaemonFeatures({ channels: { autoRoute: false }, @@ -2177,6 +2209,7 @@ export function AppShell(): React.JSX.Element { } async function refreshChannelActivity(): Promise { + if (daemonObservationRef.current.paused) throw new DaemonObservationDeferredError(); const channelId = selectedChannelId; if (!channelId) { return; @@ -2205,6 +2238,68 @@ export function AppShell(): React.JSX.Element { } } + async function refreshApprovalDependents(workspaceCwd: string, event: { + readonly runId?: string; + readonly mediaJobId?: string; + readonly externalWorkActionId?: string; + readonly channelId?: string; + }): Promise { + const owner = approvalRefreshOwner; + const observationEpoch = daemonObservationRef.current.epoch; + const isCurrent = () => approvalRefreshOwnerRef.current === owner && owner.cwd === workspaceCwd + && !daemonObservationRef.current.paused && daemonObservationRef.current.epoch === observationEpoch; + if (!isCurrent()) return; + await refreshConversationThreads(workspaceCwd); + if (!isCurrent()) return; + window.setTimeout(() => { + if (isCurrent()) void refreshConversationThreads(workspaceCwd); + }, 750); + if (event.runId && selectedConversationDetail?.runs.some((run) => run.id === event.runId)) { + await refreshSelectedConversation(workspaceCwd); + if (!isCurrent()) return; + } + if (event.externalWorkActionId) { + await refreshExternalActionDetailsByIds(workspaceCwd, [event.externalWorkActionId]); + if (!isCurrent()) return; + } + const isDiagnosticRun = event.runId && (event.runId === activeRunId || event.runId === lookupRunId || event.runId === approvalRepairRunId); + if (isDiagnosticRun) { + try { + await Promise.all([loadRun(event.runId, workspaceCwd), refreshRuns(workspaceCwd)]); + } catch (error) { + // Approval acceptance is already a fact; deferring its observation is + // not an approval failure. Real read/HTTP errors keep their owner. + if (error instanceof DaemonObservationDeferredError) return; + throw error; + } + if (!isCurrent()) return; + window.setTimeout(() => { + if (!isCurrent()) return; + void loadRun(event.runId, workspaceCwd).catch(() => undefined); + void refreshRuns(workspaceCwd); + void refreshApprovals(workspaceCwd, event.runId); + }, 750); + } + if (event.mediaJobId) await refreshMediaJobs(workspaceCwd); + if (!isCurrent()) return; + const channelId = selectedChannelId && (event.channelId + ? event.channelId === selectedChannelId + : channelRunsForArtifacts.some(candidate => candidate.id === event.runId)) ? selectedChannelId : undefined; + if (channelId) { + // Do not call refreshChannelActivity: it also refreshes approval receipts. + await Promise.all([ + ...(isDiagnosticRun ? [] : [refreshRuns(workspaceCwd)]), + refreshTasks(channelId), + refreshChannelMessages(channelId, selectedChannelMessageThreadId), + refreshChannelProtocol(channelId), + refreshChannelFiles(channelId), + refreshChannelRunArtifacts(workspaceCwd, channelId, channelRunsForArtifacts), + refreshChannelExecutionActions(workspaceCwd, channelId, channelRunsForArtifacts), + refreshChannelActivityTransitions(workspaceCwd, channelId, channelRunsForArtifacts), + ]); + } + } + async function handleDomainEvent(event: DomainEvent): Promise { const workspaceCwd = form.cwd.trim(); switch (event.type) { @@ -2217,37 +2312,31 @@ export function AppShell(): React.JSX.Element { setActiveSidebarTarget(event.target ?? event.view); return; - case "approval.resolved": - if (event.reconciliationPending) { + case "approval.recoveryChanged": { + if (event.cwd !== workspaceCwd) return; + const scope = `approval:${event.approvalId}:postcommit`; + const notice = approvalRecoveryNotice(event.recoveryState); + if (notice) { showToast({ - message: t("Approval was recorded, but execution recovery is pending. Check run status before trying again."), - scope: `approval:${event.approvalId}:postcommit`, - tone: "warning", - durationMs: 15_000, + message: t(notice.message), + scope, + tone: notice.tone, + durationMs: notice.tone === "warning" ? 15_000 : 3_000, }); + } else { + dismissToast(scope); + // Recovery completion may arrive without SSE. Refresh the owning + // views, not the receipt observer itself, to avoid recursive lookups. + if (event.runId || event.mediaJobId || event.externalWorkActionId || event.channelId) { + await refreshApprovalDependents(workspaceCwd, event); + } } + return; + } + + case "approval.resolved": await refreshApprovals(workspaceCwd, event.runId); - await refreshConversationThreads(workspaceCwd); - window.setTimeout(() => { - void refreshConversationThreads(workspaceCwd); - }, 750); - if (event.runId && selectedConversationDetail?.runs.some((run) => run.id === event.runId)) { - await refreshSelectedConversation(workspaceCwd); - } - if (event.externalWorkActionId) { - await refreshExternalActionDetailsByIds(workspaceCwd, [event.externalWorkActionId]); - } - if (event.runId && (event.runId === activeRunId || event.runId === lookupRunId || event.runId === approvalRepairRunId)) { - await Promise.all([loadRun(event.runId, workspaceCwd), refreshRuns(workspaceCwd)]); - window.setTimeout(() => { - void loadRun(event.runId, workspaceCwd); - void refreshRuns(workspaceCwd); - void refreshApprovals(workspaceCwd, event.runId); - }, 750); - } - if (event.mediaJobId) { - await refreshMediaJobs(workspaceCwd); - } + await refreshApprovalDependents(workspaceCwd, event); return; case "provider.repairRequested": { @@ -2437,6 +2526,7 @@ export function AppShell(): React.JSX.Element { : "normal_conversation" : undefined; return ( + void refreshHealth()} @@ -2805,20 +2896,21 @@ export function AppShell(): React.JSX.Element { if (!confirmation) return; } setForm((current) => { - const workspaceAccessMode = workspaceAccessModeForPermissionPreset(permissionMode === "full_access" - ? "full_access" - : permissionMode === "ask_everything" - ? "read_only" - : "workspace_collaboration"); - const legacy = authorizationAndAccessToLegacyPermission(current.authorizationMode, workspaceAccessMode); - return { - ...current, - workspaceAccessMode, - permissionMode: legacy.permissionMode, - permissionPreset: legacy.permissionPreset, - }; + const workspaceAccessMode = workspaceAccessModeForPermissionPreset(permissionMode === "full_access" + ? "full_access" + : permissionMode === "ask_everything" + ? "read_only" + : "workspace_collaboration"); + const legacy = authorizationAndAccessToLegacyPermission(current.authorizationMode, workspaceAccessMode); + return { + ...current, + workspaceAccessMode, + permissionMode: legacy.permissionMode, + permissionPreset: legacy.permissionPreset, + }; }); }} + onOpenSandboxSettings={() => openSettingsSection("approval-strategy")} onCancelActiveRun={(targetRun) => void cancelRunOrWorkUnit(targetRun)} onOpenRun={(runId) => void openRunFromWorkspace(runId)} onOpenApprovals={(runId) => openApprovalsFromWorkspace(runId)} @@ -3033,6 +3125,7 @@ export function AppShell(): React.JSX.Element { fullAccessConfirmed={fullAccessConfirmation.isConfirmed(teammateConversationFullAccessScope)} onConfirmFullAccess={() => fullAccessConfirmation.request(teammateConversationFullAccessScope)} onConfirmFullAccessSelection={() => fullAccessConfirmation.request(teammateConversationFullAccessScope)} + permissionSelectionOwnerKey={JSON.stringify(teammateConversationFullAccessScope)} defaultPermissionMode={workspaceSettings?.defaultPermissionMode} defaultPermissionPreset={workspaceSettings?.defaultPermissionPreset} workspacePolicy={approvalPolicy} @@ -3543,6 +3636,7 @@ export function AppShell(): React.JSX.Element { /> + ); } diff --git a/apps/web/src/app/FullAccessConfirmationDialog.tsx b/apps/web/src/app/FullAccessConfirmationDialog.tsx index 7ef5b2f21..0ac842349 100644 --- a/apps/web/src/app/FullAccessConfirmationDialog.tsx +++ b/apps/web/src/app/FullAccessConfirmationDialog.tsx @@ -53,7 +53,7 @@ export function FullAccessConfirmationDialog({ )} /> -

{t("Full access can read and write outside the workspace and use network or tool actions allowed by this session.")}

+

{t("Confirmation enables automatic approvals and Full Access for this conversation, including access outside the workspace. System permissions and Host deny rules still apply.")}

{t("This confirmation applies only to the current conversation. New conversations and permission changes require confirmation again.")}

{t("Use it only for trusted workspaces. Denied tools in workspace policy still stay blocked.")}

diff --git a/apps/web/src/app/domain-controller.ts b/apps/web/src/app/domain-controller.ts index e0983619b..4747759be 100644 --- a/apps/web/src/app/domain-controller.ts +++ b/apps/web/src/app/domain-controller.ts @@ -1,6 +1,7 @@ export interface DomainError { readonly scope: "load" | "mutation" | "stream"; readonly message: string; + readonly origin?: "transport"; readonly retryLabel?: string; } diff --git a/apps/web/src/app/domain-events.ts b/apps/web/src/app/domain-events.ts index 17e906c0d..638223158 100644 --- a/apps/web/src/app/domain-events.ts +++ b/apps/web/src/app/domain-events.ts @@ -1,4 +1,5 @@ import type { SidebarTarget, ViewId } from "./view-types.js"; +import type { ApprovalRecoveryState } from "../lib/approval-recovery.js"; export type DomainEvent = | { readonly type: "workspace.cwdChanged"; readonly cwd: string } @@ -7,7 +8,8 @@ export type DomainEvent = | { readonly type: "run.completed"; readonly runId: string; readonly channelId?: string; readonly taskId?: string } | { readonly type: "run.cancelled"; readonly runId: string; readonly channelId?: string; readonly taskId?: string } | { readonly type: "run.selected"; readonly runId: string } - | { readonly type: "approval.resolved"; readonly approvalId: string; readonly runId?: string; readonly mediaJobId?: string; readonly externalWorkActionId?: string; readonly reconciliationPending?: boolean } + | { readonly type: "approval.resolved"; readonly approvalId: string; readonly runId?: string; readonly mediaJobId?: string; readonly externalWorkActionId?: string; readonly channelId?: string } + | { readonly type: "approval.recoveryChanged"; readonly cwd: string; readonly approvalId: string; readonly recoveryState: ApprovalRecoveryState; readonly runId?: string; readonly mediaJobId?: string; readonly externalWorkActionId?: string; readonly channelId?: string } | { readonly type: "approval.policyUpdated" } | { readonly type: "provider.repairRequested"; readonly providerPresetId?: string; readonly sourceRunId?: string } | { readonly type: "task.repairRequested"; readonly taskId: string; readonly sourceRunId?: string } diff --git a/apps/web/src/app/use-channel-actions-controller.ts b/apps/web/src/app/use-channel-actions-controller.ts index 7a7e76c0e..263d03b3e 100644 --- a/apps/web/src/app/use-channel-actions-controller.ts +++ b/apps/web/src/app/use-channel-actions-controller.ts @@ -38,6 +38,7 @@ import { type ChannelStreamErrorOwner, type ChannelStreamErrorOwnerHint, } from "../shared/conversation/stream-error-ownership.js"; +import { DaemonObservationDeferredError, isDaemonObservationPausedForWorkspace, liveDaemonObservation, shouldDeferDaemonObservationError, type DaemonObservation } from "../shared/conversation/observation/daemon-observation.js"; import type { AiTeammate, Channel, @@ -173,6 +174,7 @@ interface CreateTaskFromSlashCommandInput { } export function useChannelActionsController(input: { + readonly observation?: DaemonObservation; readonly form: RunFormState; readonly setForm: Dispatch>; readonly channels: readonly Channel[]; @@ -235,6 +237,11 @@ export function useChannelActionsController(input: { readonly refreshRuntimeState: (cwd?: string) => Promise; readonly refreshChannelActivity: () => Promise; }): ChannelActionsController { + const observationRef = useRef(input.observation ?? liveDaemonObservation); + observationRef.current = input.observation ?? liveDaemonObservation; + function assertObservationOpen(cwd = input.form.cwd.trim()): void { + if (isDaemonObservationPausedForWorkspace(observationRef.current, cwd)) throw new DaemonObservationDeferredError(); + } const uploadOwnerKey = JSON.stringify([input.form.cwd.trim(), input.selectedChannelId, input.selectedChannelBranchRootThreadId ?? "root"]); const uploadOwnerRef = useRef({ key: uploadOwnerKey, pending: 0 }); if (uploadOwnerRef.current.key !== uploadOwnerKey) uploadOwnerRef.current = { key: uploadOwnerKey, pending: 0 }; @@ -523,13 +530,22 @@ export function useChannelActionsController(input: { return { errorMessage }; } const channelId = input.selectedChannelId; + assertObservationOpen(); const selectedChannel = input.channels.find((channel) => channel.id === channelId); const providerPresetId = messageInput.requestAiReply ? selectedChannel?.defaultProviderPresetId || input.form.providerPresetId || input.providers.find(providerPreflightReady)?.id : undefined; - const canProject = () => input.isSelectedChannelConversation?.(channelId, messageInput.threadId) ?? true; + const observation = observationRef.current; + const submissionCwd = input.form.cwd.trim(); + const canObserveStream = () => observationRef.current.epoch === observation.epoch + && observationRef.current.baseUrl === observation.baseUrl && observationRef.current.workspaceCwd === observation.workspaceCwd + && !isDaemonObservationPausedForWorkspace(observationRef.current, submissionCwd); + const refreshObservation = async (read: () => Promise): Promise => { + if (canObserveStream()) await read(); + }; + const canProject = () => (input.isSelectedChannelConversation?.(channelId, messageInput.threadId) ?? true) && canObserveStream(); const setMessages: typeof input.setChannelMessages = (update) => { if (canProject()) input.setChannelMessages(update); }; const setItems: typeof input.setChannelConversationItems = (update) => { if (canProject()) input.setChannelConversationItems(update); }; let messageAccepted = false; @@ -537,10 +553,12 @@ export function useChannelActionsController(input: { const pendingKey = submissionLookupJournal.read(submissionScope); if (pendingKey) { const query = { surface: "channel" as const, channelId, threadId: messageInput.threadId, idempotencyKey: pendingKey }; - const receipt = await recoverSubmissionReceipt(async () => validateConversationSubmissionReceipt( - await getConversationSubmission(submissionScope.cwd, query), submissionScope.cwd, query)); + const receipt = await recoverSubmissionReceipt(async () => { + if (!canObserveStream()) throw new DaemonObservationDeferredError(); + return validateConversationSubmissionReceipt(await getConversationSubmission(submissionScope.cwd, query), submissionScope.cwd, query); + }); if (!receipt?.sourceItem) throw new UnconfirmedConversationSubmissionError(pendingKey, true); - await input.refreshChannelMessages(channelId, messageInput.threadId, { silent: true }); + await refreshObservation(() => input.refreshChannelMessages(channelId, messageInput.threadId, { silent: true })); submissionLookupJournal.forget(submissionScope, pendingKey); throw new RecoveredConversationSubmissionError(projectMessageItem(receipt.sourceItem, []).body); } @@ -605,9 +623,9 @@ export function useChannelActionsController(input: { messageInput.onReplyLinked?.(data.activeReply); } const workspaceCwd = input.form.cwd.trim(); - void Promise.allSettled([ - input.refreshRuns(workspaceCwd), - input.refreshRuntimeState(workspaceCwd), + if (canObserveStream()) void Promise.allSettled([ + refreshObservation(() => input.refreshRuns(workspaceCwd)), + refreshObservation(() => input.refreshRuntimeState(workspaceCwd)), ]); return; } @@ -671,18 +689,15 @@ export function useChannelActionsController(input: { } }); if (!messageAccepted) throw new Error("Pending confirmation"); - await input.refreshChannelMessages(channelId, messageInput.threadId, { silent: true }); - if (!canProject()) return {}; - await input.refreshChannelExecutionProposals(channelId); - await input.refreshChannelIntentDecisions(channelId); - if (receivedAiReply) { - await input.refreshUsedMemoryAudit(input.form.cwd.trim()); - } - await input.refreshChannelMessageRouteDecisions(channelId); - await input.refreshChannelFiles(channelId); - if (autoStartedWorkflow) { - await input.refreshChannelActivity(); - } + if (!canObserveStream()) return {}; + await refreshObservation(() => input.refreshChannelMessages(channelId, messageInput.threadId, { silent: true })); + if (!canObserveStream()) return {}; + await refreshObservation(() => input.refreshChannelExecutionProposals(channelId)); + await refreshObservation(() => input.refreshChannelIntentDecisions(channelId)); + if (receivedAiReply) await refreshObservation(() => input.refreshUsedMemoryAudit(submissionCwd)); + await refreshObservation(() => input.refreshChannelMessageRouteDecisions(channelId)); + await refreshObservation(() => input.refreshChannelFiles(channelId)); + if (autoStartedWorkflow) await refreshObservation(() => input.refreshChannelActivity()); // A failed or blocked AI reply owns its provider error. The channel // feed renders that error beside the persisted assistant message; keep // Composer feedback for transport failures that have no reply record to @@ -701,7 +716,7 @@ export function useChannelActionsController(input: { if (streamErrorMessage) { if (messageAccepted) { await Promise.allSettled([ - input.refreshChannelMessages(channelId, messageInput.threadId, { silent: true }), + refreshObservation(() => input.refreshChannelMessages(channelId, messageInput.threadId, { silent: true })), ]); } return { @@ -712,17 +727,31 @@ export function useChannelActionsController(input: { }), }; } + if (shouldDeferDaemonObservationError(error, observationRef.current, submissionCwd)) { + if (messageAccepted) return {}; + throw new UnconfirmedConversationSubmissionError(idempotencyKey); + } let rejectionConfirmed = false; if (!messageAccepted) { + if (isDaemonObservationPausedForWorkspace(observationRef.current, submissionCwd)) { + reportFeedback("composer", composerFeedbackIdentity(channelId), "stream", error); + throw error; + } const query = { surface: "channel" as const, channelId, threadId: messageInput.threadId, idempotencyKey }; - const receipt = await recoverSubmissionReceipt(async () => validateConversationSubmissionReceipt( - await getConversationSubmission(submissionScope.cwd, query), submissionScope.cwd, query)); - rejectionConfirmed = submissionWasRejected(error, receipt); + const receipt = await recoverSubmissionReceipt(async () => { + if (!canObserveStream()) throw new DaemonObservationDeferredError(); + return validateConversationSubmissionReceipt(await getConversationSubmission(submissionScope.cwd, query), submissionScope.cwd, query); + }); + rejectionConfirmed = canObserveStream() && submissionWasRejected(error, receipt); if (receipt?.sourceItem) { messageAccepted = true; submissionLookupJournal.forget(submissionScope, idempotencyKey); messageInput.onMessageAccepted?.(projectMessageItem(receipt.sourceItem, [])); } else if (!rejectionConfirmed) { + if (!canObserveStream()) { + reportFeedback("composer", composerFeedbackIdentity(channelId), "stream", error); + throw error; + } throw new UnconfirmedConversationSubmissionError(idempotencyKey); } } @@ -730,7 +759,7 @@ export function useChannelActionsController(input: { if (messageAccepted) { // Reconcile the original accepted turn; a broken stream is not a resend request. await Promise.allSettled([ - input.refreshChannelMessages(channelId, messageInput.threadId, { silent: true }), + refreshObservation(() => input.refreshChannelMessages(channelId, messageInput.threadId, { silent: true })), ]); } throw error; @@ -1034,29 +1063,30 @@ export function useChannelActionsController(input: { } } + const guard = any>(action: T): T => (async (...args: Parameters) => { assertObservationOpen(); return action(...args); }) as T; return { - saveChannel, - saveDefaultChannel, - saveChannelEdit, - saveChannelBranch, - archiveSidebarChannel, - archiveSidebarChannelBranch, - removeChannel, - saveTask, - createTaskFromSlashCommand, - sendChannelMessage, - uploadChannelFiles, - addChannelLinkPreview, - removeChannelAttachment, - saveChannelBrief, - saveChannelProtocolPatch, - revertChannelProtocolSettings, - supersedeChannelDecision, - rejectChannelDecision, - createHandoffFromTask, - updateHandoffStatusValue, - startWorkflowFromHandoff, - createTaskFromContextSummary, - runActivityTriageAction, + saveChannel: guard(saveChannel), + saveDefaultChannel: guard(saveDefaultChannel), + saveChannelEdit: guard(saveChannelEdit), + saveChannelBranch: guard(saveChannelBranch), + archiveSidebarChannel: guard(archiveSidebarChannel), + archiveSidebarChannelBranch: guard(archiveSidebarChannelBranch), + removeChannel: guard(removeChannel), + saveTask: guard(saveTask), + createTaskFromSlashCommand: guard(createTaskFromSlashCommand), + sendChannelMessage: guard(sendChannelMessage), + uploadChannelFiles: guard(uploadChannelFiles), + addChannelLinkPreview: guard(addChannelLinkPreview), + removeChannelAttachment: guard(removeChannelAttachment), + saveChannelBrief: guard(saveChannelBrief), + saveChannelProtocolPatch: guard(saveChannelProtocolPatch), + revertChannelProtocolSettings: guard(revertChannelProtocolSettings), + supersedeChannelDecision: guard(supersedeChannelDecision), + rejectChannelDecision: guard(rejectChannelDecision), + createHandoffFromTask: guard(createHandoffFromTask), + updateHandoffStatusValue: guard(updateHandoffStatusValue), + startWorkflowFromHandoff: guard(startWorkflowFromHandoff), + createTaskFromContextSummary: guard(createTaskFromContextSummary), + runActivityTriageAction: guard(runActivityTriageAction), }; } diff --git a/apps/web/src/app/use-channel-workflow-controller.ts b/apps/web/src/app/use-channel-workflow-controller.ts index a07605f57..abf48d0c1 100644 --- a/apps/web/src/app/use-channel-workflow-controller.ts +++ b/apps/web/src/app/use-channel-workflow-controller.ts @@ -1,4 +1,5 @@ import type { Dispatch, SetStateAction } from "react"; +import { parseChannelWorkflowStepStartResponse } from "@ling-stack/contracts"; import type { RunsActions } from "../domains/runs/use-runs-domain.js"; import type { useWorkspaceCommandController } from "../domains/workspace/use-workspace-command-controller.js"; import { providerPreflightReady } from "../lib/providers.js"; @@ -11,6 +12,7 @@ import type { ChannelWorkflow, ChannelWorkflowStep, ChannelWorkflowStepStartMode, + ChannelWorkflowStepStartResponse, ProviderPreset, RunFormState, Task, @@ -18,6 +20,30 @@ import type { type WorkspaceCommands = ReturnType; +export function publishChannelWorkflowStepStartResponse( + response: ChannelWorkflowStepStartResponse, + input: { + readonly setChannelWorkflows: Dispatch>; + readonly setChannelWorkflowSteps: Dispatch>; + readonly upsertApprovalRecord: (approval: ApprovalRequest) => void; + readonly reportWorkflowError: (identity: string, message: string, scope?: "validation" | "mutation" | "stream") => void; + }, +): boolean { + input.setChannelWorkflows((current) => current.some((item) => item.id === response.workflow.id) + ? current.map((item) => item.id === response.workflow.id ? response.workflow : item) + : [response.workflow, ...current]); + input.setChannelWorkflowSteps((current) => mergeWorkflowSteps(current, [response.workflowStep])); + if (response.status === "waiting_for_approval") { + input.upsertApprovalRecord(response.approval); + return false; + } + if (response.status === "blocked") { + input.reportWorkflowError(response.workflowStep.id, response.blocker); + return false; + } + return true; +} + export interface ChannelWorkflowController { readonly startAutoplanWorkflow: () => Promise; readonly orchestrateWorkflowAction: (workflow: ChannelWorkflow) => Promise; @@ -205,44 +231,79 @@ export function useChannelWorkflowController(input: { const workflow = input.channelWorkflows.find((item) => item.id === step.workflowId); const channel = workflow ? input.channels.find((item) => item.id === workflow.channelId) : input.channels.find((item) => item.id === step.channelId); const providerPresetId = + step.metadata?.providerBinding?.providerPresetId || input.taskRunProviderId || + step.metadata?.providerPresetId || channel?.defaultProviderPresetId || input.form.providerPresetId || input.providers.find(providerPreflightReady)?.id; - const request = { - mode, + const runnerId = step.metadata?.requestedRunnerId; + const requestBasis = { providerPresetId, + runnerId, assigneeTeammateId: step.toParticipantId, }; + const expectedStart = { + workflowId: step.workflowId, + channelId: step.channelId, + workflowStep: { + id: step.id, + workflowId: step.workflowId, + channelId: step.channelId, + title: step.title, + prompt: step.prompt, + toParticipantId: step.toParticipantId, + }, + taskTitle: step.title, + taskPrompt: step.prompt, + providerPresetId, + runnerId, + assigneeTeammateId: step.toParticipantId, + skipProviderPreflight: false, + }; input.setRunningWorkflowStepId(step.id); try { input.clearWorkflowError(step.id); if (mode === "create_task") { - const response = await input.startChannelWorkflowStep(workspaceCwd, step.id, request); - if (response.workflow) { - input.setChannelWorkflows((current) => current.map((item) => item.id === response.workflow?.id ? response.workflow : item)); + const request = { ...requestBasis, mode: "create_task" as const }; + const response = await input.startChannelWorkflowStep(workspaceCwd, step.id, request, { + ...expectedStart, + }); + publishChannelWorkflowStepStartResponse(response, input); + if (response.status !== "started") { + await Promise.all([input.refreshTasks(step.channelId), input.refreshChannelProtocol(step.channelId), input.refreshRuntimeState(workspaceCwd)]); + return; } - input.setChannelWorkflowSteps((current) => mergeWorkflowSteps(current, [response.workflowStep])); const createdTask = response.task; if (createdTask) { input.setSelectedTaskId(createdTask.id); input.setTasks((current) => [createdTask, ...current.filter((item) => item.id !== createdTask.id)]); } - if (response.blocker) { - input.reportWorkflowError(step.id, response.blocker); - } await Promise.all([input.refreshTasks(step.channelId), input.refreshChannelProtocol(step.channelId), input.refreshRuntimeState(workspaceCwd)]); return; } + const request = { ...requestBasis, mode: "create_and_run_task" as const }; const url = `${input.daemonBaseUrl}/workflow-steps/${encodeURIComponent(step.id)}/start?cwd=${encodeURIComponent(workspaceCwd)}`; - await input.streamRunRequest(url, request, workspaceCwd, async () => { + const admission = await input.streamRunRequest(url, request, workspaceCwd, async () => { await Promise.all([ input.refreshTasks(step.channelId), input.refreshChannelProtocol(step.channelId), ]); - }); + }, (value) => { + const parsed = parseChannelWorkflowStepStartResponse(value, { + ...expectedStart, + mode: request.mode, + }); + if (!parsed || parsed.status === "started") { + throw new Error("Workflow step start returned an invalid admission response or foreign lineage."); + } + return parsed; + }, { disableRpc: true }); + if (admission) { + publishChannelWorkflowStepStartResponse(admission, input); + } await Promise.all([input.refreshTasks(step.channelId), input.refreshChannelProtocol(step.channelId), input.refreshRuntimeState(workspaceCwd)]); } catch (error) { input.reportWorkflowError(step.id, error instanceof Error ? error.message : String(error)); diff --git a/apps/web/src/app/use-control-plane-invalidation.ts b/apps/web/src/app/use-control-plane-invalidation.ts index 1896087dc..ce823dbc0 100644 --- a/apps/web/src/app/use-control-plane-invalidation.ts +++ b/apps/web/src/app/use-control-plane-invalidation.ts @@ -1,4 +1,6 @@ import { useEffect, useRef } from "react"; +import { LingDaemonTransportError } from "@ling-stack/api-client"; +import { DaemonObservationDeferredError, isDaemonObservationPausedForWorkspace, liveDaemonObservation, type DaemonObservation } from "../shared/conversation/observation/daemon-observation.js"; import { getControlPlaneSnapshot } from "../api/control-plane.js"; import { controlPlaneRpcStreamUrl, @@ -27,6 +29,7 @@ export interface ControlPlaneInvalidationController { export function useControlPlaneInvalidation(input: { readonly daemonBaseUrl: string; readonly daemonHealthy: boolean | undefined; + readonly observation?: DaemonObservation; readonly localWebSocketRpcEnabled: boolean; readonly workspaceCwd: string; readonly selectedChannelId: string; @@ -34,7 +37,8 @@ export function useControlPlaneInvalidation(input: { readonly channelRunsForArtifacts: readonly RunSession[]; readonly setSkillLibraryRefreshToken: (update: (current: number) => number) => void; readonly refreshRuns: (cwd?: string) => Promise; - readonly refreshSelectedConversation: (cwd?: string) => Promise; + readonly refreshConversationThreads: (cwd?: string, options?: { readonly throwOnError?: boolean }) => Promise; + readonly refreshSelectedConversation: (cwd?: string, options?: { readonly throwOnError?: boolean }) => Promise; readonly refreshRuntimeState: (cwd?: string) => Promise; readonly refreshApprovals: (cwd?: string, runId?: string) => Promise; readonly refreshApprovalPolicy: () => Promise; @@ -52,13 +56,18 @@ export function useControlPlaneInvalidation(input: { }): ControlPlaneInvalidationController { const workspaceIdentity = input.workspaceCwd.trim() || "workspace:unavailable"; const errorOwner = useFocusedDomainErrorOwner<"control-plane", string>(workspaceIdentity); + const observationRef = useRef(input.observation ?? liveDaemonObservation); + observationRef.current = input.observation ?? liveDaemonObservation; + const observationPaused = isDaemonObservationPausedForWorkspace(observationRef.current, input.workspaceCwd); const invalidationRef = useRef<(domains: readonly ControlPlaneDomain[]) => Promise>(async () => undefined); invalidationRef.current = async (domains) => { const workspaceCwd = input.workspaceCwd; + if (isDaemonObservationPausedForWorkspace(observationRef.current, workspaceCwd)) throw new DaemonObservationDeferredError(); if (!workspaceCwd) { return; } const changed = new Set(domains); + const observationEpoch = observationRef.current.epoch; if (domains.some((domain) => ["runs", "work_units", "approvals", "artifacts", "channels", "teammates"].includes(domain))) { window.dispatchEvent(new CustomEvent("ling:work-summary-invalidated", { detail: { cwd: workspaceCwd } })); } @@ -69,11 +78,16 @@ export function useControlPlaneInvalidation(input: { errorOwner.clear(request); refreshes.push(refresh.then( (value) => { + if (observationRef.current.epoch !== observationEpoch + || isDaemonObservationPausedForWorkspace(observationRef.current, workspaceCwd)) throw new DaemonObservationDeferredError(); errorOwner.clear(request); return value; }, (caught: unknown) => { - errorOwner.report(request, { scope: "stream", message: domainErrorMessage(caught) }); + if (!(caught instanceof DaemonObservationDeferredError)) { + errorOwner.report(request, { scope: "stream", message: domainErrorMessage(caught), + origin: caught instanceof LingDaemonTransportError ? "transport" : undefined }); + } throw caught; }, )); @@ -139,8 +153,11 @@ export function useControlPlaneInvalidation(input: { const conversationDomain = (["runs", "work_units", "approvals", "artifacts"] as const) .find((domain) => changed.has(domain)); if (conversationDomain) { + // The POST stream can disappear while execution continues. Recover the + // global recent list too, even when a different surface is selected. + trackRefresh(conversationDomain, "conversation-list", input.refreshConversationThreads(workspaceCwd, { throwOnError: true })); if (!input.selectedChannelId) { - trackRefresh(conversationDomain, "selected-conversation", input.refreshSelectedConversation(workspaceCwd)); + trackRefresh(conversationDomain, "selected-conversation", input.refreshSelectedConversation(workspaceCwd, { throwOnError: true })); } else if (!changed.has("runs") && !channelActivityOwnsSelectedRefreshes) { trackRefresh( conversationDomain, @@ -157,7 +174,7 @@ export function useControlPlaneInvalidation(input: { useEffect(() => { const workspaceCwd = input.workspaceCwd; - if (!workspaceCwd || input.daemonHealthy !== true) { + if (!workspaceCwd || input.daemonHealthy !== true || observationPaused) { return undefined; } let closed = false; @@ -405,13 +422,16 @@ export function useControlPlaneInvalidation(input: { }, [ input.daemonBaseUrl, input.daemonHealthy, + observationPaused, + input.observation?.epoch, input.localWebSocketRpcEnabled, input.workspaceCwd, ]); + const errors = errorOwner.errors().filter(error => !observationPaused || error.origin !== "transport"); return { - errors: errorOwner.errors() as readonly ControlPlaneInvalidationError[], - error: errorOwner.latest() as ControlPlaneInvalidationError | undefined, + errors: errors as readonly ControlPlaneInvalidationError[], + error: errors.at(-1) as ControlPlaneInvalidationError | undefined, clearError: errorOwner.clearAll, }; } diff --git a/apps/web/src/app/use-desktop-daemon-status.ts b/apps/web/src/app/use-desktop-daemon-status.ts new file mode 100644 index 000000000..eb66e4a19 --- /dev/null +++ b/apps/web/src/app/use-desktop-daemon-status.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; +import type { DesktopDaemonStatus } from "../../../desktop/shared/desktop-capabilities.js"; + +/** One shell-level Host projection shared by ordinary, private and channel views. */ +export function useDesktopDaemonStatus(): DesktopDaemonStatus | undefined { + const [status, setStatus] = useState(); + useEffect(() => { + if (typeof window === "undefined" || !window.lingDesktop) return; + return window.lingDesktop.daemon.onStatusChanged(setStatus); + }, []); + return status; +} diff --git a/apps/web/src/app/use-focused-domain-error-owner.ts b/apps/web/src/app/use-focused-domain-error-owner.ts index d03bb387e..b1aec42bb 100644 --- a/apps/web/src/app/use-focused-domain-error-owner.ts +++ b/apps/web/src/app/use-focused-domain-error-owner.ts @@ -35,13 +35,13 @@ export interface FocusedDomainErrorOwner< readonly clear: (request: FocusedErrorRequest) => boolean; readonly report: ( request: FocusedErrorRequest, - error: Pick, + error: Pick, ) => boolean; readonly reportImmediate: ( domain: TDomain, objectId: string, action: TAction, - error: Pick, + error: Pick, ) => FocusedErrorRequest; readonly invalidateRequests: () => void; readonly clearAll: () => void; @@ -119,6 +119,7 @@ export function createFocusedDomainErrorOwner< requestId: request.requestId, retryLabel: error.retryLabel, scope: error.scope, + origin: error.origin, }, order: ++reportSequence, }); diff --git a/apps/web/src/app/useFullAccessConfirmationController.ts b/apps/web/src/app/useFullAccessConfirmationController.ts index c13ae28f4..d431fc2e3 100644 --- a/apps/web/src/app/useFullAccessConfirmationController.ts +++ b/apps/web/src/app/useFullAccessConfirmationController.ts @@ -62,7 +62,12 @@ export function useFullAccessConfirmationController(): FullAccessConfirmationCon if (confirmed) return Promise.resolve(confirmed); if (pendingRef.current) return Promise.resolve(undefined); const activeElement = document.activeElement; - returnFocusTargetRef.current = activeElement instanceof HTMLElement ? activeElement : null; + // A compact menu item unmounts as the confirmation opens. Restore focus to + // its persistent trigger instead of remembering the disappearing item. + const menuTriggerId = activeElement?.closest('[role="menu"]')?.getAttribute("aria-labelledby"); + const menuTrigger = menuTriggerId ? document.getElementById(menuTriggerId) : null; + returnFocusTargetRef.current = menuTrigger instanceof HTMLElement ? menuTrigger + : activeElement instanceof HTMLElement ? activeElement : null; return new Promise((resolve) => { pendingRef.current = { resolve, scopeKey }; setOpen(true); diff --git a/apps/web/src/domains/approvals/use-approvals-domain.ts b/apps/web/src/domains/approvals/use-approvals-domain.ts index 9066346b6..e817ced76 100644 --- a/apps/web/src/domains/approvals/use-approvals-domain.ts +++ b/apps/web/src/domains/approvals/use-approvals-domain.ts @@ -15,6 +15,8 @@ import { mergeApprovalSnapshot, mergeApprovalsForRun, upsertApproval } from "../ import { removeRecordKey } from "../../lib/records.js"; import { daemonBaseUrl } from "../../api/client.js"; import { approvalDecisionLookupJournal } from "../../lib/approval-decision-lookup-journal.js"; +import { approvalReceiptRecoveryState, type ApprovalRecoveryState } from "../../lib/approval-recovery.js"; +import type { ApprovalDecisionLookup } from "../../lib/approval-decision-lookup-journal.js"; import type { ApprovalMutationResponse, ApprovalRequest, @@ -108,14 +110,30 @@ export function useApprovalsDomain(input: { const externalDetailRequestRef = useRef(new Map()); const externalMutationRequestRef = useRef(new Map()); const externalActionBusyRef = useRef(new Map()); + const recoveryTimerRef = useRef | undefined>(undefined); + const recoveryDelayRef = useRef(500); + const recoveryReadsRef = useRef(new Set()); + const recoveryStatesRef = useRef(new Map()); + const mountedRef = useRef(true); cwdRef.current = input.cwd.trim(); emitEventRef.current = input.emitEvent; + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + listRequestSequenceRef.current += 1; + externalMutationRequestRef.current.clear(); + clearRecoveryTracking(); + }; + }, []); + useEffect(() => { const cwd = input.cwd.trim(); if (previousCwdRef.current === cwd) return; previousCwdRef.current = cwd; + clearRecoveryTracking(); // Startup may request the selected workspace before its render commits. if (listRequestCwdRef.current !== cwd) listRequestSequenceRef.current += 1; resolvingApprovalsRef.current.clear(); @@ -165,40 +183,94 @@ export function useApprovalsDomain(input: { if (!isCurrentListRequest(cwd, requestId)) return; const mutationKey = `approval:${lookup.approvalId}`; const originalMutation = externalMutationRequestRef.current.get(mutationKey); - if (resolvingApprovalsRef.current.has(lookup.approvalId)) continue; + const readKey = JSON.stringify([cwd, lookup.approvalId, lookup.token]); + if (resolvingApprovalsRef.current.has(lookup.approvalId) || recoveryReadsRef.current.has(readKey)) continue; + recoveryReadsRef.current.add(readKey); let receipt: Awaited>; try { - receipt = await getApproval(cwd, lookup.approvalId); - if (!isCurrentListRequest(cwd, requestId) - || externalMutationRequestRef.current.get(mutationKey) !== originalMutation) continue; - if (receipt?.approval?.id !== lookup.approvalId - || !["pending", "approved", "denied", "expired", "cancelled"].includes(receipt.approval.status)) { - throw new Error(t("Pending confirmation")); + try { + receipt = await getApproval(cwd, lookup.approvalId); + if (receipt?.approval?.id !== lookup.approvalId + || !["pending", "approved", "denied", "expired", "cancelled"].includes(receipt.approval.status)) { + throw new Error(t("Pending confirmation")); + } + } catch { + if (!isCurrentListRequest(cwd, requestId) + || externalMutationRequestRef.current.get(mutationKey) !== originalMutation) continue; + await publishRecoveryState(cwd, lookup, "unknown"); + continue; } - } catch { if (!isCurrentListRequest(cwd, requestId) || externalMutationRequestRef.current.get(mutationKey) !== originalMutation) continue; - setApprovalErrors(current => ({ ...current, [lookup.approvalId]: t("Pending confirmation") })); - continue; - } - try { updateApprovals({ type: "upsert", approval: receipt.approval }); - approvalDecisionLookupJournal.forget(scope, lookup); + // Pending means the original decision has not been confirmed. Keep only + // its query pointer; neither refresh nor the timer can authorize again. if (receipt.approval.status === "pending") continue; + const recoveryState = approvalReceiptRecoveryState(receipt); + await publishRecoveryState(cwd, lookup, recoveryState, receipt.approval); + if (!isCurrentListRequest(cwd, requestId) + || externalMutationRequestRef.current.get(mutationKey) !== originalMutation) continue; + if (recoveryState === "completed") approvalDecisionLookupJournal.forget(scope, lookup); setApprovalErrors(current => removeRecordKey(current, lookup.approvalId)); - await emitEventRef.current({ type: "approval.resolved", approvalId: lookup.approvalId, - runId: receipt.approval.runId, mediaJobId: receipt.approval.mediaJobId, - externalWorkActionId: receipt.approval.externalWorkActionId, - reconciliationPending: !Array.isArray(receipt.postCommitEffects) - || receipt.postCommitEffects.some(effect => !effect || effect.approvalId !== lookup.approvalId - || effect.status !== "completed"), - }); } catch (caught) { if (!isCurrentListRequest(cwd, requestId) || externalMutationRequestRef.current.get(mutationKey) !== originalMutation) continue; setError({ scope: "load", message: caught instanceof Error ? caught.message : String(caught) }); + } finally { + recoveryReadsRef.current.delete(readKey); } } + scheduleRecoveryLookup(cwd); + } + + async function publishRecoveryState(cwd: string, lookup: ApprovalDecisionLookup, state: ApprovalRecoveryState, approval?: ApprovalRequest): Promise { + if (!mountedRef.current || cwdRef.current !== cwd) return; + const previous = recoveryStatesRef.current.get(lookup.approvalId); + if (previous?.token === lookup.token && previous.state === state) return; + await emitEventRef.current({ type: "approval.recoveryChanged", cwd, approvalId: lookup.approvalId, recoveryState: state, + runId: approval?.runId, mediaJobId: approval?.mediaJobId, externalWorkActionId: approval?.externalWorkActionId, + channelId: approval?.channelId }); + if (mountedRef.current && cwdRef.current === cwd) recoveryStatesRef.current.set(lookup.approvalId, { token: lookup.token, state }); + } + + function clearRecoveryTracking(): void { + if (recoveryTimerRef.current !== undefined) window.clearTimeout(recoveryTimerRef.current); + recoveryTimerRef.current = undefined; + recoveryDelayRef.current = 500; + recoveryReadsRef.current.clear(); + recoveryStatesRef.current.clear(); + } + + function scheduleRecoveryLookup(cwd: string): void { + if (!mountedRef.current || cwdRef.current !== cwd) return; + try { + if (approvalDecisionLookupJournal.read({ daemonBaseUrl, cwd }).length === 0) { + if (recoveryTimerRef.current !== undefined) window.clearTimeout(recoveryTimerRef.current); + recoveryTimerRef.current = undefined; + recoveryDelayRef.current = 500; + return; + } + } catch (caught) { + setError({ scope: "load", message: caught instanceof Error ? caught.message : String(caught) }); + return; + } + if (recoveryTimerRef.current !== undefined) return; + // One GET-only, backoff-capped observer per mounted workspace. Keep durable + // pointers on failure/reload; do not retry POSTs or infer Run completion. + recoveryTimerRef.current = window.setTimeout(async () => { + recoveryTimerRef.current = undefined; + if (!mountedRef.current || cwdRef.current !== cwd) return; + recoveryDelayRef.current = Math.min(30_000, recoveryDelayRef.current * 2); + try { + await recoverDecisionLookups(cwd, listRequestSequenceRef.current); + } catch (caught) { + if (mountedRef.current && cwdRef.current === cwd) { + setError({ scope: "load", message: caught instanceof Error ? caught.message : String(caught) }); + } + } finally { + scheduleRecoveryLookup(cwd); + } + }, recoveryDelayRef.current); } async function refreshExternalActionDetails(cwd: string, sourceApprovals: readonly ApprovalRequest[]): Promise { @@ -303,11 +375,16 @@ export function useApprovalsDomain(input: { setResolvingApprovals((current) => ({ ...current, [approvalId]: decision })); setApprovalErrors((current) => removeRecordKey(current, approvalId)); const approvalBeforeResolution = approvals.find((approval) => approval.id === approvalId); - async function applyResolution(response: ApprovalMutationResponse): Promise { + async function applyResolution(response: ApprovalMutationResponse, observedRecoveryState?: ApprovalRecoveryState): Promise { if (!isCurrentExternalRequest(`approval:${approvalId}`, requestId, cwd, externalMutationRequestRef.current)) return; setSelectedApprovalId(response.approval.id); updateApprovals({ type: "upsert", approval: response.approval }); - approvalDecisionLookupJournal.forget(lookupScope, lookup); + const recoveryState = observedRecoveryState ?? (response.postCommit?.status === "deferred" ? "pending" : "completed"); + // The mutation emits approval.resolved below; only a later GET receipt + // needs to invalidate the dependent views through recoveryChanged. + await publishRecoveryState(cwd, lookup, recoveryState); + if (!isCurrentExternalRequest(`approval:${approvalId}`, requestId, cwd, externalMutationRequestRef.current)) return; + if (recoveryState === "completed") approvalDecisionLookupJournal.forget(lookupScope, lookup); setApprovalErrors((current) => removeRecordKey(current, approvalId)); if (response.approval.externalWorkActionId) { await refreshExternalActionDetailsByIds(cwd, [response.approval.externalWorkActionId]); @@ -319,12 +396,13 @@ export function useApprovalsDomain(input: { runId: response.approval.runId || approvalBeforeResolution?.runId, mediaJobId: response.approval.mediaJobId || approvalBeforeResolution?.mediaJobId, externalWorkActionId: response.approval.externalWorkActionId || approvalBeforeResolution?.externalWorkActionId, - reconciliationPending: response.postCommit?.status === "deferred", + channelId: response.approval.channelId || approvalBeforeResolution?.channelId, }); } try { approvalDecisionLookupJournal.remember(lookupScope, lookup); let response: ApprovalMutationResponse; + let observedRecoveryState: ApprovalRecoveryState | undefined; try { response = decision !== "deny" ? await approveApproval(cwd, approvalId, { @@ -344,13 +422,8 @@ export function useApprovalsDomain(input: { if (!isCurrentExternalRequest(`approval:${approvalId}`, requestId, cwd, externalMutationRequestRef.current)) return; if (receipt?.approval?.id === approvalId && ["approved", "denied", "expired", "cancelled"].includes(receipt.approval.status)) { - response = { - approval: receipt.approval, - ...(!Array.isArray(receipt.postCommitEffects) - || receipt.postCommitEffects.some(effect => !effect || effect.approvalId !== approvalId || effect.status !== "completed") ? { - postCommit: { status: "deferred", code: "approval_postcommit_reconciliation_required" }, - } as const : {}), - }; + response = { approval: receipt.approval }; + observedRecoveryState = approvalReceiptRecoveryState(receipt); } else { const message = caught instanceof Error ? caught.message : String(caught); await refreshApprovals(cwd); @@ -361,7 +434,7 @@ export function useApprovalsDomain(input: { } } try { - await applyResolution(response); + await applyResolution(response, observedRecoveryState); } catch (caught) { if (!isCurrentExternalRequest(`approval:${approvalId}`, requestId, cwd, externalMutationRequestRef.current)) return; const message = caught instanceof Error ? caught.message : String(caught); @@ -375,6 +448,7 @@ export function useApprovalsDomain(input: { if (isCurrentExternalRequest(`approval:${approvalId}`, requestId, cwd, externalMutationRequestRef.current)) { resolvingApprovalsRef.current.delete(approvalId); setResolvingApprovals((current) => removeRecordKey(current, approvalId)); + scheduleRecoveryLookup(cwd); } } } @@ -385,6 +459,7 @@ export function useApprovalsDomain(input: { } function resetForCwd(): void { + clearRecoveryTracking(); listRequestSequenceRef.current += 1; resolvingApprovalsRef.current.clear(); externalActionBusyRef.current.clear(); @@ -401,7 +476,7 @@ export function useApprovalsDomain(input: { } function isCurrentListRequest(cwd: string, requestId: number): boolean { - return cwdRef.current === cwd && listRequestSequenceRef.current === requestId; + return mountedRef.current && cwdRef.current === cwd && listRequestSequenceRef.current === requestId; } function isCurrentExternalRequest( @@ -410,7 +485,7 @@ export function useApprovalsDomain(input: { cwd: string, requests: ReadonlyMap, ): boolean { - return cwdRef.current === cwd && requests.get(id) === requestId; + return mountedRef.current && cwdRef.current === cwd && requests.get(id) === requestId; } function beginExternalAction(actionId: string, operation: ExternalActionOperation): number { diff --git a/apps/web/src/domains/conversations/canonical-transcript-snapshot.ts b/apps/web/src/domains/conversations/canonical-transcript-snapshot.ts index b7ef59353..f067cceb9 100644 --- a/apps/web/src/domains/conversations/canonical-transcript-snapshot.ts +++ b/apps/web/src/domains/conversations/canonical-transcript-snapshot.ts @@ -466,7 +466,7 @@ function compactionSourceRefs( ...checkpoint.summaryIds.map((id) => ({ kind: "context_summary", id })), ...checkpoint.sourceRefs.map((ref) => ({ kind: ref.type, id: ref.id, label: ref.label })), ...(checkpoint.contextViewId ? [{ kind: "context_view", id: checkpoint.contextViewId }] : []), - ...(snapshot ? [{ kind: "context_snapshot", id: snapshot.id }, { kind: "segment", id: snapshot.segmentId }] : []), + ...(snapshot ? [{ kind: "context_snapshot", id: snapshot.id }, ...(snapshot.segmentId ? [{ kind: "segment", id: snapshot.segmentId }] : [])] : []), ]); } @@ -831,7 +831,7 @@ function buildContextSegmentLookup( ): ReadonlyMap { const lookup = new Map(); for (const snapshot of snapshots) { - if (!snapshot.segmentId.trim()) continue; + if (!snapshot.segmentId?.trim()) continue; for (const itemId of snapshot.sourceItemIds) { // Newer snapshots win for the same source item; this is deterministic // because detail snapshots are immutable and ordered by source refs. @@ -852,7 +852,7 @@ export interface ContextSnapshotResolutionDiagnostic { export interface ResolvedContextSnapshot { readonly id: string; readonly threadId: string; - readonly segmentId: string; + readonly segmentId?: string; readonly projectionVersion: string; readonly throughItemSequence: number; readonly summaryIds: readonly string[]; diff --git a/apps/web/src/domains/conversations/conversation-error-owner.ts b/apps/web/src/domains/conversations/conversation-error-owner.ts index 3a0d693da..929d9c25c 100644 --- a/apps/web/src/domains/conversations/conversation-error-owner.ts +++ b/apps/web/src/domains/conversations/conversation-error-owner.ts @@ -16,6 +16,7 @@ export interface ConversationErrorEntry { readonly identity: string; readonly scope: ConversationErrorScope; readonly message: string; + readonly origin?: "transport"; readonly sequence: number; } @@ -32,6 +33,7 @@ export interface ConversationErrorQuery { readonly surface: ConversationErrorSurface; readonly identity?: string; readonly scopes?: readonly ConversationErrorScope[]; + readonly excludeTransport?: boolean; } export class ConversationErrorStore { @@ -49,14 +51,14 @@ export class ConversationErrorStore { return this.latestOperationByKey.get(conversationErrorKey(operation)) === operation.sequence; } - report(operation: ConversationErrorOperation, message: string): boolean { + report(operation: ConversationErrorOperation, message: string, origin?: "transport"): boolean { if (!this.isCurrent(operation)) return false; const normalizedMessage = message.trim(); if (!normalizedMessage) return this.clear(operation); const key = conversationErrorKey(operation); const current = this.entryByKey.get(key); - if (current?.message === normalizedMessage && current.sequence === operation.sequence) return false; - this.entryByKey.set(key, { ...operation, message: normalizedMessage }); + if (current?.message === normalizedMessage && current.sequence === operation.sequence && current.origin === origin) return false; + this.entryByKey.set(key, { ...operation, message: normalizedMessage, origin }); return true; } @@ -80,6 +82,7 @@ export function selectConversationError( && entry.surface === query.surface && (query.identity === undefined || entry.identity === query.identity) && (!scopes || scopes.has(entry.scope)) + && (!query.excludeTransport || entry.origin !== "transport") ); } @@ -87,7 +90,7 @@ export function useConversationErrorOwner(): { readonly entries: readonly ConversationErrorEntry[]; readonly begin: (input: Omit) => ConversationErrorOperation; readonly isCurrent: (operation: ConversationErrorOperation) => boolean; - readonly report: (operation: ConversationErrorOperation, message: string) => void; + readonly report: (operation: ConversationErrorOperation, message: string, origin?: "transport") => void; readonly clear: (operation: ConversationErrorOperation) => void; } { const storeRef = useRef(undefined); @@ -106,8 +109,8 @@ export function useConversationErrorOwner(): { if (changed) setEntries(storeRef.current!.entries()); }, []); - const report = useCallback((operation: ConversationErrorOperation, message: string): void => { - publishIfChanged(storeRef.current!.report(operation, message)); + const report = useCallback((operation: ConversationErrorOperation, message: string, origin?: "transport"): void => { + publishIfChanged(storeRef.current!.report(operation, message, origin)); }, [publishIfChanged]); const clear = useCallback((operation: ConversationErrorOperation): void => { diff --git a/apps/web/src/domains/conversations/use-conversation-domain.ts b/apps/web/src/domains/conversations/use-conversation-domain.ts index 9971536f0..4ca9c08d5 100644 --- a/apps/web/src/domains/conversations/use-conversation-domain.ts +++ b/apps/web/src/domains/conversations/use-conversation-domain.ts @@ -1,7 +1,8 @@ import type { ExecutionBindingRequest } from "@ling-stack/contracts"; import { useCallback, useEffect, useRef, useState } from "react"; import { archiveLingThread, getConversation, getConversationSubmission, listLingThreads, streamConversation } from "../../api.js"; -import { LingDaemonError } from "@ling-stack/api-client"; +import { LingDaemonError, LingDaemonTransportError } from "@ling-stack/api-client"; +import { DaemonObservationDeferredError, isDaemonObservationPausedForWorkspace, liveDaemonObservation, type DaemonObservation } from "../../shared/conversation/observation/daemon-observation.js"; import { recoverSubmissionReceipt, submissionWasRejected } from "../../shared/conversation/conversation-submission-recovery.js"; import { submissionLookupJournal } from "../../shared/conversation/conversation-submission-journal.js"; import { validateConversationSubmissionReceipt } from "../../shared/conversation/submission-receipt-scope.js"; @@ -69,8 +70,14 @@ export interface ConversationStreamHandlers { readonly shouldCommit?: (threadId?: string) => boolean; } +interface AcceptedStreamObservation { + readonly operation: ConversationErrorOperation; + readonly sourceItemId: string; +} + export function useConversationDomain(input: { readonly cwd: string; + readonly observation?: DaemonObservation; readonly translate: (key: string, values?: Record) => string; }): { readonly state: { @@ -85,8 +92,8 @@ export function useConversationDomain(input: { readonly conversationComposerError: string; }; readonly actions: { - readonly refreshConversationThreads: (cwd?: string) => Promise; - readonly refreshSelectedConversation: (cwd?: string) => Promise; + readonly refreshConversationThreads: (cwd?: string, options?: { readonly throwOnError?: boolean }) => Promise; + readonly refreshSelectedConversation: (cwd?: string, options?: { readonly throwOnError?: boolean }) => Promise; readonly openNewConversation: () => void; readonly selectConversationThread: ( threadId: string, @@ -113,8 +120,44 @@ export function useConversationDomain(input: { const threadListLoadingOperationsRef = useRef(0); currentCwdRef.current = input.cwd.trim(); const conversationErrors = useConversationErrorOwner(); + const acceptedStreamObservationsRef = useRef(new Map()); const detailReadEpochRef = useRef(createConversationReadEpoch()); const conversationStreamBusyOwner = useConversationStreamBusyOwner(); + const observationRef = useRef(input.observation ?? liveDaemonObservation); + observationRef.current = input.observation ?? liveDaemonObservation; + const observationPaused = isDaemonObservationPausedForWorkspace(observationRef.current, input.cwd); + const canObserve = (cwd: string): boolean => !isDaemonObservationPausedForWorkspace(observationRef.current, cwd); + const reportObservationError = useCallback((operation: ConversationErrorOperation, error: unknown): void => { + conversationErrors.report(operation, error instanceof Error ? error.message : String(error), + error instanceof LingDaemonTransportError ? "transport" : undefined); + }, [conversationErrors]); + + useEffect(() => { + // Late successes from a previous observation cannot certify the current UI. + detailReadEpochRef.current.invalidate(); + }, [input.observation?.epoch]); + + const reconcileStreamObservations = useCallback(( + detail: ConversationDetailResponse, + observations: readonly AcceptedStreamObservation[], + ): void => { + for (const observation of observations) { + const { operation, sourceItemId } = observation; + if (acceptedStreamObservationsRef.current.get(operation.sequence) !== observation) continue; + if (!conversationErrors.isCurrent(operation)) { + acceptedStreamObservationsRef.current.delete(operation.sequence); + continue; + } + // Only a read started after this failure, containing the exact accepted + // source in its own scope, recovers transport observation. Host execution + // errors remain in the canonical transcript, even when observation heals. + if (detail.cwd !== operation.workspaceCwd || detail.thread.id !== operation.identity + || !detail.messages.some(message => message.id === sourceItemId && message.role === "user" + && message.threadId === operation.identity)) continue; + conversationErrors.clear(operation); + acceptedStreamObservationsRef.current.delete(operation.sequence); + } + }, [conversationErrors]); const beginThreadListLoading = useCallback((): void => { threadListLoadingOperationsRef.current += 1; @@ -151,8 +194,15 @@ export function useConversationDomain(input: { commitConversationSurfaceIdentity(createConversationDraftIdentity(draftGeneration)); }, [commitConversationSurfaceIdentity, commitSelectedConversationThreadId, input.cwd]); - const refreshConversationThreads = useCallback(async (cwd = input.cwd): Promise => { + const refreshConversationThreads = useCallback(async ( + cwd = input.cwd, + options?: { readonly throwOnError?: boolean }, + ): Promise => { const workspaceCwd = cwd.trim(); + if (!canObserve(workspaceCwd)) { + if (options?.throwOnError) throw new DaemonObservationDeferredError(); + return; + } if (!workspaceCwd) { setConversationThreads([]); return; @@ -165,17 +215,22 @@ export function useConversationDomain(input: { }); conversationErrors.clear(operation); beginThreadListLoading(); + const observationEpoch = observationRef.current.epoch; try { const response = await listLingThreads(workspaceCwd, { kind: "conversation", archived: "exclude", limit: 20 }); - if (conversationErrors.isCurrent(operation) && currentCwdRef.current === workspaceCwd) { + if (canObserve(workspaceCwd) && observationRef.current.epoch === observationEpoch + && conversationErrors.isCurrent(operation) && currentCwdRef.current === workspaceCwd) { setConversationThreads(response.threads); + } else if (options?.throwOnError) { + throw new DaemonObservationDeferredError(); } } catch (error) { - conversationErrors.report(operation, error instanceof Error ? error.message : String(error)); + if (!(error instanceof DaemonObservationDeferredError)) reportObservationError(operation, error); + if (options?.throwOnError) throw error; } finally { finishThreadListLoading(); } - }, [beginThreadListLoading, conversationErrors, finishThreadListLoading, input.cwd]); + }, [beginThreadListLoading, conversationErrors, finishThreadListLoading, input.cwd, reportObservationError]); const openNewConversation = useCallback((): void => { detailReadEpochRef.current.invalidate(); @@ -194,6 +249,7 @@ export function useConversationDomain(input: { shouldCommit: () => boolean = () => true, ): Promise => { const workspaceCwd = cwd.trim(); + if (!canObserve(workspaceCwd)) return false; if (!workspaceCwd || !threadId) { if (shouldCommit()) { openNewConversation(); @@ -214,12 +270,15 @@ export function useConversationDomain(input: { scope: "select", }); conversationErrors.clear(operation); + const observations = [...acceptedStreamObservationsRef.current.values()]; + const observationEpoch = observationRef.current.epoch; try { const detail = await getConversation(workspaceCwd, threadId); if (detail.thread.id !== threadId) throw new Error("Conversation response scope mismatch."); conversationErrors.clear(operation); if ( !detailReadEpochRef.current.isCurrent(detailRead) + || !canObserve(workspaceCwd) || observationRef.current.epoch !== observationEpoch || selectionSequence !== selectionSequenceRef.current || currentCwdRef.current !== workspaceCwd || !shouldCommit() @@ -228,10 +287,11 @@ export function useConversationDomain(input: { } commitSelectedConversationThreadId(threadId); setSelectedConversationDetail(mergeConversationDetailSnapshots(undefined, detail)); + reconcileStreamObservations(detail, observations); commitConversationSurfaceIdentity(threadId); return true; } catch (error) { - conversationErrors.report(operation, error instanceof Error ? error.message : String(error)); + reportObservationError(operation, error); if ( selectionSequence !== selectionSequenceRef.current || currentCwdRef.current !== workspaceCwd @@ -245,11 +305,18 @@ export function useConversationDomain(input: { selectionInFlightRef.current = undefined; } } - }, [commitConversationSurfaceIdentity, commitSelectedConversationThreadId, conversationErrors, input.cwd, openNewConversation]); + }, [commitConversationSurfaceIdentity, commitSelectedConversationThreadId, conversationErrors, input.cwd, openNewConversation, reconcileStreamObservations, reportObservationError]); - const refreshSelectedConversation = useCallback(async (cwd = input.cwd): Promise => { + const refreshSelectedConversation = useCallback(async (cwd = input.cwd, options?: { readonly throwOnError?: boolean }): Promise => { + if (!canObserve(cwd)) { + if (options?.throwOnError) throw new DaemonObservationDeferredError(); + return; + } // A background refresh must not supersede a navigation selection read. - if (selectionInFlightRef.current !== undefined) return; + if (selectionInFlightRef.current !== undefined) { + if (options?.throwOnError) throw new DaemonObservationDeferredError(); + return; + } const workspaceCwd = cwd.trim(); const requestedThreadId = selectedConversationThreadIdRef.current; if (!workspaceCwd || !requestedThreadId || conversationSurfaceIdentityRef.current !== requestedThreadId) { @@ -263,22 +330,29 @@ export function useConversationDomain(input: { }); const detailRead = detailReadEpochRef.current.begin(JSON.stringify([workspaceCwd, requestedThreadId])); conversationErrors.clear(operation); + const observations = [...acceptedStreamObservationsRef.current.values()]; + const observationEpoch = observationRef.current.epoch; try { const detail = await getConversation(workspaceCwd, requestedThreadId); if (detail.thread.id !== requestedThreadId) throw new Error("Conversation response scope mismatch."); conversationErrors.clear(operation); if ( detailReadEpochRef.current.isCurrent(detailRead) + && canObserve(workspaceCwd) && observationRef.current.epoch === observationEpoch && conversationErrors.isCurrent(operation) && currentCwdRef.current === workspaceCwd && selectedConversationThreadIdRef.current === requestedThreadId ) { setSelectedConversationDetail((current) => mergeConversationDetailSnapshots(current, detail)); + reconcileStreamObservations(detail, observations); + } else if (options?.throwOnError) { + throw new DaemonObservationDeferredError(); } } catch (error) { - conversationErrors.report(operation, error instanceof Error ? error.message : String(error)); + if (!(error instanceof DaemonObservationDeferredError)) reportObservationError(operation, error); + if (options?.throwOnError) throw error; } - }, [conversationErrors, input.cwd]); + }, [conversationErrors, input.cwd, reconcileStreamObservations, reportObservationError]); const archiveConversationThread = useCallback(async (threadId: string, cwd = input.cwd): Promise => { const workspaceCwd = cwd.trim(); @@ -385,6 +459,7 @@ export function useConversationDomain(input: { const originSurfaceIdentity = conversationSurfaceIdentityRef.current; let activeThreadId = originThreadId; let messageAccepted = false; + let acceptedSourceItemId = ""; let submitted = false; const submissionScope = { cwd: workspaceCwd, surface: "normal" as const, threadId: originThreadId || undefined }; const idempotencyKey = crypto.randomUUID(); @@ -399,6 +474,9 @@ export function useConversationDomain(input: { scope: "stream", }); conversationErrors.clear(streamOperation); + for (const [sequence, observation] of acceptedStreamObservationsRef.current) { + if (!conversationErrors.isCurrent(observation.operation)) acceptedStreamObservationsRef.current.delete(sequence); + } const canCommitToSurface = (threadId = activeThreadId): boolean => { if (currentCwdRef.current !== workspaceCwd) return false; if (handlers.shouldCommit) return handlers.shouldCommit(threadId || undefined); @@ -424,13 +502,30 @@ export function useConversationDomain(input: { conversationStreamBusyOwner.finish(busyOperation); return; } + if (!canObserve(workspaceCwd)) { + conversationStreamBusyOwner.finish(busyOperation); + return; + } + const streamObservation = observationRef.current; + const canPublishStream = (threadId = activeThreadId): boolean => canCommitToSurface(threadId) + && canObserve(workspaceCwd) && observationRef.current.epoch === streamObservation.epoch + && observationRef.current.baseUrl === streamObservation.baseUrl; try { const pendingKey = submissionLookupJournal.read(submissionScope); if (pendingKey) { + const observationEpoch = observationRef.current.epoch; + const assertObservationCurrent = (): void => { + if (!canObserve(workspaceCwd) || observationRef.current.epoch !== observationEpoch) { + throw new DaemonObservationDeferredError(); + } + }; const query = { surface: "normal" as const, idempotencyKey: pendingKey, threadId: originThreadId || undefined }; - const receipt = await recoverSubmissionReceipt(async () => validateConversationSubmissionReceipt( - await getConversationSubmission(workspaceCwd, query), workspaceCwd, query)); + const receipt = await recoverSubmissionReceipt(async () => { + assertObservationCurrent(); + return validateConversationSubmissionReceipt(await getConversationSubmission(workspaceCwd, query), workspaceCwd, query); + }); + assertObservationCurrent(); if (!receipt?.threadId || !receipt.sourceItem) { conversationErrors.report(streamOperation, input.translate("Pending confirmation")); if (canCommitToSurface()) handlers.onSubmissionUnknown?.(true); @@ -439,6 +534,7 @@ export function useConversationDomain(input: { activeThreadId = receipt.threadId; const detail = await getConversation(workspaceCwd, activeThreadId); if (detail.thread.id !== activeThreadId) throw new Error("Conversation response scope mismatch."); + assertObservationCurrent(); if (canCommitToSurface(activeThreadId)) { const original = detail.messages.find(item => item.id === receipt.sourceItemId); handlers.onSubmissionRecovered?.(activeThreadId, original?.content ?? ""); @@ -479,7 +575,7 @@ export function useConversationDomain(input: { let evidenceRefreshPromise: Promise | undefined; let evidenceRefreshRequested = false; const refreshActiveConversationEvidence = (): Promise => { - if (!activeThreadId || !canCommitToSurface(activeThreadId)) { + if (!canObserve(workspaceCwd) || !activeThreadId || !canCommitToSurface(activeThreadId)) { return Promise.resolve(); } if (evidenceRefreshPromise) { @@ -487,12 +583,14 @@ export function useConversationDomain(input: { return evidenceRefreshPromise; } const requestedThreadId = activeThreadId; + const observationEpoch = observationRef.current.epoch; const detailRead = detailReadEpochRef.current.begin(JSON.stringify([workspaceCwd, requestedThreadId])); evidenceRefreshPromise = (async () => { try { const detail = await getConversation(workspaceCwd, requestedThreadId); if (detail.thread.id !== requestedThreadId) throw new Error("Conversation response scope mismatch."); - if (detailReadEpochRef.current.isCurrent(detailRead) && requestedThreadId === activeThreadId && canCommitToSurface(requestedThreadId)) { + if (canObserve(workspaceCwd) && observationRef.current.epoch === observationEpoch + && detailReadEpochRef.current.isCurrent(detailRead) && requestedThreadId === activeThreadId && canCommitToSurface(requestedThreadId)) { setSelectedConversationDetail((current) => mergeConversationDetailSnapshots(current, detail)); } } catch { @@ -523,6 +621,7 @@ export function useConversationDomain(input: { const event = JSON.parse(parsed.data) as ConversationStreamReadyEvent; activeThreadId = event.thread.id; messageAccepted = true; + acceptedSourceItemId = event.userMessage.id; submissionLookupJournal.forget(submissionScope, idempotencyKey); handlers.onThreadBound?.(activeThreadId); retargetStreamOperation(activeThreadId); @@ -530,31 +629,32 @@ export function useConversationDomain(input: { conversationStreamBusyOwner.finish(busyOperation); } if (canCommitToSurface(activeThreadId)) { + // Acceptance/linkage is a fact even when its message snapshot is + // no longer the current observation. Do not turn it into a resend. + handlers.onMessageAccepted?.(event); + if (event.activeReply) handlers.onReplyLinked?.(event.activeReply); commitSelectedConversationThreadId(activeThreadId); commitConversationSurfaceIdentity(activeThreadId); - setSelectedConversationDetail((current) => mergeConversationMessages(current, workspaceCwd, event.thread, [ - event.userMessage, - event.assistantMessage, - ])); + } + if (canPublishStream(activeThreadId)) { + setSelectedConversationDetail((current) => canPublishStream(activeThreadId) + ? mergeConversationMessages(current, workspaceCwd, event.thread, [event.userMessage, event.assistantMessage]) : current); scheduleEvidenceRefresh(); handlers.onReady?.(event); - handlers.onMessageAccepted?.(event); - messageAccepted = true; - submissionLookupJournal.forget(submissionScope, idempotencyKey); - if (event.activeReply) handlers.onReplyLinked?.(event.activeReply); } return; } if (parsed.event === "run_event") { const event = JSON.parse(parsed.data) as ConversationStreamRunEvent; handlers.onThreadBound?.(event.threadId); - if (event.assistantMessage && canCommitToSurface(event.threadId)) { - setSelectedConversationDetail((current) => mergeConversationMessages(current, workspaceCwd, undefined, [event.assistantMessage as ConversationTranscriptMessage])); + if (event.assistantMessage && canPublishStream(event.threadId)) { + setSelectedConversationDetail((current) => canPublishStream(event.threadId) + ? mergeConversationMessages(current, workspaceCwd, undefined, [event.assistantMessage as ConversationTranscriptMessage]) : current); } - if (canCommitToSurface(event.threadId) && conversationEventNeedsDetailRefresh(event.event.type)) { + if (canPublishStream(event.threadId) && conversationEventNeedsDetailRefresh(event.event.type)) { scheduleEvidenceRefresh(); } - if (canCommitToSurface(event.threadId)) handlers.onRunEvent?.(event); + if (canPublishStream(event.threadId)) handlers.onRunEvent?.(event); return; } if (parsed.event === "done") { @@ -566,7 +666,10 @@ export function useConversationDomain(input: { if (canCommitToSurface(activeThreadId)) { commitSelectedConversationThreadId(activeThreadId); commitConversationSurfaceIdentity(activeThreadId); - setSelectedConversationDetail((current) => mergeConversationMessages(current, workspaceCwd, event.thread, [event.assistantMessage])); + } + if (canPublishStream(activeThreadId)) { + setSelectedConversationDetail((current) => canPublishStream(activeThreadId) + ? mergeConversationMessages(current, workspaceCwd, event.thread, [event.assistantMessage]) : current); handlers.onDone?.(event); } return; @@ -585,10 +688,11 @@ export function useConversationDomain(input: { commitSelectedConversationThreadId(activeThreadId); commitConversationSurfaceIdentity(activeThreadId); } - if (event.assistantMessage && canCommitToSurface(activeThreadId)) { - setSelectedConversationDetail((current) => mergeConversationMessages(current, workspaceCwd, event.thread, [event.assistantMessage as ConversationTranscriptMessage])); + if (event.assistantMessage && canPublishStream(activeThreadId)) { + setSelectedConversationDetail((current) => canPublishStream(activeThreadId) + ? mergeConversationMessages(current, workspaceCwd, event.thread, [event.assistantMessage as ConversationTranscriptMessage]) : current); } - if (canCommitToSurface(activeThreadId)) handlers.onBlocked?.(event); + if (canPublishStream(activeThreadId)) handlers.onBlocked?.(event); return; } if (parsed.event === "error") { @@ -602,8 +706,9 @@ export function useConversationDomain(input: { if (shouldReportConversationStreamError({ error: messageText, hasAssistantReply: Boolean(event.assistantMessage) }) && !messageAccepted) { conversationErrors.report(streamOperation, messageText); } - if (event.assistantMessage && canCommitToSurface(activeThreadId)) { - setSelectedConversationDetail((current) => mergeConversationMessages(current, workspaceCwd, event.thread, [event.assistantMessage as ConversationTranscriptMessage])); + if (event.assistantMessage && canPublishStream(activeThreadId)) { + setSelectedConversationDetail((current) => canPublishStream(activeThreadId) + ? mergeConversationMessages(current, workspaceCwd, event.thread, [event.assistantMessage as ConversationTranscriptMessage]) : current); } if (canCommitToSurface(activeThreadId)) handlers.onError?.(messageText, event); } @@ -616,7 +721,7 @@ export function useConversationDomain(input: { await evidenceRefreshPromise; // A lost stream must reconcile accepted input just like a normal terminal event. if (messageAccepted) await refreshConversationThreads(workspaceCwd); - if (messageAccepted && activeThreadId && canCommitToSurface(activeThreadId)) { + if (messageAccepted && canObserve(workspaceCwd) && activeThreadId && canCommitToSurface(activeThreadId)) { const refreshOperation = conversationErrors.begin({ workspaceCwd, surface: "thread", @@ -625,33 +730,51 @@ export function useConversationDomain(input: { }); conversationErrors.clear(refreshOperation); const detailRead = detailReadEpochRef.current.begin(JSON.stringify([workspaceCwd, activeThreadId])); + const observationEpoch = observationRef.current.epoch; try { const detail = await getConversation(workspaceCwd, activeThreadId); if (detail.thread.id !== activeThreadId) throw new Error("Conversation response scope mismatch."); conversationErrors.clear(refreshOperation); - if (detailReadEpochRef.current.isCurrent(detailRead) && canCommitToSurface(activeThreadId)) { + if (canObserve(workspaceCwd) && observationRef.current.epoch === observationEpoch + && detailReadEpochRef.current.isCurrent(detailRead) && canCommitToSurface(activeThreadId)) { setSelectedConversationDetail((current) => mergeConversationDetailSnapshots(current, detail)); } } catch (error) { - conversationErrors.report(refreshOperation, error instanceof Error ? error.message : String(error)); + reportObservationError(refreshOperation, error); } } } if (!messageAccepted) throw new Error("Pending confirmation"); } catch (error) { if (!submitted) { - conversationErrors.report(streamOperation, input.translate("Pending confirmation")); + if (error instanceof DaemonObservationDeferredError) { + conversationErrors.report(streamOperation, input.translate("Pending confirmation")); + } else { + reportObservationError(streamOperation, error); + } if (canCommitToSurface(activeThreadId)) handlers.onSubmissionUnknown?.(true); return; } let rejectionConfirmed = false; if (!messageAccepted) { + if (!canObserve(workspaceCwd)) { + reportObservationError(streamOperation, error); + if (canCommitToSurface(activeThreadId)) handlers.onSubmissionUnknown?.(); + return; + } + const observationEpoch = observationRef.current.epoch; const query = { surface: "normal" as const, idempotencyKey, threadId: originThreadId || undefined }; - const receipt = await recoverSubmissionReceipt(async () => validateConversationSubmissionReceipt( - await getConversationSubmission(workspaceCwd, query), workspaceCwd, query)); - rejectionConfirmed = submissionWasRejected(error, receipt); + const receipt = await recoverSubmissionReceipt(async () => { + if (!canObserve(workspaceCwd) || observationRef.current.epoch !== observationEpoch) { + throw new DaemonObservationDeferredError(); + } + return validateConversationSubmissionReceipt(await getConversationSubmission(workspaceCwd, query), workspaceCwd, query); + }); + const receiptObservationCurrent = canObserve(workspaceCwd) && observationRef.current.epoch === observationEpoch; + rejectionConfirmed = receiptObservationCurrent && submissionWasRejected(error, receipt); if (receipt?.threadId && receipt.turnId && receipt.sourceItemId) { messageAccepted = true; + acceptedSourceItemId = receipt.sourceItemId; submissionLookupJournal.forget(submissionScope, idempotencyKey); activeThreadId = receipt.threadId; handlers.onThreadBound?.(activeThreadId); @@ -662,48 +785,72 @@ export function useConversationDomain(input: { commitConversationSurfaceIdentity(activeThreadId); const detailRead = detailReadEpochRef.current.begin(JSON.stringify([workspaceCwd, activeThreadId])); try { + if (!canObserve(workspaceCwd) || observationRef.current.epoch !== observationEpoch) { + throw new DaemonObservationDeferredError(); + } const detail = await getConversation(workspaceCwd, activeThreadId); if (detail.thread.id !== activeThreadId) throw new Error("Conversation response scope mismatch."); - if (detailReadEpochRef.current.isCurrent(detailRead) && canCommitToSurface(activeThreadId)) { + if (canObserve(workspaceCwd) && observationRef.current.epoch === observationEpoch + && detailReadEpochRef.current.isCurrent(detailRead) && canCommitToSurface(activeThreadId)) { setSelectedConversationDetail(current => mergeConversationDetailSnapshots(current, detail)); } } catch (readError) { - conversationErrors.report(streamOperation, readError instanceof Error ? readError.message : String(readError)); + if (!(readError instanceof DaemonObservationDeferredError)) reportObservationError(streamOperation, readError); } } await refreshConversationThreads(workspaceCwd); } else if (!rejectionConfirmed) { - conversationErrors.report(streamOperation, input.translate("Pending confirmation")); + if (receiptObservationCurrent) { + conversationErrors.report(streamOperation, input.translate("Pending confirmation")); + } else { + reportObservationError(streamOperation, error); + } if (canCommitToSurface(activeThreadId)) handlers.onSubmissionUnknown?.(); return; } } if (rejectionConfirmed) submissionLookupJournal.forget(submissionScope, idempotencyKey); const messageText = error instanceof Error ? error.message : String(error); - conversationErrors.report(streamOperation, messageText); + reportObservationError(streamOperation, error); + if (messageAccepted && acceptedSourceItemId) { + if (conversationErrors.isCurrent(streamOperation)) { + acceptedStreamObservationsRef.current.set(streamOperation.sequence, { + operation: streamOperation, + sourceItemId: acceptedSourceItemId, + }); + } + // Accepted input is never re-sent and a lost POST stream is not a + // failed execution. Observe the Host now, or let control-plane recovery + // perform the same read after connectivity returns. + if (canCommitToSurface(activeThreadId)) await refreshSelectedConversation(workspaceCwd); + return; + } if (canCommitToSurface(activeThreadId)) handlers.onError?.(messageText); } finally { conversationStreamBusyOwner.finish(busyOperation); } - }, [commitConversationSurfaceIdentity, commitSelectedConversationThreadId, conversationErrors, conversationStreamBusyOwner, input.cwd, refreshConversationThreads]); + }, [commitConversationSurfaceIdentity, commitSelectedConversationThreadId, conversationErrors, conversationStreamBusyOwner, input.cwd, refreshConversationThreads, refreshSelectedConversation, reportObservationError]); const workspaceCwd = input.cwd.trim(); const conversationSidebarError = selectConversationError(conversationErrors.entries, { workspaceCwd, surface: "sidebar", scopes: ["load", "archive"], + excludeTransport: observationPaused, })?.message ?? ""; const conversationSurfaceError = selectConversationError(conversationErrors.entries, { workspaceCwd, surface: "thread", identity: conversationSurfaceIdentity, scopes: ["select", "refresh", "action"], + excludeTransport: observationPaused, })?.message ?? ""; const conversationComposerError = selectConversationError(conversationErrors.entries, { workspaceCwd, surface: "composer", identity: conversationSurfaceIdentity, scopes: ["stream"], + excludeTransport: observationPaused, })?.message ?? ""; const conversationStreamBusy = conversationStreamBusyOwner.isBusy({ workspaceCwd, diff --git a/apps/web/src/domains/runs/use-runs-domain.ts b/apps/web/src/domains/runs/use-runs-domain.ts index 702c399bd..60df684e4 100644 --- a/apps/web/src/domains/runs/use-runs-domain.ts +++ b/apps/web/src/domains/runs/use-runs-domain.ts @@ -8,12 +8,14 @@ import { listRuns, } from "../../api.js"; import type { DomainController, DomainError } from "../../app/domain-controller.js"; +import { DaemonObservationDeferredError, isDaemonObservationPausedForWorkspace, liveDaemonObservation, shouldDeferDaemonObservationError, type DaemonObservation } from "../../shared/conversation/observation/daemon-observation.js"; import { readDataString } from "../../lib/data.js"; import { inferRunStatus } from "../../lib/run-evidence.js"; import { useRunControl } from "../../lib/use-run-control.js"; import { useLatestKeyedRequestGuard } from "../../lib/use-latest-request.js"; import { useI18n } from "../../i18n.js"; -import { streamRun } from "../../sse.js"; +import { streamRun, type StreamJsonResponseParser } from "../../sse.js"; +import type { StreamRunOptions } from "../../sse.js"; import type { RunArtifact, RunEventEnvelope, @@ -43,7 +45,14 @@ export interface RunsActions { readonly loadRun: (runId?: string, cwd?: string) => Promise; readonly refreshRunList: (cwd?: string) => Promise; readonly runWithRequest: (requestBody: unknown, cwd: string, afterRunLoaded?: (runId: string) => Promise) => Promise; - readonly streamRunRequest: (url: string, requestBody: unknown, cwd: string, afterRunLoaded?: (runId: string) => Promise) => Promise; + readonly streamRunRequest: ( + url: string, + requestBody: unknown, + cwd: string, + afterRunLoaded?: (runId: string) => Promise, + parseJsonResponse?: StreamJsonResponseParser, + options?: StreamRunOptions, + ) => Promise; readonly abortCurrentRun: () => void; readonly cancelRunOrWorkUnit: (targetRun?: RunSession) => Promise; readonly resetForCwd: () => void; @@ -53,6 +62,7 @@ export type RunsController = DomainController; export function useRunsDomain(input: { readonly cwd: string; + readonly observation?: DaemonObservation; readonly confirmFullAccessRunIfNeeded: (requestBody: unknown) => Promise; readonly setSelectedWorkUnitDetail: (detail: WorkUnitDetailResponse | undefined) => void; readonly refreshApprovals: (cwd: string, runId?: string) => Promise; @@ -74,6 +84,12 @@ export function useRunsDomain(input: { const [error, setError] = useState(); const abortRef = useRef(undefined); const runLoadGuard = useLatestKeyedRequestGuard(); + const observationRef = useRef(input.observation ?? liveDaemonObservation); + observationRef.current = input.observation ?? liveDaemonObservation; + const workspaceCwdRef = useRef(input.cwd.trim()); + workspaceCwdRef.current = input.cwd.trim(); + const runLoadOwnerRef = useRef(undefined); + const runListOwnerRef = useRef(undefined); const assistantText = useMemo( () => @@ -91,8 +107,22 @@ export function useRunsDomain(input: { setError({ scope: "load", message: t("Enter a Run ID") }); return; } + const observation = observationRef.current; + const workspaceCwd = cwd.trim(); + if (workspaceCwdRef.current !== workspaceCwd || isDaemonObservationPausedForWorkspace(observation, cwd)) { + throw new DaemonObservationDeferredError(); + } const requestToken = runLoadGuard.next(trimmedRunId); + const owner = {}; + runLoadOwnerRef.current = owner; + // A keyed request alone cannot fence A -> B navigation to a different Run. + const isRequestCurrent = () => runLoadOwnerRef.current === owner && runLoadGuard.isCurrent(trimmedRunId, requestToken); + const isOwnerCurrent = () => isRequestCurrent() && workspaceCwdRef.current === workspaceCwd; + const canCommit = () => isOwnerCurrent() && observationRef.current.epoch === observation.epoch + && observationRef.current.baseUrl === observation.baseUrl && observationRef.current.workspaceCwd === observation.workspaceCwd + && !isDaemonObservationPausedForWorkspace(observationRef.current, cwd); + const assertReadCurrent = (): void => { if (!canCommit()) throw new DaemonObservationDeferredError(); }; setRunLoading(true); try { setError(undefined); @@ -101,52 +131,80 @@ export function useRunsDomain(input: { getRunEvents(trimmedRunId, cwd), listRunArtifacts(trimmedRunId, cwd), ]); - if (!runLoadGuard.isCurrent(trimmedRunId, requestToken)) { - return; - } - setRun(runResult); - setEvents(eventResult.events); - setArtifacts(artifactResult.artifacts); - setActiveRunId(runResult.id); - setLookupRunId(runResult.id); + assertReadCurrent(); + // React may apply queued publications after the observation has changed. + setRun(current => canCommit() ? runResult : current); + setEvents(current => canCommit() ? eventResult.events : current); + setArtifacts(current => canCommit() ? artifactResult.artifacts : current); + setActiveRunId(current => canCommit() ? runResult.id : current); + setLookupRunId(current => canCommit() ? runResult.id : current); if (runResult.workUnitId) { + assertReadCurrent(); try { - input.setSelectedWorkUnitDetail(await getWorkUnit(cwd, runResult.workUnitId)); - } catch { + const detail = await getWorkUnit(cwd, runResult.workUnitId); + assertReadCurrent(); + input.setSelectedWorkUnitDetail(detail); + } catch (caught) { + if (caught instanceof DaemonObservationDeferredError + || shouldDeferDaemonObservationError(caught, observationRef.current, cwd)) { + throw caught instanceof DaemonObservationDeferredError ? caught : new DaemonObservationDeferredError(); + } + // Work-unit detail is optional enrichment. Keep the canonical Run/events/artifacts + // snapshot visible when that secondary endpoint is stale or temporarily unavailable. + assertReadCurrent(); input.setSelectedWorkUnitDetail(undefined); } } else { + assertReadCurrent(); input.setSelectedWorkUnitDetail(undefined); } + assertReadCurrent(); await input.refreshRuntimeState(cwd, runResult); + assertReadCurrent(); } catch (caught) { + if (caught instanceof DaemonObservationDeferredError || shouldDeferDaemonObservationError(caught, observationRef.current, cwd)) { + throw caught instanceof DaemonObservationDeferredError ? caught : new DaemonObservationDeferredError(); + } const message = caught instanceof Error ? caught.message : String(caught); - setError({ scope: "load", message }); + if (isOwnerCurrent()) setError(current => isOwnerCurrent() ? { scope: "load", message } : current); throw caught; } finally { - if (runLoadGuard.isCurrent(trimmedRunId, requestToken)) { + if (isRequestCurrent()) { setRunLoading(false); } } } async function refreshRunList(cwd = input.cwd): Promise { + const workspaceCwd = cwd.trim(); + if (workspaceCwdRef.current !== workspaceCwd) return; if (!cwd) { setRuns([]); return; } - setRunsLoading(true); + const observation = observationRef.current; + if (isDaemonObservationPausedForWorkspace(observation, cwd)) return; + const owner = {}; + runListOwnerRef.current = owner; + const isRequestCurrent = () => runListOwnerRef.current === owner; + const isOwnerCurrent = () => isRequestCurrent() && workspaceCwdRef.current === workspaceCwd; + const canCommit = () => isOwnerCurrent() && observationRef.current.epoch === observation.epoch + && observationRef.current.baseUrl === observation.baseUrl && observationRef.current.workspaceCwd === observation.workspaceCwd + && !isDaemonObservationPausedForWorkspace(observationRef.current, cwd); + setRunsLoading(current => canCommit() ? true : current); try { - setError(undefined); + setError(current => canCommit() ? undefined : current); const response = await listRuns(cwd); - setRuns(response.runs); + if (!canCommit()) return; + setRuns(current => canCommit() ? response.runs : current); void input.refreshRuntimeState(cwd); } catch (caught) { + if (caught instanceof DaemonObservationDeferredError || shouldDeferDaemonObservationError(caught, observationRef.current, cwd)) return; const message = caught instanceof Error ? caught.message : String(caught); - setError({ scope: "load", message }); + if (isOwnerCurrent()) setError(current => isOwnerCurrent() ? { scope: "load", message } : current); } finally { - setRunsLoading(false); + if (isRequestCurrent()) setRunsLoading(current => isRequestCurrent() ? false : current); } } @@ -158,15 +216,17 @@ export function useRunsDomain(input: { await streamRunRequest(`${daemonBaseUrl}/runs`, requestBody, cwd, afterRunLoaded); } - async function streamRunRequest( + async function streamRunRequest( url: string, requestBody: unknown, cwd: string, afterRunLoaded?: (runId: string) => Promise, - ): Promise { + parseJsonResponse?: StreamJsonResponseParser, + options: StreamRunOptions = {}, + ): Promise { const confirmedRequestBody = await input.confirmFullAccessRunIfNeeded(requestBody); if (!confirmedRequestBody) { - return; + return undefined; } abortRef.current?.abort(); const controller = new AbortController(); @@ -181,7 +241,7 @@ export function useRunsDomain(input: { try { let streamedRunId = ""; - await streamRun( + const jsonResponse = await streamRun( url, confirmedRequestBody, (message) => { @@ -197,8 +257,14 @@ export function useRunsDomain(input: { setEvents((current) => [...current, message.event]); }, controller.signal, + parseJsonResponse, + options, ); + if (jsonResponse !== undefined) { + return jsonResponse; + } + if (streamedRunId) { await loadRun(streamedRunId, cwd); await refreshRunList(cwd); @@ -206,14 +272,22 @@ export function useRunsDomain(input: { await input.refreshUsedMemoryAudit(cwd); await afterRunLoaded?.(streamedRunId); } + return undefined; } catch (caught) { - if ((caught as Error).name !== "AbortError") { + // A deferred post-stream read does not reject the accepted execution. + if (caught instanceof DaemonObservationDeferredError) return undefined; + const aborted = (caught as Error).name === "AbortError"; + if (!aborted && !parseJsonResponse) { const message = caught instanceof Error ? caught.message : String(caught); setError({ scope: "stream", message }); } + if (parseJsonResponse && !aborted) { + throw caught; + } + return undefined; } finally { setIsRunning(false); - await input.refreshHealth(); + if (!isDaemonObservationPausedForWorkspace(observationRef.current, cwd)) await input.refreshHealth(); } } diff --git a/apps/web/src/domains/runtime/use-runtime-domain.ts b/apps/web/src/domains/runtime/use-runtime-domain.ts index d5984e418..0c741b1f4 100644 --- a/apps/web/src/domains/runtime/use-runtime-domain.ts +++ b/apps/web/src/domains/runtime/use-runtime-domain.ts @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { cancelWorkUnit, getControlPlaneSnapshot, @@ -27,6 +27,7 @@ import type { WorkUnitsDomainSnapshot, } from "../../types.js"; import { readControlPlaneDomainData, workUnitsFromControlPlaneSnapshot } from "../../lib/control-plane.js"; +import { DaemonObservationDeferredError, isDaemonObservationPausedForWorkspace, liveDaemonObservation, shouldDeferDaemonObservationError, type DaemonObservation } from "../../shared/conversation/observation/daemon-observation.js"; export interface RuntimeState { readonly stateTransitions: readonly StateTransitionEvent[]; @@ -56,6 +57,7 @@ export type RuntimeController = Omit Promise | void; }): RuntimeController { const [stateTransitions, setStateTransitions] = useState([]); @@ -67,6 +69,8 @@ export function useRuntimeDomain(input: { const [runtimeActionWorkUnitId, setRuntimeActionWorkUnitId] = useState(""); const runtimeIdentity = input.cwd.trim() || "workspace:unavailable"; const errorOwner = useFocusedDomainErrorOwner<"runtime", RuntimeErrorAction>(runtimeIdentity); + const observationRef = useRef(input.observation ?? liveDaemonObservation); + observationRef.current = input.observation ?? liveDaemonObservation; function isCurrentRuntimeRequest( request: FocusedErrorRequest<"runtime", RuntimeErrorAction>, @@ -77,6 +81,7 @@ export function useRuntimeDomain(input: { } async function refreshRuntimeState(cwd = input.cwd, sourceRun?: RunSession): Promise { + if (errorOwner.identity() !== (cwd.trim() || "workspace:unavailable")) return; if (!cwd) { setResourceLeases([]); setStateTransitions([]); @@ -87,17 +92,39 @@ export function useRuntimeDomain(input: { return; } + const observation = observationRef.current; + if (isDaemonObservationPausedForWorkspace(observation, cwd)) return; const request = errorOwner.begin("runtime", "runtime-state", "load"); - errorOwner.clear(request); - if (request.identity === (cwd.trim() || "workspace:unavailable")) setRuntimeLoading(true); - const snapshotWorkUnits = await getControlPlaneSnapshot(cwd, ["work_units"]) - .then((snapshot) => readControlPlaneDomainData(snapshot, "work_units")) - .then((snapshot) => snapshot ? workUnitsFromControlPlaneSnapshot(snapshot) : undefined) - .catch(() => undefined); - if (snapshotWorkUnits && isCurrentRuntimeRequest(request, cwd)) { - setWorkUnits(snapshotWorkUnits); + const canCommit = () => isCurrentRuntimeRequest(request, cwd) && observationRef.current.epoch === observation.epoch + && observationRef.current.baseUrl === observation.baseUrl && observationRef.current.workspaceCwd === observation.workspaceCwd + && !isDaemonObservationPausedForWorkspace(observationRef.current, cwd); + const assertReadCurrent = (): void => { if (!canCommit()) throw new DaemonObservationDeferredError(); }; + let snapshotWorkUnits: readonly WorkUnit[] | undefined; + let optionalReadFailed = false; + // These endpoints already have fallbacks. Keep those fallbacks without + // swallowing real errors or treating a paused transport as a fresh snapshot. + function optionalReadFallback(caught: unknown, fallback: T): T { + if (caught instanceof DaemonObservationDeferredError || shouldDeferDaemonObservationError(caught, observationRef.current, cwd)) { + throw caught instanceof DaemonObservationDeferredError ? caught : new DaemonObservationDeferredError(); + } + optionalReadFailed = true; + if (isCurrentRuntimeRequest(request, cwd)) { + errorOwner.report(request, { scope: "load", message: domainErrorMessage(caught) }); + } + return fallback; } + errorOwner.clear(request); + if (request.identity === (cwd.trim() || "workspace:unavailable")) setRuntimeLoading(current => canCommit() ? true : current); try { + snapshotWorkUnits = await getControlPlaneSnapshot(cwd, ["work_units"]) + .then((snapshot) => readControlPlaneDomainData(snapshot, "work_units")) + .then((snapshot) => snapshot ? workUnitsFromControlPlaneSnapshot(snapshot) : undefined) + .catch((caught: unknown) => optionalReadFallback(caught, undefined)); + assertReadCurrent(); + if (snapshotWorkUnits) { + const units = snapshotWorkUnits; + setWorkUnits(current => canCommit() ? units : current); + } const transitionRequests = [ listStateTransitions(cwd, { limit: 30 }).then((response) => response.transitions), listStateTransitions(cwd, { entityType: "run", limit: 30 }).then((response) => response.transitions), @@ -119,30 +146,32 @@ export function useRuntimeDomain(input: { const [leasesResponse, transitionResponses, diagnosticsResponse, workUnitsResponse] = await Promise.all([ listResourceLeases(cwd, { status: "active", limit: 50 }), Promise.all(transitionRequests), - getRuntimeDiagnostics(cwd).catch(() => undefined), - listWorkUnits(cwd, { limit: 100 }).catch(() => ({ cwd, workUnits: snapshotWorkUnits ?? [] })), + getRuntimeDiagnostics(cwd).catch((caught: unknown) => optionalReadFallback(caught, undefined)), + listWorkUnits(cwd, { limit: 100 }).catch((caught: unknown) => optionalReadFallback(caught, { cwd, workUnits: snapshotWorkUnits ?? [] })), ]); + assertReadCurrent(); const uniqueTransitions = new Map(); for (const transition of transitionResponses.flat()) { uniqueTransitions.set(transition.id, transition); } - if (isCurrentRuntimeRequest(request, cwd)) { - setResourceLeases(diagnosticsResponse?.leases ?? leasesResponse.leases); - setRuntimeDiagnostics(diagnosticsResponse); - setWorkUnits(workUnitsResponse.workUnits); - setStateTransitions([...uniqueTransitions.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt))); - errorOwner.clear(request); - } + setResourceLeases(current => canCommit() ? diagnosticsResponse?.leases ?? leasesResponse.leases : current); + setRuntimeDiagnostics(current => canCommit() ? diagnosticsResponse : current); + setWorkUnits(current => canCommit() ? workUnitsResponse.workUnits : current); + setStateTransitions(current => canCommit() ? [...uniqueTransitions.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt)) : current); + if (!optionalReadFailed) errorOwner.clear(request); } catch (caught) { + if (caught instanceof DaemonObservationDeferredError || shouldDeferDaemonObservationError(caught, observationRef.current, cwd)) return; + if (canCommit()) { + setResourceLeases(current => canCommit() ? [] : current); + setStateTransitions(current => canCommit() ? [] : current); + setRuntimeDiagnostics(current => canCommit() ? undefined : current); + setWorkUnits(current => canCommit() ? snapshotWorkUnits ?? [] : current); + } if (isCurrentRuntimeRequest(request, cwd)) { - setResourceLeases([]); - setStateTransitions([]); - setRuntimeDiagnostics(undefined); - setWorkUnits(snapshotWorkUnits ?? []); errorOwner.report(request, { scope: "load", message: domainErrorMessage(caught) }); } } finally { - if (isCurrentRuntimeRequest(request, cwd)) setRuntimeLoading(false); + if (isCurrentRuntimeRequest(request, cwd)) setRuntimeLoading(current => isCurrentRuntimeRequest(request, cwd) ? false : current); } } diff --git a/apps/web/src/domains/workspace/use-workspace-domain.ts b/apps/web/src/domains/workspace/use-workspace-domain.ts index 717a60d64..2096b1aa5 100644 --- a/apps/web/src/domains/workspace/use-workspace-domain.ts +++ b/apps/web/src/domains/workspace/use-workspace-domain.ts @@ -1,6 +1,6 @@ +import { useRef, useState, type Dispatch, type SetStateAction } from "react"; import { channelSessionThreadId, isChannelSessionDraft } from "../../app/channel-session-state.js"; import { reconcileChannelSessionMessageCache } from "./channel-session-message-cache.js"; -import { useLayoutEffect, useRef, useState, type Dispatch, type SetStateAction } from "react"; import { getChannelCollaborationProtocol, getChannelBrief, @@ -32,6 +32,8 @@ import { updateChannelCouncilPolicy, } from "../../api.js"; import type { DomainController, DomainError } from "../../app/domain-controller.js"; +import { channelConversationScopeKey } from "../../shared/conversation/stream-error-ownership.js"; +import { isDaemonObservationPausedForWorkspace, liveDaemonObservation, shouldDeferDaemonObservationError, type DaemonObservation } from "../../shared/conversation/observation/daemon-observation.js"; import { deriveChannelTasks } from "../../lib/channel-artifacts.js"; import { mergeConversationItems, mergeChannelUserInputPage, projectChannelConversationItemPage, type ChannelConversationMessagePage } from "../../lib/channel-conversation-items.js"; import { createConversationReadEpoch } from "../../lib/conversation-read-epoch.js"; @@ -230,6 +232,7 @@ interface WorkspaceFeedbackEntry { export function useWorkspaceDomain(input: { readonly cwd: string; + readonly observation?: DaemonObservation; readonly initialWorkspaceCwd: string; readonly selectedChannelBranchId: string; readonly translate: (value: string) => string; @@ -254,7 +257,17 @@ export function useWorkspaceDomain(input: { title: input.translate(defaultTaskForm.title), prompt: input.translate(defaultTaskForm.prompt), })); - const [selectedTaskId, setSelectedTaskId] = useState(""); + const [taskSelection, setTaskSelection] = useState({ id: "", explicitRevision: 0 }); + const selectedTaskId = taskSelection.id; + function setSelectedTaskId(update: SetStateAction): void { + setTaskSelection(current => ({ ...current, id: typeof update === "function" ? update(current.id) : update })); + } + function selectTaskExplicitly(update: SetStateAction): void { + setTaskSelection(current => ({ + id: typeof update === "function" ? update(current.id) : update, + explicitRevision: current.explicitRevision + 1, + })); + } const [taskEditForm, setTaskEditForm] = useState(defaultTaskEditForm); const [taskRunProviderId, setTaskRunProviderId] = useState(""); const [channelMessages, setChannelMessages] = useState([]); @@ -323,6 +336,20 @@ export function useWorkspaceDomain(input: { const [channelCouncilSaving, setChannelCouncilSaving] = useState(false); const [error, setError] = useState(); const loadGuard = useLatestKeyedRequestGuard(); + const observationRef = useRef(input.observation ?? liveDaemonObservation); + observationRef.current = input.observation ?? liveDaemonObservation; + + function beginObservationRead(cwd: string) { + const observation = observationRef.current; + const admitted = !isDaemonObservationPausedForWorkspace(observation, cwd); + return { admitted, isCurrent: () => admitted && observationRef.current.epoch === observation.epoch + && observationRef.current.baseUrl === observation.baseUrl && observationRef.current.workspaceCwd === observation.workspaceCwd + && !isDaemonObservationPausedForWorkspace(observationRef.current, cwd) }; + } + + function deferObservationError(caught: unknown, cwd = input.cwd): boolean { + return shouldDeferDaemonObservationError(caught, observationRef.current, cwd); + } const selectedChannelBranch = channelBranches.find((branch) => branch.id === input.selectedChannelBranchId && @@ -336,6 +363,21 @@ export function useWorkspaceDomain(input: { const selectedChannelMessageThreadIdRef = useRef(selectedChannelMessageThreadId); selectedChannelIdRef.current = selectedChannelId; selectedChannelMessageThreadIdRef.current = selectedChannelMessageThreadId; + // Dock panels belong to the channel, not its selected root/topic/branch. + const channelPanelOwnerKey = JSON.stringify([input.cwd.trim(), selectedChannelId]); + const [channelPanelStateOwner, setChannelPanelStateOwner] = useState({ + key: channelPanelOwnerKey, cwd: input.cwd.trim(), taskSelectionRevision: taskSelection.explicitRevision, + }); + const channelPanelOwnerRef = useRef({ key: channelPanelOwnerKey }); + if (channelPanelOwnerRef.current.key !== channelPanelOwnerKey) { + channelPanelOwnerRef.current = { key: channelPanelOwnerKey }; + } + if (channelPanelStateOwner.key !== channelPanelOwnerKey || + channelPanelStateOwner.taskSelectionRevision !== taskSelection.explicitRevision) { + setChannelPanelStateOwner({ + key: channelPanelOwnerKey, cwd: input.cwd.trim(), taskSelectionRevision: taskSelection.explicitRevision, + }); + } const channelReadOwner = JSON.stringify([input.cwd.trim(), selectedChannelId, selectedChannelMessageThreadId ?? ""]); const channelRuntimeMetrics = channelRuntimeMetricsState.owner === channelReadOwner ? channelRuntimeMetricsState.metrics : undefined; const channelReadOwnerChanged = channelReadOwnerRef.current !== channelReadOwner; @@ -346,37 +388,97 @@ export function useWorkspaceDomain(input: { }); channelReadOwnerRef.current = channelReadOwner; channelReadEpochRef.current.invalidate(); - channelInputPageRef.current = channelMessageCacheRef.current.get(channelReadOwner)?.page; - } - - useLayoutEffect(() => { const cached = channelMessageCacheRef.current.get(channelReadOwner); + channelInputPageRef.current = cached?.page; + // Keep the owner transition synchronous so a newly selected channel cannot + // paint the previous channel's transcript while its first read is pending. setChannelMessages(cached?.messages ?? []); setChannelConversationItems(cached?.items ?? []); setChannelGeneratedMedia(cached?.generatedMedia ?? []); setChannelMessagesHasOlder(cached?.hasOlder ?? false); setChannelItemsBeforeSequence(cached?.before); setChannelMessagesLoadingOlder(false); - if (cached?.page) { - setChannelTurnContextAssignmentState({ owner: channelReadOwner, assignments: cached.page.turnContextAssignments }); - setChannelUserInputState({ owner: channelReadOwner, items: cached.page.userInputs ?? [] }); - setChannelReasoningSummaryState({ owner: channelReadOwner, items: cached.page.reasoningSummaries }); - setChannelContextCompactionState({ owner: channelReadOwner, items: cached.page.contextCompactions ?? [] }); - } - }, [channelReadOwner]); - - useLayoutEffect(() => { - if (channelReadOwnerChanged) return; + setChannelTurnContextAssignmentState(cached?.page + ? { owner: channelReadOwner, assignments: cached.page.turnContextAssignments } + : { owner: channelReadOwner, assignments: [] }); + setChannelUserInputState(cached?.page + ? { owner: channelReadOwner, items: cached.page.userInputs ?? [] } + : { owner: channelReadOwner, items: [] }); + setChannelReasoningSummaryState(cached?.page + ? { owner: channelReadOwner, items: cached.page.reasoningSummaries } + : { owner: channelReadOwner, items: [] }); + setChannelContextCompactionState(cached?.page + ? { owner: channelReadOwner, items: cached.page.contextCompactions ?? [] } + : { owner: channelReadOwner, items: [] }); + } else { channelMessageCacheRef.current.set(channelReadOwner, { messages: channelMessages, items: channelConversationItems, page: channelInputPageRef.current, generatedMedia: channelGeneratedMedia, hasOlder: channelMessagesHasOlder, before: channelItemsBeforeSequence, }); - }, [channelMessages, channelConversationItems, channelGeneratedMedia, channelMessagesHasOlder, channelItemsBeforeSequence]); + } + + if (channelPanelStateOwner.key !== channelPanelOwnerKey) { + // Clear before React commits the new owner, so a newly selected channel cannot paint old dock data. + setChannelExecutionProposals([]); + setChannelAttachments([]); + setChannelLinkPreviews([]); + setChannelFilesLoading(false); + // Task navigation can inject a target row before selecting its channel in the same React batch. + setTasks(current => channelPanelStateOwner.cwd === input.cwd.trim() + ? current.filter(task => task.channelId === selectedChannelId) : []); + if (channelPanelStateOwner.taskSelectionRevision === taskSelection.explicitRevision) { + setSelectedTaskId(""); + } + setTasksLoading(false); + setChannelArtifactsByRun({}); + setChannelArtifactErrorsByRun({}); + setChannelRunArtifactsLoading(false); + setChannelExecutionActions([]); + setChannelExecutionActionError(""); + setChannelStateTransitions([]); + setChannelActivityTransitionError(""); + setChannelActivityTransitionsLoading(false); + channelActivityTransitionsLoadedScopeRef.current = ""; + setChannelGovernanceNotifications([]); + setChannelGovernanceNotificationsLoading(false); + setChannelCouncilPolicy(undefined); + setChannelEffectiveCouncil(undefined); + setChannelCouncilLoading(false); + resetChannelProtocol(); + setError(undefined); + } function isSelectedChannelConversation(channelId: string, threadId?: string): boolean { return channelId === selectedChannelIdRef.current && threadId === selectedChannelMessageThreadIdRef.current; } + function beginChannelPanelRead(key: string, cwd: string, channelId: string) { + const owner = channelPanelOwnerRef.current; + const observationRead = beginObservationRead(cwd); + const token = owner.key === JSON.stringify([cwd, channelId]) ? loadGuard.next(key) : undefined; + const isOwnerCurrent = () => token !== undefined && channelPanelOwnerRef.current === owner && loadGuard.isCurrent(key, token); + const isCurrent = () => isOwnerCurrent() && observationRead.isCurrent(); + function publish(setter: Dispatch>, update: SetStateAction>): void { + if (!isCurrent()) return; + // React may apply an updater after navigation or a newer request. + setter(current => !isCurrent() ? current : typeof update === "function" + ? (update as (value: T) => T)(current) : update); + } + function publishOwned(setter: Dispatch>, update: SetStateAction>): void { + if (!isOwnerCurrent()) return; + setter(current => !isOwnerCurrent() ? current : typeof update === "function" + ? (update as (value: T) => T)(current) : update); + } + function report(surface: WorkspaceFeedbackSurface, caught: unknown): void { + if (!isOwnerCurrent() || deferObservationError(caught, cwd)) return; + const message = caught instanceof Error ? caught.message : String(caught); + const error: DomainError = { scope: "load", message }; + setError(current => isOwnerCurrent() ? error : current); + input.reportFeedback({ workspaceCwd: cwd.trim(), surface, identity: channelId, scope: "load", message }); + } + return { isCurrent, isOwnerCurrent, publish, publishOwned, report }; + } + function clearScopedFeedback(surface: WorkspaceFeedbackSurface, identity: string): void { input.clearFeedback(surface, identity, input.cwd.trim()); } @@ -386,7 +488,9 @@ export function useWorkspaceDomain(input: { identity: string, scope: WorkspaceFeedbackScope, caught: unknown, + cwd = input.cwd, ): void { + if (deferObservationError(caught, cwd)) return; const message = caught instanceof Error ? caught.message : String(caught); setError({ scope, message }); input.reportFeedback({ workspaceCwd: input.cwd.trim(), surface, identity, scope, message }); @@ -400,13 +504,15 @@ export function useWorkspaceDomain(input: { return; } + const observationRead = beginObservationRead(cwd); + if (!observationRead.admitted) return; const token = loadGuard.next(`channels:${cwd}`); setChannelsLoading(true); try { clearScopedFeedback("workspace", cwd); setError(undefined); const response = await listChannels(cwd); - if (!loadGuard.isCurrent(`channels:${cwd}`, token)) { + if (!loadGuard.isCurrent(`channels:${cwd}`, token) || !observationRead.isCurrent()) { return; } setChannels(response.channels); @@ -429,37 +535,34 @@ export function useWorkspaceDomain(input: { async function refreshTasks(channelId = selectedChannelId): Promise { const cwd = input.cwd.trim(); + const read = beginChannelPanelRead(`tasks:${cwd}:${channelId}`, cwd, channelId); + if (!read.isCurrent()) return; if (!cwd || !channelId) { - setTasks([]); + read.publish(setTasks, []); + read.publish(setTasksLoading, false); return; } - const token = loadGuard.next(`tasks:${cwd}:${channelId}`); - setTasksLoading(true); + read.publish(setTasksLoading, true); try { clearScopedFeedback("task", channelId); - setError(undefined); + read.publish(setError, undefined); const response = await listTasks(cwd, channelId); - if (!loadGuard.isCurrent(`tasks:${cwd}:${channelId}`, token)) { - return; - } - setTasks(response.tasks); - setSelectedTaskId((current) => { + read.publish(setTasks, response.tasks); + read.publish(setSelectedTaskId, (current) => { if (response.tasks.some((task) => task.id === current)) { return current; } return ""; }); } catch (caught) { - if (!loadGuard.isCurrent(`tasks:${cwd}:${channelId}`, token)) return; - reportScopedFeedback("task", channelId, "load", caught); + read.report("task", caught); } finally { - if (loadGuard.isCurrent(`tasks:${cwd}:${channelId}`, token)) { - setTasksLoading(false); - } + read.publish(setTasksLoading, false); } } + async function refreshAiCouncils(cwd = input.cwd.trim()): Promise { if (!cwd) { setAiCouncils([]); @@ -468,6 +571,8 @@ export function useWorkspaceDomain(input: { return; } + const observationRead = beginObservationRead(cwd); + if (!observationRead.admitted) return; const token = loadGuard.next(`aiCouncils:${cwd}`); setAiCouncilsLoading(true); try { @@ -477,7 +582,7 @@ export function useWorkspaceDomain(input: { listAiCouncils(cwd, { limit: 100 }), getAiCouncilHealth(cwd, { limit: 500 }), ]); - if (!loadGuard.isCurrent(`aiCouncils:${cwd}`, token)) { + if (!loadGuard.isCurrent(`aiCouncils:${cwd}`, token) || !observationRead.isCurrent()) { return; } setAiCouncils(listResponse.councils); @@ -494,37 +599,33 @@ export function useWorkspaceDomain(input: { async function refreshSelectedChannelCouncil(channelId = selectedChannelId): Promise { const cwd = input.cwd.trim(); + const read = beginChannelPanelRead(`channelCouncil:${cwd}:${channelId}`, cwd, channelId); + if (!read.isCurrent()) return; if (!cwd || !channelId) { - setChannelCouncilPolicy(undefined); - setChannelEffectiveCouncil(undefined); - setChannelCouncilLoading(false); + read.publish(setChannelCouncilPolicy, undefined); + read.publish(setChannelEffectiveCouncil, undefined); + read.publish(setChannelCouncilLoading, false); return; } - const token = loadGuard.next(`channelCouncil:${cwd}:${channelId}`); - setChannelCouncilLoading(true); + read.publish(setChannelCouncilLoading, true); try { clearScopedFeedback("workspace", channelId); - setError(undefined); + read.publish(setError, undefined); const [policyResponse, effectiveResponse] = await Promise.all([ getChannelCouncilPolicy(cwd, channelId), getChannelEffectiveCouncil(cwd, channelId), ]); - if (!loadGuard.isCurrent(`channelCouncil:${cwd}:${channelId}`, token)) { - return; - } - setChannelCouncilPolicy(policyResponse.policy); - setChannelEffectiveCouncil(effectiveResponse.effectiveCouncil); + read.publish(setChannelCouncilPolicy, policyResponse.policy); + read.publish(setChannelEffectiveCouncil, effectiveResponse.effectiveCouncil); } catch (caught) { - if (!loadGuard.isCurrent(`channelCouncil:${cwd}:${channelId}`, token)) return; - reportScopedFeedback("workspace", channelId, "load", caught); + read.report("workspace", caught); } finally { - if (loadGuard.isCurrent(`channelCouncil:${cwd}:${channelId}`, token)) { - setChannelCouncilLoading(false); - } + read.publish(setChannelCouncilLoading, false); } } + async function updateSelectedChannelCouncilPolicy( policyInput: UpdateChannelCouncilPolicyRequest, channelId = selectedChannelId, @@ -555,6 +656,8 @@ export function useWorkspaceDomain(input: { options?: ChannelMessageRefreshOptions, ): Promise { const cwd = input.cwd.trim(); + const observationRead = beginObservationRead(cwd); + if (!observationRead.admitted) return; if (!cwd || !channelId || (threadId && isChannelSessionDraft(threadId))) { setChannelMessages([]); setChannelConversationItems([]); @@ -581,14 +684,14 @@ export function useWorkspaceDomain(input: { } try { if (!options?.silent) { - clearScopedFeedback("composer", channelId); + clearScopedFeedback("composer", channelConversationScopeKey({ workspaceCwd: cwd, channelId, branchRootThreadId: threadId })); setError(undefined); } const response = await listChannelConversationItems(cwd, channelId, { limit: channelMessageInitialLimit, surface: threadId ? { kind: "thread", threadId } : { kind: "channel_root" }, }); - if (!loadGuard.isCurrent(guardKey, token) || !channelReadEpochRef.current.isCurrent(readTicket)) { + if (!loadGuard.isCurrent(guardKey, token) || !channelReadEpochRef.current.isCurrent(readTicket) || !observationRead.isCurrent()) { return; } if (!isSelectedChannelConversation(channelId, threadId)) { @@ -618,8 +721,8 @@ export function useWorkspaceDomain(input: { } } catch (caught) { if (!loadGuard.isCurrent(guardKey, token) || !channelReadEpochRef.current.isCurrent(readTicket) || !isSelectedChannelConversation(channelId, threadId)) return; - if (!options?.silent) { - reportScopedFeedback("composer", channelId, "load", caught); + if (!options?.silent && !deferObservationError(caught, cwd)) { + reportScopedFeedback("composer", channelConversationScopeKey({ workspaceCwd: cwd, channelId, branchRootThreadId: threadId }), "load", caught, cwd); } } finally { if (loadGuard.isCurrent(guardKey, token) && channelReadEpochRef.current.isCurrent(readTicket) && isSelectedChannelConversation(channelId, threadId)) { @@ -630,6 +733,8 @@ export function useWorkspaceDomain(input: { async function loadOlderChannelMessages(channelId = selectedChannelId, threadId?: string): Promise { const cwd = input.cwd.trim(); + const observationRead = beginObservationRead(cwd); + if (!observationRead.admitted) return; if (!cwd || !channelId || channelItemsBeforeSequence === undefined || channelMessagesLoadingOlder || !channelMessagesHasOlder) { return; } @@ -642,14 +747,14 @@ export function useWorkspaceDomain(input: { const requestedBeforeSequence = channelItemsBeforeSequence; setChannelMessagesLoadingOlder(true); try { - clearScopedFeedback("composer", channelId); + clearScopedFeedback("composer", channelConversationScopeKey({ workspaceCwd: cwd, channelId, branchRootThreadId: threadId })); setError(undefined); const response = await listChannelConversationItems(cwd, channelId, { limit: channelMessageOlderLimit, beforeSequence: requestedBeforeSequence, surface: threadId ? { kind: "thread", threadId } : { kind: "channel_root" }, }); - if (!loadGuard.isCurrent(guardKey, token) || !channelReadEpochRef.current.isCurrent(readTicket)) { + if (!loadGuard.isCurrent(guardKey, token) || !channelReadEpochRef.current.isCurrent(readTicket) || !observationRead.isCurrent()) { return; } if (!isSelectedChannelConversation(channelId, threadId)) { @@ -677,7 +782,7 @@ export function useWorkspaceDomain(input: { setChannelItemsBeforeSequence(page.nextBeforeSequence); } catch (caught) { if (!loadGuard.isCurrent(guardKey, token) || !channelReadEpochRef.current.isCurrent(readTicket) || !isSelectedChannelConversation(channelId, threadId)) return; - reportScopedFeedback("composer", channelId, "load", caught); + if (!deferObservationError(caught, cwd)) reportScopedFeedback("composer", channelConversationScopeKey({ workspaceCwd: cwd, channelId, branchRootThreadId: threadId }), "load", caught, cwd); } finally { if (loadGuard.isCurrent(guardKey, token) && channelReadEpochRef.current.isCurrent(readTicket) && isSelectedChannelConversation(channelId, threadId)) { setChannelMessagesLoadingOlder(false); @@ -687,26 +792,24 @@ export function useWorkspaceDomain(input: { async function refreshChannelExecutionProposals(channelId = selectedChannelId): Promise { const cwd = input.cwd.trim(); + const read = beginChannelPanelRead(`executionProposals:${cwd}:${channelId}`, cwd, channelId); + if (!read.isCurrent()) return; if (!cwd || !channelId) { - setChannelExecutionProposals([]); + read.publish(setChannelExecutionProposals, []); return; } - const token = loadGuard.next(`executionProposals:${cwd}:${channelId}`); try { clearScopedFeedback("workflow", channelId); - setError(undefined); + read.publish(setError, undefined); const response = await listChannelExecutionProposals(cwd, channelId); - if (!loadGuard.isCurrent(`executionProposals:${cwd}:${channelId}`, token)) { - return; - } - setChannelExecutionProposals(response.proposals); + read.publish(setChannelExecutionProposals, response.proposals); } catch (caught) { - if (!loadGuard.isCurrent(`executionProposals:${cwd}:${channelId}`, token)) return; - reportScopedFeedback("workflow", channelId, "load", caught); + read.report("workflow", caught); } } + function upsertChannelExecutionProposal(proposal: ChannelExecutionProposal): void { setChannelExecutionProposals((current) => [ proposal, @@ -716,150 +819,157 @@ export function useWorkspaceDomain(input: { async function refreshChannelFiles(channelId = selectedChannelId): Promise { const cwd = input.cwd.trim(); + const read = beginChannelPanelRead(`files:${cwd}:${channelId}`, cwd, channelId); + if (!read.isCurrent()) return; if (!cwd || !channelId) { - setChannelAttachments([]); - setChannelLinkPreviews([]); + read.publish(setChannelAttachments, []); + read.publish(setChannelLinkPreviews, []); + read.publish(setChannelFilesLoading, false); return; } - const token = loadGuard.next(`files:${cwd}:${channelId}`); - setChannelFilesLoading(true); + read.publish(setChannelFilesLoading, true); try { clearScopedFeedback("dock", channelId); - setError(undefined); + read.publish(setError, undefined); const [attachmentsResponse, linksResponse] = await Promise.all([ listChannelAttachments(cwd, channelId), listChannelLinkPreviews(cwd, channelId), ]); - if (!loadGuard.isCurrent(`files:${cwd}:${channelId}`, token)) { - return; - } - setChannelAttachments(attachmentsResponse.attachments); - setChannelLinkPreviews(linksResponse.linkPreviews); + read.publish(setChannelAttachments, attachmentsResponse.attachments); + read.publish(setChannelLinkPreviews, linksResponse.linkPreviews); } catch (caught) { - if (!loadGuard.isCurrent(`files:${cwd}:${channelId}`, token)) return; - reportScopedFeedback("dock", channelId, "load", caught); + read.report("dock", caught); } finally { - if (loadGuard.isCurrent(`files:${cwd}:${channelId}`, token)) { - setChannelFilesLoading(false); - } + read.publish(setChannelFilesLoading, false); } } + async function refreshChannelRunArtifacts( cwd = input.cwd.trim(), channelId = selectedChannelId, channelRuns: readonly RunSession[] = [], ): Promise { + const read = beginChannelPanelRead(`runArtifacts:${cwd}:${channelId}`, cwd, channelId); + if (!read.isCurrent()) return; if (!cwd || !channelId) { - setChannelArtifactsByRun({}); - setChannelArtifactErrorsByRun({}); - setChannelRunArtifactsLoading(false); + read.publish(setChannelArtifactsByRun, {}); + read.publish(setChannelArtifactErrorsByRun, {}); + read.publish(setChannelRunArtifactsLoading, false); return; } if (channelRuns.length === 0) { - setChannelRunArtifactsLoading(false); + read.publish(setChannelRunArtifactsLoading, false); return; } - const token = loadGuard.next(`runArtifacts:${cwd}:${channelId}`); - setChannelRunArtifactsLoading(true); + read.publish(setChannelRunArtifactsLoading, true); const results = await Promise.all( channelRuns.map(async (sourceRun) => { try { const response = await listRunArtifacts(sourceRun.id, cwd); return { runId: sourceRun.id, artifacts: response.artifacts } as const; } catch (caught) { - const message = caught instanceof Error ? caught.message : String(caught); - return { runId: sourceRun.id, error: message } as const; + return { runId: sourceRun.id, error: caught } as const; } }), ); - if (!loadGuard.isCurrent(`runArtifacts:${cwd}:${channelId}`, token)) { - return; - } - const nextArtifacts: Record = {}; const nextErrors: Record = {}; for (const result of results) { if ("artifacts" in result) { nextArtifacts[result.runId] = result.artifacts ?? []; - } else { - nextErrors[result.runId] = result.error; + } else if (!deferObservationError(result.error, cwd)) { + nextErrors[result.runId] = result.error instanceof Error ? result.error.message : String(result.error); } } - setChannelArtifactsByRun(nextArtifacts); - setChannelArtifactErrorsByRun(nextErrors); - setChannelRunArtifactsLoading(false); + if (!read.isCurrent()) { + read.publishOwned(setChannelArtifactErrorsByRun, nextErrors); + read.publishOwned(setChannelRunArtifactsLoading, false); + return; + } + read.publish(setChannelArtifactsByRun, nextArtifacts); + read.publish(setChannelArtifactErrorsByRun, nextErrors); + read.publish(setChannelRunArtifactsLoading, false); } + async function refreshChannelExecutionActions( cwd = input.cwd.trim(), channelId = selectedChannelId, channelRuns: readonly RunSession[] = [], ): Promise { + const read = beginChannelPanelRead(`executionActions:${cwd}:${channelId}`, cwd, channelId); + if (!read.isCurrent()) return; if (!cwd || !channelId) { - setChannelExecutionActions([]); - setChannelExecutionActionError(""); + read.publish(setChannelExecutionActions, []); + read.publish(setChannelExecutionActionError, ""); return; } if (channelRuns.length === 0) { - setChannelExecutionActions([]); - setChannelExecutionActionError(""); + read.publish(setChannelExecutionActions, []); + read.publish(setChannelExecutionActionError, ""); return; } - const token = loadGuard.next(`executionActions:${cwd}:${channelId}`); const [results, narrationResults] = await Promise.all([Promise.all( channelRuns.map(async (sourceRun) => { try { const response = await listExecutionActions(cwd, { runId: sourceRun.id, limit: 100 }); return { actions: response.actions } as const; } catch (caught) { - return { error: caught instanceof Error ? caught.message : String(caught) } as const; + return { error: caught } as const; } }), ), Promise.all(liveNarrationWorkflowIds(channelWorkflowStepsRef.current, channelRuns, channelId).map(async workflowId => { try { return { steps: (await listChannelWorkflowSteps(cwd, workflowId)).workflowSteps }; } catch (caught) { - return { error: caught instanceof Error ? caught.message : String(caught) }; + return { error: caught }; } }))]); - if (!loadGuard.isCurrent(`executionActions:${cwd}:${channelId}`, token)) { + if (!read.isCurrent()) { + const errors = [...narrationResults, ...results].map(result => result.error).filter(Boolean) + .filter(error => !deferObservationError(error, cwd)) + .map(error => error instanceof Error ? error.message : String(error)); + read.publishOwned(setChannelExecutionActionError, errors[0] ?? ""); return; } const nextActions = new Map(); const errors = new Set(); - setChannelWorkflowSteps(current => mergeWorkflowNarration(current, narrationResults.flatMap(result => result.steps ?? []))); - for (const result of narrationResults) if (result.error) errors.add(result.error); + read.publish(setChannelWorkflowSteps, current => mergeWorkflowNarration(current, narrationResults.flatMap(result => result.steps ?? []))); + for (const result of narrationResults) if (result.error) errors.add(result.error instanceof Error ? result.error.message : String(result.error)); for (const result of results) { if ("error" in result) { - errors.add(result.error ?? "unknown_error"); + errors.add(result.error instanceof Error ? result.error.message : String(result.error ?? "unknown_error")); } else { for (const action of result.actions) { nextActions.set(action.id, action); } } } - setChannelExecutionActions([...nextActions.values()].sort((a, b) => + read.publish(setChannelExecutionActions, [...nextActions.values()].sort((a, b) => (b.completedAt ?? b.executedAt ?? b.resolvedAt ?? b.createdAt).localeCompare(a.completedAt ?? a.executedAt ?? a.resolvedAt ?? a.createdAt) || b.id.localeCompare(a.id), )); - setChannelExecutionActionError(errors.size > 0 ? [...errors][0] : ""); + read.publish(setChannelExecutionActionError, errors.size > 0 ? [...errors][0] : ""); } + async function refreshChannelActivityTransitions( cwd = input.cwd.trim(), channelId = selectedChannelId, channelRuns: readonly RunSession[] = [], ): Promise { + const read = beginChannelPanelRead(`activityTransitions:${cwd}:${channelId}`, cwd, channelId); + if (!read.isCurrent()) return; if (!cwd || !channelId) { channelActivityTransitionsLoadedScopeRef.current = ""; - setChannelStateTransitions([]); - setChannelActivityTransitionError(""); - setChannelActivityTransitionsLoading(false); + read.publish(setChannelStateTransitions, []); + read.publish(setChannelActivityTransitionError, ""); + read.publish(setChannelActivityTransitionsLoading, false); return; } @@ -867,14 +977,13 @@ export function useWorkspaceDomain(input: { const entities = buildChannelTransitionEntities(channelId, tasks, channelRuns, channelWorkflows, channelWorkflowSteps); if (entities.length === 0) { channelActivityTransitionsLoadedScopeRef.current = scopeKey; - setChannelStateTransitions([]); - setChannelActivityTransitionError(""); - setChannelActivityTransitionsLoading(false); + read.publish(setChannelStateTransitions, []); + read.publish(setChannelActivityTransitionError, ""); + read.publish(setChannelActivityTransitionsLoading, false); return; } - const token = loadGuard.next(`activityTransitions:${cwd}:${channelId}`); - setChannelActivityTransitionsLoading(channelActivityTransitionsLoadedScopeRef.current !== scopeKey); + read.publish(setChannelActivityTransitionsLoading, channelActivityTransitionsLoadedScopeRef.current !== scopeKey); const results = await Promise.all( entities.map(async (entity) => { try { @@ -885,11 +994,14 @@ export function useWorkspaceDomain(input: { }); return { transitions: response.transitions } as const; } catch (caught) { - return { error: caught instanceof Error ? caught.message : String(caught) } as const; + return { error: caught } as const; } }), ); - if (!loadGuard.isCurrent(`activityTransitions:${cwd}:${channelId}`, token)) { + if (!read.isCurrent()) { + const errors = results.map(result => result.error).filter(Boolean); + const businessError = errors.find(error => !deferObservationError(error, cwd)); + if (businessError) read.publishOwned(setChannelActivityTransitionError, businessError instanceof Error ? businessError.message : String(businessError)); return; } @@ -897,7 +1009,7 @@ export function useWorkspaceDomain(input: { const errors = new Set(); for (const result of results) { if ("error" in result) { - errors.add(result.error ?? "unknown_error"); + errors.add(result.error instanceof Error ? result.error.message : String(result.error ?? "unknown_error")); } else { for (const transition of result.transitions) { nextTransitions.set(transition.id, transition); @@ -905,44 +1017,39 @@ export function useWorkspaceDomain(input: { } } - setChannelStateTransitions([...nextTransitions.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt))); - setChannelActivityTransitionError(errors.size > 0 ? [...errors][0] : ""); + read.publish(setChannelStateTransitions, [...nextTransitions.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt))); + read.publish(setChannelActivityTransitionError, errors.size > 0 ? [...errors][0] : ""); channelActivityTransitionsLoadedScopeRef.current = scopeKey; - setChannelActivityTransitionsLoading(false); + read.publish(setChannelActivityTransitionsLoading, false); } + async function refreshChannelGovernanceNotifications(channelId = selectedChannelId): Promise { const cwd = input.cwd.trim(); + const read = beginChannelPanelRead(`governanceNotifications:${cwd}:${channelId}`, cwd, channelId); + if (!read.isCurrent()) return; if (!cwd || !channelId) { - setChannelGovernanceNotifications([]); - setChannelGovernanceNotificationsLoading(false); + read.publish(setChannelGovernanceNotifications, []); + read.publish(setChannelGovernanceNotificationsLoading, false); return; } - const token = loadGuard.next(`governanceNotifications:${cwd}:${channelId}`); - setChannelGovernanceNotificationsLoading(true); + read.publish(setChannelGovernanceNotificationsLoading, true); try { const response = await listGovernanceNotifications(cwd, { surface: "channel", channelId, limit: 50, }); - if (!loadGuard.isCurrent(`governanceNotifications:${cwd}:${channelId}`, token)) { - return; - } - setChannelGovernanceNotifications(response.notifications); + read.publish(setChannelGovernanceNotifications, response.notifications); } catch { - if (!loadGuard.isCurrent(`governanceNotifications:${cwd}:${channelId}`, token)) { - return; - } - setChannelGovernanceNotifications([]); + read.publish(setChannelGovernanceNotifications, []); } finally { - if (loadGuard.isCurrent(`governanceNotifications:${cwd}:${channelId}`, token)) { - setChannelGovernanceNotificationsLoading(false); - } + read.publish(setChannelGovernanceNotificationsLoading, false); } } + async function markChannelGovernanceNotificationSeen(notificationId: string): Promise { const cwd = input.cwd.trim(); if (!cwd || !notificationId || channelGovernanceNotificationActionId) { @@ -983,60 +1090,57 @@ export function useWorkspaceDomain(input: { async function refreshChannelMessageRouteDecisions(channelId = selectedChannelId): Promise { const cwd = input.cwd.trim(); + const read = beginChannelPanelRead(`messageRouteDecisions:${cwd}:${channelId}`, cwd, channelId); + if (!read.isCurrent()) return; if (!cwd || !channelId) { - setChannelMessageRouteDecisions([]); + read.publish(setChannelMessageRouteDecisions, []); return; } - const token = loadGuard.next(`messageRouteDecisions:${cwd}:${channelId}`); try { clearScopedFeedback("protocol", channelId); - setError(undefined); + read.publish(setError, undefined); const response = await listChannelMessageRouteDecisions(cwd, channelId, { limit: channelMessageRouteDecisionLimit }); - if (!loadGuard.isCurrent(`messageRouteDecisions:${cwd}:${channelId}`, token)) { - return; - } - setChannelMessageRouteDecisions(response.decisions); + read.publish(setChannelMessageRouteDecisions, response.decisions); } catch (caught) { - if (!loadGuard.isCurrent(`messageRouteDecisions:${cwd}:${channelId}`, token)) return; - reportScopedFeedback("protocol", channelId, "load", caught); + read.report("protocol", caught); } } + async function refreshChannelIntentDecisions(channelId = selectedChannelId): Promise { const cwd = input.cwd.trim(); + const read = beginChannelPanelRead(`intentDecisions:${cwd}:${channelId}`, cwd, channelId); + if (!read.isCurrent()) return; if (!cwd || !channelId) { - setChannelIntentDecisions([]); + read.publish(setChannelIntentDecisions, []); return; } - const token = loadGuard.next(`intentDecisions:${cwd}:${channelId}`); try { clearScopedFeedback("protocol", channelId); - setError(undefined); + read.publish(setError, undefined); const response = await listChannelIntentDecisions(cwd, channelId, { limit: channelIntentDecisionLimit }); - if (!loadGuard.isCurrent(`intentDecisions:${cwd}:${channelId}`, token)) { - return; - } - setChannelIntentDecisions(response.decisions); + read.publish(setChannelIntentDecisions, response.decisions); } catch (caught) { - if (!loadGuard.isCurrent(`intentDecisions:${cwd}:${channelId}`, token)) return; - reportScopedFeedback("protocol", channelId, "load", caught); + read.report("protocol", caught); } } + async function refreshChannelProtocol(channelId = selectedChannelId): Promise { const cwd = input.cwd.trim(); + const read = beginChannelPanelRead(`protocol:${cwd}:${channelId}`, cwd, channelId); + if (!read.isCurrent()) return; if (!cwd || !channelId) { resetChannelProtocol(); return; } - const token = loadGuard.next(`protocol:${cwd}:${channelId}`); - setChannelProtocolLoading(true); + read.publish(setChannelProtocolLoading, true); try { clearScopedFeedback("protocol", channelId); - setError(undefined); + read.publish(setError, undefined); const [briefResponse, protocolResponse, decisionsResponse, routeDecisionsResponse, intentDecisionsResponse, handoffsResponse, summariesResponse, workflowsResponse] = await Promise.all([ getChannelBrief(cwd, channelId), getChannelCollaborationProtocol(cwd, channelId), @@ -1047,30 +1151,28 @@ export function useWorkspaceDomain(input: { listChannelContextSummaries(cwd, channelId), listChannelWorkflows(cwd, channelId), ]); + if (!read.isCurrent()) return; const workflowSteps = await loadWorkflowSteps(cwd, workflowsResponse.workflows); - if (!loadGuard.isCurrent(`protocol:${cwd}:${channelId}`, token)) { - return; - } - setChannelBrief(briefResponse.brief); - setChannelCollaborationProtocol(protocolResponse); - setChannelDecisions(decisionsResponse.decisions); - setChannelMessageRouteDecisions(routeDecisionsResponse.decisions); - setChannelIntentDecisions(intentDecisionsResponse.decisions); - setChannelHandoffs(handoffsResponse.handoffs); - setChannelContextSummaries(summariesResponse.contextSummaries); - setChannelWorkflows(workflowsResponse.workflows); - setChannelWorkflowSteps(current => mergeWorkflowStepReadModels(current, workflowSteps)); + read.publish(setChannelBrief, briefResponse.brief); + read.publish(setChannelCollaborationProtocol, protocolResponse); + read.publish(setChannelDecisions, decisionsResponse.decisions); + read.publish(setChannelMessageRouteDecisions, routeDecisionsResponse.decisions); + read.publish(setChannelIntentDecisions, intentDecisionsResponse.decisions); + read.publish(setChannelHandoffs, handoffsResponse.handoffs); + read.publish(setChannelContextSummaries, summariesResponse.contextSummaries); + read.publish(setChannelWorkflows, workflowsResponse.workflows); + read.publish(setChannelWorkflowSteps, current => mergeWorkflowStepReadModels(current, workflowSteps)); } catch (caught) { - if (!loadGuard.isCurrent(`protocol:${cwd}:${channelId}`, token)) return; - reportScopedFeedback("protocol", channelId, "load", caught); + read.report("protocol", caught); } finally { - if (loadGuard.isCurrent(`protocol:${cwd}:${channelId}`, token)) { - setChannelProtocolLoading(false); - } + read.publish(setChannelProtocolLoading, false); } } + async function refreshUsedMemoryAudit(cwd = input.cwd.trim()): Promise { + const observationRead = beginObservationRead(cwd); + if (!observationRead.admitted) return; if (!cwd) { setMemoryObjects([]); setUsedMemoryEvents([]); @@ -1083,13 +1185,13 @@ export function useWorkspaceDomain(input: { listMemoryObjects(cwd), listUsedMemoryEvents(cwd, { limit: 200 }), ]); - if (!loadGuard.isCurrent(`memory:${cwd}`, token)) { + if (!loadGuard.isCurrent(`memory:${cwd}`, token) || !observationRead.isCurrent()) { return; } setMemoryObjects(memoryResponse.memories); setUsedMemoryEvents(usedResponse.events); } catch { - if (!loadGuard.isCurrent(`memory:${cwd}`, token)) { + if (!loadGuard.isCurrent(`memory:${cwd}`, token) || !observationRead.isCurrent()) { return; } setMemoryObjects([]); @@ -1291,7 +1393,7 @@ export function useWorkspaceDomain(input: { setChannelCreateOpen, setChannelEditForm, setTasks, - setSelectedTaskId, + setSelectedTaskId: selectTaskExplicitly, setTaskForm, setTaskEditForm, setTaskRunProviderId, diff --git a/apps/web/src/features/approvals/ConversationDecisionGate.tsx b/apps/web/src/features/approvals/ConversationDecisionGate.tsx index 0ff1eef30..9a09e298d 100644 --- a/apps/web/src/features/approvals/ConversationDecisionGate.tsx +++ b/apps/web/src/features/approvals/ConversationDecisionGate.tsx @@ -14,6 +14,7 @@ import { export type ConversationDecisionAction = "allow" | "allow_secondary" | "deny"; export function ConversationDecisionGate({ + allowDisabled = false, allowLabel, allowMenuLabel, allowSecondaryDescription, @@ -31,6 +32,7 @@ export function ConversationDecisionGate({ errorMessage, focusKey, icon, + recoveryAction, retryLabel, surface, title, @@ -38,6 +40,7 @@ export function ConversationDecisionGate({ onAllowSecondary, onDeny, }: { + readonly allowDisabled?: boolean; readonly allowLabel: string; readonly allowMenuLabel?: string; readonly allowSecondaryDescription?: string; @@ -55,6 +58,7 @@ export function ConversationDecisionGate({ readonly errorMessage?: string; readonly focusKey: string; readonly icon?: ReactNode; + readonly recoveryAction?: ReactNode; readonly retryLabel: string; readonly surface: "channel" | "normal_conversation" | "teammate_direct"; readonly title: string; @@ -94,6 +98,7 @@ export function ConversationDecisionGate({

{errorMessage}

) : null}
+ {recoveryAction} - +
diff --git a/apps/web/src/features/runs/RunDetailHeader.tsx b/apps/web/src/features/runs/RunDetailHeader.tsx index fdc94a6e9..14a8cca95 100644 --- a/apps/web/src/features/runs/RunDetailHeader.tsx +++ b/apps/web/src/features/runs/RunDetailHeader.tsx @@ -1,4 +1,5 @@ import { Activity, RotateCcw, Square } from "lucide-react"; +import { canRerunSnapshot } from "../../api/run-request-builders.js"; import { useI18n } from "../../i18n.js"; import { readTaskTitle } from "../../lib/run-evidence.js"; import { Button, StatusIndicator } from "../../shared/ui/index.js"; @@ -17,7 +18,6 @@ export function RunDetailHeader({ run, approvals, isRunning, cancellingRunId, on const { t } = useI18n(); const primaryState = deriveRunPrimaryState(run, approvals); const stopping = Boolean(run && cancellingRunId === run.id); - const providerPresetId = run?.providerPresetId || (run?.requestSnapshot && typeof run.requestSnapshot === "object" && "providerPresetId" in run.requestSnapshot ? String(run.requestSnapshot.providerPresetId ?? "") : ""); return (
@@ -27,7 +27,7 @@ export function RunDetailHeader({ run, approvals, isRunning, cancellingRunId, on
{run && (runCanBeStopped(run) || stopping) ? : null} - +
); diff --git a/apps/web/src/features/runs/RunHarnessDiagnostics.tsx b/apps/web/src/features/runs/RunHarnessDiagnostics.tsx index 5cd85658a..281ed0d20 100644 --- a/apps/web/src/features/runs/RunHarnessDiagnostics.tsx +++ b/apps/web/src/features/runs/RunHarnessDiagnostics.tsx @@ -5,7 +5,7 @@ import { useI18n, type Translate } from "../../i18n.js"; import { formatDateTime } from "../../lib/formatting.js"; import { MetaRow } from "../../shared/components/MetaRow.js"; import { Button, EmptyState, InlineFeedback, InspectorSection, StatusIndicator } from "../../shared/ui/index.js"; -import type { HarnessDiagnosticObservationV1, WorkUnitDetailResponse } from "../../types.js"; +import type { HarnessDiagnosticObservationV1, HarnessDiagnosticRecordV1, WorkUnitDetailResponse } from "../../types.js"; import { createHarnessDiagnosticsController, type HarnessDiagnosticsAvailability, type HarnessDiagnosticsIssue, type HarnessDiagnosticsState } from "./harness-diagnostics-controller.js"; /** Loaded only while the existing Debug details disclosure is open; a new scope creates an empty controller synchronously. */ @@ -54,7 +54,7 @@ export function RunHarnessDiagnosticsView({ state, onRefresh, onLoadMore }: { ) : null} {state.phase === "unavailable" ? {availabilityDescription(state.availability, t)} : null} {loading ?

{t("Loading recovery diagnostics...")}

: null} - {state.phase === "ready" && state.items.length === 0 ? : null} + {state.phase === "ready" && state.items.length === 0 ? : null}
{state.items.map(item => )}
@@ -73,20 +73,10 @@ function HarnessDiagnosticRow({ item }: { readonly item: HarnessDiagnosticObserv ["Created", formatDateTime(record.createdAt)], ["Updated", formatDateTime(record.updatedAt)], ["Claim owner", record.claimOwner], ["Lease expires", record.claimExpiresAt ? formatDateTime(record.claimExpiresAt) : undefined], ["Next retry", record.nextAttemptAt ? formatDateTime(record.nextAttemptAt) : undefined], ["Failure code", record.failureCode], - ...(record.kind === "continuation" ? [ - ["Source attempt", record.sourceAttemptId], ["Target attempt", record.targetAttemptId], ["Origin", record.origin], - ["Strategy", record.strategy], ["Runner", record.runnerId], ["Continuation generation", record.generation], - ["Failure detail artifact", record.failureDetailArtifactId], ["Completed", record.completedAt ? formatDateTime(record.completedAt) : undefined], - ] as const : [ - ["Attempt", record.attemptId], ["Tool call", record.toolCallId], ["Tool", record.toolName], ["Executor kind", record.executorKind], - ["Continuation", record.continuationId], ["Delegation step", record.delegationStepId], ["Supersedes execution", record.supersedesExecutionId], - ["Policy snapshot artifact", record.policySnapshotArtifactId], ["Approval", record.approvalId], ["Sandbox profile", record.sandboxProfileId], - ["Execution started", record.executionStartedAt ? formatDateTime(record.executionStartedAt) : undefined], - ["Execution finished", record.executionFinishedAt ? formatDateTime(record.executionFinishedAt) : undefined], - ] as const), + ...diagnosticRecordFields(record), ]; return ( - {record.id.length > 36 ? `${record.id.slice(0, 16)}…${record.id.slice(-16)}` : record.id} @@ -113,6 +103,40 @@ function HarnessDiagnosticRow({ item }: { readonly item: HarnessDiagnosticObserv ); } +function diagnosticRecordTitle(record: HarnessDiagnosticRecordV1, t: Translate): string { + switch (record.kind) { + case "continuation": return t("Continuation"); + case "tool_execution": return `${t("Controlled tool execution")} · ${record.toolName}`; + case "evaluation": return t("Run evaluation"); + case "delegation": return t("Delegation"); + } +} + +function diagnosticRecordFields(record: HarnessDiagnosticRecordV1): readonly (readonly [string, string | number | undefined])[] { + switch (record.kind) { + case "continuation": return [ + ["Source attempt", record.sourceAttemptId], ["Target attempt", record.targetAttemptId], ["Origin", record.origin], + ["Strategy", record.strategy], ["Runner", record.runnerId], ["Continuation generation", record.generation], + ["Failure detail artifact", record.failureDetailArtifactId], ["Completed", record.completedAt ? formatDateTime(record.completedAt) : undefined], + ]; + case "tool_execution": return [ + ["Attempt", record.attemptId], ["Tool call", record.toolCallId], ["Tool", record.toolName], ["Executor kind", record.executorKind], + ["Continuation", record.continuationId], ["Delegation step", record.delegationStepId], ["Supersedes execution", record.supersedesExecutionId], + ["Policy snapshot artifact", record.policySnapshotArtifactId], ["Approval", record.approvalId], ["Sandbox profile", record.sandboxProfileId], + ["Execution started", record.executionStartedAt ? formatDateTime(record.executionStartedAt) : undefined], + ["Execution finished", record.executionFinishedAt ? formatDateTime(record.executionFinishedAt) : undefined], + ]; + case "evaluation": return [ + ["Root run", record.rootRunId], ["Completed", record.completedAt ? formatDateTime(record.completedAt) : undefined], + ]; + case "delegation": return [ + ["Root run", record.rootRunId], ["Parent attempt", record.parentAttemptId], ["Workflow", record.workflowId], + ["Workflow step", record.workflowStepId], ["Parent delegation step", record.parentDelegationStepId], ["Child run", record.childRunId], + ["Required", String(record.required)], ["Depth", record.depth], ["Completed", record.completedAt ? formatDateTime(record.completedAt) : undefined], + ]; + } +} + function issueDescription(issue: HarnessDiagnosticsIssue | undefined, t: Translate): string { switch (issue) { case "workspace_uninitialized": return t("This workspace has not been initialized. This read does not initialize or recover it."); diff --git a/apps/web/src/features/runs/RuntimeRunRepairPanel.tsx b/apps/web/src/features/runs/RuntimeRunRepairPanel.tsx index 86aa4b13c..fc7480c0e 100644 --- a/apps/web/src/features/runs/RuntimeRunRepairPanel.tsx +++ b/apps/web/src/features/runs/RuntimeRunRepairPanel.tsx @@ -1,6 +1,7 @@ import { Pencil, RefreshCw, RotateCcw, SquareTerminal } from "lucide-react"; +import { canRerunSnapshot } from "../../api/run-request-builders.js"; import { useI18n } from "../../i18n.js"; -import { buildRuntimeFailureSummary, readRunProviderPresetId } from "../../lib/run-evidence.js"; +import { buildRuntimeFailureSummary } from "../../lib/run-evidence.js"; import { MetaRow } from "../../shared/components/index.js"; import { Button, InlineFeedback, StatusIndicator } from "../../shared/ui/index.js"; import type { RunEventEnvelope, RunSession } from "../../types.js"; @@ -28,7 +29,7 @@ export function RuntimeRunRepairPanel({ run, events, workspaceCwd, isRunning, on
- +
diff --git a/apps/web/src/features/runs/harness-diagnostics-response.ts b/apps/web/src/features/runs/harness-diagnostics-response.ts index ed91f886d..2a0cdc6b1 100644 --- a/apps/web/src/features/runs/harness-diagnostics-response.ts +++ b/apps/web/src/features/runs/harness-diagnostics-response.ts @@ -1,7 +1,22 @@ -import { HARNESS_TOOL_EXECUTION_STATES, RUNNER_CONTINUATION_STATES } from "@ling-stack/contracts"; -import type { HarnessDiagnosticCursorV1, HarnessDiagnosticObservationV1, WorkUnitHarnessDiagnosticsResponse } from "../../types.js"; +import { + DELEGATION_STEP_STATES, + HARNESS_TOOL_EXECUTION_STATES, + RUN_EVALUATION_STATES, + RUNNER_CONTINUATION_STATES, +} from "@ling-stack/contracts"; +import type { + HarnessDiagnosticCursorV1, + HarnessDiagnosticObservationV1, + HarnessDiagnosticProjectionCheckpointV1, + WorkUnitHarnessDiagnosticsResponse, +} from "../../types.js"; import type { HarnessDiagnosticsScope } from "./harness-diagnostics-controller.js"; +const projectionDomains = ["continuation", "tool_execution", "evaluation", "delegation"] as const; +const projectionCheckpointKeys = new Set([ + "domain", "generation", "sourceEpoch", "builtEpoch", "state", "rowCount", "isCurrent", "failureCode", "updatedAt", +]); + /** Reject malformed or foreign observations before they can become visible state. */ export function validHarnessDiagnosticsResponse( response: WorkUnitHarnessDiagnosticsResponse, @@ -10,11 +25,15 @@ export function validHarnessDiagnosticsResponse( prior: readonly HarnessDiagnosticObservationV1[], ): boolean { if (!response || response.schemaVersion !== 1 || response.cwd !== scope.cwd || response.workUnitId !== scope.workUnitId - || !Array.isArray(response.items) || response.items.length > 100 || !canonicalTimestamp(response.observedAt)) return false; - if (response.status === "run_unlinked") return response.runId === undefined && response.items.length === 0 && !response.nextCursor; + || !Array.isArray(response.items) || response.items.length > 100 || !Array.isArray(response.projectionCheckpoints) + || !canonicalTimestamp(response.observedAt)) return false; + if (response.status === "run_unlinked") return response.runId === undefined && response.items.length === 0 + && response.projectionCheckpoints.length === 0 && !response.nextCursor; if (response.runId !== scope.runId) return false; - if (response.status === "run_missing" || response.status === "lineage_mismatch") return response.items.length === 0 && !response.nextCursor; + if (response.status === "run_missing" || response.status === "lineage_mismatch") return response.items.length === 0 + && response.projectionCheckpoints.length === 0 && !response.nextCursor; if (response.status !== "available") return false; + if (!validProjectionCheckpoints(response.projectionCheckpoints)) return false; const identities = new Set(prior.map(item => `${item.record.kind}:${item.record.id}`)); for (const item of response.items) { if (!validObservation(item, scope)) return false; @@ -44,6 +63,17 @@ function validObservation(item: HarnessDiagnosticObservationV1, scope: HarnessDi } else if (record.kind === "tool_execution") { if (!HARNESS_TOOL_EXECUTION_STATES.includes(record.state) || !identifier(record.attemptId)) return false; references = [record.attemptId]; + } else if (record.kind === "evaluation") { + if (!RUN_EVALUATION_STATES.includes(record.state) || !identifier(record.rootRunId)) return false; + references = []; + } else if (record.kind === "delegation") { + if (!DELEGATION_STEP_STATES.includes(record.state) || !identifier(record.rootRunId) || !identifier(record.parentAttemptId) + || typeof record.required !== "boolean" || !nonNegativeInteger(record.depth) + || (record.workflowId !== undefined && !identifier(record.workflowId)) + || (record.workflowStepId !== undefined && !identifier(record.workflowStepId)) + || (record.parentDelegationStepId !== undefined && !identifier(record.parentDelegationStepId)) + || (record.childRunId !== undefined && !identifier(record.childRunId))) return false; + references = [record.parentAttemptId]; } else return false; if (item.attempts.length !== references.length) return false; const seen = new Set(); @@ -61,6 +91,27 @@ function validObservation(item: HarnessDiagnosticObservationV1, scope: HarnessDi return true; } +function validProjectionCheckpoints(checkpoints: readonly HarnessDiagnosticProjectionCheckpointV1[]): boolean { + if (checkpoints.length !== projectionDomains.length) return false; + const seen = new Set(); + for (const checkpoint of checkpoints) { + if (!checkpoint || Object.keys(checkpoint).some(key => !projectionCheckpointKeys.has(key)) + || !projectionDomains.includes(checkpoint.domain) || seen.has(checkpoint.domain) + || !nonNegativeInteger(checkpoint.generation) || !nonNegativeInteger(checkpoint.sourceEpoch) + || !nonNegativeInteger(checkpoint.builtEpoch) || !nonNegativeInteger(checkpoint.rowCount) + || !["stale", "rebuilding", "ready", "failed"].includes(checkpoint.state) + || checkpoint.isCurrent !== (checkpoint.state === "ready" && checkpoint.sourceEpoch === checkpoint.builtEpoch) + || (checkpoint.failureCode !== undefined && !identifier(checkpoint.failureCode)) + || !canonicalTimestamp(checkpoint.updatedAt)) return false; + seen.add(checkpoint.domain); + } + return projectionDomains.every(domain => seen.has(domain)); +} + +function nonNegativeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + function identifier(value: unknown): value is string { return typeof value === "string" && value.length > 0 && value.length <= 1024 && !value.includes("\0"); } diff --git a/apps/web/src/features/settings/ExecutionPermissionsSettingsPanel.tsx b/apps/web/src/features/settings/ExecutionPermissionsSettingsPanel.tsx index 44e06bd10..dc8dd4c34 100644 --- a/apps/web/src/features/settings/ExecutionPermissionsSettingsPanel.tsx +++ b/apps/web/src/features/settings/ExecutionPermissionsSettingsPanel.tsx @@ -205,6 +205,7 @@ function ApprovalCommandRulesSettings({ cwd }: { readonly cwd: string }): React. {rule.display} {t("Allows commands starting with {command}", { command: rule.display })} + {rule.cwd ? {t("Working directory")}: {rule.cwd} : null} )} actions={( diff --git a/apps/web/src/features/settings/execution-engines/ExecutionEnginesSettingsPage.tsx b/apps/web/src/features/settings/execution-engines/ExecutionEnginesSettingsPage.tsx index 27950cf8f..806ec2ff9 100644 --- a/apps/web/src/features/settings/execution-engines/ExecutionEnginesSettingsPage.tsx +++ b/apps/web/src/features/settings/execution-engines/ExecutionEnginesSettingsPage.tsx @@ -34,7 +34,7 @@ export function ExecutionEnginesSettingsPage({ cwd }: { readonly cwd: string }): const login = useSyncExternalStore(loginController.subscribe, loginController.getSnapshot); const [code, setCode] = useState(""); const returnFocusRef = useRef(null); - useEffect(() => { void manager.load().then(() => manager.discover()); return () => { manager.detach(); void loginController.cancel(); }; }, [manager]); + useEffect(() => { void manager.load(); return () => { manager.detach(); void loginController.cancel(); }; }, [manager]); useEffect(() => { const revalidate = () => { if (document.visibilityState === "visible" && !loginController.getSnapshot()) void manager.revalidate(); @@ -182,8 +182,7 @@ function EngineDiscoveryActions({ engineId, manager }: {
{candidate?.status === "detected" && candidate.executablePath ? ( ) : null} {pkg?.installable ? ( diff --git a/apps/web/src/features/settings/execution-engines/execution-engine-manager.ts b/apps/web/src/features/settings/execution-engines/execution-engine-manager.ts index 2fad654e8..91a37b58f 100644 --- a/apps/web/src/features/settings/execution-engines/execution-engine-manager.ts +++ b/apps/web/src/features/settings/execution-engines/execution-engine-manager.ts @@ -26,7 +26,7 @@ export interface EngineManagementState { readonly discovery?: EngineDiscoveryReport; readonly installs: Readonly>> readonly editor?: EngineProfileEditor; - readonly busy: Readonly>>; + readonly busy: Readonly>>; readonly probes: Readonly>>; readonly errors: Readonly>>; readonly authorizing?: boolean; @@ -39,6 +39,8 @@ export class ExecutionEngineManager { readonly #listeners = new Set<() => void>(); #state: EngineManagementState = { phase: "loading", profiles: [], installs: {}, busy: {}, probes: {}, errors: {} }; #loadRevision = 0; + #discoveryRevision = 0; + #editorRevision = 0; #scope = 0; #revalidation?: Promise; readonly #probes = new Map(); @@ -87,45 +89,67 @@ export class ExecutionEngineManager { /** Read-only discovery of existing engine installations on the host. */ async discover(): Promise { - if (this.#state.phase !== "ready" || this.transport.kind === "unavailable") return; + if (this.#state.phase !== "ready" || this.#state.authorizing || this.transport.kind === "unavailable") return; + const scope = this.#scope; + const revision = ++this.#discoveryRevision; try { const discovery = await this.transport.api.discover(); + if (scope !== this.#scope || revision !== this.#discoveryRevision) return; this.publish({ discovery }); - } catch { + } catch (error) { + if (scope !== this.#scope || revision !== this.#discoveryRevision) return; + const code = engineManagementErrorCode(error); + if (needsAuthority(code)) this.loseAuthority(code); // Discovery is best-effort; the manual path stays available without it. } } /** Managed install of the pinned engine package, then open the prefilled profile draft. */ async installEngine(engineId: ExternalExecutionEngineId): Promise { - if (this.#state.phase !== "ready" || this.transport.kind === "unavailable" || this.#state.busy[engineId]) return; + if (!this.canAct(engineId) || this.transport.kind === "unavailable") return; + const scope = this.#scope; + const editorRevision = this.#editorRevision; + // Reserve before the first await; a second click must not send another POST. + this.setBusy(engineId, "install"); const publish = (install: ManagedInstallStatus): void => { + if (install.engineId !== engineId) throw new Error("engine_operation_failed"); this.publish({ installs: { ...this.#state.installs, [engineId]: install } }); }; try { - publish((await this.transport.api.install(engineId)).install); + const result = await this.transport.api.install(engineId); + if (scope !== this.#scope) return; + publish(result.install); let current = this.#state.installs[engineId]; while (current && (current.phase === "downloading" || current.phase === "verifying")) { await new Promise((resolve) => setTimeout(resolve, 1500)); - publish((await this.transport.api.installStatus(engineId)).install); + if (scope !== this.#scope) return; + const result = await this.transport.api.installStatus(engineId); + if (scope !== this.#scope) return; + publish(result.install); current = this.#state.installs[engineId]; } - if (current?.phase === "installed" && current.executablePath) { - this.edit(engineId); - this.updateDraft({ executablePath: current.executablePath }); + if (current?.phase === "installed" && current.executablePath && !this.#state.editor + && editorRevision === this.#editorRevision) { + this.setBusy(engineId, undefined); + // Installation is not a configuration write or permission to replace a draft. + this.edit(engineId, current.executablePath); } } catch (error) { + if (scope !== this.#scope) return; const code = engineManagementErrorCode(error); - this.publish({ installs: { ...this.#state.installs, [engineId]: { engineId, phase: "failed", error: code, updatedAt: new Date().toISOString() } }, errors: { ...this.#state.errors, [engineId]: code } }); + if (needsAuthority(code)) this.loseAuthority(code); + else this.publish({ installs: { ...this.#state.installs, [engineId]: { engineId, phase: "failed", error: code, updatedAt: new Date().toISOString() } }, errors: { ...this.#state.errors, [engineId]: code } }); + } finally { + if (scope === this.#scope && this.#state.busy[engineId] === "install") this.setBusy(engineId, undefined); } } - edit(engineId: ExternalExecutionEngineId): void { + edit(engineId: ExternalExecutionEngineId, executablePath = ""): void { if (this.#state.phase !== "ready" || this.#state.editor || this.#state.busy[engineId]) return; const profile = this.#state.profiles.find((item) => item.configuration.engineId === engineId); this.publish({ editor: { expectedRevision: profile?.revision ?? 0, - draft: structuredClone(profile?.configuration ?? { engineId, executablePath: "", authProfileId: "ling" }), + draft: structuredClone(profile?.configuration ?? { engineId, executablePath, authProfileId: "ling" }), } }); } @@ -197,14 +221,17 @@ export class ExecutionEngineManager { const scope = ++this.#scope; this.#loadRevision++; this.abortProbes(); - this.publish({ authorizing: true, error: undefined, busy: {} }); + this.publish({ authorizing: true, error: undefined, busy: {}, discovery: undefined, installs: {} }); try { await this.transport.enroll(code); if (scope === this.#scope) await this.load(); } catch (error) { if (scope === this.#scope) this.publish({ phase: "authorization_required", error: engineManagementErrorCode(error) }); } finally { - if (scope === this.#scope) this.publish({ authorizing: false }); + if (scope === this.#scope) { + this.publish({ authorizing: false }); + void this.discover(); + } } } @@ -213,7 +240,8 @@ export class ExecutionEngineManager { const scope = ++this.#scope; this.#loadRevision++; this.abortProbes(); - this.publish({ phase: "authorization_required", profiles: [], editor: undefined, busy: {}, probes: {}, errors: {}, authorizing: true, error: undefined }); + this.publish({ phase: "authorization_required", profiles: [], editor: undefined, busy: {}, probes: {}, errors: {}, + discovery: undefined, installs: {}, authorizing: true, error: undefined }); try { await this.transport.revoke(); } catch (error) { @@ -228,7 +256,7 @@ export class ExecutionEngineManager { this.#scope++; this.#loadRevision++; this.abortProbes(); - this.#state = { ...this.#state, editor: undefined, busy: {}, authorizing: false }; + this.#state = { ...this.#state, editor: undefined, busy: {}, discovery: undefined, installs: {}, authorizing: false }; } private canAct(id: ExternalExecutionEngineId): boolean { @@ -295,7 +323,7 @@ export class ExecutionEngineManager { this.#loadRevision++; this.abortProbes(); this.publish({ phase: "authorization_required", profiles: [], editor: undefined, - probes: {}, errors: {}, busy: {}, authorizing: false, error: code }); + probes: {}, errors: {}, busy: {}, discovery: undefined, installs: {}, authorizing: false, error: code }); } private abortProbes(): void { @@ -314,6 +342,7 @@ export class ExecutionEngineManager { } private publish(patch: Partial): void { + if ("editor" in patch && patch.editor !== this.#state.editor) this.#editorRevision++; this.#state = { ...this.#state, ...patch }; for (const listener of this.#listeners) listener(); } diff --git a/apps/web/src/features/teammates/TeammatePrivateChatComposer.tsx b/apps/web/src/features/teammates/TeammatePrivateChatComposer.tsx index d91c32985..048426da2 100644 --- a/apps/web/src/features/teammates/TeammatePrivateChatComposer.tsx +++ b/apps/web/src/features/teammates/TeammatePrivateChatComposer.tsx @@ -1,16 +1,11 @@ import { CircleAlert, FileText, Plus, Send, SlidersHorizontal, Square } from "lucide-react"; import { useI18n } from "../../i18n.js"; -import { - conversationAuthorizationModeMenuOptions, - conversationPermissionPresetMenuOptions, -} from "../../lib/session-permissions.js"; import { ComposerFileReferenceToken, ComposerPendingAssetStrip, } from "../../shared/components/index.js"; import { - ComposerAuthorizationMenu, - ComposerPermissionMenu, + ConversationPermissionControl, ConversationComposerShell, ConversationBottomStatusStrip, ConversationGuidanceStrip, @@ -285,23 +280,14 @@ export function TeammatePrivateChatComposer({ }} /> - - )} diff --git a/apps/web/src/features/teammates/TeammatePrivateChatFeed.tsx b/apps/web/src/features/teammates/TeammatePrivateChatFeed.tsx index ce8780df8..04cf191a9 100644 --- a/apps/web/src/features/teammates/TeammatePrivateChatFeed.tsx +++ b/apps/web/src/features/teammates/TeammatePrivateChatFeed.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { ArrowDown, Copy, FileText, Image, Loader2, MessageSquare, Pencil, Plus, ThumbsDown, ThumbsUp } from "lucide-react"; -import { attachmentContentUrl } from "../../api.js"; +import { attachmentContentUrl, daemonBaseUrl, submitUserInputAnswer } from "../../api.js"; import { useI18n } from "../../i18n.js"; import { formatDateTime } from "../../lib/formatting.js"; import { attachmentCanPreview, loadPreviewContent } from "../../lib/previews.js"; @@ -53,6 +53,7 @@ import { selectTeammatePrivateOwnedMessages, } from "./teammate-private-timeline-items.js"; import { useTeammatePrivateTimeline } from "./use-teammate-private-timeline.js"; +import { conversationReadingScope } from "../../shared/conversation/view-store/conversation-reading-storage.js"; export type TeammatePrivateChatFeedProps = Pick submitUserInputAnswer(cwd.trim(), command), virtualScrollRef: privateFeedRef, itemLocatorRef: timelineItemLocatorRef, onPositionNavigate: onCancelPrivateFeedAutoFollow, diff --git a/apps/web/src/features/teammates/TeammatePrivateChatSurface.tsx b/apps/web/src/features/teammates/TeammatePrivateChatSurface.tsx index 9ee39f9c3..98c3c5483 100644 --- a/apps/web/src/features/teammates/TeammatePrivateChatSurface.tsx +++ b/apps/web/src/features/teammates/TeammatePrivateChatSurface.tsx @@ -55,7 +55,10 @@ export function TeammatePrivateChatSurface(props: TeammatePrivateChatSurfaceProp const terminalPresence = useConversationPanelPresence(terminalOpen, false); const terminalSession = useWorkspaceTerminalSession(cwd.trim()); const composerController = useTeammatePrivateComposerController({ - identityKey: teammate.id, + identityKey: props.permissionSelectionOwnerKey, + defaultWorkspaceAccessMode: props.defaultWorkspaceAccessMode, + permissionLocked: privateComposerSending || props.hasDirectRuntimeActivity || Boolean(props.directGuidanceSubmitting), + onPermissionError: props.onPermissionError, authorizationMode, messageDraft: props.messageDraft, onComposerKeyDown, diff --git a/apps/web/src/features/teammates/TeammateWorkspace.tsx b/apps/web/src/features/teammates/TeammateWorkspace.tsx index b8b90d352..89a8c8886 100644 --- a/apps/web/src/features/teammates/TeammateWorkspace.tsx +++ b/apps/web/src/features/teammates/TeammateWorkspace.tsx @@ -1,4 +1,5 @@ import { useWorkSummaryContext } from "../workspace/work-summary/WorkSummaryProvider.js"; +import { useTeammateObservationReads } from "./use-teammate-private-timeline.js"; import { useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent, type UIEvent } from "react"; import { Bot, @@ -113,6 +114,8 @@ import { TeammatePermissionsTab } from "./TeammatePermissionsTab.js"; import { TeammateProfileTab } from "./TeammateProfileTab.js"; import { TeammatePrivateChatSurface } from "./TeammatePrivateChatSurface.js"; import { useConversationReadingViewport } from "../../shared/conversation/blocks/use-conversation-reading-viewport.js"; +import { conversationReadingScope } from "../../shared/conversation/view-store/conversation-reading-storage.js"; +import { daemonBaseUrl } from "../../api/client.js"; import { TeammateRenameDialog } from "./TeammateRenameDialog.js"; import { RuntimeApprovalComposerGate } from "../approvals/RuntimeApprovalComposerGate.js"; import type { ApprovalResolution } from "../approvals/types.js"; @@ -123,6 +126,7 @@ import { useWorkspaceRightDockController } from "../workspace/useWorkspaceRightD import { useConversationSummaryLayout } from "../workspace/conversation-summary-layout.js"; import { useConversationHeaderController } from "../workspace/useConversationHeaderController.js"; import { buildTeammatePrivateModelNotice, teammatePrivateMessageSearchText } from "./teammate-private-chat-view-models.js"; +import { privateTimelineResponseIsOwned } from "./teammate-private-timeline-items.js"; import { directComposerFileRefs, directWorkspacePathLabel, @@ -190,6 +194,7 @@ export function TeammateWorkspace({ fullAccessConfirmed = false, onConfirmFullAccess, onConfirmFullAccessSelection, + permissionSelectionOwnerKey, defaultPermissionMode, defaultPermissionPreset, workspacePolicy, @@ -236,6 +241,7 @@ export function TeammateWorkspace({ readonly fullAccessConfirmed?: boolean; readonly onConfirmFullAccess?: () => Promise; readonly onConfirmFullAccessSelection?: () => Promise; + readonly permissionSelectionOwnerKey?: string; readonly defaultPermissionMode?: SessionPermissionMode; readonly defaultPermissionPreset?: PermissionPreset; readonly workspacePolicy?: ApprovalPolicy; @@ -258,6 +264,7 @@ export function TeammateWorkspace({ readonly onSettingsOpenRequestConsumed?: (requestId: number) => void; }): React.JSX.Element { const { t } = useI18n(); + const observationReads = useTeammateObservationReads(cwd); const titlebarPlacement = useDesktopTitlebarPlacement(); const focusRevalidationToken = useFocusRevalidationToken(); const [workspaceView, setWorkspaceView] = useState("chat"); @@ -283,12 +290,14 @@ export function TeammateWorkspace({ const [terminalReports, setTerminalReports] = useState([]); const [directExecutionActions, setDirectExecutionActions] = useState([]); const [directRuntime, setDirectRuntime] = useState(); + const [directTimelineRuns, setDirectTimelineRuns] = useState([]); const [directWorkspace, setDirectWorkspace] = useState(); const workspaceSnapshotCurrent = messageSnapshotIdentity === workspaceMessageIdentity; const currentMessages = workspaceSnapshotCurrent ? messages : emptyTeammateDirectMessages; const currentRuntimeMetrics = workspaceSnapshotCurrent ? runtimeMetrics : undefined; const currentGeneratedMedia = workspaceSnapshotCurrent ? generatedMedia : emptyConversationGeneratedMedia; const currentDirectRuntime = workspaceSnapshotCurrent ? directRuntime : undefined; + const currentDirectTimelineRuns = workspaceSnapshotCurrent ? directTimelineRuns : []; const currentDirectWorkspace = workspaceSnapshotCurrent ? directWorkspace : undefined; useEffect(() => { @@ -492,6 +501,8 @@ export function TeammateWorkspace({ const hasDirectRuntimeActivity = Boolean(directReplyActive || (currentDirectRuntime?.interventions.length ?? 0) > 0); useEffect(() => { + const observationRead = observationReads.begin(); + if (!observationRead) return undefined; const request = workspaceRequestOwner.beginWorkspaceAction("terminal-reports"); if (!workspaceRequestOwner.isActionCurrent(request)) return undefined; const workspaceCwd = cwd.trim(); @@ -506,15 +517,18 @@ export function TeammateWorkspace({ } void Promise.all(threadIds.map((threadId) => listTerminalReports(workspaceCwd, { threadId, limit: 100 }))) .then((responses) => { + if (!observationReads.isCurrent(observationRead)) return; workspaceRequestOwner.commit(request, "terminal-reports", () => { setTerminalReports(responses.flatMap((response) => response.reports)); }); }) - .catch(() => { - workspaceRequestOwner.commit(request, "terminal-reports", () => setTerminalReports([])); + .catch((error) => { + if (!observationReads.shouldMergeTransport(error)) { + workspaceRequestOwner.reportError(request, error instanceof Error ? error.message : String(error)); + } }); return undefined; - }, [cwd, runs, teammate.id, visibleMessages]); + }, [cwd, runs, teammate.id, visibleMessages, observationReads.epoch, observationReads.paused]); const visibleDirectRunIds = useMemo(() => { const teammateRunIds = new Set(runs.filter((run) => !run.teammateId || run.teammateId === teammate.id).map((run) => run.id)); @@ -523,19 +537,25 @@ export function TeammateWorkspace({ .filter((runId): runId is string => Boolean(runId && teammateRunIds.has(runId))))]; }, [runs, teammate.id, visibleMessages]); const visibleDirectRunKey = visibleDirectRunIds.join("|"); - const directApprovalViews = useMemo( - () => currentDirectRuntime ? buildInlineApprovalViews(currentDirectRuntime.approvals, { + const directApprovalViews = useMemo(() => { + if (!currentDirectRuntime) return []; + const activeWorkUnit = currentDirectRuntime.activeReply?.workUnit; + // Private runtime snapshots already carry the current Host-owned WorkUnit. + // Global runtime lists load independently and may predate this submission. + const activeWorkUnits = activeWorkUnit?.id === currentDirectRuntime.activeReply?.workUnitId + && activeWorkUnit?.kind === "teammate_direct_reply" && activeWorkUnit.teammateId === teammate.id + ? [activeWorkUnit] : []; + return buildInlineApprovalViews(currentDirectRuntime.approvals, { owner: { kind: "teammate_direct", teammateId: teammate.id, threadId: currentDirectRuntime.thread.id, }, - runs, + runs: [...runs, ...currentDirectTimelineRuns], teammateDirectMessages: currentMessages, - workUnits, - }) : [], - [currentDirectRuntime, currentMessages, runs, teammate.id, workUnits], - ); + workUnits: [...workUnits, ...activeWorkUnits], + }); + }, [currentDirectRuntime, currentDirectTimelineRuns, currentMessages, runs, teammate.id, workUnits]); const hasActiveVisibleDirectRun = useMemo(() => { const visibleRunIds = new Set(visibleDirectRunIds); return runs.some((run) => visibleRunIds.has(run.id) && ( @@ -544,6 +564,7 @@ export function TeammateWorkspace({ }, [runs, visibleDirectRunKey]); useEffect(() => { + if (!observationReads.begin()) return undefined; const emptyRequest = workspaceRequestOwner.beginWorkspaceAction("execution-actions"); if (!workspaceRequestOwner.isActionCurrent(emptyRequest)) return undefined; const workspaceCwd = cwd.trim(); @@ -554,7 +575,8 @@ export function TeammateWorkspace({ let cancelled = false; let loading = false; const refresh = async (): Promise => { - if (loading) { + const observationRead = observationReads.begin(); + if (loading || !observationRead) { return; } loading = true; @@ -567,12 +589,16 @@ export function TeammateWorkspace({ const responses = await Promise.all(visibleDirectRunIds.map((runId) => listExecutionActions(workspaceCwd, { runId, limit: 100 }), )); - if (!cancelled) workspaceRequestOwner.commit(request, "execution-actions", () => { + if (!cancelled && observationReads.isCurrent(observationRead)) workspaceRequestOwner.commit(request, "execution-actions", () => { const byId = new Map(responses.flatMap((response) => response.actions).map((action) => [action.id, action])); setDirectExecutionActions([...byId.values()]); }); - } catch { - // Preserve the last persisted snapshot across transient polling failures. + } catch (error) { + // Keep persisted evidence. Only the owned paused Host's transport + // failure belongs to the shared connection state, not business errors. + if (!observationReads.shouldMergeTransport(error)) { + workspaceRequestOwner.reportError(request, error instanceof Error ? error.message : String(error)); + } } finally { loading = false; } @@ -591,7 +617,7 @@ export function TeammateWorkspace({ window.clearInterval(interval); } }; - }, [cwd, directReplyActive, hasActiveVisibleDirectRun, hasDirectRuntimeActivity, teammate.id, visibleDirectRunKey]); + }, [cwd, directReplyActive, hasActiveVisibleDirectRun, hasDirectRuntimeActivity, teammate.id, visibleDirectRunKey, observationReads.epoch, observationReads.paused]); useEffect(() => { if (isProtectedSystemTeammate && (activeTab === "skills" || activeTab === "tools_permissions")) { @@ -605,6 +631,12 @@ export function TeammateWorkspace({ searchActive: chatHeaderController.searchOpen, enabled: !loading && workspaceView === "chat", latestThresholdPx: 24, + readingScopeKey: conversationReadingScope(daemonBaseUrl, cwd, `teammate:${teammate.id}`, currentDirectWorkspace?.threadId), + onRestoreMissingAnchor: () => { + if (!hasMoreDirectMessages || !nextDirectBeforeMessageId || normalizedMessageSearchQuery) return false; + if (!loadingOlderDirectMessages) void loadOlderDirectMessages(); + return true; + }, }); const { scrollRef: privateFeedRef, @@ -828,12 +860,12 @@ export function TeammateWorkspace({ useEffect(() => { void refreshTeammateWorkspace(); - }, [cwd, teammate.id]); + }, [cwd, teammate.id, observationReads.epoch, observationReads.paused]); useEffect(() => { if (focusRevalidationToken === 0) return; void refreshTeammateWorkspace(false); - if (workspaceView === "settings" && activeTab === "channels") void channelsController.refresh("refresh"); + if (observationReads.begin() && workspaceView === "settings" && activeTab === "channels") void channelsController.refresh("refresh"); // eslint-disable-next-line react-hooks/exhaustive-deps }, [focusRevalidationToken]); @@ -853,8 +885,10 @@ export function TeammateWorkspace({ const refresh = async (): Promise => { try { await refreshDirectRuntime(); - } catch { - // Runtime polling is opportunistic; primary send errors still surface through workspaceError. + } catch (error) { + if (!observationReads.shouldMergeTransport(error)) { + workspaceRequestOwner.reportImmediate("runtime", error instanceof Error ? error.message : String(error)); + } } }; const intervalId = globalThis.setInterval(() => { @@ -869,6 +903,8 @@ export function TeammateWorkspace({ }, [cwd, hasDirectRuntimeActivity, teammate.id, workspaceView]); async function refreshTeammateWorkspace(showLoading = true): Promise { + const observationRead = observationReads.begin(); + if (!observationRead) return; const request = workspaceRequestOwner.beginWorkspaceAction("workspace"); if (!workspaceRequestOwner.isActionCurrent(request)) return; const workspaceCwd = cwd.trim(); @@ -907,6 +943,7 @@ export function TeammateWorkspace({ listTeammateMcpBindings(workspaceCwd, request.teammateId), listTeammateActivity(workspaceCwd, request.teammateId), ]); + if (!observationReads.isCurrent(observationRead)) return; workspaceRequestOwner.commit(request, "conversation", () => { setMessages(directResponse.messages); setRuntimeMetrics(directResponse.runtimeMetrics); @@ -915,6 +952,9 @@ export function TeammateWorkspace({ setHasMoreDirectMessages(Boolean(directResponse.hasMore)); setNextDirectBeforeMessageId(directResponse.nextBeforeMessageId); setDirectRuntime(directRuntimeResponse); + setDirectTimelineRuns(directResponse.cwd === workspaceCwd && privateTimelineResponseIsOwned(directResponse, { + teammateId: request.teammateId, directThreadId: directRuntimeResponse.thread.id, + }) ? directResponse.timeline?.runs ?? [] : []); setDirectWorkspace(directWorkspaceResponse); setDirectLinkPreviews(directLinkPreviewsResponse.linkPreviews); setPrivateAttachments(privateAttachmentsResponse.attachments); @@ -935,19 +975,24 @@ export function TeammateWorkspace({ workspaceRequestOwner.commit(request, "activity", () => setActivityItems(activityResponse.items)); workspaceRequestOwner.clearError(request); } catch (error) { - workspaceRequestOwner.reportError(request, error instanceof Error ? error.message : String(error)); + if (!observationReads.shouldMergeTransport(error)) { + workspaceRequestOwner.reportError(request, error instanceof Error ? error.message : String(error)); + } } finally { if (workspaceRequestOwner.isActionCurrent(request)) setLoading(false); } } async function refreshTeammateConversationSnapshot(): Promise { + const observationRead = observationReads.begin(); + if (!observationRead) return; const request = workspaceRequestOwner.beginWorkspaceAction("messages"); if (!workspaceRequestOwner.isActionCurrent(request)) return; const workspaceCwd = cwd.trim(); if (!workspaceCwd) return; try { const response = await listTeammateDirectMessages(workspaceCwd, request.teammateId); + if (!observationReads.isCurrent(observationRead)) return; workspaceRequestOwner.commit(request, "conversation", () => { setMessages((current) => current.length > response.messages.length ? upsertDirectMessages(current, response.messages) @@ -955,14 +1000,21 @@ export function TeammateWorkspace({ setRuntimeMetrics(response.runtimeMetrics); setGeneratedMedia((current) => mergeConversationGeneratedMedia(current, response.generatedMedia)); setMessageSnapshotIdentity(JSON.stringify([workspaceCwd, request.teammateId])); + setDirectTimelineRuns(response.cwd === workspaceCwd && privateTimelineResponseIsOwned(response, { + teammateId: request.teammateId, + }) ? response.timeline?.runs ?? [] : []); }); workspaceRequestOwner.clearError(request); } catch (error) { - workspaceRequestOwner.reportError(request, error instanceof Error ? error.message : String(error)); + if (!observationReads.shouldMergeTransport(error)) { + workspaceRequestOwner.reportError(request, error instanceof Error ? error.message : String(error)); + } } } async function refreshDirectRuntime(): Promise<"committed" | "stale"> { + const observationRead = observationReads.begin(); + if (!observationRead) return "stale"; const request = workspaceRequestOwner.beginWorkspaceAction("runtime"); if (!workspaceRequestOwner.isActionCurrent(request)) return "stale"; const workspaceCwd = cwd.trim(); @@ -977,6 +1029,7 @@ export function TeammateWorkspace({ listTeammateDirectLinkPreviews(workspaceCwd, request.teammateId), listTeammatePrivateAttachments(workspaceCwd, request.teammateId), ]); + if (!observationReads.isCurrent(observationRead)) return "stale"; const committed = workspaceRequestOwner.commit(request, "conversation", () => { setMessages((current) => current.length > directResponse.messages.length ? upsertDirectMessages(current, directResponse.messages) @@ -987,6 +1040,9 @@ export function TeammateWorkspace({ setHasMoreDirectMessages(Boolean(directResponse.hasMore)); setNextDirectBeforeMessageId(directResponse.nextBeforeMessageId); setDirectRuntime(runtimeResponse); + setDirectTimelineRuns(directResponse.cwd === workspaceCwd && privateTimelineResponseIsOwned(directResponse, { + teammateId: request.teammateId, directThreadId: runtimeResponse.thread.id, + }) ? directResponse.timeline?.runs ?? [] : []); setDirectWorkspace(directWorkspaceResponse); setDirectLinkPreviews(linkPreviewsResponse.linkPreviews); setPrivateAttachments(privateAttachmentsResponse.attachments); @@ -994,21 +1050,26 @@ export function TeammateWorkspace({ if (workspaceRequestOwner.isActionCurrent(request)) workspaceRequestOwner.clearError(request); return committed ? "committed" : "stale"; } catch (error) { + if (observationReads.shouldMergeTransport(error)) return "stale"; if (workspaceRequestOwner.isActionCurrent(request)) throw error; return "stale"; } } async function refreshTeammateActivity(): Promise { + const observationRead = observationReads.begin(); + if (!observationRead) return; const request = workspaceRequestOwner.beginWorkspaceAction("activity"); if (!workspaceRequestOwner.isActionCurrent(request)) return; const workspaceCwd = cwd.trim(); if (!workspaceCwd) return; try { const response = await listTeammateActivity(workspaceCwd, request.teammateId); + if (!observationReads.isCurrent(observationRead)) return; workspaceRequestOwner.commit(request, "activity", () => setActivityItems(response.items)); workspaceRequestOwner.clearError(request); } catch (error) { + if (observationReads.shouldMergeTransport(error)) return; if (workspaceRequestOwner.isActionCurrent(request)) { workspaceRequestOwner.reportError(request, error instanceof Error ? error.message : String(error)); throw error; @@ -1017,6 +1078,8 @@ export function TeammateWorkspace({ } async function loadOlderDirectMessages(): Promise { + const observationRead = observationReads.begin(); + if (!observationRead) return; const workspaceCwd = cwd.trim(); if ( !workspaceCwd || @@ -1035,6 +1098,7 @@ export function TeammateWorkspace({ const response = await listTeammateDirectMessages(workspaceCwd, request.teammateId, { beforeMessageId, }); + if (!observationReads.isCurrent(observationRead)) return; workspaceRequestOwner.commit(request, "conversation", () => { setMessages((current) => upsertDirectMessages(current, response.messages)); setGeneratedMedia((current) => mergeConversationGeneratedMedia(current, response.generatedMedia)); @@ -1043,7 +1107,9 @@ export function TeammateWorkspace({ }); workspaceRequestOwner.clearError(request); } catch (error) { - workspaceRequestOwner.reportError(request, error instanceof Error ? error.message : String(error)); + if (!observationReads.shouldMergeTransport(error)) { + workspaceRequestOwner.reportError(request, error instanceof Error ? error.message : String(error)); + } } finally { if (workspaceRequestOwner.isActionCurrent(request)) setLoadingOlderDirectMessages(false); } @@ -1058,14 +1124,17 @@ export function TeammateWorkspace({ if (transientReply?.workUnitId) { return { status: "current" as const, activeReply: { workUnitId: transientReply.workUnitId } }; } + const observationRead = observationReads.begin(); + if (!observationRead) return { status: "stale" as const }; try { const runtimeResponse = await getTeammateDirectRuntime(cwd.trim(), operation.teammateId); - if (!workspaceRequestOwner.isActionCurrent(operation)) return { status: "stale" as const }; + if (!workspaceRequestOwner.isActionCurrent(operation) || !observationReads.isCurrent(observationRead)) return { status: "stale" as const }; workspaceRequestOwner.claimAndCommit(operation, ["conversation"], () => { setDirectRuntime(runtimeResponse); }); return { status: "current" as const, activeReply: runtimeResponse.activeReply }; } catch (error) { + if (observationReads.shouldMergeTransport(error)) return { status: "stale" as const }; if (workspaceRequestOwner.isActionCurrent(operation)) throw error; return { status: "stale" as const }; } @@ -1075,18 +1144,20 @@ export function TeammateWorkspace({ body: string, operation: TeammateWorkspaceRequestToken, ): Promise { - if (!workspaceRequestOwner.isActionCurrent(operation)) return undefined; + const observationRead = observationReads.begin(); + if (!observationRead || !workspaceRequestOwner.isActionCurrent(operation)) return undefined; const existing = [...pendingDirectLinkPreviews]; const created: TeammateDirectLinkPreview[] = []; for (const url of extractUrls(body)) { + if (!observationReads.isCurrent(observationRead) || !workspaceRequestOwner.isActionCurrent(operation)) return undefined; if (existing.some((preview) => preview.url === url || preview.normalizedUrl === url) || created.some((preview) => preview.url === url || preview.normalizedUrl === url)) { continue; } const response = await createTeammateDirectLinkPreview(cwd.trim(), operation.teammateId, { url }); - if (!workspaceRequestOwner.isActionCurrent(operation)) return undefined; + if (!observationReads.isCurrent(observationRead) || !workspaceRequestOwner.isActionCurrent(operation)) return undefined; created.push(response.linkPreview); workspaceRequestOwner.claimAndCommit(operation, ["conversation"], () => { - setDirectLinkPreviews((current) => [ + setDirectLinkPreviews((current) => !observationReads.isCurrent(observationRead) || !workspaceRequestOwner.isActionCurrent(operation) ? current : [ response.linkPreview, ...current.filter((preview) => preview.id !== response.linkPreview.id), ]); @@ -1100,6 +1171,8 @@ export function TeammateWorkspace({ readonly permissionPreset: PermissionPreset; readonly permissionConfirmation?: PermissionConfirmation; }): Promise { + const sendObservation = observationReads.begin(); + if (!sendObservation) return; if (hasPendingPrivateGuidance(cwd.trim(), teammate.id)) { await submitDirectGuidance("queue_followup"); return; @@ -1116,26 +1189,35 @@ export function TeammateWorkspace({ permissionConfirmation = await onConfirmFullAccess?.(); if (!permissionConfirmation) return; } + if (!observationReads.isCurrent(sendObservation)) return; const operation = workspaceRequestOwner.beginWorkspaceAction("send"); if (!workspaceRequestOwner.isActionCurrent(operation)) return; const submissionOwner = { cwd: cwd.trim(), surface: "private" as const, ownerId: operation.teammateId }; const unconfirmedKey = submissionLookupJournal.read(submissionOwner); if (unconfirmedKey) { - const receipt = await recoverSubmissionReceipt(async () => validatePrivateSubmissionReceipt( - await getTeammateDirectMessageSubmission(submissionOwner.cwd, operation.teammateId, unconfirmedKey), - submissionOwner.cwd, operation.teammateId, unconfirmedKey)); + const receipt = observationReads.isCurrent(sendObservation) ? await recoverSubmissionReceipt(async () => { + if (!observationReads.isCurrent(sendObservation) || !workspaceRequestOwner.isActionCurrent(operation)) { + throw new UnconfirmedConversationSubmissionError(unconfirmedKey, true); + } + return validatePrivateSubmissionReceipt( + await getTeammateDirectMessageSubmission(submissionOwner.cwd, operation.teammateId, unconfirmedKey), + submissionOwner.cwd, operation.teammateId, unconfirmedKey); + }) : undefined; if (!workspaceRequestOwner.isActionCurrent(operation)) return; if (receipt?.message && receipt.idempotencyKey === unconfirmedKey && receipt.teammateId === operation.teammateId) { submissionLookupJournal.forget(submissionOwner, unconfirmedKey); - setMessages(current => upsertDirectMessages(current, [receipt.message!, ...(receipt.aiReply ? [receipt.aiReply] : [])])); + setMessages(current => observationReads.isCurrent(sendObservation) && workspaceRequestOwner.isActionCurrent(operation) + ? upsertDirectMessages(current, [receipt.message!, ...(receipt.aiReply ? [receipt.aiReply] : [])]) : current); if (privateSubmissionDraftMatches(privateSubmissionDraftRef.current!, receipt.message)) { setMessageDraft(""); setPendingAttachments([]); setPendingDirectLinkPreviewIds([]); privateAttachmentUploads.releasePreviews(pendingAttachments); } - await refreshDirectRuntime(); - workspaceRequestOwner.clearError(operation); + if (observationReads.isCurrent(sendObservation)) { + await refreshDirectRuntime(); + if (observationReads.isCurrent(sendObservation)) workspaceRequestOwner.clearError(operation); + } } else { workspaceRequestOwner.reportError(operation, t("Pending confirmation")); } @@ -1150,9 +1232,21 @@ export function TeammateWorkspace({ let accepted = false; setSending(true); let streamFailed = false; + // A stream's acceptance/terminal receipts outlive observation pauses. Its + // view snapshots do not: resumed reads own a new observation epoch. + const publishStreamMessages = (update: (current: readonly TeammateDirectMessage[]) => readonly TeammateDirectMessage[]): void => { + if (!observationReads.isCurrent(sendObservation) || !workspaceRequestOwner.isActionCurrent(operation)) return; + workspaceRequestOwner.claimAndCommit(operation, ["conversation"], () => { + setMessages(current => observationReads.isCurrent(sendObservation) && workspaceRequestOwner.isActionCurrent(operation) + ? update(current) : current); + }); + }; try { const linkPreviews = await ensureDirectLinkPreviewsForBody(body, operation); - if (!linkPreviews || !workspaceRequestOwner.isActionCurrent(operation)) return; + if (!linkPreviews || !workspaceRequestOwner.isActionCurrent(operation) || !observationReads.isCurrent(sendObservation)) { + directSendLifecycle.settle(sendOperation); + return; + } setMessageDraft(""); setPendingAttachments([]); setPendingDirectLinkPreviewIds([]); @@ -1177,10 +1271,8 @@ export function TeammateWorkspace({ submissionLookupJournal.forget(submissionOwner, idempotencyKey); if (!workspaceRequestOwner.isActionCurrent(operation)) return; const data = JSON.parse(event.data) as TeammateDirectMessageStreamAcceptedEvent; - workspaceRequestOwner.claimAndCommit(operation, ["conversation"], () => { - setMessages(current => upsertDirectMessages(current, [data.message])); - directSendLifecycle.accept(sendOperation); - }); + directSendLifecycle.accept(sendOperation); + publishStreamMessages(current => upsertDirectMessages(current, [data.message])); return; } if (event.event === "ready" || event.event === "done" || event.event === "blocked") { @@ -1190,56 +1282,50 @@ export function TeammateWorkspace({ if (!workspaceRequestOwner.isActionCurrent(operation)) return; if (event.event === "ready") { const data = JSON.parse(event.data) as TeammateDirectMessageStreamReadyEvent; - workspaceRequestOwner.claimAndCommit(operation, ["conversation"], () => { - setMessages((current) => upsertDirectMessages(current, [data.message, data.aiReply])); - directSendLifecycle.accept(sendOperation); - }); + directSendLifecycle.accept(sendOperation); + publishStreamMessages(current => upsertDirectMessages(current, [data.message, data.aiReply])); return; } if (event.event === "delta") { const data = JSON.parse(event.data) as TeammateDirectMessageStreamDeltaEvent; - workspaceRequestOwner.claimAndCommit(operation, ["conversation"], () => { - setMessages((current) => current.map((message) => message.id === data.messageId ? { - ...message, - body: data.body, - narrationSegments: data.narrationSegments ?? message.narrationSegments, - status: "pending", - } : message)); - }); + publishStreamMessages(current => current.map((message) => message.id === data.messageId ? { + ...message, + body: data.body, + narrationSegments: data.narrationSegments ?? message.narrationSegments, + status: "pending", + } : message)); return; } if (event.event === "run_linked") { const data = JSON.parse(event.data) as TeammateDirectMessageStreamRunLinkedEvent; - workspaceRequestOwner.claimAndCommit(operation, ["conversation"], () => { - setMessages((current) => current.map((message) => message.id === data.messageId ? { - ...message, - linkedRunId: data.linkedRunId ?? message.linkedRunId, - } : message)); - if (data.activeReply) { - directSendLifecycle.accept(sendOperation); - directSendLifecycle.link(sendOperation, data.activeReply); - setSending(false); + publishStreamMessages(current => current.map((message) => message.id === data.messageId ? { + ...message, + linkedRunId: data.linkedRunId ?? message.linkedRunId, + } : message)); + if (data.activeReply) { + directSendLifecycle.accept(sendOperation); + directSendLifecycle.link(sendOperation, data.activeReply); + setSending(false); + } + if (observationReads.isCurrent(sendObservation)) void refreshDirectRuntime().catch((error) => { + if (!observationReads.shouldMergeTransport(error)) { + workspaceRequestOwner.reportError(operation, error instanceof Error ? error.message : String(error)); } }); - void refreshDirectRuntime(); return; } if (event.event === "done") { const data = JSON.parse(event.data) as TeammateDirectMessageStreamDoneEvent; - workspaceRequestOwner.claimAndCommit(operation, ["conversation"], () => { - setMessages((current) => upsertDirectMessages(current, [data.message, data.aiReply])); - directSendLifecycle.settle(sendOperation); - setSending(false); - }); + directSendLifecycle.settle(sendOperation); + setSending(false); + publishStreamMessages(current => upsertDirectMessages(current, [data.message, data.aiReply])); return; } if (event.event === "blocked") { const data = JSON.parse(event.data) as TeammateDirectMessageStreamBlockedEvent; - workspaceRequestOwner.claimAndCommit(operation, ["conversation"], () => { - setMessages((current) => upsertDirectMessages(current, [data.message, data.aiReply])); - directSendLifecycle.settle(sendOperation); - setSending(false); - }); + directSendLifecycle.settle(sendOperation); + setSending(false); + publishStreamMessages(current => upsertDirectMessages(current, [data.message, data.aiReply])); return; } if (event.event === "error") { @@ -1247,12 +1333,8 @@ export function TeammateWorkspace({ directSendLifecycle.fail(sendOperation); const data = JSON.parse(event.data) as TeammateDirectMessageStreamErrorEvent; const nextMessages = [data.message, data.aiReply].filter((message): message is TeammateDirectMessage => Boolean(message)); - workspaceRequestOwner.claimAndCommit(operation, ["conversation"], () => { - if (nextMessages.length > 0) { - setMessages((current) => upsertDirectMessages(current, nextMessages)); - } - setSending(false); - }); + if (nextMessages.length > 0) publishStreamMessages(current => upsertDirectMessages(current, nextMessages)); + setSending(false); // A failed AI reply owns its provider error. The feed renders the // error beside that persisted message, so repeating it in the // workspace-level banner creates two competing copies of the same @@ -1265,31 +1347,36 @@ export function TeammateWorkspace({ } } }); - if (!accepted) throw new Error("Pending confirmation"); + if (streamFailed) return; + if (!accepted) throw new UnconfirmedConversationSubmissionError(idempotencyKey); if (!workspaceRequestOwner.isActionCurrent(operation)) return; + privateAttachmentUploads.releasePreviews(attachmentsSnapshot); + if (!observationReads.isCurrent(sendObservation)) return; await Promise.all([ refreshTeammateActivity(), memoryController.refresh("background"), ]); - if (!workspaceRequestOwner.isActionCurrent(operation)) return; - privateAttachmentUploads.releasePreviews(attachmentsSnapshot); + if (!workspaceRequestOwner.isActionCurrent(operation) || !observationReads.isCurrent(sendObservation)) return; await refreshDirectRuntime(); - if (workspaceRequestOwner.isActionCurrent(operation) && !streamFailed) { + if (workspaceRequestOwner.isActionCurrent(operation) && observationReads.isCurrent(sendObservation) && !streamFailed) { workspaceRequestOwner.clearError(operation); } } catch (error) { let rejectionConfirmed = false; - if (submitted && !accepted) { - const receipt = await recoverSubmissionReceipt(async () => validatePrivateSubmissionReceipt( - await getTeammateDirectMessageSubmission(submissionOwner.cwd, operation.teammateId, idempotencyKey), - submissionOwner.cwd, operation.teammateId, idempotencyKey)); - rejectionConfirmed = submissionWasRejected(error, receipt); + if (submitted && !accepted && observationReads.isCurrent(sendObservation)) { + const receipt = await recoverSubmissionReceipt(async () => { + if (!observationReads.isCurrent(sendObservation) || !workspaceRequestOwner.isActionCurrent(operation)) { + throw new UnconfirmedConversationSubmissionError(idempotencyKey, true); + } + return validatePrivateSubmissionReceipt( + await getTeammateDirectMessageSubmission(submissionOwner.cwd, operation.teammateId, idempotencyKey), + submissionOwner.cwd, operation.teammateId, idempotencyKey); + }); + rejectionConfirmed = observationReads.isCurrent(sendObservation) && submissionWasRejected(error, receipt); accepted = Boolean(receipt?.message); if (receipt?.message && workspaceRequestOwner.isActionCurrent(operation)) { - workspaceRequestOwner.claimAndCommit(operation, ["conversation"], () => { - directSendLifecycle.accept(sendOperation); - setMessages(current => upsertDirectMessages(current, [receipt.message!, ...(receipt.aiReply ? [receipt.aiReply] : [])])); - }); + directSendLifecycle.accept(sendOperation); + publishStreamMessages(current => upsertDirectMessages(current, [receipt.message!, ...(receipt.aiReply ? [receipt.aiReply] : [])])); } } if (rejectionConfirmed) submissionLookupJournal.forget(submissionOwner, idempotencyKey); @@ -1297,15 +1384,19 @@ export function TeammateWorkspace({ if (accepted) { submissionLookupJournal.forget(submissionOwner, idempotencyKey); directSendLifecycle.accept(sendOperation); - await refreshDirectRuntime(); + if (observationReads.shouldMergeTransport(error)) return; + if (observationReads.isCurrent(sendObservation)) await refreshDirectRuntime(); if (!workspaceRequestOwner.isActionCurrent(operation)) return; + workspaceRequestOwner.reportError(operation, error instanceof Error ? error.message : String(error)); + return; } if (submissionLookupJournal.read(submissionOwner)) { directSendLifecycle.defer(sendOperation); setMessageDraft(current => current || draftSnapshot); setPendingAttachments(current => current.length ? current : attachmentsSnapshot.map(item => ({ ...item, deleteOnRemove: false }))); setPendingDirectLinkPreviewIds(current => current.length ? current : linkPreviewIdsSnapshot); - workspaceRequestOwner.reportError(operation, t("Pending confirmation")); + workspaceRequestOwner.reportError(operation, observationReads.shouldMergeTransport(error) || error instanceof UnconfirmedConversationSubmissionError + ? t("Pending confirmation") : error instanceof Error ? error.message : String(error)); return; } const failure = directSendLifecycle.fail(sendOperation); @@ -1325,6 +1416,8 @@ export function TeammateWorkspace({ } async function submitDirectGuidance(mode: "queue_followup" | "interrupt_redirect"): Promise { + const guidanceObservation = observationReads.begin(); + if (!guidanceObservation) return false; const payload = privateComposerPayload; const recoveryPending = hasPendingPrivateGuidance(cwd.trim(), teammate.id); if ((!recoveryPending && !payload.body && payload.attachmentIds.length === 0 && payload.linkPreviewIds.length === 0 && payload.fileRefs.length === 0) || directGuidanceSubmitting || privateAttachmentOperationPending) { @@ -1337,14 +1430,14 @@ export function TeammateWorkspace({ let accepted = false; try { const resolution = recoveryPending ? undefined : await resolveActiveDirectReply(operation); - if (resolution?.status === "stale") return false; + if (resolution?.status === "stale" || !observationReads.isCurrent(guidanceObservation)) return false; const activeReply = resolution?.activeReply; if (!recoveryPending && !activeReply) { workspaceRequestOwner.reportError(operation, t("There is no active private reply right now.")); return false; } const linkPreviews = recoveryPending ? pendingDirectLinkPreviews : await ensureDirectLinkPreviewsForBody(payload.body, operation); - if (!linkPreviews || !workspaceRequestOwner.isActionCurrent(operation)) return false; + if (!linkPreviews || !workspaceRequestOwner.isActionCurrent(operation) || !observationReads.isCurrent(guidanceObservation)) return false; const completePayload = { ...payload, linkPreviewIds: linkPreviews.map((preview) => preview.id), @@ -1360,7 +1453,8 @@ export function TeammateWorkspace({ accepted = true; if (!workspaceRequestOwner.isActionCurrent(operation)) return false; workspaceRequestOwner.claimAndCommit(operation, ["conversation"], () => { - if (response.runtime) setDirectRuntime(response.runtime); + if (response.runtime) setDirectRuntime(current => observationReads.isCurrent(guidanceObservation) && workspaceRequestOwner.isActionCurrent(operation) + ? response.runtime : current); const acceptedPayload = response.intervention.messagePayload ?? { body: response.intervention.instruction, attachmentIds: [], linkPreviewIds: [], fileRefs: [] }; if (privateSubmissionDraftMatches(privateSubmissionDraftRef.current!, { ...acceptedPayload, fileRefs: acceptedPayload.fileRefs ?? [] })) setMessageDraft(""); @@ -1370,8 +1464,9 @@ export function TeammateWorkspace({ privateAttachmentUploads.releasePreviews(liveItems.filter(item => item.attachment && boundIds.has(item.attachment.id))); setPendingDirectLinkPreviewIds(current => current.filter(id => !acceptedPayload.linkPreviewIds.includes(id))); }); + if (!observationReads.isCurrent(guidanceObservation)) return true; await refreshDirectRuntime(); - workspaceRequestOwner.clearError(operation); + if (observationReads.isCurrent(guidanceObservation)) workspaceRequestOwner.clearError(operation); return true; } catch (error) { if (!workspaceRequestOwner.isActionCurrent(operation)) return false; @@ -1434,10 +1529,13 @@ export function TeammateWorkspace({ } await refreshDirectRuntime(); if (!workspaceRequestOwner.isActionCurrent(operation)) return; + const observationRead = observationReads.begin(); + if (!observationRead) return; const request = workspaceRequestOwner.beginWorkspaceAction("messages"); if (workspaceRequestOwner.isActionCurrent(request)) { try { const directMessagesResponse = await listTeammateDirectMessages(cwd.trim(), request.teammateId); + if (!observationReads.isCurrent(observationRead)) return; workspaceRequestOwner.commit(request, "conversation", () => { setMessages(directMessagesResponse.messages); setRuntimeMetrics(directMessagesResponse.runtimeMetrics); @@ -1446,6 +1544,7 @@ export function TeammateWorkspace({ }); workspaceRequestOwner.clearError(request); } catch (error) { + if (observationReads.shouldMergeTransport(error)) return; if (workspaceRequestOwner.isActionCurrent(request)) throw error; } } @@ -1814,6 +1913,9 @@ export function TeammateWorkspace({ attachmentCleanupFailures={privateAttachmentUploads.cleanupFailures} onRetryAttachmentCleanup={privateAttachmentUploads.retryCleanup} authorizationMode={selectedAuthorizationMode} + permissionSelectionOwnerKey={permissionSelectionOwnerKey ?? workspaceMessageIdentity} + defaultWorkspaceAccessMode={resolveWorkspaceAccessMode(defaultWorkspaceAccessMode, defaultPermissionMode, defaultPermissionPreset)} + onPermissionError={(error) => workspaceRequestOwner.reportImmediate("runtime", error instanceof Error ? error.message : String(error))} onAuthorizationModeChange={setSelectedAuthorizationMode} workspaceAccessMode={selectedWorkspaceAccessMode} onWorkspaceAccessModeChange={setSelectedWorkspaceAccessMode} diff --git a/apps/web/src/features/teammates/teammate-private-chat-types.ts b/apps/web/src/features/teammates/teammate-private-chat-types.ts index 97a984c93..56acc352a 100644 --- a/apps/web/src/features/teammates/teammate-private-chat-types.ts +++ b/apps/web/src/features/teammates/teammate-private-chat-types.ts @@ -141,6 +141,9 @@ export interface TeammatePrivateChatSurfaceProps { readonly attachmentCleanupFailures?: readonly import("../../shared/components/composer-pending-assets.js").AttachmentCleanupFailure[]; readonly onRetryAttachmentCleanup?: (localId: string) => void | Promise; readonly authorizationMode: AuthorizationMode; + readonly permissionSelectionOwnerKey: string; + readonly defaultWorkspaceAccessMode: WorkspaceAccessMode; + readonly onPermissionError: (error: unknown) => void; readonly onAuthorizationModeChange: (mode: AuthorizationMode) => void; readonly workspaceAccessMode: WorkspaceAccessMode; readonly onWorkspaceAccessModeChange: (mode: WorkspaceAccessMode) => void; diff --git a/apps/web/src/features/teammates/teammate-private-timeline-items.ts b/apps/web/src/features/teammates/teammate-private-timeline-items.ts index df713f258..57aaf43a1 100644 --- a/apps/web/src/features/teammates/teammate-private-timeline-items.ts +++ b/apps/web/src/features/teammates/teammate-private-timeline-items.ts @@ -4,6 +4,7 @@ import { interleaveConversationUserInputRows, projectConversationRunNarration, type CanonicalTranscriptProjection, + type ConversationTimelineDiagnosticItem, type ConversationTimelineItem, type ConversationTimelineMessageItem, type ConversationTimelineUserInputItem, @@ -131,9 +132,13 @@ export function projectTeammatePrivateTimelineItems(input: TeammatePrivateTimeli const messages = input.messages.flatMap((entry) => { const message = ownedMessages.get(entry.message.id); if (!message || !privateMessageIsOwned(entry.message, input)) return []; - const unverifiedDiagnostics = !message.linkedRunId && entry.message.linkedRunId - ? sanitizePrivateUnverifiedDiagnostics(entry.facts, message.id) - : []; + const unverifiedDiagnostics = sanitizePrivateUnverifiedDiagnostics(entry.facts, message.id); + if (!message.linkedRunId && entry.message.linkedRunId && input.canonicalState === "loaded" && !unverifiedDiagnostics.length) { + unverifiedDiagnostics.push({ id: `diagnostic:private-unverified:${message.id}:missing`, kind: "diagnostic", + revision: message.revision ?? 1, threadSequence: 0, partIndex: 0, status: "blocked", sourceRefs: [], + title: "Activity unavailable", summary: "Execution evidence is missing required lineage.", + description: "Execution evidence is missing required lineage." }); + } return [{ ...entry, message, facts: message.linkedRunId ? entry.facts : [], unverifiedDiagnostics }]; }); const turnIdByRunId = new Map(input.runLinks?.map((link) => [link.runId, link.turnId]) ?? []); @@ -265,15 +270,19 @@ export function projectTeammatePrivateTimelineItems(input: TeammatePrivateTimeli } else if (!existing) { items.push({ ...messageItem, ...(message.linkedRunId ? { runId: message.linkedRunId } : {}) }); } - if (!input.canonical && input.canonicalState !== "loading" && input.canonicalState !== undefined && + // A loaded canonical snapshot may omit incomplete legacy action evidence. + // Preserve only sanitized notices, without granting a legacy link authority. + if (input.canonicalState === "loaded" && unverifiedDiagnostics.length > 0) { + items.push(...unverifiedDiagnostics.filter(diagnostic => !canonical.some(item => + item.kind === "diagnostic" && Boolean(message.linkedRunId && item.runId === message.linkedRunId) && + item.summary === diagnostic.summary, + )).map((diagnostic, index) => ({ + ...diagnostic, + threadSequence, + partIndex: messageItem.partIndex + index + 1, + }))); + } else if (!input.canonical && input.canonicalState !== "loading" && input.canonicalState !== undefined && (facts.length > 0 || unverifiedDiagnostics.length > 0)) { - if (input.canonicalState === "loaded" && unverifiedDiagnostics.length > 0) { - items.push(...unverifiedDiagnostics.map((diagnostic, index) => ({ - ...diagnostic, - threadSequence, - partIndex: messageItem.partIndex + index + 1, - }))); - } else { items.push({ id: `diagnostic:private-canonical:${message.id}`, kind: "diagnostic", @@ -287,7 +296,6 @@ export function projectTeammatePrivateTimelineItems(input: TeammatePrivateTimeli description: input.canonicalState === "error" ? "Execution evidence could not be loaded." : "Execution evidence is missing required lineage.", summary: input.canonicalState === "error" ? "Execution evidence could not be loaded." : "Execution evidence is missing required lineage.", }); - } } }); if (scopeBlocked) items.push({ @@ -319,7 +327,7 @@ const safePrivateUnverifiedDiagnosticSummaries = new Set([ function sanitizePrivateUnverifiedDiagnostics( facts: readonly ConversationTimelineItem[], messageId: string, -): readonly ConversationTimelineItem[] { +): ConversationTimelineDiagnosticItem[] { const summaries = new Set(); return facts.flatMap((item, index) => { if (item.kind !== "diagnostic" || item.status !== "blocked") return []; diff --git a/apps/web/src/features/teammates/teammate-workspace-utils.ts b/apps/web/src/features/teammates/teammate-workspace-utils.ts index 97816bd14..f2082fbef 100644 --- a/apps/web/src/features/teammates/teammate-workspace-utils.ts +++ b/apps/web/src/features/teammates/teammate-workspace-utils.ts @@ -107,7 +107,7 @@ export function uniqueDirectLinkPreviews(previews: readonly TeammateDirectLinkPr export function directComposerFileRefs(parts: ReturnType): readonly ChannelMessageFileRef[] { return parts.map((part) => ({ path: part.path, - source: "composer" as const, + source: "inferred_text" as const, ...(part.line ? { line: part.line } : {}), })); } diff --git a/apps/web/src/features/teammates/use-teammate-private-timeline.ts b/apps/web/src/features/teammates/use-teammate-private-timeline.ts index 4a79de8df..04d3bb623 100644 --- a/apps/web/src/features/teammates/use-teammate-private-timeline.ts +++ b/apps/web/src/features/teammates/use-teammate-private-timeline.ts @@ -1,10 +1,49 @@ import { useEffect, useRef, useState } from "react"; +import { LingDaemonTransportError } from "@ling-stack/api-client"; import type { CanonicalTranscriptProjection } from "@ling-stack/conversation-ui-core"; import { listTeammateDirectMessages } from "../../api.js"; import { projectConversationDetailTimeline } from "../../domains/conversations/canonical-transcript-snapshot.js"; import { mergeConversationDetailSnapshots } from "../../domains/conversations/conversation-detail-merge.js"; import type { ConversationDetailResponse, LingTurn, LingTurnRun, RunSession } from "../../types.js"; import { privateMessageIdsByCanonicalItemId, privateTimelineResponseIsOwned } from "./teammate-private-timeline-items.js"; +import { isDaemonObservationPausedForWorkspace, useDaemonObservation } from "../../shared/conversation/observation/daemon-observation.js"; + +interface PrivateObservationRead { + readonly cwd: string; + readonly epoch: number; + readonly baseUrl: string; + readonly workspaceCwd: string; +} + +/** Only gates observation reads, never accepted commands or their receipts. */ +export function useTeammateObservationReads(cwd: string) { + const observation = useDaemonObservation(); + const latest = useRef({ cwd: cwd.trim(), observation }); + latest.current = { cwd: cwd.trim(), observation }; + return { + paused: isDaemonObservationPausedForWorkspace(observation, cwd), + epoch: observation.epoch, + baseUrl: observation.baseUrl, + workspaceCwd: observation.workspaceCwd, + begin(): PrivateObservationRead | undefined { + const current = latest.current; + return isDaemonObservationPausedForWorkspace(current.observation, current.cwd) ? undefined + : { cwd: current.cwd, epoch: current.observation.epoch, baseUrl: current.observation.baseUrl, + workspaceCwd: current.observation.workspaceCwd }; + }, + isCurrent(read: PrivateObservationRead): boolean { + const current = latest.current; + return read.cwd === current.cwd && read.epoch === current.observation.epoch + && read.baseUrl === current.observation.baseUrl && read.workspaceCwd === current.observation.workspaceCwd + && !isDaemonObservationPausedForWorkspace(current.observation, current.cwd); + }, + shouldMergeTransport(error: unknown): boolean { + const current = latest.current; + return error instanceof LingDaemonTransportError + && isDaemonObservationPausedForWorkspace(current.observation, current.cwd); + }, + }; +} interface PrivateTimelineState { readonly owner: string; @@ -32,6 +71,7 @@ export function useTeammatePrivateTimeline(input: { readonly active: boolean; }): PrivateTimelineState { const cwd = input.cwd.trim(); + const observationReads = useTeammateObservationReads(cwd); const owner = JSON.stringify([cwd, input.teammateId, input.directThreadId ?? ""]); const ownerRef = useRef(owner); ownerRef.current = owner; @@ -51,7 +91,7 @@ export function useTeammatePrivateTimeline(input: { let lastStartedAt = 0; let timeout: number | undefined; const schedule = (urgent = false): void => { - if (cancelled || ownerRef.current !== owner || !cwd) return; + if (cancelled || ownerRef.current !== owner || !cwd || !observationReads.begin()) return; pending = true; urgentPending ||= urgent; if (loading) return; @@ -63,14 +103,15 @@ export function useTeammatePrivateTimeline(input: { timeout = window.setTimeout(() => { timeout = undefined; void refresh(); }, delay); }; const refresh = async (): Promise => { - if (loading || cancelled || ownerRef.current !== owner || !cwd) return; + const observationRead = observationReads.begin(); + if (loading || cancelled || ownerRef.current !== owner || !cwd || !observationRead) return; loading = true; pending = false; urgentPending = false; lastStartedAt = Date.now(); try { const response = await listTeammateDirectMessages(cwd, input.teammateId); - if (cancelled || ownerRef.current !== owner) return; + if (cancelled || ownerRef.current !== owner || !observationReads.isCurrent(observationRead)) return; if (!privateTimelineResponseIsOwned(response, input)) { currentDetail = undefined; setSnapshot({ owner, status: "error" }); @@ -93,8 +134,8 @@ export function useTeammatePrivateTimeline(input: { threadId: detail?.thread.id, workspaceId: detail?.thread.workspaceId, }); - } catch { - if (!cancelled && ownerRef.current === owner) setSnapshot((current) => ({ + } catch (error) { + if (!cancelled && ownerRef.current === owner && !observationReads.shouldMergeTransport(error)) setSnapshot((current) => ({ ...(current.owner === owner ? current : { owner }), status: "error", })); @@ -115,6 +156,9 @@ export function useTeammatePrivateTimeline(input: { if (timeout !== undefined) window.clearTimeout(timeout); }; }, [cwd, input.teammateId, input.directThreadId, owner]); + useEffect(() => { + if (!observationReads.paused) refreshRef.current?.(true); + }, [observationReads.paused, observationReads.epoch, observationReads.baseUrl, observationReads.workspaceCwd, owner]); useEffect(() => { // Coalesce action/message revisions, but flush terminal state promptly. // This does not restart an in-flight request for every streaming token. diff --git a/apps/web/src/features/teammates/useTeammatePrivateComposerController.ts b/apps/web/src/features/teammates/useTeammatePrivateComposerController.ts index 1af6c3174..99c9350eb 100644 --- a/apps/web/src/features/teammates/useTeammatePrivateComposerController.ts +++ b/apps/web/src/features/teammates/useTeammatePrivateComposerController.ts @@ -7,6 +7,7 @@ import { type TokenizedComposerInputHandle, } from "../../shared/conversation/index.js"; import type { AuthorizationMode, PermissionConfirmation, PermissionPreset, WorkspaceAccessMode } from "../../types.js"; +import { useConversationPermissionSelection } from "../../shared/conversation/conversation-permission-selection.js"; import { permissionPresetForWorkspaceAccessMode, workspaceAccessModeForPermissionPreset, @@ -32,6 +33,9 @@ export interface TeammatePrivateComposerController { export function useTeammatePrivateComposerController({ identityKey, + defaultWorkspaceAccessMode, + permissionLocked, + onPermissionError, authorizationMode, messageDraft, onComposerKeyDown, @@ -45,6 +49,9 @@ export function useTeammatePrivateComposerController({ workspaceAccessMode, }: { readonly identityKey: string; + readonly defaultWorkspaceAccessMode: WorkspaceAccessMode; + readonly permissionLocked: boolean; + readonly onPermissionError: (error: unknown) => void; readonly authorizationMode: AuthorizationMode; readonly messageDraft: string; readonly onComposerKeyDown: ( @@ -74,6 +81,21 @@ export function useTeammatePrivateComposerController({ const [slashActiveIndex, setSlashActiveIndex] = useState(0); const composerInputRef = useRef(null); const slashCommandPickerOptions = filterSlashCommandPickerOptions(slashCommands, slashPicker); + const handleAuthorizationModeChange = useConversationPermissionSelection({ + ownerKey: identityKey, + authorizationMode: selectedAuthorizationMode, + workspaceAccessMode: workspaceAccessModeForPermissionPreset(selectedPermissionPreset), + defaultWorkspaceAccessMode, + disabled: permissionLocked, + confirmFullAccess: onFullAccessPresetSelected, + onChange: (selection) => { + setSelectedAuthorizationMode(selection.authorizationMode); + setSelectedPermissionPreset(permissionPresetForWorkspaceAccessMode(selection.workspaceAccessMode)); + onAuthorizationModeChange(selection.authorizationMode); + onWorkspaceAccessModeChange(selection.workspaceAccessMode); + }, + onError: onPermissionError, + }); useEffect(() => { setSelectedAuthorizationMode(authorizationMode); @@ -136,6 +158,21 @@ export function useTeammatePrivateComposerController({ setSlashActiveIndex(0); } + function handlePermissionPresetChange(preset: PermissionPreset): void { + if (preset === "full_access" && onFullAccessPresetSelected) { + void onFullAccessPresetSelected().then((confirmation) => { + if (!confirmation) return; + setSelectedPermissionPreset(preset); + setSelectedAuthorizationMode("always"); + onWorkspaceAccessModeChange(workspaceAccessModeForPermissionPreset(preset)); + }); + return; + } + setSelectedPermissionPreset(preset); + setSelectedAuthorizationMode(preset === "read_only" ? "ask" : "smart"); + onWorkspaceAccessModeChange(workspaceAccessModeForPermissionPreset(preset)); + } + function commitSlashCommand(option: SlashCommand): void { const liveCaret = Math.min( composerInputRef.current?.readSelectionStart() ?? messageDraft.length, @@ -151,19 +188,6 @@ export function useTeammatePrivateComposerController({ focusComposerAt(inserted.caret); } - function handlePermissionPresetChange(preset: PermissionPreset): void { - if (preset === "full_access" && onFullAccessPresetSelected) { - void onFullAccessPresetSelected().then((confirmation) => { - if (!confirmation) return; - setSelectedPermissionPreset(preset); - onWorkspaceAccessModeChange(workspaceAccessModeForPermissionPreset(preset)); - }); - return; - } - setSelectedPermissionPreset(preset); - onWorkspaceAccessModeChange(workspaceAccessModeForPermissionPreset(preset)); - } - function focusComposerAt(caret: number): void { const focus = (): void => composerInputRef.current?.focusAt(caret); focus(); @@ -187,10 +211,7 @@ export function useTeammatePrivateComposerController({ onMessageDraftChange(value); updateSlashPicker(value, caret); }, - handleAuthorizationModeChange: (mode) => { - setSelectedAuthorizationMode(mode); - onAuthorizationModeChange(mode); - }, + handleAuthorizationModeChange, handlePermissionPresetChange, submitComposer, updateSlashPicker, diff --git a/apps/web/src/features/workspace/ChannelComposer.tsx b/apps/web/src/features/workspace/ChannelComposer.tsx index a5be59ad8..2fa4dc596 100644 --- a/apps/web/src/features/workspace/ChannelComposer.tsx +++ b/apps/web/src/features/workspace/ChannelComposer.tsx @@ -30,10 +30,6 @@ import { } from "lucide-react"; import type { ComposerSlashCommandOption } from "@ling-stack/core/composer-slash-intents"; import type { ConversationRuntimeMetrics } from "@ling-stack/contracts"; -import { - conversationAuthorizationModeMenuOptions, - conversationPermissionPresetMenuOptions, -} from "../../lib/session-permissions.js"; import { useI18n } from "../../i18n.js"; import { ComposerPendingAssetStrip, @@ -44,8 +40,7 @@ import { type TokenizedComposerInputProps, } from "../../shared/components/index.js"; import { - ComposerAuthorizationMenu, - ComposerPermissionMenu, + ConversationPermissionControl, ConversationComposerShell, ConversationBottomStatusStrip, ConversationGuidanceStrip, @@ -566,23 +561,14 @@ export function ChannelComposer({ - - {planModeChipVisible ? ( diff --git a/apps/web/src/features/workspace/ChannelExecutionProposalComposerGate.tsx b/apps/web/src/features/workspace/ChannelExecutionProposalComposerGate.tsx index 1ecf6083b..b6f5c45fd 100644 --- a/apps/web/src/features/workspace/ChannelExecutionProposalComposerGate.tsx +++ b/apps/web/src/features/workspace/ChannelExecutionProposalComposerGate.tsx @@ -1,20 +1,25 @@ import { useEffect, useState } from "react"; import { useI18n, type Translate } from "../../i18n.js"; -import type { ChannelExecutionProposal } from "../../types.js"; +import type { ChannelExecutionProposal, ToolName } from "../../types.js"; +import { Button } from "../../shared/ui/index.js"; import { ConversationDecisionGate, type ConversationDecisionAction } from "../approvals/ConversationDecisionGate.js"; export function ChannelExecutionProposalComposerGate({ + allowedTools, assigneeLabel, errorMessage, proposal, onAllow, onDeny, + onOpenChannelSettings, }: { + readonly allowedTools: readonly ToolName[]; readonly assigneeLabel: string; readonly errorMessage?: string; readonly proposal: ChannelExecutionProposal; readonly onAllow: (proposal: ChannelExecutionProposal) => Promise; readonly onDeny: (proposal: ChannelExecutionProposal) => Promise; + readonly onOpenChannelSettings: () => void; }): React.JSX.Element { const { locale, t } = useI18n(); const [busyAction, setBusyAction] = useState(); @@ -22,11 +27,12 @@ export function ChannelExecutionProposalComposerGate({ const [localError, setLocalError] = useState(""); const permissionLabels = proposalPermissionLabels(proposal.requiredTools, t); const permissionText = new Intl.ListFormat(locale, { style: "short", type: "conjunction" }).format(permissionLabels); + const missingTools = proposal.requiredTools.filter(tool => !allowedTools.includes(tool)); const description = t("{agent} wants to start task \"{task}\" and needs {permissions}.", { agent: assigneeLabel, task: proposal.title, permissions: permissionText, - }); + }) + (missingTools.length ? ` ${t("Missing tools")}: ${missingTools.join(", ")}.` : ""); const visibleError = localError || errorMessage || proposal.blocker?.message || proposal.errorMessage; useEffect(() => { @@ -36,7 +42,7 @@ export function ChannelExecutionProposalComposerGate({ }, [proposal.id, proposal.status, proposal.updatedAt]); async function decide(action: ConversationDecisionAction): Promise { - if (busyAction) return; + if (busyAction || action === "allow" && missingTools.length > 0) return; setBusyAction(action); setAttemptedAction(action); setLocalError(""); @@ -55,6 +61,7 @@ export function ChannelExecutionProposalComposerGate({ return ( 0} allowLabel={t("Allow")} allowingLabel={t("Allowing")} attemptedAction={attemptedAction} @@ -67,6 +74,11 @@ export function ChannelExecutionProposalComposerGate({ description={description} errorMessage={visibleError} focusKey={`${proposal.id}:${proposal.updatedAt}`} + recoveryAction={missingTools.length > 0 ? ( + + ) : undefined} retryLabel={t("Retry")} surface="channel" title={t("Allow this task?")} diff --git a/apps/web/src/features/workspace/ChannelMessageFeed.tsx b/apps/web/src/features/workspace/ChannelMessageFeed.tsx index 57a7c8514..d446e1e22 100644 --- a/apps/web/src/features/workspace/ChannelMessageFeed.tsx +++ b/apps/web/src/features/workspace/ChannelMessageFeed.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -22,7 +23,9 @@ import { type ConversationTimelineDisclosureIntent, type ConversationViewRetainKeys, } from "../../shared/conversation/index.js"; -import { conversationMeasurementScrollEvent } from "../../shared/conversation/blocks/conversation-reading-anchor.js"; +import { conversationMeasurementScrollEvent, restoreConversationReadingAnchor } from "../../shared/conversation/blocks/conversation-reading-anchor.js"; +import { conversationReadingStorage } from "../../shared/conversation/view-store/conversation-reading-storage.js"; +import type { ConversationTimelineItemLocator } from "../../shared/conversation/timeline/ConversationTimeline.js"; import type { Channel, ChannelMention, @@ -69,6 +72,8 @@ const conversationFocusBlockSelector = '[data-conversation-focus-block="true"][data-conversation-row-key]'; interface UseChannelMessageFeedInput { + readonly readingScopeKey?: string; + readonly itemLocatorRef?: RefObject; readonly items: readonly ChannelMessageFeedItem[]; readonly conversationWorkCellByWorkflowId: ReadonlyMap; readonly conversationWorkCellByThreadId: ReadonlyMap; @@ -98,6 +103,7 @@ export interface ChannelMessageFeedState { interface ChannelMessageFeedProps { readonly surfaceId: string; + readonly readingScopeKey?: string; readonly state: ChannelMessageFeedState; readonly ariaLabel: string; readonly loading: boolean; @@ -119,6 +125,8 @@ interface ChannelMessageFeedProps { } export function useChannelMessageFeed({ + readingScopeKey, + itemLocatorRef, canLoadOlder, conversationWorkCellByThreadId, conversationWorkCellByWorkflowId, @@ -149,6 +157,21 @@ export function useChannelMessageFeed({ const measurementScrollTopRef = useRef(undefined); const [latestButtonVisible, setLatestButtonVisible] = useState(false); const [resetRevision, setResetRevision] = useState(0); + const activeReadingScopeRef = useRef(undefined); + const savedAnchorPendingRef = useRef(undefined); + const requestedReadingPageRef = useRef(undefined); + const persistTimerRef = useRef | undefined>(undefined); + const persistReading = useCallback(() => { + if (activeReadingScopeRef.current !== readingScopeKey || savedAnchorPendingRef.current) return; + conversationReadingStorage.writeViewport(readingScopeKey, { + following: pinnedToLatestRef.current, + ...(visibleAnchorRef.current ? { anchor: visibleAnchorRef.current } : {}), + }); + }, [readingScopeKey]); + const schedulePersist = useCallback(() => { + if (persistTimerRef.current !== undefined) clearTimeout(persistTimerRef.current); + persistTimerRef.current = setTimeout(() => { persistTimerRef.current = undefined; persistReading(); }, 150); + }, [persistReading]); const renderSignature = useMemo( () => items.map((item) => channelMessageFeedItemRenderSignature(item)).join("\n"), @@ -202,6 +225,7 @@ export function useChannelMessageFeed({ const onDisclosureIntent = useCallback((intent: ConversationTimelineDisclosureIntent): void => { const feed = scrollRef.current; if (!feed) return; + savedAnchorPendingRef.current = undefined; measurementScrollTopRef.current = undefined; const row = Array.from(feed.querySelectorAll(conversationFocusBlockSelector)) .find((element) => element.dataset.conversationRowKey === intent.rowKey); @@ -215,9 +239,11 @@ export function useChannelMessageFeed({ pinnedToLatestRef.current = false; userScrollIntentRef.current = true; userScrollRenderSignatureRef.current = renderSignature; - }, [renderSignature]); + schedulePersist(); + }, [renderSignature, schedulePersist]); const onLocateIntent = useCallback((): void => { + savedAnchorPendingRef.current = undefined; measurementScrollTopRef.current = undefined; autoFollowUntilRef.current = 0; pinnedToLatestRef.current = false; @@ -232,6 +258,7 @@ export function useChannelMessageFeed({ }, [renderSignature]); const restoreVisibleAnchor = useCallback((): void => { + if (savedAnchorPendingRef.current) return; const feed = scrollRef.current; const anchor = visibleAnchorRef.current; if (!feed || !anchor) { @@ -260,6 +287,7 @@ export function useChannelMessageFeed({ }, [rememberVisibleAnchor]); const reset = useCallback((): void => { + persistReading(); const feed = scrollRef.current; if (feed) { feed.scrollLeft = 0; @@ -273,9 +301,29 @@ export function useChannelMessageFeed({ visibleAnchorRef.current = undefined; layoutSizeRef.current = undefined; measurementScrollTopRef.current = undefined; + const saved = conversationReadingStorage.read(readingScopeKey)?.viewport; + activeReadingScopeRef.current = readingScopeKey; + savedAnchorPendingRef.current = saved && !saved.following ? saved.anchor : undefined; + requestedReadingPageRef.current = undefined; + if (savedAnchorPendingRef.current) { + pinnedToLatestRef.current = false; + initialLatestPendingRef.current = false; + visibleAnchorRef.current = savedAnchorPendingRef.current; + } setLatestButtonVisible(false); setResetRevision((revision) => revision + 1); - }, []); + }, [persistReading, readingScopeKey]); + + useLayoutEffect(() => { + reset(); + globalThis.addEventListener?.("pagehide", persistReading); + return () => { + persistReading(); + globalThis.removeEventListener?.("pagehide", persistReading); + if (persistTimerRef.current !== undefined) clearTimeout(persistTimerRef.current); + persistTimerRef.current = undefined; + }; + }, [reset, persistReading]); const switchConversation = useCallback((ownerKey: string): void => { const previous = conversationOwnerRef.current; @@ -304,9 +352,11 @@ export function useChannelMessageFeed({ feed.scrollLeft = 0; feed.scrollTop = feed.scrollHeight; pinnedToLatestRef.current = true; + savedAnchorPendingRef.current = undefined; visibleAnchorRef.current = undefined; setLatestButtonVisible(false); - }, []); + schedulePersist(); + }, [schedulePersist]); const schedulePinnedFollow = useCallback((): void => { const feed = scrollRef.current; @@ -352,6 +402,7 @@ export function useChannelMessageFeed({ }, [items.length, loading, protocolLoading]); const onUserScrollIntent = useCallback((): void => { + savedAnchorPendingRef.current = undefined; const feed = scrollRef.current; measurementScrollTopRef.current = undefined; autoFollowUntilRef.current = 0; @@ -399,6 +450,7 @@ export function useChannelMessageFeed({ }, [canLoadOlder, loading, loadingOlder, onLoadOlder, protocolLoading, readVisibleAnchor]); const updateScrollPositionState = useCallback((): void => { + if (savedAnchorPendingRef.current) return; const feed = scrollRef.current; if (!feed) { return; @@ -452,7 +504,37 @@ export function useChannelMessageFeed({ && Math.abs(feed.scrollTop - measurementScrollTopRef.current) <= 1) return; measurementScrollTopRef.current = undefined; updateScrollPositionState(); - }, [updateScrollPositionState]); + schedulePersist(); + }, [updateScrollPositionState, schedulePersist]); + + useEffect(() => { + const anchor = savedAnchorPendingRef.current; + const feed = scrollRef.current; + if (!anchor || !feed || loading || protocolLoading || loadingOlder || !items.length) return; + let current = true; + const finish = () => { + if (!current || savedAnchorPendingRef.current !== anchor || activeReadingScopeRef.current !== readingScopeKey) return; + if (!restoreConversationReadingAnchor(feed, anchor)) return; + visibleAnchorRef.current = anchor; + savedAnchorPendingRef.current = undefined; + layoutSizeRef.current = { height: feed.scrollHeight, viewport: feed.clientHeight }; + measurementScrollTopRef.current = feed.scrollTop; + setLatestButtonVisible(!channelMessageFeedIsPinnedToLatest(feed)); + }; + if (restoreConversationReadingAnchor(feed, anchor)) finish(); + else if (itemLocatorRef?.current) { + void itemLocatorRef.current.revealReadingAnchor(anchor, finish).then(result => { + if (!current || savedAnchorPendingRef.current !== anchor || result !== "not_found") return; + if (canLoadOlder) { + if (requestedReadingPageRef.current !== renderSignature) { + requestedReadingPageRef.current = renderSignature; + void onLoadOlder(); + } + } else scrollToLatest(); + }).catch(() => { /* A failed optional locate leaves explicit navigation available. */ }); + } + return () => { current = false; }; + }, [readingScopeKey, itemLocatorRef, loading, protocolLoading, loadingOlder, items.length, renderSignature, canLoadOlder, onLoadOlder, scrollToLatest]); useEffect(() => { const feed = scrollRef.current; @@ -630,12 +712,14 @@ export function ChannelMessageFeed({ state, statusNode, surfaceId, + readingScopeKey, title, unifiedTimelineNode, }: ChannelMessageFeedProps): React.JSX.Element { return ( submitUserInputAnswer(input.cwd, command), renderSubAgentAvatar: (item) => renderConversationParticipantAvatar(item.participantId, item.label), renderUserInputAvatar: (item) => { if (item.kind === "userInputAnswer") return ; diff --git a/apps/web/src/features/workspace/ChannelTaskDetailPanel.tsx b/apps/web/src/features/workspace/ChannelTaskDetailPanel.tsx index d2dc74468..1a512df8a 100644 --- a/apps/web/src/features/workspace/ChannelTaskDetailPanel.tsx +++ b/apps/web/src/features/workspace/ChannelTaskDetailPanel.tsx @@ -24,10 +24,8 @@ import { buildLocalEffectivePolicyPreview, formatToolDecisionGroups, inferPermissionPreset, - permissionModeForPreset, permissionPresetDescription, permissionPresetLabel, - permissionPresetOptions, sessionPermissionModeDescription, summarizeEffectivePolicySnapshot, } from "../../lib/session-permissions.js"; @@ -40,8 +38,6 @@ import { EmptyState, InlineFeedback, InspectorSection, - SegmentedControl, - SegmentedControlItem, StatusIndicator, } from "../../shared/ui/index.js"; import { ApprovalCard } from "../approvals/ApprovalCard.js"; @@ -98,6 +94,7 @@ export function TaskDetailPanel({ onCheckThenRunTask, onRunTask, onPermissionModeChange, + onOpenSandboxSettings, onCancelActiveRun, onOpenRun, onOpenApprovals, @@ -137,6 +134,7 @@ export function TaskDetailPanel({ readonly onCheckThenRunTask: (task: Task) => void; readonly onRunTask: (task: Task) => void; readonly onPermissionModeChange: (mode: SessionPermissionMode) => void; + readonly onOpenSandboxSettings: () => void; readonly onCancelActiveRun: (run?: RunSession) => void; readonly onOpenRun: (runId: string) => void; readonly onOpenApprovals: (runId?: string) => void; @@ -348,19 +346,8 @@ export function TaskDetailPanel({
- onPermissionModeChange(permissionModeForPreset(preset as PermissionPreset))} - size="sm" - > - {permissionPresetOptions.map((preset) => ( - - {permissionPresetLabel(preset, t)} - - ))} -

{permissionPresetDescription(resolvedPermissionPreset, t)}

+
{t("Preview permissions")}
diff --git a/apps/web/src/features/workspace/ConversationHome.tsx b/apps/web/src/features/workspace/ConversationHome.tsx index 6817b9f08..63614eecb 100644 --- a/apps/web/src/features/workspace/ConversationHome.tsx +++ b/apps/web/src/features/workspace/ConversationHome.tsx @@ -364,7 +364,7 @@ export function ConversationHome({ () => composerWorkspaceFileParts(composerSubmissionInputParts).map((part) => ({ path: part.path, ...(part.line !== undefined ? { line: part.line } : {}), - source: "composer" as const, + source: "inferred_text" as const, })), [composerSubmissionInputParts], ); @@ -806,6 +806,33 @@ export function ConversationHome({ actionFeedback.report("validation", error instanceof Error ? error.message : String(error)); }); }} + onPermissionPresetChange={(preset) => { + if (preset === "full_access") { + if (!onConfirmFullAccessSelection) { + actionFeedback.report("validation", t("Use full access for this conversation after explicit confirmation.")); + return; + } + const selectionSequence = ++permissionSelectionSequenceRef.current; + const selectionIdentity = conversationIdentityController.capture(); + void onConfirmFullAccessSelection() + .then((confirmation) => { + if (!confirmation || selectionSequence !== permissionSelectionSequenceRef.current || !conversationIdentityController.isCurrent(selectionIdentity)) return; + if (selectedAuthorizationMode !== "always" || selectedPermissionPreset !== "full_access") { + permissionBeforeFullAccessRef.current = selectedPermissionPreset; + } + setSelectedAuthorizationMode("always"); + setSelectedPermissionPreset("full_access"); + }) + .catch((error: unknown) => { + if (selectionSequence !== permissionSelectionSequenceRef.current || !conversationIdentityController.isCurrent(selectionIdentity)) return; + actionFeedback.report("validation", error instanceof Error ? error.message : String(error)); + }); + return; + } + permissionBeforeFullAccessRef.current = undefined; + setSelectedPermissionPreset(preset); + setSelectedAuthorizationMode(preset === "read_only" ? "ask" : "smart"); + }} onModelChange={selectModel} onThinkingLevelChange={selectThinkingLevel} onSubmit={submitDraft} diff --git a/apps/web/src/features/workspace/ConversationModelControl.tsx b/apps/web/src/features/workspace/ConversationModelControl.tsx index 7697d21b1..bc330a899 100644 --- a/apps/web/src/features/workspace/ConversationModelControl.tsx +++ b/apps/web/src/features/workspace/ConversationModelControl.tsx @@ -37,6 +37,8 @@ export function ConversationModelControl({ const defaultLevel = normalConversationDefaultThinkingLevel(levels); const levelIndex = Math.max(0, levels.indexOf(thinkingLevel)); const levelLabel = normalConversationThinkingLabel(levels[levelIndex] ?? "off", t); + const thinkingOff = (levels[levelIndex] ?? "off") === "off"; + const effortLabel = thinkingOff ? t("Thinking off") : levelLabel; const visibleModels = modelOptions.filter((option) => [option.model, option.label, option.displayName, option.providerId].join(" ").toLowerCase().includes(search.trim().toLowerCase()), ); @@ -68,13 +70,13 @@ export function ConversationModelControl({ className="conversation-model-trigger" variant="ghost" size="sm" - aria-label={`${t("Selected model {label}", { label: modelLabel })}${supportsReasoning ? ` · ${levelLabel}` : ""}`} - title={`${modelLabel}${supportsReasoning ? ` · ${levelLabel}` : ""}`} + aria-label={`${t("Selected model {label}", { label: modelLabel })}${supportsReasoning ? ` · ${t("Selected thinking mode {label}", { label: levelLabel })}` : ""}`} + title={`${modelLabel}${supportsReasoning ? ` · ${thinkingOff ? t("Thinking is off; no thinking level will be sent.") : levelLabel}` : ""}`} trailingIcon={} disabled={disabled} > {modelLabel} - {supportsReasoning ? {levelLabel} : null} + {supportsReasoning ? {effortLabel} : null} void | Promise; readonly onAuthorizationModeChange: (mode: AuthorizationMode) => void; + readonly onPermissionPresetChange: (preset: PermissionPreset) => void; readonly onModelChange: (option: ConversationHomeModelOption) => void; readonly onThinkingLevelChange: (level: ProviderThinkingMode) => void; readonly onSubmit: () => void | Promise; @@ -86,6 +87,7 @@ export function NormalConversationComposer({ modelOptions, notice, onAuthorizationModeChange, + onPermissionPresetChange, onAttachFiles, onDragLeave, onDragOver, @@ -221,9 +223,7 @@ export function NormalConversationComposer({ ) : null} dropHintNode={(dropFeedback || dragActive) ? <> {dropFeedback} - {dragActive ? ( -
{t("Drop files to attach")}
- ) : null} + {dragActive ?
{t("Drop files to attach")}
: null} : null} previewNode={pendingAttachments.length > 0 || attachmentCleanupFailures.length > 0 ? ( )} diff --git a/apps/web/src/features/workspace/NormalConversationFeed.tsx b/apps/web/src/features/workspace/NormalConversationFeed.tsx index 4f53e51e3..8c81c10a7 100644 --- a/apps/web/src/features/workspace/NormalConversationFeed.tsx +++ b/apps/web/src/features/workspace/NormalConversationFeed.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState, type ReactNode } from "react"; import { ArrowDown, Copy, FileText, Pencil } from "lucide-react"; -import { attachmentContentUrl } from "../../api.js"; +import { attachmentContentUrl, daemonBaseUrl, submitUserInputAnswer } from "../../api.js"; import { useI18n } from "../../i18n.js"; import { attachmentCanPreview, loadPreviewContent } from "../../lib/previews.js"; import type { InlineApprovalView } from "../../lib/inline-approvals.js"; @@ -21,7 +21,9 @@ import { MessageRow, } from "../../shared/conversation/index.js"; import { IconButton, InlineFeedback } from "../../shared/ui/index.js"; -import type { ConversationGeneratedMedia, RunSession } from "../../types.js"; +import { UserProfileAvatar } from "../../shared/components/index.js"; +import { TeammateAvatarView } from "../teammates/TeammateAvatarView.js"; +import type { ConversationGeneratedMedia, RunSession, UserProfile } from "../../types.js"; import type { WorkspaceRightDockFileTarget, WorkspaceRightDockReviewTarget, @@ -44,9 +46,7 @@ import { requestTimelineWorkspaceFileOpen } from "../../shared/conversation/time import { timelineMessageChromeOwnerIds } from "../../shared/conversation/timeline/timeline-message-chrome-owners.js"; import { NormalConversationResponseActions } from "./NormalConversationResponseActions.js"; import { useConversationReadingViewport } from "../../shared/conversation/blocks/use-conversation-reading-viewport.js"; -import { UserProfileAvatar } from "../../shared/components/index.js"; -import { TeammateAvatarView } from "../teammates/TeammateAvatarView.js"; -import type { UserProfile } from "../../types.js"; +import { conversationReadingScope } from "../../shared/conversation/view-store/conversation-reading-storage.js"; export interface NormalConversationFeedProps { readonly userProfile?: UserProfile; @@ -155,6 +155,7 @@ export function NormalConversationFeed({ ); const workspaceId = detailContext?.executionActions.find((action) => action.workspaceId.trim())?.workspaceId; const timelineThreadId = detailContext?.threadId ?? feedItems.find((item) => item.message.threadId)?.message.threadId; + const readingScopeKey = conversationReadingScope(daemonBaseUrl, projectPath?.trim() || workspaceCwd, "normal", timelineThreadId); const handleOpenTimelineSourceRef = useCallback(async ( ref: import("@ling-stack/conversation-ui-core").ConversationOperationSourceRef, item?: import("@ling-stack/conversation-ui-core").ConversationTimelineItem, @@ -185,6 +186,7 @@ export function NormalConversationFeed({ revision: feedItems, contentKey: itemIdentityKey, searchActive, + readingScopeKey, }); function toggleFeedback(messageId: string, feedback: "up" | "down"): void { @@ -201,6 +203,7 @@ export function NormalConversationFeed({ return ( submitUserInputAnswer(workspaceCwd.trim(), command), onPositionNavigate: pauseTimelineAutoFollow, positionIdentity: (item) => item.kind === "userMessage" ? { name: userProfile?.displayName.trim() || t("You"), @@ -227,6 +229,8 @@ export function NormalConversationFeed({ name: t("Ling Agent"), avatar: , }, + virtualScrollRef: feedRef, + itemLocatorRef: timelineItemLocatorRef, onDisclosureIntent: pauseTimelineAutoFollow, onOpenSourceRef: handleOpenTimelineSourceRef, onOpenNativeFile: (localFile) => onOpenReportFile({ path: localFile.path, line: localFile.line, localFile }), diff --git a/apps/web/src/features/workspace/ThreadReplyPanel.tsx b/apps/web/src/features/workspace/ThreadReplyPanel.tsx index ae186521e..44ca2d3fc 100644 --- a/apps/web/src/features/workspace/ThreadReplyPanel.tsx +++ b/apps/web/src/features/workspace/ThreadReplyPanel.tsx @@ -5,7 +5,9 @@ import { buildSurfaceTimelineSnapshot } from "../../domains/conversations/timeli import { filterUsedMemoryEventsByResponse, UsedMemorySummary } from "../../shared/components/index.js"; import { ConversationReplacementRetry, ConversationTimelineFeed, InlineMessageEditor, MessageActionBar, MessageRow, mountConversationTimeline } from "../../shared/conversation/index.js"; import { useConversationReadingViewport } from "../../shared/conversation/blocks/use-conversation-reading-viewport.js"; +import { conversationReadingScope } from "../../shared/conversation/view-store/conversation-reading-storage.js"; import { IconButton, Textarea } from "../../shared/ui/index.js"; +import { daemonBaseUrl, submitUserInputAnswer } from "../../api.js"; import type { ChannelMention, ChannelMessage, ConversationItemV1, ConversationReasoningSummaryRecord, MemoryObject, UsedMemoryEvent, UserInputTranscriptItemV1 } from "../../types.js"; export function ThreadReplyPanel({ @@ -84,10 +86,12 @@ export function ThreadReplyPanel({ workspaceId: rootMessage.workspaceId, channelId: rootMessage.channelId, threadId, rootMessage, replies, conversationItems, userInputs, reasoningSummaries, contextCompactions, }); + const readingScopeKey = conversationReadingScope(daemonBaseUrl, cwd, `topic:${rootMessage.id}`, threadId); const reading = useConversationReadingViewport({ scopeKey: JSON.stringify([cwd, rootMessage.id, threadId]), revision: timelineItems, contentKey: timelineItems.map(item => item.id).join("|"), + readingScopeKey, }); const timelineNode = mountConversationTimeline({ snapshot: buildSurfaceTimelineSnapshot({ @@ -99,6 +103,7 @@ export function ThreadReplyPanel({ }), depth: 0, cwd, + onSubmitUserInputAnswer: (command) => submitUserInputAnswer(cwd, command), virtualScrollRef: reading.scrollRef, itemLocatorRef: reading.locatorRef, onDisclosureIntent: reading.pauseAutoFollow, @@ -185,6 +190,7 @@ export function ThreadReplyPanel({
) : <>{channelSessionDraft ? : null}{channelComposer}; diff --git a/apps/web/src/features/workspace/channel-protocol-timeline-status.ts b/apps/web/src/features/workspace/channel-protocol-timeline-status.ts index a900260cd..4c749a9f4 100644 --- a/apps/web/src/features/workspace/channel-protocol-timeline-status.ts +++ b/apps/web/src/features/workspace/channel-protocol-timeline-status.ts @@ -1,5 +1,5 @@ import type { ConversationTimelineItemStatus } from "@ling-stack/conversation-ui-core"; -import type { LingTurnStatus, MediaJobStatus, WorkUnitStatus } from "@ling-stack/core"; +import type { LingTurnStatus, MediaJobStatus, WorkUnitStatus } from "@ling-stack/contracts"; import type { ChannelActivityItem } from "../../lib/channel-activity.js"; type ProtocolTimelineStatus = ConversationTimelineItemStatus | undefined; diff --git a/apps/web/src/features/workspace/normal-conversation-guidance.ts b/apps/web/src/features/workspace/normal-conversation-guidance.ts index 22ff0f523..74151e562 100644 --- a/apps/web/src/features/workspace/normal-conversation-guidance.ts +++ b/apps/web/src/features/workspace/normal-conversation-guidance.ts @@ -45,6 +45,7 @@ export async function queueNormalConversationGuidance(input: { fileRefs: input.draft.fileRefs.map((fileRef) => ({ path: fileRef.path, ...(fileRef.label ? { label: fileRef.label } : {}), + ...(fileRef.source ? { source: fileRef.source } : {}), rootRelation: "unknown", kind: "unknown", materializationStatus: "not_loaded", diff --git a/apps/web/src/features/workspace/useChannelComposerActionsController.ts b/apps/web/src/features/workspace/useChannelComposerActionsController.ts index 04e4b6d6f..1de14362e 100644 --- a/apps/web/src/features/workspace/useChannelComposerActionsController.ts +++ b/apps/web/src/features/workspace/useChannelComposerActionsController.ts @@ -405,7 +405,7 @@ export function useChannelComposerActionsController({ const fileRefs = composerWorkspaceFileParts(liveInputParts).map((part): ChannelMessageFileRef => ({ path: part.path, label: part.path, - source: "composer", + source: "inferred_text", ...(part.line !== undefined ? { line: part.line } : {}), })); if (!body && serializedInput.attachmentIds.length === 0 && !pendingLinkPreviews.some((item) => item.preview) && fileRefs.length === 0) { diff --git a/apps/web/src/features/workspace/useChannelComposerInputController.ts b/apps/web/src/features/workspace/useChannelComposerInputController.ts index 682dbcc21..9f84ad5a3 100644 --- a/apps/web/src/features/workspace/useChannelComposerInputController.ts +++ b/apps/web/src/features/workspace/useChannelComposerInputController.ts @@ -429,7 +429,7 @@ export function useChannelComposerInputController({ () => composerWorkspaceFileParts(composerSubmissionInputParts).map((part): ChannelMessageFileRef => ({ path: part.path, label: part.path, - source: "composer", + source: "inferred_text", ...(part.line !== undefined ? { line: part.line } : {}), })), [composerSubmissionInputParts], diff --git a/apps/web/src/features/workspace/workspace-client-view.types.ts b/apps/web/src/features/workspace/workspace-client-view.types.ts index 7c3bb4d74..054be3018 100644 --- a/apps/web/src/features/workspace/workspace-client-view.types.ts +++ b/apps/web/src/features/workspace/workspace-client-view.types.ts @@ -1,7 +1,7 @@ import type { ChannelSessionSummary } from "@ling-stack/contracts"; import type { ComposerSlashCommandOption } from "@ling-stack/core/composer-slash-intents"; import type { ConversationRuntimeMetrics } from "@ling-stack/contracts"; -import type { AiTeammateTemplate } from "@ling-stack/core"; +import type { AiTeammateTemplate } from "@ling-stack/core/skill-types"; import type { ChannelActivityTriageDraft, ChannelActivityTriageResult } from "../../lib/channel-activity-triage.js"; import type { ApprovalRequest, @@ -261,6 +261,7 @@ export interface WorkspaceClientViewProps { readonly onRunTask: (task: Task) => void; readonly onAuthorizationModeChange: (mode: AuthorizationMode) => void; readonly onPermissionModeChange: (mode: SessionPermissionMode) => void; + readonly onOpenSandboxSettings: () => void; readonly onCancelActiveRun: (run?: RunSession) => void; readonly onOpenRun: (runId: string) => void; readonly onOpenApprovals: (runId?: string) => void; diff --git a/apps/web/src/i18n/locales/en-US.ts b/apps/web/src/i18n/locales/en-US.ts index ac9fdce2a..aef965c13 100644 --- a/apps/web/src/i18n/locales/en-US.ts +++ b/apps/web/src/i18n/locales/en-US.ts @@ -66,6 +66,9 @@ export const enUS = { "Last read at {time}": "Last read at {time}", "Loading recovery diagnostics...": "Loading recovery diagnostics...", "No stored recovery records for this run": "No stored recovery records for this run", + "No continuation, controlled tool execution, evaluation or delegation metadata was found.": "No continuation, controlled tool execution, evaluation or delegation metadata was found.", + "Run evaluation": "Run evaluation", + "Delegation": "Delegation", "No continuation or controlled tool execution metadata was found.": "No continuation or controlled tool execution metadata was found.", "Loaded {count} recovery records": "Loaded {count} recovery records", "All pages loaded. Refresh for newer recovery records.": "All pages loaded. Refresh for newer recovery records.", @@ -957,6 +960,9 @@ export const enUS = { "Auto": "Auto", "Workspace write": "Workspace write", "Full access": "Full access", + "Full access permissions": "Full access permissions", + "After confirmation, this conversation uses automatic approvals and Full Access. System permissions and Host deny rules still apply.": "After confirmation, this conversation uses automatic approvals and Full Access. System permissions and Host deny rules still apply.", + "Confirmation enables automatic approvals and Full Access for this conversation, including access outside the workspace. System permissions and Host deny rules still apply.": "Confirmation enables automatic approvals and Full Access for this conversation, including access outside the workspace. System permissions and Host deny rules still apply.", "Full access · Confirmed for this conversation": "Full access · Confirmed for this conversation", "Read-only / planning": "Read-only / planning", "Workspace collaboration": "Workspace collaboration", @@ -1186,6 +1192,9 @@ export const enUS = { "Checked action": "Checked", "Checking action": "Checking", "Approval recorded": "Approval recorded", + "Approval recorded. Execution recovery is in progress.": "Approval recorded. Execution recovery is in progress.", + "Approval recorded, but execution recovery is blocked. Check run status; do not approve again.": "Approval recorded, but execution recovery is blocked. Check run status; do not approve again.", + "Execution recovery could not be confirmed. Check run status; do not approve again.": "Execution recovery could not be confirmed. Check run status; do not approve again.", "Approval was recorded, but execution recovery is pending. Check run status before trying again.": "Approval was recorded, but execution recovery is pending. Check run status before trying again.", "Task completed: {summary}": "Task completed: {summary}", "Task completed.": "Task completed.", @@ -2956,6 +2965,8 @@ export const enUS = { "Enabled servers are available for explicit teammate assignment.": "Enabled servers are available for explicit teammate assignment.", "Run a local MCP process from this workspace.": "Run a local MCP process from this workspace.", "Command": "Command", + "Open full log": "Open full log", + "Full output is unavailable for this command.": "Full output is unavailable for this command.", "Arguments": "Arguments", "Working directory": "Working directory", "Optional": "Optional", diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index b47951713..9cfa59f28 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -66,6 +66,9 @@ export const zhCN = { "Last read at {time}": "最近读取时间:{time}", "Loading recovery diagnostics...": "正在读取恢复诊断…", "No stored recovery records for this run": "当前运行没有持久化恢复记录", + "No continuation, controlled tool execution, evaluation or delegation metadata was found.": "未找到续接、受控工具执行、评估或委派元数据。", + "Run evaluation": "运行评估", + "Delegation": "委派", "No continuation or controlled tool execution metadata was found.": "未发现续跑或受控工具执行的元数据。", "Loaded {count} recovery records": "已加载 {count} 条恢复记录", "All pages loaded. Refresh for newer recovery records.": "所有分页已加载。刷新可读取新增恢复记录。", @@ -524,6 +527,8 @@ export const zhCN = { "Enabled servers are available for explicit teammate assignment.": "启用后可被 AI 队友授权使用。", "Run a local MCP process from this workspace.": "从当前工作区启动本地 MCP 进程。", "Command": "命令", + "Open full log": "打开完整日志", + "Full output is unavailable for this command.": "此命令的完整输出不可用。", "Arguments": "参数", "Working directory": "运行目录", "Optional": "可选", @@ -3458,6 +3463,9 @@ export const zhCN = { "Auto": "自动判断", "Workspace write": "工作区写入", "Full access": "完全访问", + "Full access permissions": "完全访问权限", + "After confirmation, this conversation uses automatic approvals and Full Access. System permissions and Host deny rules still apply.": "确认后,本会话同时启用自动审批和完全访问。系统权限和 Host 拒绝规则仍然有效。", + "Confirmation enables automatic approvals and Full Access for this conversation, including access outside the workspace. System permissions and Host deny rules still apply.": "确认后,本会话同时启用自动审批和完全访问,可访问工作区外文件。系统权限和 Host 拒绝规则仍然有效。", "Full access · Confirmed for this conversation": "完全访问 · 本对话已确认", "Read-only / planning": "只读 / 规划", "Workspace collaboration": "工作区协作", @@ -3618,6 +3626,9 @@ export const zhCN = { "Checked action": "已检查", "Checking action": "正在检查", "Approval recorded": "已记录审批", + "Approval recorded. Execution recovery is in progress.": "审批已保存,正在恢复执行。", + "Approval recorded, but execution recovery is blocked. Check run status; do not approve again.": "审批已保存,但执行恢复受阻。请查看运行状态,不要重复授权。", + "Execution recovery could not be confirmed. Check run status; do not approve again.": "暂时无法确认执行恢复状态。请查看运行状态,不要重复授权。", "Approval was recorded, but execution recovery is pending. Check run status before trying again.": "审批已保存,但执行恢复尚未完成。请先查看运行状态,不要重复授权。", "Task completed: {summary}": "任务完成:{summary}", "Task completed.": "任务已完成。", diff --git a/apps/web/src/lib/approval-recovery.ts b/apps/web/src/lib/approval-recovery.ts new file mode 100644 index 000000000..5447c01d8 --- /dev/null +++ b/apps/web/src/lib/approval-recovery.ts @@ -0,0 +1,34 @@ +import type { ApprovalDetailResponse } from "@ling-stack/contracts"; + +/** Observation of post-decision work, never permission to replay a decision or tool. */ +export type ApprovalRecoveryState = "pending" | "completed" | "blocked" | "unknown"; + +export function approvalReceiptRecoveryState(receipt: ApprovalDetailResponse): ApprovalRecoveryState { + const effects = receipt.postCommitEffects; + if (!Array.isArray(effects)) return "unknown"; + const seen = new Set(); + for (const effect of effects) { + if (!effect || effect.approvalId !== receipt.approval.id + || !["decision_event", "owner", "control_plane_event"].includes(effect.effectKind) + || !["pending", "claimed", "completed", "blocked", "effect_unknown"].includes(effect.status) + || seen.has(effect.effectKind)) return "unknown"; + seen.add(effect.effectKind); + } + if (effects.some(effect => effect.status === "effect_unknown")) return "unknown"; + if (effects.some(effect => effect.status === "blocked")) return "blocked"; + return effects.every(effect => effect.status === "completed") ? "completed" : "pending"; +} + +export function approvalRecoveryNotice(state: ApprovalRecoveryState) { + if (state === "completed") return undefined; + if (state === "pending") return { + tone: "info" as const, + message: "Approval recorded. Execution recovery is in progress.", + }; + return { + tone: "warning" as const, + message: state === "blocked" + ? "Approval recorded, but execution recovery is blocked. Check run status; do not approve again." + : "Execution recovery could not be confirmed. Check run status; do not approve again.", + }; +} diff --git a/apps/web/src/lib/collaboration-task-card.ts b/apps/web/src/lib/collaboration-task-card.ts index 8d4cd479d..0c4a95f0b 100644 --- a/apps/web/src/lib/collaboration-task-card.ts +++ b/apps/web/src/lib/collaboration-task-card.ts @@ -46,7 +46,7 @@ export function buildCollaborationTaskCardModel(input: BuildCollaborationTaskCar const pendingApprovals = workflowApprovals.filter((approval) => approval.status === "pending"); const blockedStep = orderedSteps.find((step) => step.status === "blocked"); const failedStep = orderedSteps.find((step) => step.status === "failed"); - const waitingStep = orderedSteps.find((step) => step.status === "waiting_for_approval"); + const waitingStep = orderedSteps.find((step) => step.status === "waiting_for_approval" || step.status === "waiting_for_input"); const activeStep = orderedSteps.find((step) => step.status === "running" || step.status === "claimed"); const queuedStep = orderedSteps.find((step) => step.status === "queued"); const focusStep = blockedStep ?? failedStep ?? waitingStep ?? activeStep ?? queuedStep; @@ -110,7 +110,9 @@ function buildCollaborationBranches(input: { steps.find((step) => step.state === "blocked") ?? steps.find((step) => step.state === "failed") ?? steps.find((step) => step.state === "active") ?? - steps.find((step) => step.state === "waiting" || step.state === "queued") ?? + steps.find((step) => step.state === "waiting") ?? + steps.find((step) => step.state === "queued") ?? + steps.find((step) => step.state === "cancelled") ?? steps.at(-1); const label = ownerLabels.length > 0 ? input.t("{owner} branch", { owner: ownerLabels.slice(0, 2).join(", ") }) @@ -149,7 +151,8 @@ function collaborationTaskCardMode( if (workflow.status === "blocked" || steps.some((step) => step.status === "blocked")) { return "blocked"; } - if (workflow.status === "waiting_for_approval" || pendingApprovals.length > 0 || steps.some((step) => step.status === "waiting_for_approval")) { + if (workflow.status === "waiting_for_approval" || workflow.status === "waiting_for_input" || pendingApprovals.length > 0 + || steps.some((step) => step.status === "waiting_for_approval" || step.status === "waiting_for_input")) { return "waiting"; } if (workflow.status === "draft" || workflow.status === "ready") { @@ -178,7 +181,7 @@ function collaborationStepState(step: ChannelWorkflowStep): CollaborationTaskSte if (step.status === "running" || step.status === "claimed") { return "active"; } - if (step.status === "waiting_for_approval") { + if (step.status === "waiting_for_approval" || step.status === "waiting_for_input") { return "waiting"; } if (step.status === "blocked") { @@ -245,7 +248,7 @@ function collaborationBranchState(steps: readonly CollaborationTaskCardStep[]): if (steps.every((step) => step.state === "done")) { return "done"; } - if (steps.every((step) => step.state === "cancelled")) { + if (steps.every((step) => step.state === "done" || step.state === "cancelled")) { return "cancelled"; } return "waiting"; diff --git a/apps/web/src/lib/collaboration-task-work-cell.ts b/apps/web/src/lib/collaboration-task-work-cell.ts index 78c0e35e7..ddfd87930 100644 --- a/apps/web/src/lib/collaboration-task-work-cell.ts +++ b/apps/web/src/lib/collaboration-task-work-cell.ts @@ -3,7 +3,7 @@ import type { ChannelWorkflowStep } from "../types.js"; import type { ConversationAgentActivitySpan, ConversationWorkCellRow, ConversationWorkCellRowTone, ConversationWorkCellViewModel } from "./conversation-work-cell.js"; import { normalizeConversationOperationCell } from "./conversation-work-cell.js"; import type { CollaborationTaskCardModel, CollaborationTaskStepState } from "./collaboration-task-card.js"; -import { workflowProgressToConversationWorkCell, type WorkflowProgressModel } from "./workflow-progress.js"; +import { workflowProgressToConversationWorkCell, workflowStepLifecycle, type WorkflowProgressModel } from "./workflow-progress.js"; export function collaborationTaskCardToConversationWorkCell(input: { readonly card: CollaborationTaskCardModel; @@ -217,7 +217,9 @@ function buildCollaborationAgentSpans( summary: group.latestStep ? undefined : group.summary, meta: undefined, tone: collaborationStepTone(group.state), - stateLabel: collaborationTaskStepStateMeta(group.state, t), + lifecycle: collaborationGroupLifecycle(group.state), + stateLabel: group.state === "waiting" && group.latestStep && workflowStepById.get(group.latestStep.id)?.status === "waiting_for_input" + ? t("waiting for user input") : collaborationTaskStepStateMeta(group.state, t), latestRow: group.latestStep ? collaborationStepToAgentRow(group.latestStep, t, "latest", workflowStepById.get(group.latestStep.id)) : undefined, rows: group.detailSteps.map((step) => collaborationStepToAgentRow(step, t, "history", workflowStepById.get(step.id))), })); @@ -291,7 +293,9 @@ function agentSpanFocusStep( return steps.find((step) => step.state === "blocked") ?? steps.find((step) => step.state === "failed") ?? steps.find((step) => step.state === "active") ?? - steps.find((step) => step.state === "waiting" || step.state === "queued") ?? + steps.find((step) => step.state === "waiting") ?? + steps.find((step) => step.state === "queued") ?? + steps.find((step) => step.state === "cancelled") ?? steps.at(-1); } @@ -530,7 +534,7 @@ function agentSpanGroupState(steps: readonly CollaborationTaskCardModel["steps"] if (steps.every((step) => step.state === "done")) { return "done"; } - if (steps.every((step) => step.state === "cancelled")) { + if (steps.every((step) => step.state === "done" || step.state === "cancelled")) { return "cancelled"; } return "waiting"; @@ -604,3 +608,11 @@ function collaborationStepTone(state: CollaborationTaskStepState): ConversationW } return "failed"; } + +function collaborationGroupLifecycle(state: CollaborationTaskStepState): NonNullable { + const status: Record = { + done: "succeeded", active: "running", waiting: "waiting_for_approval", queued: "queued", + blocked: "blocked", failed: "failed", cancelled: "cancelled", + }; + return workflowStepLifecycle(status[state]); +} diff --git a/apps/web/src/lib/conversation-runtime-visibility.ts b/apps/web/src/lib/conversation-runtime-visibility.ts index e5d00c783..088af81c9 100644 --- a/apps/web/src/lib/conversation-runtime-visibility.ts +++ b/apps/web/src/lib/conversation-runtime-visibility.ts @@ -15,19 +15,22 @@ export function deriveSilentChannelReplyRunIds(input: { for (const run of input.runs) { const workUnit = run.workUnitId ? workUnitById.get(run.workUnitId) : undefined; + const hasVisibleWork = conversationRunHasVisibleWork({ + run, + approvals: input.approvals.filter((approval) => approval.runId === run.id), + executionActions: input.executionActions.filter((action) => action.runId === run.id), + terminalReport: terminalReportByRunId.get(run.id), + }); if (run.workUnitId && !workUnit) { - silentRunIds.add(run.id); + // A paged-out or still-loading owner does not invalidate known facts. + // Hide only the evidence-free supervision shell while ownership loads. + if (!hasVisibleWork) silentRunIds.add(run.id); continue; } if (workUnit?.kind !== "channel_ai_reply") { continue; } - if (!conversationRunHasVisibleWork({ - run, - approvals: input.approvals.filter((approval) => approval.runId === run.id), - executionActions: input.executionActions.filter((action) => action.runId === run.id), - terminalReport: terminalReportByRunId.get(run.id), - })) { + if (!hasVisibleWork) { silentRunIds.add(run.id); } } diff --git a/apps/web/src/lib/conversation-turn-projector.ts b/apps/web/src/lib/conversation-turn-projector.ts index d2212c0e0..270f34ed6 100644 --- a/apps/web/src/lib/conversation-turn-projector.ts +++ b/apps/web/src/lib/conversation-turn-projector.ts @@ -86,6 +86,8 @@ export interface BuildConversationTurnProjectionInput { readonly operationCellByThreadId: ReadonlyMap; readonly lingAgentAuthorId: string; readonly terminalReportResultItemIds?: ReadonlySet; + /** Same Host-evidence visibility decision used by the standalone activity feed. */ + readonly silentChannelReplyRunIds?: ReadonlySet; } export function buildConversationTurnProjection(input: BuildConversationTurnProjectionInput): ConversationTurnProjection { @@ -133,6 +135,7 @@ export function buildConversationTurnProjection(input: BuildConversationTurnProj runsById, operationCellByWorkflowId: input.operationCellByWorkflowId, operationCellByThreadId: input.operationCellByThreadId, + silentChannelReplyRunIds: input.silentChannelReplyRunIds, }); const childMessages = uniqueMessages([ ...(sourceMessage.turnId ? messagesByTurnId.get(sourceMessage.turnId) ?? [] : []), @@ -278,6 +281,7 @@ function findTurnOperation(input: { readonly runsById: ReadonlyMap; readonly operationCellByWorkflowId: ReadonlyMap; readonly operationCellByThreadId: ReadonlyMap; + readonly silentChannelReplyRunIds?: ReadonlySet; }): { readonly cell: ConversationWorkCellViewModel; readonly ref: ConversationTurnOperationRef } | undefined { const workflowIds = uniqueStrings([ ...input.proposals.map((proposal) => proposal.linkedWorkflowId).filter(isString), @@ -310,12 +314,18 @@ function findTurnOperation(input: { ...input.proposals.map((proposal) => proposal.linkedRunId).filter(isString), ]); for (const runId of linkedRunIds) { + if (input.silentChannelReplyRunIds?.has(runId)) { + continue; + } const threadId = input.runsById.get(runId)?.threadId; if (!threadId) { continue; } const cell = input.operationCellByThreadId.get(threadId); - if (cell) { + // The map is thread-scoped and may already contain another turn's cell. + // A shared thread alone cannot transfer that execution to this request. + if (cell && cell.runIds.includes(runId) && + (!input.sourceMessage.turnId || !cell.turnId || input.sourceMessage.turnId === cell.turnId)) { return { cell, ref: { kind: "run_thread", threadId } }; } } diff --git a/apps/web/src/lib/workflow-child-timeline.ts b/apps/web/src/lib/workflow-child-timeline.ts index aaebe2a24..9a7de0957 100644 --- a/apps/web/src/lib/workflow-child-timeline.ts +++ b/apps/web/src/lib/workflow-child-timeline.ts @@ -1,7 +1,9 @@ import { - batchCanonicalTranscriptItems, mergeConversationRunNarration, projectConversationRunNarration, projectConversationTimelineItems, + batchCanonicalTranscriptItems, mergeConversationRunNarration, parseUnverifiedBlockedNarration, + projectConversationRunNarration, projectConversationTimelineItems, type ConversationTimelineItem, type ConversationTimelineMessageItem, type ConversationWorkCellRow, } from "@ling-stack/conversation-ui-core"; +import { isDelegatedOutcomeV1 } from "@ling-stack/contracts"; import type { ChannelWorkflowStep, ExecutionAction } from "../types.js"; import { interleaveWorkflowChildUserInputs } from "./workflow-child-user-input.js"; @@ -37,6 +39,7 @@ export function projectWorkflowChildTimeline(input: { && attempts.size <= 1 && (!narration.attemptId || attempts.size === 0 || attempts.has(narration.attemptId)); if (!ownsNarration) return positionAndBatch(atomic, stepIndex, input.contextSegmentIdByItemId); const attemptId = narration.attemptId ?? [...attempts][0]; + const blockedOutcome = matchingBlockedOutcome(step, narration, attemptId, input.parentItemId); const messageStatus = step.status === "succeeded" || step.status === "skipped" ? "succeeded" : step.status === "failed" ? "failed" : step.status === "cancelled" ? "cancelled" : step.status === "blocked" ? "blocked" : "running"; @@ -59,13 +62,27 @@ export function projectWorkflowChildTimeline(input: { // Child ownership was validated above from the Host's enclosing step/run // envelope. Normal/private per-segment ownership is a different source. const messages = projectConversationRunNarration({ message, segments: narration.segments, runId: narration.runId, anchor: message }) - .map(row => ({ - ...row, attemptId, participantId: narration.participantId, workflowStepId: step.id, - sourceRefs: [ - ...row.sourceRefs.filter(ref => ref.kind !== "attempt" && ref.kind !== "participant"), - { kind: "participant", id: narration.participantId }, ...(attemptId ? [{ kind: "attempt", id: attemptId }] : []), - ], - })); + .map((row, index): ConversationTimelineItem => { + const scoped = { + ...row, attemptId, participantId: narration.participantId, workflowStepId: step.id, + sourceRefs: [ + ...row.sourceRefs.filter(ref => ref.kind !== "attempt" && ref.kind !== "participant"), + { kind: "participant", id: narration.participantId }, ...(attemptId ? [{ kind: "attempt", id: attemptId }] : []), + ], + }; + const segment = narration.segments[index]!; + const source = segment.source; + const ownSegment = !source || source.workspaceId === step.workspaceId && source.threadId === narration.threadId + && source.turnId === narration.turnId && source.runId === narration.runId + && source.attemptId === attemptId && source.participantId === narration.participantId + && typeof source.workUnitId === "string" && source.workUnitId.trim().length > 0; + const explanation = blockedOutcome && ownSegment && segment.completed && row.phase === "final" + ? parseUnverifiedBlockedNarration(row.markdown) : undefined; + if (!explanation || !blockedOutcome) return scoped; + const { markdown: _markdown, phase: _phase, logicalMessageId: _logical, waitingPlaceholder: _waiting, ...base } = scoped; + return { ...base, kind: "protocolEvent", protocolKind: "workflow-step", title: "Task blocked", + blockedExplanation: { ...explanation, hostCode: "required_operation_evidence_missing", hostReason: blockedOutcome.reason } }; + }); const scopedAtomic = atomic.map(item => item.runId === narration.runId && item.attemptId === attemptId && item.participantId === narration.participantId ? { ...item, turnId: narration.turnId } : item); const merged = interleaveWorkflowChildUserInputs({ step, participantId: input.participantId, @@ -76,6 +93,32 @@ export function projectWorkflowChildTimeline(input: { }); } +function matchingBlockedOutcome( + step: ChannelWorkflowStep, + narration: NonNullable, + attemptId: string | undefined, + parentItemId: string | undefined, +) { + const outcome = step.metadata?.delegatedOutcome; + const sourceItemId = parentItemId ?? step.metadata?.sourceMessageId; + if (step.status !== "blocked" || !isDelegatedOutcomeV1(outcome) || outcome.kind !== "blocked" + || outcome.code !== "required_operation_evidence_missing" + || outcome.workflowId !== step.workflowId || outcome.workflowStepId !== step.id + || outcome.participantId !== step.toParticipantId || outcome.participantId !== narration.participantId + || outcome.lineage.runId !== step.runId || outcome.lineage.runId !== narration.runId + || !attemptId?.trim() || outcome.lineage.attemptId !== attemptId || narration.attemptId !== attemptId + || !sourceItemId?.trim() || sourceItemId !== narration.sourceItemId + || (step.metadata?.sourceMessageId && step.metadata.sourceMessageId !== sourceItemId)) return undefined; + const expectedRefs: Readonly> = { + workflow: step.workflowId, workflow_step: step.id, run: narration.runId, attempt: attemptId, + conversation_item: sourceItemId, + }; + // The contract validates required refs and uniqueness. Reject additional refs + // to another owning run/attempt/source as well, rather than guessing lineage. + if (outcome.lineage.sourceRefs.some(ref => Object.hasOwn(expectedRefs, ref.type) && expectedRefs[ref.type] !== ref.id)) return undefined; + return outcome; +} + function positionAndBatch( items: readonly ConversationTimelineItem[], threadSequence: number, diff --git a/apps/web/src/lib/workflow-progress.ts b/apps/web/src/lib/workflow-progress.ts index 745c1dae0..baaacb6d9 100644 --- a/apps/web/src/lib/workflow-progress.ts +++ b/apps/web/src/lib/workflow-progress.ts @@ -604,6 +604,7 @@ function buildWorkflowAgentActivitySpans(input: { kind: "agent" as const, label: latestStep?.toParticipantLabel.trim() || orderedSteps[0]?.toParticipantLabel.trim() || participantId, tone: workflowStepTone(groupStatus), + lifecycle: workflowStepLifecycle(groupStatus), stateLabel: workflowStepStatusMeta(groupStatus, input.t), latestRow: latestActionRow ?? (latestStep ? workflowStepToAgentRow(latestStep, input.t, "latest") : undefined), rows, @@ -727,6 +728,7 @@ function workflowAgentFocusStep(steps: readonly ChannelWorkflowStep[]): ChannelW steps.find((step) => step.status === "waiting_for_input") ?? steps.find((step) => step.status === "running" || step.status === "claimed") ?? steps.find((step) => step.status === "queued") ?? + steps.find((step) => step.status === "cancelled") ?? steps.at(-1); } @@ -1104,6 +1106,16 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +export function workflowStepLifecycle(status: ChannelWorkflowStep["status"]): NonNullable { + if (status === "blocked") return "blocked"; + if (status === "failed") return "errored"; + if (status === "cancelled") return "interrupted"; + if (status === "succeeded" || status === "skipped") return "completed"; + if (status === "waiting_for_approval" || status === "waiting_for_input") return "waiting"; + if (status === "running" || status === "claimed") return "running"; + return "started"; +} + function workflowStepTone(status: ChannelWorkflowStep["status"]): ConversationWorkCellRowTone { if (status === "succeeded" || status === "skipped") { return "success"; diff --git a/apps/web/src/lib/workflow-timeline-content.ts b/apps/web/src/lib/workflow-timeline-content.ts index fa86645d8..a66a33e63 100644 --- a/apps/web/src/lib/workflow-timeline-content.ts +++ b/apps/web/src/lib/workflow-timeline-content.ts @@ -7,6 +7,7 @@ export function hasWorkflowTimelineContent(item: ConversationTimelineItem): bool if (item.kind === "explorationBatch") return item.batch.children.some(hasWorkflowTimelineContent); if (item.kind === "diagnostic") return item.code === "user_input_order_unavailable"; if (item.kind === "agentMessage") return !!item.markdown.trim(); + if (item.kind === "protocolEvent") return Boolean(item.blockedExplanation); return ["userInputRequest", "userInputAnswer", "terminalReport", "approval", "commandExecution", "fileChange", "exploration", "mcpToolCall", "dynamicToolCall", "collabAgentToolCall", "imageView"].includes(item.kind); diff --git a/apps/web/src/features/workspace/ConversationPermissionControl.tsx b/apps/web/src/shared/conversation/ConversationPermissionControl.tsx similarity index 69% rename from apps/web/src/features/workspace/ConversationPermissionControl.tsx rename to apps/web/src/shared/conversation/ConversationPermissionControl.tsx index 877ba52f0..7d444c2a5 100644 --- a/apps/web/src/features/workspace/ConversationPermissionControl.tsx +++ b/apps/web/src/shared/conversation/ConversationPermissionControl.tsx @@ -1,41 +1,45 @@ import { useEffect, useState } from "react"; import { Check, ChevronDown, Hand, Shield, ShieldAlert, ShieldCheck } from "lucide-react"; +import { ComposerPermissionMenu } from "@ling-stack/conversation-ui-react"; import { useI18n } from "../../i18n.js"; -import { conversationAuthorizationModeMenuOptions, permissionPresetLabel } from "../../lib/session-permissions.js"; +import { conversationAuthorizationModeMenuOptions, conversationPermissionPresetMenuOptions } from "../../lib/session-permissions.js"; import { Button, Menu, MenuContent, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../../shared/ui/index.js"; import type { AuthorizationMode, PermissionPreset } from "../../types.js"; const authorizationIcons = { ask: Hand, smart: Shield, always: ShieldAlert }; export function ConversationPermissionControl({ - authorizationMode, permissionPreset, fullAccessConfirmed, disabled, onAuthorizationModeChange, + authorizationMode, permissionPreset, fullAccessConfirmed, disabled, disabledReason, onAuthorizationModeChange, onPermissionPresetChange, }: { readonly authorizationMode: AuthorizationMode; readonly permissionPreset: PermissionPreset; readonly fullAccessConfirmed: boolean; readonly disabled: boolean; + readonly disabledReason?: string; readonly onAuthorizationModeChange: (mode: AuthorizationMode) => void; + readonly onPermissionPresetChange: (preset: PermissionPreset) => void; }): React.JSX.Element { const { t } = useI18n(); const [open, setOpen] = useState(false); const authorizationModes = conversationAuthorizationModeMenuOptions(t); const options = authorizationModes.map((option) => option.mode === "always" ? { ...option, - label: t("Full access"), - description: t("Full access lets Ling act without asking for routine local approvals in this conversation."), + label: t("Full access permissions"), + description: t("After confirmation, this conversation uses automatic approvals and Full Access. System permissions and Host deny rules still apply."), } : option); const fullAccess = authorizationMode === "always" && permissionPreset === "full_access"; // An inherited always + restricted sandbox is not the Full access shortcut. const selectedMode = authorizationMode === "always" && !fullAccess ? "custom" : authorizationMode; const selectedAuthorization = options.find((option) => option.mode === selectedMode) ?? authorizationModes.find((option) => option.mode === authorizationMode)!; + const triggerLabel = fullAccess ? t("Full access") : selectedAuthorization.label; const confirmationLabel = fullAccess && fullAccessConfirmed - ? t("Full access · Confirmed for this conversation") : selectedAuthorization.label; + ? t("Full access · Confirmed for this conversation") : triggerLabel; const Icon = selectedMode === "custom" ? ShieldCheck : authorizationIcons[authorizationMode]; useEffect(() => { if (disabled) setOpen(false); }, [disabled]); - return ( + return <> setOpen(value && !disabled)}> @@ -67,5 +71,14 @@ export function ConversationPermissionControl({ - ); + + ; } diff --git a/apps/web/src/shared/conversation/blocks/CodeBlock.tsx b/apps/web/src/shared/conversation/blocks/CodeBlock.tsx index d5a48e9f8..f85516fb8 100644 --- a/apps/web/src/shared/conversation/blocks/CodeBlock.tsx +++ b/apps/web/src/shared/conversation/blocks/CodeBlock.tsx @@ -78,7 +78,7 @@ export function CodeBlock({ >
{normalizedLanguage} - {t("{count} lines", { count: stats.lines })} · {formatCodeSize(stats.characters)} + {t("{count} lines", { count: stats.lines })} · {formatCodeSize(stats.bytes)}
{bodyMounted ? ( @@ -146,12 +146,13 @@ function renderShikiHighlightedCode( interface CodeStats { readonly characters: number; + readonly bytes: number; readonly lines: number; } function codeStats(code: string): CodeStats { if (code.length === 0) { - return { characters: 0, lines: 0 }; + return { characters: 0, bytes: 0, lines: 0 }; } let lines = 1; for (let index = 0; index < code.length; index += 1) { @@ -159,7 +160,7 @@ function codeStats(code: string): CodeStats { lines += 1; } } - return { characters: code.length, lines }; + return { characters: code.length, bytes: new TextEncoder().encode(code).byteLength, lines }; } function takeLines(code: string, count: number): string { @@ -179,14 +180,14 @@ function takeLines(code: string, count: number): string { return code; } -function formatCodeSize(characters: number): string { - if (characters < 1024) { - return `${characters} B`; +function formatCodeSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; } - if (characters < 1024 * 1024) { - return `${(characters / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; } - return `${(characters / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function normalizeCodeLanguage(language?: string): string { diff --git a/apps/web/src/shared/conversation/blocks/ConversationTimelineFeed.tsx b/apps/web/src/shared/conversation/blocks/ConversationTimelineFeed.tsx index 8cdbc6ceb..39a5b9fac 100644 --- a/apps/web/src/shared/conversation/blocks/ConversationTimelineFeed.tsx +++ b/apps/web/src/shared/conversation/blocks/ConversationTimelineFeed.tsx @@ -11,6 +11,7 @@ import { ConversationViewProvider } from "../view-store/conversation-view-provid export interface ConversationTimelineFeedProps { readonly surfaceId: string; + readonly readingScopeKey?: string; readonly scrollRef: RefObject; readonly ariaLabel: string; readonly timelineId: string; @@ -65,6 +66,7 @@ export function ConversationTimelineFeed({ scrollRef, statusNode, surfaceId, + readingScopeKey, timelineId, timelineNode, title, @@ -74,7 +76,7 @@ export function ConversationTimelineFeed({ const resolvedFeedClassName = [feedClassName, empty ? emptyFeedClassName : ""].filter(Boolean).join(" "); return ( - +
boolean; } /** Reading state only: the existing Timeline and send/runtime owners are unchanged. */ export function useConversationReadingViewport({ scopeKey, revision, contentKey, searchActive = false, enabled = true, latestThresholdPx = 56, + readingScopeKey, onRestoreMissingAnchor, }: ConversationReadingViewportInput) { const scrollRef = useRef(null); const locatorRef = useRef(null); @@ -27,6 +32,20 @@ export function useConversationReadingViewport({ const capturePendingRef = useRef(false); const restoringRef = useRef(undefined); const [latestButtonVisible, setLatestButtonVisible] = useState(false); + const savedAnchorPendingRef = useRef(false); + const restoreAttemptedContentKeyRef = useRef(undefined); + const persistTimerRef = useRef | undefined>(undefined); + const persistRef = useRef<() => void>(() => undefined); + const currentContentKeyRef = useRef(contentKey); + currentContentKeyRef.current = contentKey; + + const schedulePersist = useCallback(() => { + if (persistTimerRef.current !== undefined) clearTimeout(persistTimerRef.current); + persistTimerRef.current = setTimeout(() => { + persistTimerRef.current = undefined; + persistRef.current(); + }, 150); + }, []); const reset = useCallback(() => { followingRef.current = true; @@ -37,11 +56,37 @@ export function useConversationReadingViewport({ sizeRef.current = undefined; inputIntentRef.current = false; capturePendingRef.current = false; + savedAnchorPendingRef.current = false; + restoreAttemptedContentKeyRef.current = undefined; if (frameRef.current !== undefined) cancelAnimationFrame(frameRef.current); frameRef.current = undefined; setLatestButtonVisible(false); }, []); - useLayoutEffect(reset, [scopeKey, reset]); + useLayoutEffect(() => { + reset(); + const saved = conversationReadingStorage.read(readingScopeKey)?.viewport; + if (saved && !saved.following && saved.anchor) { + followingRef.current = false; + anchorRef.current = saved.anchor; + savedAnchorPendingRef.current = true; + } + const persist = () => { + if (savedAnchorPendingRef.current) return; + conversationReadingStorage.writeViewport(readingScopeKey, { + following: followingRef.current, + ...(anchorRef.current ? { anchor: anchorRef.current } : {}), + }); + }; + persistRef.current = persist; + globalThis.addEventListener?.("pagehide", persist); + return () => { + persist(); + globalThis.removeEventListener?.("pagehide", persist); + if (persistTimerRef.current !== undefined) clearTimeout(persistTimerRef.current); + persistTimerRef.current = undefined; + if (persistRef.current === persist) persistRef.current = () => undefined; + }; + }, [scopeKey, readingScopeKey, reset]); const scrollToLatest = useCallback((behavior: ScrollBehavior = "smooth") => { const feed = scrollRef.current; @@ -53,9 +98,11 @@ export function useConversationReadingViewport({ restoredTopRef.current = undefined; inputIntentRef.current = false; capturePendingRef.current = false; + savedAnchorPendingRef.current = false; setLatestButtonVisible(false); feed.scrollTo({ top: feed.scrollHeight, behavior: reducedMotion ? "auto" : behavior }); - }, []); + schedulePersist(); + }, [schedulePersist]); const pauseAutoFollow = useCallback(() => { followingRef.current = false; @@ -65,7 +112,9 @@ export function useConversationReadingViewport({ if (returningToLatestRef.current && feed) feed.scrollTo({ top: feed.scrollTop, behavior: "instant" }); returningToLatestRef.current = false; if (feed) anchorRef.current = readConversationReadingAnchor(feed); - }, []); + savedAnchorPendingRef.current = false; + schedulePersist(); + }, [schedulePersist]); useLayoutEffect(() => { if (searchActive) pauseAutoFollow(); }, [searchActive, scopeKey, pauseAutoFollow]); @@ -89,19 +138,40 @@ export function useConversationReadingViewport({ if (!anchorRef.current) anchorRef.current = readConversationReadingAnchor(feed); if (anchorRef.current && restoreConversationReadingAnchor(feed, anchorRef.current)) { restoredTopRef.current = feed.scrollTop; + savedAnchorPendingRef.current = false; } else if (anchorRef.current && !restoringRef.current && locatorRef.current) { const anchor = anchorRef.current; + const restoringContentKey = currentContentKeyRef.current; restoringRef.current = anchor; void locatorRef.current.revealReadingAnchor(anchor, () => { if (scrollRef.current !== feed || anchorRef.current !== anchor || followingRef.current) return; restoreConversationReadingAnchor(feed, anchor); restoredTopRef.current = feed.scrollTop; + savedAnchorPendingRef.current = false; + }).then(result => { + if (result !== "not_found" || !savedAnchorPendingRef.current || anchorRef.current !== anchor + || scrollRef.current !== feed || restoringContentKey !== currentContentKeyRef.current || !restoringContentKey) return; + if (onRestoreMissingAnchor) { + if (restoreAttemptedContentKeyRef.current === restoringContentKey) return; + restoreAttemptedContentKeyRef.current = restoringContentKey; + if (onRestoreMissingAnchor()) return; + } + // A deleted/invalid row falls back to the existing latest-message behavior. + savedAnchorPendingRef.current = false; + followingRef.current = true; + anchorRef.current = undefined; + feed.scrollTop = feed.scrollHeight; + restoredTopRef.current = feed.scrollTop; + setLatestButtonVisible(false); + schedulePersist(); + }).catch(() => { + // Optional view restoration cannot prevent reading or explicit navigation. }).finally(() => { if (restoringRef.current === anchor) restoringRef.current = undefined; }); } setLatestButtonVisible(distanceFromLatest(feed) > latestThresholdPx); - }, [latestThresholdPx]); + }, [latestThresholdPx, onRestoreMissingAnchor, schedulePersist]); const schedule = useCallback(() => { if (frameRef.current !== undefined) return; @@ -146,6 +216,10 @@ export function useConversationReadingViewport({ const onScroll = useCallback((event: UIEvent) => { const feed = event.currentTarget; + if (savedAnchorPendingRef.current && !inputIntentRef.current) { + schedule(); + return; + } const size = sizeRef.current; const restoring = !!anchorRef.current && restoringRef.current === anchorRef.current; // Layout-driven clamping/anchoring is not a request to leave the tail. @@ -167,9 +241,11 @@ export function useConversationReadingViewport({ anchorRef.current = atLatest ? undefined : readConversationReadingAnchor(feed); // A virtual range may still describe the previous scroll position this frame. capturePendingRef.current = !atLatest && !anchorRef.current; + savedAnchorPendingRef.current = false; if (capturePendingRef.current) schedule(); + schedulePersist(); setLatestButtonVisible(!atLatest); - }, [latestThresholdPx, schedule]); + }, [latestThresholdPx, schedule, schedulePersist]); return { scrollRef, locatorRef, latestButtonVisible, scrollToLatest, pauseAutoFollow, onScroll, reset }; } diff --git a/apps/web/src/shared/conversation/conversation-permission-selection.ts b/apps/web/src/shared/conversation/conversation-permission-selection.ts new file mode 100644 index 000000000..4fc2e5afd --- /dev/null +++ b/apps/web/src/shared/conversation/conversation-permission-selection.ts @@ -0,0 +1,72 @@ +import { useEffect, useRef } from "react"; +import type { AuthorizationMode, PermissionConfirmation, WorkspaceAccessMode } from "../../types.js"; + +export interface CompactPermissionSelection { + readonly authorizationMode: AuthorizationMode; + readonly workspaceAccessMode: WorkspaceAccessMode; +} + +interface PermissionSelectionInput extends CompactPermissionSelection { + readonly ownerKey: string; + readonly defaultWorkspaceAccessMode: WorkspaceAccessMode; + readonly disabled: boolean; + readonly confirmFullAccess?: () => Promise; + readonly onChange: (selection: CompactPermissionSelection) => void; + readonly onError: (error: unknown) => void; +} + +// The surface owns the permission state; this controller only sequences explicit +// selections. A dialog result is not permission to modify a replacement owner. +export class ConversationPermissionSelectionController { + private input: PermissionSelectionInput; + private revision = 0; + private active = true; + private beforeFullAccess: WorkspaceAccessMode | undefined; + + constructor(input: PermissionSelectionInput) { this.input = input; } + + update(input: PermissionSelectionInput): void { + const previous = this.input; + if (previous.ownerKey !== input.ownerKey) this.beforeFullAccess = undefined; + if (previous.ownerKey !== input.ownerKey || previous.authorizationMode !== input.authorizationMode + || previous.workspaceAccessMode !== input.workspaceAccessMode || previous.disabled !== input.disabled + || previous.defaultWorkspaceAccessMode !== input.defaultWorkspaceAccessMode) this.revision++; + this.input = input; + } + + attach(): void { this.active = true; } + detach(): void { this.active = false; this.revision++; } + + async select(mode: AuthorizationMode): Promise { + const input = this.input; + if (!this.active || input.disabled) return; + const revision = ++this.revision; + if (mode !== "always") { + const workspaceAccessMode = input.authorizationMode === "always" && input.workspaceAccessMode === "full_access" + ? this.beforeFullAccess ?? input.defaultWorkspaceAccessMode : input.workspaceAccessMode; + this.beforeFullAccess = undefined; + input.onChange({ authorizationMode: mode, workspaceAccessMode }); + return; + } + try { + if (!input.confirmFullAccess) throw new Error("Use full access for this conversation after explicit confirmation."); + const confirmation = await input.confirmFullAccess(); + if (!confirmation || !this.active || revision !== this.revision) return; + if (input.authorizationMode !== "always" || input.workspaceAccessMode !== "full_access") { + this.beforeFullAccess = input.workspaceAccessMode; + } + input.onChange({ authorizationMode: "always", workspaceAccessMode: "full_access" }); + } catch (error) { + if (this.active && revision === this.revision) this.input.onError(error); + } + } +} + +export function useConversationPermissionSelection(input: PermissionSelectionInput): (mode: AuthorizationMode) => void { + const controllerRef = useRef(null); + if (!controllerRef.current) controllerRef.current = new ConversationPermissionSelectionController(input); + const controller = controllerRef.current; + controller.update(input); + useEffect(() => { controller.attach(); return () => controller.detach(); }, [controller]); + return (mode) => { void controller.select(mode); }; +} diff --git a/apps/web/src/shared/conversation/feedback/ConversationActivityLabel.tsx b/apps/web/src/shared/conversation/feedback/ConversationActivityLabel.tsx index 893e92365..c49e5a246 100644 --- a/apps/web/src/shared/conversation/feedback/ConversationActivityLabel.tsx +++ b/apps/web/src/shared/conversation/feedback/ConversationActivityLabel.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import "./conversation-activity-label.css"; +import { useDaemonObservation } from "../observation/daemon-observation.js"; /** Motion belongs to live status text, never to a tool's identifying icon. */ export function ConversationActivityLabel({ children, active = true }: { @@ -8,9 +9,11 @@ export function ConversationActivityLabel({ children, active = true }: { }): React.JSX.Element { const ref = useRef(null); const [visible, setVisible] = useState(false); + const observing = !useDaemonObservation().paused; + const live = active && observing; useEffect(() => { - if (!active) return; + if (!live) return; const element = ref.current; if (!element) return; let intersecting = typeof IntersectionObserver === "undefined"; @@ -26,8 +29,8 @@ export function ConversationActivityLabel({ children, active = true }: { observer?.disconnect(); document.removeEventListener("visibilitychange", update); }; - }, [active]); + }, [live]); return {children}; + data-activity-motion={live ? visible ? "running" : "paused" : "idle"}>{children}; } diff --git a/apps/web/src/shared/conversation/index.ts b/apps/web/src/shared/conversation/index.ts index 85f8271ab..ed2c2b57c 100644 --- a/apps/web/src/shared/conversation/index.ts +++ b/apps/web/src/shared/conversation/index.ts @@ -223,3 +223,4 @@ export type { ConversationStatus, ConversationSurface, } from "./tokens/conversation-types.js"; +export { ConversationPermissionControl } from "./ConversationPermissionControl.js"; diff --git a/apps/web/src/shared/conversation/observation/daemon-observation.tsx b/apps/web/src/shared/conversation/observation/daemon-observation.tsx new file mode 100644 index 000000000..be653bce4 --- /dev/null +++ b/apps/web/src/shared/conversation/observation/daemon-observation.tsx @@ -0,0 +1,64 @@ +import { createContext, useContext, useRef } from "react"; +import { LingDaemonTransportError } from "@ling-stack/api-client"; +import { isDesktopDaemonStatus, type DesktopDaemonStatus } from "../../../../../desktop/shared/desktop-capabilities.js"; + +/** View/read admission only. Never an execution status or permission grant. */ +export interface DaemonObservation { + readonly paused: boolean; + readonly epoch: number; + readonly workspaceCwd: string; + readonly baseUrl: string; +} + +export const liveDaemonObservation: DaemonObservation = { paused: false, epoch: 0, workspaceCwd: "", baseUrl: "" }; +const context = createContext(liveDaemonObservation); +export const DaemonObservationProvider = context.Provider; +export const useDaemonObservation = (): DaemonObservation => useContext(context); + +export function projectDaemonObservation( + previous: DaemonObservation, + status: DesktopDaemonStatus | undefined, + baseUrl: string, + workspaceCwd: string, +): DaemonObservation { + const paused = Boolean(status && isDesktopDaemonStatus(status) && status.ownedByDesktop + && (status.state === "draining" || status.state === "blocked") + && sameEndpoint(status.baseUrl, baseUrl)); + const cwd = workspaceCwd.trim(); + return previous.paused === paused && previous.workspaceCwd === cwd && previous.baseUrl === baseUrl + ? previous : { paused, workspaceCwd: cwd, baseUrl, epoch: previous.epoch + 1 }; +} + +export function useDaemonObservationState( + status: DesktopDaemonStatus | undefined, baseUrl: string, workspaceCwd: string, +): DaemonObservation { + const observation = useRef(liveDaemonObservation); + observation.current = projectDaemonObservation(observation.current, status, baseUrl, workspaceCwd); + return observation.current; +} + +export function isDaemonObservationPausedForWorkspace(observation: DaemonObservation, cwd: string): boolean { + return observation.paused && observation.workspaceCwd === cwd.trim(); +} + +/** A deferred read is NOT an applied control-plane invalidation. */ +export class DaemonObservationDeferredError extends Error { + constructor() { super("Observation paused"); this.name = "DaemonObservationDeferredError"; } +} + +export function shouldDeferDaemonObservationError(error: unknown, observation: DaemonObservation, cwd: string): boolean { + return isDaemonObservationPausedForWorkspace(observation, cwd) + && (error instanceof LingDaemonTransportError || error instanceof DaemonObservationDeferredError); +} + +function sameEndpoint(left: string, right: string): boolean { + try { + const normalize = (value: string): string => { + const url = new URL(value); + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) return ""; + return `${url.origin}${url.pathname.replace(/\/+$/u, "")}`; + }; + const endpoint = normalize(left); + return Boolean(endpoint) && endpoint === normalize(right); + } catch { return false; } +} diff --git a/apps/web/src/shared/conversation/timeline/ConversationTimeline.tsx b/apps/web/src/shared/conversation/timeline/ConversationTimeline.tsx index 58d1b8a20..6ba12e430 100644 --- a/apps/web/src/shared/conversation/timeline/ConversationTimeline.tsx +++ b/apps/web/src/shared/conversation/timeline/ConversationTimeline.tsx @@ -10,8 +10,10 @@ import { type ConversationTimelineScopeKind, type TimelineSnapshot, } from "@ling-stack/conversation-ui-core"; +import type { UserInputAnswerAcceptedResponseV1, UserInputAnswerCommandV1 } from "../../../types.js"; import { ChevronDown, CircleAlert, UserRound } from "lucide-react"; import { ConversationActivityLabel } from "../feedback/ConversationActivityLabel.js"; +import { useDaemonObservation } from "../observation/daemon-observation.js"; import { renderConversationTimelineItem } from "./renderer-registry.js"; import type { ConversationTimelineSourceRefHandler } from "./source-ref.js"; import { @@ -46,6 +48,9 @@ export interface ConversationTimelineProps { readonly onDisclosureIntent?: (intent: ConversationTimelineDisclosureIntent) => void; readonly renderSubAgentAvatar?: (item: ConversationTimelineSubAgentItem) => ReactNode; readonly renderUserInputAvatar?: (item: ConversationTimelineUserInputItem) => ReactNode; + readonly onSubmitUserInputAnswer?: ( + command: UserInputAnswerCommandV1, + ) => Promise; readonly renderMessageChrome?: (item: Extract, body: ReactNode, presentation: ConversationTimelineMessagePresentation) => ReactNode; readonly renderResponseActions?: ConversationTimelineResponseActionsRenderer; readonly emptyLabel?: ReactNode; @@ -96,6 +101,7 @@ export function ConversationTimeline({ onDisclosureIntent, renderSubAgentAvatar, renderUserInputAvatar, + onSubmitUserInputAnswer, renderMessageChrome, renderResponseActions, emptyLabel, @@ -105,6 +111,7 @@ export function ConversationTimeline({ ariaLabel, }: ConversationTimelineProps): React.JSX.Element { const { t } = useI18n(); + const observationPaused = useDaemonObservation().paused; const timelineRootRef = useRef(null); const timelineItems = useMemo(() => placeTimelineResponseReports(snapshot.items), [snapshot.items]); const responsePresentation = useMemo(() => projectTimelineResponsePresentation(timelineItems), [timelineItems]); @@ -197,6 +204,7 @@ export function ConversationTimeline({ "data-conversation-timeline-visible-row-count": displayRows.length, "data-conversation-timeline-potential-row-count": displayModel.potentialRowCount, "data-activity-density": effectiveDensity, + "data-daemon-observation": observationPaused ? "paused" : "live", "aria-label": ariaLabel, onPointerOver: (event: React.PointerEvent) => setHoveredResponseId(responseIdForTarget(event.target)), onPointerLeave: () => setHoveredResponseId(undefined), @@ -290,8 +298,10 @@ export function ConversationTimeline({ > {displayModel.segments.map((segment) => renderTimelineSegment(segment, { t, + observationPaused, scopeKind: snapshot.scope.kind, renderUserInputAvatar, + onSubmitUserInputAnswer, cwd, onOpenSourceRef, onOpenReview, @@ -321,8 +331,10 @@ export function ConversationTimeline({
{displayModel.segments.map((segment) => renderTimelineSegment(segment, { t, + observationPaused, scopeKind: snapshot.scope.kind, renderUserInputAvatar, + onSubmitUserInputAnswer, cwd, onOpenSourceRef, onOpenReview, @@ -415,12 +427,13 @@ function countAtomicItems(items: readonly ConversationTimelineItem[]): number { }, 0); } -type TimelineRenderContext = Pick & { +type TimelineRenderContext = Pick & { readonly scopeKind?: ConversationTimelineScopeKind; readonly depth: 0 | 1; }; interface TimelineSegmentRenderContext extends Omit { + readonly observationPaused: boolean; readonly t: ReturnType["t"]; readonly visibleIdentityOwners: ReadonlySet; readonly responsePresentation: TimelineResponsePresentation; @@ -475,7 +488,7 @@ function renderTimelineSegment( className="conversation-timeline-sub-agent-detail conversation-timeline-sub-agent-group" role="group" aria-label={segment.item.label} - aria-busy={segment.item.lifecycle === "running" || segment.item.lifecycle === "waiting"} + aria-busy={!context.observationPaused && (segment.item.lifecycle === "running" || segment.item.lifecycle === "waiting")} data-conversation-sub-agent-group="true" data-conversation-parent-sub-agent-id={segment.item.id} data-conversation-timeline-scope="child" @@ -536,6 +549,7 @@ function renderTimelineDisplayRow( }} /> : renderTimelineItem(row, { scopeKind: context.scopeKind, renderUserInputAvatar: context.renderUserInputAvatar, + onSubmitUserInputAnswer: context.onSubmitUserInputAnswer, cwd: context.cwd, onOpenSourceRef: context.onOpenSourceRef, onInvokeProtocolAction: context.onInvokeProtocolAction, @@ -566,6 +580,7 @@ function renderTimelineItem( return renderConversationTimelineItem(row.item, { scopeKind: context.scopeKind, renderUserInputAvatar: context.renderUserInputAvatar, + onSubmitUserInputAnswer: context.onSubmitUserInputAnswer, cwd: context.cwd, onOpenSourceRef: context.onOpenSourceRef, onInvokeProtocolAction: context.onInvokeProtocolAction, @@ -610,8 +625,11 @@ function RootSubAgentTimelineItem({ readonly onExpandedChange: (expanded: boolean) => void; }): React.JSX.Element { const { t } = useI18n(); + const observationPaused = useDaemonObservation().paused; const hasChildItems = item.childItems.length > 0; - const statusLabel = t(subAgentStatusLabel(item.lifecycle)); + const originalStatusLabel = item.stateLabel ?? t(subAgentStatusLabel(item.lifecycle)); + const statusLabel = observationPaused && (item.lifecycle === "running" || item.lifecycle === "waiting") + ? `${t("Stale snapshot")} · ${originalStatusLabel}` : originalStatusLabel; return (