diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..3b6ed6fc --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,9 @@ +# Changes to governance, automation, releases, signing, and provenance require +# explicit owner review even after additional maintainers are added. +/.github/ @wolfiesch +/GOVERNANCE.md @wolfiesch +/LICENSE @wolfiesch +/THIRD_PARTY_NOTICES.md @wolfiesch +/infra/ @wolfiesch +/scripts/*package* @wolfiesch +/scripts/*release* @wolfiesch diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 708bf9d3..5d8a4e1b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,16 +1,17 @@ name: Bug report -description: Something in T4 Code is broken or behaves wrong. +description: Something in Omperator is broken or behaves incorrectly. labels: ["bug"] body: - type: markdown attributes: value: | - Security problem? **Stop. Do not file a public issue.** Follow [SECURITY.md](https://github.com/LycaonLLC/t4-code/blob/main/SECURITY.md) instead. + Security problem? **Stop. Do not file a public issue.** Follow [SECURITY.md](https://github.com/wolfiesch/omperator/blob/main/SECURITY.md) instead. - type: input id: version attributes: - label: T4 Code version - placeholder: "0.1.30" + label: Omperator version + description: Use the displayed T4 Code version while the application rebrand is in progress. + placeholder: "0.1.31" validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 65c4510f..a2cf860d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ blank_issues_enabled: false contact_links: - name: Security vulnerability (private) - url: https://github.com/LycaonLLC/t4-code/security/advisories/new + url: https://github.com/wolfiesch/omperator/security/advisories/new about: Never report security problems in a public issue. Use GitHub private vulnerability reporting. - name: Documentation - url: https://t4code.net/docs - about: Install, first run, and usage docs. + url: https://github.com/wolfiesch/omperator#readme + about: Current installation, development, and product documentation. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 2d78cb46..3dd41786 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,5 +1,5 @@ name: Feature request -description: Suggest something T4 Code should do. +description: Suggest something Omperator should do. labels: ["enhancement"] body: - type: textarea diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 00000000..cbcd2d02 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,32 @@ +name: Question +description: Ask about installing, configuring, or using Omperator. +labels: ["question"] +body: + - type: textarea + id: goal + attributes: + label: What are you trying to do? + description: Describe the outcome you want, not only the command or screen you are using. + validations: + required: true + - type: textarea + id: attempted + attributes: + label: What have you tried? + description: Include relevant commands or screenshots after removing secrets and private paths. + - type: input + id: version + attributes: + label: Omperator version + description: Use the displayed T4 Code version while the application rebrand is in progress. + placeholder: "0.1.31" + - type: dropdown + id: platform + attributes: + label: Platform + options: + - macOS Apple Silicon + - Linux x86_64 + - Android + - Built from source + - Other diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index bab62351..8640a4a3 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,10 @@ ## What this changes - + + +## Risk and rollout + + ## How you verified it @@ -10,5 +14,6 @@ - [ ] `pnpm check` passes (release contract, provenance, lint, typecheck) - [ ] `pnpm test` passes; new behavior has a test that fails without this change +- [ ] User-facing or architectural changes update the relevant docs, ADR, or release notes - [ ] Any pasted logs or screenshots are redacted: no secrets, tokens, pairing codes, or private paths - [ ] Ported code (T3 Code / OMP) follows the provenance rules in `THIRD_PARTY_NOTICES.md` diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..50f4f4cd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,38 @@ +version: 2 + +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: America/Los_Angeles + # Keep security updates enabled while the inherited dependency baseline is + # repaired deliberately. GitHub applies a separate PR limit to security + # updates, so zero disables only scheduled version-update PRs. + open-pull-requests-limit: 0 + commit-message: + prefix: "chore(deps)" + + - package-ecosystem: gomod + directory: /packages/cluster-operator + schedule: + interval: weekly + day: monday + time: "09:15" + timezone: America/Los_Angeles + open-pull-requests-limit: 0 + commit-message: + prefix: "chore(deps-go)" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "09:30" + timezone: America/Los_Angeles + open-pull-requests-limit: 0 + commit-message: + prefix: "chore(actions)" diff --git a/.github/labels.json b/.github/labels.json new file mode 100644 index 00000000..7b5095b4 --- /dev/null +++ b/.github/labels.json @@ -0,0 +1,18 @@ +[ + { "name": "proposal", "color": "5319e7", "description": "A product or architecture decision that needs review" }, + { "name": "triaged", "color": "0e8a16", "description": "Scope and next action have been confirmed" }, + { "name": "needs-info", "color": "fbca04", "description": "More information is required before work can continue" }, + { "name": "prio:p0", "color": "b60205", "description": "Critical: release, security, or severe user impact" }, + { "name": "prio:p1", "color": "d93f0b", "description": "High: significant impact and near-term work" }, + { "name": "prio:p2", "color": "fbca04", "description": "Medium: important but not urgent" }, + { "name": "prio:p3", "color": "0e8a16", "description": "Low: minor impact or backlog" }, + { "name": "platform:macos", "color": "e99695", "description": "macOS-specific behavior" }, + { "name": "platform:linux", "color": "e99695", "description": "Linux-specific behavior" }, + { "name": "platform:android", "color": "e99695", "description": "Android-specific behavior" }, + { "name": "area:desktop", "color": "0052cc", "description": "Electron desktop application" }, + { "name": "area:host", "color": "0052cc", "description": "Host service, protocol, or OMP integration" }, + { "name": "area:cluster", "color": "0052cc", "description": "Cluster operator or multi-host orchestration" }, + { "name": "area:release", "color": "0052cc", "description": "Packaging, signing, publication, or updates" }, + { "name": "dependencies", "color": "0366d6", "description": "Dependency and supply-chain updates" }, + { "name": "security", "color": "b60205", "description": "Security-sensitive work; do not disclose vulnerabilities publicly" } +] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6da34f12..ffccc34c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,9 +23,6 @@ jobs: official_omp_gate0: ${{ steps.classify.outputs.official_omp_gate0 }} tooling: ${{ steps.classify.outputs.tooling }} android_debug: ${{ steps.classify.outputs.android_debug }} - flutter: ${{ steps.classify.outputs.flutter }} - flutter_android: ${{ steps.classify.outputs.flutter_android }} - flutter_apple: ${{ steps.classify.outputs.flutter_apple }} steps: - name: Check out source and base history uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -57,6 +54,46 @@ jobs: [[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] git diff --name-only -z "$BASE_SHA...$HEAD_SHA" | node scripts/ci-paths.mjs >> "$GITHUB_OUTPUT" + t4-api-generation: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check out exact source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Install pinned pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 11.10.0 + + - name: Install pinned Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.13.1 + + - name: Install dependencies from the committed lockfile + run: pnpm install --frozen-lockfile + + - name: Validate OpenAPI and deterministic generation + run: | + pnpm --filter @t4-code/t4-api-contract validate + pnpm --filter @t4-code/t4-api-contract generate:ci + pnpm --filter @t4-code/t4-api-contract check:generated + pnpm --filter @t4-code/t4-api-client typecheck + pnpm --filter @t4-code/client typecheck + pnpm --filter @t4-code/client exec vp test run test/t4-api-v1-conformance.test.ts + - name: Upload deterministic T4 API client + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: t4-api-generated-${{ github.run_id }} + path: artifacts/t4-api/generated/schema.ts + if-no-files-found: error + + + core: runs-on: ubuntu-24.04 timeout-minutes: 25 @@ -87,13 +124,13 @@ jobs: run: pnpm exec playwright install --with-deps chromium - name: Check source and types - run: pnpm check:release && pnpm check:provenance && pnpm lint && pnpm --filter '!@t4-code/flutter' -r typecheck + run: pnpm check - name: Run tests - run: pnpm --filter '!@t4-code/flutter' -r test + run: pnpm test - name: Build all workspaces - run: pnpm --filter '!@t4-code/flutter' -r build + run: pnpm build - name: Run built-app end-to-end tests run: pnpm test:e2e @@ -139,11 +176,11 @@ jobs: shell: bash run: | set -euo pipefail - source_repository="$(jq -er '.sourceRepository' provenance/omp-host-migration.json)" - test "$source_repository" = "https://github.com/lyc-aon/oh-my-pi" + source_repository="$(jq -er '.verifiedRuntime.sourceRepository' compat/omp-app-matrix.json)" + test "$source_repository" = "https://github.com/wolfiesch/oh-my-pi" sha="$(jq -er '.inputs.operationsContinuity' provenance/omp-host-migration.json)" [[ "$sha" =~ ^[0-9a-f]{40}$ ]] - echo "repository=lyc-aon/oh-my-pi" >> "$GITHUB_OUTPUT" + echo "repository=wolfiesch/oh-my-pi" >> "$GITHUB_OUTPUT" echo "sha=$sha" >> "$GITHUB_OUTPUT" - name: Check out pinned OMP authority source @@ -175,6 +212,81 @@ jobs: if-no-files-found: error retention-days: 14 + current-bridge-continuity: + needs: changes + if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.continuity == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Check out exact T4 source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Install pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 11.10.0 + + - name: Install Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.13.1 + cache: pnpm + + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + + - name: Install T4 dependencies + run: pnpm install --frozen-lockfile + + - name: Resolve current OMP authority source + id: current-authority + shell: bash + run: | + set -euo pipefail + source_repository="$(jq -er '.verifiedRuntime.sourceRepository' compat/omp-app-matrix.json)" + test "$source_repository" = "https://github.com/wolfiesch/oh-my-pi" + sha="$(jq -er '.verifiedRuntime.sourceCommit' compat/omp-app-matrix.json)" + [[ "$sha" =~ ^[0-9a-f]{40}$ ]] + echo "repository=wolfiesch/oh-my-pi" >> "$GITHUB_OUTPUT" + echo "sha=$sha" >> "$GITHUB_OUTPUT" + + - name: Check out current OMP authority source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: ${{ steps.current-authority.outputs.repository }} + ref: ${{ steps.current-authority.outputs.sha }} + path: .current-continuity/omp + + - name: Install current OMP authority dependencies + working-directory: .current-continuity/omp + run: bun install --frozen-lockfile + + - name: Build current OMP native addon + working-directory: .current-continuity/omp + run: bun run build:native + + - name: Run current OMP authority source tests + working-directory: .current-continuity/omp + run: bun test packages/coding-agent/test/appserver-bridge.test.ts packages/coding-agent/test/appserver-session-lifecycle.test.ts + + - name: Run current bridge compatibility proof + env: + T4_CURRENT_OMP_SOURCE_DIR: ${{ github.workspace }}/.current-continuity/omp + run: pnpm --filter @t4-code/host-service verify:current-omp-bridge + + - name: Upload current bridge evidence + if: ${{ success() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: current-omp-bridge-${{ github.run_id }} + path: artifacts/current-omp-bridge/ + if-no-files-found: error + retention-days: 14 + official-omp-gate0: needs: changes if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.official_omp_gate0 == 'true' }} @@ -300,6 +412,8 @@ jobs: - name: Run operator tests working-directory: packages/cluster-operator + env: + GOFLAGS: -mod=readonly run: go test ./... - name: Lint cluster chart @@ -373,161 +487,10 @@ jobs: - name: Verify unsigned Android debug application run: pnpm --filter @t4-code/mobile check:android:debug - flutter: - needs: changes - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.flutter == 'true' }} - runs-on: ubuntu-24.04 - timeout-minutes: 30 - steps: - - name: Check out source - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - with: - version: 11.10.0 - - - name: Install Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 24.13.1 - cache: pnpm - - - name: Install Flutter - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 - with: - flutter-version: 3.44.6 - channel: stable - cache: true - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Check Flutter source - run: pnpm check:flutter - - - name: Run Flutter tests with coverage gate - run: pnpm --filter @t4-code/flutter test:coverage - - - name: Build Flutter web application - run: pnpm build:flutter:web - - flutter-android: - needs: changes - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.flutter_android == 'true' }} - runs-on: ubuntu-24.04 - timeout-minutes: 35 - steps: - - name: Check out source - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install Java - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 - with: - distribution: temurin - java-version: "21" - cache: gradle - - - name: Install Flutter - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 - with: - flutter-version: 3.44.6 - channel: stable - cache: true - - - name: Build Flutter Android application - working-directory: apps/flutter - run: flutter build apk --debug - - - name: Run Flutter Android native tests - working-directory: apps/flutter/android - run: ./gradlew app:testDebugUnitTest - - - name: Enable Android emulator acceleration - run: sudo chmod 666 /dev/kvm - - - name: Run Flutter Android device smoke test - uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2 - with: - api-level: 35 - arch: x86_64 - profile: pixel_6 - script: cd apps/flutter && flutter test integration_test/app_smoke_test.dart -d emulator-5554 - - flutter-apple: - needs: changes - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.flutter_apple == 'true' }} - runs-on: macos-15 - timeout-minutes: 40 - steps: - - name: Check out source - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install Flutter - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 - with: - flutter-version: 3.44.6 - channel: stable - cache: true - - - name: Build Flutter iOS simulator application - working-directory: apps/flutter - run: flutter build ios --simulator --debug - - - name: Run Flutter iOS launch smoke test - working-directory: apps/flutter - shell: bash - run: | - set -euo pipefail - DEVICE_ID="$(xcrun simctl list devices available -j | python3 -c 'import json,sys; data=json.load(sys.stdin); print(next(device["udid"] for devices in data["devices"].values() for device in devices if device["name"].startswith("iPhone")))')" - BUNDLE_ID="com.lycaonsolutions.t4code" - xcrun simctl boot "$DEVICE_ID" 2>/dev/null || true - xcrun simctl bootstatus "$DEVICE_ID" -b - xcrun simctl install "$DEVICE_ID" build/ios/iphonesimulator/Runner.app - trap 'xcrun simctl terminate "$DEVICE_ID" "$BUNDLE_ID" 2>/dev/null || true' EXIT - launch_output="$(xcrun simctl launch --terminate-running-process "$DEVICE_ID" "$BUNDLE_ID")" - app_pid="$(sed -nE 's/^.*: ([0-9]+)$/\1/p' <<<"$launch_output" | tail -n 1)" - [[ "$app_pid" =~ ^[0-9]+$ ]] - sleep 5 - kill -0 "$app_pid" - - - name: Install pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - with: - version: 11.10.0 - - - name: Install Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 24.13.1 - cache: pnpm - - - name: Install Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.3.14 - - - name: Install T4 dependencies - run: pnpm install --frozen-lockfile - - - name: Build standalone T4 host for Flutter macOS - run: pnpm build:host - - - name: Build Flutter macOS application - working-directory: apps/flutter - run: flutter build macos --debug - - - name: Verify bundled Flutter macOS host - run: test -x apps/flutter/build/macos/Build/Products/Debug/t4code.app/Contents/Resources/runtime/t4-host - - - name: Run Flutter macOS native tests - working-directory: apps/flutter/macos - run: xcodebuild test -workspace Runner.xcworkspace -scheme Runner -configuration Debug -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO - verify: name: verify if: ${{ always() }} - needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple] + needs: [changes, t4-api-generation, core, legacy-bridge-continuity, current-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug] runs-on: ubuntu-24.04 timeout-minutes: 5 steps: @@ -535,28 +498,26 @@ jobs: shell: bash env: CHANGES_RESULT: ${{ needs.changes.result }} + T4_API_GENERATION_RESULT: ${{ needs.t4-api-generation.result }} CORE_RESULT: ${{ needs.core.result }} CONTINUITY_RESULT: ${{ needs.legacy-bridge-continuity.result }} + CURRENT_CONTINUITY_RESULT: ${{ needs.current-bridge-continuity.result }} OFFICIAL_OMP_GATE0_RESULT: ${{ needs.official-omp-gate0.result }} CLUSTER_RESULT: ${{ needs.cluster.result }} TOOLING_RESULT: ${{ needs.tooling.result }} ANDROID_RESULT: ${{ needs.android-debug.result }} - FLUTTER_RESULT: ${{ needs.flutter.result }} - FLUTTER_ANDROID_RESULT: ${{ needs.flutter-android.result }} - FLUTTER_APPLE_RESULT: ${{ needs.flutter-apple.result }} run: | set -euo pipefail test "$CHANGES_RESULT" = success + test "$T4_API_GENERATION_RESULT" = success test "$CORE_RESULT" = success for result in \ "$CONTINUITY_RESULT" \ + "$CURRENT_CONTINUITY_RESULT" \ "$OFFICIAL_OMP_GATE0_RESULT" \ "$CLUSTER_RESULT" \ "$TOOLING_RESULT" \ - "$ANDROID_RESULT" \ - "$FLUTTER_RESULT" \ - "$FLUTTER_ANDROID_RESULT" \ - "$FLUTTER_APPLE_RESULT" + "$ANDROID_RESULT" do case "$result" in success|skipped) ;; diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 61184d2b..f294bfa1 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -6,7 +6,7 @@ on: branches: [main, master] paths: - "apps/site/**" - - "apps/flutter/**" + - "apps/web/**" - "packages/**" - "scripts/check-release-publication.mjs" - "scripts/build-demo.mjs" @@ -65,44 +65,25 @@ jobs: with: node-version: 24.13.1 - - name: Install Flutter - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 - with: - flutter-version: 3.44.6 - channel: stable - cache: true - - name: Install dependencies run: pnpm install --frozen-lockfile - name: Authenticate to AWS with GitHub OIDC + if: ${{ vars.AWS_ROLE_ARN != '' }} uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4 with: role-to-assume: ${{ vars.AWS_ROLE_ARN }} aws-region: us-east-1 - - name: Check whether the live demo permits the Flutter renderer - id: demo_csp - shell: bash - run: | - set -euo pipefail - headers=$(curl --fail --silent --show-error --head https://t4code.net/demo) - if printf '%s\n' "$headers" \ - | tr -d '\r' \ - | grep -i '^content-security-policy:' \ - | grep -Fq "'wasm-unsafe-eval'"; then - echo "ready=true" >> "$GITHUB_OUTPUT" - else - echo "ready=false" >> "$GITHUB_OUTPUT" - echo "::warning::Flutter demo deployment is deferred until the demo CloudFront CSP permits wasm-unsafe-eval." - fi - - - name: Defer Flutter demo until its response policy is active - if: ${{ steps.demo_csp.outputs.ready != 'true' }} - run: echo "Apply infra/site/cloudformation.yml, then rerun this workflow from a main push." + - name: Authenticate to AWS with deployment credentials + if: ${{ vars.AWS_ROLE_ARN == '' }} + uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 - - name: Build and deploy current Flutter demo - if: ${{ steps.demo_csp.outputs.ready == 'true' }} + - name: Build and deploy current React demo env: T4_SITE_BUCKET: ${{ vars.T4_SITE_BUCKET }} T4_CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.T4_CLOUDFRONT_DISTRIBUTION_ID }} @@ -229,12 +210,20 @@ jobs: run: pnpm install --frozen-lockfile - name: Authenticate to AWS with GitHub OIDC - if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} + if: ${{ (steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success') && vars.AWS_ROLE_ARN != '' }} uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4 with: role-to-assume: ${{ vars.AWS_ROLE_ARN }} aws-region: us-east-1 + - name: Authenticate to AWS with deployment credentials + if: ${{ (steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success') && vars.AWS_ROLE_ARN == '' }} + uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + - name: Build and deploy immutable release site if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} env: diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml new file mode 100644 index 00000000..16ac101c --- /dev/null +++ b/.github/workflows/sync-labels.yml @@ -0,0 +1,44 @@ +name: Sync repository labels + +on: + push: + branches: [main] + paths: + - ".github/labels.json" + - ".github/workflows/sync-labels.yml" + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: sync-repository-labels + cancel-in-progress: true + +jobs: + sync: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Check out trusted label configuration + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Create or update declared labels + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + jq -c '.[]' .github/labels.json | while IFS= read -r label; do + name=$(jq -r '.name' <<<"$label") + color=$(jq -r '.color' <<<"$label") + description=$(jq -r '.description' <<<"$label") + gh label create "$name" \ + --repo "$GITHUB_REPOSITORY" \ + --color "$color" \ + --description "$description" \ + --force + done diff --git a/.gitignore b/.gitignore index 1e0549d8..625fe26a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,9 @@ dist-electron/ # Test and runtime output /artifacts/legacy-bridge-continuity/ /artifacts/official-omp-gate0/ +/artifacts/current-omp-bridge/ /.continuity/ +/.current-continuity/ /evidence/wire/ coverage/ playwright-report/ diff --git a/.woodpecker.yml b/.woodpecker.yml index a96101ac..35ecf692 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -31,6 +31,32 @@ steps: memory: 2Gi ephemeral-storage: 4Gi + t4-api-contract: + depends_on: [dependencies] + image: mirror.gcr.io/library/node:24.13.1-bookworm@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 + commands: + - corepack enable + - pnpm --filter @t4-code/t4-api-contract validate + - pnpm --filter @t4-code/t4-api-contract generate:ci + - test -f artifacts/t4-api/generated/schema.ts + - echo "T4_API_GENERATED_ARTIFACT=artifacts/t4-api/generated/schema.ts" + - pnpm --filter @t4-code/t4-api-contract check:generated + - pnpm --filter @t4-code/t4-api-client typecheck + - pnpm --filter @t4-code/client typecheck + - pnpm --filter @t4-code/client exec vp test run test/t4-api-v1-conformance.test.ts + backend_options: + kubernetes: + serviceAccountName: woodpecker-ci-untrusted + resources: + requests: + cpu: "0" + memory: 256Mi + ephemeral-storage: 256Mi + limits: + cpu: "1" + memory: 2Gi + ephemeral-storage: 2Gi + bun-runtime: depends_on: [dependencies] image: mirror.gcr.io/oven/bun:1.3.14@sha256:e10577f0db68676a7024391c6e5cb4b879ebd17188ab750cf10024a6d700e5c4 @@ -50,14 +76,14 @@ steps: ephemeral-storage: 256Mi upstream-core: - depends_on: [cluster-ci-contracts, bun-runtime] + depends_on: [cluster-ci-contracts, t4-api-contract, bun-runtime] image: mirror.gcr.io/library/node:24.13.1-bookworm@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 commands: - export PATH="$PWD/.ci:$PATH" - corepack enable - - pnpm check:release && pnpm check:provenance && pnpm lint && pnpm --filter '!@t4-code/flutter' -r typecheck - - VP_RUN_CONCURRENCY_LIMIT=1 pnpm --filter '!@t4-code/flutter' -r test - - pnpm --filter '!@t4-code/flutter' -r build + - pnpm check + - VP_RUN_CONCURRENCY_LIMIT=1 pnpm test + - pnpm build - pnpm exec playwright install --with-deps chromium - pnpm test:e2e - pnpm test:packaging @@ -99,7 +125,7 @@ steps: image: mirror.gcr.io/alpine/git:2.49.1@sha256:c0280cf9572316299b08544065d3bf35db65043d5e3963982ec50647d2746e26 commands: - git init .continuity/omp - - git -C .continuity/omp remote add origin https://github.com/lyc-aon/oh-my-pi.git + - git -C .continuity/omp remote add origin https://github.com/wolfiesch/oh-my-pi.git - git -C .continuity/omp fetch --depth=1 origin 8476f4451ed95c5d5401785d279a93d3c659fac4 - git -C .continuity/omp checkout --detach FETCH_HEAD - test "$(git -C .continuity/omp rev-parse HEAD)" = 8476f4451ed95c5d5401785d279a93d3c659fac4 @@ -161,8 +187,77 @@ steps: memory: 2Gi ephemeral-storage: 2Gi + current-authority-source: + depends_on: [upstream-tooling] + image: mirror.gcr.io/alpine/git:2.49.1@sha256:c0280cf9572316299b08544065d3bf35db65043d5e3963982ec50647d2746e26 + commands: + - git init .current-continuity/omp + - git -C .current-continuity/omp remote add origin https://github.com/wolfiesch/oh-my-pi.git + - git -C .current-continuity/omp fetch --depth=1 origin fc0c391334c08ab260057756aa84bd2b07741ee7 + - git -C .current-continuity/omp checkout --detach FETCH_HEAD + - test "$(git -C .current-continuity/omp rev-parse HEAD)" = fc0c391334c08ab260057756aa84bd2b07741ee7 + backend_options: + kubernetes: + serviceAccountName: woodpecker-ci-untrusted + resources: + requests: + cpu: "0" + memory: 128Mi + ephemeral-storage: 256Mi + limits: + cpu: 500m + memory: 512Mi + ephemeral-storage: 1Gi + + current-authority-build: + depends_on: [current-authority-source, bun-runtime] + image: docker.io/library/rust:1.86-slim-bookworm@sha256:57d415bbd61ce11e2d5f73de068103c7bd9f3188dc132c97cef4a8f62989e944 + environment: + RUSTFLAGS: "-C link-arg=-fuse-ld=lld" + CARGO_TARGET_DIR: /tmp/t4-current-authority-target + CARGO_INCREMENTAL: "0" + commands: + - sh scripts/cluster-ci/install-legacy-authority-toolchain.sh + - (cd .current-continuity/omp && bun install --frozen-lockfile) + - (cd .current-continuity/omp && bun run build:native) + - (cd .current-continuity/omp && bun test packages/coding-agent/test/appserver-bridge.test.ts packages/coding-agent/test/appserver-session-lifecycle.test.ts) + backend_options: + kubernetes: + serviceAccountName: woodpecker-ci-untrusted + resources: + requests: + cpu: "0" + memory: 512Mi + ephemeral-storage: 1Gi + limits: + cpu: "2" + memory: 6Gi + ephemeral-storage: 12Gi + + current-bridge-continuity: + depends_on: [current-authority-build] + image: mirror.gcr.io/library/node:24.13.1-bookworm@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 + environment: + T4_CURRENT_OMP_SOURCE_DIR: .current-continuity/omp + commands: + - export PATH="$PWD/.ci:$PATH" + - corepack enable + - pnpm --filter @t4-code/host-service verify:current-omp-bridge + backend_options: + kubernetes: + serviceAccountName: woodpecker-ci-untrusted + resources: + requests: + cpu: "0" + memory: 512Mi + ephemeral-storage: 512Mi + limits: + cpu: "1" + memory: 2Gi + ephemeral-storage: 2Gi + android-debug: - depends_on: [legacy-bridge-continuity] + depends_on: [legacy-bridge-continuity, current-bridge-continuity] image: cimg/android:2026.03.1-node@sha256:b62bbea69ba6bb28fe165ff913c23bde5220d0305fed7247121fb2919ecffd0e commands: - corepack enable @@ -252,9 +347,9 @@ steps: image: mirror.gcr.io/library/golang:1.25.1-bookworm@sha256:c423747fbd96fd8f0b1102d947f51f9b266060217478e5f9bf86f145969562ee directory: packages/cluster-operator commands: - - GOMAXPROCS=1 GOFLAGS=-p=1 go test ./api/... ./controllers/... ./cmd/... + - GOMAXPROCS=1 GOFLAGS='-mod=readonly -p=1' go test ./api/... ./controllers/... ./cmd/... - mkdir -p ../../artifacts/cluster-proof - - CGO_ENABLED=0 GOMAXPROCS=1 GOFLAGS=-p=1 go test -c ./charttests -o ../../artifacts/cluster-proof/chart-contract.test + - CGO_ENABLED=0 GOMAXPROCS=1 GOFLAGS='-mod=readonly -p=1' go test -c ./charttests -o ../../artifacts/cluster-proof/chart-contract.test backend_options: kubernetes: serviceAccountName: woodpecker-ci-untrusted diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3fbaea29..bf03a8ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to T4 Code +# Contributing to Omperator Thanks for helping. This page covers setup, checks, and what a good report or PR looks like. @@ -7,8 +7,8 @@ Thanks for helping. This page covers setup, checks, and what a good report or PR Needs Node `^24.13.1` and pnpm `11.10.0`. ```sh -git clone https://github.com/LycaonLLC/t4-code.git -cd t4-code +git clone https://github.com/wolfiesch/omperator.git +cd omperator pnpm install --frozen-lockfile node scripts/t4-doctor.mjs pnpm dev @@ -50,7 +50,7 @@ We will close reports containing live secrets and ask you to rotate them. ## Reporting bugs -Use the [bug report template](https://github.com/LycaonLLC/t4-code/issues/new/choose). Version, OS, install method, and steps to reproduce matter more than prose. +Use the [bug report template](https://github.com/wolfiesch/omperator/issues/new/choose). Version, OS, install method, and steps to reproduce matter more than prose. **Security problems are different: never open a public issue.** See [SECURITY.md](SECURITY.md). diff --git a/FEATURE_MATRIX.md b/FEATURE_MATRIX.md index e445c24d..70ad8669 100644 --- a/FEATURE_MATRIX.md +++ b/FEATURE_MATRIX.md @@ -42,7 +42,7 @@ OMP authority: `packages/coding-agent/src/session/agent-session.ts`, `session-ma | List/recent/search/filter | session store and metadata | Codex-parity left rail: By project or In one list; Priority, Last updated, or Manual order; real project/session dragging with keyboard fallbacks; title/project/host search; attention/running/unread/error filters; pinned shortcuts; five-row Show more; project aliases; reversible hidden projects; bulk read/archive; local-only Finder reveal; direct pin/archive controls | `Rail.tsx`, `session-tree.ts`, workspace store, management helpers, browser tests | Launch | | New session | `/new` | Create in selected project/host; model/profile defaults visible before first prompt | draft routes and composer draft store | Launch | | Fast switch and tabs | session IDs and snapshots | One-click/keyboard switch; preserve draft, scroll anchor, panel widths/tabs, terminal focus; no white flash | T3 routes, `composerDraftStore`, `rightPanelStore`, terminal store | Launch | -| Tail-first transcript history | Bounded `transcript.page` range reads plus the existing live attach cursor | Paint a small newest page on cold open; prepend older pages without moving the reading anchor or live cursor | T4-owned host, web client, and thin OMP bridge implemented; Flutter local cache planned | Launch | +| Tail-first transcript history | Bounded `transcript.page` range reads plus the existing live attach cursor | Paint a small newest page on cold open; prepend older pages without moving the reading anchor or live cursor | T4-owned host, React client, and thin OMP bridge implemented; offline display cache remains planned | Launch | | Resume | `/resume` | Open existing session by stable ID/path; recover moved/missing files with explicit error | thread routing and reconnect supervisor | Launch | | Rename | `/rename` | Inline rename with optimistic state and rollback | sidebar row actions | Launch | | Move working directory/session | `/move` | Native/remote path picker, validation, explicit impact message | environment picker patterns | Parity | diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 00000000..71825d60 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,53 @@ +# Governance + +Omperator is a maintainer-led project with one accountable product owner. The +goal is fast collaboration without reopening settled product direction in every +pull request. + +## Roles + +- **Product owner / BDFL:** `@wolfiesch` owns product direction, architecture, + repository access, releases, and final decisions when consensus does not form. +- **Maintainers:** trusted collaborators may triage issues, review and merge + ordinary pull requests, and own defined subsystems. Maintainer access does not + include changing security, signing, release, or governance boundaries without + product-owner approval. +- **Contributors:** anyone may open an issue or a pull request. Merge access is + earned through sustained, trustworthy work rather than a single contribution. + +## Product baseline + +- Electron and React are the production client until a replacement reaches + explicit feature, reliability, packaging, and release parity. +- Native Swift work may proceed as an isolated experiment against stable host + contracts. It does not replace the production client by default. +- OMP remains the runtime authority. Omperator owns the workspace, host, + orchestration, client, and release experience around that runtime boundary. + +Changing one of these baselines requires a `proposal` issue, a written decision, +and product-owner approval. + +## Change and merge flow + +1. Work happens on a branch and lands through a pull request. +2. Each pull request represents one coherent, independently explainable outcome. +3. The `verify` CI check must pass and review conversations must be resolved. +4. Every change requires one approval from someone other than its author. + CODEOWNERS route sensitive paths to the product owner, but GitHub does not + require a CODEOWNER approval because that would deadlock product-owner-authored + changes. Product-owner authorship satisfies the policy approval for those + sensitive paths; the independent maintainer review still applies. +5. Direct pushes to `main` are reserved for the product owner during release or + emergency recovery. Any emergency change gets a follow-up issue or pull + request with the missing evidence. + +Maintainers may make routine implementation decisions. Cross-cutting product or +architecture changes use a `proposal` issue and, when durable, an ADR under +`docs/adr/`. The product owner makes the final call when needed. + +## Security and releases + +Security boundaries, signing identities, credentials, public deployment, +release publication, breaking compatibility, and irreversible data changes +require product-owner approval. Vulnerabilities must use private reporting as +described in [SECURITY.md](SECURITY.md). diff --git a/PRODUCT_BRIEF.md b/PRODUCT_BRIEF.md index 78db5438..cea936fc 100644 --- a/PRODUCT_BRIEF.md +++ b/PRODUCT_BRIEF.md @@ -2,7 +2,8 @@ ## Product -T4 Code is a Flutter desktop, mobile, and web workspace for official Oh My Pi (OMP). It makes +T4 Code is an Electron desktop workspace with a shared React compatibility client for official Oh +My Pi (OMP). It makes projects, concurrent sessions, live streaming, tools, terminal activity, task agents, reviews, files, settings, and local or remote execution easier to operate without reimplementing OMP behavior. @@ -17,8 +18,8 @@ settings, and local or remote execution easier to operate without reimplementing - Browser and app preview remains a focused workspace rather than a permanent sixth pane. - Keyboard shortcuts, Quick Open, workspace menus, and transcript links use one action registry so availability and behavior do not diverge. -- Quick Open searches through bounded authorized operations. Flutter never receives or chooses an - absolute path it does not already own. +- Quick Open searches through bounded authorized operations. The renderer never receives or + chooses an absolute path it does not already own. - The Universal Working Set lets a user deliberately stage exact material from a file preview, transcript message, review diff, selected terminal text, or browser accessibility snapshot. The user can inspect and remove each item before it joins one ordinary OMP prompt; it does not become @@ -28,7 +29,9 @@ settings, and local or remote execution easier to operate without reimplementing ## Product modes -T4 Code presents one client experience across four execution profiles: +T4 Code presents one desktop-first client experience across four execution profiles. The +responsive browser/PWA and React/Capacitor Android builds are compatibility clients for paired +hosts; they do not promise native desktop parity. - **T4 Local:** native execution on this macOS or Linux computer. - **Personal Hub:** a managed installation on one Linux machine. diff --git a/README.md b/README.md index 28f27aa1..c390cb9f 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,15 @@ T4 Code is a free, open-source (MIT) desktop app for [Oh My Pi](https://github.c ![T4 Code main window](docs/assets/t4-code-main.png) -[**Download v0.1.30**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.30) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source) +[**Download v0.1.31**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.31) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source) ## Requirements -T4 Code v0.1.30 packages its own standalone `t4-host` and needs the matching OMP build with the smaller authority bridge. +The installed Apple Silicon Mac app packages its own standalone `t4-host` and matching OMP authority runtime. Source builds, Linux hosts, and remote hosts need the matching OMP build with the smaller authority bridge. -T4 Code v0.1.30 was verified with OMP 17.0.5 built from [`8476f445`](https://github.com/lyc-aon/oh-my-pi/commit/8476f4451ed95c5d5401785d279a93d3c659fac4), tagged [`t4code-17.0.5-appserver-10`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-10). That integration is based on the official upstream [`v17.0.5`](https://github.com/can1357/oh-my-pi/tree/v17.0.5) tag at [`9fd6e971`](https://github.com/can1357/oh-my-pi/commit/9fd6e97113f5ed3a847e66d346970efdf8afcad9). It exposes the bounded `t4-omp-authority/1` bridge used by T4's standalone host and removes the old public OMP appserver launchers. It also includes bounded newest-first transcript paging, stale-owner recovery, privacy-safe project reveal, fast lazy session indexing, cross-session attention and transcript search, the negotiated browser-preview command surface, redacted Codex transport diagnostics, the versioned Agent View lifecycle contract, session-owned cancellation, lock-aware session observation, complete transcript reconciliation, the cooperative `/continue-in-t4` handoff, and deterministic session ordering. Fork CI verifies the exact upstream base, ancestry, release gates, and published binaries. The official upstream v17.0.5 tag has no `appserver` command, so it cannot host T4 Code. It also does not include the authority bridge needed by T4's standalone host. T4 Code vendors `@oh-my-pi/app-wire` 0.7.0 from integration commit [`796bb7dc`](https://github.com/lyc-aon/oh-my-pi/commit/796bb7dca45027bd4b7b94017cdf41ef214a11f2), source tree `0c195a01ba0bb98fbf4d4863aee59bf23a6e81b7`. +T4 Code v0.1.31 was verified with OMP 17.0.5 built from [`fc0c3913`](https://github.com/wolfiesch/oh-my-pi/commit/fc0c391334c08ab260057756aa84bd2b07741ee7), tagged [`t4code-17.0.5-appserver-13`](https://github.com/wolfiesch/oh-my-pi/tree/t4code-17.0.5-appserver-13). That integration is based on the official upstream [`v17.0.5`](https://github.com/can1357/oh-my-pi/tree/v17.0.5) tag at [`9fd6e971`](https://github.com/can1357/oh-my-pi/commit/9fd6e97113f5ed3a847e66d346970efdf8afcad9). It exposes the bounded `t4-omp-authority/1` bridge used by T4's standalone host and removes the old public OMP appserver launchers. It also includes snapshot-consistent paged authority inventories with explicit partial-state metadata, stale-lock lifecycle recovery, sparse authority-side session lists, atomic `xd://` mount publication, bounded newest-first transcript paging, stale-owner recovery, privacy-safe project reveal, fast lazy session indexing, cross-session attention and transcript search, the negotiated browser-preview command surface, redacted Codex transport diagnostics, the versioned Agent View lifecycle contract, session-owned cancellation, lock-aware session observation, complete transcript reconciliation, the cooperative `/continue-in-t4` handoff, and deterministic session ordering. Fork CI verifies the exact upstream base, ancestry, release gates, and published binaries. The official upstream v17.0.5 tag has no `appserver` command, so it cannot host T4 Code. It also does not include the authority bridge needed by T4's standalone host. T4 Code vendors `@oh-my-pi/app-wire` 0.7.0 from integration commit [`796bb7dc`](https://github.com/lyc-aon/oh-my-pi/commit/796bb7dca45027bd4b7b94017cdf41ef214a11f2), source tree `0c195a01ba0bb98fbf4d4863aee59bf23a6e81b7`. -T4 owns the client wire, generic host service, and standalone daemon in `@t4-code/host-wire`, `@t4-code/host-service`, and `@t4-code/host-daemon`. The frozen `@oh-my-pi/app-wire` 0.7.0 tarball remains only as compatibility evidence. The released package still launches `t4-host` against the strict `t4-omp-authority/1` bridge, while OMP owns session files, locks, agent execution, and takeover decisions. A separately pinned unmodified official OMP 17.0.6 now passes T4's direct RPC behavior gate on macOS ARM64; native Linux evidence and packaged cutover proof remain before that path replaces the released fallback. +T4 owns the client wire, generic host service, and standalone daemon in `@t4-code/host-wire`, `@t4-code/host-service`, and `@t4-code/host-daemon`. The frozen `@oh-my-pi/app-wire` 0.7.0 tarball remains only as compatibility evidence. The released package still launches `t4-host` against the strict `t4-omp-authority/1` bridge, while OMP owns session files, locks, agent execution, and takeover decisions. A separately pinned unmodified official OMP 17.0.9 now passes T4's direct RPC and packaged-host behavior gates on macOS ARM64 and Linux x64/ARM64; packaged desktop and managed-session cutover proofs remain before that path replaces the released fallback. | Platform | Arch | Package | | -------- | --------------------- | ----------------------------------------- | @@ -20,9 +20,10 @@ T4 owns the client wire, generic host service, and standalone daemon in `@t4-cod | Linux | x86_64 | `.deb`, AppImage | | macOS | Apple Silicon (arm64) | `.dmg`, `.zip` (**signed and notarized**) | -No Windows build and no Intel Mac build in v0.1.30. The iOS TestFlight build is coming soon. +No Windows build, Intel Mac build, or native iOS application is currently shipped. iPhone and iPad +access use the responsive Tailnet browser/PWA compatibility client. -## What changed in v0.1.30 +## What changed in v0.1.31 - The session rail now matches the Codex desktop organization model: search, activity filters, sort controls, collapsible projects, flat and grouped views, and persistent preferences. - Project menus can create sessions, open folders in the system file manager, collapse a group, or hide it from the rail. Hidden projects remain recoverable through the filter menu. @@ -52,7 +53,7 @@ No Windows build and no Intel Mac build in v0.1.30. The iOS TestFlight build is ### Android 1. On the Android phone, sign in to Tailscale with an account that can reach the T4 Code host. -2. Download [`T4-Code-0.1.30-android.apk`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.30/T4-Code-0.1.30-android.apk). +2. Download [`T4-Code-0.1.31-android.apk`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.31/T4-Code-0.1.31-android.apk). 3. If Android asks, allow your browser or file manager to install unknown apps, then install the APK. 4. Open T4 Code and enter the host's HTTPS Tailscale address, including its port. The app saves the address; you can add more hosts later and switch between them. @@ -61,8 +62,8 @@ The APK does not contain a host daemon or expose one to the public internet. It ### Linux (Debian/Ubuntu) ```sh -wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.30/T4-Code-0.1.30-linux-amd64.deb -sudo apt install ./T4-Code-0.1.30-linux-amd64.deb +wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.31/T4-Code-0.1.31-linux-amd64.deb +sudo apt install ./T4-Code-0.1.31-linux-amd64.deb ``` Use `apt install` rather than `dpkg -i` so system dependencies resolve automatically. @@ -70,16 +71,17 @@ Use `apt install` rather than `dpkg -i` so system dependencies resolve automatic ### Linux (AppImage) ```sh -wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.30/T4-Code-0.1.30-linux-x86_64.AppImage -chmod +x T4-Code-0.1.30-linux-x86_64.AppImage -./T4-Code-0.1.30-linux-x86_64.AppImage +wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.31/T4-Code-0.1.31-linux-x86_64.AppImage +chmod +x T4-Code-0.1.31-linux-x86_64.AppImage +./T4-Code-0.1.31-linux-x86_64.AppImage ``` ### macOS (Apple Silicon) -1. Download [`T4-Code-0.1.30-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.30/T4-Code-0.1.30-mac-arm64.dmg) (or [`T4-Code-0.1.30-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.30/T4-Code-0.1.30-mac-arm64.zip)). +1. Download [`T4-Code-0.1.31-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.31/T4-Code-0.1.31-mac-arm64.dmg) (or [`T4-Code-0.1.31-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.31/T4-Code-0.1.31-mac-arm64.zip)). 2. Drag `T4 Code.app` into `/Applications`. 3. Open T4 Code normally. The release workflow verifies the pinned publisher, hardened runtime, secure timestamp, Apple notarization, stapled ticket, and Gatekeeper acceptance before publication. +4. To start terminal or CMUX sessions that can hand off to T4 Code, open **Settings → Hosts** and choose **Install t4-omp**. This adds `~/.local/bin/t4-omp` and leaves any existing `omp` command unchanged. ## What the app does @@ -97,7 +99,7 @@ Some actions depend on what the host supports. When a host can't do something (s ## Local and paired hosts -**Local.** T4 Code looks for the `omp` executable via `$OMP_EXECUTABLE`, your `PATH`, and common install locations (`~/.local/bin`, `/usr/local/bin`, `/opt/omp/bin`, ...). It then manages one T4 host per OMP profile for you: a systemd user service on Linux, a launch agent on macOS. Named profiles under `~/.omp/profiles` appear as their own local hosts and can auto-start with the app. Host logs remain in the compatibility paths `~/.local/state/t4-code/appserver` (Linux) or `~/Library/Logs/T4 Code/appserver` (macOS); named profiles log under `profiles/` inside those directories. +**Local.** The installed Apple Silicon Mac app uses its bundled pinned OMP runtime. Its optional `t4-omp` terminal command points to that same runtime without replacing `omp`; sessions started with `t4-omp` support the cooperative `/continue-in-t4` handoff. Sessions from an OMP build without the compatible handoff signal remain readable but read-only. Source builds and other hosts discover OMP via `$OMP_EXECUTABLE`, `PATH`, and common install locations. T4 then manages one host per OMP profile: a systemd user service on Linux or a launch agent on macOS. Named profiles under `~/.omp/profiles` appear as their own local hosts and can auto-start with the app. Host logs remain in the compatibility paths `~/.local/state/t4-code/appserver` (Linux) or `~/Library/Logs/T4 Code/appserver` (macOS); named profiles log under `profiles/` inside those directories. **Paired.** Connect to an OMP host on another machine through a `t4-code://pair/...` link generated on that host. Device credentials are encrypted with your OS keychain (Electron `safeStorage`) before they touch disk. Dropped connections reconnect automatically with backoff, and any settings you had staged stay staged until the host confirms. @@ -105,9 +107,10 @@ Some actions depend on what the host supports. When a host can't do something (s ## First run -1. Install and start OMP on the machine you want to work on. -2. Launch T4 Code. On the same machine, it finds `omp` and offers to start the T4 host. For another machine, open the pairing link from that host. -3. Pick a project, pick or create a session, and start working. +1. Install and launch T4 Code. The Apple Silicon Mac app prepares its bundled OMP runtime and starts the local T4 host. +2. For terminal handoff, open **Settings → Hosts**, install `t4-omp`, and use that command instead of `omp` for new terminal or CMUX sessions. +3. For another machine, install the verified OMP integration there and open its pairing link. +4. Pick a project, pick or create a session, and start working. ## Build from source @@ -124,11 +127,7 @@ pnpm test:soak # headless 10k-history and 20-reconnect stress checks pnpm package:linux # .deb + AppImage into release/ pnpm package:mac:unsigned # unsigned macOS build (on a Mac) pnpm package:mac # maintainer-only signed and notarized macOS build -cd apps/flutter -flutter test # shared client, protocol, settings, and UI contracts -flutter build apk --debug -flutter build ios --simulator --debug -pnpm --dir ../.. build:flutter:macos +pnpm --filter @t4-code/mobile check:android:debug # React/Capacitor compatibility APK ``` Prefer Task as a Make alternative? Install [Task](https://taskfile.dev/), then run `task setup`, @@ -143,11 +142,11 @@ native release checks. ## Architecture ``` -apps/desktop Electron main process: window, local OMP discovery, - host lifecycle, pairing, credential storage -apps/flutter Canonical Android/iOS/macOS/web UI: responsive workspace, - secure credentials, lifecycle, updates, OMP service controls -apps/web Legacy React compatibility UI for the Electron client +apps/desktop Primary Electron shell: windows, local OMP discovery, + host lifecycle, pairing, credential storage, native surfaces +apps/web Canonical React renderer shared by Electron, Tailnet browser/PWA, + and the React/Capacitor Android compatibility client +apps/mobile Android compatibility wrapper and native secure-storage/update bridges packages/ client, protocol, host-wire, host-service, remote, service-manager, ui ``` diff --git a/SECURITY.md b/SECURITY.md index 09378b65..df238360 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,4 +1,4 @@ -# Security Policy +# Omperator Security Policy ## Supported versions @@ -11,7 +11,7 @@ **Do not open a public issue for a security problem.** -Report privately through **GitHub private vulnerability reporting**: [Report a vulnerability](https://github.com/LycaonLLC/t4-code/security/advisories/new). +Report privately through **GitHub private vulnerability reporting**: [Report a vulnerability](https://github.com/wolfiesch/omperator/security/advisories/new). Include what you can: @@ -24,7 +24,7 @@ We read every report and will reply to tell you what happens next. This is a sma ## Scope notes -- T4 Code is a desktop client. The OMP runtime is a separate project; runtime vulnerabilities belong at . +- Omperator is currently distributed through the existing T4 Code application identifiers while the product rebrand is completed. The OMP runtime is a separate project; runtime vulnerabilities belong at . - Pairing credentials are encrypted with the OS keychain via Electron `safeStorage`. Reports about credential handling, the pairing flow, or the `t4-code://` deep-link handler are especially welcome. -- The macOS v0.1.30 build is signed with Apple Developer ID and notarized by Apple. Reports of certificate, Team ID, hardened-runtime, timestamp, Gatekeeper, or stapled-ticket drift are security-relevant. +- Published macOS builds are signed with Apple Developer ID and notarized by Apple. Reports of certificate, Team ID, hardened-runtime, timestamp, Gatekeeper, or stapled-ticket drift are security-relevant. - Starting with v0.1.24, the release workflow requires the pinned Developer ID identity, hardened runtime, Apple notarization, a stapled ticket, and a successful Gatekeeper assessment before publishing macOS artifacts. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d9a4e9c6..7a9b3fb4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/desktop", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/desktop/src/browser-downloads.ts b/apps/desktop/src/browser-downloads.ts index 11c0adb2..cda897b9 100644 --- a/apps/desktop/src/browser-downloads.ts +++ b/apps/desktop/src/browser-downloads.ts @@ -1,5 +1,5 @@ import { app, type DownloadItem, type Session, type WebContents } from "electron"; -import { mkdir, link, readdir, unlink } from "node:fs/promises"; +import { link, readdir, unlink } from "node:fs/promises"; import { dirname, join } from "node:path"; import type { BrowserDownload, BrowserEvent, SurfaceId } from "@t4-code/protocol/browser-ipc"; @@ -13,18 +13,6 @@ export interface BrowserDownloadControllerOptions { readonly downloadsPath?: string; } -interface DownloadItemLike { - getURL(): string; - getSuggestedFilename(): string; - getMimeType?(): string; - getTotalBytes?(): number; - getReceivedBytes?(): number; - setSavePath(path: string): void; - cancel?(): void; - on(event: "updated", listener: (event: unknown, state: string) => void): this; - once(event: "done", listener: (event: unknown, state: string) => void): this; -} - interface Waiter { readonly resolve: (download: BrowserDownload | undefined) => void; readonly timer: NodeJS.Timeout; @@ -150,7 +138,7 @@ export class BrowserDownloadController { private readonly surfaces = new Map(); private readonly sessionListeners = new Map(); private readonly records = new Map(); - private readonly items = new Map(); + private readonly items = new Map(); private readonly waiters = new Map>(); private disposed = false; @@ -197,7 +185,7 @@ export class BrowserDownloadController { const item = this.items.get(downloadId); if (item === undefined) return false; try { - item.cancel?.(); + item.cancel(); } catch { // The terminal done event still performs cleanup and records failure. } @@ -238,37 +226,37 @@ export class BrowserDownloadController { event.preventDefault(); return; } - event.preventDefault(); - void this.startDownload(item as unknown as DownloadItemLike, attached.surfaceId); + this.startDownload(item, attached.surfaceId); } - private async startDownload(item: DownloadItemLike, surfaceId: SurfaceId): Promise { + private startDownload(item: DownloadItem, surfaceId: SurfaceId): void { const downloadId = crypto.randomUUID(); const url = item.getURL(); - const mimeType = item.getMimeType?.() || undefined; - const filename = safeDownloadFilename(item.getSuggestedFilename(), mimeType, url); - const totalBytes = boundedBytes(item.getTotalBytes?.()); + const mimeType = item.getMimeType() || undefined; + const filename = safeDownloadFilename(item.getFilename(), mimeType, url); + const totalBytes = boundedBytes(item.getTotalBytes()); const started: BrowserDownload = Object.freeze({ downloadId, surfaceId, state: "started", url, filename, ...(mimeType === undefined ? {} : { mimeType }), ...(totalBytes === undefined ? {} : { totalBytes }), receivedBytes: 0 }); this.records.set(downloadId, started); this.items.set(downloadId, item); - this.publish(started); try { - const downloadsPath = await this.resolveDownloadsPath(); - await mkdir(downloadsPath, { recursive: true }); + const downloadsPath = this.resolveDownloadsPath(); const temporaryPath = join(downloadsPath, `.t4-download-${downloadId}.part`); + // Electron only permits setSavePath from inside the synchronous + // will-download callback. Calling preventDefault cancels the item. item.setSavePath(temporaryPath); item.on("updated", (_event, state) => this.onUpdated(downloadId, state)); item.once("done", (_event, state) => void this.onDone(downloadId, state, temporaryPath)); + this.publish(started); if (this.disposed) this.cancel(downloadId); } catch (error) { - await this.failDownload(downloadId, error); + void this.failDownload(downloadId, error); } } private onUpdated(downloadId: string, _state: string): void { const current = this.records.get(downloadId); if (this.disposed || current === undefined || TERMINAL_STATES.has(current.state)) return; - const receivedBytes = boundedBytes(this.items.get(downloadId)?.getReceivedBytes?.(), current.receivedBytes ?? 0); + const receivedBytes = boundedBytes(this.items.get(downloadId)?.getReceivedBytes(), current.receivedBytes ?? 0); const next: BrowserDownload = Object.freeze({ ...current, state: "progress", ...(receivedBytes === undefined ? {} : { receivedBytes }) }); this.records.set(downloadId, next); this.publish(next); @@ -282,7 +270,7 @@ export class BrowserDownloadController { } const current = this.records.get(downloadId); if (current === undefined || TERMINAL_STATES.has(current.state)) return; - const receivedBytes = boundedBytes(this.items.get(downloadId)?.getReceivedBytes?.(), current.receivedBytes ?? 0); + const receivedBytes = boundedBytes(this.items.get(downloadId)?.getReceivedBytes(), current.receivedBytes ?? 0); if (state === "completed") { try { const savePath = await this.commitTemporaryFile(temporaryPath, current.filename); @@ -336,7 +324,7 @@ export class BrowserDownloadController { } } - private async resolveDownloadsPath(): Promise { + private resolveDownloadsPath(): string { if (this.downloadsPath !== undefined) return this.downloadsPath; try { return app.getPath("downloads"); @@ -373,7 +361,7 @@ export class BrowserDownloadController { } private async removeTemporaryFile(downloadId: string): Promise { - const directory = await this.resolveDownloadsPath(); + const directory = this.resolveDownloadsPath(); const prefix = `.t4-download-${downloadId}.part`; try { const names = await readdir(directory); diff --git a/apps/desktop/src/browser-profiles.ts b/apps/desktop/src/browser-profiles.ts index 00a98b1b..93e2d174 100644 --- a/apps/desktop/src/browser-profiles.ts +++ b/apps/desktop/src/browser-profiles.ts @@ -1,7 +1,7 @@ import ElectronStore from "electron-store"; import { createHash } from "node:crypto"; import { session as electronSession, type Session } from "electron"; -import type { BrowserProfile } from "@t4-code/protocol/browser-ipc"; +import { isBrowserOwnerSessionId, type BrowserProfile } from "@t4-code/protocol/browser-ipc"; export const BROWSER_ISOLATED_PROFILE_ID = "isolated-session" as const; export const BROWSER_ISOLATED_PARTITION = "browser-isolated-session" as const; @@ -107,7 +107,7 @@ function profilePartition(profileId: string): string { } function isolatedPartition(ownerSessionId: string): string { - if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(ownerSessionId)) { + if (!isBrowserOwnerSessionId(ownerSessionId)) { throw new Error("an owning OMP session is required for isolated browser state"); } // Partition names are observable in Electron diagnostics. Hash the durable diff --git a/apps/desktop/src/browser-runtime.ts b/apps/desktop/src/browser-runtime.ts index 93443980..7b04ea39 100644 --- a/apps/desktop/src/browser-runtime.ts +++ b/apps/desktop/src/browser-runtime.ts @@ -1,17 +1,18 @@ import { randomUUID } from "node:crypto"; import type { BrowserWindow, Session, WebContents } from "electron"; -import type { - BrowserCall, - BrowserCallResult, - BrowserErrorCode, - BrowserEvent, - BrowserJsonValue, - BrowserMethod, - BrowserProfile, - BrowserSurfaceState, - OwnerSessionId, - SurfaceHandle, - SurfaceId, +import { + isBrowserOwnerSessionId, + type BrowserCall, + type BrowserCallResult, + type BrowserErrorCode, + type BrowserEvent, + type BrowserJsonValue, + type BrowserMethod, + type BrowserProfile, + type BrowserSurfaceState, + type OwnerSessionId, + type SurfaceHandle, + type SurfaceId, } from "@t4-code/protocol/browser-ipc"; import type { BrowserSessionMetadata } from "./browser-session-store.ts"; import { BrowserProfileRegistry, type BrowserProfileCreateOptions } from "./browser-profiles.ts"; @@ -101,6 +102,63 @@ function protocolError(error: unknown, method: BrowserMethod, surfaceId?: Surfac const errorSurfaceId = typeof candidate.surfaceId === "string" ? candidate.surfaceId as SurfaceId : surfaceId; return new BrowserRuntimeError(code, message, method, errorSurfaceId); } + +function listedDownloads( + controller: DownloadControllerLike, + surfaceId: SurfaceId, +): readonly { readonly downloadId: string; readonly url?: string }[] { + const value = controller.list?.(surfaceId); + if (!Array.isArray(value)) return []; + return value.filter( + (entry): entry is { readonly downloadId: string; readonly url?: string } => + typeof entry === "object" && + entry !== null && + "downloadId" in entry && + typeof entry.downloadId === "string" && + (!("url" in entry) || entry.url === undefined || typeof entry.url === "string"), + ); +} + +function matchesRequestedDownload(downloadUrl: string | undefined, requestedUrl: string): boolean { + if (downloadUrl === undefined) return false; + try { + const download = new URL(downloadUrl); + const requested = new URL(requestedUrl); + // Electron may canonicalize or append a query before accepting the item. + // The origin and resource path still identify the navigation; an + // unrelated page-initiated download on the same surface does not. + return download.origin === requested.origin && download.pathname === requested.pathname; + } catch { + return false; + } +} + +async function acceptedDownloadAfterNavigationFailure( + controller: DownloadControllerLike, + surfaceId: SurfaceId, + existingDownloadIds: ReadonlySet, + requestedUrl: string, + timeoutMs = 250, +): Promise { + const deadline = Date.now() + timeoutMs; + while (true) { + if ( + listedDownloads(controller, surfaceId).some( + (download) => + !existingDownloadIds.has(download.downloadId) && + matchesRequestedDownload(download.url, requestedUrl), + ) + ) { + return true; + } + const remaining = deadline - Date.now(); + if (remaining <= 0) return false; + await new Promise((resolve) => + setTimeout(resolve, Math.min(10, remaining)), + ); + } +} + interface PrewarmEntry { readonly surface: BrowserSurface; readonly ownerSessionId: OwnerSessionId; @@ -144,10 +202,10 @@ function requestRecord(request: unknown): Record { } function ownerSessionIdFromCall(call: BrowserCall, method: BrowserMethod): OwnerSessionId { - if (typeof call.ownerSessionId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(call.ownerSessionId)) { + if (!isBrowserOwnerSessionId(call.ownerSessionId)) { throw new BrowserRuntimeError("invalid_params", "ownerSessionId is required", method); } - return call.ownerSessionId as OwnerSessionId; + return call.ownerSessionId; } /** Coordinates native surfaces so React chrome always has at most one attached tab. */ @@ -186,7 +244,9 @@ export class BrowserRuntime { this.userDataPath = options.userDataPath; this.profileRegistry = options.profileRegistry ?? new BrowserProfileRegistry({ userDataPath: options.userDataPath }); this.sessionStore = options.sessionStore ?? new BrowserSessionStore({ userDataPath: options.userDataPath }); - this.downloadController = options.downloadController ?? new BrowserDownloadController({ emit: options.emit }); + this.downloadController = + options.downloadController ?? + new BrowserDownloadController({ emit: (event) => this.emit(event) }); this.installSecurity = options.installSecurity ?? installBrowserSurfaceSecurity; this.allowFileUrls = options.allowFileUrls === true; this.prewarmTtlMs = Math.max(1_000, options.prewarmTtlMs ?? 30_000); @@ -717,8 +777,33 @@ export class BrowserRuntime { case "surface.navigate": { const surface = this.lookup(surfaceIdFromRequest(request), ownerSessionId, method); if (typeof request.url !== "string") throw new BrowserRuntimeError("invalid_params", "url is required", method, surface.surfaceId); - const result = await surface.navigate(request.url, booleanOption(request, "snapshotAfter")); - return result; + const previous = surface.state; + const existingDownloadIds = new Set( + listedDownloads(this.downloadController, surface.surfaceId).map( + (download) => download.downloadId, + ), + ); + try { + return await surface.navigate( + request.url, + booleanOption(request, "snapshotAfter"), + ); + } catch (error) { + const acceptedDownload = await acceptedDownloadAfterNavigationFailure( + this.downloadController, + surface.surfaceId, + existingDownloadIds, + request.url, + ); + if (!acceptedDownload) throw error; + const restored = surface.restoreAfterDownloadNavigation(previous); + return { + surface: restored, + ...(booleanOption(request, "snapshotAfter") + ? { snapshot: await surface.snapshot() } + : {}), + }; + } } case "surface.reload": return this.actionFor(ownerSessionId, method, request, (surface) => surface.reload(booleanOption(request, "snapshotAfter"))); case "surface.goBack": return this.actionFor(ownerSessionId, method, request, (surface) => surface.goBack(booleanOption(request, "snapshotAfter"))); diff --git a/apps/desktop/src/browser-session-store.ts b/apps/desktop/src/browser-session-store.ts index 581dc2d3..58035968 100644 --- a/apps/desktop/src/browser-session-store.ts +++ b/apps/desktop/src/browser-session-store.ts @@ -1,18 +1,21 @@ import ElectronStore from "electron-store"; -import type { BrowserProfile, SurfaceHandle, SurfaceId } from "@t4-code/protocol/browser-ipc"; +import { + isBrowserOwnerSessionId, + type BrowserProfile, + type SurfaceHandle, + type SurfaceId, +} from "@t4-code/protocol/browser-ipc"; export const BROWSER_SESSION_STORE_VERSION = 2 as const; export const MAX_BROWSER_SESSIONS = 64; const MAX_SURFACE_ID_BYTES = 64; const MAX_SURFACE_HANDLE_BYTES = 32; -const MAX_SESSION_ID_BYTES = 128; const MAX_URL_BYTES = 8_192; const MAX_ORDER = 100_000; const MIN_ZOOM = 0.25; const MAX_ZOOM = 5; const SURFACE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; const SURFACE_HANDLE_PATTERN = /^surface:[1-9][0-9]{0,8}$/u; -const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; const SENSITIVE_QUERY_KEY = /(?:token|secret|password|passwd|credential|authorization|auth|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|session|cookie|code)/iu; export interface BrowserSessionMetadata { @@ -96,8 +99,7 @@ function safeSurfaceHandle(value: unknown): SurfaceHandle | undefined { } function safeOwnerSessionId(value: unknown): string | undefined { - const result = boundedString(value, MAX_SESSION_ID_BYTES); - return result !== undefined && SESSION_ID_PATTERN.test(result) ? result : undefined; + return isBrowserOwnerSessionId(value) ? value : undefined; } function safeProfile(value: unknown): BrowserProfile | undefined { @@ -253,4 +255,4 @@ export class BrowserSessionStore { export function normalizeBrowserSessionMetadata(value: unknown): BrowserSessionMetadata | undefined { return normalizeRecord(value); -} \ No newline at end of file +} diff --git a/apps/desktop/src/browser-surface.ts b/apps/desktop/src/browser-surface.ts index c48bb002..76e7fdbe 100644 --- a/apps/desktop/src/browser-surface.ts +++ b/apps/desktop/src/browser-surface.ts @@ -539,6 +539,30 @@ export class BrowserSurface { return this.action(snapshotAfter); } + /** + * Electron rejects loadURL when a navigation becomes a download even though + * the previous document remains usable. Restore that document's projected + * state once the owning download controller confirms it accepted the item. + */ + restoreAfterDownloadNavigation(previous: BrowserSurfaceState): BrowserSurfaceState { + const contents = this.view?.webContents; + if (contents && !contents.isDestroyed()) { + this.contentReadyContents = contents; + this.contentReadyFailure = null; + } + this.update({ + url: previous.url, + title: previous.title, + lifecycle: previous.lifecycle, + readyState: previous.readyState, + loading: previous.loading, + progress: previous.progress, + canGoBack: previous.canGoBack, + canGoForward: previous.canGoForward, + }); + return this.state; + } + async reload(snapshotAfter = false): Promise { const contents = this.view?.webContents; this.contentReadyContents = null; diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index b6e9aff5..c39bf4cf 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -41,6 +41,7 @@ import { type ServiceActionResult, type ServiceAvailabilityIssue, type ServiceInspection, + type T4OmpLauncherState, type TargetAddRequest, type TargetListResult, type TargetRemoveResult, @@ -98,6 +99,11 @@ export interface IpcRuntime { readonly inspect: () => Promise; readonly configure: () => Promise; }; + readonly t4OmpLauncher?: { + readonly inspect: () => Promise; + readonly install: () => Promise; + readonly remove: () => Promise; + }; } export class RemotePairingUnavailableError extends Error { readonly code = "remote_pairing_unavailable" as const; @@ -315,6 +321,15 @@ export class DesktopIpcRegistry { return { completed: true }; }); } + for (const action of ["inspect", "install", "remove"] as const) { + this.ipc.handle(`app:t4-omp:${action}`, async (event, payload: unknown): Promise => { + this.assertSender(event); + decodeRequest(`app:t4-omp:${action}`, payload); + const launcher = this.runtime.t4OmpLauncher; + if (launcher === undefined) throw new Error("The t4-omp terminal launcher is unavailable in this build."); + return launcher[action](); + }); + } this.ipc.handle("app:update:get-state", (event, payload: unknown): DesktopUpdateState => { this.assertSender(event); decodeRequest("app:update:get-state", payload); @@ -387,6 +402,7 @@ export class DesktopIpcRegistry { "omp:profiles:status", "omp:profiles:start", "omp:profiles:stop", "omp:profiles:restart", "omp:service:inspect", "omp:service:install", "omp:service:start", "omp:service:stop", "omp:service:restart", "omp:service:uninstall", + "app:t4-omp:inspect", "app:t4-omp:install", "app:t4-omp:remove", "app:update:get-state", "app:update:check", "app:update:download", "app:update:restart", "app:update:renderer-ready", "app:projection-cache:load", "app:projection-cache:save", diff --git a/apps/desktop/src/lifecycle.ts b/apps/desktop/src/lifecycle.ts index 1aedbeab..bd117be5 100644 --- a/apps/desktop/src/lifecycle.ts +++ b/apps/desktop/src/lifecycle.ts @@ -42,6 +42,7 @@ import { LocalProfileRegistry } from "./local-profiles.ts"; import { LocalProfileRuntime } from "./profile-runtime.ts"; import { installBundledOmpRuntime } from "./bundled-runtime.ts"; import { PhoneSetupService } from "./phone-setup.ts"; +import { T4OmpLauncher } from "./t4-omp-launcher.ts"; type ProjectionCacheRuntime = NonNullable; @@ -145,6 +146,7 @@ export class DesktopLifecycle { private readonly serviceAvailabilityIssues = new Map(); private updateController: DesktopUpdateController | undefined; private phoneSetup: PhoneSetupService | undefined; + private t4OmpLauncher: T4OmpLauncher | undefined; private pendingUpdateOpen = false; private rendererLoaded = false; private updateRendererReady = false; @@ -259,6 +261,14 @@ export class DesktopLifecycle { ingest(value); }); await this.electronApp.whenReady(); + const applicationSupportPath = this.electronApp.getPath("userData"); + this.t4OmpLauncher = new T4OmpLauncher({ + supported: + this.electronApp.isPackaged && process.platform === "darwin" && process.arch === "arm64", + homeDirectory: homedir(), + runtimeRoot: join(applicationSupportPath, "runtime"), + resolveRuntime: () => this.discoverServiceExecutable(), + }); this.projectionCache = this.projectionCacheFactory(); if (process.platform === "darwin") this.electronApp.setAsDefaultProtocolClient("t4-code"); this.updateController = this.updateControllerFactory(); @@ -346,6 +356,7 @@ export class DesktopLifecycle { this.updateController?.dispose(); this.updateController = undefined; this.phoneSetup = undefined; + this.t4OmpLauncher = undefined; this.projectionCache = undefined; const manager = this.manager; this.manager = undefined; @@ -697,6 +708,7 @@ export class DesktopLifecycle { drainPendingUpdateOpen: () => this.markUpdateRendererReady(), ...(this.updateController === undefined ? {} : { updateController: this.updateController }), ...(this.phoneSetup === undefined ? {} : { phoneSetup: this.phoneSetup }), + ...(this.t4OmpLauncher === undefined ? {} : { t4OmpLauncher: this.t4OmpLauncher }), }); this.ipc.install(); } diff --git a/apps/desktop/src/phone-setup.ts b/apps/desktop/src/phone-setup.ts index b8eb8927..72d8f8ad 100644 --- a/apps/desktop/src/phone-setup.ts +++ b/apps/desktop/src/phone-setup.ts @@ -15,6 +15,26 @@ import type { PhoneSetupState } from "@t4-code/protocol/desktop-ipc"; const LOCAL_GATEWAY_PORT = 4_194; const TAILSCALE_HTTPS_PORT = 8_445; +function gatewayOriginFromStatus(output: string): string | undefined { + const match = /^allowed origin:\s*(\S+)\s*$/imu.exec(output); + if (match?.[1] === undefined) return undefined; + try { + const url = new URL(match[1]); + if ( + url.protocol !== "https:" + || url.username !== "" + || url.password !== "" + || url.pathname !== "/" + || url.search !== "" + || url.hash !== "" + || !url.hostname.endsWith(".ts.net") + ) return undefined; + return url.origin; + } catch { + return undefined; + } +} + export interface PhoneSetupServiceOptions { readonly platform?: NodeJS.Platform; readonly arch?: string; @@ -102,6 +122,32 @@ export class PhoneSetupService { }); } + private installGateway(origin: string, deploymentIdentity: string): Promise<{ exitCode: number | null; stdout: string; stderr: string }> { + return this.runGatewayService([ + "install", + "--origin", origin, + "--web-root", join(this.resourcesPath, "web"), + "--deployment-identity", deploymentIdentity, + "--electron-run-as-node", + ]); + } + + private async repairStaleGateway(deploymentIdentity: string): Promise { + let status: { exitCode: number | null; stdout: string; stderr: string }; + try { + status = await this.runGatewayService(["status", "--deployment-identity", deploymentIdentity]); + } catch { + return; + } + if (!/^deployment identity:\s*stale\s*$/imu.test(status.stdout)) return; + const origin = gatewayOriginFromStatus(status.stdout); + if (origin === undefined) return; + const install = await this.installGateway(origin, deploymentIdentity); + if (install.exitCode !== 0) { + throw new Error(install.stderr.trim().slice(0, 512) || "The private phone gateway could not be upgraded."); + } + } + private async gatewayIsHealthy(deploymentIdentity: string): Promise { try { const service = await this.runGatewayService(["status", "--deployment-identity", deploymentIdentity]); @@ -196,13 +242,7 @@ export class PhoneSetupService { return { phase: "tailscale-required", message: error instanceof Error ? error.message : "Tailscale is unavailable." }; } const deploymentIdentity = await this.identity(); - const service = await this.runGatewayService([ - "install", - "--origin", facts.url, - "--web-root", join(this.resourcesPath, "web"), - "--deployment-identity", deploymentIdentity, - "--electron-run-as-node", - ]); + const service = await this.installGateway(facts.url, deploymentIdentity); if (service.exitCode !== 0) { return { phase: "error", message: service.stderr.trim().slice(0, 512) || "The private phone gateway could not start." }; } @@ -231,6 +271,8 @@ export class PhoneSetupService { private async restoreInternal(): Promise { const unsupported = this.unsupported(); if (unsupported) return unsupported; + const deploymentIdentity = await this.identity(); + await this.repairStaleGateway(deploymentIdentity); let facts: { executable: string; url: string }; try { facts = await this.tailscaleFacts(); @@ -240,17 +282,10 @@ export class PhoneSetupService { if (!await this.hasExpectedServe(facts.executable, facts.url)) { return { phase: "not-configured", message: "Set up private phone access, then scan the QR code with your phone.", url: facts.url }; } - const deploymentIdentity = await this.identity(); if (await this.gatewayIsHealthy(deploymentIdentity)) { return { phase: "ready", message: "Phone access is ready on your private Tailscale network.", url: facts.url }; } - const service = await this.runGatewayService([ - "install", - "--origin", facts.url, - "--web-root", join(this.resourcesPath, "web"), - "--deployment-identity", deploymentIdentity, - "--electron-run-as-node", - ]); + const service = await this.installGateway(facts.url, deploymentIdentity); if (service.exitCode !== 0) { return { phase: "error", message: service.stderr.trim().slice(0, 512) || "The private phone gateway could not restart." }; } diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index f153866a..2e005c9a 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -6,6 +6,7 @@ import { decodeProjectionCacheLoadResult, decodeProjectionCacheSaveResult, decodePhoneSetupState, + decodeT4OmpLauncherState, type BootstrapResult, type CommandRequest, type CommandResult, @@ -32,6 +33,7 @@ import { type RuntimeErrorEvent, type ServiceActionResult, type ServiceInspection, + type T4OmpLauncherState, type TargetAddRequest, type TargetAddResult, type TargetListResult, @@ -73,6 +75,9 @@ export interface OmpShellBridge { readonly serviceStop: () => Promise; readonly serviceRestart: () => Promise; readonly serviceUninstall: () => Promise; + readonly inspectT4OmpLauncher: () => Promise; + readonly installT4OmpLauncher: () => Promise; + readonly removeT4OmpLauncher: () => Promise; readonly loadProjectionCache: () => Promise; readonly saveProjectionCache: (request: ProjectionCacheSaveRequest) => Promise; readonly getUpdateState: () => Promise; @@ -104,7 +109,7 @@ export interface OmpShellBridge { readonly onOpenUpdateSettings: (listener: (event: DesktopUpdateOpenEvent) => void) => () => void; } -function invoke(channel: C, payload: unknown): Promise { +function invoke(channel: C, payload: unknown): Promise { return ipcRenderer.invoke(channel, { channel, payload }) as Promise; } @@ -190,6 +195,9 @@ const bridge: OmpShellBridge = { serviceStop: () => invoke("omp:service:stop", {}), serviceRestart: () => invoke("omp:service:restart", {}), serviceUninstall: () => invoke("omp:service:uninstall", {}), + inspectT4OmpLauncher: () => invoke<"app:t4-omp:inspect", unknown>("app:t4-omp:inspect", {}).then(decodeT4OmpLauncherState), + installT4OmpLauncher: () => invoke<"app:t4-omp:install", unknown>("app:t4-omp:install", {}).then(decodeT4OmpLauncherState), + removeT4OmpLauncher: () => invoke<"app:t4-omp:remove", unknown>("app:t4-omp:remove", {}).then(decodeT4OmpLauncherState), getUpdateState: () => invoke<"app:update:get-state", unknown>("app:update:get-state", {}).then(decodeDesktopUpdateState), checkForUpdate: () => invoke<"app:update:check", unknown>("app:update:check", {}).then(decodeDesktopUpdateState), downloadUpdate: () => invoke<"app:update:download", unknown>("app:update:download", {}).then(decodeDesktopUpdateState), diff --git a/apps/desktop/src/t4-omp-launcher.ts b/apps/desktop/src/t4-omp-launcher.ts new file mode 100644 index 00000000..121711fd --- /dev/null +++ b/apps/desktop/src/t4-omp-launcher.ts @@ -0,0 +1,160 @@ +import { lstat, mkdir, readlink, symlink, unlink } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import type { T4OmpLauncherState } from "@t4-code/protocol/desktop-ipc"; + +const COMMAND = "t4-omp" as const; +const DISPLAY_PATH = "~/.local/bin/t4-omp" as const; + +export interface T4OmpLauncherOptions { + readonly supported: boolean; + readonly homeDirectory: string; + readonly runtimeRoot: string; + readonly resolveRuntime: () => Promise; + readonly launcherPath?: string; +} + +function state( + phase: T4OmpLauncherState["phase"], + message: string, +): T4OmpLauncherState { + return { phase, command: COMMAND, location: DISPLAY_PATH, message }; +} + +function pathInside(root: string, candidate: string): boolean { + const child = relative(resolve(root), resolve(candidate)); + return child !== "" && !child.startsWith("..") && !isAbsolute(child); +} + +async function exists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +/** + * Installs a side-by-side terminal entrypoint without changing `omp` or shell + * configuration. Only symlinks targeting T4's private runtime tree are ever + * replaced or removed. + */ +export class T4OmpLauncher { + private readonly supported: boolean; + private readonly runtimeRoot: string; + private readonly runtime: () => Promise; + private readonly launcherPath: string; + private readonly localDirectory: string; + + constructor(options: T4OmpLauncherOptions) { + this.supported = options.supported; + this.runtimeRoot = resolve(options.runtimeRoot); + this.runtime = options.resolveRuntime; + this.localDirectory = resolve(options.homeDirectory, ".local"); + this.launcherPath = resolve( + options.launcherPath ?? join(options.homeDirectory, ".local", "bin", COMMAND), + ); + } + + async inspect(): Promise { + if (!this.supported) { + return state("unsupported", "The t4-omp terminal launcher is available in the installed macOS app."); + } + const runtime = await this.currentRuntime(); + if (!(await exists(this.launcherPath))) { + return state( + "not-installed", + "Install t4-omp to start terminal sessions with the same OMP runtime as T4 Code.", + ); + } + const entry = await lstat(this.launcherPath); + if (!entry.isSymbolicLink()) { + return state("conflict", "A different file already uses ~/.local/bin/t4-omp. T4 Code left it untouched."); + } + const rawTarget = await readlink(this.launcherPath); + const target = resolve(dirname(this.launcherPath), rawTarget); + if (target === runtime) { + return state("installed", "t4-omp uses the same verified OMP runtime as T4 Code."); + } + if (this.isOwnedRuntimeTarget(target)) { + return state("update-available", "Update t4-omp to use this version of T4 Code's OMP runtime."); + } + return state("conflict", "A different command already owns ~/.local/bin/t4-omp. T4 Code left it untouched."); + } + + async install(): Promise { + if (!this.supported) throw new Error("The t4-omp terminal launcher is unavailable in this build."); + const runtime = await this.currentRuntime(); + const current = await this.inspect(); + if (current.phase === "conflict") throw new Error(current.message); + if (current.phase === "installed") return current; + + await this.prepareDirectory(); + if (current.phase === "update-available") await this.unlinkOwnedLink(); + // Creating the final link directly is intentionally no-clobber. If + // another process wins the name, this fails instead of overwriting it. + await symlink(runtime, this.launcherPath, "file"); + return this.inspect(); + } + + async remove(): Promise { + if (!this.supported) throw new Error("The t4-omp terminal launcher is unavailable in this build."); + const current = await this.inspect(); + if (current.phase === "conflict") throw new Error(current.message); + if (current.phase === "installed" || current.phase === "update-available") { + await this.unlinkOwnedLink(); + } + return state( + "not-installed", + "t4-omp is not installed. Your existing omp command was not changed.", + ); + } + + private async currentRuntime(): Promise { + const runtime = await this.runtime(); + if (runtime === undefined || !this.isOwnedRuntimeTarget(runtime)) { + throw new Error("T4 Code's bundled OMP runtime is unavailable."); + } + const info = await lstat(runtime); + if (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o111) === 0) { + throw new Error("T4 Code's bundled OMP runtime is not executable."); + } + return resolve(runtime); + } + + private isOwnedRuntimeTarget(candidate: string): boolean { + return pathInside(this.runtimeRoot, candidate) && candidate.endsWith("/omp"); + } + + private async prepareDirectory(): Promise { + const directory = dirname(this.launcherPath); + if (dirname(directory) !== this.localDirectory) { + throw new Error("The t4-omp installation path is outside ~/.local/bin."); + } + if (await exists(this.localDirectory)) { + const local = await lstat(this.localDirectory); + if (!local.isDirectory() || local.isSymbolicLink()) { + throw new Error("The ~/.local directory is not a safe installation directory."); + } + } else { + await mkdir(this.localDirectory, { mode: 0o755 }); + } + if (!(await exists(directory))) await mkdir(directory, { mode: 0o755 }); + const info = await lstat(directory); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error("The ~/.local/bin directory is not a safe installation directory."); + } + } + + private async unlinkOwnedLink(): Promise { + const info = await lstat(this.launcherPath); + if (!info.isSymbolicLink()) throw new Error("The t4-omp command changed before T4 Code could update it."); + const rawTarget = await readlink(this.launcherPath); + const target = resolve(dirname(this.launcherPath), rawTarget); + if (!this.isOwnedRuntimeTarget(target)) { + throw new Error("The t4-omp command changed before T4 Code could update it."); + } + await unlink(this.launcherPath); + } +} diff --git a/apps/desktop/src/target-manager.ts b/apps/desktop/src/target-manager.ts index ceb644dc..99a5239b 100644 --- a/apps/desktop/src/target-manager.ts +++ b/apps/desktop/src/target-manager.ts @@ -103,6 +103,7 @@ export class DesktopTargetManager { private readonly targetQueues = new Map }>(); private readonly runtimes = new Map(); private readonly localStates = new Map(); + private readonly publishedStates = new Map(); private closed = false; private readonly generations = new Map(); private readonly latestWelcomes = new Map< @@ -184,6 +185,7 @@ export class DesktopTargetManager { await this.closeRuntime(targetId); if (this.credentials !== undefined) await this.credentials.revoke(targetId); await enqueue(this.registryQueue, async () => { if (this.registry !== undefined) await this.registry.remove(targetId); }); + this.publishedStates.delete(targetId); this.targetQueues.delete(targetId); }); } @@ -406,7 +408,7 @@ export class DesktopTargetManager { capabilities: requestedCapabilities, requestedFeatures: this.requestedFeatures, compatibilityRequestedFeatures: this.compatibilityRequestedFeatures, - client: { name: "T4 Code", version: "0.1.30", build: "desktop", platform: process.platform }, + client: { name: "T4 Code", version: "0.1.31", build: "desktop", platform: process.platform }, reconnect: { baseMs: 250, maxMs: 10_000 }, }; const client = createOmpClient(clientOptions); @@ -420,8 +422,7 @@ export class DesktopTargetManager { client.onState((snapshot) => { if (this.generations.get(targetId) !== generation) return; const state = this.stateFor(snapshot.state); - if (local !== undefined) this.localStates.set(targetId, state); - this.events.onState({ targetId, state }); + this.publishState(targetId, state); }); client.onError((error) => { if (this.generations.get(targetId) !== generation) return; @@ -461,6 +462,8 @@ export class DesktopTargetManager { } private publishState(targetId: string, state: DesktopTargetState): void { if (localProfileId(targetId) !== undefined) this.localStates.set(targetId, state); + if (state === "connecting" && this.publishedStates.get(targetId) === state) return; + this.publishedStates.set(targetId, state); this.events.onState({ targetId, state }); } private stateFor(state: OmpClient["state"]): DesktopTargetState { diff --git a/apps/desktop/test/browser-downloads.test.ts b/apps/desktop/test/browser-downloads.test.ts index 4bf1e624..d604cce3 100644 --- a/apps/desktop/test/browser-downloads.test.ts +++ b/apps/desktop/test/browser-downloads.test.ts @@ -70,10 +70,22 @@ class FakeDownloadItem { return this.url; } - public getSuggestedFilename(): string { + public getFilename(): string { return this.filename; } + public getMimeType(): string { + return "text/plain"; + } + + public getTotalBytes(): number { + return 0; + } + + public getReceivedBytes(): number { + return 0; + } + public setSavePath(path: string): void { this.savePath = path; } @@ -116,12 +128,18 @@ describe("BrowserDownloadController session routing", () => { expect(rejected.defaultPrevented).toBe(true); expect(emitted).toEqual([]); - const first = firstSession.emitWillDownload(new FakeDownloadItem("https://example.test/first", "first.txt"), firstContents); - const second = firstSession.emitWillDownload(new FakeDownloadItem("https://example.test/second", "second.txt"), secondContents); - const third = secondSession.emitWillDownload(new FakeDownloadItem("https://example.test/third", "third.txt"), thirdContents); - expect(first.defaultPrevented).toBe(true); - expect(second.defaultPrevented).toBe(true); - expect(third.defaultPrevented).toBe(true); + const firstItem = new FakeDownloadItem("https://example.test/first", "first.txt"); + const secondItem = new FakeDownloadItem("https://example.test/second", "second.txt"); + const thirdItem = new FakeDownloadItem("https://example.test/third", "third.txt"); + const first = firstSession.emitWillDownload(firstItem, firstContents); + const second = firstSession.emitWillDownload(secondItem, secondContents); + const third = secondSession.emitWillDownload(thirdItem, thirdContents); + expect(first.defaultPrevented).toBe(false); + expect(second.defaultPrevented).toBe(false); + expect(third.defaultPrevented).toBe(false); + expect(firstItem.savePath).toMatch(/^.+\/[.]t4-download-.+[.]part$/u); + expect(secondItem.savePath).toMatch(/^.+\/[.]t4-download-.+[.]part$/u); + expect(thirdItem.savePath).toMatch(/^.+\/[.]t4-download-.+[.]part$/u); expect(emitted.map((event) => event.type === "download" ? event.download.surfaceId : undefined)).toEqual([ "surface:first", "surface:second", diff --git a/apps/desktop/test/browser-profiles.test.ts b/apps/desktop/test/browser-profiles.test.ts index 186ab82f..a5c237c2 100644 --- a/apps/desktop/test/browser-profiles.test.ts +++ b/apps/desktop/test/browser-profiles.test.ts @@ -78,9 +78,9 @@ describe("BrowserProfileRegistry isolated sessions", () => { const profiles = registry(); const isolated = { kind: "isolated-session", profileId: "isolated-session" } as const; - const first = profiles.getSession(isolated, "workspace-session-a"); - const sameOwner = profiles.getSession(isolated, "workspace-session-a"); - const second = profiles.getSession(isolated, "workspace-session-b"); + const first = profiles.getSession(isolated, "host-a/workspace-session-a"); + const sameOwner = profiles.getSession(isolated, "host-a/workspace-session-a"); + const second = profiles.getSession(isolated, "host-b/workspace-session-b"); expect(first).toBe(sameOwner); expect(first).not.toBe(second); diff --git a/apps/desktop/test/browser-runtime.test.ts b/apps/desktop/test/browser-runtime.test.ts index cc14b1af..fb87e334 100644 --- a/apps/desktop/test/browser-runtime.test.ts +++ b/apps/desktop/test/browser-runtime.test.ts @@ -15,6 +15,28 @@ const electron = (() => { type Listener = (...args: unknown[]) => void; const loadFailures: unknown[] = []; + class FakeSession { + readonly listeners = new Map(); + on(event: string, listener: Listener): void { + const listeners = this.listeners.get(event) ?? []; + listeners.push(listener); + this.listeners.set(event, listeners); + } + removeListener(event: string, listener: Listener): void { + const listeners = this.listeners.get(event) ?? []; + this.listeners.set( + event, + listeners.filter((candidate) => candidate !== listener), + ); + } + emit(event: string, ...args: unknown[]): void { + for (const listener of this.listeners.get(event) ?? []) listener(...args); + } + reset(): void { + this.listeners.clear(); + } + } + class FakeWebContents { readonly listeners = new Map(); @@ -76,9 +98,12 @@ const electron = (() => { } } + const defaultSession = new FakeSession(); + return { + app: { getPath: () => "/tmp" }, WebContentsView, - session: { defaultSession: {} }, + session: { defaultSession }, views, ipcMain: { on: () => {}, removeListener: () => {} }, contentTracing: { startRecording: async () => {}, stopRecording: async () => "" }, @@ -86,11 +111,13 @@ const electron = (() => { reset: () => { views.length = 0; loadFailures.length = 0; + defaultSession.reset(); }, }; })(); vitest.vi.mock("electron", () => ({ + app: electron.app, WebContentsView: electron.WebContentsView, contentTracing: electron.contentTracing, ipcMain: electron.ipcMain, @@ -113,9 +140,9 @@ class FakeWindow { } const isolatedProfile = { kind: "isolated-session", profileId: "isolated-session" } as const; -const OWNER_A = "workspace-session-a"; -const OWNER_B = "workspace-session-b"; -const OWNER_C = "workspace-session-c"; +const OWNER_A = "host-a/workspace-session-a"; +const OWNER_B = "host-b/workspace-session-b"; +const OWNER_C = "host-c/workspace-session-c"; function browserCall(method: string, request: Record, ownerSessionId = OWNER_A): never { return { method, request, ownerSessionId } as never; @@ -141,6 +168,210 @@ async function settleBackgroundWork(): Promise { } describe("BrowserRuntime native view lifecycle", () => { + it("routes default download events through the owning workspace session", async () => { + electron.reset(); + const emitted: BrowserEvent[] = []; + const runtime = new BrowserRuntime({ + window: new FakeWindow() as never, + emit: (event) => { + emitted.push(event); + }, + userDataPath: "/tmp/t4-browser-runtime-download-owner", + profileRegistry: { + getSession: () => electron.session.defaultSession, + markInUse: () => {}, + release: () => {}, + }, + sessionStore: { save: () => {} }, + installSecurity: () => ({ + auth: null, + clearTrustGrants: () => {}, + dispose: () => {}, + grantCertificate: () => false, + setProfile: () => {}, + configureProxy: async () => ({ + ok: false, + code: "not_supported", + message: "Not used by this test", + }), + }), + }); + const created = (await runtime.call( + browserCall("surface.create", { + profile: isolatedProfile, + url: "https://example.test/", + }), + )) as BrowserCallResult<"surface.create">; + const item = { + getURL: () => "https://example.test/download", + getFilename: () => "proof.txt", + getMimeType: () => "text/plain", + getTotalBytes: () => 0, + getReceivedBytes: () => 0, + setSavePath: () => {}, + on() { + return this; + }, + once() { + return this; + }, + cancel: () => {}, + }; + const event = { + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true; + }, + }; + + electron.session.defaultSession.emit( + "will-download", + event, + item, + electron.views[0]?.webContents, + ); + + expect(event.defaultPrevented).toBe(false); + const download = emitted.find((candidate) => candidate.type === "download"); + expect(download).toMatchObject({ + type: "download", + download: { + surfaceId: created.surface.surfaceId, + filename: "proof.txt", + state: "started", + }, + ownerSessionId: OWNER_A, + }); + await runtime.dispose(); + }); + + it("treats an accepted download navigation as success and keeps the current page ready", async () => { + electron.reset(); + const downloadUrl = "https://example.test/proof.txt"; + let listCalls = 0; + const runtime = new BrowserRuntime({ + window: new FakeWindow() as never, + emit: () => {}, + userDataPath: "/tmp/t4-browser-runtime-download-navigation", + profileRegistry: { + getSession: () => electron.session.defaultSession, + markInUse: () => {}, + release: () => {}, + }, + sessionStore: { save: () => {} }, + downloadController: { + attach: () => {}, + list: () => { + listCalls += 1; + return listCalls < 3 + ? [] + : [{ downloadId: "download-1", url: `${downloadUrl}?canonicalized=1` }]; + }, + disposeSurface: () => {}, + dispose: () => {}, + }, + installSecurity: () => ({ + auth: null, + clearTrustGrants: () => {}, + dispose: () => {}, + grantCertificate: () => false, + setProfile: () => {}, + configureProxy: async () => ({ + ok: false, + code: "not_supported", + message: "Not used by this test", + }), + }), + }); + const created = (await runtime.call( + browserCall("surface.create", { + profile: isolatedProfile, + url: "https://example.test/current", + }), + )) as BrowserCallResult<"surface.create">; + const view = electron.views[0]!; + view.webContents.title = "Current page"; + view.webContents.emit("did-finish-load"); + electron.failNextLoad(new Error("ERR_FAILED (-2)")); + + const navigated = (await runtime.call( + browserCall("surface.navigate", { + surfaceId: created.surface.surfaceId, + url: downloadUrl, + }), + )) as BrowserCallResult<"surface.navigate">; + + expect(navigated.surface).toMatchObject({ + url: "https://example.test/current", + title: "Current page", + lifecycle: "ready", + loading: false, + }); + await runtime.dispose(); + }); + + it("does not conceal a failed navigation when an unrelated download starts", async () => { + electron.reset(); + const requestedUrl = "https://example.test/failing-page"; + let listCalls = 0; + const runtime = new BrowserRuntime({ + window: new FakeWindow() as never, + emit: () => {}, + userDataPath: "/tmp/t4-browser-runtime-unrelated-download", + profileRegistry: { + getSession: () => electron.session.defaultSession, + markInUse: () => {}, + release: () => {}, + }, + sessionStore: { save: () => {} }, + downloadController: { + attach: () => {}, + list: () => { + listCalls += 1; + return listCalls < 3 + ? [] + : [ + { + downloadId: "unrelated-download", + url: "https://example.test/other-file.txt", + }, + ]; + }, + disposeSurface: () => {}, + dispose: () => {}, + }, + installSecurity: () => ({ + auth: null, + clearTrustGrants: () => {}, + dispose: () => {}, + grantCertificate: () => false, + setProfile: () => {}, + configureProxy: async () => ({ + ok: false, + code: "not_supported", + message: "Not used by this test", + }), + }), + }); + const created = (await runtime.call( + browserCall("surface.create", { + profile: isolatedProfile, + url: "https://example.test/current", + }), + )) as BrowserCallResult<"surface.create">; + electron.failNextLoad(new Error("certificate failure")); + + await expect( + runtime.call( + browserCall("surface.navigate", { + surfaceId: created.surface.surfaceId, + url: requestedUrl, + }), + ), + ).rejects.toThrow("certificate failure"); + await runtime.dispose(); + }); + it("routes accessibility snapshots and design mode through the exact owner-scoped surface", async () => { electron.reset(); const calls: unknown[] = []; diff --git a/apps/desktop/test/doctor.test.ts b/apps/desktop/test/doctor.test.ts index 0e53ffc2..54baad05 100644 --- a/apps/desktop/test/doctor.test.ts +++ b/apps/desktop/test/doctor.test.ts @@ -42,9 +42,9 @@ describe("T4 setup doctor", () => { const source = await readSourceContract(); expect(source.ompVersion).toBe("17.0.5"); - expect(source.ompTag).toBe("t4code-17.0.5-appserver-10"); + expect(source.ompTag).toBe("t4code-17.0.5-appserver-13"); expect(source.ompUrl).toBe( - "https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-10", + "https://github.com/wolfiesch/oh-my-pi/tree/t4code-17.0.5-appserver-13", ); }); diff --git a/apps/desktop/test/ipc-lifecycle.test.ts b/apps/desktop/test/ipc-lifecycle.test.ts index 46e33de1..68d0542a 100644 --- a/apps/desktop/test/ipc-lifecycle.test.ts +++ b/apps/desktop/test/ipc-lifecycle.test.ts @@ -23,6 +23,31 @@ function makeRuntime(serviceManager?: ServiceManager, serviceAvailabilityIssue?: const request = (channel: string, payload: unknown = {}): unknown => ({ channel, payload }); describe("desktop IPC lifecycle proof", () => { + it("routes t4-omp inspect, install, and remove through the trusted desktop service", async () => { + const ipc = new FakeIpc(); + const { runtime: baseRuntime } = makeRuntime(); + const calls: string[] = []; + const launcherState = (phase: "not-installed" | "installed") => ({ + phase, + command: "t4-omp" as const, + location: "~/.local/bin/t4-omp" as const, + message: phase === "installed" ? "Ready" : "Not installed", + }); + const runtime: IpcRuntime = { + ...baseRuntime, + t4OmpLauncher: { + inspect: async () => { calls.push("inspect"); return launcherState("not-installed"); }, + install: async () => { calls.push("install"); return launcherState("installed"); }, + remove: async () => { calls.push("remove"); return launcherState("not-installed"); }, + }, + }; + new DesktopIpcRegistry(runtime, ipc).install(); + const event = { sender: runtime.window.webContents, senderFrame: runtime.window.webContents.mainFrame }; + for (const action of ["inspect", "install", "remove"] as const) { + await ipc.handlers.get(`app:t4-omp:${action}`)!(event, request(`app:t4-omp:${action}`)); + } + expect(calls).toEqual(["inspect", "install", "remove"]); + }); it("exposes strict profile lifecycle handlers and bounds their service diagnostics", async () => { const ipc = new FakeIpc(); const { runtime: baseRuntime } = makeRuntime(); diff --git a/apps/desktop/test/phone-setup.test.ts b/apps/desktop/test/phone-setup.test.ts index 0971fc1a..da8b6b06 100644 --- a/apps/desktop/test/phone-setup.test.ts +++ b/apps/desktop/test/phone-setup.test.ts @@ -135,6 +135,62 @@ describe("phone setup", () => { expect(calls.some((call) => call.args?.[0] === "serve" && call.args?.[1] === "--bg")).toBe(false); }); + it("upgrades a stale gateway before requiring Tailscale", async () => { + const resourcesPath = await mkdtemp(join(tmpdir(), "t4-phone-setup-stale-upgrade-")); + await mkdir(join(resourcesPath, "runtime")); + await writeFile(join(resourcesPath, "runtime", "manifest.json"), '{"tag":"synthetic"}\n'); + const calls: ProcessSpec[] = []; + const runner: ProcessRunner = { + spawn: async (spec) => { + calls.push(spec); + const isGatewayStatus = spec.command.includes("T4 Code") && spec.args?.[1] === "status"; + const isGatewayInstall = spec.command.includes("T4 Code") && spec.args?.[1] === "install"; + return { + kill: () => {}, + result: Promise.resolve(isGatewayStatus + ? { + exitCode: 1, + signal: null, + stdout: [ + "definition: current", + "supervisor: running", + "health: healthy", + "allowed origin: https://work-mac.example.ts.net:8445", + "deployment identity: stale", + ].join("\n"), + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } + : { + exitCode: isGatewayInstall ? 0 : 1, + signal: null, + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }; + }, + }; + const service = new PhoneSetupService({ + platform: "darwin", + arch: "arm64", + resourcesPath, + electronExecutable: "/Applications/T4 Code.app/Contents/MacOS/T4 Code", + runner, + discoverTailscale: async () => { throw new Error("Tailscale is offline."); }, + }); + + expect(await service.restore()).toEqual({ + phase: "tailscale-required", + message: "Tailscale is offline.", + }); + const install = calls.find((call) => call.args?.[1] === "install"); + expect(install?.args).toContain("https://work-mac.example.ts.net:8445"); + expect(install?.args).toContain("--electron-run-as-node"); + }); + it("does not call phone access ready while the local OMP runtime is unreachable", async () => { const resourcesPath = await mkdtemp(join(tmpdir(), "t4-phone-setup-offline-")); await mkdir(join(resourcesPath, "runtime")); diff --git a/apps/desktop/test/t4-omp-launcher.test.ts b/apps/desktop/test/t4-omp-launcher.test.ts new file mode 100644 index 00000000..488e9788 --- /dev/null +++ b/apps/desktop/test/t4-omp-launcher.test.ts @@ -0,0 +1,91 @@ +import { chmod, mkdir, mkdtemp, readFile, readlink, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { T4OmpLauncher } from "../src/t4-omp-launcher.ts"; + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "t4-omp-launcher-")); + const home = join(root, "home"); + const runtimeRoot = join(root, "support", "runtime"); + const current = join(runtimeRoot, "t4code-17.0.5-appserver-13", "omp"); + const previous = join(runtimeRoot, "t4code-17.0.5-appserver-12", "omp"); + const launcherPath = join(home, ".local", "bin", "t4-omp"); + await mkdir(join(current, ".."), { recursive: true }); + await mkdir(join(previous, ".."), { recursive: true }); + await writeFile(current, "current"); + await writeFile(previous, "previous"); + await chmod(current, 0o755); + await chmod(previous, 0o755); + const launcher = new T4OmpLauncher({ + supported: true, + homeDirectory: home, + runtimeRoot, + resolveRuntime: async () => current, + }); + return { current, home, launcher, launcherPath, previous, root, runtimeRoot }; +} + +describe("t4-omp terminal launcher", () => { + it("installs and removes a side-by-side link without touching omp", async () => { + const { current, home, launcher, launcherPath } = await fixture(); + const existingOmp = join(home, ".local", "bin", "omp"); + await mkdir(join(existingOmp, ".."), { recursive: true }); + await writeFile(existingOmp, "upstream"); + + expect((await launcher.inspect()).phase).toBe("not-installed"); + expect((await launcher.install()).phase).toBe("installed"); + expect(await readlink(launcherPath)).toBe(current); + expect(await readFile(existingOmp, "utf8")).toBe("upstream"); + expect((await launcher.remove()).phase).toBe("not-installed"); + expect(await readFile(existingOmp, "utf8")).toBe("upstream"); + }); + + it("updates only links that already point into T4's runtime tree", async () => { + const { current, launcher, launcherPath, previous } = await fixture(); + await mkdir(join(launcherPath, ".."), { recursive: true }); + await symlink(previous, launcherPath); + expect((await launcher.inspect()).phase).toBe("update-available"); + expect((await launcher.install()).phase).toBe("installed"); + expect(await readlink(launcherPath)).toBe(current); + }); + + it("refuses regular files and foreign symlinks", async () => { + const regular = await fixture(); + await mkdir(join(regular.launcherPath, ".."), { recursive: true }); + await writeFile(regular.launcherPath, "mine"); + expect((await regular.launcher.inspect()).phase).toBe("conflict"); + await expect(regular.launcher.install()).rejects.toThrow("left it untouched"); + expect(await readFile(regular.launcherPath, "utf8")).toBe("mine"); + + const foreign = await fixture(); + const other = join(foreign.root, "foreign-omp"); + await writeFile(other, "foreign"); + await mkdir(join(foreign.launcherPath, ".."), { recursive: true }); + await symlink(other, foreign.launcherPath); + expect((await foreign.launcher.inspect()).phase).toBe("conflict"); + await expect(foreign.launcher.remove()).rejects.toThrow("left it untouched"); + expect(await readlink(foreign.launcherPath)).toBe(other); + }); + + it("fails closed outside the installed macOS product", async () => { + const { home, current, runtimeRoot } = await fixture(); + const launcher = new T4OmpLauncher({ + supported: false, + homeDirectory: home, + runtimeRoot, + resolveRuntime: async () => current, + }); + expect((await launcher.inspect()).phase).toBe("unsupported"); + await expect(launcher.install()).rejects.toThrow("unavailable"); + }); + + it("refuses a symlinked ~/.local installation tree", async () => { + const { home, launcher, root } = await fixture(); + const outside = join(root, "outside"); + await mkdir(home, { recursive: true }); + await mkdir(outside); + await symlink(outside, join(home, ".local")); + await expect(launcher.install()).rejects.toThrow("not a safe installation directory"); + }); +}); diff --git a/apps/desktop/test/target-manager.test.ts b/apps/desktop/test/target-manager.test.ts index 767cacc7..c70522e9 100644 --- a/apps/desktop/test/target-manager.test.ts +++ b/apps/desktop/test/target-manager.test.ts @@ -226,6 +226,26 @@ async function settlesBeforeTurnLimit(promise: Promise): Promise { } describe("desktop target manager boundaries", () => { + it("publishes each public connection state transition once", async () => { + const states: string[] = []; + const runtime = new DesktopTargetManager({ + cursorStore: new Store(), + localTransportFactory: () => new Transport() as never, + events: { + onEvent: () => {}, + onState: (event) => { + states.push(event.state); + }, + onError: () => {}, + }, + }); + + await runtime.connect("local"); + + expect(states).toEqual(["connecting", "connected"]); + await runtime.close(); + }); + it("requests cluster.operator only after explicit desktop opt-in", async () => { const defaultTransport = new Transport(); const defaultRuntime = new DesktopTargetManager({ diff --git a/apps/flutter/.gitignore b/apps/flutter/.gitignore deleted file mode 100644 index 3820a95c..00000000 --- a/apps/flutter/.gitignore +++ /dev/null @@ -1,45 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.build/ -.buildlog/ -.history -.svn/ -.swiftpm/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins-dependencies -.pub-cache/ -.pub/ -/build/ -/coverage/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release diff --git a/apps/flutter/.metadata b/apps/flutter/.metadata deleted file mode 100644 index 224ef085..00000000 --- a/apps/flutter/.metadata +++ /dev/null @@ -1,45 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "ee80f08bbf97172ec030b8751ceab557177a34a6" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - - platform: android - create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - - platform: ios - create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - - platform: linux - create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - - platform: macos - create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - - platform: web - create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - - platform: windows - create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/apps/flutter/README.md b/apps/flutter/README.md deleted file mode 100644 index eb5a72d0..00000000 --- a/apps/flutter/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# T4 Code Flutter client - -Native Android, iOS, and macOS client for an OMP `omp-app/1` host. The Flutter -shell owns the responsive UI and typed wire projection; platform channels are -limited to credentials, lifecycle, runtime-service management, and signed -application updates. - -## Run and verify - -From this directory: - -```sh -flutter run -d android -flutter run -d ios -pnpm --dir ../.. dev:flutter -- -d macos -flutter test -flutter analyze -flutter test integration_test/app_smoke_test.dart -d -``` - -`--dart-define=T4_DEVELOPMENT_ENDPOINT=wss://…` connects an unsigned development -build directly to a host. Development credentials are intentionally volatile. -Release builds use platform secure storage. - -The public `https://t4code.net/demo/` preview is a read-only Flutter build with -local display data. From the repository root, `pnpm build:demo` creates that -subpath build in `apps/site/dist/demo`; it does not connect to a host or store -credentials. Demo publication follows current `main` independently of desktop -release tags. - -The device smoke harness launches the real native shell, waits for persistent -storage and platform initialization, and opens host management. CI runs it on -an Android emulator and an iOS simulator; local device IDs come from -`flutter devices`. - -## Platform lifecycle - -- Android reports foreground/resume events and performs user-driven updates - only after validating the canonical T4 manifest, APK package, version, and - signer. -- iOS uses the Flutter lifecycle and App Store-managed updates. -- macOS bundles the standalone `t4-host`, manages its per-user LaunchAgent, - connects it to OMP's authority bridge, and validates canonical signed DMG - updates before opening the installer. - -The Settings surface is generated from host `catalog.get` and `settings.read` -frames. Non-secret values may be staged and written only with negotiated -capabilities and explicit host confirmation; secret values are never projected -to the client. Diagnostics exports contain a fixed redacted allowlist. - -Transcript search uses the bounded `transcript.search` and -`transcript.context` commands. Results expose display-safe snippets and inline -historical context without projecting transcript paths or full session files. - -Opening a session fetches the newest bounded `transcript.page` before the live -stream attaches. The app keeps a small encrypted, display-only copy of recent -messages, so a previously opened conversation can paint immediately while the -host refreshes it. Each session is stored separately, earlier pages load on -demand, and paging cursors are never stored. Hosts without `transcript.page` -keep the original snapshot-and-stream behavior. - -Usage and account status use the bounded `usage.read` and `broker.status` -commands. The client renders provider limits and sanitized broker endpoints; -credentials and unrecognized metadata are rejected at the wire boundary. - -The Inbox projects host-reported parent/child agent relationships and live -progress. Active agents can be cancelled only with `agents.control`, the latest -session revision, and the host's correlated confirmation challenge. - -Files are edited against host-provided authority revisions. Writes and review -applications use the wire protocol's correlated confirmation challenge. -Preview controls cover navigation, capture, click, fill, type, select, key -press, scroll, upload, and handoff without executing page code in the client. diff --git a/apps/flutter/analysis_options.yaml b/apps/flutter/analysis_options.yaml deleted file mode 100644 index f9b30346..00000000 --- a/apps/flutter/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: package:flutter_lints/flutter.yaml diff --git a/apps/flutter/android/.gitignore b/apps/flutter/android/.gitignore deleted file mode 100644 index be3943c9..00000000 --- a/apps/flutter/android/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -gradle-wrapper.jar -/.gradle -/captures/ -/gradlew -/gradlew.bat -/local.properties -GeneratedPluginRegistrant.java -.cxx/ - -# Remember to never publicly share your keystore. -# See https://flutter.dev/to/reference-keystore -key.properties -**/*.keystore -**/*.jks diff --git a/apps/flutter/android/app/build.gradle.kts b/apps/flutter/android/app/build.gradle.kts deleted file mode 100644 index 199cdaf2..00000000 --- a/apps/flutter/android/app/build.gradle.kts +++ /dev/null @@ -1,50 +0,0 @@ -plugins { - id("com.android.application") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id("dev.flutter.flutter-gradle-plugin") -} - -android { - namespace = "com.lycaonsolutions.t4code" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.lycaonsolutions.t4code" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") - } - } -} - -kotlin { - compilerOptions { - jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 - } -} - -dependencies { - implementation("androidx.core:core:1.17.0") - testImplementation("junit:junit:4.13.2") -} - -flutter { - source = "../.." -} diff --git a/apps/flutter/android/app/src/debug/AndroidManifest.xml b/apps/flutter/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index d16cdf39..00000000 --- a/apps/flutter/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - diff --git a/apps/flutter/android/app/src/main/AndroidManifest.xml b/apps/flutter/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index e47f44a9..00000000 --- a/apps/flutter/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/LegacyCredentialMigration.java b/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/LegacyCredentialMigration.java deleted file mode 100644 index aadabdeb..00000000 --- a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/LegacyCredentialMigration.java +++ /dev/null @@ -1,221 +0,0 @@ -package com.lycaonsolutions.t4code; - -import android.content.Context; -import android.content.SharedPreferences; -import android.util.Base64; - -import org.json.JSONObject; - -import java.nio.charset.StandardCharsets; -import java.security.KeyStore; -import java.security.MessageDigest; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import javax.crypto.Cipher; -import javax.crypto.SecretKey; -import javax.crypto.spec.GCMParameterSpec; - -final class LegacyCredentialMigration { - private static final String KEYSTORE_PROVIDER = "AndroidKeyStore"; - private static final String KEY_ALIAS = "t4_code_device_credentials_v1"; - private static final String CIPHER_TRANSFORMATION = "AES/GCM/NoPadding"; - private static final String PREFERENCES_NAME = "t4_code_secure_storage"; - private static final String PREFERENCE_IV = "credentials_iv"; - private static final String PREFERENCE_PAYLOAD = "credentials_payload"; - private static final String PREFERENCE_IV_PREFIX = "credentials_iv_"; - private static final String PREFERENCE_PAYLOAD_PREFIX = "credentials_payload_"; - private static final char[] HEX_DIGITS = "0123456789abcdef".toCharArray(); - private static final int GCM_TAG_BITS = 128; - private static final int MAX_HOST_KEY_LENGTH = 2048; - private static final int MAX_DEVICE_ID_LENGTH = 256; - private static final int MAX_DEVICE_TOKEN_LENGTH = 512; - - private final Context context; - - LegacyCredentialMigration(Context context) { - this.context = context.getApplicationContext(); - } - - synchronized Map discover(List hostKeys, boolean includeUnkeyed) - throws Exception { - SharedPreferences preferences = preferences(); - Set visited = new HashSet<>(); - for (Object value : hostKeys) { - if (!(value instanceof String) || !isBoundedText((String) value, MAX_HOST_KEY_LENGTH)) { - throw new IllegalArgumentException("Invalid host key."); - } - String hostKey = (String) value; - if (!visited.add(hostKey)) continue; - - String suffix = preferenceSuffix(hostKey); - String ivKey = PREFERENCE_IV_PREFIX + suffix; - String payloadKey = PREFERENCE_PAYLOAD_PREFIX + suffix; - String encodedIv = preferences.getString(ivKey, null); - String encodedPayload = preferences.getString(payloadKey, null); - if (encodedIv != null || encodedPayload != null) { - requireComplete(encodedIv, encodedPayload); - return decrypt( - encodedIv, - encodedPayload, - hostKey, - sourceSelector("keyed", suffix, encodedIv, encodedPayload) - ); - } - } - - if (!includeUnkeyed) return null; - String encodedIv = preferences.getString(PREFERENCE_IV, null); - String encodedPayload = preferences.getString(PREFERENCE_PAYLOAD, null); - if (encodedIv == null && encodedPayload == null) return null; - requireComplete(encodedIv, encodedPayload); - return decrypt( - encodedIv, - encodedPayload, - null, - sourceSelector("unkeyed", "", encodedIv, encodedPayload) - ); - } - - synchronized void clear(String source) throws Exception { - if (source == null) throw new IllegalArgumentException("Invalid legacy source."); - String[] parts = source.split("\\.", -1); - final String ivKey; - final String payloadKey; - final String expectedFingerprint; - if (parts.length == 3 && "keyed".equals(parts[0]) && isHexDigest(parts[1])) { - ivKey = PREFERENCE_IV_PREFIX + parts[1]; - payloadKey = PREFERENCE_PAYLOAD_PREFIX + parts[1]; - expectedFingerprint = parts[2]; - } else if (parts.length == 2 && "unkeyed".equals(parts[0])) { - ivKey = PREFERENCE_IV; - payloadKey = PREFERENCE_PAYLOAD; - expectedFingerprint = parts[1]; - } else { - throw new IllegalArgumentException("Invalid legacy source."); - } - if (!isHexDigest(expectedFingerprint)) { - throw new IllegalArgumentException("Invalid legacy source."); - } - - SharedPreferences preferences = preferences(); - String encodedIv = preferences.getString(ivKey, null); - String encodedPayload = preferences.getString(payloadKey, null); - requireComplete(encodedIv, encodedPayload); - String actualFingerprint = payloadFingerprint(encodedIv, encodedPayload); - if (!MessageDigest.isEqual( - expectedFingerprint.getBytes(StandardCharsets.US_ASCII), - actualFingerprint.getBytes(StandardCharsets.US_ASCII) - )) { - throw new IllegalStateException("Legacy source changed."); - } - if (!preferences.edit().remove(ivKey).remove(payloadKey).commit()) { - throw new IllegalStateException("Legacy source could not be cleared."); - } - } - - private SharedPreferences preferences() { - return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE); - } - - private Map decrypt( - String encodedIv, - String encodedPayload, - String hostKey, - String source - ) throws Exception { - byte[] iv = Base64.decode(encodedIv, Base64.NO_WRAP); - byte[] ciphertext = Base64.decode(encodedPayload, Base64.NO_WRAP); - Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION); - cipher.init(Cipher.DECRYPT_MODE, existingKey(), new GCMParameterSpec(GCM_TAG_BITS, iv)); - if (hostKey != null) cipher.updateAAD(hostKey.getBytes(StandardCharsets.UTF_8)); - String plaintext = new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); - JSONObject credentials = new JSONObject(plaintext); - String deviceId = credentials.getString("deviceId"); - String deviceToken = credentials.getString("deviceToken"); - if (!isBoundedText(deviceId, MAX_DEVICE_ID_LENGTH) - || !isBoundedText(deviceToken, MAX_DEVICE_TOKEN_LENGTH)) { - throw new IllegalStateException("Invalid legacy credential payload."); - } - - Map result = new HashMap<>(); - result.put("deviceId", deviceId); - result.put("deviceToken", deviceToken); - result.put("source", source); - return result; - } - - private SecretKey existingKey() throws Exception { - KeyStore keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER); - keyStore.load(null); - if (!keyStore.containsAlias(KEY_ALIAS)) { - throw new IllegalStateException("Legacy key is unavailable."); - } - return (SecretKey) keyStore.getKey(KEY_ALIAS, null); - } - - private static void requireComplete(String encodedIv, String encodedPayload) { - if (encodedIv == null || encodedPayload == null) { - throw new IllegalStateException("Incomplete legacy credential state."); - } - } - - private static String preferenceSuffix(String hostKey) throws Exception { - return hex( - MessageDigest.getInstance("SHA-256") - .digest(hostKey.getBytes(StandardCharsets.UTF_8)) - ); - } - - private static String sourceSelector( - String kind, - String suffix, - String encodedIv, - String encodedPayload - ) throws Exception { - String fingerprint = payloadFingerprint(encodedIv, encodedPayload); - return "keyed".equals(kind) - ? kind + "." + suffix + "." + fingerprint - : kind + "." + fingerprint; - } - - private static String payloadFingerprint(String encodedIv, String encodedPayload) - throws Exception { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - digest.update(encodedIv.getBytes(StandardCharsets.UTF_8)); - digest.update((byte) 0); - digest.update(encodedPayload.getBytes(StandardCharsets.UTF_8)); - return hex(digest.digest()); - } - - private static String hex(byte[] bytes) { - StringBuilder result = new StringBuilder(bytes.length * 2); - for (byte value : bytes) { - result.append(HEX_DIGITS[(value >>> 4) & 0x0f]); - result.append(HEX_DIGITS[value & 0x0f]); - } - return result.toString(); - } - - private static boolean isHexDigest(String value) { - if (value.length() != 64) return false; - for (int index = 0; index < value.length(); index += 1) { - char character = value.charAt(index); - if ((character < '0' || character > '9') - && (character < 'a' || character > 'f')) return false; - } - return true; - } - - private static boolean isBoundedText(String value, int maxLength) { - if (value == null || value.isEmpty() || value.length() > maxLength) return false; - for (int index = 0; index < value.length(); index += 1) { - char character = value.charAt(index); - if (character <= 0x1f || character == 0x7f) return false; - } - return true; - } -} diff --git a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/MainActivity.java b/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/MainActivity.java deleted file mode 100644 index 6b91a951..00000000 --- a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/MainActivity.java +++ /dev/null @@ -1,125 +0,0 @@ -package com.lycaonsolutions.t4code; - -import androidx.annotation.NonNull; - -import java.util.List; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; - -import io.flutter.embedding.android.FlutterActivity; -import io.flutter.embedding.engine.FlutterEngine; -import io.flutter.plugin.common.MethodChannel; - -public class MainActivity extends FlutterActivity { - private static final String LEGACY_CREDENTIAL_CHANNEL = - "com.lycaonsolutions.t4code/legacy_credentials"; - private T4UpdatePlugin updatePlugin; - - private final ExecutorService legacyCredentialExecutor = new ThreadPoolExecutor( - 1, - 1, - 0L, - TimeUnit.MILLISECONDS, - new ArrayBlockingQueue<>(8) - ); - - @Override - public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { - super.configureFlutterEngine(flutterEngine); - if (updatePlugin != null) updatePlugin.destroy(); - updatePlugin = new T4UpdatePlugin(this); - new MethodChannel( - flutterEngine.getDartExecutor().getBinaryMessenger(), - T4UpdatePlugin.CHANNEL_NAME - ).setMethodCallHandler(updatePlugin::handle); - - LegacyCredentialMigration migration = new LegacyCredentialMigration(this); - new MethodChannel( - flutterEngine.getDartExecutor().getBinaryMessenger(), - LEGACY_CREDENTIAL_CHANNEL - ).setMethodCallHandler((call, result) -> { - try { - switch (call.method) { - case "discoverCredentials": - List hostKeys = call.argument("hostKeys"); - Boolean includeUnkeyed = call.argument("includeUnkeyed"); - if (hostKeys == null || hostKeys.isEmpty() || includeUnkeyed == null) { - throw new IllegalArgumentException("Invalid discovery request."); - } - executeLegacyOperation( - result, - () -> migration.discover(hostKeys, includeUnkeyed) - ); - break; - case "clearCredentials": - String source = call.argument("source"); - executeLegacyOperation(result, () -> { - migration.clear(source); - return null; - }); - break; - default: - result.notImplemented(); - break; - } - } catch (Exception error) { - sendLegacyError(result); - } - }); - } - - @Override - protected void onPause() { - if (updatePlugin != null) updatePlugin.onPause(); - super.onPause(); - } - - @Override - protected void onResume() { - super.onResume(); - if (updatePlugin != null) updatePlugin.onResume(); - } - - @Override - protected void onDestroy() { - if (updatePlugin != null) { - updatePlugin.destroy(); - updatePlugin = null; - } - legacyCredentialExecutor.shutdownNow(); - super.onDestroy(); - } - - private void executeLegacyOperation( - MethodChannel.Result result, - LegacyOperation operation - ) { - try { - legacyCredentialExecutor.execute(() -> { - try { - Object value = operation.run(); - runOnUiThread(() -> result.success(value)); - } catch (Exception error) { - runOnUiThread(() -> sendLegacyError(result)); - } - }); - } catch (RejectedExecutionException error) { - sendLegacyError(result); - } - } - - private void sendLegacyError(MethodChannel.Result result) { - result.error( - "legacy_credentials_unavailable", - "Legacy credentials could not be migrated.", - null - ); - } - - private interface LegacyOperation { - Object run() throws Exception; - } -} diff --git a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4FileProvider.java b/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4FileProvider.java deleted file mode 100644 index 612c1081..00000000 --- a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4FileProvider.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.lycaonsolutions.t4code; - -import androidx.core.content.FileProvider; - -/** App-scoped provider for handing one verified update to Android's installer. */ -public final class T4FileProvider extends FileProvider {} diff --git a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateFileStore.java b/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateFileStore.java deleted file mode 100644 index 2c720a98..00000000 --- a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateFileStore.java +++ /dev/null @@ -1,228 +0,0 @@ -package com.lycaonsolutions.t4code; - -import java.io.File; -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; - -/** - * Owns the app-private APK cache used by the updater. - * - * A normal verified APK is temporary. An {@code -installer.apk} file is the - * one exception: it is retained while Android's package installer owns a - * read-only content URI, then removed when T4 returns to the foreground. - */ -final class T4UpdateFileStore { - private static final String PARTIAL_SUFFIX = ".apk.partial"; - private static final String APK_SUFFIX = ".apk"; - private static final String HANDOFF_SUFFIX = "-installer.apk"; - private static final Object OWNERSHIP_LOCK = new Object(); - private static final Map ACTIVE_OWNERS = new HashMap<>(); - - private final File directory; - private final String ownershipKey; - private final Object ownerToken = new Object(); - private File activeHandoff; - - T4UpdateFileStore(File directory) { - this.directory = directory; - ownershipKey = normalizedPath(directory); - synchronized (OWNERSHIP_LOCK) { - ACTIVE_OWNERS.put(ownershipKey, ownerToken); - } - } - - /** - * Removes interrupted downloads and unhanded verified packages. At most - * one prior installer handoff survives until the activity is foregrounded. - */ - synchronized File prepareOnStartup() throws IOException { - synchronized (OWNERSHIP_LOCK) { - requireOwnership(); - ensureDirectory(); - File keep = newestHandoff(); - cleanExcept(keep); - activeHandoff = keep != null && keep.isFile() ? keep : null; - return activeHandoff; - } - } - - /** The user is foregrounded and starting a new download, so no old handoff is live. */ - synchronized void prepareForDownload() throws IOException { - synchronized (OWNERSHIP_LOCK) { - requireOwnership(); - ensureDirectory(); - cleanExcept(null); - activeHandoff = null; - } - } - - synchronized File createPartial(String version) throws IOException { - synchronized (OWNERSHIP_LOCK) { - requireOwnership(); - ensureDirectory(); - return File.createTempFile("T4-Code-" + version + "-", PARTIAL_SUFFIX, directory); - } - } - - synchronized File finalizeVerified(File partial) throws IOException { - synchronized (OWNERSHIP_LOCK) { - requireOwnership(); - requireManagedFile(partial, PARTIAL_SUFFIX); - String partialName = partial.getName(); - File verified = new File(directory, partialName.substring(0, partialName.length() - ".partial".length())); - deleteIfPresent(verified); - if (!partial.renameTo(verified)) { - throw new IOException("could not finalize verified update"); - } - if (!verified.setReadOnly()) { - deleteIfPresent(verified); - throw new IOException("could not protect verified update"); - } - return verified; - } - } - - /** - * Renames the verified file before its URI is granted. The distinctive - * suffix is the process-death marker that lets a new plugin instance keep - * exactly this one file until T4 is foregrounded again. - */ - synchronized File beginInstallerHandoff(File verified) throws IOException { - synchronized (OWNERSHIP_LOCK) { - requireOwnership(); - requireManagedFile(verified, APK_SUFFIX); - if (isHandoff(verified)) throw new IOException("update is already handed to the installer"); - String name = verified.getName(); - File handoff = new File(directory, name.substring(0, name.length() - APK_SUFFIX.length()) + HANDOFF_SUFFIX); - cleanHandoffsExcept(null); - deleteIfPresent(handoff); - if (!verified.renameTo(handoff)) { - throw new IOException("could not prepare update for the installer"); - } - activeHandoff = handoff; - return handoff; - } - } - - synchronized void finishInstallerHandoff(File handoff) throws IOException { - synchronized (OWNERSHIP_LOCK) { - requireOwnership(); - requireManagedFileOrMissing(handoff, HANDOFF_SUFFIX); - deleteIfPresent(handoff); - if (sameFile(activeHandoff, handoff)) activeHandoff = null; - } - } - - synchronized void discard(File file) { - synchronized (OWNERSHIP_LOCK) { - if (!ownsDirectory() || file == null || !isDirectChild(file)) return; - file.delete(); - if (sameFile(activeHandoff, file)) activeHandoff = null; - } - } - - /** Destroy may interrupt a worker, but must not revoke a URI already owned by the installer. */ - synchronized void cleanupForDestroy() { - synchronized (OWNERSHIP_LOCK) { - if (!ownsDirectory()) return; - try { - ensureDirectory(); - cleanExcept(activeHandoff); - } catch (IOException ignored) { - // Startup and pre-download sweeps retry cleanup on the next plugin instance. - } - } - } - - synchronized File activeHandoff() { - synchronized (OWNERSHIP_LOCK) { - return ownsDirectory() ? activeHandoff : null; - } - } - - private void requireOwnership() throws IOException { - if (!ownsDirectory()) throw new IOException("update storage belongs to a newer Android activity"); - } - - private boolean ownsDirectory() { - return ACTIVE_OWNERS.get(ownershipKey) == ownerToken; - } - - private static String normalizedPath(File directory) { - try { - return directory.getCanonicalPath(); - } catch (IOException ignored) { - return directory.getAbsoluteFile().toURI().normalize().getPath(); - } - } - - private void ensureDirectory() throws IOException { - if ((!directory.isDirectory() && !directory.mkdirs()) || !directory.isDirectory()) { - throw new IOException("could not create private update directory"); - } - } - - private File newestHandoff() throws IOException { - File newest = null; - for (File entry : entries()) { - if (!entry.isFile() || !isHandoff(entry)) continue; - if ( - newest == null || - entry.lastModified() > newest.lastModified() || - (entry.lastModified() == newest.lastModified() && entry.getName().compareTo(newest.getName()) > 0) - ) { - newest = entry; - } - } - return newest; - } - - private void cleanExcept(File keep) throws IOException { - for (File entry : entries()) { - if (sameFile(entry, keep)) continue; - deleteIfPresent(entry); - } - } - - private void cleanHandoffsExcept(File keep) throws IOException { - for (File entry : entries()) { - if (!isHandoff(entry) || sameFile(entry, keep)) continue; - deleteIfPresent(entry); - } - } - - private File[] entries() throws IOException { - File[] entries = directory.listFiles(); - if (entries == null) throw new IOException("could not inspect private update directory"); - return entries; - } - - private void requireManagedFile(File file, String suffix) throws IOException { - requireManagedFileOrMissing(file, suffix); - if (!file.isFile()) throw new IOException("update file is missing"); - } - - private void requireManagedFileOrMissing(File file, String suffix) throws IOException { - if (file == null || !isDirectChild(file) || !file.getName().endsWith(suffix)) { - throw new IOException("update file is outside the private update directory"); - } - } - - private boolean isDirectChild(File file) { - File parent = file.getAbsoluteFile().getParentFile(); - return parent != null && parent.equals(directory.getAbsoluteFile()); - } - - private boolean isHandoff(File file) { - return file.getName().endsWith(HANDOFF_SUFFIX); - } - - private boolean sameFile(File first, File second) { - return first != null && second != null && first.getAbsoluteFile().equals(second.getAbsoluteFile()); - } - - private void deleteIfPresent(File file) throws IOException { - if (file.exists() && !file.delete()) throw new IOException("could not remove stale update file"); - } -} diff --git a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdatePlugin.java b/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdatePlugin.java deleted file mode 100644 index 1e2060d3..00000000 --- a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdatePlugin.java +++ /dev/null @@ -1,767 +0,0 @@ -package com.lycaonsolutions.t4code; - -import android.app.Activity; -import android.content.ClipData; -import android.content.Intent; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; -import android.content.pm.Signature; -import android.content.pm.SigningInfo; -import android.net.Uri; -import android.os.Build; - -import androidx.core.content.FileProvider; - -import org.json.JSONArray; -import org.json.JSONObject; - -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.Iterator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; - -import javax.net.ssl.HttpsURLConnection; - -import io.flutter.plugin.common.MethodCall; -import io.flutter.plugin.common.MethodChannel; - -/** - * User-driven Android release updates for Flutter's platform lifecycle channel. - * The Dart layer supplies no URLs or package data: native code verifies T4's - * exact first-party manifest, APK bytes, package identity, version, and signer - * before a separate user action may open Android's installer. - */ -final class T4UpdatePlugin { - static final String CHANNEL_NAME = "com.lycaonsolutions.t4code/platform_lifecycle"; - - private static final String MANIFEST_URL = "https://t4code.net/releases/latest.json"; - private static final String EXPECTED_PACKAGE_ID = "com.lycaonsolutions.t4code"; - private static final String UPDATE_CACHE_DIRECTORY = "t4-updates"; - private static final String APK_MIME_TYPE = "application/vnd.android.package-archive"; - private static final int NETWORK_TIMEOUT_MS = 8_000; - private static final int MAX_MANIFEST_BYTES = 64 * 1024; - private static final long MAX_ASSET_BYTES = 1024L * 1024L * 1024L; - private static final int MAX_ASSET_REDIRECTS = 4; - private static final int MAX_TEXT_LENGTH = 512; - - private final Activity activity; - private final T4UpdateStateMachine updateState = new T4UpdateStateMachine(); - private final T4UpdateFileStore updateFiles; - private final ExecutorService executor = new ThreadPoolExecutor( - 1, - 1, - 0L, - TimeUnit.MILLISECONDS, - new ArrayBlockingQueue<>(8), - runnable -> { - Thread thread = new Thread(runnable, "T4VerifiedUpdate"); - thread.setDaemon(true); - return thread; - }, - new ThreadPoolExecutor.AbortPolicy() - ); - - private String latestVersion; - private Long checkedAt; - private String errorMessage; - private String statusMessage; - private ManifestRelease validatedRelease; - private File verifiedPackage; - private File installerHandoff; - private boolean installerWasPaused; - private boolean recoveredHandoff; - private volatile boolean destroyed; - - T4UpdatePlugin(Activity activity) { - this.activity = activity; - updateFiles = new T4UpdateFileStore( - new File(activity.getCacheDir(), UPDATE_CACHE_DIRECTORY) - ); - submitInternal(() -> { - try { - File recovered = updateFiles.prepareOnStartup(); - synchronized (this) { - if (destroyed) return; - installerHandoff = recovered; - recoveredHandoff = recovered != null; - } - } catch (IOException ignored) { - // The foreground download path retries the private cache sweep. - } - }); - } - - void handle(MethodCall call, MethodChannel.Result result) { - if (call.arguments != null) { - sendError(result, "invalid_state", "Android update methods do not accept arguments."); - return; - } - switch (call.method) { - case "update.getState": - submit(result, () -> completeSuccess(result, statePayload())); - break; - case "update.check": - submit(result, () -> checkForUpdate(result)); - break; - case "update.download": - submit(result, () -> downloadUpdate(result)); - break; - case "update.install": - submit(result, () -> installUpdate(result)); - break; - default: - result.notImplemented(); - break; - } - } - - void onPause() { - synchronized (this) { - if (installerHandoff != null && "installer".equals(updateState.phase())) { - installerWasPaused = true; - } - } - } - - void onResume() { - submitInternal(() -> { - final File completedHandoff; - synchronized (this) { - if (installerHandoff == null || (!recoveredHandoff && !installerWasPaused)) return; - completedHandoff = installerHandoff; - installerHandoff = null; - recoveredHandoff = false; - installerWasPaused = false; - verifiedPackage = null; - if ("installer".equals(updateState.phase())) { - boolean canRetry = validatedRelease != null; - updateState.installerReturned(canRetry); - errorMessage = null; - statusMessage = canRetry - ? "Android's installer closed. Download the release again to retry." - : null; - } - } - finishInstallerHandoff(completedHandoff); - }); - } - - void destroy() { - destroyed = true; - executor.shutdownNow(); - File unhandedPackage; - synchronized (this) { - unhandedPackage = verifiedPackage; - verifiedPackage = null; - validatedRelease = null; - statusMessage = null; - errorMessage = null; - updateState.reset(); - } - updateFiles.discard(unhandedPackage); - updateFiles.cleanupForDestroy(); - } - - private void checkForUpdate(MethodChannel.Result result) { - File stalePackage; - synchronized (this) { - if (!updateState.beginCheck()) { - completeSuccess(result, statePayload()); - return; - } - stalePackage = verifiedPackage; - verifiedPackage = null; - statusMessage = "Checking the published Android release."; - errorMessage = null; - } - updateFiles.discard(stalePackage); - - try { - ManifestRelease release = fetchRelease(); - String currentVersion = currentVersion(); - int comparison = T4UpdateVerifier.compareVersions(release.version, currentVersion); - Map state; - synchronized (this) { - if (!updateState.finishCheck(comparison > 0 ? "available" : "current")) { - completeSuccess(result, statePayload()); - return; - } - latestVersion = release.version; - checkedAt = System.currentTimeMillis(); - errorMessage = null; - statusMessage = null; - validatedRelease = comparison > 0 ? release : null; - state = statePayload(); - } - completeSuccess(result, state); - } catch (IOException error) { - finishCheckFailure( - result, - "T4 Code could not reach the published Android release. Check your connection and try again." - ); - } catch (Exception error) { - finishCheckFailure( - result, - "T4 Code could not validate the published Android release manifest." - ); - } - } - - private void finishCheckFailure(MethodChannel.Result result, String message) { - Map state; - synchronized (this) { - if (!updateState.finishCheck("error")) { - completeSuccess(result, statePayload()); - return; - } - latestVersion = null; - checkedAt = System.currentTimeMillis(); - validatedRelease = null; - statusMessage = null; - errorMessage = boundedText(message); - state = statePayload(); - } - completeSuccess(result, state); - } - - private void downloadUpdate(MethodChannel.Result result) { - final ManifestRelease release; - synchronized (this) { - release = validatedRelease; - if (updateState.beginDownload(release != null) != T4UpdateStateMachine.DownloadStart.STARTED) { - completeSuccess(result, statePayload()); - return; - } - errorMessage = null; - statusMessage = "Downloading and verifying the published Android APK."; - } - - File packageFile = null; - try { - if (destroyed) throw new IllegalStateException("activity was destroyed"); - packageFile = downloadVerifiedPackage(release); - verifyAndroidPackage(packageFile, release.version); - if (destroyed) throw new IllegalStateException("activity was destroyed"); - Map state; - synchronized (this) { - verifiedPackage = packageFile; - updateState.downloadSucceeded(); - errorMessage = null; - statusMessage = "The Android update is verified and ready for installation."; - state = statePayload(); - } - completeSuccess(result, state); - } catch (Exception error) { - updateFiles.discard(packageFile); - Map state; - synchronized (this) { - if ("downloading".equals(updateState.phase())) updateState.downloadFailed(); - verifiedPackage = null; - validatedRelease = null; - statusMessage = null; - errorMessage = boundedText( - "T4 Code could not verify the Android update. Your current installation is unchanged." - ); - state = statePayload(); - } - completeSuccess(result, state); - } - } - - private void installUpdate(MethodChannel.Result result) { - final File packageFile; - synchronized (this) { - packageFile = verifiedPackage; - if (packageFile == null || !packageFile.isFile() || !"available".equals(updateState.phase())) { - sendError(result, "invalid_state", "Download and verify an available update first."); - return; - } - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - !activity.getPackageManager().canRequestPackageInstalls()) { - sendError( - result, - "installer_unavailable", - "Android has not allowed T4 Code to request package installation." - ); - return; - } - - final File handoff; - final Uri contentUri; - try { - handoff = updateFiles.beginInstallerHandoff(packageFile); - contentUri = FileProvider.getUriForFile( - activity, - activity.getPackageName() + ".fileprovider", - handoff - ); - synchronized (this) { - installerHandoff = handoff; - recoveredHandoff = false; - installerWasPaused = false; - verifiedPackage = null; - } - } catch (Exception error) { - updateFiles.discard(packageFile); - synchronized (this) { - verifiedPackage = null; - } - sendError(result, "installer_unavailable", "Android's package installer is unavailable."); - return; - } - - CountDownLatch installerCompleted = new CountDownLatch(1); - activity.runOnUiThread(() -> { - try { - if (destroyed) { - failInstallerHandoff(result, handoff); - return; - } - Intent intent = new Intent(Intent.ACTION_VIEW); - intent.setDataAndType(contentUri, APK_MIME_TYPE); - intent.setClipData(ClipData.newRawUri("T4 Code update", contentUri)); - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - activity.startActivity(intent); - Map state; - synchronized (this) { - updateState.installerOpened(); - errorMessage = null; - statusMessage = "The verified APK is open in Android's installer."; - state = statePayload(); - } - result.success(state); - } catch (Exception error) { - failInstallerHandoff(result, handoff); - } finally { - installerCompleted.countDown(); - } - }); - try { - installerCompleted.await(); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - } - } - - private void failInstallerHandoff(MethodChannel.Result result, File handoff) { - synchronized (this) { - installerHandoff = null; - recoveredHandoff = false; - installerWasPaused = false; - } - submitInternal(() -> finishInstallerHandoff(handoff)); - result.error( - "installer_unavailable", - boundedText("Android's package installer is unavailable."), - null - ); - } - - private File downloadVerifiedPackage(ManifestRelease release) throws Exception { - updateFiles.prepareForDownload(); - File partial = updateFiles.createPartial(release.version); - HttpsURLConnection connection = null; - try { - connection = openAssetConnection(release.apkUrl); - long responseSize = connection.getContentLengthLong(); - if (responseSize >= 0 && responseSize != release.apkSize) { - throw new IllegalStateException("release response size does not match its manifest"); - } - try ( - InputStream input = new BufferedInputStream(connection.getInputStream()); - FileOutputStream fileOutput = new FileOutputStream(partial, false); - BufferedOutputStream output = new BufferedOutputStream(fileOutput) - ) { - T4UpdateVerifier.copyExact(input, output, release.apkSize, release.apkSha256); - fileOutput.getFD().sync(); - } - return updateFiles.finalizeVerified(partial); - } catch (Exception error) { - updateFiles.discard(partial); - throw error; - } finally { - if (connection != null) connection.disconnect(); - } - } - - private HttpsURLConnection openAssetConnection(String validatedUrl) throws Exception { - URL current = new URL(validatedUrl); - for (int redirects = 0; redirects <= MAX_ASSET_REDIRECTS; redirects += 1) { - T4UpdateVerifier.requireAllowedAssetUrl(current, redirects == 0); - if (redirects == 0 && !validatedUrl.equals(current.toString())) { - throw new IllegalStateException("release asset URL changed before download"); - } - HttpsURLConnection connection = (HttpsURLConnection) current.openConnection(); - connection.setConnectTimeout(NETWORK_TIMEOUT_MS); - connection.setReadTimeout(NETWORK_TIMEOUT_MS); - connection.setInstanceFollowRedirects(false); - connection.setRequestMethod("GET"); - connection.setRequestProperty("Accept", APK_MIME_TYPE); - connection.setRequestProperty("Accept-Encoding", "identity"); - connection.setUseCaches(false); - int status = connection.getResponseCode(); - if (status == HttpsURLConnection.HTTP_OK) return connection; - if (!T4UpdateVerifier.isRedirectStatus(status) || redirects == MAX_ASSET_REDIRECTS) { - connection.disconnect(); - throw new IllegalStateException("release asset response was not successful"); - } - String location = connection.getHeaderField("Location"); - connection.disconnect(); - if (location == null || location.isEmpty() || location.length() > 8192) { - throw new IllegalStateException("release asset redirect is invalid"); - } - current = new URL(current, location); - } - throw new IllegalStateException("release asset redirect limit exceeded"); - } - - private void verifyAndroidPackage(File packageFile, String expectedVersion) throws Exception { - PackageManager manager = activity.getPackageManager(); - PackageInfo candidate = archivePackageInfo(manager, packageFile); - if (candidate == null || !EXPECTED_PACKAGE_ID.equals(candidate.packageName)) { - throw new IllegalStateException("update package identity does not match T4 Code"); - } - if (!expectedVersion.equals(candidate.versionName)) { - throw new IllegalStateException("update package version does not match its manifest"); - } - PackageInfo installed = installedPackageInfo(manager); - if (!EXPECTED_PACKAGE_ID.equals(installed.packageName)) { - throw new IllegalStateException("installed package identity does not match T4 Code"); - } - boolean trustedSigner; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - SigningEvidence installedSigning = signingEvidence(installed); - SigningEvidence candidateSigning = signingEvidence(candidate); - trustedSigner = T4UpdateVerifier.isTrustedSignerTransition( - installedSigning.current, - installedSigning.history, - installedSigning.multiple, - candidateSigning.current, - candidateSigning.history, - candidateSigning.multiple - ); - } else { - trustedSigner = T4UpdateVerifier.sameSignerSet(legacySigners(installed), legacySigners(candidate)); - } - if (!trustedSigner) { - throw new IllegalStateException("update package signer does not match this installation"); - } - } - - @SuppressWarnings("deprecation") - private PackageInfo installedPackageInfo(PackageManager manager) throws Exception { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return manager.getPackageInfo( - EXPECTED_PACKAGE_ID, - PackageManager.PackageInfoFlags.of(PackageManager.GET_SIGNING_CERTIFICATES) - ); - } - int flags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P - ? PackageManager.GET_SIGNING_CERTIFICATES - : PackageManager.GET_SIGNATURES; - return manager.getPackageInfo(EXPECTED_PACKAGE_ID, flags); - } - - @SuppressWarnings("deprecation") - private PackageInfo archivePackageInfo(PackageManager manager, File packageFile) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return manager.getPackageArchiveInfo( - packageFile.getAbsolutePath(), - PackageManager.PackageInfoFlags.of(PackageManager.GET_SIGNING_CERTIFICATES) - ); - } - int flags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P - ? PackageManager.GET_SIGNING_CERTIFICATES - : PackageManager.GET_SIGNATURES; - return manager.getPackageArchiveInfo(packageFile.getAbsolutePath(), flags); - } - - @android.annotation.TargetApi(Build.VERSION_CODES.P) - private SigningEvidence signingEvidence(PackageInfo packageInfo) { - SigningInfo signingInfo = packageInfo.signingInfo; - if (signingInfo == null) return new SigningEvidence(new ArrayList<>(), new ArrayList<>(), false); - boolean multiple = signingInfo.hasMultipleSigners(); - List current = signatureBytes(signingInfo.getApkContentsSigners()); - List history = multiple ? new ArrayList<>() : signatureBytes(signingInfo.getSigningCertificateHistory()); - return new SigningEvidence(current, history, multiple); - } - - @SuppressWarnings("deprecation") - private List legacySigners(PackageInfo packageInfo) { - return signatureBytes(packageInfo.signatures); - } - - private List signatureBytes(Signature[] signatures) { - if (signatures == null) return new ArrayList<>(); - List result = new ArrayList<>(signatures.length); - for (Signature signature : signatures) result.add(signature.toByteArray()); - return result; - } - - @SuppressWarnings("deprecation") - private String currentVersion() { - String packageName = activity.getPackageName(); - if (!EXPECTED_PACKAGE_ID.equals(packageName)) { - throw new IllegalStateException("Android application identity is invalid"); - } - try { - PackageInfo packageInfo; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageInfo = activity.getPackageManager().getPackageInfo( - packageName, - PackageManager.PackageInfoFlags.of(0) - ); - } else { - packageInfo = activity.getPackageManager().getPackageInfo(packageName, 0); - } - String version = packageInfo.versionName; - if (!T4UpdateVerifier.isValidVersion(version)) { - throw new IllegalStateException("Android application version is invalid"); - } - return version; - } catch (PackageManager.NameNotFoundException error) { - throw new IllegalStateException("Android application identity is unavailable", error); - } - } - - private ManifestRelease fetchRelease() throws Exception { - HttpsURLConnection connection = (HttpsURLConnection) new URL(MANIFEST_URL).openConnection(); - connection.setConnectTimeout(NETWORK_TIMEOUT_MS); - connection.setReadTimeout(NETWORK_TIMEOUT_MS); - connection.setInstanceFollowRedirects(false); - connection.setRequestMethod("GET"); - connection.setRequestProperty("Accept", "application/json"); - connection.setUseCaches(false); - try { - if (connection.getResponseCode() != HttpsURLConnection.HTTP_OK) { - throw new IOException("update manifest response was not successful"); - } - long declaredLength = connection.getContentLengthLong(); - if (declaredLength > MAX_MANIFEST_BYTES) { - throw new IllegalStateException("update manifest is too large"); - } - byte[] bytes; - try (InputStream input = connection.getInputStream()) { - bytes = readBounded(input); - } - return parseManifest(new JSONObject(new String(bytes, StandardCharsets.UTF_8))); - } finally { - connection.disconnect(); - } - } - - private byte[] readBounded(InputStream input) throws Exception { - ByteArrayOutputStream output = new ByteArrayOutputStream(); - byte[] buffer = new byte[8 * 1024]; - int count; - while ((count = input.read(buffer)) != -1) { - if (output.size() + count > MAX_MANIFEST_BYTES) { - throw new IllegalStateException("update manifest is too large"); - } - output.write(buffer, 0, count); - } - return output.toByteArray(); - } - - private ManifestRelease parseManifest(JSONObject manifest) throws Exception { - requireExactKeys( - manifest, - "schemaVersion", - "channel", - "version", - "tag", - "publishedAt", - "releaseUrl", - "assets" - ); - if (requireJsonInteger(manifest, "schemaVersion") != 1 || - !"stable".equals(requireJsonString(manifest, "channel"))) { - throw new IllegalStateException("unsupported update manifest"); - } - - String version = requireJsonString(manifest, "version"); - T4UpdateVerifier.requireManifestReleaseIdentity( - version, - requireJsonString(manifest, "tag"), - requireJsonString(manifest, "releaseUrl"), - requireJsonString(manifest, "publishedAt") - ); - - Object assetsValue = manifest.get("assets"); - if (!(assetsValue instanceof JSONArray)) throw new IllegalStateException("release assets must be an array"); - JSONArray assets = (JSONArray) assetsValue; - if (assets.length() != 5) throw new IllegalStateException("invalid release asset count"); - Set identities = new HashSet<>(); - String apkUrl = null; - Long apkSize = null; - String apkSha256 = null; - for (int index = 0; index < assets.length(); index += 1) { - JSONObject asset = assets.getJSONObject(index); - requireExactKeys(asset, "platform", "kind", "arch", "name", "url", "size", "sha256"); - String platform = requireJsonString(asset, "platform"); - String kind = requireJsonString(asset, "kind"); - String arch = requireJsonString(asset, "arch"); - String name = requireJsonString(asset, "name"); - String url = requireJsonString(asset, "url"); - long size = requireJsonInteger(asset, "size"); - String sha256 = requireJsonString(asset, "sha256"); - String identity = T4UpdateVerifier.requireManifestAsset( - version, - platform, - kind, - arch, - name, - url, - size, - sha256, - MAX_ASSET_BYTES - ); - if (!identities.add(identity)) throw new IllegalStateException("duplicate release asset"); - if ("android:apk:universal".equals(identity)) { - apkUrl = url; - apkSize = size; - apkSha256 = sha256; - } - } - if (identities.size() != 5 || apkUrl == null || apkSize == null || apkSha256 == null) { - throw new IllegalStateException("Android release asset is missing"); - } - return new ManifestRelease(version, apkUrl, apkSize, apkSha256); - } - - private String requireJsonString(JSONObject object, String key) throws Exception { - Object value = object.get(key); - if (!(value instanceof String)) throw new IllegalStateException(key + " must be a string"); - String string = (String) value; - if (string.length() > 8192) throw new IllegalStateException(key + " is too long"); - return string; - } - - private long requireJsonInteger(JSONObject object, String key) throws Exception { - Object value = object.get(key); - if (!(value instanceof Number)) throw new IllegalStateException(key + " must be an integer"); - Number number = (Number) value; - long integer = number.longValue(); - double numeric = number.doubleValue(); - if (Double.isNaN(numeric) || Double.isInfinite(numeric) || numeric != (double) integer) { - throw new IllegalStateException(key + " must be an integer"); - } - return integer; - } - - private void requireExactKeys(JSONObject object, String... expected) { - Set keys = new HashSet<>(); - Iterator iterator = object.keys(); - while (iterator.hasNext()) keys.add(iterator.next()); - Set allowed = new HashSet<>(); - for (String key : expected) allowed.add(key); - if (!keys.equals(allowed)) throw new IllegalStateException("unexpected update manifest fields"); - } - - private synchronized Map statePayload() { - Map result = new LinkedHashMap<>(7); - result.put("currentVersion", currentVersion()); - result.put("phase", updateState.phase()); - result.put("revision", updateState.revision()); - if (latestVersion != null) result.put("latestVersion", latestVersion); - if (checkedAt != null) result.put("checkedAt", checkedAt); - if (errorMessage != null) result.put("error", boundedText(errorMessage)); - if (statusMessage != null) result.put("message", boundedText(statusMessage)); - return result; - } - - private String boundedText(String message) { - if (message == null) return ""; - StringBuilder output = new StringBuilder(Math.min(message.length(), MAX_TEXT_LENGTH)); - for (int index = 0; index < message.length() && output.length() < MAX_TEXT_LENGTH; index += 1) { - char character = message.charAt(index); - output.append(character <= 0x1f || character == 0x7f ? ' ' : character); - } - return output.toString(); - } - - private void submit(MethodChannel.Result result, Runnable operation) { - try { - executor.execute(() -> { - try { - operation.run(); - } catch (Exception error) { - sendError(result, "invalid_state", "Android update state is unavailable."); - } - }); - } catch (RejectedExecutionException error) { - sendError(result, "invalid_state", "Android update operations are unavailable."); - } - } - - private void submitInternal(Runnable operation) { - try { - executor.execute(operation); - } catch (RejectedExecutionException ignored) { - // Activity teardown owns final private-cache cleanup. - } - } - - private void completeSuccess(MethodChannel.Result result, Map value) { - activity.runOnUiThread(() -> result.success(value)); - } - - private void sendError(MethodChannel.Result result, String code, String message) { - activity.runOnUiThread(() -> result.error(code, boundedText(message), null)); - } - - private void finishInstallerHandoff(File handoff) { - if (handoff == null) return; - try { - updateFiles.finishInstallerHandoff(handoff); - } catch (IOException ignored) { - // Startup and pre-download sweeps retry this bounded one-file cleanup. - } - } - - private static final class ManifestRelease { - private final String version; - private final String apkUrl; - private final long apkSize; - private final String apkSha256; - - private ManifestRelease(String version, String apkUrl, long apkSize, String apkSha256) { - this.version = version; - this.apkUrl = apkUrl; - this.apkSize = apkSize; - this.apkSha256 = apkSha256; - } - } - - private static final class SigningEvidence { - private final List current; - private final List history; - private final boolean multiple; - - private SigningEvidence(List current, List history, boolean multiple) { - this.current = current; - this.history = history; - this.multiple = multiple; - } - } -} diff --git a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateStateMachine.java b/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateStateMachine.java deleted file mode 100644 index 5fd814c5..00000000 --- a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateStateMachine.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.lycaonsolutions.t4code; - -/** Small synchronized state machine so bridge calls and the download worker cannot race. */ -final class T4UpdateStateMachine { - enum DownloadStart { - STARTED, - BUSY, - HANDED_OFF, - UNAVAILABLE, - } - - private String phase = "idle"; - private long revision; - private boolean downloadInProgress; - - synchronized String phase() { - return phase; - } - - synchronized long revision() { - return revision; - } - - synchronized boolean beginCheck() { - if (downloadInProgress || "checking".equals(phase) || "installer".equals(phase)) return false; - transition("checking"); - return true; - } - - synchronized boolean finishCheck(String resultPhase) { - if (!("available".equals(resultPhase) || "current".equals(resultPhase) || "error".equals(resultPhase))) { - throw new IllegalArgumentException("invalid check result phase"); - } - if (downloadInProgress || !"checking".equals(phase)) return false; - transition(resultPhase); - return true; - } - - synchronized DownloadStart beginDownload(boolean hasValidatedRelease) { - if (downloadInProgress || "downloading".equals(phase)) return DownloadStart.BUSY; - if ("installer".equals(phase)) return DownloadStart.HANDED_OFF; - if (!hasValidatedRelease || !"available".equals(phase)) return DownloadStart.UNAVAILABLE; - downloadInProgress = true; - transition("downloading"); - return DownloadStart.STARTED; - } - - synchronized void downloadSucceeded() { - requireActiveDownload(); - downloadInProgress = false; - transition("available"); - } - - synchronized void installerOpened() { - if (downloadInProgress || !"available".equals(phase)) { - throw new IllegalStateException("no verified update is ready for installation"); - } - transition("installer"); - } - - synchronized void installerReturned(boolean updateStillAvailable) { - if (!"installer".equals(phase) || downloadInProgress) { - throw new IllegalStateException("no installer handoff is active"); - } - transition(updateStillAvailable ? "available" : "idle"); - } - - synchronized void downloadFailed() { - requireActiveDownload(); - downloadInProgress = false; - transition("error"); - } - - synchronized void reset() { - downloadInProgress = false; - transition("idle"); - } - - private void requireActiveDownload() { - if (!downloadInProgress || !"downloading".equals(phase)) { - throw new IllegalStateException("no verified update download is active"); - } - } - - private void transition(String nextPhase) { - phase = nextPhase; - revision += 1; - } -} diff --git a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateVerifier.java b/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateVerifier.java deleted file mode 100644 index f3c8d3da..00000000 --- a/apps/flutter/android/app/src/main/java/com/lycaonsolutions/t4code/T4UpdateVerifier.java +++ /dev/null @@ -1,251 +0,0 @@ -package com.lycaonsolutions.t4code; - -import java.io.InputStream; -import java.io.OutputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.security.MessageDigest; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.regex.Pattern; - -/** Pure-Java verification primitives shared by the Android updater and JVM tests. */ -final class T4UpdateVerifier { - private static final int COPY_BUFFER_BYTES = 64 * 1024; - private static final String RELEASE_DOWNLOAD_ROOT = "https://github.com/LycaonLLC/t4-code/releases/download/"; - private static final String RELEASE_PAGE_ROOT = "https://github.com/LycaonLLC/t4-code/releases/tag/"; - private static final Pattern VERSION_PATTERN = Pattern.compile( - "^(?:0|[1-9][0-9]{0,5})\\.(?:0|[1-9][0-9]{0,5})\\.(?:0|[1-9][0-9]{0,5})$" - ); - private static final Pattern SHA256_PATTERN = Pattern.compile("^[0-9a-f]{64}$"); - private static final Pattern PUBLISHED_AT_PATTERN = Pattern.compile( - "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{1,9})?Z$" - ); - - private T4UpdateVerifier() {} - - static void copyExact( - InputStream input, - OutputStream output, - long expectedSize, - String expectedSha256 - ) throws Exception { - if (expectedSize <= 0) throw new IllegalArgumentException("expected size must be positive"); - if (!isValidSha256(expectedSha256)) { - throw new IllegalArgumentException("expected SHA-256 is invalid"); - } - - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] buffer = new byte[COPY_BUFFER_BYTES]; - long total = 0; - int count; - while ((count = input.read(buffer)) != -1) { - if (count == 0) continue; - if (count > expectedSize - total) { - throw new IllegalStateException("downloaded package exceeds its declared size"); - } - output.write(buffer, 0, count); - digest.update(buffer, 0, count); - total += count; - } - if (total != expectedSize) { - throw new IllegalStateException("downloaded package size does not match its manifest"); - } - String actualSha256 = lowercaseHex(digest.digest()); - if (!MessageDigest.isEqual( - expectedSha256.getBytes(java.nio.charset.StandardCharsets.US_ASCII), - actualSha256.getBytes(java.nio.charset.StandardCharsets.US_ASCII) - )) { - throw new IllegalStateException("downloaded package digest does not match its manifest"); - } - output.flush(); - } - - static boolean sameSignerSet(List installed, List candidate) throws Exception { - if (installed == null || candidate == null || installed.isEmpty() || candidate.isEmpty()) return false; - Set installedFingerprints = signerFingerprints(installed); - Set candidateFingerprints = signerFingerprints(candidate); - return installedFingerprints.size() == installed.size() && - candidateFingerprints.size() == candidate.size() && - installedFingerprints.equals(candidateFingerprints); - } - - /** - * Accepts the same signer, or a forward single-signer rotation whose - * PackageManager-verified candidate history contains the installed current - * signer. Multi-signer packages cannot rotate and must match exactly. - */ - static boolean isTrustedSignerTransition( - List installedCurrent, - List installedHistory, - boolean installedHasMultipleSigners, - List candidateCurrent, - List candidateHistory, - boolean candidateHasMultipleSigners - ) throws Exception { - if (installedHasMultipleSigners || candidateHasMultipleSigners) { - return installedHasMultipleSigners && - candidateHasMultipleSigners && - sameSignerSet(installedCurrent, candidateCurrent); - } - if (installedCurrent == null || candidateCurrent == null || installedCurrent.size() != 1 || candidateCurrent.size() != 1) { - return false; - } - - List installedLineage = orderedSignerFingerprints(installedHistory); - List candidateLineage = orderedSignerFingerprints(candidateHistory); - if (installedLineage.isEmpty() || candidateLineage.isEmpty()) return false; - String installedSigner = signerFingerprint(installedCurrent.get(0)); - String candidateSigner = signerFingerprint(candidateCurrent.get(0)); - if (installedSigner == null || candidateSigner == null) return false; - if (!installedSigner.equals(installedLineage.get(installedLineage.size() - 1))) return false; - if (!candidateSigner.equals(candidateLineage.get(candidateLineage.size() - 1))) return false; - if (new HashSet<>(installedLineage).size() != installedLineage.size()) return false; - if (new HashSet<>(candidateLineage).size() != candidateLineage.size()) return false; - if (installedSigner.equals(candidateSigner)) return true; - - int installedSignerInCandidateHistory = candidateLineage.indexOf(installedSigner); - return installedSignerInCandidateHistory >= 0 && - installedSignerInCandidateHistory < candidateLineage.size() - 1; - } - - static int compareVersions(String left, String right) { - int[] leftParts = strictVersionParts(left); - int[] rightParts = strictVersionParts(right); - for (int index = 0; index < leftParts.length; index += 1) { - int comparison = Integer.compare(leftParts[index], rightParts[index]); - if (comparison != 0) return comparison; - } - return 0; - } - - static String expectedAssetName(String version, String platform, String kind, String arch) { - strictVersionParts(version); - String identity = platform + ":" + kind + ":" + arch; - switch (identity) { - case "android:apk:universal": - return "T4-Code-" + version + "-android.apk"; - case "linux:deb:x86_64": - return "T4-Code-" + version + "-linux-amd64.deb"; - case "linux:appimage:x86_64": - return "T4-Code-" + version + "-linux-x86_64.AppImage"; - case "mac:dmg:arm64": - return "T4-Code-" + version + "-mac-arm64.dmg"; - case "mac:zip:arm64": - return "T4-Code-" + version + "-mac-arm64.zip"; - default: - throw new IllegalArgumentException("unknown release asset"); - } - } - - static void requireManifestReleaseIdentity( - String version, - String tag, - String releaseUrl, - String publishedAt - ) { - strictVersionParts(version); - String expectedTag = "v" + version; - if (!expectedTag.equals(tag)) throw new IllegalArgumentException("release tag mismatch"); - if (!(RELEASE_PAGE_ROOT + expectedTag).equals(releaseUrl)) { - throw new IllegalArgumentException("release page mismatch"); - } - if (publishedAt == null || publishedAt.length() > 64 || !PUBLISHED_AT_PATTERN.matcher(publishedAt).matches()) { - throw new IllegalArgumentException("invalid release timestamp"); - } - } - - static String requireManifestAsset( - String version, - String platform, - String kind, - String arch, - String name, - String url, - long size, - String sha256, - long maximumSize - ) { - String identity = platform + ":" + kind + ":" + arch; - String expectedName = expectedAssetName(version, platform, kind, arch); - if (!expectedName.equals(name)) throw new IllegalArgumentException("release asset name mismatch"); - String expectedUrl = RELEASE_DOWNLOAD_ROOT + "v" + version + "/" + expectedName; - if (!expectedUrl.equals(url)) throw new IllegalArgumentException("release asset URL mismatch"); - if (size <= 0 || size > maximumSize) throw new IllegalArgumentException("invalid release asset size"); - if (!isValidSha256(sha256)) throw new IllegalArgumentException("invalid release asset digest"); - return identity; - } - - static void requireAllowedAssetUrl(URL url, boolean initial) { - if (!"https".equals(url.getProtocol()) || url.getUserInfo() != null || (url.getPort() != -1 && url.getPort() != 443)) { - throw new IllegalArgumentException("release asset connection is not secure"); - } - String host = url.getHost().toLowerCase(java.util.Locale.ROOT); - boolean trustedDownloadHost = "release-assets.githubusercontent.com".equals(host) || - "objects.githubusercontent.com".equals(host); - if (initial ? !"github.com".equals(host) : !("github.com".equals(host) || trustedDownloadHost)) { - throw new IllegalArgumentException("release asset host is not allowed"); - } - } - - static boolean isRedirectStatus(int status) { - return status == HttpURLConnection.HTTP_MOVED_PERM || - status == HttpURLConnection.HTTP_MOVED_TEMP || - status == HttpURLConnection.HTTP_SEE_OTHER || - status == 307 || - status == 308; - } - - static boolean isValidSha256(String value) { - return value != null && SHA256_PATTERN.matcher(value).matches(); - } - - static boolean isValidVersion(String value) { - return value != null && VERSION_PATTERN.matcher(value).matches(); - } - - private static Set signerFingerprints(List certificates) throws Exception { - Set fingerprints = new HashSet<>(); - for (byte[] certificate : certificates) { - if (certificate == null || certificate.length == 0) return new HashSet<>(); - fingerprints.add(lowercaseHex(MessageDigest.getInstance("SHA-256").digest(certificate))); - } - return fingerprints; - } - - private static List orderedSignerFingerprints(List certificates) throws Exception { - List fingerprints = new ArrayList<>(); - if (certificates == null) return fingerprints; - for (byte[] certificate : certificates) { - String fingerprint = signerFingerprint(certificate); - if (fingerprint == null) return new ArrayList<>(); - fingerprints.add(fingerprint); - } - return fingerprints; - } - - private static String signerFingerprint(byte[] certificate) throws Exception { - if (certificate == null || certificate.length == 0) return null; - return lowercaseHex(MessageDigest.getInstance("SHA-256").digest(certificate)); - } - - private static int[] strictVersionParts(String version) { - if (version == null || !VERSION_PATTERN.matcher(version).matches()) { - throw new IllegalArgumentException("release version is invalid"); - } - String[] values = version.split("\\."); - return new int[] { - Integer.parseInt(values[0]), - Integer.parseInt(values[1]), - Integer.parseInt(values[2]), - }; - } - - private static String lowercaseHex(byte[] value) { - StringBuilder result = new StringBuilder(value.length * 2); - for (byte item : value) result.append(String.format(java.util.Locale.ROOT, "%02x", item & 0xff)); - return result.toString(); - } -} diff --git a/apps/flutter/android/app/src/main/res/drawable-v21/launch_background.xml b/apps/flutter/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index f74085f3..00000000 --- a/apps/flutter/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/apps/flutter/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/apps/flutter/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index c7bd21db..00000000 --- a/apps/flutter/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - diff --git a/apps/flutter/android/app/src/main/res/drawable/launch_background.xml b/apps/flutter/android/app/src/main/res/drawable/launch_background.xml deleted file mode 100644 index 304732f8..00000000 --- a/apps/flutter/android/app/src/main/res/drawable/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/apps/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 036d09bc..00000000 --- a/apps/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/apps/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/apps/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index 036d09bc..00000000 --- a/apps/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/apps/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 04e56297..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/apps/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index ba8eafc3..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/apps/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index 04e56297..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 2e8a85cd..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/apps/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index 04344b6f..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/apps/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index 2e8a85cd..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 8e1579f7..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/apps/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index 8f46c7ff..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/apps/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index 8e1579f7..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 4e4e05be..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 8262c741..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/apps/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index 0d1adac8..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 7854e90f..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 54aca261..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/apps/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index 7854e90f..00000000 Binary files a/apps/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/apps/flutter/android/app/src/main/res/values-night/styles.xml b/apps/flutter/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index 06952be7..00000000 --- a/apps/flutter/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/apps/flutter/android/app/src/main/res/values/ic_launcher_background.xml b/apps/flutter/android/app/src/main/res/values/ic_launcher_background.xml deleted file mode 100644 index 06781cec..00000000 --- a/apps/flutter/android/app/src/main/res/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #0D0D0D - diff --git a/apps/flutter/android/app/src/main/res/values/styles.xml b/apps/flutter/android/app/src/main/res/values/styles.xml deleted file mode 100644 index cb1ef880..00000000 --- a/apps/flutter/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/apps/flutter/android/app/src/main/res/xml/update_file_paths.xml b/apps/flutter/android/app/src/main/res/xml/update_file_paths.xml deleted file mode 100644 index 44e6b48d..00000000 --- a/apps/flutter/android/app/src/main/res/xml/update_file_paths.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/apps/flutter/android/app/src/profile/AndroidManifest.xml b/apps/flutter/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index 399f6981..00000000 --- a/apps/flutter/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/apps/flutter/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateFileStoreTest.java b/apps/flutter/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateFileStoreTest.java deleted file mode 100644 index 8e2ffa0b..00000000 --- a/apps/flutter/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateFileStoreTest.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.lycaonsolutions.t4code; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; - -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -public final class T4UpdateFileStoreTest { - @Rule - public final TemporaryFolder temporaryFolder = new TemporaryFolder(); - - @Test - public void startupRemovesPartialsAndVerifiedFilesButKeepsOneHandoff() throws Exception { - File directory = temporaryFolder.newFolder("updates"); - File partial = write(directory, "T4-Code-1.2.3-a.apk.partial"); - File verified = write(directory, "T4-Code-1.2.3-b.apk"); - File olderHandoff = write(directory, "T4-Code-1.2.2-a-installer.apk"); - File newerHandoff = write(directory, "T4-Code-1.2.3-b-installer.apk"); - assertTrue(olderHandoff.setLastModified(1_000)); - assertTrue(newerHandoff.setLastModified(2_000)); - - T4UpdateFileStore store = new T4UpdateFileStore(directory); - File retained = store.prepareOnStartup(); - - assertEquals(newerHandoff.getAbsoluteFile(), retained.getAbsoluteFile()); - assertFalse(partial.exists()); - assertFalse(verified.exists()); - assertFalse(olderHandoff.exists()); - assertTrue(newerHandoff.exists()); - assertEquals(1, fileCount(directory)); - } - - @Test - public void foregroundDownloadClearsAProcessDeathHandoff() throws Exception { - File directory = temporaryFolder.newFolder("updates"); - File oldHandoff = write(directory, "T4-Code-1.2.2-a-installer.apk"); - T4UpdateFileStore store = new T4UpdateFileStore(directory); - assertNotNull(store.prepareOnStartup()); - - store.prepareForDownload(); - - assertFalse(oldHandoff.exists()); - assertNull(store.activeHandoff()); - assertEquals(0, fileCount(directory)); - } - - @Test - public void installerHandoffSurvivesDestroyThenIsRemovedOnReturn() throws Exception { - File directory = temporaryFolder.newFolder("updates"); - T4UpdateFileStore store = new T4UpdateFileStore(directory); - store.prepareForDownload(); - File partial = store.createPartial("1.2.3"); - writeBytes(partial); - File verified = store.finalizeVerified(partial); - File handoff = store.beginInstallerHandoff(verified); - File interruptedAfterHandoff = store.createPartial("1.2.4"); - writeBytes(interruptedAfterHandoff); - - assertFalse(partial.exists()); - assertFalse(verified.exists()); - assertTrue(handoff.exists()); - assertTrue(handoff.getName().endsWith("-installer.apk")); - - store.cleanupForDestroy(); - assertTrue(handoff.exists()); - assertFalse(interruptedAfterHandoff.exists()); - assertEquals(1, fileCount(directory)); - - T4UpdateFileStore recreated = new T4UpdateFileStore(directory); - File recovered = recreated.prepareOnStartup(); - assertEquals(handoff.getAbsoluteFile(), recovered.getAbsoluteFile()); - recreated.finishInstallerHandoff(recovered); - - assertFalse(handoff.exists()); - assertNull(recreated.activeHandoff()); - assertEquals(0, fileCount(directory)); - } - - @Test - public void destroyRemovesInterruptedAndUnhandedPackages() throws Exception { - File directory = temporaryFolder.newFolder("updates"); - T4UpdateFileStore store = new T4UpdateFileStore(directory); - store.prepareForDownload(); - File partial = store.createPartial("1.2.3"); - writeBytes(partial); - File verified = store.finalizeVerified(partial); - File secondPartial = store.createPartial("1.2.4"); - writeBytes(secondPartial); - - store.cleanupForDestroy(); - - assertFalse(partial.exists()); - assertFalse(verified.exists()); - assertFalse(secondPartial.exists()); - assertEquals(0, fileCount(directory)); - } - - @Test - public void staleActivityCannotSweepANewerInstallerHandoff() throws Exception { - File directory = temporaryFolder.newFolder("updates"); - T4UpdateFileStore stale = new T4UpdateFileStore(directory); - stale.prepareOnStartup(); - - T4UpdateFileStore current = new T4UpdateFileStore(directory); - current.prepareOnStartup(); - current.prepareForDownload(); - File partial = current.createPartial("1.2.3"); - writeBytes(partial); - File handoff = current.beginInstallerHandoff(current.finalizeVerified(partial)); - - assertThrows(IOException.class, stale::prepareForDownload); - stale.cleanupForDestroy(); - stale.discard(handoff); - - assertTrue(handoff.exists()); - assertEquals(handoff.getAbsoluteFile(), current.activeHandoff().getAbsoluteFile()); - assertEquals(1, fileCount(directory)); - } - - private static File write(File directory, String name) throws Exception { - File file = new File(directory, name); - writeBytes(file); - return file; - } - - private static void writeBytes(File file) throws Exception { - try (FileOutputStream output = new FileOutputStream(file)) { - output.write(new byte[] { 1, 2, 3, 4 }); - output.getFD().sync(); - } - } - - private static int fileCount(File directory) { - File[] files = directory.listFiles(); - return files == null ? 0 : files.length; - } -} diff --git a/apps/flutter/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateStateMachineTest.java b/apps/flutter/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateStateMachineTest.java deleted file mode 100644 index af524870..00000000 --- a/apps/flutter/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateStateMachineTest.java +++ /dev/null @@ -1,132 +0,0 @@ -package com.lycaonsolutions.t4code; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; - -import org.junit.Test; - -public final class T4UpdateStateMachineTest { - @Test - public void onlyOneConcurrentDownloadCanStart() throws Exception { - T4UpdateStateMachine state = new T4UpdateStateMachine(); - assertTrue(state.beginCheck()); - assertTrue(state.finishCheck("available")); - - int workers = 12; - ExecutorService executor = Executors.newFixedThreadPool(workers); - CountDownLatch start = new CountDownLatch(1); - List> attempts = new ArrayList<>(); - for (int index = 0; index < workers; index += 1) { - attempts.add(executor.submit(() -> { - start.await(); - return state.beginDownload(true); - })); - } - start.countDown(); - - int started = 0; - int busy = 0; - for (Future attempt : attempts) { - T4UpdateStateMachine.DownloadStart result = attempt.get(); - if (result == T4UpdateStateMachine.DownloadStart.STARTED) started += 1; - else if (result == T4UpdateStateMachine.DownloadStart.BUSY) busy += 1; - } - executor.shutdownNow(); - - assertEquals(1, started); - assertEquals(workers - 1, busy); - assertEquals("downloading", state.phase()); - assertFalse(state.beginCheck()); - } - - @Test - public void concurrentChecksProduceOneNativeTransition() throws Exception { - T4UpdateStateMachine state = new T4UpdateStateMachine(); - int workers = 12; - ExecutorService executor = Executors.newFixedThreadPool(workers); - CountDownLatch start = new CountDownLatch(1); - List> attempts = new ArrayList<>(); - for (int index = 0; index < workers; index += 1) { - attempts.add(executor.submit(() -> { - start.await(); - return state.beginCheck(); - })); - } - start.countDown(); - - int started = 0; - for (Future attempt : attempts) { - if (attempt.get()) started += 1; - } - executor.shutdownNow(); - - assertEquals(1, started); - assertEquals("checking", state.phase()); - assertEquals(1, state.revision()); - } - - @Test - public void staleCheckCompletionCannotReplaceAResetState() { - T4UpdateStateMachine state = new T4UpdateStateMachine(); - assertTrue(state.beginCheck()); - state.reset(); - long resetRevision = state.revision(); - - assertFalse(state.finishCheck("available")); - assertEquals("idle", state.phase()); - assertEquals(resetRevision, state.revision()); - } - - @Test - public void installerHandoffCannotStartAReplacementDownload() { - T4UpdateStateMachine state = new T4UpdateStateMachine(); - assertTrue(state.beginCheck()); - assertTrue(state.finishCheck("available")); - assertEquals(T4UpdateStateMachine.DownloadStart.STARTED, state.beginDownload(true)); - state.downloadSucceeded(); - state.installerOpened(); - - assertEquals("installer", state.phase()); - long handoffRevision = state.revision(); - assertEquals(T4UpdateStateMachine.DownloadStart.HANDED_OFF, state.beginDownload(true)); - assertEquals(handoffRevision, state.revision()); - assertFalse(state.beginCheck()); - } - - @Test - public void installerReturnAllowsTheVerifiedReleaseToBeRetried() { - T4UpdateStateMachine state = new T4UpdateStateMachine(); - assertTrue(state.beginCheck()); - assertTrue(state.finishCheck("available")); - assertEquals(T4UpdateStateMachine.DownloadStart.STARTED, state.beginDownload(true)); - state.downloadSucceeded(); - state.installerOpened(); - state.installerReturned(true); - - assertEquals("available", state.phase()); - assertEquals(T4UpdateStateMachine.DownloadStart.STARTED, state.beginDownload(true)); - } - - @Test - public void failedDownloadRequiresANewSuccessfulCheck() { - T4UpdateStateMachine state = new T4UpdateStateMachine(); - assertTrue(state.beginCheck()); - assertTrue(state.finishCheck("available")); - assertEquals(T4UpdateStateMachine.DownloadStart.STARTED, state.beginDownload(true)); - state.downloadFailed(); - - assertEquals("error", state.phase()); - assertEquals(T4UpdateStateMachine.DownloadStart.UNAVAILABLE, state.beginDownload(true)); - assertTrue(state.beginCheck()); - assertTrue(state.finishCheck("current")); - assertEquals("current", state.phase()); - } -} diff --git a/apps/flutter/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateVerifierTest.java b/apps/flutter/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateVerifierTest.java deleted file mode 100644 index 82404196..00000000 --- a/apps/flutter/android/app/src/test/java/com/lycaonsolutions/t4code/T4UpdateVerifierTest.java +++ /dev/null @@ -1,355 +0,0 @@ -package com.lycaonsolutions.t4code; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.nio.charset.StandardCharsets; -import java.net.URL; -import java.security.MessageDigest; -import java.util.Arrays; -import java.util.Collections; - -import org.junit.Test; - -public final class T4UpdateVerifierTest { - @Test - public void exactStreamAcceptsOnlyDeclaredBytesAndDigest() throws Exception { - byte[] packageBytes = "verified android package".getBytes(StandardCharsets.UTF_8); - ByteArrayOutputStream output = new ByteArrayOutputStream(); - - T4UpdateVerifier.copyExact( - new ByteArrayInputStream(packageBytes), - output, - packageBytes.length, - sha256(packageBytes) - ); - - assertArrayEquals(packageBytes, output.toByteArray()); - } - - @Test - public void exactStreamRejectsOversizedUndersizedAndAlteredPackages() throws Exception { - byte[] packageBytes = "package".getBytes(StandardCharsets.UTF_8); - - assertThrows( - IllegalStateException.class, - () -> T4UpdateVerifier.copyExact( - new ByteArrayInputStream(packageBytes), - new ByteArrayOutputStream(), - packageBytes.length - 1, - sha256(packageBytes) - ) - ); - assertThrows( - IllegalStateException.class, - () -> T4UpdateVerifier.copyExact( - new ByteArrayInputStream(packageBytes), - new ByteArrayOutputStream(), - packageBytes.length + 1, - sha256(packageBytes) - ) - ); - assertThrows( - IllegalStateException.class, - () -> T4UpdateVerifier.copyExact( - new ByteArrayInputStream(packageBytes), - new ByteArrayOutputStream(), - packageBytes.length, - sha256("different".getBytes(StandardCharsets.UTF_8)) - ) - ); - } - - @Test - public void signerSetRequiresTheSameNonEmptyCertificatesRegardlessOfOrder() throws Exception { - byte[] first = "first certificate".getBytes(StandardCharsets.UTF_8); - byte[] second = "second certificate".getBytes(StandardCharsets.UTF_8); - byte[] other = "other certificate".getBytes(StandardCharsets.UTF_8); - - assertTrue(T4UpdateVerifier.sameSignerSet(Arrays.asList(first, second), Arrays.asList(second, first))); - assertFalse(T4UpdateVerifier.sameSignerSet(Arrays.asList(first, second), Arrays.asList(first, other))); - assertFalse(T4UpdateVerifier.sameSignerSet(Arrays.asList(first, second), Collections.singletonList(first))); - assertFalse(T4UpdateVerifier.sameSignerSet(Collections.emptyList(), Collections.singletonList(first))); - } - - @Test - public void signerTransitionAcceptsVerifiedForwardRotationAndRejectsRollback() throws Exception { - byte[] first = "first certificate".getBytes(StandardCharsets.UTF_8); - byte[] second = "second certificate".getBytes(StandardCharsets.UTF_8); - byte[] third = "third certificate".getBytes(StandardCharsets.UTF_8); - - assertTrue(T4UpdateVerifier.isTrustedSignerTransition( - Collections.singletonList(first), - Collections.singletonList(first), - false, - Collections.singletonList(second), - Arrays.asList(first, second), - false - )); - assertTrue(T4UpdateVerifier.isTrustedSignerTransition( - Collections.singletonList(second), - Arrays.asList(first, second), - false, - Collections.singletonList(second), - Collections.singletonList(second), - false - )); - assertFalse(T4UpdateVerifier.isTrustedSignerTransition( - Collections.singletonList(second), - Arrays.asList(first, second), - false, - Collections.singletonList(first), - Collections.singletonList(first), - false - )); - assertTrue(T4UpdateVerifier.isTrustedSignerTransition( - Collections.singletonList(second), - Arrays.asList(first, second), - false, - Collections.singletonList(third), - Arrays.asList(first, second, third), - false - )); - } - - @Test - public void signerTransitionRejectsMalformedOrUnprovenLineage() throws Exception { - byte[] first = "first certificate".getBytes(StandardCharsets.UTF_8); - byte[] second = "second certificate".getBytes(StandardCharsets.UTF_8); - byte[] third = "third certificate".getBytes(StandardCharsets.UTF_8); - - assertFalse(T4UpdateVerifier.isTrustedSignerTransition( - Collections.singletonList(first), - Collections.emptyList(), - false, - Collections.singletonList(second), - Arrays.asList(first, second), - false - )); - assertFalse(T4UpdateVerifier.isTrustedSignerTransition( - Collections.singletonList(first), - Collections.singletonList(first), - false, - Collections.singletonList(second), - Arrays.asList(second, first), - false - )); - assertFalse(T4UpdateVerifier.isTrustedSignerTransition( - Collections.singletonList(first), - Collections.singletonList(first), - false, - Collections.singletonList(second), - Arrays.asList(first, first, second), - false - )); - assertFalse(T4UpdateVerifier.isTrustedSignerTransition( - Collections.singletonList(first), - Collections.singletonList(second), - false, - Collections.singletonList(second), - Arrays.asList(first, second), - false - )); - assertFalse(T4UpdateVerifier.isTrustedSignerTransition( - Collections.singletonList(second), - Arrays.asList(first, second), - false, - Collections.singletonList(third), - Arrays.asList(first, third), - false - )); - } - - @Test - public void multiSignerTransitionRequiresTheSameCompleteSignerSet() throws Exception { - byte[] first = "first certificate".getBytes(StandardCharsets.UTF_8); - byte[] second = "second certificate".getBytes(StandardCharsets.UTF_8); - - assertTrue(T4UpdateVerifier.isTrustedSignerTransition( - Arrays.asList(first, second), - Collections.emptyList(), - true, - Arrays.asList(second, first), - Collections.emptyList(), - true - )); - assertFalse(T4UpdateVerifier.isTrustedSignerTransition( - Arrays.asList(first, second), - Collections.emptyList(), - true, - Collections.singletonList(first), - Collections.singletonList(first), - false - )); - } - - @Test - public void versionsAssetsAndRedirectsAreStrict() throws Exception { - assertTrue(T4UpdateVerifier.compareVersions("1.2.4", "1.2.3") > 0); - assertEquals(0, T4UpdateVerifier.compareVersions("1.2.3", "1.2.3")); - assertTrue(T4UpdateVerifier.compareVersions("1.2.3", "2.0.0") < 0); - assertThrows(IllegalArgumentException.class, () -> T4UpdateVerifier.compareVersions("1.2", "1.2.0")); - assertThrows(IllegalArgumentException.class, () -> T4UpdateVerifier.compareVersions("01.2.3", "1.2.3")); - assertThrows(IllegalArgumentException.class, () -> T4UpdateVerifier.compareVersions("1.2.3-beta", "1.2.3")); - assertEquals( - "T4-Code-1.2.3-android.apk", - T4UpdateVerifier.expectedAssetName("1.2.3", "android", "apk", "universal") - ); - assertThrows( - IllegalArgumentException.class, - () -> T4UpdateVerifier.expectedAssetName("1.2.3", "android", "aab", "universal") - ); - - T4UpdateVerifier.requireAllowedAssetUrl( - new URL("https://github.com/LycaonLLC/t4-code/releases/download/v1.2.3/T4-Code-1.2.3-android.apk"), - true - ); - T4UpdateVerifier.requireAllowedAssetUrl( - new URL("https://release-assets.githubusercontent.com/github-production-release-asset/file?token=signed"), - false - ); - T4UpdateVerifier.requireAllowedAssetUrl( - new URL("https://objects.githubusercontent.com/github-production-release-asset/file"), - false - ); - assertThrows( - IllegalArgumentException.class, - () -> T4UpdateVerifier.requireAllowedAssetUrl(new URL("https://example.com/update.apk"), false) - ); - assertThrows( - IllegalArgumentException.class, - () -> T4UpdateVerifier.requireAllowedAssetUrl(new URL("http://github.com/update.apk"), true) - ); - assertThrows( - IllegalArgumentException.class, - () -> T4UpdateVerifier.requireAllowedAssetUrl(new URL("https://github.com.evil.example/update.apk"), true) - ); - assertThrows( - IllegalArgumentException.class, - () -> T4UpdateVerifier.requireAllowedAssetUrl(new URL("https://user@github.com/update.apk"), true) - ); - assertThrows( - IllegalArgumentException.class, - () -> T4UpdateVerifier.requireAllowedAssetUrl(new URL("https://github.com:444/update.apk"), true) - ); - assertThrows( - IllegalArgumentException.class, - () -> T4UpdateVerifier.requireAllowedAssetUrl( - new URL("https://release-assets.githubusercontent.com/update.apk"), - true - ) - ); - assertTrue(T4UpdateVerifier.isRedirectStatus(302)); - assertTrue(T4UpdateVerifier.isRedirectStatus(308)); - assertFalse(T4UpdateVerifier.isRedirectStatus(200)); - } - - @Test - public void manifestIdentityAndEveryPublishedAssetAreExact() { - String version = "1.2.3"; - String digest = String.join("", Collections.nCopies(64, "a")); - T4UpdateVerifier.requireManifestReleaseIdentity( - version, - "v1.2.3", - "https://github.com/LycaonLLC/t4-code/releases/tag/v1.2.3", - "2026-07-15T12:30:00.000Z" - ); - - String[][] assets = new String[][] { - { "android", "apk", "universal", "T4-Code-1.2.3-android.apk" }, - { "linux", "deb", "x86_64", "T4-Code-1.2.3-linux-amd64.deb" }, - { "linux", "appimage", "x86_64", "T4-Code-1.2.3-linux-x86_64.AppImage" }, - { "mac", "dmg", "arm64", "T4-Code-1.2.3-mac-arm64.dmg" }, - { "mac", "zip", "arm64", "T4-Code-1.2.3-mac-arm64.zip" }, - }; - for (String[] asset : assets) { - assertEquals( - asset[0] + ":" + asset[1] + ":" + asset[2], - T4UpdateVerifier.requireManifestAsset( - version, - asset[0], - asset[1], - asset[2], - asset[3], - "https://github.com/LycaonLLC/t4-code/releases/download/v1.2.3/" + asset[3], - 1024, - digest, - 2048 - ) - ); - } - - assertThrows( - IllegalArgumentException.class, - () -> T4UpdateVerifier.requireManifestReleaseIdentity( - version, - "v1.2.4", - "https://github.com/LycaonLLC/t4-code/releases/tag/v1.2.3", - "2026-07-15T12:30:00Z" - ) - ); - assertThrows( - IllegalArgumentException.class, - () -> T4UpdateVerifier.requireManifestReleaseIdentity( - version, - "v1.2.3", - "https://example.com/v1.2.3", - "not-a-timestamp" - ) - ); - assertThrows( - IllegalArgumentException.class, - () -> T4UpdateVerifier.requireManifestAsset( - version, - "android", - "apk", - "universal", - "T4-Code-1.2.3-android.apk", - "https://example.com/T4-Code-1.2.3-android.apk", - 1024, - digest, - 2048 - ) - ); - assertThrows( - IllegalArgumentException.class, - () -> T4UpdateVerifier.requireManifestAsset( - version, - "android", - "apk", - "universal", - "T4-Code-1.2.3-android.apk", - "https://github.com/LycaonLLC/t4-code/releases/download/v1.2.3/T4-Code-1.2.3-android.apk", - 2049, - digest, - 2048 - ) - ); - assertThrows( - IllegalArgumentException.class, - () -> T4UpdateVerifier.requireManifestAsset( - version, - "android", - "apk", - "universal", - "T4-Code-1.2.3-android.apk", - "https://github.com/LycaonLLC/t4-code/releases/download/v1.2.3/T4-Code-1.2.3-android.apk", - 1024, - digest.toUpperCase(java.util.Locale.ROOT), - 2048 - ) - ); - } - - private static String sha256(byte[] input) throws Exception { - byte[] digest = MessageDigest.getInstance("SHA-256").digest(input); - StringBuilder result = new StringBuilder(digest.length * 2); - for (byte item : digest) result.append(String.format(java.util.Locale.ROOT, "%02x", item & 0xff)); - return result.toString(); - } -} diff --git a/apps/flutter/android/build.gradle.kts b/apps/flutter/android/build.gradle.kts deleted file mode 100644 index dbee657b..00000000 --- a/apps/flutter/android/build.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - -val newBuildDir: Directory = - rootProject.layout.buildDirectory - .dir("../../build") - .get() -rootProject.layout.buildDirectory.value(newBuildDir) - -subprojects { - val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) - project.layout.buildDirectory.value(newSubprojectBuildDir) -} -subprojects { - project.evaluationDependsOn(":app") -} - -tasks.register("clean") { - delete(rootProject.layout.buildDirectory) -} diff --git a/apps/flutter/android/gradle.properties b/apps/flutter/android/gradle.properties deleted file mode 100644 index e96108cf..00000000 --- a/apps/flutter/android/gradle.properties +++ /dev/null @@ -1,6 +0,0 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -android.useAndroidX=true -# This newDsl flag was added by the Flutter template -android.newDsl=false -# This builtInKotlin flag was added by the Flutter template -android.builtInKotlin=false diff --git a/apps/flutter/android/gradle/wrapper/gradle-wrapper.properties b/apps/flutter/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 2d428bfb..00000000 --- a/apps/flutter/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/apps/flutter/android/settings.gradle.kts b/apps/flutter/android/settings.gradle.kts deleted file mode 100644 index c21f0c5b..00000000 --- a/apps/flutter/android/settings.gradle.kts +++ /dev/null @@ -1,26 +0,0 @@ -pluginManagement { - val flutterSdkPath = - run { - val properties = java.util.Properties() - file("local.properties").inputStream().use { properties.load(it) } - val flutterSdkPath = properties.getProperty("flutter.sdk") - require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } - flutterSdkPath - } - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "9.0.1" apply false - id("org.jetbrains.kotlin.android") version "2.3.20" apply false -} - -include(":app") diff --git a/apps/flutter/assets/fonts/DMSans-OFL.txt b/apps/flutter/assets/fonts/DMSans-OFL.txt deleted file mode 100644 index 4430b85a..00000000 --- a/apps/flutter/assets/fonts/DMSans-OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/flutter/assets/fonts/DMSans-Variable.ttf b/apps/flutter/assets/fonts/DMSans-Variable.ttf deleted file mode 100644 index c672f980..00000000 Binary files a/apps/flutter/assets/fonts/DMSans-Variable.ttf and /dev/null differ diff --git a/apps/flutter/assets/fonts/JetBrainsMono-OFL.txt b/apps/flutter/assets/fonts/JetBrainsMono-OFL.txt deleted file mode 100644 index 821a3dac..00000000 --- a/apps/flutter/assets/fonts/JetBrainsMono-OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. - -This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/flutter/assets/fonts/JetBrainsMono-Variable.ttf b/apps/flutter/assets/fonts/JetBrainsMono-Variable.ttf deleted file mode 100644 index aa310be8..00000000 Binary files a/apps/flutter/assets/fonts/JetBrainsMono-Variable.ttf and /dev/null differ diff --git a/apps/flutter/integration_test/app_smoke_test.dart b/apps/flutter/integration_test/app_smoke_test.dart deleted file mode 100644 index 54afbb60..00000000 --- a/apps/flutter/integration_test/app_smoke_test.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:t4code/main.dart' as app; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - testWidgets('native shell launches and opens host management', ( - tester, - ) async { - await tester.pumpWidget(const app.T4Bootstrap()); - await _pumpUntil( - tester, - () => - find.byTooltip('Open navigation').evaluate().isNotEmpty || - find.byTooltip('Manage hosts').evaluate().isNotEmpty || - find.text('Manage hosts').evaluate().isNotEmpty || - find.text('Add host').evaluate().isNotEmpty, - ); - - if (find.text('Add host').evaluate().isEmpty) { - final navigationButton = find.byTooltip('Open navigation'); - if (navigationButton.evaluate().isNotEmpty) { - await tester.tap(navigationButton); - await tester.pumpAndSettle(); - } - final manageHosts = find.text('Manage hosts'); - final manageHostsButton = find.byTooltip('Manage hosts'); - expect( - manageHosts.evaluate().isNotEmpty || - manageHostsButton.evaluate().isNotEmpty, - isTrue, - ); - await tester.tap( - manageHosts.evaluate().isNotEmpty - ? manageHosts.last - : manageHostsButton, - ); - await tester.pumpAndSettle(); - } - expect(find.text('Add host'), findsOneWidget); - }); -} - -Future _pumpUntil(WidgetTester tester, bool Function() predicate) async { - for (var attempt = 0; attempt < 100; attempt += 1) { - if (predicate()) return; - await tester.pump(const Duration(milliseconds: 100)); - } - throw TestFailure('Native shell did not reach host navigation.'); -} diff --git a/apps/flutter/ios/.gitignore b/apps/flutter/ios/.gitignore deleted file mode 100644 index 7a7f9873..00000000 --- a/apps/flutter/ios/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -**/dgph -*.mode1v3 -*.mode2v3 -*.moved-aside -*.pbxuser -*.perspectivev3 -**/*sync/ -.sconsign.dblite -.tags* -**/.vagrant/ -**/DerivedData/ -Icon? -**/Pods/ -**/.symlinks/ -profile -xcuserdata -**/.generated/ -Flutter/App.framework -Flutter/Flutter.framework -Flutter/Flutter.podspec -Flutter/Generated.xcconfig -Flutter/ephemeral/ -Flutter/app.flx -Flutter/app.zip -Flutter/flutter_assets/ -Flutter/flutter_export_environment.sh -ServiceDefinitions.json -Runner/GeneratedPluginRegistrant.* - -# Exceptions to above rules. -!default.mode1v3 -!default.mode2v3 -!default.pbxuser -!default.perspectivev3 diff --git a/apps/flutter/ios/Flutter/AppFrameworkInfo.plist b/apps/flutter/ios/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 391a902b..00000000 --- a/apps/flutter/ios/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,24 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - - diff --git a/apps/flutter/ios/Flutter/Debug.xcconfig b/apps/flutter/ios/Flutter/Debug.xcconfig deleted file mode 100644 index 592ceee8..00000000 --- a/apps/flutter/ios/Flutter/Debug.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/apps/flutter/ios/Flutter/Release.xcconfig b/apps/flutter/ios/Flutter/Release.xcconfig deleted file mode 100644 index 592ceee8..00000000 --- a/apps/flutter/ios/Flutter/Release.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/apps/flutter/ios/Runner.xcodeproj/project.pbxproj b/apps/flutter/ios/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 19be5f1e..00000000 --- a/apps/flutter/ios/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,655 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXBuildFile section */ - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 97C146E61CF9000F007C117D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 97C146ED1CF9000F007C117D; - remoteInfo = Runner; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 8DBA452E253DC32896127870 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EB1CF9000F007C117D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C8082294A63A400263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C807B294A618700263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - 331C8082294A63A400263BE5 /* RunnerTests */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - 331C8081294A63A400263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); - path = Runner; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C8080294A63A400263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - 331C807D294A63A400263BE5 /* Sources */, - 331C807F294A63A400263BE5 /* Resources */, - 8DBA452E253DC32896127870 /* Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - 331C8086294A63A400263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 97C146ED1CF9000F007C117D /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Runner; - packageProductDependencies = ( - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, - ); - productName = Runner; - productReference = 97C146EE1CF9000F007C117D /* Runner.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 97C146E61CF9000F007C117D /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C8080294A63A400263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 97C146ED1CF9000F007C117D; - }; - 97C146ED1CF9000F007C117D = { - CreatedOnToolsVersion = 7.3.1; - LastSwiftMigration = 1100; - }; - }; - }; - buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 97C146E51CF9000F007C117D; - packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, - ); - productRefGroup = 97C146EF1CF9000F007C117D /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 97C146ED1CF9000F007C117D /* Runner */, - 331C8080294A63A400263BE5 /* RunnerTests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C807F294A63A400263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EC1CF9000F007C117D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C807D294A63A400263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 97C146ED1CF9000F007C117D /* Runner */; - targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 97C146FA1CF9000F007C117D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C146FB1CF9000F007C117D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 249021D3217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Profile; - }; - 249021D4217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - 331C8088294A63A400263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Debug; - }; - 331C8089294A63A400263BE5 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Release; - }; - 331C808A294A63A400263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Profile; - }; - 97C147031CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 97C147041CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 97C147061CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - 97C147071CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C8088294A63A400263BE5 /* Debug */, - 331C8089294A63A400263BE5 /* Release */, - 331C808A294A63A400263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147031CF9000F007C117D /* Debug */, - 97C147041CF9000F007C117D /* Release */, - 249021D3217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147061CF9000F007C117D /* Debug */, - 97C147071CF9000F007C117D /* Release */, - 249021D4217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; - }; -/* End XCLocalSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { - isa = XCSwiftPackageProductDependency; - productName = FlutterGeneratedPluginSwiftPackage; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = 97C146E61CF9000F007C117D /* Project object */; -} diff --git a/apps/flutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/flutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a6..00000000 --- a/apps/flutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/apps/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/apps/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/apps/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5..00000000 --- a/apps/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/apps/flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index c3fedb29..00000000 --- a/apps/flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/flutter/ios/Runner.xcworkspace/contents.xcworkspacedata b/apps/flutter/ios/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a16..00000000 --- a/apps/flutter/ios/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/apps/flutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/flutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/apps/flutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/apps/flutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/flutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5..00000000 --- a/apps/flutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/apps/flutter/ios/Runner/AppDelegate.swift b/apps/flutter/ios/Runner/AppDelegate.swift deleted file mode 100644 index c30b367e..00000000 --- a/apps/flutter/ios/Runner/AppDelegate.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Flutter -import UIKit - -@main -@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } - - func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { - GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) - } -} diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d36b1fab..00000000 --- a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index 9d3a1a38..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index a56bace0..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 69dee203..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index 937761a5..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 2c93f3c5..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index 181d9846..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index 29861d50..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 69dee203..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index ba014889..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index bb09fb4e..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index bb09fb4e..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index a3dc9702..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index 10496d3a..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index f7e37df8..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index 42cd43a1..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index 0bedcf2f..00000000 --- a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "LaunchImage.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 9da19eac..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 9da19eac..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index 9da19eac..00000000 Binary files a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725b..00000000 --- a/apps/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/apps/flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard b/apps/flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index f2e259c7..00000000 --- a/apps/flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/flutter/ios/Runner/Base.lproj/Main.storyboard b/apps/flutter/ios/Runner/Base.lproj/Main.storyboard deleted file mode 100644 index f3c28516..00000000 --- a/apps/flutter/ios/Runner/Base.lproj/Main.storyboard +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/flutter/ios/Runner/DebugProfile.entitlements b/apps/flutter/ios/Runner/DebugProfile.entitlements deleted file mode 100644 index fbad0237..00000000 --- a/apps/flutter/ios/Runner/DebugProfile.entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - - - keychain-access-groups - - - diff --git a/apps/flutter/ios/Runner/Info.plist b/apps/flutter/ios/Runner/Info.plist deleted file mode 100644 index 1b8e3097..00000000 --- a/apps/flutter/ios/Runner/Info.plist +++ /dev/null @@ -1,70 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - T4 Code - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - T4 Code - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneClassName - UIWindowScene - UISceneConfigurationName - flutter - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/apps/flutter/ios/Runner/Release.entitlements b/apps/flutter/ios/Runner/Release.entitlements deleted file mode 100644 index fbad0237..00000000 --- a/apps/flutter/ios/Runner/Release.entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - - - keychain-access-groups - - - diff --git a/apps/flutter/ios/Runner/Runner-Bridging-Header.h b/apps/flutter/ios/Runner/Runner-Bridging-Header.h deleted file mode 100644 index 308a2a56..00000000 --- a/apps/flutter/ios/Runner/Runner-Bridging-Header.h +++ /dev/null @@ -1 +0,0 @@ -#import "GeneratedPluginRegistrant.h" diff --git a/apps/flutter/ios/Runner/SceneDelegate.swift b/apps/flutter/ios/Runner/SceneDelegate.swift deleted file mode 100644 index b9ce8ea2..00000000 --- a/apps/flutter/ios/Runner/SceneDelegate.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Flutter -import UIKit - -class SceneDelegate: FlutterSceneDelegate { - -} diff --git a/apps/flutter/ios/RunnerTests/RunnerTests.swift b/apps/flutter/ios/RunnerTests/RunnerTests.swift deleted file mode 100644 index 86a7c3b1..00000000 --- a/apps/flutter/ios/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Flutter -import UIKit -import XCTest - -class RunnerTests: XCTestCase { - - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } - -} diff --git a/apps/flutter/lib/main.dart b/apps/flutter/lib/main.dart deleted file mode 100644 index 62c77b46..00000000 --- a/apps/flutter/lib/main.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/widgets.dart'; -import 'package:flutter/foundation.dart'; - -import 'src/client/app_state.dart'; -import 'src/client/t4_client_controller.dart'; -import 'src/client/transcript_tail_store.dart'; -import 'src/demo/demo_app.dart'; -import 'src/host/app_preferences.dart'; -import 'src/host/persistent_host_stores.dart'; -import 'src/platform/platform_lifecycle_controller.dart'; -import 'src/ui/t4_app.dart'; - -void main() { - const demoMode = bool.fromEnvironment('T4_DEMO_MODE'); - if (demoMode) { - runApp(T4DemoApp()); - return; - } - runApp(T4Bootstrap(developmentEndpoint: _developmentEndpoint())); -} - -Uri? _developmentEndpoint() { - const configured = String.fromEnvironment('T4_DEVELOPMENT_ENDPOINT'); - if (configured.isEmpty) return null; - final endpoint = Uri.tryParse(configured); - if (endpoint == null || - (endpoint.scheme != 'ws' && endpoint.scheme != 'wss')) { - return null; - } - return endpoint; -} - -final class T4Bootstrap extends StatefulWidget { - const T4Bootstrap({this.developmentEndpoint, super.key}); - - final Uri? developmentEndpoint; - - @override - State createState() => _T4BootstrapState(); -} - -final class _T4BootstrapState extends State - with WidgetsBindingObserver { - late final T4ClientController _controller; - late final PlatformLifecycleController _platformController; - late final bool _credentialsAreVolatile; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - _credentialsAreVolatile = - kDebugMode && defaultTargetPlatform == TargetPlatform.macOS; - _controller = T4ClientController( - hostDirectoryStore: PersistentHostDirectoryStore(), - hostCredentialStore: _credentialsAreVolatile - ? VolatileHostCredentialStore() - : SecureHostCredentialStore(), - appPreferenceStore: PersistentAppPreferenceStore(), - transcriptTailStore: PersistentTranscriptTailStore(), - developmentEndpoint: widget.developmentEndpoint, - ); - _platformController = PlatformLifecycleController(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - unawaited(_controller.initialize()); - unawaited(_platformController.initialize()); - }); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - switch (state) { - case AppLifecycleState.resumed: - unawaited(_controller.handleLifecyclePhase(T4LifecyclePhase.resumed)); - unawaited(_platformController.refreshPlatformState()); - case AppLifecycleState.hidden: - case AppLifecycleState.paused: - case AppLifecycleState.detached: - unawaited( - _controller.handleLifecyclePhase(T4LifecyclePhase.background), - ); - case AppLifecycleState.inactive: - break; - } - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: Listenable.merge([ - _controller, - _platformController, - ]), - builder: (context, _) => T4App( - state: _controller.state, - actions: _controller, - credentialsAreVolatile: _credentialsAreVolatile, - platformState: _platformController.state, - platformActions: _platformController, - ), - ); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _controller.dispose(); - _platformController.dispose(); - super.dispose(); - } -} diff --git a/apps/flutter/lib/src/client/app_state.dart b/apps/flutter/lib/src/client/app_state.dart deleted file mode 100644 index 4da9e6e3..00000000 --- a/apps/flutter/lib/src/client/app_state.dart +++ /dev/null @@ -1,780 +0,0 @@ -import 'dart:typed_data'; - -import '../host/host_profile.dart'; -import '../protocol/models.dart'; -import 'model_labels.dart'; - -enum ConnectionPhase { - disconnected, - connecting, - synchronizing, - ready, - retrying, - failed, -} - -enum AuthenticationPhase { unknown, local, pairingRequired, pairing, paired } - -const List t4RequestedFeatures = [ - 'resume', - 'host.watch', - 'session.watch', - 'session.state', - 'session.delta', - 'session.observer', - 'controller.lease', - 'prompt.lease', - 'prompt.images', - 'transcript.images', - 'transcript.search', - 'transcript.page', - 'agent.lifecycle', - 'agent.progress', - 'agent.event', - 'agent.transcript', - 'terminal.io', - 'files.list', - 'files.search', - 'files.diff', - 'audit.tail', - 'catalog.metadata', - 'settings.metadata', - 'preview.control', -]; - -const List t4RequestedCapabilities = [ - 'sessions.read', - 'sessions.prompt', - 'sessions.control', - 'sessions.manage', - 'term.open', - 'term.input', - 'term.resize', - 'files.read', - 'files.write', - 'files.list', - 'files.diff', - 'agents.control', - 'audit.read', - 'config.read', - 'catalog.read', - 'config.write', - 'broker.read', - 'usage.read', - 'preview.read', - 'preview.control', - 'preview.input', -]; - -final String t4PairCommand = [ - 'omp appserver pair', - for (final capability in t4RequestedCapabilities) '--capability $capability', -].join(' '); - -enum MessageRole { user, assistant, system, tool } - -enum TranscriptKind { message, tool, compaction, notice } - -final class TranscriptImageMetadata { - const TranscriptImageMetadata({required this.sha256, required this.mimeType}); - - final String sha256; - final String mimeType; -} - -final class SessionSummary { - const SessionSummary({ - required this.hostId, - required this.sessionId, - required this.title, - required this.revision, - required this.status, - this.projectId = 'unknown-project', - this.projectName = 'Project', - this.updatedAt = '', - this.archivedAt, - this.working = false, - this.modelSelector, - this.modelDisplayName, - this.thinking, - this.thinkingSupported, - this.thinkingLevels = const [], - this.fast = false, - this.fastAvailable = false, - this.turnActive = false, - this.isPaused = false, - this.queuedFollowUpCount = 0, - }); - - final String hostId; - final String sessionId; - final String title; - final String revision; - final String status; - final String projectId; - final String projectName; - final String updatedAt; - final String? archivedAt; - final bool working; - final String? modelSelector; - final String? modelDisplayName; - final String? thinking; - final bool? thinkingSupported; - final List thinkingLevels; - final bool fast; - final bool fastAvailable; - final bool turnActive; - final bool isPaused; - final int queuedFollowUpCount; - - bool get archived => archivedAt != null; -} - -final class TranscriptMessage { - const TranscriptMessage({ - required this.id, - required this.role, - required this.text, - this.kind = TranscriptKind.message, - this.reasoning = '', - this.streaming = false, - this.toolName, - this.toolTitle, - this.toolArguments, - this.toolOutput, - this.toolSucceeded, - this.toolRunning = false, - this.toolProgress, - this.images = const [], - }); - - final String id; - final MessageRole role; - final String text; - final TranscriptKind kind; - final String reasoning; - final bool streaming; - final String? toolName; - final String? toolTitle; - final String? toolArguments; - final String? toolOutput; - final bool? toolSucceeded; - final bool toolRunning; - final double? toolProgress; - final List images; -} - -final class ComposerModelChoice { - const ComposerModelChoice({ - required this.label, - required this.selector, - this.provider = '', - this.providerLabel = '', - this.supported = true, - this.reason, - }); - - final String label; - final String selector; - - /// Raw provider id (empty when the model has no provider). - final String provider; - - /// Friendly provider name (empty when none). - final String providerLabel; - final bool supported; - final String? reason; -} - -final class ComposerSlashCommand { - const ComposerSlashCommand({ - required this.name, - required this.description, - required this.insert, - this.aliases = const [], - this.disabledReason, - }); - - final String name; - final String description; - final String insert; - final List aliases; - final String? disabledReason; -} - -final class SessionComposerState { - const SessionComposerState({ - this.modelLabel, - this.modelSelector, - this.modelChoices = const [], - this.modelGroups = const [], - this.slashCommands = const [], - this.thinking, - this.thinkingLevels = const [], - this.fastEnabled = false, - this.fastAvailable = false, - this.turnActive = false, - this.isPaused = false, - this.queuedFollowUpCount = 0, - }); - - final String? modelLabel; - final String? modelSelector; - final List modelChoices; - - /// Provider-grouped choices for the navigable model selector. - final List modelGroups; - final List slashCommands; - final String? thinking; - final List thinkingLevels; - final bool fastEnabled; - final bool fastAvailable; - final bool turnActive; - final bool isPaused; - final int queuedFollowUpCount; -} - -final class PromptImageAttachment { - const PromptImageAttachment({ - required this.id, - required this.name, - required this.mimeType, - required this.bytes, - }); - - final String id; - final String name; - final String mimeType; - final Uint8List bytes; -} - -enum AttentionKind { - approval, - question, - plan, - confirmation, - completed, - failed, - cancelled, -} - -enum AttentionDecision { approve, deny, revise, reject } - -final class AttentionChoice { - const AttentionChoice({required this.id, required this.label}); - - final String id; - final String label; -} - -final class AttentionItem { - const AttentionItem({ - required this.key, - required this.kind, - required this.sessionId, - required this.sessionTitle, - required this.revision, - required this.title, - required this.summary, - required this.at, - this.requestId, - this.confirmationId, - this.commandId, - this.expiresAt, - this.choices = const [], - this.allowText = false, - this.actionable = false, - }); - - final String key; - final AttentionKind kind; - final String sessionId; - final String sessionTitle; - final String revision; - final String title; - final String summary; - final DateTime at; - final String? requestId; - final String? confirmationId; - final String? commandId; - final DateTime? expiresAt; - final List choices; - final bool allowText; - final bool actionable; - - bool get needsResponse => - kind == AttentionKind.approval || - kind == AttentionKind.question || - kind == AttentionKind.plan || - kind == AttentionKind.confirmation; - - bool get isProblem => - kind == AttentionKind.failed || kind == AttentionKind.cancelled; -} - -final class AgentActivity { - const AgentActivity({ - required this.agentId, - required this.sessionId, - required this.label, - required this.status, - required this.updatedAt, - this.progress, - this.parentAgentId, - this.description, - this.model, - this.currentTool, - this.evidence, - }); - - final String agentId; - final String sessionId; - final String label; - final String status; - final DateTime updatedAt; - final double? progress; - final String? parentAgentId; - final String? description; - final String? model; - final String? currentTool; - final String? evidence; -} - -final class AttentionResponse { - const AttentionResponse({ - required this.decision, - this.optionIds = const [], - this.text = '', - }); - - final AttentionDecision decision; - final List optionIds; - final String text; -} - -enum DeveloperSurface { activity, files, review, terminal, preview } - -final class DeveloperActivity { - const DeveloperActivity({ - required this.id, - required this.category, - required this.title, - required this.detail, - required this.at, - required this.raw, - }); - - final String id; - final String category; - final String title; - final String detail; - final DateTime at; - final String raw; -} - -final class TerminalSession { - const TerminalSession({ - required this.terminalId, - required this.sessionId, - required this.title, - this.output = '', - this.running = true, - this.exitCode, - this.signal, - }); - - final String terminalId; - final String sessionId; - final String title; - final String output; - final bool running; - final int? exitCode; - final String? signal; -} - -final class DeveloperFileEntry { - const DeveloperFileEntry({ - required this.path, - required this.kind, - this.size, - this.revision, - }); - - final String path; - final String kind; - final int? size; - final String? revision; -} - -final class FileWorkspaceState { - const FileWorkspaceState({ - this.path = '', - this.entries = const [], - this.content, - this.diff, - this.revision, - this.loading = false, - this.error, - }); - - final String path; - final List entries; - final String? content; - final String? diff; - final String? revision; - final bool loading; - final String? error; -} - -final class ProjectFileSearchResult { - const ProjectFileSearchResult({required this.paths, required this.truncated}); - - final List paths; - final bool truncated; -} - -final class ReviewWorkspaceItem { - const ReviewWorkspaceItem({ - required this.reviewId, - required this.sessionId, - required this.status, - required this.findings, - this.path, - }); - - final String reviewId; - final String sessionId; - final String status; - final String? path; - final List> findings; -} - -final class PreviewWorkspaceState { - const PreviewWorkspaceState({ - required this.previewId, - required this.sessionId, - required this.state, - required this.url, - required this.revision, - this.title, - this.canGoBack = false, - this.canGoForward = false, - this.capture, - this.captureMimeType, - this.error, - }); - - final String previewId; - final String sessionId; - final String state; - final String url; - final String revision; - final String? title; - final bool canGoBack; - final bool canGoForward; - final Uint8List? capture; - final String? captureMimeType; - final String? error; -} - -enum T4ThemePreference { system, light, dark } - -enum T4LifecyclePhase { resumed, background } - -enum HostSettingControlKind { - boolean, - number, - text, - enumeration, - list, - map, - secret, - unsupported, -} - -final class HostSettingOption { - const HostSettingOption({ - required this.value, - required this.label, - this.help, - }); - - final String value; - final String label; - final String? help; -} - -final class HostSettingEntry { - const HostSettingEntry({ - required this.path, - required this.section, - required this.label, - required this.help, - required this.control, - required this.configured, - required this.options, - required this.writableScopes, - required this.restartRequired, - required this.available, - required this.sensitive, - this.effectiveValue, - this.effectiveSource, - this.min, - this.max, - this.unit, - }); - - final String path; - final String section; - final String label; - final String help; - final HostSettingControlKind control; - final Object? effectiveValue; - final bool configured; - final String? effectiveSource; - final List options; - final num? min; - final num? max; - final String? unit; - final List writableScopes; - final bool restartRequired; - final bool available; - final bool sensitive; -} - -final class HostSettingsState { - const HostSettingsState({ - this.revision, - this.entries = const [], - this.loading = false, - this.error, - this.issues = const [], - }); - - final String? revision; - final List entries; - final bool loading; - final String? error; - final List issues; -} - -final class T4ViewState { - const T4ViewState({ - required this.connectionPhase, - this.sessions = const [], - this.selectedSessionId, - this.messages = const [], - this.transcriptHistoryLoading = false, - this.transcriptHistoryHasMore = false, - this.transcriptHistoryError, - this.transcriptTailFromCache = false, - this.errorMessage, - this.hostDirectory = const HostDirectory.empty(), - this.authenticationPhase = AuthenticationPhase.unknown, - this.grantedCapabilities = const {}, - this.grantedFeatures = const {}, - this.targetConfigured = false, - this.hostOperationPending = false, - this.submitting = false, - this.sessionOperationPending = false, - this.composer = const SessionComposerState(), - this.attentionItems = const [], - this.agentActivities = const [], - this.attentionPartial = false, - this.omittedAttentionCount = 0, - this.activities = const [], - this.terminals = const [], - this.activeTerminalId, - this.fileWorkspace = const FileWorkspaceState(), - this.previews = const [], - this.reviews = const [], - this.activePreviewId, - this.developerOperationPending = false, - this.themePreference = T4ThemePreference.system, - this.settings = const HostSettingsState(), - this.lifecyclePhase = T4LifecyclePhase.resumed, - this.settingsOperationPending = false, - }); - - const T4ViewState.disconnected() - : this(connectionPhase: ConnectionPhase.disconnected); - - final ConnectionPhase connectionPhase; - final List sessions; - final String? selectedSessionId; - final List messages; - final bool transcriptHistoryLoading; - final bool transcriptHistoryHasMore; - final String? transcriptHistoryError; - final bool transcriptTailFromCache; - final String? errorMessage; - final HostDirectory hostDirectory; - final AuthenticationPhase authenticationPhase; - final Set grantedCapabilities; - final Set grantedFeatures; - final bool targetConfigured; - final bool hostOperationPending; - final bool submitting; - final bool sessionOperationPending; - final SessionComposerState composer; - final List attentionItems; - final List agentActivities; - final bool attentionPartial; - final int omittedAttentionCount; - final List activities; - final List terminals; - final String? activeTerminalId; - final FileWorkspaceState fileWorkspace; - final List previews; - final List reviews; - final String? activePreviewId; - final bool developerOperationPending; - final T4ThemePreference themePreference; - final HostSettingsState settings; - final T4LifecyclePhase lifecyclePhase; - final bool settingsOperationPending; - - TerminalSession? get activeTerminal => terminals - .where((terminal) => terminal.terminalId == activeTerminalId) - .firstOrNull; - - PreviewWorkspaceState? get activePreview => previews - .where((preview) => preview.previewId == activePreviewId) - .firstOrNull; - - int get urgentAttentionCount => - attentionItems.where((item) => item.needsResponse).length + - omittedAttentionCount; - - SessionSummary? get selectedSession { - for (final session in sessions) { - if (session.sessionId == selectedSessionId) return session; - } - return null; - } -} - -abstract interface class T4Actions { - Future connect(); - Future disconnect(); - Future setThemePreference(T4ThemePreference preference); - Future refreshSettings(); - Future writeSetting( - String path, - String scope, { - Object? value, - bool reset = false, - }); - Future handleLifecyclePhase(T4LifecyclePhase phase); - - void cancelHostProbe(); - - Future addHost( - String address, { - String profileId = defaultHostProfileId, - }); - - Future activateHost(String endpointKey); - - Future removeHost(String endpointKey); - - Future pairHost(String code); - - Future selectSession(String sessionId); - Future createSession(String projectId, {String? title}); - - Future renameSession(String sessionId, String title); - - Future terminateSession(String sessionId); - - Future archiveSession(String sessionId); - - Future restoreSession(String sessionId); - - Future deleteSession(String sessionId); - Future searchTranscripts({ - required String query, - String? cursor, - String? projectId, - List? roles, - String archived = 'include', - DateTime? from, - DateTime? to, - }); - - Future loadTranscriptContext({ - required String sessionId, - required String anchorId, - int before = 8, - int after = 8, - }); - - Future loadEarlierTranscript(); - - Future readUsage(); - Future readBrokerStatus(); - - Future submitPrompt( - String message, { - List images = const [], - }); - - Future queuePrompt(String message); - - Future cancelTurn(); - - Future pauseSession(); - - Future resumeSession(); - - Future compactSession({String? instructions}); - - Future setSessionModel(String selector); - - Future setSessionThinking(String level); - - Future setSessionFast(bool enabled); - - Future respondToAttention( - AttentionItem item, - AttentionResponse response, - ); - - Future retrySession(String sessionId); - - Future refreshActivity(); - - Future openTerminal({String? cwd}); - void sendTerminalInput(String terminalId, String data); - void resizeTerminal(String terminalId, int cols, int rows); - - Future cancelAgent(String agentId); - void closeTerminal(String terminalId); - - Future listFiles([String path = '']); - Future searchProjectFiles( - String query, { - int limit = 12, - }); - Future readFile(String path); - Future loadSessionDiff(); - Future writeFile(String path, String content); - Future refreshReview(String reviewId); - Future applyReview(String reviewId); - - Future launchPreview(String url); - Future selectPreview(String previewId); - Future navigatePreview(String previewId, String url); - Future runPreviewAction(String previewId, String action); - Future runPreviewInteraction( - String previewId, - String action, - Map args, - ); - Future capturePreview(String previewId); - - Future readTranscriptImage( - String entryId, - TranscriptImageMetadata image, - ); -} diff --git a/apps/flutter/lib/src/client/model_labels.dart b/apps/flutter/lib/src/client/model_labels.dart deleted file mode 100644 index 570a4bd0..00000000 --- a/apps/flutter/lib/src/client/model_labels.dart +++ /dev/null @@ -1,324 +0,0 @@ -// Presentation-only humanization for the in-chat model selector, mirroring the -// Electron/web T4 model picker (apps/web/src/features/settings/settings-presentation.ts). -// -// The host catalog's `name` is the label authority when it names a model; every -// fallback is deterministic so the same input always renders the same words. -// Canonical `provider/modelId` selectors remain the submitted protocol values — -// these helpers NEVER rewrite what is sent to `session.model.set`. - -import '../protocol/models.dart'; - -/// Acronyms kept uppercase when a raw id is humanized for display. -const Map _idAcronyms = { - 'ai': true, - 'api': true, - 'aws': true, - 'cli': true, - 'glm': true, - 'gpt': true, - 'io': true, - 'llm': true, - 'mcp': true, - 'omp': true, - 'qa': true, - 'sdk': true, - 'tts': true, - 'tui': true, - 'ui': true, - 'ux': true, -}; - -/// `gpt-engineer` → "GPT Engineer", `claude-fable-5` → "Claude Fable 5", -/// `quickTask` → "Quick Task". Split on case changes, hyphens, underscores, -/// and spaces; title-case words; known acronyms go uppercase. Never invents -/// words — a token it can't improve passes through capitalized. -String humanizeIdentifier(String id) { - // Insert a space before each uppercase letter that follows a lowercase letter - // or digit (`quickTask` → `quick Task`, `gpt5` → `gpt 5`). - final camelSplit = id.replaceAllMapped( - RegExp(r'([a-z0-9])([A-Z])'), - (Match match) => '${match.group(1)} ${match.group(2)}', - ); - final words = camelSplit - .split(RegExp(r'[-_\s]+')) - .where((word) => word.isNotEmpty) - .toList(growable: false); - if (words.isEmpty) return id; - return words - .map((String word) { - final lower = word.toLowerCase(); - if (_idAcronyms[lower] == true) { - return lower.toUpperCase(); - } - return word[0].toUpperCase() + word.substring(1); - }) - .join(' '); -} - -/// Friendly names for provider ids OMP routes through. Lowercase keys. -const Map _providerNames = { - 'anthropic': 'Anthropic', - 'openai': 'OpenAI', - 'google': 'Google', - 'gemini': 'Google', - 'xai': 'xAI', - 'openrouter': 'OpenRouter', - 'github-copilot': 'GitHub Copilot', - 'copilot': 'GitHub Copilot', - 'amazon-bedrock': 'Amazon Bedrock', - 'bedrock': 'Amazon Bedrock', - 'azure-openai': 'Azure OpenAI', - 'azure': 'Azure OpenAI', -}; - -/// Friendly provider name. Case-insensitive; the longest hyphen-segment prefix -/// wins, so `openai-codex` → "OpenAI", `xai-oauth` → "xAI", -/// `google-vertex` → "Google". Unknown ids humanize deterministically -/// (`ollama` → "Ollama") instead of echoing raw casing. -String providerDisplayName(String id) { - final segments = id.trim().toLowerCase().split('-'); - for (var end = segments.length; end > 0; end -= 1) { - final hit = _providerNames[segments.sublist(0, end).join('-')]; - if (hit != null) return hit; - } - return humanizeIdentifier(id); -} - -/// `provider/modelId` with any trailing `:level` thinking suffix removed. -String baseSelector(String selector) { - final colon = selector.lastIndexOf(':'); - final slash = selector.indexOf('/'); - return (colon > slash && colon != -1) - ? selector.substring(0, colon) - : selector; -} - -/// The provider segment of a `provider/modelId` selector, or '' when the -/// selector has no provider. -String providerOf(String selector) { - final base = baseSelector(selector); - final slash = base.indexOf('/'); - return slash > 0 ? base.substring(0, slash) : ''; -} - -/// The model-id segment of a `provider/modelId` selector, or the whole -/// selector when it has no provider. -String modelIdOf(String selector) { - final base = baseSelector(selector); - final slash = base.indexOf('/'); - return slash > 0 ? base.substring(slash + 1) : base; -} - -/// The last path segment of a model id, humanized for unknown-model fallback. -String _lastSegment(String modelId) { - final lastSlash = modelId.lastIndexOf('/'); - return lastSlash >= 0 ? modelId.substring(lastSlash + 1) : modelId; -} - -/// A resolved display label for one catalog model. -/// -/// [label] is the human-facing name (catalog name wins; a miss humanizes the -/// model id's last path segment). [provider] is the raw provider id carried -/// through for grouping; [providerLabel] is its friendly name. The [selector] -/// is the exact `provider/modelId` string submitted to `session.model.set`. -final class ModelLabel { - const ModelLabel({ - required this.selector, - required this.label, - required this.provider, - required this.providerLabel, - required this.inCatalog, - }); - - final String selector; - final String label; - final String provider; - final String providerLabel; - final bool inCatalog; -} - -/// Resolve a display label for a catalog model item. -/// -/// The catalog `name` wins when it is a human name (not the raw selector); -/// otherwise the model id's last path segment is humanized. The selector is -/// always the exact `provider/modelId` from metadata (or `item.name` when it -/// already contains a slash), carried through untouched as the submitted value. -ModelLabel modelLabelFor(CatalogItem item) { - final selector = modelItemSelector(item); - if (selector == null) { - // No provider/modelId metadata and name has no slash: treat the name as - // both the label and the selector so the model stays selectable. - return ModelLabel( - selector: item.name, - label: item.name, - provider: '', - providerLabel: '', - inCatalog: true, - ); - } - final provider = providerOf(selector); - final modelId = modelIdOf(selector); - final catalogName = item.name.trim(); - final labelIsRawSelector = - catalogName.isEmpty || catalogName == selector || catalogName == modelId; - final label = labelIsRawSelector - ? humanizeIdentifier(_lastSegment(modelId)) - : catalogName; - return ModelLabel( - selector: selector, - label: label, - provider: provider, - providerLabel: provider.isEmpty ? '' : providerDisplayName(provider), - inCatalog: true, - ); -} - -/// `provider/modelId` from a catalog model item's metadata, guarded. -/// -/// Mirrors the web `modelItemSelector`: metadata's `provider`/`modelId` win; -/// a name that already contains a slash is accepted as a selector; otherwise -/// the item does not resolve to a switchable selector. -String? modelItemSelector(CatalogItem item) { - final metadata = item.metadata; - if (metadata != null) { - final provider = metadata['provider']; - final modelId = metadata['modelId']; - if (provider is String && - provider.isNotEmpty && - modelId is String && - modelId.isNotEmpty) { - return '$provider/$modelId'; - } - } - return item.name.contains('/') ? item.name : null; -} - -/// A provider group for the navigable model menu. -final class ModelProviderGroup { - const ModelProviderGroup({ - required this.provider, - required this.label, - required this.choices, - }); - - /// Raw provider id (empty string for models with no provider). - final String provider; - - /// Friendly provider name, or 'Other models' when the provider is unknown. - final String label; - - /// Choices within this group, ordered by label. - final List choices; -} - -/// A model choice with its resolved display label and raw selector. -final class ResolvedModelChoice { - const ResolvedModelChoice({ - required this.selector, - required this.label, - required this.provider, - required this.providerLabel, - required this.supported, - required this.reason, - }); - - /// Exact `provider/modelId` submitted to `session.model.set`. - final String selector; - - /// Human-facing model name. - final String label; - - /// Raw provider id (empty when none). - final String provider; - - /// Friendly provider name (empty when none). - final String providerLabel; - - final bool supported; - final String? reason; -} - -/// Group catalog model choices by provider for a navigable selector. -/// -/// Known providers are ordered by friendly name; an unknown/empty provider -/// bucket ('Other models') sorts last so it never hides known providers. -/// Within a group, choices sort by label. Duplicate selectors collapse to the -/// first occurrence. Unsupported models are retained but marked, so they stay -/// selectable (and the host can explain why) rather than silently disappearing. -List groupModelChoices(Iterable catalog) { - final seen = {}; - final byProvider = >{}; - for (final item in catalog) { - if (item.kind != 'model') continue; - final selector = modelItemSelector(item); - if (selector == null || !seen.add(selector)) continue; - final label = modelLabelFor(item); - byProvider - .putIfAbsent(label.provider, () => []) - .add( - ResolvedModelChoice( - selector: label.selector, - label: label.label, - provider: label.provider, - providerLabel: label.providerLabel, - supported: item.supported != false, - reason: item.reason, - ), - ); - } - final groups = []; - for (final entry in byProvider.entries) { - entry.value.sort((a, b) => a.label.compareTo(b.label)); - groups.add( - ModelProviderGroup( - provider: entry.key, - label: entry.key.isEmpty - ? 'Other models' - : (entry.value.first.providerLabel.isEmpty - ? 'Other models' - : entry.value.first.providerLabel), - choices: List.unmodifiable(entry.value), - ), - ); - } - // Known providers alphabetical by friendly label; 'Other models' last. - groups.sort((a, b) { - final aOther = a.provider.isEmpty; - final bOther = b.provider.isEmpty; - if (aOther != bOther) return aOther ? 1 : -1; - return a.label.compareTo(b.label); - }); - return List.unmodifiable(groups); -} - -/// Resolve a human-facing label for a session's current model selector. -/// -/// Catalog labels win; a selector that matches no catalog entry humanizes its -/// model id's last path segment so unknown models still read as a name rather -/// than a raw `provider/modelId` id. The raw selector is returned unchanged as -/// the submitted value. Returns null when the session has no model selector. -String? sessionModelLabel( - String? selector, - String? displayName, - Iterable catalog, -) { - if (selector == null || selector.isEmpty) { - return displayName; - } - if (displayName != null && displayName.trim().isNotEmpty) { - return displayName; - } - final base = baseSelector(selector); - for (final item in catalog) { - if (item.kind != 'model') continue; - final itemSelector = modelItemSelector(item); - if (itemSelector == null) continue; - if (baseSelector(itemSelector) == base) { - final label = modelLabelFor(item); - return label.label; - } - } - // Unknown selector: humanize the model id's last path segment. - final modelId = modelIdOf(selector); - return humanizeIdentifier(_lastSegment(modelId)); -} diff --git a/apps/flutter/lib/src/client/t4_client_controller.dart b/apps/flutter/lib/src/client/t4_client_controller.dart deleted file mode 100644 index 400f5005..00000000 --- a/apps/flutter/lib/src/client/t4_client_controller.dart +++ /dev/null @@ -1,5157 +0,0 @@ -import 'dart:async'; -import 'dart:collection'; -import 'dart:convert'; -import 'dart:math'; -import 'dart:typed_data'; - -import 'package:crypto/crypto.dart'; -import 'package:flutter/foundation.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -import '../host/app_preferences.dart'; -import '../host/host_profile.dart'; -import '../protocol/protocol.dart'; -import 'app_state.dart'; -import 'model_labels.dart'; -import 'transcript_tail_store.dart'; -import 'web_socket_connector.dart'; - -String _secureToken(int byteLength) { - final random = Random.secure(); - final bytes = List.generate(byteLength, (_) => random.nextInt(256)); - return base64Url.encode(bytes).replaceAll('=', ''); -} - -const int _maxPagedTranscriptEntries = 4096; - -final class T4ClientController extends ChangeNotifier implements T4Actions { - T4ClientController({ - required this.hostDirectoryStore, - required this.hostCredentialStore, - AppPreferenceStore? appPreferenceStore, - TranscriptTailStore? transcriptTailStore, - WebSocketConnector? webSocketConnector, - this.developmentEndpoint, - }) : appPreferenceStore = appPreferenceStore ?? InMemoryAppPreferenceStore(), - transcriptTailStore = - transcriptTailStore ?? InMemoryTranscriptTailStore(), - _webSocketConnector = webSocketConnector ?? connectPlatformWebSocket; - - final HostDirectoryStore hostDirectoryStore; - final HostCredentialStore hostCredentialStore; - final AppPreferenceStore appPreferenceStore; - final TranscriptTailStore transcriptTailStore; - final WebSocketConnector _webSocketConnector; - final Uri? developmentEndpoint; - - final LinkedHashMap _messages = LinkedHashMap(); - final Map _savedCursors = - {}; - List _pagedTranscriptEntries = const []; - String? _transcriptPageGeneration; - String? _transcriptPageCursor; - bool _transcriptPageLoading = false; - bool _transcriptPageHasMore = false; - String? _transcriptPageError; - String? _transcriptRecoverySessionId; - bool _transcriptTailFromCache = false; - int _transcriptOpenGeneration = 0; - int? _transcriptPrimeGeneration; - final Map _pendingCommands = - {}; - final Map _pendingSessionOperations = - {}; - final Map _attentionBySession = - {}; - final Map _attentionConfirmations = - {}; - final Map _agentActivities = {}; - final LinkedHashMap _activities = LinkedHashMap(); - final Map _terminals = {}; - final Map _terminalCursors = - {}; - final Map _previews = - {}; - final Map _previewCaptures = {}; - final Map _reviews = - {}; - - HostDirectory _hostDirectory = const HostDirectory.empty(); - WebSocketChannel? _channel; - StreamSubscription? _subscription; - WebSocketChannel? _hostProbe; - Timer? _reconnectTimer; - List _sessions = const []; - ConnectionPhase _phase = ConnectionPhase.disconnected; - AuthenticationPhase _authenticationPhase = AuthenticationPhase.unknown; - String? _selectedSessionId; - String? _errorMessage; - String? _hostId; - _PendingPair? _pendingPair; - Set _grantedCapabilities = const {}; - Set _grantedFeatures = const {}; - List _catalogItems = const []; - CatalogFrame? _catalogFrame; - SettingsFrame? _settingsFrame; - HostSettingsState _settingsState = const HostSettingsState(); - T4ThemePreference _themePreference = T4ThemePreference.system; - T4LifecyclePhase _lifecyclePhase = T4LifecyclePhase.resumed; - bool _settingsOperationPending = false; - bool _resumeConnectionOwed = false; - Completer? _settingsRefreshCompleter; - bool _settingsRefreshSawCatalog = false; - bool _settingsRefreshSawSettings = false; - Timer? _settingsRefreshTimer; - int _settingsBootstrapGeneration = -1; - FileWorkspaceState _fileWorkspace = const FileWorkspaceState(); - String? _activeTerminalId; - String? _activePreviewId; - Future? _initialization; - bool _initialized = false; - bool _hostOperationPending = false; - bool _submitting = false; - bool _sessionOperationPending = false; - bool _developerOperationPending = false; - bool _directoryLoaded = false; - bool _disposed = false; - int _connectionGeneration = 0; - int _hostOperationGeneration = 0; - int? _hostProbeOperation; - bool _reconnectEnabled = true; - int _bootstrapGeneration = -1; - int _commandOrdinal = 0; - final String _commandNamespace = _secureToken(12); - int _localPromptOrdinal = 0; - String? _sessionIndexEpoch; - int? _sessionIndexSeq; - int _reconnectAttempt = 0; - - T4ViewState get state => T4ViewState( - connectionPhase: _phase, - sessions: List.unmodifiable(_sessions), - selectedSessionId: _selectedSessionId, - messages: List.unmodifiable(_messages.values), - transcriptHistoryLoading: _transcriptPageLoading, - transcriptHistoryHasMore: _transcriptPageHasMore, - transcriptHistoryError: _transcriptPageError, - transcriptTailFromCache: _transcriptTailFromCache, - errorMessage: _errorMessage, - hostDirectory: _hostDirectory, - authenticationPhase: _authenticationPhase, - grantedCapabilities: Set.unmodifiable(_grantedCapabilities), - grantedFeatures: Set.unmodifiable(_grantedFeatures), - targetConfigured: developmentEndpoint != null || _activeProfile != null, - hostOperationPending: _hostOperationPending, - submitting: _submitting, - sessionOperationPending: _sessionOperationPending, - composer: _composerState, - attentionItems: _allAttentionItems, - agentActivities: _allAgentActivities, - attentionPartial: _attentionBySession.values.any( - (attention) => attention.malformed || attention.truncated, - ), - omittedAttentionCount: _attentionBySession.values.fold( - 0, - (total, attention) => total + attention.omittedCount, - ), - activities: List.unmodifiable( - _activities.values.toList(growable: false).reversed, - ), - terminals: List.unmodifiable( - _terminals.values.where( - (terminal) => terminal.sessionId == _selectedSessionId, - ), - ), - activeTerminalId: _activeTerminalId, - fileWorkspace: _fileWorkspace, - previews: List.unmodifiable( - _previews.values.where( - (preview) => preview.sessionId == _selectedSessionId, - ), - ), - reviews: List.unmodifiable( - _reviews.values.where((review) => review.sessionId == _selectedSessionId), - ), - activePreviewId: _activePreviewId, - developerOperationPending: _developerOperationPending, - themePreference: _themePreference, - settings: _settingsState, - lifecyclePhase: _lifecyclePhase, - settingsOperationPending: _settingsOperationPending, - ); - - SessionComposerState get _composerState { - final session = _sessions - .where((candidate) => candidate.sessionId == _selectedSessionId) - .firstOrNull; - if (session == null) return const SessionComposerState(); - final choices = []; - final seen = {}; - final slashCommands = {}; - final operationCapabilities = _catalogFrame?.operations; - for (final item in _catalogItems) { - if (item.kind == 'model') { - final selector = modelItemSelector(item); - if (selector == null || !seen.add(selector)) continue; - final label = modelLabelFor(item); - choices.add( - ComposerModelChoice( - label: label.label, - selector: label.selector, - provider: label.provider, - providerLabel: label.providerLabel, - supported: item.supported != false, - reason: item.reason, - ), - ); - continue; - } - if (item.kind != 'command' || operationCapabilities != null) continue; - final metadata = item.metadata ?? const {}; - if (!item.name.startsWith('/') && metadata['slashCommand'] != true) { - continue; - } - final bareName = item.name.replaceFirst(RegExp(r'^/+'), ''); - final missingCapability = item.capabilities - ?.where((capability) => !_grantedCapabilities.contains(capability)) - .firstOrNull; - final disabledReason = item.supported == false - ? item.reason ?? 'Not available on this host' - : missingCapability == null - ? null - : 'Not granted on this host'; - final name = '/$bareName'; - slashCommands[name] = ComposerSlashCommand( - name: name, - description: item.description ?? '', - insert: '$name ', - disabledReason: disabledReason, - ); - } - for (final operation - in operationCapabilities ?? const []) { - if (!operation.operationId.startsWith('slash.')) continue; - final bareName = operation.operationId.substring('slash.'.length); - if (bareName.isEmpty) continue; - final name = '/$bareName'; - final metadata = operation.raw['metadata']; - final rawAliases = metadata is Map - ? metadata['aliases'] - : null; - final aliases = rawAliases is List - ? rawAliases - .whereType() - .where((alias) => alias.isNotEmpty) - .map((alias) => '/${alias.replaceFirst(RegExp(r'^/+'), '')}') - .toList(growable: false) - : const []; - final requiredCapabilities = { - 'sessions.prompt', - ...?operation.capabilities, - }; - final missingCapability = requiredCapabilities - .where((capability) => !_grantedCapabilities.contains(capability)) - .firstOrNull; - String? disabledReason; - if (!operation.supported) { - disabledReason = - operation.disabledReason?.message ?? 'Not available on this host'; - } else if (missingCapability != null) { - disabledReason = missingCapability == 'terminal.io' - ? 'Needs terminal access on this host' - : 'Not granted on this host'; - } else if ((session.turnActive || _submitting) && bareName == 'compact') { - disabledReason = 'Wait for the turn to finish'; - } else if ((session.turnActive || _submitting) && bareName == 'retry') { - disabledReason = 'A turn is already running'; - } - slashCommands[name] = ComposerSlashCommand( - name: name, - aliases: List.unmodifiable(aliases), - description: operation.description ?? '', - insert: '$name ', - disabledReason: disabledReason, - ); - } - final groups = groupModelChoices(_catalogItems); - // The host reports concrete effort levels for the selected model and a - // thinkingSupported flag. Off and Auto are client-side bookends the host - // never lists; the menu is Off, Auto, then the model's concrete efforts in - // host-reported order. When the model cannot reason the menu is empty. - final levels = []; - if (session.thinkingSupported != false) { - levels.addAll(['off', 'auto']); - for (final level in session.thinkingLevels) { - if (level != 'off' && level != 'auto' && !levels.contains(level)) { - levels.add(level); - } - } - // Keep a still-valid configured value selectable even before the host - // reports concrete efforts (e.g. a resumed session set to "high"). The - // host already accepted this level, so it is advertised for the model. - final configured = session.thinking; - if (configured != null && - configured != 'off' && - configured != 'auto' && - configured != 'inherit' && - !levels.contains(configured)) { - levels.add(configured); - } - } - return SessionComposerState( - modelLabel: sessionModelLabel( - session.modelSelector, - session.modelDisplayName, - _catalogItems, - ), - modelSelector: session.modelSelector, - modelChoices: List.unmodifiable(choices), - modelGroups: List.unmodifiable(groups), - slashCommands: List.unmodifiable( - slashCommands.values, - ), - thinking: session.thinking, - thinkingLevels: List.unmodifiable(levels), - fastEnabled: session.fast, - fastAvailable: session.fastAvailable, - turnActive: session.turnActive || _submitting, - isPaused: session.isPaused, - queuedFollowUpCount: session.queuedFollowUpCount, - ); - } - - List get _allAttentionItems { - final items = [ - for (final attention in _attentionBySession.values) ...attention.items, - for (final frame in _attentionConfirmations.values) - if (DateTime.tryParse( - frame.expiresAt, - )?.isAfter(DateTime.now().toUtc()) == - true) - AttentionItem( - key: - 'confirmation:${frame.sessionId ?? 'host'}:${frame.confirmationId}', - kind: AttentionKind.confirmation, - sessionId: frame.sessionId ?? '', - sessionTitle: frame.sessionId == null - ? (_activeProfile?.label ?? 'Host') - : _sessions - .where( - (session) => session.sessionId == frame.sessionId, - ) - .firstOrNull - ?.title ?? - 'Session', - revision: frame.revision, - title: 'Security confirmation', - summary: frame.preview ?? frame.summary, - at: DateTime.now().toUtc(), - requestId: frame.confirmationId, - confirmationId: frame.confirmationId, - commandId: frame.commandId, - expiresAt: DateTime.tryParse(frame.expiresAt)?.toUtc(), - actionable: true, - ), - ]; - items.sort((left, right) { - if (left.needsResponse != right.needsResponse) { - return left.needsResponse ? -1 : 1; - } - return right.at.compareTo(left.at); - }); - return List.unmodifiable(items); - } - - List get _allAgentActivities { - final activities = _agentActivities.values.toList(growable: false) - ..sort((left, right) => right.updatedAt.compareTo(left.updatedAt)); - return List.unmodifiable(activities); - } - - Future initialize() => _initialization ??= _initialize(); - - Future _initialize() async { - try { - final storedTheme = await appPreferenceStore.loadThemePreference(); - if (_disposed) return; - _themePreference = - T4ThemePreference.values - .where((preference) => preference.name == storedTheme) - .firstOrNull ?? - T4ThemePreference.system; - } on Object { - if (_disposed) return; - _themePreference = T4ThemePreference.system; - } - _publish(); - - try { - final directory = await hostDirectoryStore.load(); - if (_disposed) return; - _hostDirectory = directory; - _initialized = true; - _directoryLoaded = true; - _errorMessage = null; - _publish(); - if (_lifecyclePhase == T4LifecyclePhase.resumed && _reconnectEnabled) { - await _connectCurrent(); - } - } on Object catch (error) { - if (_disposed) return; - _initialized = true; - _fail('Could not load saved hosts: $error'); - } - } - - @override - Future connect() async { - _reconnectEnabled = true; - if (_lifecyclePhase == T4LifecyclePhase.background) { - _resumeConnectionOwed = true; - _publish(); - return; - } - if (!_initialized) { - await initialize(); - return; - } - await _connectCurrent(); - } - - @override - Future setThemePreference(T4ThemePreference preference) async { - if (_themePreference == preference) return; - _themePreference = preference; - _publish(); - try { - await appPreferenceStore.saveThemePreference(preference.name); - } on Object catch (error) { - _errorMessage = 'Could not save appearance preference: $error'; - _publish(); - rethrow; - } - } - - @override - Future refreshSettings() { - _requireSettingsReadAccess(); - final completer = Completer(); - _beginSettingsRefresh(completer: completer); - return completer.future; - } - - @override - Future writeSetting( - String path, - String scope, { - Object? value, - bool reset = false, - }) async { - if (_settingsOperationPending) { - throw StateError('another settings change is already running'); - } - if (_phase != ConnectionPhase.ready || _hostId == null) { - throw StateError('connect before changing settings'); - } - if (!_grantedCapabilities.contains('config.write')) { - throw StateError('this device was not granted config.write'); - } - final revision = _settingsFrame?.revision; - if (revision == null) { - throw StateError('refresh settings before changing them'); - } - final entry = _settingsState.entries - .where((candidate) => candidate.path == path) - .firstOrNull; - if (entry == null) { - throw ArgumentError.value(path, 'path', 'is not a published setting'); - } - if (!entry.available || - entry.sensitive || - entry.control == HostSettingControlKind.secret || - entry.control == HostSettingControlKind.unsupported) { - throw StateError('this setting is not writable'); - } - if ((scope != 'global' && scope != 'session') || - !entry.writableScopes.contains(scope)) { - throw ArgumentError.value(scope, 'scope', 'is not writable for $path'); - } - if (!reset && !_settingValueMatches(entry, value)) { - throw ArgumentError.value(value, 'value', 'does not match $path'); - } - if (reset && value != null) { - throw ArgumentError.value(value, 'value', 'must be omitted when reset'); - } - - final edit = { - 'path': path, - 'scope': scope, - if (reset) 'reset': true else 'value': _copySettingValue(value), - }; - _settingsOperationPending = true; - _settingsState = HostSettingsState( - revision: _settingsState.revision, - entries: _settingsState.entries, - loading: _settingsState.loading, - issues: _settingsState.issues, - ); - _publish(); - try { - final frame = await _runHostCommand( - prefix: 'settings-write', - command: 'settings.write', - expectedRevision: revision, - args: { - 'edits': >[edit], - 'expectedRevision': revision, - }, - confirmationExpected: true, - ); - if (!frame.ok) { - throw StateError(frame.error?.message ?? 'settings.write failed'); - } - await refreshSettings(); - } on Object catch (error) { - _settingsState = HostSettingsState( - revision: _settingsState.revision, - entries: _settingsState.entries, - error: 'Settings change failed: $error', - issues: _settingsState.issues, - ); - _publish(); - rethrow; - } finally { - _settingsOperationPending = false; - _publish(); - } - } - - void _requireSettingsReadAccess() { - if (_hostId == null || _phase == ConnectionPhase.disconnected) { - throw StateError('connect before refreshing settings'); - } - if (!_grantedCapabilities.contains('catalog.read') || - !_grantedFeatures.contains('catalog.metadata') || - !_grantedCapabilities.contains('config.read') || - !_grantedFeatures.contains('settings.metadata')) { - throw StateError('this host did not grant live settings metadata'); - } - } - - void _beginSettingsRefresh({Completer? completer}) { - _settingsRefreshCompleter?.completeError( - StateError('settings refresh was replaced'), - ); - _settingsRefreshCompleter = completer; - _settingsRefreshSawCatalog = false; - _settingsRefreshSawSettings = false; - _settingsRefreshTimer?.cancel(); - _settingsState = HostSettingsState( - revision: _settingsState.revision, - entries: _settingsState.entries, - loading: true, - issues: _settingsState.issues, - ); - _publish(); - _sendHostProduct('catalog.get'); - _sendHostProduct('settings.read'); - _settingsRefreshTimer = Timer(const Duration(seconds: 10), () { - if (!_settingsState.loading) return; - final error = TimeoutException('settings refresh timed out'); - _settingsState = HostSettingsState( - revision: _settingsState.revision, - entries: _settingsState.entries, - error: error.message, - issues: _settingsState.issues, - ); - final pending = _settingsRefreshCompleter; - _settingsRefreshCompleter = null; - if (pending != null && !pending.isCompleted) pending.completeError(error); - _publish(); - }); - } - - void _sendHostProduct(String command) { - final hostId = _hostId; - if (hostId == null) throw StateError('host identity is unavailable'); - final ids = _nextCommandIds(command.replaceAll('.', '-')); - _pendingCommands[ids.requestId] = _PendingCommand( - commandId: ids.commandId, - command: command, - ); - _send( - WireEncoder.command( - requestId: ids.requestId, - commandId: ids.commandId, - hostId: hostId, - command: command, - args: const {}, - ), - ); - } - - Future _runHostCommand({ - required String prefix, - required String command, - required Map args, - String? expectedRevision, - bool confirmationExpected = false, - }) async { - final hostId = _hostId; - if (hostId == null) throw StateError('host identity is unavailable'); - final ids = _nextCommandIds(prefix); - final completer = Completer(); - _pendingCommands[ids.requestId] = _PendingCommand( - commandId: ids.commandId, - command: command, - completer: completer, - expectedRevision: expectedRevision, - confirmationExpected: confirmationExpected, - ); - try { - _send( - WireEncoder.command( - requestId: ids.requestId, - commandId: ids.commandId, - hostId: hostId, - command: command, - expectedRevision: expectedRevision, - args: args, - ), - ); - return await completer.future.timeout( - const Duration(seconds: 30), - onTimeout: () => throw TimeoutException('$command timed out'), - ); - } finally { - _pendingCommands.remove(ids.requestId); - } - } - - void _projectHostSettings() { - final catalog = _catalogFrame; - final settings = _settingsFrame; - if (catalog != null && - settings != null && - catalog.hostId == _hostId && - settings.hostId == _hostId) { - final projection = _buildHostSettings(catalog, settings); - final complete = - _settingsRefreshSawCatalog && _settingsRefreshSawSettings; - _settingsState = HostSettingsState( - revision: settings.revision, - entries: projection.entries, - loading: !complete, - issues: projection.issues, - ); - if (complete) { - _settingsRefreshTimer?.cancel(); - _settingsRefreshTimer = null; - final pending = _settingsRefreshCompleter; - _settingsRefreshCompleter = null; - if (pending != null && !pending.isCompleted) pending.complete(); - } - } - _publish(); - } - - void _clearSettingsProjection() { - _catalogFrame = null; - _settingsFrame = null; - _settingsState = const HostSettingsState(); - _settingsOperationPending = false; - _settingsRefreshTimer?.cancel(); - _settingsRefreshTimer = null; - final pending = _settingsRefreshCompleter; - _settingsRefreshCompleter = null; - if (pending != null && !pending.isCompleted) { - pending.completeError(StateError('settings projection was cleared')); - } - _settingsRefreshSawCatalog = false; - _settingsRefreshSawSettings = false; - _settingsBootstrapGeneration = -1; - } - - @override - Future handleLifecyclePhase(T4LifecyclePhase phase) async { - if (_lifecyclePhase == phase) return; - _lifecyclePhase = phase; - if (phase == T4LifecyclePhase.background) { - final active = - _channel != null || - _subscription != null || - _reconnectTimer != null || - _phase == ConnectionPhase.connecting || - _phase == ConnectionPhase.synchronizing || - _phase == ConnectionPhase.ready || - _phase == ConnectionPhase.retrying; - if (!active || !_reconnectEnabled) { - _publish(); - return; - } - _resumeConnectionOwed = true; - _reconnectEnabled = false; - _connectionGeneration += 1; - _reconnectTimer?.cancel(); - _reconnectTimer = null; - final previousSubscription = _subscription; - final previousChannel = _channel; - _subscription = null; - _channel = null; - _cancelPendingCommands(StateError('app moved to the background')); - _settingsOperationPending = false; - _settingsRefreshTimer?.cancel(); - _settingsRefreshTimer = null; - final refresh = _settingsRefreshCompleter; - _settingsRefreshCompleter = null; - if (refresh != null && !refresh.isCompleted) { - refresh.completeError(StateError('app moved to the background')); - } - _settingsState = HostSettingsState( - revision: _settingsState.revision, - entries: _settingsState.entries, - issues: _settingsState.issues, - ); - _phase = ConnectionPhase.disconnected; - _authenticationPhase = AuthenticationPhase.unknown; - _errorMessage = null; - _publish(); - await previousSubscription?.cancel(); - await previousChannel?.sink.close(); - return; - } - - final reconnect = _resumeConnectionOwed; - _resumeConnectionOwed = false; - if (!reconnect) { - _publish(); - return; - } - _reconnectEnabled = true; - _publish(); - if (_initialized) await _connectCurrent(); - } - - @override - Future disconnect() async { - _reconnectEnabled = false; - _resumeConnectionOwed = false; - _connectionGeneration += 1; - _reconnectTimer?.cancel(); - _reconnectTimer = null; - final previousSubscription = _subscription; - final previousChannel = _channel; - _subscription = null; - _channel = null; - _cancelPendingCommands( - StateError('connection closed before the command completed'), - ); - _pendingPair = null; - _submitting = false; - _sessionOperationPending = false; - _hostId = null; - _grantedCapabilities = const {}; - _grantedFeatures = const {}; - _catalogItems = const []; - _clearSettingsProjection(); - _bootstrapGeneration = -1; - _reconnectAttempt = 0; - _phase = ConnectionPhase.disconnected; - _authenticationPhase = AuthenticationPhase.unknown; - _errorMessage = null; - _publish(); - await previousSubscription?.cancel(); - await previousChannel?.sink.close(); - } - - @override - void cancelHostProbe() { - final operation = _hostProbeOperation; - if (operation == null) return; - _hostOperationGeneration += 1; - _hostProbeOperation = null; - final probe = _hostProbe; - _hostProbe = null; - _hostOperationPending = false; - _errorMessage = null; - _publish(); - if (probe != null) unawaited(probe.sink.close()); - } - - Future _connectCurrent() async { - final profile = developmentEndpoint == null ? _activeProfile : null; - final target = developmentEndpoint ?? profile?.webSocketUrl; - final generation = ++_connectionGeneration; - _reconnectTimer?.cancel(); - _reconnectTimer = null; - final previousSubscription = _subscription; - final previousChannel = _channel; - _subscription = null; - _channel = null; - await previousSubscription?.cancel(); - await previousChannel?.sink.close(); - if (_disposed || generation != _connectionGeneration) return; - - if (target == null) { - _phase = ConnectionPhase.disconnected; - _authenticationPhase = AuthenticationPhase.unknown; - _errorMessage = 'Add a host to connect.'; - _publish(); - return; - } - - _phase = _reconnectAttempt == 0 - ? ConnectionPhase.connecting - : ConnectionPhase.retrying; - _authenticationPhase = AuthenticationPhase.unknown; - _grantedCapabilities = const {}; - _grantedFeatures = const {}; - _errorMessage = null; - _publish(); - - WebSocketChannel? connectingChannel; - try { - final credentials = profile == null - ? null - : await hostCredentialStore.read(profile); - if (_disposed || generation != _connectionGeneration) return; - - connectingChannel = await _webSocketConnector(target); - await connectingChannel.ready; - if (_disposed || generation != _connectionGeneration) { - await connectingChannel.sink.close(); - return; - } - _channel = connectingChannel; - _subscription = connectingChannel.stream.listen( - (message) => unawaited(_handlePayload(generation, message)), - onError: (Object error, StackTrace stackTrace) => - _handleTransportLoss(generation, error), - onDone: () => _handleTransportLoss(generation), - cancelOnError: true, - ); - _phase = ConnectionPhase.synchronizing; - _publish(); - connectingChannel.sink.add(_hello(credentials)); - } on Object catch (error) { - if (connectingChannel != null && connectingChannel != _channel) { - unawaited(_closeChannelQuietly(connectingChannel)); - } - _handleTransportLoss(generation, error); - } - } - - Future _closeChannelQuietly(WebSocketChannel channel) async { - try { - await channel.sink.close().timeout(const Duration(seconds: 1)); - } on Object { - // Failed handshakes can leave close futures unresolved. - } - } - - String _hello(DeviceCredentials? credentials) => WireEncoder.hello( - client: ClientIdentity( - name: 'T4 Code', - version: '0.1.30', - build: 'flutter', - platform: defaultTargetPlatform.name, - ), - requestedFeatures: t4RequestedFeatures, - capabilities: t4RequestedCapabilities, - authentication: credentials == null - ? null - : DeviceAuthentication( - deviceId: credentials.deviceId, - deviceToken: credentials.deviceToken, - ), - savedCursors: _savedCursors.entries - .map((entry) { - final session = _sessions - .where((candidate) => candidate.sessionId == entry.key) - .firstOrNull; - return SavedCursor( - hostId: session?.hostId ?? _hostId ?? '', - sessionId: entry.key, - cursor: entry.value, - ); - }) - .where((cursor) => cursor.hostId.isNotEmpty), - ); - - HostProfile? get _activeProfile => _hostDirectory.activeProfile; - - @override - Future addHost( - String address, { - String profileId = defaultHostProfileId, - }) async { - if (!_initialized) await initialize(); - if (_disposed || !_directoryLoaded) { - throw StateError('Saved hosts are unavailable.'); - } - if (_hostOperationPending) return; - final operation = ++_hostOperationGeneration; - _hostProbeOperation = operation; - _hostOperationPending = true; - _errorMessage = null; - _publish(); - WebSocketChannel? probe; - try { - final profile = HostProfile.parseTailnetAddress( - address, - profileId: profileId, - ); - probe = await _webSocketConnector(profile.webSocketUrl); - if (!_acceptHostOperation(operation)) { - await probe.sink.close(); - return; - } - _hostProbe = probe; - await probe.ready; - await probe.sink.close(); - probe = null; - _hostProbe = null; - if (!_acceptHostOperation(operation)) return; - - final next = _hostDirectory.upsert(profile); - await hostDirectoryStore.save(next); - if (!_acceptHostOperation(operation)) return; - final switched = _hostDirectory.activeEndpointKey != profile.endpointKey; - _hostDirectory = next; - if (switched) _clearTargetProjection(); - _hostOperationPending = false; - _reconnectEnabled = true; - _publish(); - await _connectCurrent(); - } on Object catch (error) { - if (probe != null) await probe.sink.close(); - if (!_acceptHostOperation(operation)) return; - _hostOperationPending = false; - _errorMessage = 'Could not add host: $error'; - _publish(); - rethrow; - } finally { - if (_hostProbeOperation == operation) _hostProbeOperation = null; - if (identical(_hostProbe, probe)) _hostProbe = null; - } - } - - @override - Future activateHost(String endpointKey) async { - if (!_initialized) await initialize(); - if (_disposed || !_directoryLoaded) { - throw StateError('Saved hosts are unavailable.'); - } - if (_hostOperationPending || - endpointKey == _hostDirectory.activeEndpointKey) { - return; - } - final operation = ++_hostOperationGeneration; - _hostOperationPending = true; - _errorMessage = null; - _publish(); - try { - final next = _hostDirectory.activate(endpointKey); - await hostDirectoryStore.save(next); - if (!_acceptHostOperation(operation)) return; - _hostDirectory = next; - _clearTargetProjection(); - _hostOperationPending = false; - _publish(); - _reconnectEnabled = true; - await _connectCurrent(); - } on Object catch (error) { - if (!_acceptHostOperation(operation)) return; - _hostOperationPending = false; - _errorMessage = 'Could not switch hosts: $error'; - _publish(); - rethrow; - } - } - - @override - Future removeHost(String endpointKey) async { - if (!_initialized) await initialize(); - if (_disposed || !_directoryLoaded) { - throw StateError('Saved hosts are unavailable.'); - } - if (_hostOperationPending) return; - HostProfile? profile; - for (final candidate in _hostDirectory.profiles) { - if (candidate.endpointKey == endpointKey) profile = candidate; - } - if (profile == null) return; - - final operation = ++_hostOperationGeneration; - final previous = _hostDirectory; - final next = previous.remove(endpointKey); - final removedActive = previous.activeEndpointKey == endpointKey; - _hostOperationPending = true; - _errorMessage = null; - _publish(); - try { - await hostDirectoryStore.save(next); - if (!_acceptHostOperation(operation)) return; - try { - await hostCredentialStore.delete(profile); - } on Object { - await hostDirectoryStore.save(previous); - if (!_acceptHostOperation(operation)) return; - throw StateError( - 'Could not remove host credentials; the host was restored.', - ); - } - if (!_acceptHostOperation(operation)) return; - _hostDirectory = next; - if (removedActive) _clearTargetProjection(); - _hostOperationPending = false; - _publish(); - if (removedActive) await _connectCurrent(); - } on Object catch (error) { - if (!_acceptHostOperation(operation)) return; - _hostOperationPending = false; - _errorMessage = 'Could not remove host: $error'; - _publish(); - rethrow; - } - } - - bool _acceptHostOperation(int operation) => - !_disposed && operation == _hostOperationGeneration; - - @override - Future pairHost(String code) async { - if (!RegExp(r'^\d{6}$').hasMatch(code)) { - _errorMessage = 'Enter the six-digit pairing code.'; - _publish(); - return; - } - final profile = developmentEndpoint == null ? _activeProfile : null; - if (profile == null || - _authenticationPhase != AuthenticationPhase.pairingRequired || - _pendingPair != null) { - return; - } - final ids = _nextCommandIds('pair'); - final deviceId = _newDeviceId(); - final pending = _PendingPair( - requestId: ids.requestId, - endpointKey: profile.endpointKey, - deviceId: deviceId, - deviceName: 'T4 Code', - platform: defaultTargetPlatform.name, - requestedCapabilities: t4RequestedCapabilities, - ); - _pendingPair = pending; - _authenticationPhase = AuthenticationPhase.pairing; - _errorMessage = null; - _publish(); - _send( - WireEncoder.pairStart( - requestId: pending.requestId, - code: code, - deviceId: pending.deviceId, - deviceName: pending.deviceName, - platform: pending.platform, - requestedCapabilities: pending.requestedCapabilities, - ), - ); - } - - String _newDeviceId() => _secureToken(24); - - @override - Future selectSession(String sessionId) async { - if (_selectedSessionId == sessionId && _phase == ConnectionPhase.ready) { - return; - } - final known = _sessions.any((session) => session.sessionId == sessionId); - if (!known) return; - _selectedSessionId = sessionId; - _selectDeveloperSessionProjection(sessionId); - _resetTranscriptPage(); - _messages.clear(); - _phase = ConnectionPhase.synchronizing; - _errorMessage = null; - _publish(); - _primeTranscriptTailThenAttach(sessionId); - } - - @override - Future createSession(String projectId, {String? title}) async { - final normalizedProjectId = projectId.trim(); - if (normalizedProjectId.isEmpty) { - throw ArgumentError.value(projectId, 'projectId', 'must not be empty'); - } - final normalizedTitle = title?.trim(); - final frame = await _runSessionOperation( - prefix: 'create-session', - command: 'session.create', - capability: 'sessions.manage', - args: { - 'projectId': normalizedProjectId, - if (normalizedTitle != null && normalizedTitle.isNotEmpty) - 'title': normalizedTitle, - }, - ); - final result = frame.result; - if (result is! Map || !result.containsKey('session')) { - throw const FormatException('session.create result is missing'); - } - final created = WireDecoder.decodeSessionRef(result['session']); - if (created.hostId != _hostId || - created.project['projectId'] != normalizedProjectId) { - throw const FormatException('session.create returned another project'); - } - _upsertSession(created); - await selectSession(created.sessionId); - } - - @override - Future renameSession(String sessionId, String title) async { - final normalized = title.trim(); - if (normalized.isEmpty) { - throw ArgumentError.value(title, 'title', 'must not be empty'); - } - await _runLifecycleCommand( - sessionId, - command: 'session.rename', - args: {'name': normalized}, - ); - } - - @override - Future terminateSession(String sessionId) => _runLifecycleCommand( - sessionId, - command: 'session.close', - confirmationExpected: true, - ); - - @override - Future archiveSession(String sessionId) async { - await _runLifecycleCommand(sessionId, command: 'session.archive'); - if (_selectedSessionId == sessionId) { - final replacement = _sessions - .where((session) => !session.archived) - .firstOrNull; - if (replacement == null) { - _selectedSessionId = null; - _selectDeveloperSessionProjection(null); - _messages.clear(); - _publish(); - } else { - await selectSession(replacement.sessionId); - } - } - } - - @override - Future restoreSession(String sessionId) => - _runLifecycleCommand(sessionId, command: 'session.restore'); - - @override - Future deleteSession(String sessionId) => _runLifecycleCommand( - sessionId, - command: 'session.delete', - confirmationExpected: true, - ); - - @override - Future cancelAgent(String agentId) async { - final activity = _agentActivities[agentId]; - if (activity == null) { - throw ArgumentError.value(agentId, 'agentId', 'is not projected'); - } - if (const {'completed', 'failed', 'cancelled'}.contains(activity.status)) { - throw StateError('this agent has already stopped'); - } - final session = _sessions - .where((candidate) => candidate.sessionId == activity.sessionId) - .firstOrNull; - if (session == null) { - throw StateError('the agent session is no longer indexed'); - } - await _runSessionOperation( - prefix: 'agent-cancel', - command: 'agent.cancel', - capability: 'agents.control', - session: session, - args: {'agentId': agentId}, - confirmationExpected: true, - ); - } - - @override - Future searchTranscripts({ - required String query, - String? cursor, - String? projectId, - List? roles, - String archived = 'include', - DateTime? from, - DateTime? to, - }) async { - final normalizedQuery = query.trim(); - if (normalizedQuery.isEmpty) { - throw ArgumentError.value(query, 'query', 'must not be blank'); - } - if (_phase != ConnectionPhase.ready || _hostId == null) { - throw StateError('connect before searching transcripts'); - } - if (!_grantedCapabilities.contains('sessions.read')) { - throw StateError('this device was not granted sessions.read'); - } - if (!const {'include', 'only', 'exclude'}.contains(archived)) { - throw ArgumentError.value(archived, 'archived', 'is not supported'); - } - if (roles != null && - (roles.isEmpty || roles.toSet().length != roles.length)) { - throw ArgumentError.value(roles, 'roles', 'must be non-empty and unique'); - } - final fromUtc = from?.toUtc(); - final toUtc = to?.toUtc(); - if (fromUtc != null && toUtc != null && fromUtc.isAfter(toUtc)) { - throw ArgumentError.value(from, 'from', 'must not be later than to'); - } - final frame = await _runHostCommand( - prefix: 'transcript-search', - command: 'transcript.search', - args: { - 'query': normalizedQuery, - 'limit': 25, - 'archived': archived, - 'cursor': ?cursor, - 'projectId': ?projectId, - if (roles != null) - 'roles': roles.map((role) => role.name).toList(growable: false), - if (fromUtc != null) 'from': fromUtc.toIso8601String(), - if (toUtc != null) 'to': toUtc.toIso8601String(), - }, - ); - if (!frame.ok) { - throw StateError(frame.error?.message ?? 'transcript search failed'); - } - final result = frame.result; - if (result is! TranscriptSearchResult) { - throw const WireFormatException( - 'transcript.search returned an invalid result', - 'result', - ); - } - return result; - } - - @override - Future loadTranscriptContext({ - required String sessionId, - required String anchorId, - int before = 8, - int after = 8, - }) async { - if (sessionId.isEmpty) { - throw ArgumentError.value(sessionId, 'sessionId', 'must not be empty'); - } - if (anchorId.isEmpty) { - throw ArgumentError.value(anchorId, 'anchorId', 'must not be empty'); - } - if (before < 0 || before > 20 || after < 0 || after > 20) { - throw ArgumentError('context bounds must be between 0 and 20'); - } - final frame = await _runSessionReadCommand( - prefix: 'transcript-context', - command: 'transcript.context', - sessionId: sessionId, - args: { - 'anchorId': anchorId, - 'before': before, - 'after': after, - }, - ); - final result = frame.result; - if (result is! TranscriptContextResult) { - throw const WireFormatException( - 'transcript.context returned an invalid result', - 'result', - ); - } - return result; - } - - @override - Future loadEarlierTranscript() async { - final sessionId = _selectedSessionId; - final before = _transcriptPageCursor; - if (_transcriptPageLoading || - !_transcriptPageSupported || - sessionId == null || - before == null) { - return; - } - final remaining = - _maxPagedTranscriptEntries - _pagedTranscriptEntries.length; - if (remaining <= 0) { - _transcriptPageCursor = null; - _transcriptPageHasMore = false; - _transcriptPageError = 'This view reached its history limit.'; - _publish(); - return; - } - final connectionGeneration = _connectionGeneration; - final openGeneration = _transcriptOpenGeneration; - final pageGeneration = _transcriptPageGeneration; - _transcriptPageLoading = true; - _transcriptPageError = null; - _publish(); - try { - final frame = await _runSessionReadCommand( - prefix: 'transcript-page-older', - command: 'transcript.page', - sessionId: sessionId, - args: { - 'before': before, - 'limit': min(128, remaining), - 'maxBytes': 512 * 1024, - }, - ); - if (connectionGeneration != _connectionGeneration || - openGeneration != _transcriptOpenGeneration || - sessionId != _selectedSessionId) { - return; - } - final page = frame.transcriptPageResult; - if (page == null) { - throw const WireFormatException( - 'transcript.page returned an invalid result', - 'result', - ); - } - _validateTranscriptPage(page, sessionId); - if (pageGeneration != null && page.generation != pageGeneration) { - throw StateError( - 'The transcript changed while older history was loading.', - ); - } - final existingIds = _pagedTranscriptEntries - .map((entry) => entry.id) - .toSet(); - final added = page.entries - .where((entry) => existingIds.add(entry.id)) - .take(remaining) - .toList(growable: false); - _pagedTranscriptEntries = [ - ...added, - ..._pagedTranscriptEntries, - ]; - _transcriptPageGeneration = page.generation; - _transcriptPageCursor = page.nextCursor; - _transcriptPageHasMore = page.hasMore && page.nextCursor != null; - if (_pagedTranscriptEntries.length >= _maxPagedTranscriptEntries) { - _transcriptPageCursor = null; - _transcriptPageHasMore = false; - if (page.hasMore) { - _transcriptPageError = 'This view reached its history limit.'; - } - } - final visible = _messages.values.toList(growable: false); - _messages.clear(); - for (final entry in added) { - _upsertEntry(entry); - } - for (final message in visible) { - _messages[message.id] = message; - } - } on Object catch (error) { - if (connectionGeneration == _connectionGeneration && - openGeneration == _transcriptOpenGeneration && - sessionId == _selectedSessionId) { - _transcriptPageError = error is StateError - ? error.message - : 'Older transcript history could not be loaded.'; - } - } finally { - if (connectionGeneration == _connectionGeneration && - openGeneration == _transcriptOpenGeneration && - sessionId == _selectedSessionId) { - _transcriptPageLoading = false; - _publish(); - } - } - } - - @override - Future readUsage() async { - final frame = await _runHostReadCommand( - prefix: 'usage-read', - command: 'usage.read', - capability: 'usage.read', - ); - final result = frame.result; - if (result is! UsageReadResult) { - throw const WireFormatException( - 'usage.read returned an invalid result', - 'result', - ); - } - return result; - } - - @override - Future readBrokerStatus() async { - final frame = await _runHostReadCommand( - prefix: 'broker-status', - command: 'broker.status', - capability: 'broker.read', - ); - final result = frame.result; - if (result is! BrokerStatusResult) { - throw const WireFormatException( - 'broker.status returned an invalid result', - 'result', - ); - } - return result; - } - - Future _runHostReadCommand({ - required String prefix, - required String command, - required String capability, - }) async { - if (_phase != ConnectionPhase.ready || _hostId == null) { - throw StateError('connect before reading host status'); - } - if (!_grantedCapabilities.contains(capability)) { - throw StateError('this device was not granted $capability'); - } - final frame = await _runHostCommand( - prefix: prefix, - command: command, - args: const {}, - ); - if (!frame.ok) { - throw StateError(frame.error?.message ?? '$command failed'); - } - return frame; - } - - Future _runSessionReadCommand({ - required String prefix, - required String command, - required String sessionId, - required Map args, - }) async { - final hostId = _hostId; - if (_channel == null || - hostId == null || - _phase == ConnectionPhase.disconnected || - _phase == ConnectionPhase.retrying || - _phase == ConnectionPhase.failed) { - throw StateError('connect before reading a session'); - } - if (!_grantedCapabilities.contains('sessions.read')) { - throw StateError('this device was not granted sessions.read'); - } - final ids = _nextCommandIds(prefix); - final completer = Completer(); - _pendingCommands[ids.requestId] = _PendingCommand( - commandId: ids.commandId, - command: command, - sessionId: sessionId, - completer: completer, - ); - try { - _send( - WireEncoder.command( - requestId: ids.requestId, - commandId: ids.commandId, - hostId: hostId, - sessionId: sessionId, - command: command, - args: args, - ), - ); - final frame = await completer.future.timeout( - const Duration(seconds: 30), - onTimeout: () => throw TimeoutException('$command timed out'), - ); - if (!frame.ok) { - throw StateError(frame.error?.message ?? '$command failed'); - } - return frame; - } finally { - _pendingCommands.remove(ids.requestId); - } - } - - Future _runLifecycleCommand( - String sessionId, { - required String command, - Map args = const {}, - bool confirmationExpected = false, - }) { - final session = _sessions - .where((candidate) => candidate.sessionId == sessionId) - .firstOrNull; - if (session == null) { - throw ArgumentError.value(sessionId, 'sessionId', 'is not indexed'); - } - return _runSessionOperation( - prefix: command.replaceAll('.', '-'), - command: command, - capability: 'sessions.manage', - session: session, - args: args, - confirmationExpected: confirmationExpected, - ); - } - - Future _runSessionOperation({ - required String prefix, - required String command, - required String capability, - SessionSummary? session, - Map args = const {}, - bool confirmationExpected = false, - String? expectedRevision, - }) async { - if (_sessionOperationPending) { - throw StateError('another session action is already running'); - } - final hostId = _hostId; - if (_phase != ConnectionPhase.ready || hostId == null) { - throw StateError('connect before managing sessions'); - } - if (!_grantedCapabilities.contains(capability)) { - throw StateError('this device was not granted $capability'); - } - final operationRevision = expectedRevision ?? session?.revision; - final ids = _nextCommandIds(prefix); - final completer = Completer(); - final pending = _PendingCommand( - commandId: ids.commandId, - command: command, - sessionId: session?.sessionId, - completer: completer, - expectedRevision: operationRevision, - confirmationExpected: confirmationExpected, - ); - _pendingSessionOperations[ids.requestId] = pending; - _sessionOperationPending = true; - _errorMessage = null; - _publish(); - try { - _send( - WireEncoder.command( - requestId: ids.requestId, - commandId: ids.commandId, - hostId: hostId, - sessionId: session?.sessionId, - command: command, - expectedRevision: operationRevision, - args: args, - ), - ); - final frame = await completer.future.timeout( - const Duration(seconds: 10), - onTimeout: () => throw TimeoutException('$command timed out'), - ); - if (!frame.ok) { - throw StateError(frame.error?.message ?? '$command failed'); - } - return frame; - } on Object catch (error) { - _errorMessage = 'Session action failed: $error'; - _publish(); - rethrow; - } finally { - _pendingSessionOperations.remove(ids.requestId); - _sessionOperationPending = false; - _publish(); - } - } - - @override - Future submitPrompt( - String message, { - List images = const [], - }) async { - final text = message.trim(); - final session = state.selectedSession; - if (session == null || _phase != ConnectionPhase.ready) return false; - if (text.isEmpty && images.isEmpty) return false; - if (state.composer.turnActive) { - if (images.isNotEmpty) { - throw StateError('Images cannot be added while a turn is active.'); - } - return _sendPromptText('session.steer', text); - } - if (images.length > 8) { - throw ArgumentError.value(images.length, 'images', 'maximum is 8'); - } - - final optimisticId = - 'local-prompt:${session.sessionId}:${_localPromptOrdinal++}'; - _messages[optimisticId] = TranscriptMessage( - id: optimisticId, - role: MessageRole.user, - text: text, - ); - _submitting = true; - _publish(); - final uploaded = []; - try { - for (final image in images) { - uploaded.add(await _uploadImage(session, image)); - } - final frame = await _runPromptLeasedCommand( - prefix: 'prompt', - command: 'session.prompt', - session: session, - expectedRevision: session.revision, - args: { - 'message': text, - if (uploaded.isNotEmpty) - 'images': >[ - for (final imageId in uploaded) - {'imageId': imageId}, - ], - }, - ); - final result = frame.result; - return result is Map && result['accepted'] == true; - } on Object { - await _discardUploadedImages(session, uploaded); - _messages.remove(optimisticId); - _submitting = false; - _publish(); - rethrow; - } - } - - @override - Future queuePrompt(String message) => - _sendPromptText('session.followUp', message.trim()); - - Future _sendPromptText(String command, String text) async { - final session = state.selectedSession; - if (text.isEmpty || session == null || _phase != ConnectionPhase.ready) { - return false; - } - final frame = await _runPromptLeasedCommand( - prefix: command.replaceAll('.', '-'), - command: command, - session: session, - expectedRevision: session.revision, - args: {'message': text}, - ); - final result = frame.result; - return result is Map && result['accepted'] == true; - } - - @override - Future cancelTurn() async { - final session = state.selectedSession; - if (session == null || !state.composer.turnActive) return; - await _runSessionOperation( - prefix: 'session-cancel', - command: 'session.cancel', - capability: 'sessions.control', - session: session, - confirmationExpected: true, - ); - } - - @override - Future pauseSession() async { - final session = state.selectedSession; - if (session == null || session.isPaused) return; - final frame = await _runSessionOperation( - prefix: 'session-pause', - command: 'session.pause', - capability: 'sessions.control', - session: session, - ); - final result = frame.result; - if (result is Map && result['paused'] is bool) { - _setSessionPaused(session.sessionId, result['paused']! as bool); - } - } - - @override - Future resumeSession() async { - final session = state.selectedSession; - if (session == null || !session.isPaused) return; - final frame = await _runSessionOperation( - prefix: 'session-resume', - command: 'session.resume', - capability: 'sessions.control', - session: session, - ); - final result = frame.result; - final paused = result is Map && result['paused'] is bool - ? result['paused']! as bool - : false; - _setSessionPaused(session.sessionId, paused); - } - - @override - Future compactSession({String? instructions}) async { - final session = state.selectedSession; - if (session == null || session.turnActive || session.isPaused) return; - final normalized = instructions?.trim(); - await _runSessionOperation( - prefix: 'session-compact', - command: 'session.compact', - capability: 'sessions.control', - session: session, - args: { - if (normalized != null && normalized.isNotEmpty) - 'instructions': normalized, - }, - ); - } - - void _setSessionPaused(String sessionId, bool paused) { - _sessions = _sessions - .map( - (session) => session.sessionId == sessionId - ? SessionSummary( - hostId: session.hostId, - sessionId: session.sessionId, - title: session.title, - revision: session.revision, - status: session.status, - projectId: session.projectId, - projectName: session.projectName, - updatedAt: session.updatedAt, - archivedAt: session.archivedAt, - working: session.working, - modelSelector: session.modelSelector, - modelDisplayName: session.modelDisplayName, - thinking: session.thinking, - thinkingSupported: session.thinkingSupported, - thinkingLevels: session.thinkingLevels, - fast: session.fast, - fastAvailable: session.fastAvailable, - turnActive: session.turnActive, - isPaused: paused, - queuedFollowUpCount: session.queuedFollowUpCount, - ) - : session, - ) - .toList(growable: false); - _publish(); - } - - @override - Future setSessionModel(String selector) async { - final session = state.selectedSession; - if (session == null) return; - await _runSessionOperation( - prefix: 'session-model', - command: 'session.model.set', - capability: 'sessions.manage', - session: session, - args: {'selector': selector, 'persistence': 'session'}, - ); - } - - @override - Future setSessionThinking(String level) async { - final session = state.selectedSession; - if (session == null) return; - await _runSessionOperation( - prefix: 'session-thinking', - command: 'session.thinking.set', - capability: 'sessions.manage', - session: session, - args: {'level': level}, - ); - } - - @override - Future setSessionFast(bool enabled) async { - final session = state.selectedSession; - if (session == null) return; - await _runSessionOperation( - prefix: 'session-fast', - command: 'session.fast.set', - capability: 'sessions.manage', - session: session, - args: {'enabled': enabled}, - ); - } - - @override - Future respondToAttention( - AttentionItem item, - AttentionResponse response, - ) async { - final current = _allAttentionItems - .where( - (candidate) => - candidate.key == item.key && - candidate.revision == item.revision && - candidate.needsResponse, - ) - .firstOrNull; - if (current == null) { - throw StateError('This attention item was already resolved or replaced.'); - } - if (_phase != ConnectionPhase.ready) { - throw StateError('Connect before responding to attention items.'); - } - if (current.kind == AttentionKind.confirmation) { - final confirmationId = current.confirmationId; - final commandId = current.commandId; - final hostId = _hostId; - if (confirmationId == null || commandId == null || hostId == null) { - throw StateError('The confirmation is incomplete.'); - } - final expiresAt = current.expiresAt; - if (expiresAt == null || !expiresAt.isAfter(DateTime.now().toUtc())) { - _attentionConfirmations.remove(confirmationId); - _publish(); - throw StateError('The confirmation expired.'); - } - final ids = _nextCommandIds('attention-confirm'); - _send( - WireEncoder.confirm( - requestId: ids.requestId, - confirmationId: confirmationId, - commandId: commandId, - hostId: hostId, - sessionId: current.sessionId.isEmpty ? null : current.sessionId, - decision: response.decision == AttentionDecision.approve - ? 'approve' - : 'deny', - ), - ); - _attentionConfirmations.remove(confirmationId); - _publish(); - return true; - } - if (!_grantedCapabilities.contains('sessions.prompt')) { - throw StateError('This device cannot answer session requests.'); - } - final requestId = current.requestId; - final session = _sessions - .where((candidate) => candidate.sessionId == current.sessionId) - .firstOrNull; - if (requestId == null || session == null) { - throw StateError('The attention item no longer belongs to this host.'); - } - final args = {'requestId': requestId}; - switch (current.kind) { - case AttentionKind.approval: - args['confirmed'] = response.decision == AttentionDecision.approve; - case AttentionKind.question: - final value = response.text.trim().isNotEmpty - ? response.text.trim() - : response.optionIds.join(', '); - if (value.isEmpty) throw ArgumentError('Choose or enter an answer.'); - args['value'] = value; - case AttentionKind.plan: - switch (response.decision) { - case AttentionDecision.approve: - args['confirmed'] = true; - case AttentionDecision.reject: - case AttentionDecision.deny: - args['confirmed'] = false; - case AttentionDecision.revise: - args['value'] = response.text.trim(); - } - case AttentionKind.confirmation: - case AttentionKind.completed: - case AttentionKind.failed: - case AttentionKind.cancelled: - throw StateError('This attention item cannot be answered.'); - } - final frame = await _runPromptLeasedCommand( - prefix: 'attention-response', - command: 'session.ui.respond', - session: session, - expectedRevision: current.revision, - args: args, - stillCurrent: () => _allAttentionItems.any( - (candidate) => - candidate.key == current.key && - candidate.revision == current.revision, - ), - ); - final result = frame.result; - return result is Map && result['accepted'] == true; - } - - @override - Future retrySession(String sessionId) async { - final session = _sessions - .where((candidate) => candidate.sessionId == sessionId) - .firstOrNull; - if (session == null) throw StateError('The session no longer exists.'); - await _runSessionOperation( - prefix: 'session-retry', - command: 'session.retry', - capability: 'sessions.control', - session: session, - ); - } - - void _selectDeveloperSessionProjection(String? sessionId) { - _fileWorkspace = const FileWorkspaceState(); - _activeTerminalId = sessionId == null - ? null - : _terminals.values - .where((terminal) => terminal.sessionId == sessionId) - .firstOrNull - ?.terminalId; - _activePreviewId = sessionId == null - ? null - : _previews.values - .where((preview) => preview.sessionId == sessionId) - .firstOrNull - ?.previewId; - } - - SessionSummary _developerSession() => - state.selectedSession ?? - (throw StateError('Choose a session before opening developer tools.')); - - Future _runDeveloperOperation(Future Function() operation) async { - if (_developerOperationPending) { - throw StateError('Another developer action is already running.'); - } - _developerOperationPending = true; - _publish(); - try { - return await operation(); - } finally { - _developerOperationPending = false; - _publish(); - } - } - - @override - Future refreshActivity() => _runDeveloperOperation(() async { - final frame = await _runSessionOperation( - prefix: 'audit-read', - command: 'audit.read', - capability: 'audit.read', - ); - final result = frame.result; - if (result is! Map || result['events'] is! List) { - throw const FormatException('audit.read result is invalid'); - } - for (final raw in result['events']! as List) { - if (raw is Map) _recordAuditMap(raw); - } - _publish(); - }); - - @override - Future openTerminal({String? cwd}) => _runDeveloperOperation( - () async { - final session = _developerSession(); - final frame = await _runSessionOperation( - prefix: 'terminal-open', - command: 'term.open', - capability: 'term.open', - session: session, - args: { - if (cwd != null && cwd.trim().isNotEmpty) 'cwd': cwd.trim(), - 'cols': 80, - 'rows': 24, - }, - confirmationExpected: true, - ); - final result = frame.result; - if (result is! Map || result['terminalId'] is! String) { - throw const FormatException('term.open result is invalid'); - } - final terminalId = result['terminalId']! as String; - _terminals[terminalId] = TerminalSession( - terminalId: terminalId, - sessionId: session.sessionId, - title: cwd?.trim().isNotEmpty == true ? cwd!.trim() : 'Terminal', - ); - _activeTerminalId = terminalId; - _recordActivity( - category: 'shell', - title: 'Terminal opened', - detail: cwd ?? session.projectName, - raw: result, - ); - return terminalId; - }, - ); - - @override - void sendTerminalInput(String terminalId, String data) { - final terminal = _terminals[terminalId]; - final hostId = _hostId; - if (terminal == null || - !terminal.running || - hostId == null || - _phase != ConnectionPhase.ready || - !_grantedCapabilities.contains('term.input')) { - return; - } - _send( - WireEncoder.terminalInput( - hostId: hostId, - sessionId: terminal.sessionId, - terminalId: terminalId, - data: data, - ), - ); - } - - @override - void resizeTerminal(String terminalId, int cols, int rows) { - final terminal = _terminals[terminalId]; - final hostId = _hostId; - if (terminal == null || - !terminal.running || - hostId == null || - _phase != ConnectionPhase.ready || - !_grantedCapabilities.contains('term.resize')) { - return; - } - _send( - WireEncoder.terminalResize( - hostId: hostId, - sessionId: terminal.sessionId, - terminalId: terminalId, - cols: cols, - rows: rows, - ), - ); - } - - @override - void closeTerminal(String terminalId) { - final terminal = _terminals[terminalId]; - final hostId = _hostId; - if (terminal == null || hostId == null) return; - if (_phase == ConnectionPhase.ready) { - _send( - WireEncoder.terminalClose( - hostId: hostId, - sessionId: terminal.sessionId, - terminalId: terminalId, - reason: 'user', - ), - ); - } - _terminals.remove(terminalId); - _terminalCursors.remove(terminalId); - _activeTerminalId = _terminals.keys.lastOrNull; - _publish(); - } - - @override - Future listFiles([String path = '']) => - _runDeveloperOperation(() async { - final session = _developerSession(); - _fileWorkspace = FileWorkspaceState( - path: path, - loading: true, - content: _fileWorkspace.content, - diff: _fileWorkspace.diff, - ); - _publish(); - final frame = await _runComposerCommand( - prefix: 'files-list', - command: 'files.list', - session: session, - capability: 'files.list', - args: {if (path.isNotEmpty) 'path': path}, - ); - final result = frame.result; - if (result is! Map || - result['entries'] is! List) { - throw const FormatException('files.list result is invalid'); - } - final entries = []; - for (final raw in result['entries']! as List) { - if (raw is! Map || - raw['path'] is! String || - raw['kind'] is! String) { - throw const FormatException('files.list entry is invalid'); - } - entries.add( - DeveloperFileEntry( - path: raw['path']! as String, - kind: raw['kind']! as String, - size: raw['size'] is int ? raw['size']! as int : null, - revision: raw['revision'] is String - ? raw['revision']! as String - : null, - ), - ); - } - _fileWorkspace = FileWorkspaceState( - path: path, - entries: List.unmodifiable(entries), - revision: result['revision'] is String - ? result['revision']! as String - : null, - ); - }); - - @override - Future searchProjectFiles( - String query, { - int limit = 12, - }) async { - final normalized = query.trim(); - if (normalized.isEmpty) { - throw ArgumentError.value(query, 'query', 'must not be blank'); - } - if (utf8.encode(normalized).length > 256) { - throw ArgumentError.value(query, 'query', 'must be at most 256 bytes'); - } - if (limit < 1 || limit > 50) { - throw RangeError.range(limit, 1, 50, 'limit'); - } - if (!_grantedFeatures.contains('files.search')) { - throw StateError('This host does not support project file search.'); - } - final session = _developerSession(); - final frame = await _runComposerCommand( - prefix: 'files-search', - command: 'files.search', - session: session, - capability: 'files.list', - args: {'query': normalized, 'limit': limit}, - ); - final result = frame.result; - if (result is! Map || - result['matches'] is! List || - result['truncated'] is! bool) { - throw const FormatException('files.search result is invalid'); - } - if (result.keys.toSet().difference(const { - 'matches', - 'truncated', - }).isNotEmpty || - (result['matches']! as List).length > 50) { - throw const FormatException('files.search result is invalid'); - } - final paths = []; - final seen = {}; - for (final raw in result['matches']! as List) { - if (raw is! Map || - raw.keys.toSet().difference(const {'path'}).isNotEmpty || - raw['path'] is! String) { - throw const FormatException('files.search match is invalid'); - } - final path = _safeProjectSearchPath(raw['path']! as String); - if (!seen.add(path)) { - throw const FormatException('files.search match is duplicated'); - } - paths.add(path); - } - return ProjectFileSearchResult( - paths: List.unmodifiable(paths), - truncated: result['truncated']! as bool, - ); - } - - @override - Future readFile(String path) => _runDeveloperOperation(() async { - final session = _developerSession(); - final frame = await _runComposerCommand( - prefix: 'files-read', - command: 'files.read', - session: session, - capability: 'files.read', - args: {'path': path}, - ); - final result = frame.result; - if (result is! Map || result['content'] is! String) { - throw const FormatException('files.read result is invalid'); - } - _fileWorkspace = FileWorkspaceState( - path: path, - entries: _fileWorkspace.entries, - content: result['content']! as String, - revision: result['revision'] is String - ? result['revision']! as String - : null, - ); - }); - - @override - Future loadSessionDiff() => _runDeveloperOperation(() async { - final session = _developerSession(); - final path = _fileWorkspace.path; - if (path.isEmpty) { - throw StateError('Choose a file before loading its diff.'); - } - final frame = await _runComposerCommand( - prefix: 'files-diff', - command: 'files.diff', - session: session, - capability: 'files.diff', - args: {'path': path}, - ); - final result = frame.result; - if (result is! Map || result['diff'] is! String) { - throw const FormatException('files.diff result is invalid'); - } - _fileWorkspace = FileWorkspaceState( - path: path, - entries: _fileWorkspace.entries, - content: _fileWorkspace.content, - diff: result['diff']! as String, - revision: result['toRevision'] is String - ? result['toRevision']! as String - : _fileWorkspace.revision, - ); - }); - - @override - Future writeFile(String path, String content) => - _runDeveloperOperation(() async { - final session = _developerSession(); - if (path != _fileWorkspace.path || _fileWorkspace.content == null) { - throw StateError('Read this file again before saving it.'); - } - final revision = _fileWorkspace.revision; - if (revision == null) { - throw StateError('Waiting for the file revision before saving.'); - } - final frame = await _runSessionOperation( - prefix: 'files-write', - command: 'files.write', - capability: 'files.write', - session: session, - expectedRevision: revision, - args: {'path': path, 'content': content}, - confirmationExpected: true, - ); - final result = frame.result; - final nextRevision = - result is Map && result['revision'] is String - ? result['revision']! as String - : _fileWorkspace.revision; - _fileWorkspace = FileWorkspaceState( - path: path, - entries: _fileWorkspace.entries, - content: content, - revision: nextRevision, - ); - }); - - @override - Future refreshReview(String reviewId) => - _runDeveloperOperation(() async { - final review = _reviews[reviewId]; - if (review == null) { - throw ArgumentError.value(reviewId, 'reviewId', 'is not projected'); - } - final session = _developerSession(); - if (session.sessionId != review.sessionId) { - throw StateError('Open the review session before refreshing it.'); - } - await _runComposerCommand( - prefix: 'review-read', - command: 'review.read', - session: session, - capability: 'files.read', - expectedRevision: session.revision, - args: {'reviewId': reviewId}, - ); - }); - - @override - Future applyReview(String reviewId) => _runDeveloperOperation(() async { - final review = _reviews[reviewId]; - if (review == null) { - throw ArgumentError.value(reviewId, 'reviewId', 'is not projected'); - } - if (review.status == 'applied' || review.status == 'discarded') { - throw StateError('This review is no longer pending.'); - } - final session = _developerSession(); - if (session.sessionId != review.sessionId) { - throw StateError('Open the review session before applying it.'); - } - await _runSessionOperation( - prefix: 'review-apply', - command: 'review.apply', - capability: 'files.write', - session: session, - args: {'reviewId': reviewId}, - confirmationExpected: true, - ); - }); - - @override - Future launchPreview(String url) => _runDeveloperOperation(() async { - final session = _developerSession(); - final frame = await _runSessionOperation( - prefix: 'preview-launch', - command: 'preview.launch', - capability: 'preview.control', - session: session, - args: {'url': url.trim(), 'authorityId': 'omp-session'}, - confirmationExpected: true, - ); - final preview = _previewResult(frame, session.sessionId); - _activePreviewId = preview.previewId; - return preview.previewId; - }); - - @override - Future selectPreview(String previewId) => - _runPreviewMutation(previewId, 'preview.activate'); - - @override - Future navigatePreview(String previewId, String url) => - _runPreviewMutation( - previewId, - 'preview.navigate', - extraArgs: {'url': url.trim()}, - confirmationExpected: true, - ); - - @override - Future runPreviewAction(String previewId, String action) { - if (!const { - 'back', - 'forward', - 'reload', - 'close', - }.contains(action)) { - throw ArgumentError.value( - action, - 'action', - 'is not a safe preview action', - ); - } - return _runPreviewMutation(previewId, 'preview.$action'); - } - - @override - Future runPreviewInteraction( - String previewId, - String action, - Map args, - ) { - const inputActions = { - 'click', - 'fill', - 'scroll', - 'type', - 'select', - 'press', - 'upload', - }; - if (!inputActions.contains(action) && action != 'handoff') { - throw ArgumentError.value( - action, - 'action', - 'is not a supported preview interaction', - ); - } - if (args.containsKey('previewId')) { - throw ArgumentError.value(args, 'args', 'must not override previewId'); - } - return _runPreviewMutation( - previewId, - 'preview.$action', - capability: action == 'handoff' ? 'preview.control' : 'preview.input', - extraArgs: args, - confirmationExpected: action == 'upload', - ); - } - - Future _runPreviewMutation( - String previewId, - String command, { - Map extraArgs = const {}, - String capability = 'preview.control', - bool confirmationExpected = false, - }) => _runDeveloperOperation(() async { - final session = _developerSession(); - final frame = confirmationExpected - ? await _runSessionOperation( - prefix: command.replaceAll('.', '-'), - command: command, - capability: capability, - session: session, - args: {...extraArgs, 'previewId': previewId}, - confirmationExpected: true, - ) - : await _runComposerCommand( - prefix: command.replaceAll('.', '-'), - command: command, - session: session, - capability: capability, - args: {...extraArgs, 'previewId': previewId}, - ); - final preview = _previewResult(frame, session.sessionId); - if (command == 'preview.close' || preview.state == 'closed') { - _previews.remove(previewId); - _previewCaptures.remove(previewId); - _activePreviewId = _previews.keys.lastOrNull; - } else { - _activePreviewId = previewId; - } - }); - - @override - Future capturePreview(String previewId) => - _runDeveloperOperation(() async { - final session = _developerSession(); - final captureFrame = await _runComposerCommand( - prefix: 'preview-capture', - command: 'preview.capture', - session: session, - capability: 'preview.read', - args: {'previewId': previewId}, - ); - final preview = _previewResult(captureFrame, session.sessionId); - final rawPreview = - (captureFrame.result! as Map)['preview']; - final capture = rawPreview is Map - ? rawPreview['capture'] - : null; - if (capture is! Map || - capture['captureId'] is! String || - capture['size'] is! int || - capture['sha256'] is! String || - capture['mimeType'] is! String) { - throw const FormatException('preview capture metadata is invalid'); - } - final bytes = BytesBuilder(copy: false); - var offset = 0; - final size = capture['size']! as int; - while (offset < size) { - final chunkFrame = await _runComposerCommand( - prefix: 'preview-capture-read', - command: 'preview.capture.read', - session: session, - capability: 'preview.read', - args: { - 'previewId': previewId, - 'captureId': capture['captureId']! as String, - 'offset': offset, - }, - ); - final chunk = chunkFrame.result; - if (chunk is! Map || - chunk['offset'] != offset || - chunk['nextOffset'] is! int || - chunk['content'] is! String) { - throw const FormatException('preview capture chunk is invalid'); - } - final decoded = base64Decode(chunk['content']! as String); - final nextOffset = chunk['nextOffset']! as int; - if (nextOffset - offset != decoded.length || nextOffset <= offset) { - throw const FormatException('preview capture offsets are invalid'); - } - bytes.add(decoded); - offset = nextOffset; - } - final content = bytes.takeBytes(); - if (content.length != size || - sha256.convert(content).toString() != capture['sha256']) { - throw const FormatException('preview capture integrity check failed'); - } - _previewCaptures[previewId] = content; - _previews[previewId] = PreviewWorkspaceState( - previewId: preview.previewId, - sessionId: preview.sessionId, - state: preview.state, - url: preview.url, - revision: preview.revision, - title: preview.title, - canGoBack: preview.canGoBack, - canGoForward: preview.canGoForward, - capture: content, - captureMimeType: capture['mimeType']! as String, - ); - }); - - @override - Future readTranscriptImage( - String entryId, - TranscriptImageMetadata image, - ) async { - final session = state.selectedSession; - if (session == null) { - throw StateError('choose a session before loading transcript images'); - } - final bytes = BytesBuilder(copy: false); - var offset = 0; - int? expectedSize; - while (true) { - final frame = await _runComposerCommand( - prefix: 'image-read', - command: 'session.image.read', - session: session, - capability: 'sessions.read', - args: { - 'entryId': entryId, - 'sha256': image.sha256, - 'offset': offset, - }, - ); - final result = frame.result; - if (result is! Map || - result['sha256'] != image.sha256 || - result['mimeType'] != image.mimeType || - result['size'] is! int || - result['offset'] != offset || - result['nextOffset'] is! int || - result['complete'] is! bool || - result['content'] is! String) { - throw const FormatException('session.image.read result is invalid'); - } - final size = result['size']! as int; - expectedSize ??= size; - if (size != expectedSize || size <= 0 || size > 20 * 1024 * 1024) { - throw const FormatException('transcript image size changed'); - } - final nextOffset = result['nextOffset']! as int; - final chunk = base64Decode(result['content']! as String); - if (nextOffset <= offset || nextOffset - offset != chunk.length) { - throw const FormatException('transcript image offsets are invalid'); - } - bytes.add(chunk); - offset = nextOffset; - final complete = result['complete']! as bool; - if (complete != (offset == size)) { - throw const FormatException('transcript image completion is invalid'); - } - if (complete) break; - } - final value = bytes.takeBytes(); - if (value.length != expectedSize || - sha256.convert(value).toString() != image.sha256) { - throw const FormatException('transcript image integrity check failed'); - } - return value; - } - - Future _uploadImage( - SessionSummary session, - PromptImageAttachment attachment, - ) async { - if (!const { - 'image/png', - 'image/jpeg', - 'image/gif', - 'image/webp', - }.contains(attachment.mimeType)) { - throw ArgumentError.value( - attachment.mimeType, - 'mimeType', - 'is not supported', - ); - } - if (attachment.bytes.isEmpty || - attachment.bytes.length > 20 * 1024 * 1024) { - throw ArgumentError.value( - attachment.bytes.length, - 'bytes', - 'image must be between 1 byte and 20 MiB', - ); - } - final begin = await _runComposerCommand( - prefix: 'image-begin', - command: 'session.image.begin', - session: session, - args: { - 'mimeType': attachment.mimeType, - 'size': attachment.bytes.length, - 'sha256': sha256.convert(attachment.bytes).toString(), - }, - ); - final beginResult = begin.result; - if (beginResult is! Map || - beginResult['imageId'] is! String || - beginResult['chunkBytes'] is! int) { - throw const FormatException('session.image.begin result is invalid'); - } - final imageId = beginResult['imageId']! as String; - final chunkBytes = beginResult['chunkBytes']! as int; - if (chunkBytes <= 0) { - throw const FormatException('session.image.begin chunk size is invalid'); - } - for ( - var offset = 0; - offset < attachment.bytes.length; - offset += chunkBytes - ) { - final end = min(offset + chunkBytes, attachment.bytes.length); - final chunk = Uint8List.sublistView(attachment.bytes, offset, end); - final response = await _runComposerCommand( - prefix: 'image-chunk', - command: 'session.image.chunk', - session: session, - args: { - 'imageId': imageId, - 'offset': offset, - 'content': base64Encode(chunk), - }, - ); - final result = response.result; - if (result is! Map || - result['imageId'] != imageId || - result['received'] != end || - result['complete'] != (end == attachment.bytes.length)) { - throw const FormatException('session.image.chunk result is invalid'); - } - } - return imageId; - } - - Future _discardUploadedImages( - SessionSummary session, - List imageIds, - ) async { - for (final imageId in imageIds) { - try { - await _runComposerCommand( - prefix: 'image-discard', - command: 'session.image.discard', - session: session, - args: {'imageId': imageId}, - ); - } on Object { - // The original upload failure remains the actionable error. - } - } - } - - Future _runPromptLeasedCommand({ - required String prefix, - required String command, - required SessionSummary session, - required String expectedRevision, - required Map args, - bool Function()? stillCurrent, - }) async { - if (!_grantedFeatures.contains('prompt.lease')) { - return _runComposerCommand( - prefix: prefix, - command: command, - session: session, - expectedRevision: expectedRevision, - args: args, - ); - } - final acquisition = await _runComposerCommand( - prefix: 'prompt-lease-acquire', - command: 'prompt.lease.acquire', - session: session, - expectedRevision: expectedRevision, - args: const {'ownerId': 't4-code-flutter'}, - ); - final result = acquisition.result; - if (result is! Map || - result['accepted'] == false || - result['leaseId'] is! String || - (result['leaseId']! as String).isEmpty) { - throw const FormatException('prompt lease acquisition was rejected'); - } - final currentSession = _sessions - .where((candidate) => candidate.sessionId == session.sessionId) - .firstOrNull; - if (currentSession?.revision != expectedRevision || - stillCurrent?.call() == false) { - throw StateError('The session request changed before it could be sent.'); - } - return _runComposerCommand( - prefix: prefix, - command: command, - session: currentSession!, - expectedRevision: expectedRevision, - args: {...args, 'leaseId': result['leaseId']! as String}, - ); - } - - Future _runComposerCommand({ - required String prefix, - required String command, - required SessionSummary session, - required Map args, - String? expectedRevision, - String capability = 'sessions.prompt', - }) async { - if (_phase != ConnectionPhase.ready || _hostId == null) { - throw StateError('connect before sending a prompt'); - } - if (!_grantedCapabilities.contains(capability)) { - throw StateError('this device was not granted $capability'); - } - final ids = _nextCommandIds(prefix); - final completer = Completer(); - _pendingCommands[ids.requestId] = _PendingCommand( - commandId: ids.commandId, - command: command, - sessionId: session.sessionId, - completer: completer, - ); - _errorMessage = null; - _publish(); - try { - _send( - WireEncoder.command( - requestId: ids.requestId, - commandId: ids.commandId, - hostId: session.hostId, - sessionId: session.sessionId, - command: command, - expectedRevision: expectedRevision, - args: args, - ), - ); - final frame = await completer.future.timeout( - const Duration(seconds: 30), - onTimeout: () => throw TimeoutException('$command timed out'), - ); - if (!frame.ok) { - throw StateError(frame.error?.message ?? '$command failed'); - } - return frame; - } finally { - _pendingCommands.remove(ids.requestId); - } - } - - Future _handlePayload(int generation, Object? payload) async { - if (_disposed || generation != _connectionGeneration) return; - try { - final encoded = switch (payload) { - String value => value, - List value => utf8.decode(value, allowMalformed: false), - _ => throw const FormatException('unsupported websocket payload'), - }; - final frame = WireDecoder.decode(encoded); - if (frame case PairOkFrame()) { - await _applyPairOk(generation, frame); - } else { - _applyFrame(frame); - } - } on Object catch (error) { - if (_disposed || generation != _connectionGeneration) return; - _connectionGeneration += 1; - _reconnectTimer?.cancel(); - _fail('Protocol error: $error'); - unawaited(_subscription?.cancel()); - unawaited(_channel?.sink.close()); - } - } - - void _applyFrame(WireFrame frame) { - switch (frame) { - case WelcomeFrame(): - _hostId = frame.hostId; - _reconnectAttempt = 0; - _authenticationPhase = switch (frame.authentication) { - 'local' => AuthenticationPhase.local, - 'pairing-required' => AuthenticationPhase.pairingRequired, - 'paired' => AuthenticationPhase.paired, - _ => throw const FormatException('unknown authentication state'), - }; - _grantedCapabilities = frame.grantedCapabilities.toSet(); - _grantedFeatures = frame.grantedFeatures.toSet(); - if (_authenticationPhase != AuthenticationPhase.pairingRequired && - _settingsBootstrapGeneration != _connectionGeneration) { - final canReadCatalog = - _grantedCapabilities.contains('catalog.read') && - _grantedFeatures.contains('catalog.metadata'); - final canReadSettings = - _grantedCapabilities.contains('config.read') && - _grantedFeatures.contains('settings.metadata'); - if (canReadCatalog || canReadSettings) { - _settingsBootstrapGeneration = _connectionGeneration; - final needsCatalog = canReadCatalog && _catalogFrame == null; - final needsSettings = canReadSettings && _settingsFrame == null; - if (needsCatalog && needsSettings) { - _beginSettingsRefresh(); - } else { - if (needsCatalog) { - _sendHostProduct('catalog.get'); - } - if (needsSettings) { - _sendHostProduct('settings.read'); - } - } - } - } - if (_authenticationPhase != AuthenticationPhase.pairingRequired && - !_grantedCapabilities.contains('sessions.read')) { - _phase = ConnectionPhase.ready; - _errorMessage = - 'This device cannot read sessions. Pair again with ' - 'sessions.read permission.'; - _publish(); - return; - } - _publish(); - if (_grantedCapabilities.contains('sessions.read') && - _authenticationPhase != AuthenticationPhase.pairingRequired && - _bootstrapGeneration != _connectionGeneration) { - _bootstrapGeneration = _connectionGeneration; - _sendSessionList(); - } - case SessionsFrame(): - _applySessions(frame); - case SnapshotFrame(): - if (frame.hostId == _hostId && frame.sessionId == _selectedSessionId) { - _applySnapshot(frame); - } - case EntryFrame(): - if (frame.hostId == _hostId && - frame.sessionId == _selectedSessionId && - _acceptTranscriptCursor(frame.sessionId, frame.cursor)) { - _upsertEntry(frame.entry); - _publish(); - } - case EventFrame(): - if (frame.hostId == _hostId && - frame.sessionId == _selectedSessionId && - _acceptTranscriptCursor(frame.sessionId, frame.cursor)) { - _applyEvent(frame.event, frame.cursor); - _publish(); - } - case TerminalOutputFrame(): - _applyTerminalOutput(frame); - case TerminalExitFrame(): - _applyTerminalExit(frame); - case FilesAdditiveFrame(): - _applyFilesFrame(frame); - case ReviewFrame(): - _applyReviewFrame(frame); - case AuditTailFrame(): - _applyAuditEvents(frame.events); - case AuditEventFrame(): - _applyAuditEvents([frame.event]); - case PreviewFrame(): - _applyPreviewFrame(frame); - case ResponseFrame(): - _applyResponse(frame); - case SessionDeltaFrame(): - _applySessionDelta(frame); - case ConfirmationFrame(): - _applyConfirmation(frame); - case AgentFrame(): - _applyLegacyAgentFrame(frame); - case AgentAdditiveFrame(): - _applyAgentFrame(frame); - case PairErrorFrame(): - _applyPairError(frame); - case CatalogFrame(): - if (frame.hostId == _hostId) { - _catalogFrame = frame; - _catalogItems = List.unmodifiable(frame.items); - _settingsRefreshSawCatalog = true; - _projectHostSettings(); - } - case SettingsFrame(): - if (frame.hostId == _hostId) { - _settingsFrame = frame; - _settingsRefreshSawSettings = true; - _projectHostSettings(); - } - case ErrorFrame(): - _errorMessage = frame.message; - _submitting = false; - _publish(); - case GapFrame(): - if (frame.hostId == _hostId && frame.sessionId == _selectedSessionId) { - _transcriptRecoverySessionId = frame.sessionId; - _savedCursors.remove(frame.sessionId); - _phase = ConnectionPhase.synchronizing; - _errorMessage = 'Recovering transcript continuity…'; - _publish(); - _sendAttach(frame.sessionId); - } - default: - break; - } - } - - Future _applyPairOk(int generation, PairOkFrame frame) async { - final pending = _pendingPair; - if (pending == null || frame.requestId != pending.requestId) { - throw const FormatException('pairing response correlation mismatch'); - } - final requested = pending.requestedCapabilities.toSet(); - final expiration = DateTime.tryParse(frame.expiresAt); - if (frame.deviceId != pending.deviceId || - frame.deviceName != pending.deviceName || - frame.platform != pending.platform || - frame.requestedCapabilities.any( - (value) => !requested.contains(value), - ) || - frame.grantedCapabilities.any((value) => !requested.contains(value)) || - expiration == null || - !expiration.isAfter(DateTime.now().toUtc())) { - throw const FormatException('pairing response identity mismatch'); - } - final profile = _activeProfile; - if (profile == null || profile.endpointKey != pending.endpointKey) return; - _pendingPair = null; - await hostCredentialStore.write( - profile, - DeviceCredentials( - deviceId: frame.deviceId, - deviceToken: frame.deviceToken, - ), - ); - if (_disposed || - generation != _connectionGeneration || - _activeProfile?.endpointKey != pending.endpointKey) { - return; - } - _authenticationPhase = AuthenticationPhase.paired; - _publish(); - await _connectCurrent(); - } - - void _applyPairError(PairErrorFrame frame) { - final pending = _pendingPair; - if (pending == null || - (frame.requestId != null && frame.requestId != pending.requestId)) { - throw const FormatException('pairing error correlation mismatch'); - } - _pendingPair = null; - _authenticationPhase = AuthenticationPhase.pairingRequired; - _errorMessage = - 'Pairing failed (${frame.code}). Check the code and try again.'; - _publish(); - } - - void _applySessions(SessionsFrame frame) { - _applySessionListCursor(frame.cursor); - _applySessionRefs(frame.sessions); - } - - void _applySessionListCursor(SessionIndexCursor cursor) { - _sessionIndexEpoch = cursor.epoch; - _sessionIndexSeq = cursor.seq; - } - - void _applySessionRefs(List sessions) { - final previousSelectedSessionId = _selectedSessionId; - _sessions = sessions.map(_summaryFromRef).toList(growable: false) - ..sort(_compareSessions); - _attentionBySession - ..clear() - ..addEntries( - sessions.map( - (session) => - MapEntry(session.sessionId, _decodeSessionAttention(session)), - ), - ); - final selectedStillExists = _sessions.any( - (session) => session.sessionId == _selectedSessionId, - ); - if (!selectedStillExists) { - _selectedSessionId = - _sessions - .where((session) => !session.archived) - .firstOrNull - ?.sessionId ?? - _sessions.firstOrNull?.sessionId; - _selectDeveloperSessionProjection(_selectedSessionId); - } - final selected = _selectedSessionId; - if (selected == null) { - _phase = ConnectionPhase.ready; - _publish(); - return; - } - final selectionChanged = selected != previousSelectedSessionId; - if (selectionChanged) { - _resetTranscriptPage(); - _messages.clear(); - } - _publish(); - final cursor = selectionChanged ? null : _savedCursors[selected]; - if (cursor == null) { - _primeTranscriptTailThenAttach(selected); - } else { - _sendAttach(selected, cursor: cursor); - } - } - - SessionSummary _summaryFromRef(SessionRef session) { - final projectId = session.project['projectId']; - final projectName = session.project['name']; - final archivedAt = session.raw['archivedAt']; - final pendingApproval = session.raw['pendingApproval']; - final pendingUserInput = session.raw['pendingUserInput']; - final rawLiveState = session.raw['liveState']; - final liveState = rawLiveState is Map - ? rawLiveState - : const {}; - final rawModel = liveState['model']; - String? modelSelector; - String? modelDisplayName; - if (rawModel is String && rawModel.isNotEmpty) { - modelSelector = rawModel; - } else if (rawModel is Map) { - final selector = rawModel['selector']; - final provider = rawModel['provider']; - final id = rawModel['id']; - if (selector is String && selector.isNotEmpty) { - modelSelector = selector; - } else if (provider is String && - provider.isNotEmpty && - id is String && - id.isNotEmpty) { - modelSelector = '$provider/$id'; - } - final displayName = rawModel['displayName']; - if (displayName is String && displayName.isNotEmpty) { - modelDisplayName = displayName; - } - } - final refModel = session.raw['model']; - if (modelSelector == null && refModel is String && refModel.isNotEmpty) { - modelSelector = refModel; - } - final rawThinking = liveState['thinking'] ?? session.raw['thinking']; - final thinking = rawThinking is String ? rawThinking : null; - final thinkingSupported = liveState['thinkingSupported'] == true; - final thinkingLevels = switch (liveState['thinkingLevels']) { - final List values => values.whereType().toList( - growable: false, - ), - _ => const [], - }; - final queuedCount = switch (liveState['queuedMessageCount']) { - final int count when count >= 0 => count, - _ => _queuedMessageCount(liveState['queuedMessages']), - }; - final streaming = liveState['isStreaming'] == true; - return SessionSummary( - hostId: session.hostId, - sessionId: session.sessionId, - title: session.title, - revision: session.revision, - status: session.status, - projectId: projectId is String ? projectId : 'unknown-project', - projectName: projectName is String && projectName.trim().isNotEmpty - ? projectName - : 'Project', - updatedAt: session.updatedAt, - archivedAt: archivedAt is String ? archivedAt : null, - working: - session.status == 'active' || - pendingApproval == true || - pendingUserInput == true || - streaming, - modelSelector: modelSelector, - modelDisplayName: modelDisplayName, - thinking: thinking, - thinkingSupported: thinkingSupported, - thinkingLevels: thinkingLevels, - fast: liveState['fast'] == true, - fastAvailable: liveState['fastAvailable'] == true, - turnActive: - streaming || pendingApproval == true || pendingUserInput == true, - isPaused: liveState['isPaused'] == true, - queuedFollowUpCount: queuedCount, - ); - } - - _SessionAttention _decodeSessionAttention(SessionRef session) { - final raw = session.raw['attention']; - if (raw == null) return const _SessionAttention(); - if (raw is! Map) { - return const _SessionAttention(malformed: true); - } - final pending = raw['pending']; - final pendingCount = raw['pendingCount']; - final truncated = raw['truncated']; - if (pending is! List || - pending.length > 8 || - pendingCount is! int || - pendingCount < pending.length || - truncated is! bool || - truncated != (pendingCount > pending.length)) { - return const _SessionAttention(malformed: true); - } - final items = []; - for (final value in pending) { - if (value is! Map || - value['kind'] is! String || - value['id'] is! String || - value['requestedAt'] is! String) { - return const _SessionAttention(malformed: true); - } - final kind = switch (value['kind']) { - 'approval' => AttentionKind.approval, - 'question' => AttentionKind.question, - 'plan' => AttentionKind.plan, - _ => null, - }; - final at = DateTime.tryParse(value['requestedAt']! as String); - final requestId = value['id']! as String; - if (kind == null || requestId.isEmpty || at == null) { - return const _SessionAttention(malformed: true); - } - var title = ''; - var summary = ''; - var allowText = false; - var choices = const []; - if (kind == AttentionKind.question) { - final question = value['question']; - final options = value['options']; - if (question is! String || - options is! List || - options.length > 32 || - value['allowText'] is! bool) { - return const _SessionAttention(malformed: true); - } - final parsed = []; - for (final option in options) { - if (option is! Map || - option['id'] is! String || - option['label'] is! String) { - return const _SessionAttention(malformed: true); - } - parsed.add( - AttentionChoice( - id: option['id']! as String, - label: option['label']! as String, - ), - ); - } - title = 'Question'; - summary = question; - allowText = value['allowText']! as bool; - choices = List.unmodifiable(parsed); - } else { - if (value['title'] is! String || value['summary'] is! String) { - return const _SessionAttention(malformed: true); - } - title = value['title']! as String; - summary = value['summary']! as String; - } - items.add( - AttentionItem( - key: '${session.sessionId}:${kind.name}:$requestId', - kind: kind, - sessionId: session.sessionId, - sessionTitle: session.title, - revision: session.revision, - title: title, - summary: summary, - at: at.toUtc(), - requestId: requestId, - choices: choices, - allowText: allowText, - actionable: true, - ), - ); - } - final latest = raw['latestOutcome']; - if (latest != null) { - if (latest is! Map || - latest['id'] is! String || - latest['kind'] is! String || - latest['at'] is! String || - latest['summary'] is! String) { - return const _SessionAttention(malformed: true); - } - final kind = switch (latest['kind']) { - 'completed' => AttentionKind.completed, - 'failed' => AttentionKind.failed, - 'cancelled' => AttentionKind.cancelled, - _ => null, - }; - final at = DateTime.tryParse(latest['at']! as String); - if (kind == null || at == null) { - return const _SessionAttention(malformed: true); - } - final outcomeId = latest['id']! as String; - items.add( - AttentionItem( - key: '${session.sessionId}:${kind.name}:$outcomeId', - kind: kind, - sessionId: session.sessionId, - sessionTitle: session.title, - revision: session.revision, - title: switch (kind) { - AttentionKind.completed => 'Completed', - AttentionKind.failed => 'Failed', - AttentionKind.cancelled => 'Cancelled', - _ => 'Update', - }, - summary: latest['summary']! as String, - at: at.toUtc(), - ), - ); - } - return _SessionAttention( - items: List.unmodifiable(items), - omittedCount: pendingCount - pending.length, - truncated: truncated, - ); - } - - int _queuedMessageCount(Object? value) { - if (value is! Map) return 0; - var count = 0; - for (final messages in value.values) { - if (messages is List) count += messages.length; - } - return count; - } - - int _compareSessions(SessionSummary left, SessionSummary right) { - final updated = right.updatedAt.compareTo(left.updatedAt); - return updated != 0 ? updated : left.sessionId.compareTo(right.sessionId); - } - - void _upsertSession(SessionRef ref) { - final next = _summaryFromRef(ref); - final index = _sessions.indexWhere( - (session) => session.sessionId == next.sessionId, - ); - final sessions = _sessions.toList(growable: true); - if (index < 0) { - sessions.add(next); - } else { - sessions[index] = next; - } - sessions.sort(_compareSessions); - _sessions = List.unmodifiable(sessions); - _attentionBySession[ref.sessionId] = _decodeSessionAttention(ref); - _publish(); - } - - void _applySessionDelta(SessionDeltaFrame frame) { - final cursor = frame.cursor; - final currentEpoch = _sessionIndexEpoch; - final currentSeq = _sessionIndexSeq; - if (currentEpoch != null && currentSeq != null) { - if (cursor.epoch == currentEpoch && cursor.seq <= currentSeq) return; - if (cursor.epoch != currentEpoch || cursor.seq != currentSeq + 1) { - _sendSessionList(); - return; - } - } - _sessionIndexEpoch = cursor.epoch; - _sessionIndexSeq = cursor.seq; - final upsert = frame.upsert; - if (upsert != null) { - _upsertSession(upsert); - return; - } - final removed = frame.remove; - if (removed == null) return; - final wasSelected = _selectedSessionId == removed; - _sessions = _sessions - .where((session) => session.sessionId != removed) - .toList(growable: false); - _attentionBySession.remove(removed); - _attentionConfirmations.removeWhere( - (_, frame) => frame.sessionId == removed, - ); - _agentActivities.removeWhere( - (_, activity) => activity.sessionId == removed, - ); - _reviews.removeWhere((_, review) => review.sessionId == removed); - _savedCursors.remove(removed); - if (!wasSelected) { - _publish(); - return; - } - _messages.clear(); - final replacement = - _sessions.where((session) => !session.archived).firstOrNull ?? - _sessions.firstOrNull; - _selectedSessionId = replacement?.sessionId; - _selectDeveloperSessionProjection(_selectedSessionId); - if (replacement == null) { - _phase = ConnectionPhase.ready; - _publish(); - return; - } - _phase = ConnectionPhase.synchronizing; - _publish(); - _sendAttach(replacement.sessionId); - } - - void _applyConfirmation(ConfirmationFrame frame) { - if (frame.hostId != _hostId || - (frame.sessionId != null && - !_sessions.any( - (session) => session.sessionId == frame.sessionId, - ))) { - return; - } - final expiresAt = DateTime.tryParse(frame.expiresAt); - if (expiresAt == null || !expiresAt.isAfter(DateTime.now().toUtc())) { - throw const FormatException('confirmation is expired'); - } - final sessionPending = _pendingSessionOperations.values - .where((candidate) => candidate.commandId == frame.commandId) - .firstOrNull; - if (sessionPending != null) { - if (!sessionPending.confirmationExpected || - sessionPending.confirmationSent || - frame.sessionId != sessionPending.sessionId || - frame.summary != sessionPending.command || - frame.revision != sessionPending.expectedRevision) { - throw const FormatException('confirmation correlation mismatch'); - } - sessionPending.confirmationSent = true; - final ids = _nextCommandIds('confirm'); - _send( - WireEncoder.confirm( - requestId: ids.requestId, - confirmationId: frame.confirmationId, - commandId: frame.commandId, - hostId: frame.hostId, - sessionId: frame.sessionId, - decision: 'approve', - ), - ); - _attentionConfirmations.remove(frame.confirmationId); - return; - } - - final hostPending = _pendingCommands.values - .where((candidate) => candidate.commandId == frame.commandId) - .firstOrNull; - if (hostPending != null && - (!hostPending.confirmationExpected || - hostPending.confirmationSent || - frame.sessionId != hostPending.sessionId || - frame.revision != hostPending.expectedRevision)) { - throw const FormatException('confirmation correlation mismatch'); - } - if (hostPending != null) hostPending.confirmationSent = true; - _attentionConfirmations[frame.confirmationId] = frame; - _publish(); - } - - void _applyLegacyAgentFrame(AgentFrame frame) { - if (frame.hostId != _hostId) return; - final detail = frame.detail; - String? text(String key) => switch (detail?[key]) { - final String value when value.trim().isNotEmpty => value.trim(), - _ => null, - }; - final parentAgentId = text('parentId'); - _agentActivities[frame.agentId] = AgentActivity( - agentId: frame.agentId, - sessionId: frame.sessionId, - label: text('title') ?? text('name') ?? frame.agentId, - status: frame.state, - progress: frame.progress, - updatedAt: DateTime.now().toUtc(), - parentAgentId: parentAgentId == frame.agentId ? null : parentAgentId, - description: text('description'), - model: text('model'), - currentTool: text('currentTool') ?? text('tool'), - evidence: text('evidence'), - ); - _recordActivity( - category: 'agent', - title: _agentActivities[frame.agentId]!.label, - detail: frame.state, - raw: frame.raw, - ); - _publish(); - } - - void _applyAgentFrame(AgentAdditiveFrame frame) { - if (frame.hostId != _hostId || frame.frameType == 'agent.transcript') { - return; - } - final previous = _agentActivities[frame.agentId]; - final status = - frame.lifecycle ?? - frame.state ?? - frame.event ?? - previous?.status ?? - 'active'; - final detail = frame.detail; - final label = switch (detail?['title'] ?? - detail?['label'] ?? - detail?['message']) { - final String value when value.trim().isNotEmpty => value.trim(), - _ => previous?.label ?? 'Background agent ${frame.agentId}', - }; - _agentActivities[frame.agentId] = AgentActivity( - agentId: frame.agentId, - sessionId: frame.sessionId, - label: label, - status: status, - progress: frame.progress ?? previous?.progress, - updatedAt: DateTime.now().toUtc(), - parentAgentId: previous?.parentAgentId, - description: previous?.description, - model: previous?.model, - currentTool: previous?.currentTool, - evidence: previous?.evidence, - ); - _recordActivity( - category: 'agent', - title: label, - detail: status, - raw: frame.raw, - ); - _publish(); - } - - void _applyTerminalOutput(TerminalOutputFrame frame) { - if (frame.hostId != _hostId) return; - final terminal = _terminals[frame.terminalId]; - if (terminal == null || terminal.sessionId != frame.sessionId) return; - final current = _terminalCursors[frame.terminalId]; - if (current != null && - current.epoch == frame.cursor.epoch && - frame.cursor.seq <= current.seq) { - return; - } - _terminalCursors[frame.terminalId] = frame.cursor; - final chunk = frame.encoding == 'base64' - ? utf8.decode(base64Decode(frame.data), allowMalformed: true) - : frame.data; - final combined = - '${current == null || current.epoch == frame.cursor.epoch ? terminal.output : ''}$chunk'; - final output = combined.length > 1000000 - ? combined.substring(combined.length - 1000000) - : combined; - _terminals[frame.terminalId] = TerminalSession( - terminalId: terminal.terminalId, - sessionId: terminal.sessionId, - title: terminal.title, - output: output, - running: terminal.running, - exitCode: terminal.exitCode, - signal: terminal.signal, - ); - _publish(); - } - - void _applyTerminalExit(TerminalExitFrame frame) { - if (frame.hostId != _hostId) return; - final terminal = _terminals[frame.terminalId]; - if (terminal == null || terminal.sessionId != frame.sessionId) return; - _terminalCursors[frame.terminalId] = frame.cursor; - _terminals[frame.terminalId] = TerminalSession( - terminalId: terminal.terminalId, - sessionId: terminal.sessionId, - title: terminal.title, - output: terminal.output, - running: false, - exitCode: frame.exitCode, - signal: frame.signal, - ); - _recordActivity( - category: 'shell', - title: 'Terminal exited', - detail: frame.signal ?? 'Exit code ${frame.exitCode}', - raw: frame.raw, - ); - _publish(); - } - - void _applyFilesFrame(FilesAdditiveFrame frame) { - if (frame.hostId != _hostId || frame.sessionId != _selectedSessionId) { - return; - } - _fileWorkspace = FileWorkspaceState( - path: frame.path, - entries: frame.entries == null - ? _fileWorkspace.entries - : [ - for (final entry in frame.entries!) - DeveloperFileEntry( - path: entry.path, - kind: entry.kind, - size: entry.size, - revision: entry.revision, - ), - ], - content: frame.content ?? _fileWorkspace.content, - diff: frame.diff ?? frame.patch ?? _fileWorkspace.diff, - revision: frame.revision ?? frame.toRevision ?? _fileWorkspace.revision, - ); - _recordActivity( - category: 'files', - title: frame.frameType, - detail: frame.path, - raw: frame.raw, - ); - _publish(); - } - - void _applyReviewFrame(ReviewFrame frame) { - if (frame.hostId != _hostId) return; - _reviews[frame.reviewId] = ReviewWorkspaceItem( - reviewId: frame.reviewId, - sessionId: frame.sessionId, - status: frame.status, - path: frame.path, - findings: frame.findings, - ); - _recordActivity( - category: 'review', - title: 'Review ${frame.status}', - detail: frame.path ?? frame.reviewId, - raw: frame.raw, - ); - _publish(); - } - - void _applyAuditEvents(List events) { - for (final event in events) { - _recordActivity( - id: event.eventId, - category: 'audit', - title: event.action, - detail: event.actor, - at: DateTime.tryParse(event.timestamp)?.toUtc(), - raw: event.raw, - ); - } - _publish(); - } - - void _recordAuditMap(Map raw) { - final eventId = raw['eventId']; - final action = raw['action']; - final actor = raw['actor']; - final timestamp = raw['timestamp']; - if (eventId is! String || action is! String || actor is! String) return; - _recordActivity( - id: eventId, - category: 'audit', - title: action, - detail: actor, - at: timestamp is String ? DateTime.tryParse(timestamp)?.toUtc() : null, - raw: raw, - ); - } - - void _recordActivity({ - String? id, - required String category, - required String title, - required String detail, - required Map raw, - DateTime? at, - }) { - final timestamp = at ?? DateTime.now().toUtc(); - final key = id ?? '$category:${timestamp.microsecondsSinceEpoch}'; - _activities[key] = DeveloperActivity( - id: key, - category: category, - title: title, - detail: detail, - at: timestamp, - raw: const JsonEncoder.withIndent(' ').convert(_redact(raw)), - ); - while (_activities.length > 1000) { - _activities.remove(_activities.keys.first); - } - } - - Object? _redact(Object? value) { - if (value is List) return value.map(_redact).toList(); - if (value is! Map) return value; - return { - for (final entry in value.entries) - entry.key: - RegExp( - r'token|password|secret|authorization|cookie', - caseSensitive: false, - ).hasMatch(entry.key) - ? '' - : _redact(entry.value), - }; - } - - PreviewWorkspaceState _previewResult(ResponseFrame frame, String sessionId) { - final result = frame.result; - if (result is! Map || - result['preview'] is! Map) { - throw const FormatException('preview command result is invalid'); - } - return _applyPreviewMap( - result['preview']! as Map, - sessionId, - ); - } - - PreviewWorkspaceState _applyPreviewMap( - Map raw, - String sessionId, - ) { - if (raw['previewId'] is! String || - raw['state'] is! String || - raw['url'] is! String || - raw['revision'] is! String) { - throw const FormatException('preview snapshot is invalid'); - } - final previewId = raw['previewId']! as String; - final preview = PreviewWorkspaceState( - previewId: previewId, - sessionId: sessionId, - state: raw['state']! as String, - url: raw['url']! as String, - revision: raw['revision']! as String, - title: raw['title'] is String ? raw['title']! as String : null, - canGoBack: raw['canGoBack'] == true, - canGoForward: raw['canGoForward'] == true, - capture: _previewCaptures[previewId], - captureMimeType: raw['capture'] is Map - ? (raw['capture']! as Map)['mimeType'] as String? - : null, - ); - _previews[previewId] = preview; - _activePreviewId ??= previewId; - return preview; - } - - void _applyPreviewFrame(PreviewFrame frame) { - if (frame.hostId != _hostId) return; - final snapshot = frame.snapshot; - if (snapshot != null) { - final preview = PreviewWorkspaceState( - previewId: snapshot.previewId, - sessionId: frame.sessionId, - state: snapshot.state, - url: snapshot.url, - revision: snapshot.revision, - title: snapshot.title, - canGoBack: snapshot.canGoBack ?? false, - canGoForward: snapshot.canGoForward ?? false, - capture: _previewCaptures[snapshot.previewId], - captureMimeType: snapshot.capture?['mimeType'] as String?, - ); - _previews[preview.previewId] = preview; - _activePreviewId ??= preview.previewId; - } else if (_previews[frame.previewId] case final previous?) { - _previews[frame.previewId] = PreviewWorkspaceState( - previewId: previous.previewId, - sessionId: previous.sessionId, - state: previous.state, - url: previous.url, - revision: frame.revision, - title: previous.title, - canGoBack: previous.canGoBack, - canGoForward: previous.canGoForward, - capture: previous.capture, - captureMimeType: previous.captureMimeType, - error: frame.message ?? frame.error ?? frame.code, - ); - } - _recordActivity( - category: 'preview', - title: frame.frameType, - detail: frame.message ?? frame.error ?? frame.previewId, - raw: frame.raw, - ); - _publish(); - } - - void _applySnapshot(SnapshotFrame frame) { - if (_selectedSessionId != frame.sessionId) return; - _transcriptRecoverySessionId = null; - final liveIds = frame.entries.map((entry) => entry.id).toSet(); - final firstOverlap = _pagedTranscriptEntries.indexWhere( - (entry) => liveIds.contains(entry.id), - ); - final pagedPrefix = firstOverlap < 0 - ? _pagedTranscriptEntries - : _pagedTranscriptEntries.take(firstOverlap); - final cachedEntries = [...pagedPrefix, ...frame.entries]; - _pagedTranscriptEntries = cachedEntries; - _transcriptTailFromCache = false; - _messages.clear(); - for (final entry in cachedEntries) { - _upsertEntry(entry); - } - final hostId = _hostId; - final pageGeneration = _transcriptPageGeneration; - if (hostId != null && pageGeneration != null) { - unawaited( - transcriptTailStore - .save( - hostId: hostId, - sessionId: frame.sessionId, - generation: pageGeneration, - entries: cachedEntries, - ) - .then((_) {}, onError: (_, _) {}), - ); - } - _savedCursors[frame.sessionId] = frame.cursor; - _sessions = _sessions - .map( - (session) => session.sessionId == frame.sessionId - ? SessionSummary( - hostId: session.hostId, - sessionId: session.sessionId, - title: session.title, - revision: frame.revision, - status: session.status, - projectId: session.projectId, - projectName: session.projectName, - updatedAt: session.updatedAt, - archivedAt: session.archivedAt, - working: session.working, - modelSelector: session.modelSelector, - modelDisplayName: session.modelDisplayName, - thinking: session.thinking, - thinkingSupported: session.thinkingSupported, - thinkingLevels: session.thinkingLevels, - fast: session.fast, - fastAvailable: session.fastAvailable, - turnActive: session.turnActive, - isPaused: session.isPaused, - queuedFollowUpCount: session.queuedFollowUpCount, - ) - : session, - ) - .toList(growable: false); - _phase = ConnectionPhase.ready; - _errorMessage = null; - _publish(); - } - - bool _acceptTranscriptCursor(String sessionId, TranscriptCursor next) { - final current = _savedCursors[sessionId]; - if (current == null) { - _savedCursors[sessionId] = next; - return true; - } - if (next.epoch == current.epoch && next.seq <= current.seq) return false; - if (next.epoch != current.epoch || next.seq != current.seq + 1) { - _phase = ConnectionPhase.synchronizing; - _errorMessage = 'Transcript continuity changed; waiting for a snapshot.'; - _publish(); - return false; - } - _savedCursors[sessionId] = next; - return true; - } - - void _upsertEntry(DurableEntry entry) { - final data = entry.data; - if (entry.kind == 'message') { - final text = data['text']; - if (text is! String) return; - _messages[entry.id] = TranscriptMessage( - id: entry.id, - role: _messageRole(data['role']), - text: text, - reasoning: data['reasoning'] is String - ? data['reasoning']! as String - : '', - images: _entryImages(data['images']), - ); - return; - } - if (entry.kind == 'tool-use') { - final result = data['result']; - final resultMap = result is Map ? result : null; - final output = resultMap?['output']; - final isError = resultMap?['isError']; - _messages[entry.id] = TranscriptMessage( - id: entry.id, - role: MessageRole.tool, - kind: TranscriptKind.tool, - text: '', - toolName: data['tool'] is String ? data['tool']! as String : 'tool', - toolTitle: data['title'] is String ? data['title']! as String : null, - toolArguments: _jsonDisplay(data['args']), - toolOutput: output is String ? output : _jsonDisplay(result), - toolSucceeded: isError is bool ? !isError : data['ok'] as bool?, - images: _entryImages(data['images']), - ); - return; - } - if (entry.kind == 'compaction') { - final summary = data['summary']; - if (summary is! String) return; - _messages[entry.id] = TranscriptMessage( - id: entry.id, - role: MessageRole.system, - kind: TranscriptKind.compaction, - text: summary, - ); - } - } - - List _entryImages(Object? value) { - if (value is! List) return const []; - final images = []; - for (final item in value) { - if (item is! Map) continue; - final sha256 = item['sha256']; - final mimeType = item['mimeType']; - if (sha256 is String && mimeType is String) { - images.add(TranscriptImageMetadata(sha256: sha256, mimeType: mimeType)); - } - } - return List.unmodifiable(images); - } - - String? _jsonDisplay(Object? value) { - if (value == null) return null; - if (value is String) return value; - try { - return const JsonEncoder.withIndent(' ').convert(value); - } on Object { - return value.toString(); - } - } - - String _eventItemId(TranscriptCursor cursor, String suffix) => - 'event-${cursor.epoch}-${cursor.seq}-$suffix'; - - void _applyEvent(Map event, TranscriptCursor cursor) { - final eventType = event['type']; - _recordActivity( - id: 'runtime:${cursor.epoch}:${cursor.seq}', - category: eventType is String && eventType.startsWith('tool.') - ? 'tool' - : 'runtime', - title: eventType is String ? eventType : 'Unknown event', - detail: - _jsonDisplay(event['message'] ?? event['title'] ?? event['note']) ?? - '', - raw: event, - ); - switch (event['type']) { - case 'message.update': - final entryId = event['entryId']; - final text = event['text']; - if (entryId is String && text is String) { - if (event['role'] == 'user') { - _messages.removeWhere( - (id, message) => id.startsWith('local-prompt:'), - ); - } - _messages[entryId] = TranscriptMessage( - id: entryId, - role: _messageRole(event['role']), - text: text, - reasoning: event['reasoning'] is String - ? event['reasoning']! as String - : '', - streaming: true, - ); - } - case 'message.settled': - final transientEntryId = event['transientEntryId']; - if (transientEntryId is String) _messages.remove(transientEntryId); - case 'message.discarded': - final transientEntryId = event['transientEntryId']; - if (transientEntryId is String) _messages.remove(transientEntryId); - case 'tool.start': - final callId = event['callId']; - if (callId is String) { - _messages['tool:$callId'] = TranscriptMessage( - id: 'tool:$callId', - role: MessageRole.tool, - kind: TranscriptKind.tool, - text: '', - toolName: event['tool'] is String - ? event['tool']! as String - : 'tool', - toolTitle: event['title'] is String - ? event['title']! as String - : null, - toolArguments: _jsonDisplay(event['args']), - toolRunning: true, - ); - } - case 'tool.progress': - final callId = event['callId']; - final current = callId is String ? _messages['tool:$callId'] : null; - if (callId is String && current != null) { - final note = event['note']; - final chunk = event['chunk']; - final appended = [ - if (current.toolOutput case final output? when output.isNotEmpty) - output, - if (chunk is String && chunk.isNotEmpty) chunk, - ].join(); - _messages['tool:$callId'] = TranscriptMessage( - id: current.id, - role: current.role, - kind: current.kind, - text: note is String && note.isNotEmpty ? note : current.text, - toolName: current.toolName, - toolTitle: current.toolTitle, - toolArguments: current.toolArguments, - toolOutput: appended.isEmpty ? null : appended, - toolRunning: true, - toolProgress: switch (event['progress']) { - final num progress => progress.toDouble().clamp(0, 1), - _ => current.toolProgress, - }, - ); - } - case 'tool.result': - final callId = event['callId']; - final current = callId is String ? _messages['tool:$callId'] : null; - if (callId is String) { - _messages['tool:$callId'] = TranscriptMessage( - id: 'tool:$callId', - role: MessageRole.tool, - kind: TranscriptKind.tool, - text: current?.text ?? '', - toolName: current?.toolName ?? 'tool', - toolTitle: current?.toolTitle, - toolArguments: current?.toolArguments, - toolOutput: _jsonDisplay(event['result']), - toolSucceeded: event['ok'] is bool ? event['ok']! as bool : null, - ); - } - case 'turn.start': - case 'agent.start': - _submitting = true; - case 'turn.end': - case 'agent.end': - _submitting = false; - case 'turn.error': - _submitting = false; - final message = event['message']; - final text = message is String ? message : 'The agent turn failed.'; - _errorMessage = text; - _messages[_eventItemId(cursor, 'error')] = TranscriptMessage( - id: _eventItemId(cursor, 'error'), - role: MessageRole.system, - kind: TranscriptKind.notice, - text: text, - ); - case 'notice': - final message = event['message']; - if (message is String) { - _messages[_eventItemId(cursor, 'notice')] = TranscriptMessage( - id: _eventItemId(cursor, 'notice'), - role: MessageRole.system, - kind: TranscriptKind.notice, - text: message, - ); - } - } - } - - void _applyResponse(ResponseFrame frame) { - final operation = _pendingSessionOperations.remove(frame.requestId); - if (operation != null) { - if (operation.commandId != frame.commandId || - operation.command != frame.command || - operation.sessionId != frame.sessionId) { - throw const FormatException('session operation correlation mismatch'); - } - operation.completer!.complete(frame); - return; - } - final pending = _pendingCommands.remove(frame.requestId); - if (pending == null) { - throw FormatException( - 'unexpected response for ${frame.command ?? 'unknown command'}', - ); - } - if (pending.commandId != frame.commandId) { - throw const FormatException('response command ID mismatch'); - } - if (pending.command != frame.command) { - throw const FormatException('response command mismatch'); - } - if (pending.sessionId != frame.sessionId) { - throw const FormatException('response session mismatch'); - } - final completer = pending.completer; - if (completer != null) { - completer.complete(frame); - return; - } - if (pending.command == 'session.attach' && - pending.sessionId != _selectedSessionId) { - return; - } - if (!frame.ok) { - if (pending.command == 'session.state.get' && - frame.error?.code == 'session_locked') { - _publish(); - return; - } - if (frame.command == 'catalog.get' || frame.command == 'settings.read') { - final error = StateError( - frame.error?.message ?? '${frame.command} failed', - ); - _settingsRefreshTimer?.cancel(); - _settingsRefreshTimer = null; - _settingsState = HostSettingsState( - revision: _settingsState.revision, - entries: _settingsState.entries, - error: error.message, - issues: _settingsState.issues, - ); - final refresh = _settingsRefreshCompleter; - _settingsRefreshCompleter = null; - if (refresh != null && !refresh.isCompleted) { - refresh.completeError(error); - } - } - _submitting = false; - _errorMessage = frame.error?.message ?? 'Command failed.'; - _publish(); - return; - } - if (frame.command == 'catalog.get') { - final result = frame.catalogResult; - if (result == null) { - throw const FormatException('catalog.get result is missing'); - } - _catalogFrame = CatalogFrame( - hostId: frame.hostId, - revision: result.revision, - items: result.items, - operations: result.operations, - raw: frame.raw, - ); - _catalogItems = List.unmodifiable(result.items); - _settingsRefreshSawCatalog = true; - _projectHostSettings(); - return; - } - if (frame.command == 'settings.read') { - final result = frame.settingsResult; - if (result == null) { - throw const FormatException('settings.read result is missing'); - } - _settingsFrame = SettingsFrame( - hostId: frame.hostId, - revision: result.revision, - settings: result.settings, - raw: frame.raw, - ); - _settingsRefreshSawSettings = true; - _projectHostSettings(); - return; - } - if (frame.command == 'session.list') { - final sessions = frame.sessionListResult; - if (sessions == null) { - throw const FormatException('session.list result is missing'); - } - _applySessionListCursor(sessions.cursor); - _applySessionRefs(sessions.sessions); - if (_grantedFeatures.contains('host.watch')) { - _sendHostWatch(sessions.cursor); - } - return; - } - if (frame.command == 'session.state.get') { - final result = frame.sessionStateResult; - if (result == null || pending.sessionId == null) { - throw const FormatException('session.state.get result is missing'); - } - _applySessionStateResult(pending.sessionId!, result); - return; - } - if (frame.command == 'session.attach' && - pending.sessionId == _selectedSessionId && - _transcriptRecoverySessionId != pending.sessionId && - (_messages.isNotEmpty || - _savedCursors.containsKey(_selectedSessionId))) { - _phase = ConnectionPhase.ready; - } - if (frame.command == 'session.attach' && - pending.sessionId == _selectedSessionId) { - _sendSessionStateGet(pending.sessionId!); - } - _publish(); - } - - void _applySessionStateResult(String sessionId, SessionStateResult result) { - _sessions = _sessions - .map((session) { - if (session.sessionId != sessionId) return session; - final model = result.model; - final modelSelector = - model?.selector ?? - (model == null ? null : '${model.provider}/${model.id}'); - return SessionSummary( - hostId: session.hostId, - sessionId: session.sessionId, - title: session.title, - revision: session.revision, - status: session.status, - projectId: session.projectId, - projectName: session.projectName, - updatedAt: session.updatedAt, - archivedAt: session.archivedAt, - working: - session.status == 'active' || - session.working || - result.isStreaming, - modelSelector: modelSelector ?? session.modelSelector, - modelDisplayName: model?.displayName ?? session.modelDisplayName, - thinking: result.thinking ?? session.thinking, - thinkingSupported: - result.thinkingSupported ?? session.thinkingSupported, - thinkingLevels: result.thinkingLevels ?? session.thinkingLevels, - fast: result.fastActive ?? result.fast ?? session.fast, - fastAvailable: result.fastAvailable ?? session.fastAvailable, - turnActive: result.isStreaming || session.turnActive, - isPaused: result.isPaused, - queuedFollowUpCount: result.queuedMessageCount, - ); - }) - .toList(growable: false); - _publish(); - } - - void _sendSessionList() { - final hostId = _hostId; - if (hostId == null || - _pendingCommands.values.any( - (pending) => pending.command == 'session.list', - )) { - return; - } - final ids = _nextCommandIds('session-list'); - _pendingCommands[ids.requestId] = _PendingCommand( - commandId: ids.commandId, - command: 'session.list', - ); - _send( - WireEncoder.sessionList( - requestId: ids.requestId, - commandId: ids.commandId, - hostId: hostId, - ), - ); - } - - void _sendHostWatch(SessionIndexCursor cursor) { - final hostId = _hostId; - if (hostId == null) return; - final ids = _nextCommandIds('host-watch'); - _pendingCommands[ids.requestId] = _PendingCommand( - commandId: ids.commandId, - command: 'host.watch', - ); - _send( - WireEncoder.hostWatch( - requestId: ids.requestId, - commandId: ids.commandId, - hostId: hostId, - cursor: cursor, - ), - ); - } - - void _resetTranscriptPage() { - _transcriptOpenGeneration += 1; - _pagedTranscriptEntries = const []; - _transcriptPageGeneration = null; - _transcriptPageCursor = null; - _transcriptPageLoading = false; - _transcriptPageHasMore = false; - _transcriptPageError = null; - _transcriptRecoverySessionId = null; - _transcriptTailFromCache = false; - _transcriptPrimeGeneration = null; - } - - bool get _transcriptPageSupported => - _grantedCapabilities.contains('sessions.read') && - _grantedFeatures.contains('transcript.page'); - - void _primeTranscriptTailThenAttach(String sessionId) { - if (_pendingCommands.values.any( - (pending) => - pending.command == 'transcript.page' && - pending.sessionId == sessionId, - )) { - return; - } - if (!_transcriptPageSupported) { - _sendAttach(sessionId); - return; - } - final connectionGeneration = _connectionGeneration; - final openGeneration = _transcriptOpenGeneration; - if (_transcriptPrimeGeneration == openGeneration) return; - final hostId = _hostId; - if (hostId == null) { - _sendAttach(sessionId); - return; - } - _transcriptPrimeGeneration = openGeneration; - unawaited( - _loadCachedThenInitialTranscriptPage( - hostId: hostId, - sessionId: sessionId, - openGeneration: openGeneration, - ).whenComplete(() { - if (_transcriptPrimeGeneration == openGeneration) { - _transcriptPrimeGeneration = null; - } - if (_disposed || - connectionGeneration != _connectionGeneration || - openGeneration != _transcriptOpenGeneration || - sessionId != _selectedSessionId || - _channel == null) { - return; - } - _sendAttach(sessionId); - }), - ); - } - - Future _loadCachedThenInitialTranscriptPage({ - required String hostId, - required String sessionId, - required int openGeneration, - }) async { - try { - final cached = await transcriptTailStore.load( - hostId: hostId, - sessionId: sessionId, - ); - if (!_transcriptOpenIsCurrent(sessionId, openGeneration)) return; - if (cached != null) { - _pagedTranscriptEntries = cached.entries; - _transcriptPageGeneration = cached.generation; - _transcriptTailFromCache = true; - _messages.clear(); - for (final entry in cached.entries) { - _upsertEntry(entry); - } - _publish(); - } - } on Object { - // A damaged or unavailable display cache never blocks the live session. - } - if (_transcriptOpenIsCurrent(sessionId, openGeneration)) { - await _loadInitialTranscriptPage(sessionId, openGeneration); - } - } - - bool _transcriptOpenIsCurrent(String sessionId, int openGeneration) => - !_disposed && - _selectedSessionId == sessionId && - _transcriptOpenGeneration == openGeneration; - - Future _loadInitialTranscriptPage( - String sessionId, - int openGeneration, - ) async { - try { - final frame = await _runSessionReadCommand( - prefix: 'transcript-page', - command: 'transcript.page', - sessionId: sessionId, - args: const {'limit': 64, 'maxBytes': 256 * 1024}, - ); - if (!_transcriptOpenIsCurrent(sessionId, openGeneration)) return; - final page = frame.transcriptPageResult; - if (page == null) { - throw const WireFormatException( - 'transcript.page returned an invalid result', - 'result', - ); - } - _validateTranscriptPage(page, sessionId); - _pagedTranscriptEntries = page.entries; - _transcriptPageGeneration = page.generation; - _transcriptPageCursor = page.nextCursor; - _transcriptPageHasMore = page.hasMore && page.nextCursor != null; - _transcriptPageError = null; - _transcriptTailFromCache = false; - _messages.clear(); - for (final entry in page.entries) { - _upsertEntry(entry); - } - _publish(); - final hostId = _hostId; - if (hostId != null) { - unawaited( - transcriptTailStore - .save( - hostId: hostId, - sessionId: sessionId, - generation: page.generation, - entries: page.entries, - ) - .then((_) {}, onError: (_, _) {}), - ); - } - } on Object { - // Bounded history is an optional read lane. Live attach remains the - // authority and must proceed when paging is unavailable or stale. - } - } - - void _validateTranscriptPage(TranscriptPageResult page, String sessionId) { - final hostId = _hostId; - if (hostId == null || - page.entries.any( - (entry) => entry.hostId != hostId || entry.sessionId != sessionId, - )) { - throw const WireFormatException( - 'transcript.page returned entries for another session', - 'result.entries', - ); - } - } - - void _sendSessionStateGet(String sessionId) { - final hostId = _hostId; - if (hostId == null || - !_grantedCapabilities.contains('sessions.read') || - _pendingCommands.values.any( - (pending) => - pending.command == 'session.state.get' && - pending.sessionId == sessionId, - )) { - return; - } - final ids = _nextCommandIds('session-state'); - _pendingCommands[ids.requestId] = _PendingCommand( - commandId: ids.commandId, - command: 'session.state.get', - sessionId: sessionId, - ); - _send( - WireEncoder.command( - requestId: ids.requestId, - commandId: ids.commandId, - hostId: hostId, - sessionId: sessionId, - command: 'session.state.get', - args: const {}, - ), - ); - } - - void _sendAttach(String sessionId, {TranscriptCursor? cursor}) { - final session = _sessions - .where((item) => item.sessionId == sessionId) - .firstOrNull; - if (session == null || - _pendingCommands.values.any( - (pending) => - pending.command == 'session.attach' && - pending.sessionId == sessionId, - )) { - return; - } - final ids = _nextCommandIds('attach'); - _pendingCommands[ids.requestId] = _PendingCommand( - commandId: ids.commandId, - command: 'session.attach', - sessionId: sessionId, - ); - _send( - WireEncoder.sessionAttach( - requestId: ids.requestId, - commandId: ids.commandId, - hostId: session.hostId, - sessionId: session.sessionId, - cursor: cursor, - ), - ); - } - - ({String requestId, String commandId}) _nextCommandIds(String prefix) { - final ordinal = ++_commandOrdinal; - return ( - requestId: '$prefix-$_commandNamespace-request-$ordinal', - commandId: '$prefix-$_commandNamespace-command-$ordinal', - ); - } - - void _send(String encoded) { - final channel = _channel; - if (channel == null) throw StateError('websocket is not connected'); - channel.sink.add(encoded); - } - - void _clearTargetProjection() { - _connectionGeneration += 1; - _reconnectTimer?.cancel(); - _reconnectTimer = null; - unawaited(_subscription?.cancel()); - unawaited(_channel?.sink.close()); - _subscription = null; - _channel = null; - _sessions = const []; - _messages.clear(); - _resetTranscriptPage(); - _savedCursors.clear(); - _attentionBySession.clear(); - _attentionConfirmations.clear(); - _agentActivities.clear(); - _activities.clear(); - _terminals.clear(); - _terminalCursors.clear(); - _previews.clear(); - _previewCaptures.clear(); - _reviews.clear(); - _fileWorkspace = const FileWorkspaceState(); - _activeTerminalId = null; - _activePreviewId = null; - _developerOperationPending = false; - _cancelPendingCommands(StateError('active host changed')); - _pendingPair = null; - _selectedSessionId = null; - _hostId = null; - _submitting = false; - _sessionOperationPending = false; - _grantedCapabilities = const {}; - _sessionIndexEpoch = null; - _sessionIndexSeq = null; - _grantedFeatures = const {}; - _catalogItems = const []; - _clearSettingsProjection(); - _bootstrapGeneration = -1; - _reconnectAttempt = 0; - _phase = ConnectionPhase.disconnected; - _authenticationPhase = AuthenticationPhase.unknown; - } - - void _handleTransportLoss(int generation, [Object? error]) { - if (_disposed || generation != _connectionGeneration) return; - _subscription = null; - _channel = null; - _cancelPendingCommands(StateError('connection lost')); - _pendingPair = null; - _submitting = false; - _sessionOperationPending = false; - if (!_reconnectEnabled) { - _phase = ConnectionPhase.disconnected; - _authenticationPhase = AuthenticationPhase.unknown; - _errorMessage = null; - _publish(); - return; - } - _reconnectAttempt += 1; - final exponent = (_reconnectAttempt - 1).clamp(0, 4); - final delay = Duration(milliseconds: 500 * (1 << exponent)); - _phase = ConnectionPhase.retrying; - _authenticationPhase = AuthenticationPhase.unknown; - _errorMessage = error == null - ? 'Connection closed. Retrying…' - : 'Connection lost: $error'; - _publish(); - _reconnectTimer?.cancel(); - _reconnectTimer = Timer(delay, () { - if (!_disposed && - _reconnectEnabled && - generation == _connectionGeneration) { - unawaited(_connectCurrent()); - } - }); - } - - MessageRole _messageRole(Object? role) => switch (role) { - 'user' => MessageRole.user, - 'system' => MessageRole.system, - 'tool' => MessageRole.tool, - _ => MessageRole.assistant, - }; - - void _cancelPendingCommands(Object error) { - for (final pending in _pendingSessionOperations.values) { - final completer = pending.completer; - if (completer != null && !completer.isCompleted) { - completer.completeError(error); - } - } - for (final pending in _pendingCommands.values) { - final completer = pending.completer; - if (completer != null && !completer.isCompleted) { - completer.completeError(error); - } - } - _pendingSessionOperations.clear(); - _pendingCommands.clear(); - } - - void _fail(String message) { - _cancelPendingCommands(StateError(message)); - _phase = ConnectionPhase.failed; - _errorMessage = message; - _submitting = false; - _sessionOperationPending = false; - _publish(); - } - - void _publish() { - if (!_disposed) notifyListeners(); - } - - @override - void dispose() { - final settingsRefresh = _settingsRefreshCompleter; - _settingsRefreshCompleter = null; - if (settingsRefresh != null && !settingsRefresh.isCompleted) { - settingsRefresh.completeError(StateError('controller disposed')); - } - _disposed = true; - _reconnectEnabled = false; - _hostProbeOperation = null; - final hostProbe = _hostProbe; - _hostProbe = null; - if (hostProbe != null) unawaited(hostProbe.sink.close()); - _connectionGeneration += 1; - _hostOperationGeneration += 1; - _reconnectTimer?.cancel(); - _settingsRefreshTimer?.cancel(); - _cancelPendingCommands(StateError('controller disposed')); - unawaited(_subscription?.cancel()); - unawaited(_channel?.sink.close()); - super.dispose(); - } -} - -const Set _knownSettingItemKeys = { - 'path', - 'label', - 'description', - 'controlType', - 'options', - 'min', - 'max', - 'step', - 'unit', - 'scopes', - 'restartRequired', - 'platform', - 'availability', - 'maxItems', - 'maxEntries', - 'default', - 'effective', - 'effectiveSource', - 'configured', - 'sensitive', - 'tab', - 'group', -}; - -final Set _knownSettingValueKeys = _knownSettingItemKeys - .where( - (key) => - key != 'path' && - key != 'label' && - key != 'description' && - key != 'tab' && - key != 'group', - ) - .toSet(); - -final RegExp _settingControlCharacters = RegExp( - r'[\u0000-\u001f\u007f-\u009f]', -); - -({List entries, List issues}) _buildHostSettings( - CatalogFrame catalog, - SettingsFrame settings, -) { - final issues = []; - final rows = <({HostSettingEntry entry, String group})>[]; - final seen = {}; - for (final item in catalog.items.where((item) => item.kind == 'setting')) { - final metadata = item.metadata; - final fallbackPath = _safeSettingText(item.name, 128) ?? item.id; - if (metadata == null) { - issues.add('${item.id}: setting item carries no metadata'); - if (seen.add(fallbackPath)) { - rows.add(( - entry: _unsupportedSetting( - path: fallbackPath, - section: 'advanced', - label: _humanizeSettingPath(fallbackPath), - help: '', - ), - group: '', - )); - } - continue; - } - final path = - _safeSettingText(metadata['path'], 128) ?? - _safeSettingText(item.name, 128); - if (path == null || path.startsWith('/') || path.startsWith('~')) { - issues.add('${item.id}: setting item has no usable path'); - continue; - } - if (!seen.add(path)) { - issues.add('$path: duplicate setting path ignored'); - continue; - } - final section = _safeSettingText(metadata['tab'], 64) ?? 'advanced'; - final group = _safeSettingText(metadata['group'], 64) ?? ''; - final hostLabel = _safeSettingText(metadata['label'], 200); - final label = hostLabel != null && hostLabel != path - ? hostLabel - : _humanizeSettingPath(path); - final help = _safeSettingText(metadata['description'], 2000) ?? ''; - final unknown = metadata.keys - .where((key) => !_knownSettingItemKeys.contains(key)) - .toList(growable: false); - if (unknown.isNotEmpty) { - issues.add('$path: unrecognized metadata (${unknown.join(', ')})'); - rows.add(( - entry: _unsupportedSetting( - path: path, - section: section, - label: label, - help: help, - ), - group: group, - )); - continue; - } - - final rawValues = settings.settings[path]; - final values = rawValues is Map ? rawValues : null; - if (rawValues != null && values == null) { - issues.add('$path: value metadata is not a record'); - rows.add(( - entry: _unsupportedSetting( - path: path, - section: section, - label: label, - help: help, - ), - group: group, - )); - continue; - } - final unknownValues = values?.keys - .where((key) => !_knownSettingValueKeys.contains(key)) - .toList(growable: false); - if (unknownValues != null && unknownValues.isNotEmpty) { - issues.add( - '$path: unrecognized value metadata (${unknownValues.join(', ')})', - ); - rows.add(( - entry: _unsupportedSetting( - path: path, - section: section, - label: label, - help: help, - ), - group: group, - )); - continue; - } - - final source = values ?? metadata; - final sensitive = - metadata['sensitive'] == true || values?['sensitive'] == true; - final configured = - metadata['configured'] == true || values?['configured'] == true; - final restartRequired = metadata['restartRequired'] == true; - final available = metadata['availability'] != false; - if (sensitive) { - final delivered = - metadata.containsKey('default') || - metadata.containsKey('effective') || - (values?.containsKey('default') ?? false) || - (values?.containsKey('effective') ?? false); - if (delivered) { - issues.add('$path: sensitive setting arrived with a value'); - } - rows.add(( - entry: HostSettingEntry( - path: path, - section: section, - label: label, - help: help, - control: delivered - ? HostSettingControlKind.unsupported - : HostSettingControlKind.secret, - configured: configured, - options: const [], - writableScopes: const [], - restartRequired: restartRequired, - available: available, - sensitive: true, - ), - group: group, - )); - continue; - } - - final combined = {...metadata, ...?values}; - final controlProjection = _settingControl(combined); - if (controlProjection.issue case final issue?) { - issues.add('$path: $issue'); - rows.add(( - entry: _unsupportedSetting( - path: path, - section: section, - label: label, - help: help, - configured: configured, - restartRequired: restartRequired, - available: available, - ), - group: group, - )); - continue; - } - if (source.containsKey('default') && - _copySafeSettingValue(source['default']) == null) { - issues.add('$path: default value has a shape this app cannot edit'); - rows.add(( - entry: _unsupportedSetting( - path: path, - section: section, - label: label, - help: help, - configured: configured, - restartRequired: restartRequired, - available: available, - ), - group: group, - )); - continue; - } - final hasEffective = source.containsKey('effective'); - final effective = hasEffective - ? _copySafeSettingValue(source['effective']) - : null; - if (hasEffective && effective == null) { - issues.add('$path: effective value has a shape this app cannot edit'); - rows.add(( - entry: _unsupportedSetting( - path: path, - section: section, - label: label, - help: help, - configured: configured, - restartRequired: restartRequired, - available: available, - ), - group: group, - )); - continue; - } - final effectiveSource = source['effectiveSource']; - if (effective != null && - (effectiveSource is! String || - !const { - 'override', - 'configOverlay', - 'project', - 'global', - 'default', - }.contains(effectiveSource))) { - issues.add('$path: unrecognized effective source $effectiveSource'); - rows.add(( - entry: _unsupportedSetting( - path: path, - section: section, - label: label, - help: help, - configured: configured, - restartRequired: restartRequired, - available: available, - ), - group: group, - )); - continue; - } - final scopeProjection = _settingScopes(metadata['scopes']); - if (scopeProjection.issue case final issue?) { - issues.add('$path: $issue'); - rows.add(( - entry: _unsupportedSetting( - path: path, - section: section, - label: label, - help: help, - configured: configured, - restartRequired: restartRequired, - available: available, - ), - group: group, - )); - continue; - } - rows.add(( - entry: HostSettingEntry( - path: path, - section: section, - label: label, - help: help, - control: controlProjection.kind, - effectiveValue: effective, - configured: configured, - effectiveSource: effective == null ? null : effectiveSource as String, - options: controlProjection.options, - min: controlProjection.min, - max: controlProjection.max, - unit: controlProjection.unit, - writableScopes: available ? scopeProjection.scopes : const [], - restartRequired: restartRequired, - available: available, - sensitive: false, - ), - group: group, - )); - } - rows.sort((left, right) { - final section = left.entry.section.compareTo(right.entry.section); - if (section != 0) return section; - final group = left.group.compareTo(right.group); - if (group != 0) return group; - return left.entry.path.compareTo(right.entry.path); - }); - return ( - entries: List.unmodifiable(rows.map((row) => row.entry)), - issues: List.unmodifiable(issues), - ); -} - -HostSettingEntry _unsupportedSetting({ - required String path, - required String section, - required String label, - required String help, - bool configured = false, - bool restartRequired = false, - bool available = true, -}) => HostSettingEntry( - path: path, - section: section, - label: label, - help: help, - control: HostSettingControlKind.unsupported, - configured: configured, - options: const [], - writableScopes: const [], - restartRequired: restartRequired, - available: available, - sensitive: false, -); - -({ - HostSettingControlKind kind, - List options, - num? min, - num? max, - String? unit, - String? issue, -}) -_settingControl(Map metadata) { - final declared = metadata['controlType']; - switch (declared) { - case 'boolean': - return ( - kind: HostSettingControlKind.boolean, - options: const [], - min: null, - max: null, - unit: null, - issue: null, - ); - case 'number': - final min = _finiteSettingNumber(metadata['min']); - final max = _finiteSettingNumber(metadata['max']); - if ((metadata['min'] != null && min == null) || - (metadata['max'] != null && max == null)) { - return _unsupportedControl('number bounds are malformed'); - } - return ( - kind: HostSettingControlKind.number, - options: const [], - min: min, - max: max, - unit: _safeSettingText(metadata['unit'], 16), - issue: null, - ); - case 'string': - return ( - kind: HostSettingControlKind.text, - options: const [], - min: null, - max: null, - unit: null, - issue: null, - ); - case 'enum': - final options = _settingOptions(metadata['options']); - if (options == null) { - return _unsupportedControl('enum options are malformed'); - } - return ( - kind: HostSettingControlKind.enumeration, - options: options, - min: null, - max: null, - unit: null, - issue: null, - ); - case 'array': - return ( - kind: HostSettingControlKind.list, - options: const [], - min: null, - max: null, - unit: null, - issue: null, - ); - case 'record': - return ( - kind: HostSettingControlKind.map, - options: const [], - min: null, - max: null, - unit: null, - issue: null, - ); - default: - return _unsupportedControl( - declared == null - ? 'setting control is missing' - : 'unrecognized setting control $declared', - ); - } -} - -({ - HostSettingControlKind kind, - List options, - num? min, - num? max, - String? unit, - String? issue, -}) -_unsupportedControl(String issue) => ( - kind: HostSettingControlKind.unsupported, - options: const [], - min: null, - max: null, - unit: null, - issue: issue, -); - -List? _settingOptions(Object? raw) { - if (raw is! List || raw.isEmpty) return null; - final options = []; - for (final option in raw) { - if (option is String) { - final value = _safeSettingText(option, 200); - if (value == null) return null; - options.add(HostSettingOption(value: value, label: value)); - continue; - } - if (option is! Map) return null; - final rawValue = option['value']; - final value = - _safeSettingText(rawValue, 200) ?? - (rawValue is num && _finiteSettingNumber(rawValue) != null - ? rawValue.toString() - : null); - if (value == null) return null; - options.add( - HostSettingOption( - value: value, - label: _safeSettingText(option['label'], 200) ?? value, - help: _safeSettingText(option['description'], 2000), - ), - ); - } - return List.unmodifiable(options); -} - -({List scopes, String? issue}) _settingScopes(Object? raw) { - if (raw == null) { - return (scopes: const ['global'], issue: null); - } - if (raw is! List || raw.any((scope) => scope is! String)) { - return (scopes: const [], issue: 'writable scopes are malformed'); - } - final scopes = raw - .cast() - .where((scope) => scope == 'global' || scope == 'session') - .toSet() - .toList(growable: false); - return ( - scopes: scopes.isEmpty - ? const ['global'] - : List.unmodifiable(scopes), - issue: null, - ); -} - -String? _safeSettingText(Object? value, int maximumLength) { - if (value is! String || - value.isEmpty || - value.length > maximumLength || - _settingControlCharacters.hasMatch(value)) { - return null; - } - return value; -} - -num? _finiteSettingNumber(Object? value) { - if (value is int) return value; - if (value is double && value.isFinite) return value; - return null; -} - -Object? _copySafeSettingValue(Object? value) { - if (value is bool) return value; - if (value is num) return _finiteSettingNumber(value); - if (value is String) { - if (value.isEmpty) return value; - return _safeSettingText(value, 4096); - } - if (value is List) { - final result = []; - for (final item in value) { - if (item is! String || _settingControlCharacters.hasMatch(item)) { - return null; - } - result.add(item); - } - return List.unmodifiable(result); - } - if (value is Map) { - final result = {}; - for (final entry in value.entries) { - if (entry.value is! String || - _settingControlCharacters.hasMatch(entry.key) || - _settingControlCharacters.hasMatch(entry.value! as String)) { - return null; - } - result[entry.key] = entry.value! as String; - } - return Map.unmodifiable(result); - } - return null; -} - -Object? _copySettingValue(Object? value) => _copySafeSettingValue(value); - -bool _settingValueMatches(HostSettingEntry entry, Object? value) { - final safe = _copySafeSettingValue(value); - if (safe == null) return false; - return switch (entry.control) { - HostSettingControlKind.boolean => safe is bool, - HostSettingControlKind.number => - safe is num && - (entry.min == null || safe >= entry.min!) && - (entry.max == null || safe <= entry.max!), - HostSettingControlKind.text => safe is String, - HostSettingControlKind.enumeration => - safe is String && entry.options.any((option) => option.value == safe), - HostSettingControlKind.list => safe is List, - HostSettingControlKind.map => safe is Map, - HostSettingControlKind.secret || - HostSettingControlKind.unsupported => false, - }; -} - -String _humanizeSettingPath(String path) => path - .split('.') - .map( - (segment) => segment - .replaceAllMapped( - RegExp(r'([a-z0-9])([A-Z])'), - (match) => '${match[1]} ${match[2]}', - ) - .split(RegExp(r'[-_\s]+')) - .where((word) => word.isNotEmpty) - .map( - (word) => - '${word.substring(0, 1).toUpperCase()}${word.substring(1)}', - ) - .join(' '), - ) - .join(' · '); - -String _safeProjectSearchPath(String value) { - final hasControl = value.runes.any((rune) => rune <= 0x1f || rune == 0x7f); - if (value.isEmpty || - utf8.encode(value).length > 4096 || - hasControl || - value.contains(r'\') || - value.startsWith('/') || - RegExp(r'^[A-Za-z]:').hasMatch(value) || - value.startsWith('~')) { - throw const FormatException( - 'files.search path must be a safe relative POSIX path', - ); - } - final parts = value.split('/'); - if (parts.any((part) => part.isEmpty || part == '.' || part == '..')) { - throw const FormatException('files.search path contains an unsafe segment'); - } - return value; -} - -final class _PendingCommand { - _PendingCommand({ - required this.commandId, - required this.command, - this.sessionId, - this.completer, - this.expectedRevision, - this.confirmationExpected = false, - }); - - final String commandId; - final String command; - final String? sessionId; - final Completer? completer; - final String? expectedRevision; - final bool confirmationExpected; - bool confirmationSent = false; -} - -final class _PendingPair { - const _PendingPair({ - required this.requestId, - required this.endpointKey, - required this.deviceId, - required this.deviceName, - required this.platform, - required this.requestedCapabilities, - }); - - final String requestId; - final String endpointKey; - final String deviceId; - final String deviceName; - final String platform; - final List requestedCapabilities; -} - -final class _SessionAttention { - const _SessionAttention({ - this.items = const [], - this.omittedCount = 0, - this.truncated = false, - this.malformed = false, - }); - - final List items; - final int omittedCount; - final bool truncated; - final bool malformed; -} diff --git a/apps/flutter/lib/src/client/transcript_tail_store.dart b/apps/flutter/lib/src/client/transcript_tail_store.dart deleted file mode 100644 index f6f42b5b..00000000 --- a/apps/flutter/lib/src/client/transcript_tail_store.dart +++ /dev/null @@ -1,239 +0,0 @@ -import 'dart:convert'; - -import 'package:crypto/crypto.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -import '../protocol/protocol.dart'; - -const String transcriptTailStorageKey = - 't4-code:secure-transcript-tail:index:v1'; -const String _recordKeyPrefix = 't4-code:secure-transcript-tail:record:v1:'; -const int _maxCachedSessions = 8; -const int _maxCachedEntries = 64; -const int _maxCachedTailBytes = 128 * 1024; - -final class CachedTranscriptTail { - const CachedTranscriptTail({required this.entries, required this.generation}); - - final List entries; - final String generation; -} - -abstract interface class TranscriptTailStore { - Future load({ - required String hostId, - required String sessionId, - }); - - Future save({ - required String hostId, - required String sessionId, - required String generation, - required List entries, - }); -} - -abstract interface class TranscriptTailStorage { - Future getString(String key); - - Future setString(String key, String value); - - Future delete(String key); -} - -final class InMemoryTranscriptTailStore implements TranscriptTailStore { - final Map _tails = - {}; - - @override - Future load({ - required String hostId, - required String sessionId, - }) async => _tails[_cacheId(hostId, sessionId)]; - - @override - Future save({ - required String hostId, - required String sessionId, - required String generation, - required List entries, - }) async { - _tails[_cacheId(hostId, sessionId)] = CachedTranscriptTail( - entries: List.unmodifiable(entries), - generation: generation, - ); - } -} - -/// A bounded, encrypted display cache for recently opened transcripts. -/// -/// Each session is stored separately so opening one conversation does not read -/// every cached transcript. Live and backward-page cursors are never stored; -/// the host remains authoritative after every reconnect. -final class PersistentTranscriptTailStore implements TranscriptTailStore { - PersistentTranscriptTailStore({TranscriptTailStorage? storage}) - : _storage = storage ?? _SecureTranscriptTailStorage(); - - final TranscriptTailStorage _storage; - Future _writes = Future.value(); - - @override - Future load({ - required String hostId, - required String sessionId, - }) async { - await _writes; - final encoded = await _storage.getString(_recordKey(hostId, sessionId)); - if (encoded == null) return null; - try { - final decoded = jsonDecode(encoded); - if (decoded is! Map || - decoded['version'] != 1 || - decoded['hostId'] != hostId || - decoded['sessionId'] != sessionId) { - return null; - } - final result = WireDecoder.decodeTranscriptPageResult({ - 'entries': decoded['entries'], - 'hasMore': false, - 'generation': decoded['generation'], - }); - if (result.entries.any( - (entry) => entry.hostId != hostId || entry.sessionId != sessionId, - )) { - return null; - } - return CachedTranscriptTail( - entries: result.entries, - generation: result.generation, - ); - } on Object { - return null; - } - } - - @override - Future save({ - required String hostId, - required String sessionId, - required String generation, - required List entries, - }) { - final operation = _writes.then( - (_) => _saveNow( - hostId: hostId, - sessionId: sessionId, - generation: generation, - entries: entries, - ), - ); - _writes = operation.then((_) {}, onError: (_, _) {}); - return operation; - } - - Future _saveNow({ - required String hostId, - required String sessionId, - required String generation, - required List entries, - }) async { - final bounded = entries.length <= _maxCachedEntries - ? entries.toList(growable: true) - : entries.sublist(entries.length - _maxCachedEntries); - while (bounded.isNotEmpty && - utf8 - .encode(jsonEncode(bounded.map((entry) => entry.raw).toList())) - .length > - _maxCachedTailBytes) { - bounded.removeAt(0); - } - - final key = _recordKey(hostId, sessionId); - final index = await _readIndex(); - index.removeWhere((record) => record.key == key); - if (bounded.isEmpty) { - await _storage.delete(key); - } else { - final updatedAt = DateTime.now().toUtc().toIso8601String(); - await _storage.setString( - key, - jsonEncode({ - 'version': 1, - 'hostId': hostId, - 'sessionId': sessionId, - 'generation': generation, - 'entries': bounded.map((entry) => entry.raw).toList(growable: false), - }), - ); - index.add((key: key, updatedAt: updatedAt)); - } - - index.sort((left, right) => right.updatedAt.compareTo(left.updatedAt)); - if (index.length > _maxCachedSessions) { - final removed = index.sublist(_maxCachedSessions); - index.removeRange(_maxCachedSessions, index.length); - for (final record in removed) { - await _storage.delete(record.key); - } - } - await _storage.setString( - transcriptTailStorageKey, - jsonEncode({ - 'version': 1, - 'records': [ - for (final record in index) - {'key': record.key, 'updatedAt': record.updatedAt}, - ], - }), - ); - } - - Future> _readIndex() async { - final encoded = await _storage.getString(transcriptTailStorageKey); - if (encoded == null) return <({String key, String updatedAt})>[]; - try { - final decoded = jsonDecode(encoded); - if (decoded is! Map || decoded['version'] != 1) { - return <({String key, String updatedAt})>[]; - } - final values = decoded['records']; - if (values is! List) { - return <({String key, String updatedAt})>[]; - } - return <({String key, String updatedAt})>[ - for (final value in values.take(_maxCachedSessions)) - if (value is Map && - value['key'] is String && - (value['key']! as String).startsWith(_recordKeyPrefix) && - value['updatedAt'] is String) - ( - key: value['key']! as String, - updatedAt: value['updatedAt']! as String, - ), - ]; - } on Object { - return <({String key, String updatedAt})>[]; - } - } -} - -String _cacheId(String hostId, String sessionId) => '$hostId\u0000$sessionId'; - -String _recordKey(String hostId, String sessionId) => - '$_recordKeyPrefix${sha256.convert(utf8.encode(_cacheId(hostId, sessionId)))}'; - -final class _SecureTranscriptTailStorage implements TranscriptTailStorage { - _SecureTranscriptTailStorage() : _storage = const FlutterSecureStorage(); - - final FlutterSecureStorage _storage; - - @override - Future getString(String key) => _storage.read(key: key); - - @override - Future setString(String key, String value) => - _storage.write(key: key, value: value); - - @override - Future delete(String key) => _storage.delete(key: key); -} diff --git a/apps/flutter/lib/src/client/web_socket_connector.dart b/apps/flutter/lib/src/client/web_socket_connector.dart deleted file mode 100644 index 9424879c..00000000 --- a/apps/flutter/lib/src/client/web_socket_connector.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:web_socket_channel/web_socket_channel.dart'; - -import 'web_socket_connector_stub.dart' - if (dart.library.io) 'web_socket_connector_io.dart' - if (dart.library.html) 'web_socket_connector_web.dart' - as platform; - -typedef WebSocketConnector = Future Function(Uri endpoint); - -Future connectPlatformWebSocket(Uri endpoint) => - platform.connectPlatformWebSocket(endpoint); diff --git a/apps/flutter/lib/src/client/web_socket_connector_io.dart b/apps/flutter/lib/src/client/web_socket_connector_io.dart deleted file mode 100644 index 95e071f0..00000000 --- a/apps/flutter/lib/src/client/web_socket_connector_io.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:web_socket_channel/io.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -Future connectPlatformWebSocket(Uri endpoint) async => - IOWebSocketChannel.connect( - endpoint, - headers: const {'Origin': 'https://localhost'}, - ); diff --git a/apps/flutter/lib/src/client/web_socket_connector_stub.dart b/apps/flutter/lib/src/client/web_socket_connector_stub.dart deleted file mode 100644 index 91c5a922..00000000 --- a/apps/flutter/lib/src/client/web_socket_connector_stub.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:web_socket_channel/web_socket_channel.dart'; - -Future connectPlatformWebSocket(Uri endpoint) => - Future.error( - UnsupportedError('WebSocket transport is unavailable on this platform.'), - ); diff --git a/apps/flutter/lib/src/client/web_socket_connector_web.dart b/apps/flutter/lib/src/client/web_socket_connector_web.dart deleted file mode 100644 index d938204b..00000000 --- a/apps/flutter/lib/src/client/web_socket_connector_web.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:web_socket_channel/html.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -Future connectPlatformWebSocket(Uri endpoint) async => - HtmlWebSocketChannel.connect(endpoint); diff --git a/apps/flutter/lib/src/demo/demo_app.dart b/apps/flutter/lib/src/demo/demo_app.dart deleted file mode 100644 index 20055f70..00000000 --- a/apps/flutter/lib/src/demo/demo_app.dart +++ /dev/null @@ -1,561 +0,0 @@ -import 'dart:typed_data'; - -import 'package:flutter/widgets.dart'; - -import '../client/app_state.dart'; -import '../host/host_profile.dart'; -import '../protocol/models.dart'; -import '../ui/t4_app.dart'; - -/// Read-only public preview of the canonical Flutter client. -/// -/// The demo deliberately uses local display data and never opens a network -/// connection or stores credentials. -final class T4DemoApp extends StatelessWidget { - const T4DemoApp({super.key}); - - static const T4Actions _actions = _DemoActions(); - - @override - Widget build(BuildContext context) => T4App( - state: demoViewState, - actions: _actions, - credentialsAreVolatile: false, - demoMode: true, - ); -} - -final HostProfile _demoProfile = HostProfile.parseTailnetAddress( - 'https://demo.t4code.ts.net', -); - -/// Fixed timestamp for all demo usage data: 2026-07-21T08:00:00Z. -const int _demoGeneratedAtMs = 1784620800000; - -final T4ViewState demoViewState = T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(_demoProfile), - authenticationPhase: AuthenticationPhase.paired, - targetConfigured: true, - grantedCapabilities: t4RequestedCapabilities.toSet(), - grantedFeatures: t4RequestedFeatures.toSet(), - selectedSessionId: 'sess-settings', - sessions: const [ - SessionSummary( - hostId: 'demo-host', - sessionId: 'sess-settings', - projectId: 'project-t4', - projectName: 'T4 Code', - title: 'Fix quick-open stale results', - revision: 'demo-revision-3', - status: 'idle', - updatedAt: '2026-07-21T08:00:00Z', - modelSelector: 'openai-codex/gpt-5.6-sol', - modelDisplayName: 'GPT-5.6 Sol', - thinking: 'high', - thinkingSupported: true, - thinkingLevels: ['off', 'medium', 'high'], - fastAvailable: true, - ), - SessionSummary( - hostId: 'demo-host', - sessionId: 'sess-runtime', - projectId: 'project-t4', - projectName: 'T4 Code', - title: 'Flutter runtime integration', - revision: 'demo-revision-2', - status: 'idle', - updatedAt: '2026-07-21T07:40:00Z', - ), - SessionSummary( - hostId: 'demo-host', - sessionId: 'sess-release', - projectId: 'project-t4', - projectName: 'T4 Code', - title: 'Release readiness', - revision: 'demo-revision-1', - status: 'idle', - updatedAt: '2026-07-21T07:10:00Z', - ), - SessionSummary( - hostId: 'demo-host', - sessionId: 'sess-omp-advisor', - projectId: 'project-omp', - projectName: 'omp', - title: 'Advisor routing selectors', - revision: 'demo-revision-8', - status: 'running widget tests', - updatedAt: '2026-07-21T07:58:00Z', - working: true, - modelSelector: 'openai-codex/gpt-5.6-sol', - modelDisplayName: 'GPT-5.6 Sol', - ), - SessionSummary( - hostId: 'demo-host', - sessionId: 'sess-omp-usage', - projectId: 'project-omp', - projectName: 'omp', - title: 'Usage meter polish', - revision: 'demo-revision-5', - status: 'closed', - updatedAt: '2026-07-18T16:45:00Z', - archivedAt: '2026-07-18T16:45:00Z', - ), - ], - messages: const [ - TranscriptMessage( - id: 'demo-message-1', - role: MessageRole.user, - text: - 'Quick open keeps showing results from the previous session after I ' - 'switch projects. Can you fix it?', - ), - TranscriptMessage( - id: 'demo-message-2', - role: MessageRole.assistant, - reasoning: - 'Reproduced the report: open quick open in session A, switch to ' - 'session B, reopen the palette — the first frame still lists paths ' - 'from A. The result cache in quick_open.dart is keyed by query text ' - 'only, so it survives session switches. Either the cache needs the ' - 'session id in its key, or it should be dropped when the selected ' - 'session changes. Scoping the key is smaller and keeps warm results ' - 'per session.', - text: - 'Reproduced it. The palette caches results in `_resultCache`, keyed ' - 'by the query string alone, so switching sessions replays stale ' - 'paths from the previous workspace. Let me confirm the shape of the ' - 'cache before proposing a fix.', - ), - TranscriptMessage( - id: 'demo-message-3', - role: MessageRole.tool, - kind: TranscriptKind.tool, - text: '', - toolName: 'files.read', - toolTitle: 'Read lib/src/quick_open.dart', - toolArguments: '{"path": "lib/src/quick_open.dart"}', - toolOutput: - 'final Map> _resultCache =\n' - ' >{};\n' - '\n' - 'Future> search(String query) async {\n' - ' final cached = _resultCache[query];\n' - ' if (cached != null) return cached;\n' - ' ...\n' - '}', - toolSucceeded: true, - ), - TranscriptMessage( - id: 'demo-message-4', - role: MessageRole.assistant, - text: - 'Confirmed — the cache never sees the session change. Two viable ' - 'fixes:\n' - '\n' - '| Approach | Blast radius | Warm-cache behavior |\n' - '| --- | --- | --- |\n' - '| Scope keys by `(sessionId, query)` | One file | Keeps per-session warm results |\n' - '| Clear cache on `selectSession` | Two files | Cold palette after every switch |\n' - '\n' - '> Scoping the key is the smaller change and preserves warm results ' - 'when you switch back to a session.\n' - '\n' - 'Checking for other readers of `_resultCache` before touching it.', - ), - TranscriptMessage( - id: 'demo-message-5', - role: MessageRole.tool, - kind: TranscriptKind.tool, - text: '', - toolName: 'terminal.run', - toolTitle: 'grep -rn _resultCache lib/', - toolArguments: '{"command": "grep -rn _resultCache lib/"}', - toolOutput: - 'lib/src/quick_open.dart:18:final Map> _resultCache =\n' - 'lib/src/quick_open.dart:22: final cached = _resultCache[query];\n' - 'lib/src/quick_open.dart:31: _resultCache[query] = paths;', - toolSucceeded: true, - ), - TranscriptMessage( - id: 'demo-message-6', - role: MessageRole.assistant, - text: - 'Only quick_open.dart touches the cache, so the scoped key is safe. ' - 'Proposed change:\n' - '\n' - '```dart\n' - 'final Map<(String, String), List> _resultCache =\n' - ' <(String, String), List>{};\n' - '\n' - 'Future> search(String sessionId, String query) async {\n' - ' final key = (sessionId, query);\n' - ' final cached = _resultCache[key];\n' - ' if (cached != null) return cached;\n' - ' final paths = await _actions.searchProjectFiles(query);\n' - ' _resultCache[key] = paths.paths;\n' - ' return paths.paths;\n' - '}\n' - '```\n' - '\n' - 'The palette already knows the selected session, so the callsite ' - 'change is one line.', - ), - TranscriptMessage( - id: 'demo-message-7', - role: MessageRole.user, - text: - 'Go with the session-scoped cache. Run the tests before you wrap up.', - ), - TranscriptMessage( - id: 'demo-message-8', - role: MessageRole.tool, - kind: TranscriptKind.tool, - text: '', - toolName: 'terminal.run', - toolTitle: 'flutter test test/quick_open_test.dart', - toolArguments: '{"command": "flutter test test/quick_open_test.dart"}', - toolOutput: - '00:03 +11: quick open scopes cached results per session\n' - '00:04 +12: All tests passed!', - toolSucceeded: true, - ), - TranscriptMessage( - id: 'demo-message-9', - role: MessageRole.assistant, - text: - 'Applied and verified. To reproduce the check locally:\n' - '\n' - '```bash\n' - 'cd apps/flutter\n' - 'flutter analyze\n' - 'flutter test test/quick_open_test.dart\n' - '```\n' - '\n' - 'Quick open now keys its cache by `(sessionId, query)`, so switching ' - 'projects can no longer surface another workspace\u2019s paths.', - ), - TranscriptMessage( - id: 'demo-message-10', - role: MessageRole.system, - kind: TranscriptKind.notice, - text: - 'Turn complete \u00b7 2 files changed \u00b7 12 tests passed \u00b7 ' - 'quick-open results are now scoped per session.', - ), - ], - attentionItems: [ - AttentionItem( - key: 'demo-attention-approval', - kind: AttentionKind.approval, - sessionId: 'sess-omp-advisor', - sessionTitle: 'Advisor routing selectors', - revision: 'demo-revision-8', - title: 'Run pnpm test?', - summary: - 'The agent wants to run `pnpm test` in packages/advisor before ' - 'committing the selector change.', - at: DateTime.utc(2026, 7, 21, 7, 58), - choices: const [ - AttentionChoice(id: 'allow', label: 'Allow'), - AttentionChoice(id: 'deny', label: 'Deny'), - ], - actionable: true, - ), - AttentionItem( - key: 'demo-attention-completed', - kind: AttentionKind.completed, - sessionId: 'sess-settings', - sessionTitle: 'Fix quick-open stale results', - revision: 'demo-revision-3', - title: 'Quick-open fix landed', - summary: - 'Session-scoped result cache applied; analyzer clean and 12 tests ' - 'passing.', - at: DateTime.utc(2026, 7, 21, 8), - ), - ], - agentActivities: [ - AgentActivity( - agentId: 'demo-agent-tests', - sessionId: 'sess-omp-advisor', - label: 'Widget test sweep', - status: 'running', - updatedAt: DateTime.utc(2026, 7, 21, 7, 59), - progress: 0.62, - description: 'Running the advisor selector widget tests before commit.', - model: 'GPT-5.6 Sol', - currentTool: 'terminal.run', - ), - ], - fileWorkspace: FileWorkspaceState( - path: 'lib/src/quick_open.dart', - entries: const [ - DeveloperFileEntry(path: 'lib', kind: 'dir'), - DeveloperFileEntry(path: 'test', kind: 'dir'), - DeveloperFileEntry(path: 'README.md', kind: 'file', size: 412), - DeveloperFileEntry(path: 'CHANGELOG.md', kind: 'file', size: 268), - DeveloperFileEntry(path: 'pubspec.yaml', kind: 'file', size: 301), - DeveloperFileEntry(path: 'analysis_options.yaml', kind: 'file', size: 88), - ], - content: _demoWorkspaceFiles['lib/src/quick_open.dart'], - diff: _demoQuickOpenDiff, - revision: 'demo-revision-3', - ), - reviews: const [ - ReviewWorkspaceItem( - reviewId: 'demo-review-1', - sessionId: 'sess-settings', - status: 'completed', - path: 'lib/src/quick_open.dart', - findings: >[ - { - 'path': 'lib/src/quick_open.dart', - 'severity': 'info', - 'message': - 'Result cache is now keyed by (sessionId, query); no cross-' - 'session reuse remains.', - }, - ], - ), - ], - composer: const SessionComposerState( - modelLabel: 'GPT-5.6 Sol', - modelSelector: 'openai-codex/gpt-5.6-sol', - modelChoices: [ - ComposerModelChoice( - label: 'GPT-5.6 Sol', - selector: 'openai-codex/gpt-5.6-sol', - provider: 'openai-codex', - providerLabel: 'OpenAI Codex', - ), - ], - thinking: 'high', - thinkingLevels: ['off', 'medium', 'high'], - fastAvailable: true, - ), - themePreference: T4ThemePreference.system, -); - -/// Small static workspace used by quick open, file search, and file reads. -const Map _demoWorkspaceFiles = { - 'README.md': - '# demo workspace\n' - '\n' - 'Sample project served by the T4 Code public preview. Everything here ' - 'is static display data; no command leaves the browser.\n' - '\n' - '- `lib/main.dart` — app entrypoint\n' - '- `lib/src/quick_open.dart` — palette + session-scoped result cache\n' - '- `test/quick_open_test.dart` — regression coverage for the cache\n', - 'CHANGELOG.md': - '## Unreleased\n' - '\n' - '- Quick open: scope cached results per session.\n' - '- Usage pane: show provider limit windows with reset times.\n', - 'pubspec.yaml': - 'name: demo_workspace\n' - 'description: Sample workspace for the T4 Code public preview.\n' - 'environment:\n' - " sdk: '>=3.8.0 <4.0.0'\n" - 'dependencies:\n' - ' flutter:\n' - ' sdk: flutter\n', - 'analysis_options.yaml': - 'include: package:flutter_lints/flutter.yaml\n' - 'linter:\n' - ' rules:\n' - ' - prefer_const_constructors\n', - 'lib/main.dart': - "import 'package:flutter/widgets.dart';\n" - "import 'src/quick_open.dart';\n" - '\n' - 'void main() => runApp(const DemoWorkspaceApp());\n', - 'lib/src/quick_open.dart': - 'final Map<(String, String), List> _resultCache =\n' - ' <(String, String), List>{};\n' - '\n' - 'Future> search(String sessionId, String query) async {\n' - ' final key = (sessionId, query);\n' - ' return _resultCache[key] ??= await _lookup(query);\n' - '}\n', - 'lib/src/session_scope.dart': - '/// Identifies the session a palette query belongs to.\n' - 'final class SessionScope {\n' - ' const SessionScope(this.sessionId);\n' - ' final String sessionId;\n' - '}\n', - 'test/quick_open_test.dart': - "import 'package:flutter_test/flutter_test.dart';\n" - '\n' - 'void main() {\n' - " test('quick open scopes cached results per session', () {\n" - ' // Regression: switching sessions must not replay stale paths.\n' - ' });\n' - '}\n', -}; - -const String _demoQuickOpenDiff = - '--- a/lib/src/quick_open.dart\n' - '+++ b/lib/src/quick_open.dart\n' - '@@ -15,10 +15,11 @@\n' - '-final Map> _resultCache =\n' - '- >{};\n' - '+final Map<(String, String), List> _resultCache =\n' - '+ <(String, String), List>{};\n' - ' \n' - '-Future> search(String query) async {\n' - '- final cached = _resultCache[query];\n' - '+Future> search(String sessionId, String query) async {\n' - '+ final key = (sessionId, query);\n' - '+ final cached = _resultCache[key];\n' - ' if (cached != null) return cached;\n'; - -/// Safe action sink for the public preview. Interactive controls render as the -/// real client does, but no command leaves the browser. -final class _DemoActions implements T4Actions { - const _DemoActions(); - - @override - Future refreshSettings() async {} - - @override - Future setThemePreference(T4ThemePreference preference) async {} - - @override - Future selectSession(String sessionId) async {} - - @override - Future submitPrompt( - String message, { - List images = const [], - }) async => false; - - @override - Future queuePrompt(String message) async => false; - - @override - Future respondToAttention( - AttentionItem item, - AttentionResponse response, - ) async => false; - - @override - Future readTranscriptImage( - String entryId, - TranscriptImageMetadata image, - ) async => Uint8List(0); - - @override - Future readUsage() async => const UsageReadResult( - generatedAt: _demoGeneratedAtMs, - reports: [ - UsageReport( - provider: 'openai-codex', - fetchedAt: _demoGeneratedAtMs, - limits: [ - UsageLimit( - id: 'session-5h', - label: '5-hour window', - scope: UsageScope(provider: 'openai-codex'), - window: UsageWindow( - id: 'session-5h', - label: '5h', - durationMs: 5 * 60 * 60 * 1000, - resetsAt: _demoGeneratedAtMs + 2 * 60 * 60 * 1000, - ), - amount: UsageAmount( - unit: UsageUnit.percent, - used: 34, - limit: 100, - remaining: 66, - usedFraction: 0.34, - remainingFraction: 0.66, - ), - status: UsageStatus.ok, - notes: [], - ), - UsageLimit( - id: 'weekly', - label: 'Weekly window', - scope: UsageScope(provider: 'openai-codex'), - window: UsageWindow( - id: 'weekly', - label: '7d', - durationMs: 7 * 24 * 60 * 60 * 1000, - resetsAt: _demoGeneratedAtMs + 3 * 24 * 60 * 60 * 1000, - ), - amount: UsageAmount( - unit: UsageUnit.percent, - used: 61, - limit: 100, - remaining: 39, - usedFraction: 0.61, - remainingFraction: 0.39, - ), - status: UsageStatus.warning, - notes: [], - ), - ], - notes: [], - metadata: {}, - ), - ], - accountsWithoutUsage: [], - capacity: >{}, - ); - - @override - Future readBrokerStatus() async => - const BrokerStatusResult( - state: BrokerState.connected, - generation: 7, - endpoint: 'https://demo.t4code.ts.net', - ); - - @override - Future searchProjectFiles( - String query, { - int limit = 12, - }) async { - final needle = query.trim().toLowerCase(); - final matches = _demoWorkspaceFiles.keys - .where((path) => needle.isEmpty || path.toLowerCase().contains(needle)) - .take(limit) - .toList(growable: false); - return ProjectFileSearchResult(paths: matches, truncated: false); - } - - @override - dynamic noSuchMethod(Invocation invocation) { - return switch (invocation.memberName) { - #searchTranscripts => Future.value( - const TranscriptSearchResult( - items: [], - incomplete: false, - index: TranscriptSearchIndexStatus( - state: TranscriptSearchIndexState.ready, - indexedSessions: 5, - knownSessions: 5, - generation: 'demo', - ), - ), - ), - #loadTranscriptContext => Future.value( - const TranscriptContextResult( - anchorId: '', - rows: [], - anchorIndex: 0, - hasBefore: false, - hasAfter: false, - generation: 'demo', - ), - ), - #submitPrompt || - #queuePrompt || - #respondToAttention => Future.value(false), - #openTerminal || #launchPreview => Future.value(''), - _ => Future.value(), - }; - } -} diff --git a/apps/flutter/lib/src/host/app_preferences.dart b/apps/flutter/lib/src/host/app_preferences.dart deleted file mode 100644 index 3014ee60..00000000 --- a/apps/flutter/lib/src/host/app_preferences.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:shared_preferences/shared_preferences.dart'; - -const String appThemePreferenceKey = 't4-code:theme-preference:v1'; - -abstract interface class AppPreferenceStore { - Future loadThemePreference(); - - Future saveThemePreference(String value); -} - -final class InMemoryAppPreferenceStore implements AppPreferenceStore { - InMemoryAppPreferenceStore({this.themePreference}); - - String? themePreference; - - @override - Future loadThemePreference() async => themePreference; - - @override - Future saveThemePreference(String value) async { - themePreference = value; - } -} - -final class PersistentAppPreferenceStore implements AppPreferenceStore { - PersistentAppPreferenceStore({SharedPreferencesAsync? preferences}) - : _preferences = preferences ?? SharedPreferencesAsync(); - - final SharedPreferencesAsync _preferences; - - @override - Future loadThemePreference() => - _preferences.getString(appThemePreferenceKey); - - @override - Future saveThemePreference(String value) => - _preferences.setString(appThemePreferenceKey, value); -} diff --git a/apps/flutter/lib/src/host/host_profile.dart b/apps/flutter/lib/src/host/host_profile.dart deleted file mode 100644 index f2e8059a..00000000 --- a/apps/flutter/lib/src/host/host_profile.dart +++ /dev/null @@ -1,282 +0,0 @@ -const int hostProfileSchemaVersion = 3; -const int maximumSavedHosts = 16; -const int maximumHostUrlLength = 2048; -const int maximumHostLabelLength = 128; -const int maximumProfileIdLength = 64; -const int maximumDeviceIdLength = 256; -const int maximumDeviceTokenLength = 512; -const String defaultHostProfileId = 'default'; - -final RegExp _profileIdPattern = RegExp(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$'); - -final class HostProfile { - const HostProfile._({ - required this.endpointKey, - required this.origin, - required this.profileId, - required this.webSocketUrl, - required this.label, - }); - - factory HostProfile.parseTailnetAddress( - String value, { - String profileId = defaultHostProfileId, - }) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw const FormatException( - 'Enter the HTTPS address shown by T4 on your computer.', - ); - } - if (trimmed.length > maximumHostUrlLength) { - throw const FormatException('That address is too long.'); - } - - final normalizedProfileId = normalizeHostProfileId(profileId); - final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed'; - final parsed = Uri.tryParse(candidate); - if (parsed == null || !parsed.hasAuthority || parsed.scheme != 'https') { - throw const FormatException('Enter a valid HTTPS Tailnet address.'); - } - if (parsed.userInfo.isNotEmpty) { - throw const FormatException('The address cannot contain credentials.'); - } - if ((parsed.path.isNotEmpty && parsed.path != '/') || - parsed.hasQuery || - parsed.hasFragment) { - throw const FormatException( - 'Enter the host address only, without a path, query, or fragment.', - ); - } - - final hostname = parsed.host.toLowerCase(); - if (hostname == 'ts.net' || !hostname.endsWith('.ts.net')) { - throw const FormatException( - 'Use the full Tailscale hostname ending in .ts.net.', - ); - } - - final explicitPort = parsed.hasPort && parsed.port != 443 - ? ':${parsed.port}' - : ''; - final origin = 'https://$hostname$explicitPort'; - final encodedProfileId = Uri.encodeComponent(normalizedProfileId); - final path = normalizedProfileId == defaultHostProfileId - ? '/v1/ws' - : '/v1/profiles/$encodedProfileId/ws'; - final endpointKey = '$origin#profile=$normalizedProfileId'; - final label = 'T4 on ${hostname.substring(0, hostname.indexOf('.'))}'; - - return HostProfile._( - endpointKey: endpointKey, - origin: origin, - profileId: normalizedProfileId, - webSocketUrl: Uri.parse('wss://$hostname$explicitPort$path'), - label: _boundedText(label, 'host label', maximumHostLabelLength), - ); - } - - factory HostProfile.fromJson(Object? value) { - final data = _jsonObject(value, 'saved host'); - if (data['version'] != hostProfileSchemaVersion) { - throw const FormatException( - 'The saved host is from an unsupported app version.', - ); - } - - final origin = _boundedText( - data['origin'], - 'host origin', - maximumHostUrlLength, - ); - final profileId = normalizeHostProfileId( - _boundedText(data['profileId'], 'profile ID', maximumProfileIdLength), - ); - final canonical = HostProfile.parseTailnetAddress( - origin, - profileId: profileId, - ); - if (data['endpointKey'] != canonical.endpointKey || - data['wsUrl'] != canonical.webSocketUrl.toString() || - data['label'] != canonical.label) { - throw const FormatException( - 'The saved host is inconsistent. Add the host again.', - ); - } - return canonical; - } - - final String endpointKey; - final String origin; - final String profileId; - final Uri webSocketUrl; - final String label; - - Map toJson() => { - 'version': hostProfileSchemaVersion, - 'endpointKey': endpointKey, - 'origin': origin, - 'profileId': profileId, - 'wsUrl': webSocketUrl.toString(), - 'label': label, - }; -} - -final class HostDirectory { - HostDirectory._({ - required List profiles, - required this.activeEndpointKey, - }) : profiles = List.unmodifiable(profiles); - - const HostDirectory.empty() - : profiles = const [], - activeEndpointKey = null; - - factory HostDirectory.fromJson(Object? value) { - final data = _jsonObject(value, 'saved host list'); - if (data['version'] != hostProfileSchemaVersion || - data['activeEndpointKey'] is! String || - data['backends'] is! List) { - throw const FormatException( - 'The saved host list is from an unsupported app version.', - ); - } - - final rawProfiles = data['backends']! as List; - if (rawProfiles.isEmpty || rawProfiles.length > maximumSavedHosts) { - throw const FormatException( - 'The saved host list is from an unsupported app version.', - ); - } - final profiles = rawProfiles.map(HostProfile.fromJson).toList(); - final keys = profiles.map((profile) => profile.endpointKey).toSet(); - final activeEndpointKey = data['activeEndpointKey']! as String; - if (keys.length != profiles.length || !keys.contains(activeEndpointKey)) { - throw const FormatException( - 'The saved host list is inconsistent. Add the host again.', - ); - } - return HostDirectory._( - profiles: profiles, - activeEndpointKey: activeEndpointKey, - ); - } - - final List profiles; - final String? activeEndpointKey; - - HostProfile? get activeProfile { - final activeKey = activeEndpointKey; - if (activeKey == null) return null; - for (final profile in profiles) { - if (profile.endpointKey == activeKey) return profile; - } - return null; - } - - HostDirectory upsert(HostProfile profile) { - final existing = profiles - .where((candidate) => candidate.endpointKey != profile.endpointKey) - .toList(); - if (existing.length >= maximumSavedHosts) { - throw StateError('This device can save up to 16 T4 hosts.'); - } - return HostDirectory._( - profiles: [...existing, profile], - activeEndpointKey: profile.endpointKey, - ); - } - - HostDirectory activate(String endpointKey) { - if (!profiles.any((profile) => profile.endpointKey == endpointKey)) { - throw ArgumentError.value(endpointKey, 'endpointKey', 'Unknown host.'); - } - return HostDirectory._(profiles: profiles, activeEndpointKey: endpointKey); - } - - HostDirectory remove(String endpointKey) { - final remaining = profiles - .where((profile) => profile.endpointKey != endpointKey) - .toList(); - if (remaining.length == profiles.length) return this; - final nextActive = activeEndpointKey == endpointKey - ? (remaining.isEmpty ? null : remaining.first.endpointKey) - : activeEndpointKey; - return HostDirectory._(profiles: remaining, activeEndpointKey: nextActive); - } - - Map toJson() { - final activeKey = activeEndpointKey; - if (profiles.isEmpty || activeKey == null) { - throw StateError('An empty host directory is not persisted.'); - } - return { - 'version': hostProfileSchemaVersion, - 'activeEndpointKey': activeKey, - 'backends': profiles.map((profile) => profile.toJson()).toList(), - }; - } -} - -final class DeviceCredentials { - DeviceCredentials({required String deviceId, required String deviceToken}) - : deviceId = _boundedText(deviceId, 'device ID', maximumDeviceIdLength), - deviceToken = _boundedText( - deviceToken, - 'device token', - maximumDeviceTokenLength, - ); - - final String deviceId; - final String deviceToken; -} - -abstract interface class HostDirectoryStore { - Future load(); - - Future save(HostDirectory directory); -} - -abstract interface class HostCredentialStore { - Future read(HostProfile profile); - - Future write(HostProfile profile, DeviceCredentials credentials); - - Future delete(HostProfile profile); -} - -String normalizeHostProfileId(String value) { - final trimmed = value.trim(); - if (trimmed.isEmpty) return defaultHostProfileId; - if (trimmed.length > maximumProfileIdLength || - !_profileIdPattern.hasMatch(trimmed)) { - throw const FormatException( - 'Use a profile ID made of ASCII letters, numbers, dot, dash, or underscore.', - ); - } - return trimmed; -} - -Map _jsonObject(Object? value, String name) { - if (value is! Map) { - throw FormatException('The $name is damaged.'); - } - final result = {}; - for (final entry in value.entries) { - if (entry.key is! String) { - throw FormatException('The $name is damaged.'); - } - result[entry.key! as String] = entry.value; - } - return result; -} - -String _boundedText(Object? value, String name, int maximumLength) { - if (value is! String || - value.isEmpty || - value.length > maximumLength || - value.codeUnits.any((code) => code <= 0x1f || code == 0x7f)) { - throw FormatException('Invalid $name.'); - } - return value; -} diff --git a/apps/flutter/lib/src/host/persistent_host_stores.dart b/apps/flutter/lib/src/host/persistent_host_stores.dart deleted file mode 100644 index 90ef89b3..00000000 --- a/apps/flutter/lib/src/host/persistent_host_stores.dart +++ /dev/null @@ -1,310 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter/services.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import 'host_profile.dart'; - -const String hostDirectoryStorageKey = 't4-code:mobile-backends:v3'; -const String _credentialStoragePrefix = 't4-code:device-credentials:v1:'; - -abstract interface class HostDirectoryPreferences { - Future getString(String key); - - Future setString(String key, String value); - - Future remove(String key); -} - -abstract interface class HostCredentialStorage { - Future read(String key); - - Future write(String key, String value); - - Future delete(String key); -} - -abstract interface class LegacyHostCredentialSource { - Future discover({ - required List hostKeys, - required bool includeUnkeyed, - }); - - Future clear(String source); -} - -final class LegacyHostCredentials { - const LegacyHostCredentials({ - required this.deviceId, - required this.deviceToken, - required this.source, - }); - - final String deviceId; - final String deviceToken; - final String source; -} - -final class PersistentHostDirectoryStore implements HostDirectoryStore { - PersistentHostDirectoryStore({HostDirectoryPreferences? preferences}) - : _preferences = preferences ?? _SharedPreferencesAdapter(); - - final HostDirectoryPreferences _preferences; - - @override - Future load() async { - final encoded = await _preferences.getString(hostDirectoryStorageKey); - if (encoded == null) return const HostDirectory.empty(); - return HostDirectory.fromJson(jsonDecode(encoded)); - } - - @override - Future save(HostDirectory directory) { - if (directory.profiles.isEmpty) { - return _preferences.remove(hostDirectoryStorageKey); - } - return _preferences.setString( - hostDirectoryStorageKey, - jsonEncode(directory.toJson()), - ); - } -} - -/// Process-local credentials for unsigned macOS development builds. -/// -/// Values are intentionally lost when the app exits and never cross into a -/// platform channel or persistent store. -final class VolatileHostCredentialStore implements HostCredentialStore { - final Map _credentials = - {}; - - @override - Future read(HostProfile profile) async => - _credentials[profile.endpointKey]; - - @override - Future write(HostProfile profile, DeviceCredentials credentials) async { - _credentials[profile.endpointKey] = credentials; - } - - @override - Future delete(HostProfile profile) async { - _credentials.remove(profile.endpointKey); - } -} - -final class SecureHostCredentialStore implements HostCredentialStore { - SecureHostCredentialStore({ - HostCredentialStorage? storage, - LegacyHostCredentialSource? legacySource, - }) : _storage = storage ?? _FlutterSecureStorageAdapter(), - _legacySource = - legacySource ?? - (storage == null - ? const _MethodChannelLegacyCredentialSource() - : const _AbsentLegacyCredentialSource()); - - final HostCredentialStorage _storage; - final LegacyHostCredentialSource _legacySource; - - @override - Future read(HostProfile profile) async { - final currentKey = _credentialKey(profile.endpointKey); - final current = await _storage.read(currentKey); - if (current != null) return _decodeCredentials(current); - - if (profile.profileId == defaultHostProfileId) { - final originKey = _credentialKey(profile.origin); - final origin = await _storage.read(originKey); - if (origin != null) { - final credentials = _decodeCredentials(origin); - await _migrate( - currentKey, - credentials, - () => _storage.delete(originKey), - ); - return credentials; - } - } - - final isDefault = profile.profileId == defaultHostProfileId; - final legacy = await _legacySource.discover( - hostKeys: [profile.endpointKey, if (isDefault) profile.origin], - includeUnkeyed: isDefault, - ); - if (legacy == null) return null; - final credentials = DeviceCredentials( - deviceId: legacy.deviceId, - deviceToken: legacy.deviceToken, - ); - await _migrate( - currentKey, - credentials, - () => _legacySource.clear(legacy.source), - ); - return credentials; - } - - Future _migrate( - String currentKey, - DeviceCredentials credentials, - Future Function() clearLegacy, - ) async { - await _storage.write(currentKey, _encodeCredentials(credentials)); - try { - await clearLegacy(); - } catch (error, stackTrace) { - try { - await _storage.delete(currentKey); - } catch (_) { - // Preserve the clear failure that explains why migration did not commit. - } - Error.throwWithStackTrace(error, stackTrace); - } - } - - @override - Future write(HostProfile profile, DeviceCredentials credentials) async { - await _storage.write( - _credentialKey(profile.endpointKey), - _encodeCredentials(credentials), - ); - if (profile.profileId == defaultHostProfileId) { - await _storage.delete(_credentialKey(profile.origin)); - } - } - - @override - Future delete(HostProfile profile) async { - await _storage.delete(_credentialKey(profile.endpointKey)); - if (profile.profileId == defaultHostProfileId) { - await _storage.delete(_credentialKey(profile.origin)); - } - } -} - -final class _SharedPreferencesAdapter implements HostDirectoryPreferences { - _SharedPreferencesAdapter() : _preferences = SharedPreferencesAsync(); - - final SharedPreferencesAsync _preferences; - - @override - Future getString(String key) => _preferences.getString(key); - - @override - Future setString(String key, String value) => - _preferences.setString(key, value); - - @override - Future remove(String key) => _preferences.remove(key); -} - -final class _FlutterSecureStorageAdapter implements HostCredentialStorage { - _FlutterSecureStorageAdapter() : _storage = const FlutterSecureStorage(); - - final FlutterSecureStorage _storage; - - @override - Future read(String key) => _storage.read(key: key); - - @override - Future write(String key, String value) => - _storage.write(key: key, value: value); - - @override - Future delete(String key) => _storage.delete(key: key); -} - -final class _AbsentLegacyCredentialSource - implements LegacyHostCredentialSource { - const _AbsentLegacyCredentialSource(); - - @override - Future discover({ - required List hostKeys, - required bool includeUnkeyed, - }) async => null; - - @override - Future clear(String source) async {} -} - -final class _MethodChannelLegacyCredentialSource - implements LegacyHostCredentialSource { - const _MethodChannelLegacyCredentialSource(); - - static const MethodChannel _channel = MethodChannel( - 'com.lycaonsolutions.t4code/legacy_credentials', - ); - - @override - Future discover({ - required List hostKeys, - required bool includeUnkeyed, - }) async { - Map? value; - try { - value = await _channel.invokeMapMethod( - 'discoverCredentials', - { - 'hostKeys': hostKeys, - 'includeUnkeyed': includeUnkeyed, - }, - ); - } on MissingPluginException { - return null; - } - if (value == null) return null; - if (value.length != 3 || - value['deviceId'] is! String || - value['deviceToken'] is! String || - value['source'] is! String) { - throw const FormatException('The legacy device credentials are damaged.'); - } - final source = value['source']! as String; - if (source.isEmpty || - source.length > 256 || - source.codeUnits.any((code) => code <= 0x1f || code == 0x7f)) { - throw const FormatException('The legacy device credentials are damaged.'); - } - return LegacyHostCredentials( - deviceId: value['deviceId']! as String, - deviceToken: value['deviceToken']! as String, - source: source, - ); - } - - @override - Future clear(String source) => _channel.invokeMethod( - 'clearCredentials', - {'source': source}, - ); -} - -String _credentialKey(String hostKey) { - final encodedHostKey = base64Url - .encode(utf8.encode(hostKey)) - .replaceAll('=', ''); - return '$_credentialStoragePrefix$encodedHostKey'; -} - -String _encodeCredentials(DeviceCredentials credentials) => - jsonEncode({ - 'deviceId': credentials.deviceId, - 'deviceToken': credentials.deviceToken, - }); - -DeviceCredentials _decodeCredentials(String encoded) { - final value = jsonDecode(encoded); - if (value is! Map || - value.length != 2 || - value['deviceId'] is! String || - value['deviceToken'] is! String) { - throw const FormatException('The saved device credentials are damaged.'); - } - return DeviceCredentials( - deviceId: value['deviceId']! as String, - deviceToken: value['deviceToken']! as String, - ); -} diff --git a/apps/flutter/lib/src/platform/platform_lifecycle.dart b/apps/flutter/lib/src/platform/platform_lifecycle.dart deleted file mode 100644 index 9d4275cf..00000000 --- a/apps/flutter/lib/src/platform/platform_lifecycle.dart +++ /dev/null @@ -1,270 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; - -const String t4PlatformLifecycleChannel = - 'com.lycaonsolutions.t4code/platform_lifecycle'; - -enum RuntimeDefinitionState { missing, current, drifted } - -enum RuntimeServicePhase { stopped, starting, running, failed, unknown } - -enum PlatformUpdatePhase { - unsupported, - idle, - checking, - current, - available, - manual, - downloading, - installer, - ready, - error, -} - -final class RuntimeServiceStatus { - const RuntimeServiceStatus({ - required this.supported, - required this.available, - required this.definition, - required this.service, - required this.diagnostics, - this.executable, - this.issueCode, - this.message, - }); - - const RuntimeServiceStatus.unsupported() - : supported = false, - available = false, - definition = RuntimeDefinitionState.missing, - service = RuntimeServicePhase.unknown, - diagnostics = 'Local OMP service management is available on macOS only.', - executable = null, - issueCode = 'unsupported_platform', - message = null; - - final bool supported; - final bool available; - final RuntimeDefinitionState definition; - final RuntimeServicePhase service; - final String diagnostics; - final String? executable; - final String? issueCode; - final String? message; - - factory RuntimeServiceStatus.fromMap(Map value) { - final definition = _enumByName( - RuntimeDefinitionState.values, - value['definition'], - 'definition', - ); - final service = _enumByName( - RuntimeServicePhase.values, - value['service'], - 'service', - ); - return RuntimeServiceStatus( - supported: true, - available: _requiredBool(value, 'available'), - definition: definition, - service: service, - diagnostics: _boundedText(value['diagnostics'], 'diagnostics', 4096), - executable: _optionalText(value['executable'], 'executable', 1024), - issueCode: _optionalText(value['issueCode'], 'issueCode', 128), - message: _optionalText(value['message'], 'message', 512), - ); - } -} - -final class PlatformUpdateStatus { - const PlatformUpdateStatus({ - required this.supported, - required this.currentVersion, - required this.phase, - this.latestVersion, - this.checkedAt, - this.revision, - this.progressPercent, - this.error, - this.message, - }); - - const PlatformUpdateStatus.unsupported() - : supported = false, - currentVersion = '', - phase = PlatformUpdatePhase.unsupported, - latestVersion = null, - checkedAt = null, - revision = null, - progressPercent = null, - error = null, - message = 'Updates are managed outside this app on this platform.'; - - final bool supported; - final String currentVersion; - final PlatformUpdatePhase phase; - final String? latestVersion; - final int? checkedAt; - final int? revision; - final double? progressPercent; - final String? error; - final String? message; - - factory PlatformUpdateStatus.fromMap(Map value) { - final phaseName = _boundedText(value['phase'], 'phase', 32); - final phase = PlatformUpdatePhase.values - .where((candidate) => candidate.name == phaseName) - .firstOrNull; - if (phase == null || phase == PlatformUpdatePhase.unsupported) { - throw FormatException('unsupported update phase: $phaseName'); - } - final checkedAt = value['checkedAt']; - if (checkedAt != null && (checkedAt is! int || checkedAt < 0)) { - throw const FormatException('checkedAt must be a positive integer'); - } - final revision = value['revision']; - if (revision != null && - (revision is! int || revision < 0 || revision > 9007199254740991)) { - throw const FormatException('revision must be a positive safe integer'); - } - final progress = value['progressPercent']; - if (progress != null && - (progress is! num || - !progress.isFinite || - progress < 0 || - progress > 100)) { - throw const FormatException('progressPercent must be between 0 and 100'); - } - return PlatformUpdateStatus( - supported: true, - currentVersion: _boundedText( - value['currentVersion'], - 'currentVersion', - 64, - ), - phase: phase, - latestVersion: _optionalText(value['latestVersion'], 'latestVersion', 64), - checkedAt: checkedAt as int?, - revision: revision as int?, - progressPercent: (progress as num?)?.toDouble(), - error: _optionalText(value['error'], 'error', 128), - message: _optionalText(value['message'], 'message', 512), - ); - } -} - -abstract interface class PlatformLifecycleBridge { - bool get supportsRuntimeService; - bool get supportsUpdates; - - Future inspectRuntime(); - Future installRuntime(); - Future startRuntime(); - Future stopRuntime(); - Future restartRuntime(); - Future uninstallRuntime(); - - Future getUpdateState(); - Future checkForUpdates(); - Future downloadUpdate(); - Future installUpdate(); -} - -final class MethodChannelPlatformLifecycleBridge - implements PlatformLifecycleBridge { - const MethodChannelPlatformLifecycleBridge({ - this._channel = const MethodChannel(t4PlatformLifecycleChannel), - }); - - final MethodChannel _channel; - - @override - bool get supportsRuntimeService => - !kIsWeb && defaultTargetPlatform == TargetPlatform.macOS; - - @override - bool get supportsUpdates => - !kIsWeb && - (defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.macOS); - - @override - Future inspectRuntime() => _runtime('runtime.inspect'); - - @override - Future installRuntime() => _runtime('runtime.install'); - - @override - Future startRuntime() => _runtime('runtime.start'); - - @override - Future stopRuntime() => _runtime('runtime.stop'); - - @override - Future restartRuntime() => _runtime('runtime.restart'); - - @override - Future uninstallRuntime() => - _runtime('runtime.uninstall'); - - @override - Future getUpdateState() => _update('update.getState'); - - @override - Future checkForUpdates() => _update('update.check'); - - @override - Future downloadUpdate() => _update('update.download'); - - @override - Future installUpdate() => _update('update.install'); - - Future _runtime(String method) async { - if (!supportsRuntimeService) { - return const RuntimeServiceStatus.unsupported(); - } - final value = await _channel.invokeMethod(method); - return RuntimeServiceStatus.fromMap(_map(value, method)); - } - - Future _update(String method) async { - if (!supportsUpdates) return const PlatformUpdateStatus.unsupported(); - final value = await _channel.invokeMethod(method); - return PlatformUpdateStatus.fromMap(_map(value, method)); - } -} - -Map _map(Object? value, String field) { - if (value is! Map) { - throw FormatException('$field result must be an object'); - } - return value; -} - -T _enumByName(List values, Object? value, String field) { - final name = _boundedText(value, field, 32); - final match = values.where((candidate) => candidate.name == name).firstOrNull; - if (match == null) throw FormatException('unsupported $field: $name'); - return match; -} - -bool _requiredBool(Map value, String field) { - final result = value[field]; - if (result is! bool) throw FormatException('$field must be a boolean'); - return result; -} - -String _boundedText(Object? value, String field, int maxLength) { - if (value is! String || value.isEmpty || value.length > maxLength) { - throw FormatException('$field must be non-empty bounded text'); - } - if (value.runes.any((rune) => rune < 0x20 && rune != 0x09 && rune != 0x0a)) { - throw FormatException('$field contains control characters'); - } - return value; -} - -String? _optionalText(Object? value, String field, int maxLength) { - if (value == null) return null; - return _boundedText(value, field, maxLength); -} diff --git a/apps/flutter/lib/src/platform/platform_lifecycle_controller.dart b/apps/flutter/lib/src/platform/platform_lifecycle_controller.dart deleted file mode 100644 index 5ce79f4a..00000000 --- a/apps/flutter/lib/src/platform/platform_lifecycle_controller.dart +++ /dev/null @@ -1,280 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; - -import 'platform_lifecycle.dart'; - -final class PlatformLifecycleViewState { - const PlatformLifecycleViewState({ - required this.runtime, - required this.update, - this.initializing = false, - this.runtimeOperationPending = false, - this.updateOperationPending = false, - this.errorMessage, - }); - - const PlatformLifecycleViewState.initial() - : runtime = const RuntimeServiceStatus.unsupported(), - update = const PlatformUpdateStatus.unsupported(), - initializing = true, - runtimeOperationPending = false, - updateOperationPending = false, - errorMessage = null; - - final RuntimeServiceStatus runtime; - final PlatformUpdateStatus update; - final bool initializing; - final bool runtimeOperationPending; - final bool updateOperationPending; - final String? errorMessage; - - PlatformLifecycleViewState copyWith({ - RuntimeServiceStatus? runtime, - PlatformUpdateStatus? update, - bool? initializing, - bool? runtimeOperationPending, - bool? updateOperationPending, - String? errorMessage, - bool clearError = false, - }) => PlatformLifecycleViewState( - runtime: runtime ?? this.runtime, - update: update ?? this.update, - initializing: initializing ?? this.initializing, - runtimeOperationPending: - runtimeOperationPending ?? this.runtimeOperationPending, - updateOperationPending: - updateOperationPending ?? this.updateOperationPending, - errorMessage: clearError ? null : errorMessage ?? this.errorMessage, - ); -} - -abstract interface class PlatformLifecycleActions { - Future refreshPlatformState(); - Future installRuntime(); - Future startRuntime(); - Future stopRuntime(); - Future restartRuntime(); - Future uninstallRuntime(); - Future checkForUpdates(); - Future downloadUpdate(); - Future installUpdate(); -} - -final class PlatformLifecycleController extends ChangeNotifier - implements PlatformLifecycleActions { - PlatformLifecycleController() - : _bridge = const MethodChannelPlatformLifecycleBridge(); - - @visibleForTesting - PlatformLifecycleController.withBridge(this._bridge); - - final PlatformLifecycleBridge _bridge; - PlatformLifecycleViewState _state = - const PlatformLifecycleViewState.initial(); - bool _disposed = false; - - PlatformLifecycleViewState get state => _state; - - Future initialize() async { - final results = await Future.wait( - [ - _bridge.supportsRuntimeService - ? _bridge.inspectRuntime() - : Future.value( - const RuntimeServiceStatus.unsupported(), - ), - _bridge.supportsUpdates - ? _bridge.getUpdateState() - : Future.value( - const PlatformUpdateStatus.unsupported(), - ), - ].map((future) async { - try { - return await future; - } on Object catch (error) { - return error; - } - }), - ); - if (_disposed) return; - final runtime = results[0]; - final update = results[1]; - final errors = [ - if (runtime is! RuntimeServiceStatus) _safeError(runtime), - if (update is! PlatformUpdateStatus) _safeError(update), - ]; - _state = PlatformLifecycleViewState( - runtime: runtime is RuntimeServiceStatus - ? runtime - : _bridge.supportsRuntimeService - ? const RuntimeServiceStatus( - supported: true, - available: false, - definition: RuntimeDefinitionState.missing, - service: RuntimeServicePhase.unknown, - diagnostics: '', - issueCode: 'runtime_status_unavailable', - message: 'The desktop OMP runtime status could not be loaded.', - ) - : const RuntimeServiceStatus.unsupported(), - update: update is PlatformUpdateStatus - ? update - : _bridge.supportsUpdates - ? const PlatformUpdateStatus( - supported: true, - currentVersion: 'unknown', - phase: PlatformUpdatePhase.error, - error: 'update_status_unavailable', - message: 'The app update status could not be loaded.', - ) - : const PlatformUpdateStatus.unsupported(), - errorMessage: errors.isEmpty ? null : errors.join(' '), - ); - _notify(); - } - - @override - Future refreshPlatformState() => _runBoth( - runtime: _bridge.supportsRuntimeService ? _bridge.inspectRuntime : null, - update: _bridge.supportsUpdates ? _bridge.getUpdateState : null, - ); - - @override - Future installRuntime() => _runRuntime(_bridge.installRuntime); - - @override - Future startRuntime() => _runRuntime(_bridge.startRuntime); - - @override - Future stopRuntime() => _runRuntime(_bridge.stopRuntime); - - @override - Future restartRuntime() => _runRuntime(_bridge.restartRuntime); - - @override - Future uninstallRuntime() => _runRuntime(_bridge.uninstallRuntime); - - @override - Future checkForUpdates() => _runUpdate(_bridge.checkForUpdates); - - @override - Future downloadUpdate() => _runUpdate(_bridge.downloadUpdate); - - @override - Future installUpdate() => _runUpdate(_bridge.installUpdate); - - Future _runBoth({ - Future Function()? runtime, - Future Function()? update, - }) async { - if (_state.runtimeOperationPending || _state.updateOperationPending) return; - _state = _state.copyWith( - runtimeOperationPending: runtime != null, - updateOperationPending: update != null, - clearError: true, - ); - _notify(); - try { - final runtimeFuture = runtime?.call(); - final updateFuture = update?.call(); - final runtimeResult = runtimeFuture == null ? null : await runtimeFuture; - final updateResult = updateFuture == null ? null : await updateFuture; - if (_disposed) return; - _state = _state.copyWith( - runtime: runtimeResult, - update: updateResult, - runtimeOperationPending: false, - updateOperationPending: false, - clearError: true, - ); - } on Object catch (error) { - if (_disposed) return; - _state = _state.copyWith( - runtimeOperationPending: false, - updateOperationPending: false, - errorMessage: _safeError(error), - ); - } - _notify(); - } - - Future _runRuntime( - Future Function() operation, - ) async { - if (!_bridge.supportsRuntimeService || _state.runtimeOperationPending) { - return; - } - _state = _state.copyWith(runtimeOperationPending: true, clearError: true); - _notify(); - try { - final runtime = await operation(); - if (_disposed) return; - _state = _state.copyWith( - runtime: runtime, - runtimeOperationPending: false, - clearError: true, - ); - } on Object catch (error) { - if (_disposed) return; - _state = _state.copyWith( - runtimeOperationPending: false, - errorMessage: _safeError(error), - ); - } - _notify(); - } - - Future _runUpdate( - Future Function() operation, - ) async { - if (!_bridge.supportsUpdates || _state.updateOperationPending) return; - _state = _state.copyWith(updateOperationPending: true, clearError: true); - _notify(); - try { - final update = await operation(); - if (_disposed) return; - _state = _state.copyWith( - update: update, - updateOperationPending: false, - clearError: true, - ); - } on Object catch (error) { - if (_disposed) return; - _state = _state.copyWith( - updateOperationPending: false, - errorMessage: _safeError(error), - ); - } - _notify(); - } - - void _notify() { - if (!_disposed) notifyListeners(); - } - - @override - void dispose() { - _disposed = true; - super.dispose(); - } -} - -String _safeError(Object? error) { - var message = error.toString().replaceAll( - RegExp(r'[\u0000-\u0008\u000b\u000c\u000e-\u001f]'), - '', - ); - message = message.replaceAllMapped( - RegExp( - r'(authorization|cookie|password|passphrase|secret|token|api[_-]?key|credential)(\s*[:=]\s*)(\S+)', - caseSensitive: false, - ), - (match) => '${match.group(1)}${match.group(2)}[REDACTED]', - ); - message = message.replaceAll( - RegExp(r'Bearer\s+\S+', caseSensitive: false), - 'Bearer [REDACTED]', - ); - return message.length <= 512 ? message : message.substring(0, 512); -} diff --git a/apps/flutter/lib/src/protocol/models.dart b/apps/flutter/lib/src/protocol/models.dart deleted file mode 100644 index 50dcc101..00000000 --- a/apps/flutter/lib/src/protocol/models.dart +++ /dev/null @@ -1,1317 +0,0 @@ -/// The only protocol version accepted by the pinned app-wire 0.6.1 client. -const String ompAppProtocolVersion = 'omp-app/1'; - -/// A decoding failure at the application wire boundary. -final class WireFormatException implements FormatException { - const WireFormatException(this.message, [this.path]); - - @override - final String message; - - /// Dot/bracket path of the invalid value, when one is available. - final String? path; - - @override - int? get offset => null; - - @override - Object? get source => null; - - @override - String toString() => path == null - ? 'WireFormatException: $message' - : 'WireFormatException at $path: $message'; -} - -/// A cursor in a session's transcript stream. -/// -/// This is deliberately not assignable to [SessionIndexCursor]. -final class TranscriptCursor { - const TranscriptCursor({required this.epoch, required this.seq}); - - final String epoch; - final int seq; - - @override - bool operator ==(Object other) => - other is TranscriptCursor && other.epoch == epoch && other.seq == seq; - - @override - int get hashCode => Object.hash(epoch, seq); -} - -/// A cursor in the host-wide session-index stream. -/// -/// Session-index sequence numbers must never be compared with transcript -/// sequence numbers. -final class SessionIndexCursor { - const SessionIndexCursor({required this.epoch, required this.seq}); - - final String epoch; - final int seq; - - @override - bool operator ==(Object other) => - other is SessionIndexCursor && other.epoch == epoch && other.seq == seq; - - @override - int get hashCode => Object.hash(epoch, seq); -} - -final class ClientIdentity { - const ClientIdentity({ - required this.name, - required this.version, - required this.build, - required this.platform, - }); - - final String name; - final String version; - final String build; - final String platform; -} - -final class DeviceAuthentication { - const DeviceAuthentication({ - required this.deviceId, - required this.deviceToken, - }); - - final String deviceId; - final String deviceToken; -} - -final class SavedCursor { - const SavedCursor({ - required this.hostId, - required this.sessionId, - required this.cursor, - }); - - final String hostId; - final String sessionId; - final TranscriptCursor cursor; -} - -/// Immutable projection of a session-index item. -final class SessionRef { - const SessionRef({ - required this.hostId, - required this.sessionId, - required this.title, - required this.revision, - required this.status, - required this.updatedAt, - required this.project, - required this.raw, - }); - - final String hostId; - final String sessionId; - final String title; - final String revision; - final String status; - final String updatedAt; - final Map project; - final Map raw; -} - -/// Immutable durable transcript entry. -final class DurableEntry { - const DurableEntry({ - required this.id, - required this.parentId, - required this.hostId, - required this.sessionId, - required this.kind, - required this.timestamp, - required this.data, - required this.raw, - }); - - final String id; - final String? parentId; - final String hostId; - final String sessionId; - final String kind; - final String timestamp; - final Map data; - final Map raw; -} - -/// Base type for the modeled omp-app/1 application-frame union. -sealed class WireFrame { - const WireFrame({required this.raw}); - - /// The complete, recursively immutable decoded frame, including additive - /// fields not understood by this client version. - final Map raw; -} - -final class WelcomeFrame extends WireFrame { - const WelcomeFrame({ - required this.hostId, - required this.resumed, - required this.selectedProtocol, - required this.epoch, - required this.authentication, - required this.grantedCapabilities, - required this.grantedFeatures, - required this.negotiatedLimits, - required super.raw, - }); - - final String hostId; - final bool resumed; - final String selectedProtocol; - final String epoch; - final String authentication; - final List grantedCapabilities; - final List grantedFeatures; - final Map negotiatedLimits; -} - -final class SessionsFrame extends WireFrame { - const SessionsFrame({ - required this.hostId, - required this.cursor, - required this.sessions, - required this.totalCount, - required this.truncated, - required super.raw, - }); - - final String? hostId; - final SessionIndexCursor cursor; - final List sessions; - final int totalCount; - final bool truncated; -} - -/// Typed result payload of host.list and session.list command responses. -final class SessionListResult { - const SessionListResult({ - required this.cursor, - required this.sessions, - required this.totalCount, - required this.truncated, - required this.raw, - }); - - final SessionIndexCursor cursor; - final List sessions; - final int totalCount; - final bool truncated; - final Map raw; -} - -enum TranscriptSearchRole { user, assistant, summary } - -enum TranscriptSearchIndexState { building, ready, stale } - -final class TranscriptSearchHighlight { - const TranscriptSearchHighlight({required this.start, required this.end}); - - final int start; - final int end; -} - -final class TranscriptSearchItem { - const TranscriptSearchItem({ - required this.sessionId, - required this.projectId, - required this.sessionTitle, - required this.anchorId, - required this.role, - required this.timestamp, - required this.snippet, - required this.highlights, - this.archivedAt, - }); - - final String sessionId; - final String projectId; - final String sessionTitle; - final String? archivedAt; - final String anchorId; - final TranscriptSearchRole role; - final String timestamp; - final String snippet; - final List highlights; -} - -final class TranscriptSearchIndexStatus { - const TranscriptSearchIndexStatus({ - required this.state, - required this.indexedSessions, - required this.knownSessions, - required this.generation, - }); - - final TranscriptSearchIndexState state; - final int indexedSessions; - final int knownSessions; - final String generation; -} - -final class TranscriptSearchResult { - const TranscriptSearchResult({ - required this.items, - required this.incomplete, - required this.index, - this.nextCursor, - }); - - final List items; - final String? nextCursor; - final bool incomplete; - final TranscriptSearchIndexStatus index; -} - -final class TranscriptPageResult { - const TranscriptPageResult({ - required this.entries, - required this.hasMore, - required this.generation, - this.nextCursor, - }); - - final List entries; - final String? nextCursor; - final bool hasMore; - final String generation; -} - -final class TranscriptContextRow { - const TranscriptContextRow({ - required this.anchorId, - required this.role, - required this.timestamp, - required this.text, - }); - - final String anchorId; - final TranscriptSearchRole role; - final String timestamp; - final String text; -} - -final class TranscriptContextResult { - const TranscriptContextResult({ - required this.anchorId, - required this.rows, - required this.anchorIndex, - required this.hasBefore, - required this.hasAfter, - required this.generation, - }); - - final String anchorId; - final List rows; - final int anchorIndex; - final bool hasBefore; - final bool hasAfter; - final String generation; -} - -enum UsageUnit { percent, tokens, requests, usd, minutes, bytes, unknown } - -enum UsageStatus { ok, warning, exhausted, unknown } - -enum UsageAccountType { apiKey, oauth } - -final class UsageWindow { - const UsageWindow({ - required this.id, - required this.label, - this.durationMs, - this.resetsAt, - }); - - final String id; - final String label; - final int? durationMs; - final int? resetsAt; -} - -final class UsageAmount { - const UsageAmount({ - required this.unit, - this.used, - this.limit, - this.remaining, - this.usedFraction, - this.remainingFraction, - }); - - final UsageUnit unit; - final double? used; - final double? limit; - final double? remaining; - final double? usedFraction; - final double? remainingFraction; -} - -final class UsageScope { - const UsageScope({ - required this.provider, - this.accountId, - this.projectId, - this.orgId, - this.modelId, - this.tier, - this.windowId, - this.shared, - }); - - final String provider; - final String? accountId; - final String? projectId; - final String? orgId; - final String? modelId; - final String? tier; - final String? windowId; - final bool? shared; -} - -final class UsageLimit { - const UsageLimit({ - required this.id, - required this.label, - required this.scope, - required this.amount, - required this.notes, - this.window, - this.status, - }); - - final String id; - final String label; - final UsageScope scope; - final UsageWindow? window; - final UsageAmount amount; - final UsageStatus? status; - final List notes; -} - -final class UsageReport { - const UsageReport({ - required this.provider, - required this.fetchedAt, - required this.limits, - required this.notes, - required this.metadata, - this.availableResetCredits, - }); - - final String provider; - final int fetchedAt; - final List limits; - final int? availableResetCredits; - final List notes; - final Map metadata; -} - -final class UsageAccountWithoutReport { - const UsageAccountWithoutReport({ - required this.provider, - required this.type, - this.email, - this.accountId, - this.projectId, - this.enterpriseUrl, - this.orgId, - this.orgName, - }); - - final String provider; - final UsageAccountType type; - final String? email; - final String? accountId; - final String? projectId; - final String? enterpriseUrl; - final String? orgId; - final String? orgName; -} - -final class UsageCapacityWindow { - const UsageCapacityWindow({ - required this.window, - required this.accounts, - required this.usedAccounts, - required this.remainingAccounts, - this.durationMs, - }); - - final String window; - final int? durationMs; - final int accounts; - final double usedAccounts; - final double remainingAccounts; -} - -final class UsageReadResult { - const UsageReadResult({ - required this.generatedAt, - required this.reports, - required this.accountsWithoutUsage, - required this.capacity, - }); - - final int generatedAt; - final List reports; - final List accountsWithoutUsage; - final Map> capacity; -} - -enum BrokerState { local, connected, missingToken, unreachable } - -final class BrokerStatusResult { - const BrokerStatusResult({ - required this.state, - required this.generation, - this.endpoint, - }); - - final BrokerState state; - final int generation; - final String? endpoint; -} - -final class SnapshotFrame extends WireFrame { - const SnapshotFrame({ - required this.hostId, - required this.sessionId, - required this.cursor, - required this.revision, - required this.entries, - required super.raw, - }); - - final String hostId; - final String sessionId; - final TranscriptCursor cursor; - final String revision; - final List entries; -} - -final class EntryFrame extends WireFrame { - const EntryFrame({ - required this.hostId, - required this.sessionId, - required this.cursor, - required this.revision, - required this.entry, - required super.raw, - }); - - final String hostId; - final String sessionId; - final TranscriptCursor cursor; - final String revision; - final DurableEntry entry; -} - -final class EventFrame extends WireFrame { - const EventFrame({ - required this.hostId, - required this.sessionId, - required this.cursor, - required this.event, - required super.raw, - }); - - final String hostId; - final String sessionId; - final TranscriptCursor cursor; - - /// Raw immutable event payload. Unknown event subtypes are intentionally - /// accepted as long as their `type` is a valid string. - final Map event; -} - -final class WireResponseError { - const WireResponseError({ - required this.code, - required this.message, - required this.details, - required this.raw, - }); - - final String code; - final String message; - final Map? details; - final Map raw; -} - -enum OperationExecution { typed, headless, terminalOnly, unavailable } - -final class OperationDisabledReason { - const OperationDisabledReason({ - required this.code, - required this.message, - required this.raw, - }); - - final String code; - final String message; - final Map raw; -} - -final class OperationCapability { - const OperationCapability({ - required this.operationId, - required this.label, - required this.description, - required this.execution, - required this.supported, - required this.disabledReason, - required this.capabilities, - required this.raw, - }); - - final String operationId; - final String label; - final String? description; - final OperationExecution execution; - final bool supported; - final OperationDisabledReason? disabledReason; - final List? capabilities; - final Map raw; -} - -final class CatalogResult { - const CatalogResult({ - required this.revision, - required this.items, - this.operations, - }); - - final String revision; - final List items; - final List? operations; -} - -final class SettingsResult { - const SettingsResult({required this.revision, required this.settings}); - - final String revision; - final Map settings; -} - -final class SessionStateModel { - const SessionStateModel({ - required this.id, - required this.provider, - required this.displayName, - required this.selector, - required this.role, - }); - - final String id; - final String provider; - final String? displayName; - final String? selector; - final String? role; -} - -final class SessionStateResult { - const SessionStateResult({ - required this.isStreaming, - required this.isCompacting, - required this.isPaused, - required this.messageCount, - required this.queuedMessageCount, - required this.model, - required this.thinking, - required this.thinkingLevels, - required this.thinkingSupported, - required this.fast, - required this.fastAvailable, - required this.fastActive, - }); - - final bool isStreaming; - final bool isCompacting; - final bool isPaused; - final int messageCount; - final int queuedMessageCount; - final SessionStateModel? model; - final String? thinking; - final List? thinkingLevels; - final bool? thinkingSupported; - final bool? fast; - final bool? fastAvailable; - final bool? fastActive; -} - -final class ResponseFrame extends WireFrame { - const ResponseFrame({ - required this.requestId, - required this.commandId, - required this.hostId, - required this.sessionId, - required this.command, - required this.ok, - required this.result, - required this.error, - required super.raw, - }); - - final String requestId; - final String? commandId; - final String hostId; - final String? sessionId; - final String? command; - final bool ok; - final Object? result; - final WireResponseError? error; - - /// Typed command products decoded at the wire boundary. - SessionListResult? get sessionListResult => - result is SessionListResult ? result as SessionListResult : null; - - CatalogResult? get catalogResult => - result is CatalogResult ? result as CatalogResult : null; - - SettingsResult? get settingsResult => - result is SettingsResult ? result as SettingsResult : null; - - TranscriptSearchResult? get transcriptSearchResult => - result is TranscriptSearchResult - ? result as TranscriptSearchResult - : null; - - TranscriptContextResult? get transcriptContextResult => - result is TranscriptContextResult - ? result as TranscriptContextResult - : null; - - TranscriptPageResult? get transcriptPageResult => - result is TranscriptPageResult ? result as TranscriptPageResult : null; - - SessionStateResult? get sessionStateResult => - result is SessionStateResult ? result as SessionStateResult : null; - - UsageReadResult? get usageReadResult => - result is UsageReadResult ? result as UsageReadResult : null; - - BrokerStatusResult? get brokerStatusResult => - result is BrokerStatusResult ? result as BrokerStatusResult : null; -} - -final class ErrorFrame extends WireFrame { - const ErrorFrame({ - required this.code, - required this.message, - required this.requestId, - required this.details, - required super.raw, - }); - - final String code; - final String message; - final String? requestId; - final Map? details; -} - -final class GapFrame extends WireFrame { - const GapFrame({ - required this.hostId, - required this.sessionId, - required this.from, - required this.to, - required this.reason, - required super.raw, - }); - - final String hostId; - final String sessionId; - final TranscriptCursor from; - final TranscriptCursor to; - final String reason; -} - -final class PingFrame extends WireFrame { - const PingFrame({ - required this.nonce, - required this.timestamp, - required super.raw, - }); - - final String nonce; - final String timestamp; -} - -final class PongFrame extends WireFrame { - const PongFrame({ - required this.nonce, - required this.timestamp, - required super.raw, - }); - - final String nonce; - final String timestamp; -} - -/// A server request for an explicit command authorization decision. -final class ConfirmationFrame extends WireFrame { - const ConfirmationFrame({ - required this.confirmationId, - required this.commandId, - required this.hostId, - required this.sessionId, - required this.commandHash, - required this.revision, - required this.expiresAt, - required this.summary, - required this.preview, - required super.raw, - }); - - final String confirmationId; - final String commandId; - final String hostId; - final String? sessionId; - final String commandHash; - final String revision; - final String expiresAt; - final String summary; - final String? preview; -} - -/// Legacy aggregate agent frame retained by the pinned package union. -final class AgentFrame extends WireFrame { - const AgentFrame({ - required this.hostId, - required this.sessionId, - required this.agentId, - required this.state, - required this.progress, - required this.detail, - required super.raw, - }); - - final String hostId; - final String sessionId; - final String agentId; - final String state; - final double? progress; - final Map? detail; -} - -/// Legacy aggregate terminal frame retained by the pinned package union. -final class TerminalFrame extends WireFrame { - const TerminalFrame({ - required this.hostId, - required this.sessionId, - required this.terminalId, - required this.stream, - required this.data, - required this.exitCode, - required super.raw, - }); - - final String hostId; - final String sessionId; - final String terminalId; - final String stream; - final String? data; - final int? exitCode; -} - -/// Legacy aggregate file frame retained by the pinned package union. -final class FilesFrame extends WireFrame { - const FilesFrame({ - required this.hostId, - required this.sessionId, - required this.path, - required this.content, - required this.truncated, - required super.raw, - }); - - final String hostId; - final String sessionId; - final String path; - final String? content; - final bool? truncated; -} - -final class ReviewFrame extends WireFrame { - const ReviewFrame({ - required this.hostId, - required this.sessionId, - required this.reviewId, - required this.status, - required this.path, - required this.findings, - required super.raw, - }); - - final String hostId; - final String sessionId; - final String reviewId; - final String status; - final String? path; - final List> findings; -} - -final class AuditFrame extends WireFrame { - const AuditFrame({ - required this.hostId, - required this.sessionId, - required this.action, - required this.actor, - required this.timestamp, - required this.detail, - required super.raw, - }); - - final String hostId; - final String? sessionId; - final String action; - final String actor; - final String timestamp; - final Map? detail; -} - -/// A successful pairing response. Pairing IDs are never confirmation IDs. -final class PairOkFrame extends WireFrame { - const PairOkFrame({ - required this.requestId, - required this.pairingId, - required this.deviceId, - required this.deviceName, - required this.platform, - required this.requestedCapabilities, - required this.grantedCapabilities, - required this.deviceToken, - required this.expiresAt, - required super.raw, - }); - - final String requestId; - final String pairingId; - final String deviceId; - final String deviceName; - final String platform; - final List requestedCapabilities; - final List grantedCapabilities; - final String deviceToken; - final String expiresAt; -} - -final class PairErrorFrame extends WireFrame { - const PairErrorFrame({ - required this.code, - required this.message, - required this.requestId, - required super.raw, - }); - - final String code; - final String message; - final String? requestId; -} - -final class ByeFrame extends WireFrame { - const ByeFrame({ - required this.code, - required this.reason, - required this.retryable, - required super.raw, - }); - - final String code; - final String reason; - final bool retryable; -} - -sealed class WatchFrame extends WireFrame { - const WatchFrame({ - required this.frameType, - required this.hostId, - required this.revision, - required super.raw, - }); - - final String frameType; - final String hostId; - final String revision; -} - -final class HostWatchFrame extends WatchFrame { - const HostWatchFrame({ - required this.watchId, - required this.cursor, - required this.state, - required super.hostId, - required super.revision, - required super.raw, - }) : super(frameType: 'host.watch'); - - final String watchId; - final SessionIndexCursor cursor; - final String state; -} - -final class SessionWatchFrame extends WatchFrame { - const SessionWatchFrame({ - required this.watchId, - required this.sessionId, - required this.cursor, - required this.state, - required super.hostId, - required super.revision, - required super.raw, - }) : super(frameType: 'session.watch'); - - final String watchId; - final String sessionId; - final TranscriptCursor cursor; - final String state; -} - -final class SessionStateFrame extends WatchFrame { - const SessionStateFrame({ - required this.sessionId, - required this.cursor, - required this.state, - required super.hostId, - required super.revision, - required super.raw, - }) : super(frameType: 'session.state'); - - final String sessionId; - final TranscriptCursor cursor; - final String state; -} - -final class SessionDeltaFrame extends WatchFrame { - const SessionDeltaFrame({ - required this.sessionId, - required this.cursor, - required this.upsert, - required this.remove, - required super.hostId, - required super.revision, - required super.raw, - }) : super(frameType: 'session.delta'); - - final String sessionId; - final TranscriptCursor cursor; - final SessionRef? upsert; - final String? remove; -} - -/// One of the controller or prompt lease server frames. -final class LeaseFrame extends WireFrame { - const LeaseFrame({ - required this.frameType, - required this.hostId, - required this.sessionId, - required this.leaseId, - required this.cursor, - required this.kind, - required this.state, - required this.owner, - required this.expiresAt, - required this.revision, - required super.raw, - }); - - final String frameType; - final String hostId; - final String sessionId; - final String leaseId; - final TranscriptCursor cursor; - final String kind; - final String state; - final String owner; - final String expiresAt; - final String? revision; -} - -/// One of the five negotiated agent.* server frames. -final class AgentAdditiveFrame extends WireFrame { - const AgentAdditiveFrame({ - required this.frameType, - required this.hostId, - required this.sessionId, - required this.agentId, - required this.cursor, - required this.revision, - required this.state, - required this.lifecycle, - required this.progress, - required this.event, - required this.detail, - required this.data, - required this.entries, - required super.raw, - }); - - final String frameType; - final String hostId; - final String sessionId; - final String agentId; - final TranscriptCursor cursor; - final String revision; - final String? state; - final String? lifecycle; - final double? progress; - final String? event; - final Map? detail; - final Map? data; - final List? entries; -} - -final class TerminalOutputFrame extends WireFrame { - const TerminalOutputFrame({ - required this.hostId, - required this.sessionId, - required this.terminalId, - required this.cursor, - required this.stream, - required this.data, - required this.encoding, - required super.raw, - }); - - final String hostId; - final String sessionId; - final String terminalId; - final TranscriptCursor cursor; - final String stream; - final String data; - final String? encoding; -} - -final class TerminalExitFrame extends WireFrame { - const TerminalExitFrame({ - required this.hostId, - required this.sessionId, - required this.terminalId, - required this.cursor, - required this.exitCode, - required this.signal, - required super.raw, - }); - - final String hostId; - final String sessionId; - final String terminalId; - final TranscriptCursor cursor; - final int exitCode; - final String? signal; -} - -final class FileListEntry { - const FileListEntry({ - required this.path, - required this.kind, - required this.size, - required this.revision, - required this.raw, - }); - - final String path; - final String kind; - final int? size; - final String? revision; - final Map raw; -} - -/// One of files.list, files.read, files.write, files.patch, or files.diff. -final class FilesAdditiveFrame extends WireFrame { - const FilesAdditiveFrame({ - required this.frameType, - required this.hostId, - required this.sessionId, - required this.path, - required this.entries, - required this.content, - required this.encoding, - required this.patch, - required this.diff, - required this.cursor, - required this.revision, - required this.fromRevision, - required this.toRevision, - required super.raw, - }); - - final String frameType; - final String hostId; - final String sessionId; - final String path; - final List? entries; - final String? content; - final String? encoding; - final String? patch; - final String? diff; - final TranscriptCursor? cursor; - final String? revision; - final String? fromRevision; - final String? toRevision; -} - -final class AuditEvent { - const AuditEvent({ - required this.eventId, - required this.hostId, - required this.sessionId, - required this.action, - required this.actor, - required this.timestamp, - required this.detail, - required this.raw, - }); - - final String eventId; - final String hostId; - final String? sessionId; - final String action; - final String actor; - final String timestamp; - final Map? detail; - final Map raw; -} - -final class AuditTailFrame extends WireFrame { - const AuditTailFrame({ - required this.hostId, - required this.cursor, - required this.events, - required super.raw, - }); - - final String hostId; - final TranscriptCursor cursor; - final List events; -} - -final class AuditEventFrame extends WireFrame { - const AuditEventFrame({ - required this.hostId, - required this.cursor, - required this.event, - required super.raw, - }); - - final String hostId; - final TranscriptCursor cursor; - final AuditEvent event; -} - -final class CatalogItem { - const CatalogItem({ - required this.id, - required this.kind, - required this.name, - required this.description, - required this.capabilities, - required this.supported, - required this.reason, - required this.metadata, - required this.raw, - }); - - final String id; - final String kind; - final String name; - final String? description; - final List? capabilities; - final bool? supported; - final String? reason; - final Map? metadata; - final Map raw; -} - -final class CatalogFrame extends WireFrame { - const CatalogFrame({ - required this.hostId, - required this.revision, - required this.items, - this.operations, - required super.raw, - }); - - final String hostId; - final String revision; - final List items; - final List? operations; -} - -final class SettingsFrame extends WireFrame { - const SettingsFrame({ - required this.hostId, - required this.revision, - required this.settings, - required super.raw, - }); - - final String hostId; - final String revision; - final Map settings; -} - -final class PreviewSnapshot { - const PreviewSnapshot({ - required this.previewId, - required this.state, - required this.url, - required this.revision, - required this.cursor, - required this.title, - required this.canGoBack, - required this.canGoForward, - required this.viewport, - required this.capture, - required this.authority, - required this.availableActions, - }); - - final String previewId; - final String state; - final String url; - final String revision; - final TranscriptCursor cursor; - final String? title; - final bool? canGoBack; - final bool? canGoForward; - final Map? viewport; - final Map? capture; - final Map? authority; - final List? availableActions; -} - -/// One of the five preview.* frames in AdditiveServerFrame. -final class PreviewFrame extends WireFrame { - const PreviewFrame({ - required this.frameType, - required this.hostId, - required this.sessionId, - required this.snapshot, - required this.previewId, - required this.cursor, - required this.revision, - required this.code, - required this.message, - required this.error, - required super.raw, - }); - - final String frameType; - final String hostId; - final String sessionId; - final PreviewSnapshot? snapshot; - final String previewId; - final TranscriptCursor cursor; - final String revision; - final String? code; - final String? message; - final String? error; -} diff --git a/apps/flutter/lib/src/protocol/protocol.dart b/apps/flutter/lib/src/protocol/protocol.dart deleted file mode 100644 index 477fa6af..00000000 --- a/apps/flutter/lib/src/protocol/protocol.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'models.dart'; -export 'wire_decoder.dart'; -export 'wire_encoder.dart'; diff --git a/apps/flutter/lib/src/protocol/wire_decoder.dart b/apps/flutter/lib/src/protocol/wire_decoder.dart deleted file mode 100644 index c3bbce92..00000000 --- a/apps/flutter/lib/src/protocol/wire_decoder.dart +++ /dev/null @@ -1,2555 +0,0 @@ -import 'dart:collection'; -import 'dart:convert'; - -import 'models.dart'; - -const int _maxInboundBytes = 4 * 1024 * 1024; -const int _maxSafeInteger = 9007199254740991; -const int _maxArrayItems = 1000; -const int _maxMapKeys = 512; -const int _maxJsonDepth = 32; -const int _maxJsonNodes = 20000; - -/// Allocation-conscious boundary decoder for inbound omp-app/1 JSON frames. -abstract final class WireDecoder { - /// Decodes one complete JSON frame. - /// - /// The UTF-8 size is checked without first allocating an encoded byte list. - /// All maps and lists reachable from the returned frame are immutable views - /// over the single object graph produced by [jsonDecode]. - static WireFrame decode(String source) { - if (!_utf8LengthAtMost(source, _maxInboundBytes)) { - throw const WireFormatException( - 'inbound frame exceeds the 4 MiB UTF-8 limit', - ); - } - - final Object? decoded; - try { - decoded = jsonDecode(source); - } on FormatException catch (error) { - throw WireFormatException('invalid JSON: ${error.message}'); - } - - final frozen = _freezeJson(decoded, _JsonBudget(), 0); - final raw = _map(frozen, 'frame'); - _exactVersion(raw); - final type = _string(raw['type'], 'type', 128); - return switch (type) { - 'welcome' => _decodeWelcome(raw), - 'sessions' => _decodeSessions(raw), - 'snapshot' => _decodeSnapshot(raw), - 'entry' => _decodeEntryFrame(raw), - 'event' => _decodeEvent(raw), - 'agent' => _decodeAgent(raw), - 'terminal' => _decodeTerminal(raw), - 'files' => _decodeFiles(raw), - 'review' => _decodeReview(raw), - 'audit' => _decodeAudit(raw), - 'confirmation' => _decodeConfirmation(raw), - 'pair.ok' => _decodePairOk(raw), - 'pair.error' => _decodePairError(raw), - 'response' => _decodeResponse(raw), - 'gap' => _decodeGap(raw), - 'error' => _decodeError(raw), - 'pong' => _decodePong(raw), - 'bye' => _decodeBye(raw), - 'host.watch' || - 'session.watch' || - 'session.state' || - 'session.delta' => _decodeWatch(raw, type), - 'lease' || 'prompt.lease' => _decodeLease(raw, type), - 'agent.state' || - 'agent.lifecycle' || - 'agent.progress' || - 'agent.event' || - 'agent.transcript' => _decodeAgentAdditive(raw, type), - 'terminal.output' => _decodeTerminalOutput(raw), - 'terminal.exit' => _decodeTerminalExit(raw), - 'files.list' || - 'files.read' || - 'files.write' || - 'files.patch' || - 'files.diff' => _decodeFilesAdditive(raw, type), - 'audit.tail' => _decodeAuditTail(raw), - 'audit.event' => _decodeAuditEventFrame(raw), - 'catalog' => _decodeCatalog(raw), - 'settings' => _decodeSettings(raw), - 'preview.launch' || - 'preview.state' || - 'preview.navigation' || - 'preview.capture' || - 'preview.error' => _decodePreview(raw, type), - _ => throw WireFormatException('unknown top-level frame family', 'type'), - }; - } - - /// Strictly decodes an authoritative host.list/session.list result payload. - /// - /// A recursively immutable copy is returned and [value] is never mutated. - static SessionListResult decodeSessionListResult(Object? value) { - final frozen = _freezeJsonCopy(value, _JsonBudget(), 0); - return _decodeSessionListResult(_map(frozen, 'result')); - } - - /// Strictly decodes one session reference returned by session.create. - static SessionRef decodeSessionRef(Object? value) { - final frozen = _freezeJsonCopy(value, _JsonBudget(), 0); - return _sessionRef(frozen, 'session'); - } - - /// Strictly decodes a bounded transcript.page result payload. - static TranscriptPageResult decodeTranscriptPageResult(Object? value) { - final frozen = _freezeJsonCopy(value, _JsonBudget(), 0); - return _decodeTranscriptPageResult(_map(frozen, 'result')); - } -} - -WelcomeFrame _decodeWelcome(Map raw) { - final selectedProtocol = _string( - raw['selectedProtocol'], - 'selectedProtocol', - 64, - ); - if (selectedProtocol != ompAppProtocolVersion) { - throw const WireFormatException( - 'selected protocol must be omp-app/1', - 'selectedProtocol', - ); - } - final hostId = _id(raw['hostId'], 'hostId'); - _string(raw['ompVersion'], 'ompVersion', 64); - _string(raw['ompBuild'], 'ompBuild', 128); - _string(raw['appserverVersion'], 'appserverVersion', 64); - _string(raw['appserverBuild'], 'appserverBuild', 128); - final epoch = _string(raw['epoch'], 'epoch', 128); - final authentication = _string(raw['authentication'], 'authentication', 32); - if (authentication != 'local' && - authentication != 'pairing-required' && - authentication != 'paired') { - throw const WireFormatException( - 'invalid authentication state', - 'authentication', - ); - } - final capabilities = _stringList( - raw['grantedCapabilities'], - 'grantedCapabilities', - maxItems: 128, - ); - if (authentication == 'pairing-required' && capabilities.isNotEmpty) { - throw const WireFormatException( - 'pairing-required welcome cannot grant capabilities', - 'grantedCapabilities', - ); - } - final features = _stringList( - raw['grantedFeatures'], - 'grantedFeatures', - maxItems: 128, - ); - final limits = _map(raw['negotiatedLimits'], 'negotiatedLimits'); - final resumed = _bool(raw['resumed'], 'resumed'); - return WelcomeFrame( - hostId: hostId, - resumed: resumed, - selectedProtocol: selectedProtocol, - epoch: epoch, - authentication: authentication, - grantedCapabilities: capabilities, - grantedFeatures: features, - negotiatedLimits: limits, - raw: raw, - ); -} - -SessionsFrame _decodeSessions(Map raw) { - final hostId = raw.containsKey('hostId') - ? _id(raw['hostId'], 'hostId') - : null; - final cursor = _sessionIndexCursor(raw['cursor'], 'cursor'); - final values = _list(raw['sessions'], 'sessions'); - final sessions = []; - for (var index = 0; index < values.length; index++) { - sessions.add(_sessionRef(values[index], 'sessions[$index]')); - } - final totalCount = raw.containsKey('totalCount') - ? _safeInteger(raw['totalCount'], 'totalCount') - : sessions.length; - if (totalCount < sessions.length) { - throw const WireFormatException( - 'totalCount cannot be less than sessions length', - 'totalCount', - ); - } - final expectedTruncated = totalCount > sessions.length; - final truncated = raw.containsKey('truncated') - ? _bool(raw['truncated'], 'truncated') - : expectedTruncated; - if (truncated != expectedTruncated) { - throw const WireFormatException( - 'truncated does not match totalCount', - 'truncated', - ); - } - return SessionsFrame( - hostId: hostId, - cursor: cursor, - sessions: UnmodifiableListView(sessions), - totalCount: totalCount, - truncated: truncated, - raw: raw, - ); -} - -SessionListResult _decodeSessionListResult(Map raw) { - final cursor = _sessionIndexCursor(raw['cursor'], 'result.cursor'); - final values = _list(raw['sessions'], 'result.sessions'); - final sessions = []; - for (var index = 0; index < values.length; index++) { - sessions.add(_sessionRef(values[index], 'result.sessions[$index]')); - } - final totalCount = raw.containsKey('totalCount') - ? _safeInteger(raw['totalCount'], 'result.totalCount') - : sessions.length; - if (totalCount < sessions.length) { - throw const WireFormatException( - 'totalCount cannot be less than sessions length', - 'result.totalCount', - ); - } - final expectedTruncated = totalCount > sessions.length; - final truncated = raw.containsKey('truncated') - ? _bool(raw['truncated'], 'result.truncated') - : expectedTruncated; - if (truncated != expectedTruncated) { - throw const WireFormatException( - 'truncated does not match totalCount', - 'result', - ); - } - return SessionListResult( - cursor: cursor, - sessions: UnmodifiableListView(sessions), - totalCount: totalCount, - truncated: truncated, - raw: raw, - ); -} - -SessionRef _sessionRef(Object? value, String path) { - final raw = _map(value, path); - final hostId = _id(raw['hostId'], '$path.hostId'); - final sessionId = _id(raw['sessionId'], '$path.sessionId'); - final project = _map(raw['project'], '$path.project'); - _id(project['projectId'], '$path.project.projectId'); - if (project.containsKey('name')) { - _string(project['name'], '$path.project.name', 256); - } - final revision = _id(raw['revision'], '$path.revision'); - final title = _string(raw['title'], '$path.title', 512); - final status = _string(raw['status'], '$path.status', 64); - final updatedAt = _string(raw['updatedAt'], '$path.updatedAt', 128); - if (raw.containsKey('archivedAt')) { - _string(raw['archivedAt'], '$path.archivedAt', 128); - } - if (raw.containsKey('liveState')) { - _map(raw['liveState'], '$path.liveState'); - } - if (raw.containsKey('model')) { - _string(raw['model'], '$path.model', 256); - } - if (raw.containsKey('thinking')) { - _string(raw['thinking'], '$path.thinking', 256); - } - if (raw.containsKey('pendingApproval')) { - _bool(raw['pendingApproval'], '$path.pendingApproval'); - } - if (raw.containsKey('pendingUserInput')) { - _bool(raw['pendingUserInput'], '$path.pendingUserInput'); - } - if (raw.containsKey('proposedPlan')) { - _string(raw['proposedPlan'], '$path.proposedPlan', 4096); - } - return SessionRef( - hostId: hostId, - sessionId: sessionId, - title: title, - revision: revision, - status: status, - updatedAt: updatedAt, - project: project, - raw: raw, - ); -} - -SnapshotFrame _decodeSnapshot(Map raw) { - final hostId = _id(raw['hostId'], 'hostId'); - final sessionId = _id(raw['sessionId'], 'sessionId'); - final cursor = _transcriptCursor(raw['cursor'], 'cursor'); - final revision = _id(raw['revision'], 'revision'); - final values = _list(raw['entries'], 'entries'); - final entries = []; - for (var index = 0; index < values.length; index++) { - final entry = _durableEntry(values[index], 'entries[$index]'); - if (entry.hostId != hostId || entry.sessionId != sessionId) { - throw WireFormatException( - 'entry belongs to another session', - 'entries[$index]', - ); - } - entries.add(entry); - } - return SnapshotFrame( - hostId: hostId, - sessionId: sessionId, - cursor: cursor, - revision: revision, - entries: UnmodifiableListView(entries), - raw: raw, - ); -} - -EntryFrame _decodeEntryFrame(Map raw) { - final hostId = _id(raw['hostId'], 'hostId'); - final sessionId = _id(raw['sessionId'], 'sessionId'); - final entry = _durableEntry(raw['entry'], 'entry'); - if (entry.hostId != hostId || entry.sessionId != sessionId) { - throw const WireFormatException( - 'entry belongs to another session', - 'entry', - ); - } - return EntryFrame( - hostId: hostId, - sessionId: sessionId, - cursor: _transcriptCursor(raw['cursor'], 'cursor'), - revision: _id(raw['revision'], 'revision'), - entry: entry, - raw: raw, - ); -} - -DurableEntry _durableEntry(Object? value, String path) { - final raw = _map(value, path); - final id = _id(raw['id'], '$path.id'); - if (!raw.containsKey('parentId')) { - throw WireFormatException('parentId is required', '$path.parentId'); - } - final parentId = raw['parentId'] == null - ? null - : _id(raw['parentId'], '$path.parentId'); - final hostId = _id(raw['hostId'], '$path.hostId'); - final sessionId = _id(raw['sessionId'], '$path.sessionId'); - final kind = _string(raw['kind'], '$path.kind', 128); - final timestamp = _string(raw['timestamp'], '$path.timestamp', 128); - final data = _map(raw['data'], '$path.data'); - return DurableEntry( - id: id, - parentId: parentId, - hostId: hostId, - sessionId: sessionId, - kind: kind, - timestamp: timestamp, - data: data, - raw: raw, - ); -} - -EventFrame _decodeEvent(Map raw) { - final event = _map(raw['event'], 'event'); - _string(event['type'], 'event.type', 128); - return EventFrame( - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - cursor: _transcriptCursor(raw['cursor'], 'cursor'), - event: event, - raw: raw, - ); -} - -ResponseFrame _decodeResponse(Map raw) { - final requestId = _id(raw['requestId'], 'requestId'); - final commandId = raw.containsKey('commandId') - ? _id(raw['commandId'], 'commandId') - : null; - final hostId = _id(raw['hostId'], 'hostId'); - final sessionId = raw.containsKey('sessionId') - ? _id(raw['sessionId'], 'sessionId') - : null; - final command = raw.containsKey('command') - ? _string(raw['command'], 'command', 128) - : null; - final ok = _bool(raw['ok'], 'ok'); - final hasResult = raw.containsKey('result'); - Object? result; - WireResponseError? error; - if (ok) { - if (raw.containsKey('error')) { - throw const WireFormatException( - 'successful response cannot have an error', - 'error', - ); - } - if (hasResult && command == null) { - throw const WireFormatException( - 'successful response result requires command correlation', - 'command', - ); - } - if (hasResult) { - result = switch (command) { - 'session.list' || - 'host.list' => _decodeSessionListResult(_map(raw['result'], 'result')), - 'catalog.get' => _decodeCatalogResult(_map(raw['result'], 'result')), - 'settings.read' => _decodeSettingsResult(_map(raw['result'], 'result')), - 'transcript.search' => _decodeTranscriptSearchResult( - _map(raw['result'], 'result'), - ), - 'transcript.context' => _decodeTranscriptContextResult( - _map(raw['result'], 'result'), - ), - 'transcript.page' => _decodeTranscriptPageResult( - _map(raw['result'], 'result'), - ), - 'session.state.get' => _decodeSessionStateResult( - _map(raw['result'], 'result'), - ), - 'usage.read' => _decodeUsageReadResult(_map(raw['result'], 'result')), - 'broker.status' => _decodeBrokerStatusResult( - _map(raw['result'], 'result'), - ), - _ => raw['result'], - }; - } - } else { - if (hasResult) { - throw const WireFormatException( - 'failed response cannot have a result', - 'result', - ); - } - final errorRaw = _map(raw['error'], 'error'); - error = WireResponseError( - code: _string(errorRaw['code'], 'error.code', 128), - message: _nonemptyText(errorRaw['message'], 'error.message', 1024), - details: errorRaw.containsKey('details') - ? _map(errorRaw['details'], 'error.details') - : null, - raw: errorRaw, - ); - } - return ResponseFrame( - requestId: requestId, - commandId: commandId, - hostId: hostId, - sessionId: sessionId, - command: command, - ok: ok, - result: result, - error: error, - raw: raw, - ); -} - -SessionStateResult _decodeSessionStateResult(Map raw) { - const allowedKeys = { - 'isStreaming', - 'isCompacting', - 'isPaused', - 'messageCount', - 'queuedMessageCount', - 'steeringMode', - 'followUpMode', - 'interruptMode', - 'queuedMessages', - 'model', - 'thinking', - 'thinkingEffective', - 'thinkingResolved', - 'thinkingLevels', - 'thinkingSupported', - 'thinkingOffFloored', - 'fast', - 'fastAvailable', - 'fastActive', - 'sessionName', - 'contextUsage', - }; - for (final key in raw.keys) { - if (!allowedKeys.contains(key)) { - throw WireFormatException('unknown session state field', 'result.$key'); - } - } - _enumString(raw['steeringMode'], 'result.steeringMode', const { - 'all', - 'one-at-a-time', - }); - _enumString(raw['followUpMode'], 'result.followUpMode', const { - 'all', - 'one-at-a-time', - }); - _enumString(raw['interruptMode'], 'result.interruptMode', const { - 'immediate', - 'wait', - }); - - SessionStateModel? model; - if (raw['model'] case final Object value) { - final modelRaw = _map(value, 'result.model'); - const modelKeys = { - 'id', - 'provider', - 'displayName', - 'selector', - 'role', - }; - for (final key in modelRaw.keys) { - if (!modelKeys.contains(key)) { - throw WireFormatException( - 'unknown session model field', - 'result.model.$key', - ); - } - } - model = SessionStateModel( - id: _string(modelRaw['id'], 'result.model.id', 256), - provider: _string(modelRaw['provider'], 'result.model.provider', 256), - displayName: modelRaw.containsKey('displayName') - ? _string(modelRaw['displayName'], 'result.model.displayName', 256) - : null, - selector: modelRaw.containsKey('selector') - ? _string(modelRaw['selector'], 'result.model.selector', 512) - : null, - role: modelRaw.containsKey('role') - ? _string(modelRaw['role'], 'result.model.role', 256) - : null, - ); - } - - const configuredThinking = { - 'inherit', - 'off', - 'auto', - 'minimal', - 'low', - 'medium', - 'high', - 'xhigh', - 'max', - }; - const resolvedThinking = { - 'minimal', - 'low', - 'medium', - 'high', - 'xhigh', - 'max', - }; - final thinking = raw.containsKey('thinking') - ? _enumString(raw['thinking'], 'result.thinking', configuredThinking) - : null; - if (raw.containsKey('thinkingEffective')) { - _enumString(raw['thinkingEffective'], 'result.thinkingEffective', const { - 'off', - ...resolvedThinking, - }); - } - if (raw.containsKey('thinkingResolved')) { - _enumString( - raw['thinkingResolved'], - 'result.thinkingResolved', - resolvedThinking, - ); - } - List? thinkingLevels; - if (raw.containsKey('thinkingLevels')) { - thinkingLevels = _stringList( - raw['thinkingLevels'], - 'result.thinkingLevels', - maxItems: resolvedThinking.length, - ); - for (var index = 0; index < thinkingLevels.length; index++) { - if (!resolvedThinking.contains(thinkingLevels[index])) { - throw WireFormatException( - 'invalid thinking level', - 'result.thinkingLevels[$index]', - ); - } - } - if (thinkingLevels.toSet().length != thinkingLevels.length) { - throw const WireFormatException( - 'duplicate thinking level', - 'result.thinkingLevels', - ); - } - thinkingLevels = List.unmodifiable(thinkingLevels); - } - if (raw.containsKey('thinkingOffFloored')) { - _bool(raw['thinkingOffFloored'], 'result.thinkingOffFloored'); - } - if (raw.containsKey('sessionName')) { - _string(raw['sessionName'], 'result.sessionName', 512); - } - if (raw['contextUsage'] case final Object value) { - final usage = _map(value, 'result.contextUsage'); - if (usage.keys.toSet().difference(const {'used', 'limit'}).isNotEmpty) { - throw const WireFormatException( - 'unknown context usage field', - 'result.contextUsage', - ); - } - final used = _safeInteger(usage['used'], 'result.contextUsage.used'); - final limit = _safeInteger(usage['limit'], 'result.contextUsage.limit'); - if (used > limit) { - throw const WireFormatException( - 'context usage exceeds limit', - 'result.contextUsage', - ); - } - } - if (raw['queuedMessages'] case final Object value) { - final queues = _map(value, 'result.queuedMessages'); - if (queues.keys.toSet().difference(const { - 'steering', - 'followUp', - }).isNotEmpty) { - throw const WireFormatException( - 'unknown queued message field', - 'result.queuedMessages', - ); - } - for (final name in const ['steering', 'followUp']) { - final messages = _list(queues[name], 'result.queuedMessages.$name', 128); - for (var index = 0; index < messages.length; index++) { - _boundedText( - messages[index], - 'result.queuedMessages.$name[$index]', - 65_536, - ); - } - } - } - - return SessionStateResult( - isStreaming: _bool(raw['isStreaming'], 'result.isStreaming'), - isCompacting: _bool(raw['isCompacting'], 'result.isCompacting'), - isPaused: _bool(raw['isPaused'], 'result.isPaused'), - messageCount: _safeInteger(raw['messageCount'], 'result.messageCount'), - queuedMessageCount: _safeInteger( - raw['queuedMessageCount'], - 'result.queuedMessageCount', - ), - model: model, - thinking: thinking, - thinkingLevels: thinkingLevels, - thinkingSupported: raw.containsKey('thinkingSupported') - ? _bool(raw['thinkingSupported'], 'result.thinkingSupported') - : null, - fast: raw.containsKey('fast') ? _bool(raw['fast'], 'result.fast') : null, - fastAvailable: raw.containsKey('fastAvailable') - ? _bool(raw['fastAvailable'], 'result.fastAvailable') - : null, - fastActive: raw.containsKey('fastActive') - ? _bool(raw['fastActive'], 'result.fastActive') - : null, - ); -} - -ErrorFrame _decodeError(Map raw) { - return ErrorFrame( - code: _string(raw['code'], 'code', 128), - message: _string(raw['message'], 'message', 2048), - requestId: raw.containsKey('requestId') - ? _id(raw['requestId'], 'requestId') - : null, - details: raw.containsKey('details') - ? _map(raw['details'], 'details') - : null, - raw: raw, - ); -} - -GapFrame _decodeGap(Map raw) { - final from = _transcriptCursor(raw['from'], 'from'); - final to = _transcriptCursor(raw['to'], 'to'); - if (from.epoch != to.epoch || to.seq < from.seq) { - throw const WireFormatException('invalid gap cursor range', 'to'); - } - return GapFrame( - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - from: from, - to: to, - reason: _string(raw['reason'], 'reason', 256), - raw: raw, - ); -} - -AgentFrame _decodeAgent(Map raw) { - final progress = raw.containsKey('progress') - ? _finiteNumber(raw['progress'], 'progress') - : null; - if (progress != null && (progress < 0 || progress > 1)) { - throw const WireFormatException( - 'progress must be between zero and one', - 'progress', - ); - } - return AgentFrame( - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - agentId: _id(raw['agentId'], 'agentId'), - state: _string(raw['state'], 'state', 64), - progress: progress, - detail: raw.containsKey('detail') ? _map(raw['detail'], 'detail') : null, - raw: raw, - ); -} - -TerminalFrame _decodeTerminal(Map raw) { - final stream = _enumString(raw['stream'], 'stream', const { - 'stdout', - 'stderr', - 'exit', - }); - String? data; - int? exitCode; - if (stream == 'exit') { - if (raw.containsKey('data')) { - throw const WireFormatException('terminal exit cannot have data', 'data'); - } - exitCode = _signedSafeInteger(raw['exitCode'], 'exitCode'); - } else { - data = _boundedText(raw['data'], 'data', 256000); - if (raw.containsKey('exitCode')) { - throw const WireFormatException( - 'terminal output cannot have exitCode', - 'exitCode', - ); - } - } - return TerminalFrame( - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - terminalId: _id(raw['terminalId'], 'terminalId'), - stream: stream, - data: data, - exitCode: exitCode, - raw: raw, - ); -} - -FilesFrame _decodeFiles(Map raw) { - return FilesFrame( - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - path: _safeRelativePath(raw['path'], 'path'), - content: raw.containsKey('content') - ? _boundedText(raw['content'], 'content', 768 * 1024) - : null, - truncated: raw.containsKey('truncated') - ? _bool(raw['truncated'], 'truncated') - : null, - raw: raw, - ); -} - -ReviewFrame _decodeReview(Map raw) { - final values = _list(raw['findings'], 'findings'); - final findings = >[]; - for (var index = 0; index < values.length; index++) { - findings.add(_map(values[index], 'findings[$index]')); - } - return ReviewFrame( - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - reviewId: _id(raw['reviewId'], 'reviewId'), - status: _string(raw['status'], 'status', 64), - path: raw.containsKey('path') - ? _safeRelativePath(raw['path'], 'path') - : null, - findings: List>.unmodifiable(findings), - raw: raw, - ); -} - -AuditFrame _decodeAudit(Map raw) { - return AuditFrame( - hostId: _id(raw['hostId'], 'hostId'), - sessionId: raw.containsKey('sessionId') - ? _id(raw['sessionId'], 'sessionId') - : null, - action: _string(raw['action'], 'action', 128), - actor: _string(raw['actor'], 'actor', 256), - timestamp: _string(raw['timestamp'], 'timestamp', 128), - detail: raw.containsKey('detail') ? _map(raw['detail'], 'detail') : null, - raw: raw, - ); -} - -ConfirmationFrame _decodeConfirmation(Map raw) { - return ConfirmationFrame( - confirmationId: _id(raw['confirmationId'], 'confirmationId'), - commandId: _id(raw['commandId'], 'commandId'), - hostId: _id(raw['hostId'], 'hostId'), - sessionId: raw.containsKey('sessionId') - ? _id(raw['sessionId'], 'sessionId') - : null, - commandHash: _string(raw['commandHash'], 'commandHash', 256), - revision: _id(raw['revision'], 'revision'), - expiresAt: _string(raw['expiresAt'], 'expiresAt', 128), - summary: _nonemptyText(raw['summary'], 'summary', 2048), - preview: raw.containsKey('preview') - ? _nonemptyText(raw['preview'], 'preview', 8192) - : null, - raw: raw, - ); -} - -PairOkFrame _decodePairOk(Map raw) { - final token = _string(raw['deviceToken'], 'deviceToken', 512); - if (!RegExp(r'^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$').hasMatch(token)) { - throw const WireFormatException( - 'device token must be canonical base64url for 32 bytes', - 'deviceToken', - ); - } - return PairOkFrame( - requestId: _id(raw['requestId'], 'requestId'), - pairingId: _id(raw['pairingId'], 'pairingId'), - deviceId: _id(raw['deviceId'], 'deviceId'), - deviceName: _string(raw['deviceName'], 'deviceName', 256), - platform: _string(raw['platform'], 'platform', 128), - requestedCapabilities: _capabilityList( - raw['requestedCapabilities'], - 'requestedCapabilities', - ), - grantedCapabilities: _capabilityList( - raw['grantedCapabilities'], - 'grantedCapabilities', - ), - deviceToken: token, - expiresAt: _string(raw['expiresAt'], 'expiresAt', 128), - raw: raw, - ); -} - -PairErrorFrame _decodePairError(Map raw) { - return PairErrorFrame( - code: _string(raw['code'], 'code', 128), - message: _nonemptyText(raw['message'], 'message', 1024), - requestId: raw.containsKey('requestId') - ? _id(raw['requestId'], 'requestId') - : null, - raw: raw, - ); -} - -ByeFrame _decodeBye(Map raw) => ByeFrame( - code: _string(raw['code'], 'code', 128), - reason: _string(raw['reason'], 'reason', 1024), - retryable: _bool(raw['retryable'], 'retryable'), - raw: raw, -); - -WatchFrame _decodeWatch(Map raw, String type) { - final hostId = _id(raw['hostId'], 'hostId'); - final revision = _id(raw['revision'], 'revision'); - if (type == 'host.watch') { - return HostWatchFrame( - watchId: _id(raw['watchId'], 'watchId'), - hostId: hostId, - cursor: _sessionIndexCursor(raw['cursor'], 'cursor'), - state: _enumString(raw['state'], 'state', const { - 'started', - 'stopped', - 'ready', - }), - revision: revision, - raw: raw, - ); - } - final sessionId = _id(raw['sessionId'], 'sessionId'); - final cursor = _transcriptCursor(raw['cursor'], 'cursor'); - if (type == 'session.watch') { - return SessionWatchFrame( - watchId: _id(raw['watchId'], 'watchId'), - hostId: hostId, - sessionId: sessionId, - cursor: cursor, - state: _enumString(raw['state'], 'state', const { - 'started', - 'stopped', - 'ready', - }), - revision: revision, - raw: raw, - ); - } - if (type == 'session.state') { - return SessionStateFrame( - hostId: hostId, - sessionId: sessionId, - cursor: cursor, - state: _string(raw['state'], 'state', 128), - revision: revision, - raw: raw, - ); - } - SessionRef? upsert; - String? remove; - if (raw.containsKey('upsert')) { - upsert = _sessionRef(raw['upsert'], 'upsert'); - } - if (raw.containsKey('remove')) { - remove = _id(raw['remove'], 'remove'); - } - if ((upsert == null) == (remove == null)) { - throw const WireFormatException( - 'session delta requires exactly one of upsert or remove', - 'delta', - ); - } - if (upsert != null && - (upsert.hostId != hostId || upsert.sessionId != sessionId)) { - throw const WireFormatException( - 'upsert belongs to another session', - 'upsert', - ); - } - if (remove != null && remove != sessionId) { - throw const WireFormatException( - 'remove belongs to another session', - 'remove', - ); - } - return SessionDeltaFrame( - hostId: hostId, - sessionId: sessionId, - cursor: cursor, - revision: revision, - upsert: upsert, - remove: remove, - raw: raw, - ); -} - -LeaseFrame _decodeLease(Map raw, String type) { - final expectedKind = type == 'lease' ? 'controller' : 'prompt'; - if (raw['kind'] != expectedKind) { - throw const WireFormatException('lease kind does not match type', 'kind'); - } - return LeaseFrame( - frameType: type, - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - leaseId: _id(raw['leaseId'], 'leaseId'), - cursor: _transcriptCursor(raw['cursor'], 'cursor'), - kind: expectedKind, - state: _enumString(raw['state'], 'state', const { - 'acquired', - 'renewed', - 'released', - 'expired', - }), - owner: _id(raw['owner'], 'owner'), - expiresAt: _string(raw['expiresAt'], 'expiresAt', 128), - revision: raw.containsKey('revision') - ? _id(raw['revision'], 'revision') - : null, - raw: raw, - ); -} - -AgentAdditiveFrame _decodeAgentAdditive(Map raw, String type) { - String? state; - String? lifecycle; - double? progress; - String? event; - Map? detail; - Map? data; - List? entries; - const states = { - 'created', - 'started', - 'running', - 'completed', - 'failed', - 'cancelled', - }; - switch (type) { - case 'agent.state': - state = _enumString(raw['state'], 'state', states); - break; - case 'agent.lifecycle': - lifecycle = _enumString(raw['lifecycle'], 'lifecycle', states); - break; - case 'agent.progress': - progress = _finiteNumber(raw['progress'], 'progress'); - if (progress < 0 || progress > 1) { - throw const WireFormatException( - 'progress must be between zero and one', - 'progress', - ); - } - if (raw.containsKey('detail')) { - detail = _map(raw['detail'], 'detail'); - } - break; - case 'agent.event': - event = _string(raw['event'], 'event', 128); - if (raw.containsKey('data')) { - data = _map(raw['data'], 'data'); - } - break; - case 'agent.transcript': - final values = _list(raw['entries'], 'entries'); - final decoded = []; - for (var index = 0; index < values.length; index++) { - decoded.add(_durableEntry(values[index], 'entries[$index]')); - } - entries = List.unmodifiable(decoded); - break; - } - final hostId = _id(raw['hostId'], 'hostId'); - final sessionId = _id(raw['sessionId'], 'sessionId'); - if (entries != null) { - for (final entry in entries) { - if (entry.hostId != hostId || entry.sessionId != sessionId) { - throw const WireFormatException( - 'transcript entry belongs to another session', - 'entries', - ); - } - } - } - return AgentAdditiveFrame( - frameType: type, - hostId: hostId, - sessionId: sessionId, - agentId: _id(raw['agentId'], 'agentId'), - cursor: _transcriptCursor(raw['cursor'], 'cursor'), - revision: _id(raw['revision'], 'revision'), - state: state, - lifecycle: lifecycle, - progress: progress, - event: event, - detail: detail, - data: data, - entries: entries, - raw: raw, - ); -} - -TerminalOutputFrame _decodeTerminalOutput(Map raw) { - final encoding = raw.containsKey('encoding') - ? _enumString(raw['encoding'], 'encoding', const {'utf8', 'base64'}) - : null; - final data = encoding == 'base64' - ? _base64(raw['data'], 'data', 256000) - : _boundedText(raw['data'], 'data', 256000); - return TerminalOutputFrame( - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - terminalId: _id(raw['terminalId'], 'terminalId'), - cursor: _transcriptCursor(raw['cursor'], 'cursor'), - stream: _enumString(raw['stream'], 'stream', const {'stdout', 'stderr'}), - data: data, - encoding: encoding, - raw: raw, - ); -} - -TerminalExitFrame _decodeTerminalExit(Map raw) { - return TerminalExitFrame( - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - terminalId: _id(raw['terminalId'], 'terminalId'), - cursor: _transcriptCursor(raw['cursor'], 'cursor'), - exitCode: _signedSafeInteger(raw['exitCode'], 'exitCode'), - signal: raw.containsKey('signal') - ? _string(raw['signal'], 'signal', 128) - : null, - raw: raw, - ); -} - -FilesAdditiveFrame _decodeFilesAdditive(Map raw, String type) { - List? entries; - String? content; - String? encoding; - String? patch; - String? diff; - TranscriptCursor? cursor; - String? revision; - String? fromRevision; - String? toRevision; - switch (type) { - case 'files.list': - final values = _list(raw['entries'], 'entries'); - final decoded = []; - for (var index = 0; index < values.length; index++) { - decoded.add(_fileListEntry(values[index], 'entries[$index]')); - } - entries = List.unmodifiable(decoded); - if (raw.containsKey('cursor')) { - cursor = _transcriptCursor(raw['cursor'], 'cursor'); - } - if (raw.containsKey('revision')) { - revision = _id(raw['revision'], 'revision'); - } - break; - case 'files.read': - encoding = raw.containsKey('encoding') - ? _enumString(raw['encoding'], 'encoding', const {'utf8', 'base64'}) - : null; - content = encoding == 'base64' - ? _base64(raw['content'], 'content', 768 * 1024) - : _boundedText(raw['content'], 'content', 768 * 1024); - if (raw.containsKey('revision')) { - revision = _id(raw['revision'], 'revision'); - } - break; - case 'files.write': - encoding = raw.containsKey('encoding') - ? _enumString(raw['encoding'], 'encoding', const {'utf8', 'base64'}) - : null; - content = encoding == 'base64' - ? _base64(raw['content'], 'content', 768 * 1024) - : _boundedText(raw['content'], 'content', 768 * 1024); - revision = _id(raw['revision'], 'revision'); - break; - case 'files.patch': - patch = _boundedText(raw['patch'], 'patch', 768 * 1024); - revision = _id(raw['revision'], 'revision'); - break; - case 'files.diff': - diff = _boundedText(raw['diff'], 'diff', 768 * 1024); - if (raw.containsKey('fromRevision')) { - fromRevision = _id(raw['fromRevision'], 'fromRevision'); - } - if (raw.containsKey('toRevision')) { - toRevision = _id(raw['toRevision'], 'toRevision'); - } - break; - } - return FilesAdditiveFrame( - frameType: type, - hostId: _id(raw['hostId'], 'hostId'), - sessionId: _id(raw['sessionId'], 'sessionId'), - path: _safeRelativePath(raw['path'], 'path'), - entries: entries, - content: content, - encoding: encoding, - patch: patch, - diff: diff, - cursor: cursor, - revision: revision, - fromRevision: fromRevision, - toRevision: toRevision, - raw: raw, - ); -} - -FileListEntry _fileListEntry(Object? value, String path) { - final raw = _map(value, path); - final size = raw.containsKey('size') - ? _safeInteger(raw['size'], '$path.size') - : null; - if (size != null && size > 768 * 1024 * 1024) { - throw WireFormatException('file size exceeds limit', '$path.size'); - } - return FileListEntry( - path: _safeRelativePath(raw['path'], '$path.path'), - kind: _enumString(raw['kind'], '$path.kind', const { - 'file', - 'directory', - 'symlink', - }), - size: size, - revision: raw.containsKey('revision') - ? _id(raw['revision'], '$path.revision') - : null, - raw: raw, - ); -} - -AuditTailFrame _decodeAuditTail(Map raw) { - final hostId = _id(raw['hostId'], 'hostId'); - final values = _list(raw['events'], 'events'); - final events = []; - for (var index = 0; index < values.length; index++) { - final event = _auditEvent(values[index], 'events[$index]'); - if (event.hostId != hostId) { - throw const WireFormatException( - 'audit event belongs to another host', - 'events', - ); - } - events.add(event); - } - return AuditTailFrame( - hostId: hostId, - cursor: _transcriptCursor(raw['cursor'], 'cursor'), - events: List.unmodifiable(events), - raw: raw, - ); -} - -AuditEventFrame _decodeAuditEventFrame(Map raw) { - final hostId = _id(raw['hostId'], 'hostId'); - final event = _auditEvent(raw['event'], 'event'); - if (event.hostId != hostId) { - throw const WireFormatException( - 'audit event belongs to another host', - 'event.hostId', - ); - } - return AuditEventFrame( - hostId: hostId, - cursor: _transcriptCursor(raw['cursor'], 'cursor'), - event: event, - raw: raw, - ); -} - -AuditEvent _auditEvent(Object? value, String path) { - final raw = _map(value, path); - return AuditEvent( - eventId: _id(raw['eventId'], '$path.eventId'), - hostId: _id(raw['hostId'], '$path.hostId'), - sessionId: raw.containsKey('sessionId') - ? _id(raw['sessionId'], '$path.sessionId') - : null, - action: _string(raw['action'], '$path.action', 128), - actor: _string(raw['actor'], '$path.actor', 256), - timestamp: _string(raw['timestamp'], '$path.timestamp', 128), - detail: raw.containsKey('detail') - ? _map(raw['detail'], '$path.detail') - : null, - raw: raw, - ); -} - -OperationExecution _operationExecution(Object? value, String path) { - return switch (_enumString(value, path, const { - 'typed', - 'headless', - 'terminal-only', - 'unavailable', - })) { - 'typed' => OperationExecution.typed, - 'headless' => OperationExecution.headless, - 'terminal-only' => OperationExecution.terminalOnly, - 'unavailable' => OperationExecution.unavailable, - _ => throw WireFormatException('unknown operation execution', path), - }; -} - -OperationDisabledReason _operationDisabledReason(Object? value, String path) { - final raw = _map(value, path); - return OperationDisabledReason( - code: _string(raw['code'], '$path.code', 128), - message: _boundedText(raw['message'], '$path.message', 2048), - raw: raw, - ); -} - -OperationCapability _operationCapability(Object? value, String path) { - final raw = _map(value, path); - final execution = _operationExecution(raw['execution'], '$path.execution'); - final supported = _bool(raw['supported'], '$path.supported'); - final disabledReason = raw.containsKey('disabledReason') - ? _operationDisabledReason(raw['disabledReason'], '$path.disabledReason') - : null; - if (!supported && disabledReason == null) { - throw WireFormatException( - 'unsupported operation requires disabledReason', - '$path.disabledReason', - ); - } - if (supported && disabledReason != null) { - throw WireFormatException( - 'supported operation cannot have disabledReason', - '$path.disabledReason', - ); - } - if (supported && - (execution == OperationExecution.terminalOnly || - execution == OperationExecution.unavailable)) { - throw WireFormatException( - 'terminal-only and unavailable operations cannot be supported', - '$path.supported', - ); - } - return OperationCapability( - operationId: _id(raw['operationId'], '$path.operationId'), - label: _string(raw['label'], '$path.label', 256), - description: raw.containsKey('description') - ? _boundedText(raw['description'], '$path.description', 4096) - : null, - execution: execution, - supported: supported, - disabledReason: disabledReason, - capabilities: raw.containsKey('capabilities') - ? _stringList(raw['capabilities'], '$path.capabilities', maxItems: 128) - : null, - raw: raw, - ); -} - -List? _operationCapabilities( - Map raw, - String path, -) { - if (!raw.containsKey('operations')) { - return null; - } - final values = _list(raw['operations'], path); - return List.unmodifiable([ - for (var index = 0; index < values.length; index++) - _operationCapability(values[index], '$path[$index]'), - ]); -} - -CatalogFrame _decodeCatalog(Map raw) { - final values = _list(raw['items'], 'items'); - final items = []; - for (var index = 0; index < values.length; index++) { - items.add(_catalogItem(values[index], 'items[$index]')); - } - return CatalogFrame( - hostId: _id(raw['hostId'], 'hostId'), - revision: _id(raw['revision'], 'revision'), - items: List.unmodifiable(items), - operations: _operationCapabilities(raw, 'operations'), - raw: raw, - ); -} - -CatalogItem _catalogItem(Object? value, String path) { - final raw = _map(value, path); - return CatalogItem( - id: _id(raw['id'], '$path.id'), - kind: _enumString(raw['kind'], '$path.kind', const { - 'tool', - 'model', - 'command', - 'setting', - 'skill', - 'agent', - 'provider', - 'mode', - }), - name: _string(raw['name'], '$path.name', 256), - description: raw.containsKey('description') - ? _boundedText(raw['description'], '$path.description', 4096) - : null, - capabilities: raw.containsKey('capabilities') - ? _stringList(raw['capabilities'], '$path.capabilities', maxItems: 128) - : null, - supported: raw.containsKey('supported') - ? _bool(raw['supported'], '$path.supported') - : null, - reason: raw.containsKey('reason') - ? _boundedText(raw['reason'], '$path.reason', 2048) - : null, - metadata: raw.containsKey('metadata') - ? _map(raw['metadata'], '$path.metadata') - : null, - raw: raw, - ); -} - -SettingsFrame _decodeSettings(Map raw) => SettingsFrame( - hostId: _id(raw['hostId'], 'hostId'), - revision: _id(raw['revision'], 'revision'), - settings: _map(raw['settings'], 'settings'), - raw: raw, -); - -CatalogResult _decodeCatalogResult(Map raw) { - final values = _list(raw['items'], 'result.items'); - final items = []; - for (var index = 0; index < values.length; index++) { - items.add(_catalogItem(values[index], 'result.items[$index]')); - } - return CatalogResult( - revision: _id(raw['revision'], 'result.revision'), - items: List.unmodifiable(items), - operations: _operationCapabilities(raw, 'result.operations'), - ); -} - -SettingsResult _decodeSettingsResult(Map raw) => - SettingsResult( - revision: _id(raw['revision'], 'result.revision'), - settings: _map(raw['settings'], 'result.settings'), - ); - -TranscriptSearchResult _decodeTranscriptSearchResult(Map raw) { - final values = _list(raw['items'], 'result.items', 50); - final items = []; - for (var index = 0; index < values.length; index++) { - final path = 'result.items[$index]'; - final item = _map(values[index], path); - final snippet = _boundedText(item['snippet'], '$path.snippet', 768); - final rawHighlights = _list(item['highlights'], '$path.highlights', 32); - final highlights = []; - for ( - var highlightIndex = 0; - highlightIndex < rawHighlights.length; - highlightIndex++ - ) { - final highlightPath = '$path.highlights[$highlightIndex]'; - final highlight = _map(rawHighlights[highlightIndex], highlightPath); - final start = _safeInteger(highlight['start'], '$highlightPath.start'); - final end = _safeInteger(highlight['end'], '$highlightPath.end'); - if (start >= end || end > snippet.length) { - throw WireFormatException( - 'highlight must be a non-empty range within the snippet', - highlightPath, - ); - } - highlights.add(TranscriptSearchHighlight(start: start, end: end)); - } - highlights.sort((left, right) { - final start = left.start.compareTo(right.start); - return start != 0 ? start : left.end.compareTo(right.end); - }); - items.add( - TranscriptSearchItem( - sessionId: _id(item['sessionId'], '$path.sessionId'), - projectId: _id(item['projectId'], '$path.projectId'), - sessionTitle: _boundedText( - item['sessionTitle'], - '$path.sessionTitle', - 512, - ), - archivedAt: item.containsKey('archivedAt') - ? _string(item['archivedAt'], '$path.archivedAt', 128) - : null, - anchorId: _id(item['anchorId'], '$path.anchorId'), - role: _transcriptSearchRole(item['role'], '$path.role'), - timestamp: _string(item['timestamp'], '$path.timestamp', 128), - snippet: snippet, - highlights: List.unmodifiable(highlights), - ), - ); - } - final index = _map(raw['index'], 'result.index'); - final indexedSessions = _safeInteger( - index['indexedSessions'], - 'result.index.indexedSessions', - ); - final knownSessions = _safeInteger( - index['knownSessions'], - 'result.index.knownSessions', - ); - if (indexedSessions > knownSessions) { - throw const WireFormatException( - 'indexedSessions must not exceed knownSessions', - 'result.index.indexedSessions', - ); - } - final state = _enumString(index['state'], 'result.index.state', const { - 'building', - 'ready', - 'stale', - }); - return TranscriptSearchResult( - items: List.unmodifiable(items), - nextCursor: raw.containsKey('nextCursor') - ? _string(raw['nextCursor'], 'result.nextCursor', 2048) - : null, - incomplete: _bool(raw['incomplete'], 'result.incomplete'), - index: TranscriptSearchIndexStatus( - state: TranscriptSearchIndexState.values.byName(state), - indexedSessions: indexedSessions, - knownSessions: knownSessions, - generation: _string(index['generation'], 'result.index.generation', 128), - ), - ); -} - -TranscriptPageResult _decodeTranscriptPageResult(Map raw) { - _onlyKeys(raw, 'result', const { - 'entries', - 'nextCursor', - 'hasMore', - 'generation', - }); - final values = _list(raw['entries'], 'result.entries', 128); - final entries = []; - for (var index = 0; index < values.length; index++) { - final entry = _durableEntry(values[index], 'result.entries[$index]'); - if (DateTime.tryParse(entry.timestamp) == null) { - throw WireFormatException( - 'entry timestamp must be ISO-compatible', - 'result.entries[$index].timestamp', - ); - } - entries.add(entry); - } - final hasMore = _bool(raw['hasMore'], 'result.hasMore'); - final nextCursor = raw.containsKey('nextCursor') - ? _string(raw['nextCursor'], 'result.nextCursor', 2048) - : null; - if (hasMore != (nextCursor != null)) { - throw const WireFormatException( - 'nextCursor must be present exactly when hasMore is true', - 'result.nextCursor', - ); - } - if (!_utf8LengthAtMost(jsonEncode(raw), 512 * 1024)) { - throw const WireFormatException( - 'transcript page result exceeds its wire budget', - 'result', - ); - } - return TranscriptPageResult( - entries: List.unmodifiable(entries), - nextCursor: nextCursor, - hasMore: hasMore, - generation: _string(raw['generation'], 'result.generation', 128), - ); -} - -TranscriptContextResult _decodeTranscriptContextResult( - Map raw, -) { - final anchorId = _id(raw['anchorId'], 'result.anchorId'); - final values = _list(raw['rows'], 'result.rows', 41); - final rows = []; - for (var index = 0; index < values.length; index++) { - final path = 'result.rows[$index]'; - final row = _map(values[index], path); - rows.add( - TranscriptContextRow( - anchorId: _id(row['anchorId'], '$path.anchorId'), - role: _transcriptSearchRole(row['role'], '$path.role'), - timestamp: _string(row['timestamp'], '$path.timestamp', 128), - text: _boundedText(row['text'], '$path.text', 16384), - ), - ); - } - final anchorIndex = _safeInteger(raw['anchorIndex'], 'result.anchorIndex'); - if (anchorIndex >= rows.length || rows[anchorIndex].anchorId != anchorId) { - throw const WireFormatException( - 'anchorIndex must identify the matching anchor row', - 'result.anchorIndex', - ); - } - return TranscriptContextResult( - anchorId: anchorId, - rows: List.unmodifiable(rows), - anchorIndex: anchorIndex, - hasBefore: _bool(raw['hasBefore'], 'result.hasBefore'), - hasAfter: _bool(raw['hasAfter'], 'result.hasAfter'), - generation: _string(raw['generation'], 'result.generation', 128), - ); -} - -TranscriptSearchRole _transcriptSearchRole(Object? value, String path) { - final role = _enumString(value, path, const {'user', 'assistant', 'summary'}); - return TranscriptSearchRole.values.byName(role); -} - -PreviewFrame _decodePreview(Map raw, String type) { - final hostId = _id(raw['hostId'], 'hostId'); - final sessionId = _id(raw['sessionId'], 'sessionId'); - if (type == 'preview.error') { - return PreviewFrame( - frameType: type, - hostId: hostId, - sessionId: sessionId, - snapshot: null, - previewId: _id(raw['previewId'], 'previewId'), - cursor: _transcriptCursor(raw['cursor'], 'cursor'), - revision: _id(raw['revision'], 'revision'), - code: _string(raw['code'], 'code', 128), - message: _boundedText(raw['message'], 'message', 2048), - error: null, - raw: raw, - ); - } - final snapshot = _previewSnapshot(raw); - if (type == 'preview.capture' && snapshot.capture == null) { - throw const WireFormatException( - 'preview capture frame requires capture metadata', - 'capture', - ); - } - return PreviewFrame( - frameType: type, - hostId: hostId, - sessionId: sessionId, - snapshot: snapshot, - previewId: snapshot.previewId, - cursor: snapshot.cursor, - revision: snapshot.revision, - code: null, - message: null, - error: type == 'preview.state' && raw.containsKey('error') - ? _boundedText(raw['error'], 'error', 2048) - : null, - raw: raw, - ); -} - -UsageReadResult _decodeUsageReadResult(Map raw) { - _onlyKeys(raw, 'result', const { - 'generatedAt', - 'reports', - 'accountsWithoutUsage', - 'capacity', - }); - if (utf8.encode(jsonEncode(raw)).length > 512 * 1024) { - throw const WireFormatException( - 'usage result exceeds its wire budget', - 'result', - ); - } - final reports = []; - final reportValues = _list(raw['reports'], 'result.reports', 64); - for (var index = 0; index < reportValues.length; index++) { - reports.add( - _decodeUsageReport(reportValues[index], 'result.reports[$index]'), - ); - } - final accounts = []; - final accountValues = _list( - raw['accountsWithoutUsage'], - 'result.accountsWithoutUsage', - 128, - ); - for (var index = 0; index < accountValues.length; index++) { - accounts.add( - _decodeUsageAccount( - accountValues[index], - 'result.accountsWithoutUsage[$index]', - ), - ); - } - final rawCapacity = _map(raw['capacity'], 'result.capacity'); - if (rawCapacity.length > 64) { - throw const WireFormatException( - 'usage capacity has too many providers', - 'result.capacity', - ); - } - final capacity = >{}; - for (final entry in rawCapacity.entries) { - _usageText(entry.key, 'result.capacity.${entry.key}', 128); - final windows = []; - final values = _list(entry.value, 'result.capacity.${entry.key}', 32); - for (var index = 0; index < values.length; index++) { - windows.add( - _decodeUsageCapacityWindow( - values[index], - 'result.capacity.${entry.key}[$index]', - ), - ); - } - capacity[entry.key] = List.unmodifiable(windows); - } - return UsageReadResult( - generatedAt: _usageInteger( - raw['generatedAt'], - 'result.generatedAt', - 8640000000000000, - ), - reports: List.unmodifiable(reports), - accountsWithoutUsage: List.unmodifiable( - accounts, - ), - capacity: Map>.unmodifiable(capacity), - ); -} - -UsageReport _decodeUsageReport(Object? value, String path) { - final raw = _map(value, path); - _onlyKeys(raw, path, const { - 'provider', - 'fetchedAt', - 'limits', - 'resetCredits', - 'notes', - 'metadata', - }); - final provider = _usageText(raw['provider'], '$path.provider', 128); - final limits = []; - final ids = {}; - final values = _list(raw['limits'], '$path.limits', 32); - for (var index = 0; index < values.length; index++) { - final limit = _decodeUsageLimit( - values[index], - '$path.limits[$index]', - provider, - ); - if (!ids.add(limit.id)) { - throw WireFormatException( - 'duplicate usage limit id', - '$path.limits[$index].id', - ); - } - limits.add(limit); - } - int? availableResetCredits; - if (raw.containsKey('resetCredits')) { - final credits = _map(raw['resetCredits'], '$path.resetCredits'); - _onlyKeys(credits, '$path.resetCredits', const { - 'availableCount', - 'credits', - }); - availableResetCredits = _usageInteger( - credits['availableCount'], - '$path.resetCredits.availableCount', - 64, - ); - if (credits.containsKey('credits')) { - final creditValues = _list( - credits['credits'], - '$path.resetCredits.credits', - 64, - ); - for (var index = 0; index < creditValues.length; index++) { - final creditPath = '$path.resetCredits.credits[$index]'; - final credit = _map(creditValues[index], creditPath); - _onlyKeys(credit, creditPath, const { - 'grantedAt', - 'expiresAt', - 'status', - }); - for (final field in const ['grantedAt', 'expiresAt']) { - if (credit.containsKey(field)) { - final timestamp = _usageText( - credit[field], - '$creditPath.$field', - 64, - ); - if (DateTime.tryParse(timestamp) == null) { - throw WireFormatException( - 'expected ISO timestamp', - '$creditPath.$field', - ); - } - } - } - if (credit.containsKey('status')) { - _usageText(credit['status'], '$creditPath.status', 64); - } - } - } - } - final metadata = raw.containsKey('metadata') - ? _decodeUsageMetadata(raw['metadata'], '$path.metadata') - : const {}; - return UsageReport( - provider: provider, - fetchedAt: _usageInteger( - raw['fetchedAt'], - '$path.fetchedAt', - 8640000000000000, - ), - limits: List.unmodifiable(limits), - availableResetCredits: availableResetCredits, - notes: raw.containsKey('notes') - ? _usageNotes(raw['notes'], '$path.notes', 8) - : const [], - metadata: metadata, - ); -} - -UsageLimit _decodeUsageLimit(Object? value, String path, String provider) { - final raw = _map(value, path); - _onlyKeys(raw, path, const { - 'id', - 'label', - 'scope', - 'window', - 'amount', - 'status', - 'notes', - }); - final scope = _decodeUsageScope(raw['scope'], '$path.scope'); - if (scope.provider != provider) { - throw WireFormatException( - 'usage limit provider does not match its report', - '$path.scope.provider', - ); - } - return UsageLimit( - id: _usageText(raw['id'], '$path.id', 256), - label: _usageText(raw['label'], '$path.label', 512), - scope: scope, - window: raw.containsKey('window') - ? _decodeUsageWindow(raw['window'], '$path.window') - : null, - amount: _decodeUsageAmount(raw['amount'], '$path.amount'), - status: raw.containsKey('status') - ? UsageStatus.values.byName( - _enumString(raw['status'], '$path.status', const { - 'ok', - 'warning', - 'exhausted', - 'unknown', - }), - ) - : null, - notes: raw.containsKey('notes') - ? _usageNotes(raw['notes'], '$path.notes', 8) - : const [], - ); -} - -UsageScope _decodeUsageScope(Object? value, String path) { - final raw = _map(value, path); - _onlyKeys(raw, path, const { - 'provider', - 'accountId', - 'projectId', - 'orgId', - 'modelId', - 'tier', - 'windowId', - 'shared', - }); - String? optional(String field, [int max = 512]) => raw.containsKey(field) - ? _usageText(raw[field], '$path.$field', max) - : null; - return UsageScope( - provider: _usageText(raw['provider'], '$path.provider', 128), - accountId: optional('accountId'), - projectId: optional('projectId'), - orgId: optional('orgId'), - modelId: optional('modelId'), - tier: optional('tier', 256), - windowId: optional('windowId', 256), - shared: raw.containsKey('shared') - ? _bool(raw['shared'], '$path.shared') - : null, - ); -} - -UsageWindow _decodeUsageWindow(Object? value, String path) { - final raw = _map(value, path); - _onlyKeys(raw, path, const {'id', 'label', 'durationMs', 'resetsAt'}); - return UsageWindow( - id: _usageText(raw['id'], '$path.id', 256), - label: _usageText(raw['label'], '$path.label', 512), - durationMs: raw.containsKey('durationMs') - ? _usageInteger(raw['durationMs'], '$path.durationMs', 315576000000) - : null, - resetsAt: raw.containsKey('resetsAt') - ? _usageInteger(raw['resetsAt'], '$path.resetsAt', 8640000000000000) - : null, - ); -} - -UsageAmount _decodeUsageAmount(Object? value, String path) { - final raw = _map(value, path); - _onlyKeys(raw, path, const { - 'used', - 'limit', - 'remaining', - 'usedFraction', - 'remainingFraction', - 'unit', - }); - double? amount(String field) => - raw.containsKey(field) ? _usageNumber(raw[field], '$path.$field') : null; - return UsageAmount( - unit: UsageUnit.values.byName( - _enumString(raw['unit'], '$path.unit', const { - 'percent', - 'tokens', - 'requests', - 'usd', - 'minutes', - 'bytes', - 'unknown', - }), - ), - used: amount('used'), - limit: amount('limit'), - remaining: amount('remaining'), - usedFraction: amount('usedFraction'), - remainingFraction: amount('remainingFraction'), - ); -} - -UsageAccountWithoutReport _decodeUsageAccount(Object? value, String path) { - final raw = _map(value, path); - _onlyKeys(raw, path, const { - 'provider', - 'type', - 'email', - 'accountId', - 'projectId', - 'enterpriseUrl', - 'orgId', - 'orgName', - }); - String? optional(String field, [int max = 512]) => raw.containsKey(field) - ? _usageText(raw[field], '$path.$field', max) - : null; - final enterpriseUrl = optional('enterpriseUrl', 2048); - if (enterpriseUrl != null) { - _safeHttpEndpoint(enterpriseUrl, '$path.enterpriseUrl'); - } - final type = _enumString(raw['type'], '$path.type', const { - 'api_key', - 'oauth', - }); - return UsageAccountWithoutReport( - provider: _usageText(raw['provider'], '$path.provider', 128), - type: type == 'api_key' ? UsageAccountType.apiKey : UsageAccountType.oauth, - email: optional('email'), - accountId: optional('accountId'), - projectId: optional('projectId'), - enterpriseUrl: enterpriseUrl, - orgId: optional('orgId'), - orgName: optional('orgName'), - ); -} - -UsageCapacityWindow _decodeUsageCapacityWindow(Object? value, String path) { - final raw = _map(value, path); - _onlyKeys(raw, path, const { - 'window', - 'durationMs', - 'accounts', - 'usedAccounts', - 'remainingAccounts', - }); - final accounts = _usageInteger(raw['accounts'], '$path.accounts', 2048); - return UsageCapacityWindow( - window: _usageText(raw['window'], '$path.window', 256), - durationMs: raw.containsKey('durationMs') - ? _usageInteger(raw['durationMs'], '$path.durationMs', 315576000000) - : null, - accounts: accounts, - usedAccounts: _usageBoundedNumber( - raw['usedAccounts'], - '$path.usedAccounts', - 0, - accounts.toDouble(), - ), - remainingAccounts: _usageBoundedNumber( - raw['remainingAccounts'], - '$path.remainingAccounts', - 0, - accounts.toDouble(), - ), - ); -} - -Map _decodeUsageMetadata(Object? value, String path) { - final raw = _map(value, path); - const stringKeys = { - 'email', - 'accountId', - 'projectId', - 'orgId', - 'orgName', - 'planType', - 'plan', - 'currentTierId', - 'currentTierName', - 'source', - 'period', - 'quotaResetDate', - }; - const booleanKeys = {'allowed', 'limitReached'}; - _onlyKeys(raw, path, const {...stringKeys, ...booleanKeys}); - final result = {}; - for (final entry in raw.entries) { - result[entry.key] = stringKeys.contains(entry.key) - ? _usageText( - entry.value, - '$path.${entry.key}', - entry.key == 'email' ? 512 : 256, - ) - : _bool(entry.value, '$path.${entry.key}'); - } - return Map.unmodifiable(result); -} - -BrokerStatusResult _decodeBrokerStatusResult(Map raw) { - final state = _enumString(raw['state'], 'result.state', const { - 'local', - 'connected', - 'missing-token', - 'unreachable', - }); - final endpointRequired = state == 'connected' || state == 'unreachable'; - _onlyKeys( - raw, - 'result', - state == 'local' - ? const {'state', 'generation'} - : const {'state', 'endpoint', 'generation'}, - ); - final endpoint = raw.containsKey('endpoint') - ? _safeHttpEndpoint( - _usageText(raw['endpoint'], 'result.endpoint', 2048), - 'result.endpoint', - ) - : null; - if (endpointRequired && endpoint == null) { - throw const WireFormatException( - 'broker endpoint is required', - 'result.endpoint', - ); - } - return BrokerStatusResult( - state: switch (state) { - 'local' => BrokerState.local, - 'connected' => BrokerState.connected, - 'missing-token' => BrokerState.missingToken, - _ => BrokerState.unreachable, - }, - generation: _usageInteger( - raw['generation'], - 'result.generation', - 2147483647, - ), - endpoint: endpoint, - ); -} - -String _safeHttpEndpoint(String value, String path) { - final uri = Uri.tryParse(value); - if (uri == null || - !uri.hasAuthority || - uri.host.isEmpty || - (uri.scheme != 'http' && uri.scheme != 'https') || - uri.userInfo.isNotEmpty || - uri.hasQuery || - uri.hasFragment) { - throw WireFormatException( - 'endpoint must be an HTTP(S) URL without credentials or parameters', - path, - ); - } - return uri.toString(); -} - -List _usageNotes(Object? value, String path, int max) { - final values = _list(value, path, max); - return List.unmodifiable( - List.generate( - values.length, - (index) => _usageText(values[index], '$path[$index]', 1024), - growable: false, - ), - ); -} - -String _usageText(Object? value, String path, int maxBytes) { - if (value is! String || - !_utf8LengthAtMost(value, maxBytes) || - _hasControlCharacter(value)) { - throw WireFormatException('expected bounded control-free text', path); - } - return value; -} - -double _usageBoundedNumber( - Object? value, - String path, - double minimum, - double maximum, -) { - final result = _usageNumber(value, path); - if (result < minimum || result > maximum) { - throw WireFormatException( - 'usage number is outside its allowed range', - path, - ); - } - return result; -} - -double _usageNumber(Object? value, String path) { - if (value is! num || - !value.isFinite || - value < -1000000000000000 || - value > 1000000000000000) { - throw WireFormatException( - 'usage number is outside its allowed range', - path, - ); - } - return value.toDouble(); -} - -int _usageInteger(Object? value, String path, int max) { - final result = _safeInteger(value, path); - if (result > max) throw WireFormatException('value exceeds limit', path); - return result; -} - -void _onlyKeys(Map value, String path, Set allowed) { - for (final key in value.keys) { - if (!allowed.contains(key)) { - throw WireFormatException('unknown field', '$path.$key'); - } - } -} - -PreviewSnapshot _previewSnapshot(Map raw) { - final url = _string(raw['url'], 'preview.url', 4096); - final uri = Uri.tryParse(url); - if (uri == null || - !uri.hasScheme || - (uri.scheme != 'http' && uri.scheme != 'https') || - uri.userInfo.isNotEmpty) { - throw const WireFormatException( - 'preview URL must be http(s) without credentials', - 'preview.url', - ); - } - Map? viewport; - if (raw.containsKey('viewport')) { - viewport = _map(raw['viewport'], 'preview.viewport'); - final width = _safeInteger(viewport['width'], 'preview.viewport.width'); - final height = _safeInteger(viewport['height'], 'preview.viewport.height'); - if (width == 0 || height == 0 || width * height > 16 * 1024 * 1024) { - throw const WireFormatException( - 'preview viewport dimensions exceed limit', - 'preview.viewport', - ); - } - if (viewport.containsKey('deviceScaleFactor')) { - final scale = _finiteNumber( - viewport['deviceScaleFactor'], - 'preview.viewport.deviceScaleFactor', - ); - if (scale <= 0 || scale > 8) { - throw const WireFormatException( - 'preview device scale factor exceeds limit', - 'preview.viewport.deviceScaleFactor', - ); - } - } - } - final capture = raw.containsKey('capture') - ? _previewCapture(raw['capture'], 'preview.capture') - : null; - final authority = raw.containsKey('authority') - ? _previewAuthority(raw['authority'], 'preview.authority') - : null; - List? actions; - if (raw.containsKey('availableActions')) { - actions = _stringList( - raw['availableActions'], - 'preview.availableActions', - maxItems: 15, - ); - const allowed = { - 'activate', - 'navigate', - 'back', - 'forward', - 'reload', - 'close', - 'capture', - 'click', - 'fill', - 'type', - 'press', - 'scroll', - 'select', - 'upload', - 'handoff', - }; - if (actions.any((action) => !allowed.contains(action)) || - actions.toSet().length != actions.length) { - throw const WireFormatException( - 'preview actions must be known and unique', - 'preview.availableActions', - ); - } - } - return PreviewSnapshot( - previewId: _id(raw['previewId'], 'preview.previewId'), - state: _enumString(raw['state'], 'preview.state', const { - 'launching', - 'ready', - 'running', - 'stopped', - 'failed', - }), - url: url, - revision: _id(raw['revision'], 'preview.revision'), - cursor: _transcriptCursor(raw['cursor'], 'preview.cursor'), - title: raw.containsKey('title') - ? _boundedText(raw['title'], 'preview.title', 512) - : null, - canGoBack: raw.containsKey('canGoBack') - ? _bool(raw['canGoBack'], 'preview.canGoBack') - : null, - canGoForward: raw.containsKey('canGoForward') - ? _bool(raw['canGoForward'], 'preview.canGoForward') - : null, - viewport: viewport, - capture: capture, - authority: authority, - availableActions: actions, - ); -} - -Map _previewCapture(Object? value, String path) { - final raw = _map(value, path); - _id(raw['captureId'], '$path.captureId'); - _enumString(raw['mimeType'], '$path.mimeType', const { - 'image/png', - 'image/jpeg', - 'image/webp', - }); - final size = _safeInteger(raw['size'], '$path.size'); - final width = _safeInteger(raw['width'], '$path.width'); - final height = _safeInteger(raw['height'], '$path.height'); - if (size == 0 || size > 8 * 1024 * 1024) { - throw WireFormatException( - 'preview capture size exceeds limit', - '$path.size', - ); - } - if (width == 0 || height == 0 || width * height > 16 * 1024 * 1024) { - throw WireFormatException('preview capture dimensions exceed limit', path); - } - _safeInteger(raw['capturedAt'], '$path.capturedAt'); - final digest = _string(raw['sha256'], '$path.sha256', 64); - if (!RegExp(r'^[0-9a-f]{64}$').hasMatch(digest)) { - throw WireFormatException( - 'preview capture digest must be lowercase sha256', - '$path.sha256', - ); - } - return raw; -} - -Map _previewAuthority(Object? value, String path) { - final raw = _map(value, path); - _string(raw['id'], '$path.id', 128); - _boundedText(raw['label'], '$path.label', 256); - _enumString(raw['kind'], '$path.kind', const { - 'isolated-session', - 'authenticated-profile', - }); - _bool(raw['requiresExplicitOptIn'], '$path.requiresExplicitOptIn'); - return raw; -} - -PongFrame _decodePong(Map raw) => PongFrame( - nonce: _string(raw['nonce'], 'nonce', 128), - timestamp: _string(raw['timestamp'], 'timestamp', 128), - raw: raw, -); - -TranscriptCursor _transcriptCursor(Object? value, String path) { - final cursor = _map(value, path); - return TranscriptCursor( - epoch: _string(cursor['epoch'], '$path.epoch', 128), - seq: _safeInteger(cursor['seq'], '$path.seq'), - ); -} - -SessionIndexCursor _sessionIndexCursor(Object? value, String path) { - final cursor = _map(value, path); - return SessionIndexCursor( - epoch: _string(cursor['epoch'], '$path.epoch', 128), - seq: _safeInteger(cursor['seq'], '$path.seq'), - ); -} - -void _exactVersion(Map raw) { - if (raw['v'] != ompAppProtocolVersion) { - throw const WireFormatException( - 'protocol version must be exactly omp-app/1', - 'v', - ); - } -} - -Map _map(Object? value, String path) { - if (value is! Map) { - throw WireFormatException('expected object', path); - } - return value; -} - -List _list(Object? value, String path, [int max = _maxArrayItems]) { - if (value is! List || value.length > max) { - throw WireFormatException('expected array with at most $max items', path); - } - return value; -} - -List _stringList( - Object? value, - String path, { - int maxItems = _maxArrayItems, -}) { - final values = _list(value, path, maxItems); - for (var index = 0; index < values.length; index++) { - _string(values[index], '$path[$index]', 256); - } - return values.cast(); -} - -const Set _deviceCapabilities = { - 'sessions.read', - 'sessions.prompt', - 'sessions.control', - 'sessions.manage', - 'bash.run', - 'term.open', - 'term.input', - 'term.resize', - 'files.read', - 'files.write', - 'files.list', - 'files.diff', - 'agents.control', - 'audit.read', - 'config.read', - 'catalog.read', - 'config.write', - 'broker.read', - 'usage.read', - 'preview.read', - 'preview.control', - 'preview.input', -}; - -List _capabilityList(Object? value, String path) { - final capabilities = _stringList(value, path, maxItems: 128); - for (var index = 0; index < capabilities.length; index++) { - if (!_deviceCapabilities.contains(capabilities[index])) { - throw WireFormatException('unknown device capability', '$path[$index]'); - } - } - return capabilities; -} - -String _enumString(Object? value, String path, Set allowed) { - final result = _string(value, path, 128); - if (!allowed.contains(result)) { - throw WireFormatException('unknown discriminant $result', path); - } - return result; -} - -String _boundedText(Object? value, String path, int maxBytes) { - if (value is! String || !_utf8LengthAtMost(value, maxBytes)) { - throw WireFormatException('expected bounded text', path); - } - return value; -} - -double _finiteNumber(Object? value, String path) { - if (value is! num || !value.isFinite) { - throw WireFormatException('expected finite number', path); - } - return value.toDouble(); -} - -int _signedSafeInteger(Object? value, String path) { - if (value is! num || - !value.isFinite || - value.abs() > _maxSafeInteger || - value.truncateToDouble() != value) { - throw WireFormatException('expected a safe integer', path); - } - return value.toInt(); -} - -String _base64(Object? value, String path, int maxDecodedBytes) { - final text = _boundedText( - value, - path, - ((maxDecodedBytes * 4) / 3).ceil() + 4, - ); - final valid = RegExp( - r'^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$', - ); - if (text.length % 4 != 0 || !valid.hasMatch(text)) { - throw WireFormatException('invalid base64 payload', path); - } - late final List decoded; - try { - decoded = base64.decode(text); - } on FormatException { - throw WireFormatException('invalid base64 payload', path); - } - if (decoded.length > maxDecodedBytes) { - throw WireFormatException('decoded payload exceeds protocol limit', path); - } - return text; -} - -String _safeRelativePath(Object? value, String path) { - final result = _string(value, path, 4096); - if (result.contains(r'\') || - result.startsWith('/') || - RegExp(r'^[A-Za-z]:').hasMatch(result) || - result.startsWith('~')) { - throw WireFormatException('path must be a safe relative POSIX path', path); - } - final parts = result.split('/'); - if (parts.any((part) => part.isEmpty || part == '.' || part == '..')) { - throw WireFormatException('path contains an unsafe segment', path); - } - return result; -} - -String _id(Object? value, String path) => _string(value, path, 256); - -String _string(Object? value, String path, int maxBytes) { - if (value is! String || - value.isEmpty || - !_utf8LengthAtMost(value, maxBytes) || - _hasControlCharacter(value)) { - throw WireFormatException('expected bounded non-empty string', path); - } - return value; -} - -String _nonemptyText(Object? value, String path, int maxBytes) { - if (value is! String || - value.isEmpty || - !_utf8LengthAtMost(value, maxBytes)) { - throw WireFormatException('expected bounded non-empty text', path); - } - return value; -} - -bool _bool(Object? value, String path) { - if (value is! bool) { - throw WireFormatException('expected boolean', path); - } - return value; -} - -int _safeInteger(Object? value, String path) { - if (value is! num || - !value.isFinite || - value < 0 || - value > _maxSafeInteger || - value.truncateToDouble() != value) { - throw WireFormatException('expected a safe nonnegative integer', path); - } - return value.toInt(); -} - -bool _hasControlCharacter(String value) { - for (final codeUnit in value.codeUnits) { - if (codeUnit <= 0x1f || codeUnit == 0x7f) { - return true; - } - } - return false; -} - -bool _utf8LengthAtMost(String value, int maximum) { - var bytes = 0; - for (var index = 0; index < value.length; index++) { - final codeUnit = value.codeUnitAt(index); - if (codeUnit <= 0x7f) { - bytes++; - } else if (codeUnit <= 0x7ff) { - bytes += 2; - } else if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { - if (index + 1 < value.length) { - final next = value.codeUnitAt(index + 1); - if (next >= 0xdc00 && next <= 0xdfff) { - bytes += 4; - index++; - } else { - bytes += 3; - } - } else { - bytes += 3; - } - } else { - bytes += 3; - } - if (bytes > maximum) { - return false; - } - } - return true; -} - -final class _JsonBudget { - int nodes = 0; -} - -Object? _freezeJson(Object? value, _JsonBudget budget, int depth) { - budget.nodes++; - if (budget.nodes > _maxJsonNodes) { - throw const WireFormatException('JSON value has too many nodes'); - } - if (depth > _maxJsonDepth) { - throw const WireFormatException('JSON value is nested too deeply'); - } - if (value is Map) { - if (value.length > _maxMapKeys) { - throw const WireFormatException('JSON object has too many keys'); - } - value.updateAll((_, child) => _freezeJson(child, budget, depth + 1)); - return UnmodifiableMapView(value); - } - if (value is List) { - if (value.length > _maxArrayItems) { - throw const WireFormatException('JSON array has too many items'); - } - for (var index = 0; index < value.length; index++) { - value[index] = _freezeJson(value[index], budget, depth + 1); - } - return UnmodifiableListView(value); - } - return value; -} - -Object? _freezeJsonCopy(Object? value, _JsonBudget budget, int depth) { - budget.nodes++; - if (budget.nodes > _maxJsonNodes) { - throw const WireFormatException('JSON value has too many nodes'); - } - if (depth > _maxJsonDepth) { - throw const WireFormatException('JSON value is nested too deeply'); - } - if (value is Map) { - if (value.length > _maxMapKeys) { - throw const WireFormatException('JSON object has too many keys'); - } - final copy = {}; - for (final entry in value.entries) { - copy[entry.key] = _freezeJsonCopy(entry.value, budget, depth + 1); - } - return UnmodifiableMapView(copy); - } - if (value is List) { - if (value.length > _maxArrayItems) { - throw const WireFormatException('JSON array has too many items'); - } - return UnmodifiableListView( - value - .map((child) => _freezeJsonCopy(child, budget, depth + 1)) - .toList(growable: false), - ); - } - if (value == null || value is bool || value is String) { - return value; - } - if (value is num && value.isFinite) { - return value; - } - throw const WireFormatException('expected JSON-compatible value'); -} diff --git a/apps/flutter/lib/src/protocol/wire_encoder.dart b/apps/flutter/lib/src/protocol/wire_encoder.dart deleted file mode 100644 index 740026cb..00000000 --- a/apps/flutter/lib/src/protocol/wire_encoder.dart +++ /dev/null @@ -1,681 +0,0 @@ -import 'dart:convert'; - -import 'models.dart'; - -const int _maxSafeInteger = 9007199254740991; -const int _maxSavedCursors = 128; - -/// Explicit builders for every outbound omp-app/1 top-level frame family. -abstract final class WireEncoder { - static String hello({ - required ClientIdentity client, - Iterable requestedFeatures = const [], - Iterable savedCursors = const [], - Iterable capabilities = const [], - DeviceAuthentication? authentication, - }) { - final features = requestedFeatures.toList(growable: false); - final cursors = savedCursors.toList(growable: false); - final requestedCapabilities = capabilities.toList(growable: false); - _boundedStrings(features, 'requestedFeatures', 128); - _capabilityStrings(requestedCapabilities, 'capabilities'); - if (cursors.length > _maxSavedCursors) { - throw ArgumentError.value( - cursors.length, - 'savedCursors', - 'must contain at most $_maxSavedCursors cursors', - ); - } - - final clientJson = { - 'name': _controlString(client.name, 'client.name', 128), - 'version': _controlString(client.version, 'client.version', 64), - 'build': _controlString(client.build, 'client.build', 128), - 'platform': _controlString(client.platform, 'client.platform', 128), - }; - final cursorJson = >[]; - for (var index = 0; index < cursors.length; index++) { - final saved = cursors[index]; - cursorJson.add({ - 'hostId': _id(saved.hostId, 'savedCursors[$index].hostId'), - 'sessionId': _id(saved.sessionId, 'savedCursors[$index].sessionId'), - 'cursor': _cursorJson(saved.cursor, 'savedCursors[$index].cursor'), - }); - } - - final frame = { - 'v': ompAppProtocolVersion, - 'type': 'hello', - 'protocol': { - 'min': ompAppProtocolVersion, - 'max': ompAppProtocolVersion, - }, - 'client': clientJson, - 'requestedFeatures': features, - 'savedCursors': cursorJson, - if (requestedCapabilities.isNotEmpty) - 'capabilities': {'client': requestedCapabilities}, - if (authentication != null) - 'authentication': { - 'deviceId': _id(authentication.deviceId, 'authentication.deviceId'), - 'deviceToken': _deviceToken(authentication.deviceToken), - }, - }; - return jsonEncode(frame); - } - - static String sessionList({ - required String requestId, - required String commandId, - required String hostId, - }) { - return command( - requestId: requestId, - commandId: commandId, - hostId: hostId, - command: 'session.list', - args: const {}, - ); - } - - static String hostWatch({ - required String requestId, - required String commandId, - required String hostId, - required SessionIndexCursor cursor, - }) { - return command( - requestId: requestId, - commandId: commandId, - hostId: hostId, - command: 'host.watch', - args: { - 'cursor': _sessionIndexCursorJson(cursor, 'cursor'), - }, - ); - } - - static String sessionAttach({ - required String requestId, - required String commandId, - required String hostId, - required String sessionId, - TranscriptCursor? cursor, - }) { - return command( - requestId: requestId, - commandId: commandId, - hostId: hostId, - sessionId: sessionId, - command: 'session.attach', - args: { - if (cursor != null) 'cursor': _cursorJson(cursor, 'cursor'), - }, - ); - } - - static String sessionPrompt({ - required String requestId, - required String commandId, - required String hostId, - required String sessionId, - required String expectedRevision, - required String text, - List imageIds = const [], - }) { - if (imageIds.length > 8) { - throw ArgumentError.value(imageIds.length, 'imageIds', 'maximum is 8'); - } - if (text.isEmpty && imageIds.isEmpty) { - throw ArgumentError.value( - text, - 'text', - 'must not be empty without images', - ); - } - return command( - requestId: requestId, - commandId: commandId, - hostId: hostId, - sessionId: sessionId, - command: 'session.prompt', - expectedRevision: _id(expectedRevision, 'expectedRevision'), - args: { - 'message': _boundedText(text, 'text', 65536), - if (imageIds.isNotEmpty) - 'images': >[ - for (final imageId in imageIds) - {'imageId': _id(imageId, 'imageId')}, - ], - }, - ); - } - - /// Encodes the package-level command envelope for every pinned command. - /// - /// The command descriptor's host/session scope, revision policy, and - /// confirmation policy are enforced here. Command-specific argument - /// convenience methods may add narrower validation. - static String command({ - required String requestId, - required String commandId, - required String hostId, - required String command, - required Map args, - String? sessionId, - String? expectedRevision, - String? confirmationId, - }) { - if (!_knownCommands.contains(command)) { - throw ArgumentError.value(command, 'command', 'is not a pinned command'); - } - final hostScoped = _hostCommands.contains(command); - if (hostScoped && sessionId != null) { - throw ArgumentError.value( - sessionId, - 'sessionId', - 'is forbidden for a host command', - ); - } - if (!hostScoped && sessionId == null) { - throw ArgumentError.notNull('sessionId'); - } - if (_noRevisionCommands.contains(command) && expectedRevision != null) { - throw ArgumentError.value( - expectedRevision, - 'expectedRevision', - 'is forbidden for this command', - ); - } - if (_requiredRevisionCommands.contains(command) && - expectedRevision == null) { - throw ArgumentError.notNull('expectedRevision'); - } - if (!_confirmationCommands.contains(command) && confirmationId != null) { - throw ArgumentError.value( - confirmationId, - 'confirmationId', - 'is forbidden for this command', - ); - } - return jsonEncode({ - 'v': ompAppProtocolVersion, - 'type': 'command', - 'requestId': _id(requestId, 'requestId'), - 'commandId': _id(commandId, 'commandId'), - 'hostId': _id(hostId, 'hostId'), - if (sessionId != null) 'sessionId': _id(sessionId, 'sessionId'), - 'command': command, - if (expectedRevision != null) - 'expectedRevision': _id(expectedRevision, 'expectedRevision'), - if (confirmationId != null) - 'confirmationId': _id(confirmationId, 'confirmationId'), - 'args': args, - }); - } - - static String confirm({ - required String requestId, - required String confirmationId, - required String commandId, - required String hostId, - required String decision, - String? sessionId, - }) { - if (decision != 'approve' && decision != 'deny') { - throw ArgumentError.value( - decision, - 'decision', - 'must be approve or deny', - ); - } - return jsonEncode({ - 'v': ompAppProtocolVersion, - 'type': 'confirm', - 'requestId': _id(requestId, 'requestId'), - 'confirmationId': _id(confirmationId, 'confirmationId'), - 'commandId': _id(commandId, 'commandId'), - 'hostId': _id(hostId, 'hostId'), - if (sessionId != null) 'sessionId': _id(sessionId, 'sessionId'), - 'decision': decision, - }); - } - - static String pairStart({ - required String requestId, - required String code, - required String deviceId, - required String deviceName, - required String platform, - Iterable requestedCapabilities = const [], - }) { - if (!RegExp(r'^\d{6}$').hasMatch(code)) { - throw ArgumentError.value(code, 'code', 'must be exactly six digits'); - } - final capabilities = requestedCapabilities.toList(growable: false); - _capabilityStrings(capabilities, 'requestedCapabilities'); - return jsonEncode({ - 'v': ompAppProtocolVersion, - 'type': 'pair.start', - 'requestId': _id(requestId, 'requestId'), - 'code': code, - 'deviceId': _id(deviceId, 'deviceId'), - 'deviceName': _controlString(deviceName, 'deviceName', 256), - 'platform': _controlString(platform, 'platform', 128), - 'requestedCapabilities': capabilities, - }); - } - - static String terminalInput({ - required String hostId, - required String sessionId, - required String terminalId, - required String data, - String? encoding, - }) { - if (encoding != null && encoding != 'utf8' && encoding != 'base64') { - throw ArgumentError.value(encoding, 'encoding', 'must be utf8 or base64'); - } - final payload = encoding == 'base64' - ? _base64(data, 'data', 256000) - : _boundedText(data, 'data', 256000); - return jsonEncode({ - 'v': ompAppProtocolVersion, - 'type': 'terminal.input', - 'hostId': _id(hostId, 'hostId'), - 'sessionId': _id(sessionId, 'sessionId'), - 'terminalId': _id(terminalId, 'terminalId'), - 'data': payload, - 'encoding': ?encoding, - }); - } - - static String terminalResize({ - required String hostId, - required String sessionId, - required String terminalId, - required int cols, - required int rows, - }) { - if (cols < 1 || cols > 1000) { - throw ArgumentError.value(cols, 'cols', 'must be between 1 and 1000'); - } - if (rows < 1 || rows > 500) { - throw ArgumentError.value(rows, 'rows', 'must be between 1 and 500'); - } - return jsonEncode({ - 'v': ompAppProtocolVersion, - 'type': 'terminal.resize', - 'hostId': _id(hostId, 'hostId'), - 'sessionId': _id(sessionId, 'sessionId'), - 'terminalId': _id(terminalId, 'terminalId'), - 'cols': cols, - 'rows': rows, - }); - } - - static String terminalClose({ - required String hostId, - required String sessionId, - required String terminalId, - String? reason, - }) { - return jsonEncode({ - 'v': ompAppProtocolVersion, - 'type': 'terminal.close', - 'hostId': _id(hostId, 'hostId'), - 'sessionId': _id(sessionId, 'sessionId'), - 'terminalId': _id(terminalId, 'terminalId'), - if (reason != null) 'reason': _controlString(reason, 'reason', 256), - }); - } - - static String ping({required String nonce, required String timestamp}) { - return jsonEncode({ - 'v': ompAppProtocolVersion, - 'type': 'ping', - 'nonce': _controlString(nonce, 'nonce', 128), - 'timestamp': _controlString(timestamp, 'timestamp', 128), - }); - } -} - -const Set _knownCommands = { - 'host.list', - 'session.list', - 'transcript.search', - 'transcript.context', - 'transcript.page', - 'session.create', - 'session.attach', - 'session.prompt', - 'session.image.begin', - 'session.image.chunk', - 'session.image.discard', - 'session.image.read', - 'session.state.get', - 'session.steer', - 'session.followUp', - 'session.rename', - 'session.retry', - 'session.compact', - 'session.pause', - 'session.resume', - 'session.archive', - 'session.restore', - 'session.delete', - 'session.model.set', - 'session.thinking.set', - 'session.fast.set', - 'session.ui.respond', - 'session.cancel', - 'session.close', - 'files.read', - 'files.write', - 'files.patch', - 'files.list', - 'files.search', - 'files.diff', - 'review.read', - 'review.apply', - 'agent.cancel', - 'bash.run', - 'term.open', - 'audit.read', - 'audit.tail', - 'config.write', - 'settings.read', - 'settings.write', - 'catalog.get', - 'broker.status', - 'usage.read', - 'host.watch', - 'session.watch', - 'controller.lease.acquire', - 'controller.lease.renew', - 'controller.lease.release', - 'prompt.lease.acquire', - 'prompt.lease.renew', - 'prompt.lease.release', - 'preview.launch', - 'preview.state', - 'preview.activate', - 'preview.navigate', - 'preview.back', - 'preview.forward', - 'preview.reload', - 'preview.close', - 'preview.capture', - 'preview.capture.read', - 'preview.click', - 'preview.fill', - 'preview.scroll', - 'preview.type', - 'preview.select', - 'preview.press', - 'preview.upload', - 'preview.policy.check', - 'preview.lease.acquire', - 'preview.lease.renew', - 'preview.lease.release', - 'preview.handoff', -}; - -const Set _hostCommands = { - 'host.list', - 'session.list', - 'transcript.search', - 'session.create', - 'audit.read', - 'audit.tail', - 'settings.read', - 'catalog.get', - 'broker.status', - 'usage.read', - 'host.watch', - 'config.write', - 'settings.write', -}; - -const Set _noRevisionCommands = { - 'host.list', - 'session.list', - 'transcript.search', - 'session.create', - 'audit.read', - 'audit.tail', - 'settings.read', - 'catalog.get', - 'broker.status', - 'usage.read', - 'host.watch', - 'transcript.context', - 'transcript.page', - 'session.attach', - 'session.image.begin', - 'session.image.chunk', - 'session.image.discard', - 'session.image.read', - 'session.state.get', - 'session.watch', - 'preview.capture.read', - 'preview.policy.check', -}; - -const Set _requiredRevisionCommands = { - 'session.rename', - 'session.retry', - 'session.compact', - 'session.pause', - 'session.resume', - 'session.archive', - 'session.restore', - 'session.model.set', - 'session.thinking.set', - 'session.fast.set', - 'controller.lease.acquire', - 'controller.lease.renew', - 'controller.lease.release', - 'prompt.lease.acquire', - 'prompt.lease.renew', - 'prompt.lease.release', - 'session.delete', - 'session.close', - 'files.write', - 'files.patch', - 'review.apply', - 'config.write', - 'settings.write', -}; - -const Set _confirmationCommands = { - 'session.delete', - 'session.close', - 'files.write', - 'files.patch', - 'review.apply', - 'session.cancel', - 'agent.cancel', - 'bash.run', - 'term.open', - 'preview.launch', - 'preview.navigate', - 'preview.upload', - 'config.write', - 'settings.write', -}; - -const Set _deviceCapabilities = { - 'sessions.read', - 'sessions.prompt', - 'sessions.control', - 'sessions.manage', - 'bash.run', - 'term.open', - 'term.input', - 'term.resize', - 'files.read', - 'files.write', - 'files.list', - 'files.diff', - 'agents.control', - 'audit.read', - 'config.read', - 'catalog.read', - 'config.write', - 'broker.read', - 'usage.read', - 'preview.read', - 'preview.control', - 'preview.input', -}; - -Map _cursorJson(TranscriptCursor cursor, String path) { - if (cursor.seq < 0 || cursor.seq > _maxSafeInteger) { - throw ArgumentError.value( - cursor.seq, - '$path.seq', - 'must be a safe nonnegative integer', - ); - } - return { - 'epoch': _controlString(cursor.epoch, '$path.epoch', 128), - 'seq': cursor.seq, - }; -} - -Map _sessionIndexCursorJson( - SessionIndexCursor cursor, - String path, -) { - if (cursor.seq < 0 || cursor.seq > _maxSafeInteger) { - throw ArgumentError.value( - cursor.seq, - '$path.seq', - 'must be a safe nonnegative integer', - ); - } - return { - 'epoch': _controlString(cursor.epoch, '$path.epoch', 128), - 'seq': cursor.seq, - }; -} - -void _boundedStrings(List values, String path, int maxItems) { - if (values.length > maxItems) { - throw ArgumentError.value( - values.length, - path, - 'must contain at most $maxItems values', - ); - } - for (var index = 0; index < values.length; index++) { - _controlString(values[index], '$path[$index]', 256); - } -} - -void _capabilityStrings(List values, String path) { - _boundedStrings(values, path, 128); - for (var index = 0; index < values.length; index++) { - if (!_deviceCapabilities.contains(values[index])) { - throw ArgumentError.value( - values[index], - '$path[$index]', - 'is not a pinned device capability', - ); - } - } -} - -String _base64(String value, String path, int maxDecodedBytes) { - _boundedText(value, path, ((maxDecodedBytes * 4) / 3).ceil() + 4); - final valid = RegExp( - r'^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$', - ); - if (value.length % 4 != 0 || !valid.hasMatch(value)) { - throw ArgumentError.value(value, path, 'must be canonical base64'); - } - late final List decoded; - try { - decoded = base64.decode(value); - } on FormatException { - throw ArgumentError.value(value, path, 'must be canonical base64'); - } - if (decoded.length > maxDecodedBytes) { - throw ArgumentError.value(value, path, 'decoded payload exceeds limit'); - } - return value; -} - -String _id(String value, String path) => _controlString(value, path, 256); - -String _deviceToken(String value) { - final canonical = RegExp(r'^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$'); - if (!canonical.hasMatch(value)) { - throw ArgumentError.value( - value, - 'authentication.deviceToken', - 'must be canonical base64url for exactly 32 bytes', - ); - } - return value; -} - -String _controlString(String value, String path, int maxBytes) { - if (value.isEmpty || - !_utf8LengthAtMost(value, maxBytes) || - _hasControlCharacter(value)) { - throw ArgumentError.value( - value, - path, - 'must be a bounded non-empty string', - ); - } - return value; -} - -String _boundedText(String value, String path, int maxBytes) { - if (!_utf8LengthAtMost(value, maxBytes)) { - throw ArgumentError.value(value, path, 'exceeds the UTF-8 byte limit'); - } - return value; -} - -bool _hasControlCharacter(String value) { - for (final codeUnit in value.codeUnits) { - if (codeUnit <= 0x1f || codeUnit == 0x7f) { - return true; - } - } - return false; -} - -bool _utf8LengthAtMost(String value, int maximum) { - var bytes = 0; - for (var index = 0; index < value.length; index++) { - final codeUnit = value.codeUnitAt(index); - if (codeUnit <= 0x7f) { - bytes++; - } else if (codeUnit <= 0x7ff) { - bytes += 2; - } else if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { - if (index + 1 < value.length) { - final next = value.codeUnitAt(index + 1); - if (next >= 0xdc00 && next <= 0xdfff) { - bytes += 4; - index++; - } else { - bytes += 3; - } - } else { - bytes += 3; - } - } else { - bytes += 3; - } - if (bytes > maximum) { - return false; - } - } - return true; -} diff --git a/apps/flutter/lib/src/ui/access_mode_selector.dart b/apps/flutter/lib/src/ui/access_mode_selector.dart deleted file mode 100644 index 2d3dd0d9..00000000 --- a/apps/flutter/lib/src/ui/access_mode_selector.dart +++ /dev/null @@ -1,169 +0,0 @@ -part of 't4_app.dart'; - -/// One selectable entry in an [AccessModeSelector] menu. -class AccessModeOption { - const AccessModeOption({ - required this.id, - required this.label, - this.detail, - this.selected = false, - }); - - /// Stable identifier passed to [AccessModeSelector.onSelected]. - final String id; - - /// Primary menu-row text. - final String label; - - /// Optional dimmed second line describing the option. - final String? detail; - - /// Whether this option renders with a leading check mark. - final bool selected; -} - -/// Quiet access-mode pill opening a themed [MenuAnchor] of options. -/// -/// Matches the composer pill look: hairline stadium border, 12 px label, -/// chevron, and a 14 px shield leading icon. With an empty [options] list the -/// pill is informational: the menu shows a single disabled item repeating -/// [label] instead of selectable entries. -class AccessModeSelector extends StatelessWidget { - const AccessModeSelector({ - required this.label, - required this.options, - required this.onSelected, - this.enabled = true, - this.readOnly = false, - super.key, - }); - - /// Text shown inside the pill (current mode). - final String label; - - /// Selectable modes; empty means display-only. - final List options; - - /// Called with the tapped option's [AccessModeOption.id]. - final ValueChanged onSelected; - - /// Disables the pill entirely when false. - final bool enabled; - - /// Renders options as a non-interactive granted-permissions status list: - /// rows are disabled and the check mark means "granted", not "chosen". - /// Use this until the wire protocol exposes a real access-mode command. - final bool readOnly; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - return Semantics( - label: 'Access mode: $label', - button: true, - enabled: enabled, - child: MenuAnchor( - style: const MenuStyle( - visualDensity: VisualDensity.compact, - maximumSize: WidgetStatePropertyAll(Size(320, 360)), - ), - alignmentOffset: const Offset(0, _T4Space.xs), - menuChildren: options.isEmpty - ? [ - MenuItemButton( - style: _accessMenuItemStyle, - onPressed: null, - child: Text(label), - ), - ] - : [ - for (final option in options) - MenuItemButton( - key: ValueKey('access-mode-${option.id}'), - style: _accessMenuItemStyle, - leadingIcon: option.selected - ? Icon(Icons.check, size: 16, color: scheme.primary) - : const SizedBox(width: 16), - onPressed: readOnly ? null : () => onSelected(option.id), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text(option.label), - if (option.detail case final detail?) - Text( - detail, - style: TextStyle( - fontSize: 11, - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - builder: (context, controller, child) => _AccessModePill( - label: label, - onTap: enabled - ? () => controller.isOpen ? controller.close() : controller.open() - : null, - ), - ), - ); - } -} - -final ButtonStyle _accessMenuItemStyle = MenuItemButton.styleFrom( - visualDensity: VisualDensity.compact, - textStyle: const TextStyle(fontSize: 12), - minimumSize: const Size.fromHeight(36), -); - -/// Hairline stadium pill anchor for [AccessModeSelector]. -final class _AccessModePill extends StatelessWidget { - const _AccessModePill({required this.label, required this.onTap}); - - final String label; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final enabled = onTap != null; - final color = enabled ? scheme.onSurfaceVariant : scheme.outline; - final shape = StadiumBorder(side: BorderSide(color: scheme.outlineVariant)); - return Material( - color: Colors.transparent, - shape: shape, - child: InkWell( - customBorder: const StadiumBorder(), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.xs, - vertical: _T4Space.xxs + 1, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.shield_outlined, size: 14, color: color), - const SizedBox(width: _T4Space.xxs), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 160), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 12, color: color), - ), - ), - const SizedBox(width: 2), - Icon(Icons.keyboard_arrow_down, size: 14, color: color), - ], - ), - ), - ), - ); - } -} diff --git a/apps/flutter/lib/src/ui/adaptive_session_shell.dart b/apps/flutter/lib/src/ui/adaptive_session_shell.dart deleted file mode 100644 index e14fe1b0..00000000 --- a/apps/flutter/lib/src/ui/adaptive_session_shell.dart +++ /dev/null @@ -1,932 +0,0 @@ -part of 't4_app.dart'; - -final class _AdaptiveSessionShell extends StatefulWidget { - const _AdaptiveSessionShell({ - required this.state, - required this.actions, - required this.platformState, - required this.platformActions, - }); - - final T4ViewState state; - final T4Actions actions; - final PlatformLifecycleViewState platformState; - final PlatformLifecycleActions? platformActions; - - @override - State<_AdaptiveSessionShell> createState() => _AdaptiveSessionShellState(); -} - -final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> { - final GlobalKey _scaffoldKey = GlobalKey(); - String? _selectingSessionId; - bool _connecting = false; - bool _disconnecting = false; - bool _showHostManager = false; - bool _showAttention = false; - bool _showDeveloper = false; - bool _showSettings = false; - bool _showSearch = false; - bool _showUsage = false; - bool _showContextPanel = false; - int _developerInitialTab = 0; - - Future _connect() async { - if (_connecting) return; - setState(() => _connecting = true); - try { - await widget.actions.connect(); - } on Object { - if (!mounted) return; - _showActionFailure('Could not connect. Try again.'); - } finally { - if (mounted) setState(() => _connecting = false); - } - } - - Future _disconnect() async { - if (_disconnecting) return; - setState(() => _disconnecting = true); - try { - await widget.actions.disconnect(); - } on Object { - if (!mounted) return; - _showActionFailure('Could not disconnect. Try again.'); - } finally { - if (mounted) setState(() => _disconnecting = false); - } - } - - Future _runConnectionAction() => - widget.state.connectionPhase.canDisconnect ? _disconnect() : _connect(); - - Future _selectSession( - String sessionId, { - required bool closeDrawer, - }) async { - if (_selectingSessionId != null) return; - if (sessionId == widget.state.selectedSessionId) { - setState(() { - _showHostManager = false; - _showAttention = false; - _showDeveloper = false; - _showSettings = false; - _showSearch = false; - _showUsage = false; - }); - if (closeDrawer) _scaffoldKey.currentState?.closeDrawer(); - return; - } - - setState(() { - _showHostManager = false; - _showAttention = false; - _showDeveloper = false; - _showSettings = false; - _showSearch = false; - _showUsage = false; - _selectingSessionId = sessionId; - }); - try { - await widget.actions.selectSession(sessionId); - if (!mounted) return; - if (closeDrawer) _scaffoldKey.currentState?.closeDrawer(); - } on Object { - if (!mounted) return; - _showActionFailure('Could not open that session. Try again.'); - } finally { - if (mounted) setState(() => _selectingSessionId = null); - } - } - - void _showActionFailure(String message) { - final messenger = ScaffoldMessenger.of(context); - messenger - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(message))); - } - - void _openNavigation() => _scaffoldKey.currentState?.openDrawer(); - - void _openHostManager({required bool closeDrawer}) { - setState(() { - _showHostManager = true; - _showAttention = false; - _showDeveloper = false; - _showSettings = false; - _showSearch = false; - _showUsage = false; - }); - if (closeDrawer) _scaffoldKey.currentState?.closeDrawer(); - } - - void _closeHostManager() => setState(() => _showHostManager = false); - - void _openAttention() => setState(() { - _showHostManager = false; - _showDeveloper = false; - _showSettings = false; - _showSearch = false; - _showUsage = false; - _showAttention = true; - }); - - void _closeAttention() => setState(() => _showAttention = false); - - void _openDeveloper({int initialTab = 0}) => setState(() { - _showHostManager = false; - _showAttention = false; - _showDeveloper = true; - _developerInitialTab = initialTab; - _showSettings = false; - _showSearch = false; - _showUsage = false; - }); - - void _closeDeveloper() => setState(() => _showDeveloper = false); - - bool get _canQuickOpen => - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.selectedSession != null && - widget.state.grantedCapabilities.contains('files.list') && - widget.state.grantedCapabilities.contains('files.read') && - widget.state.grantedFeatures.contains('files.search'); - - Future _openQuickOpen() async { - if (!_canQuickOpen) return; - final path = await showDialog( - context: context, - builder: (context) => _QuickOpenDialog(actions: widget.actions), - ); - if (path == null || !mounted) return; - try { - await widget.actions.readFile(path); - if (mounted) _openDeveloper(initialTab: 1); - } on Object { - if (mounted) _showActionFailure('Could not open that project file.'); - } - } - - void _openSettings({required bool closeDrawer}) { - setState(() { - _showHostManager = false; - _showAttention = false; - _showDeveloper = false; - _showSettings = true; - _showSearch = false; - _showUsage = false; - }); - if (closeDrawer) _scaffoldKey.currentState?.closeDrawer(); - } - - void _closeSettings() => setState(() => _showSettings = false); - - void _openSearch({required bool closeDrawer}) { - setState(() { - _showHostManager = false; - _showAttention = false; - _showDeveloper = false; - _showSettings = false; - _showUsage = false; - _showSearch = true; - }); - if (closeDrawer) _scaffoldKey.currentState?.closeDrawer(); - } - - void _closeSearch() => setState(() => _showSearch = false); - - void _openUsage({required bool closeDrawer}) { - setState(() { - _showHostManager = false; - _showAttention = false; - _showDeveloper = false; - _showSettings = false; - _showSearch = false; - _showUsage = true; - }); - if (closeDrawer) _scaffoldKey.currentState?.closeDrawer(); - } - - void _closeUsage() => setState(() => _showUsage = false); - - void _toggleContextPanel() => - setState(() => _showContextPanel = !_showContextPanel); - - void _toggleSearch() { - if (_showSearch) { - _closeSearch(); - } else { - _openSearch(closeDrawer: false); - } - } - - void _toggleSettings() { - if (_showSettings) { - _closeSettings(); - } else { - _openSettings(closeDrawer: false); - } - } - - void _toggleDeveloper() { - if (_showDeveloper) { - _closeDeveloper(); - } else { - _openDeveloper(); - } - } - - /// Escape: returns to the conversation when any takeover surface is open. - void _dismissTakeovers() { - if (!_showHostManager && - !_showAttention && - !_showDeveloper && - !_showSettings && - !_showSearch && - !_showUsage) { - return; - } - setState(() { - _showHostManager = false; - _showAttention = false; - _showDeveloper = false; - _showSettings = false; - _showSearch = false; - _showUsage = false; - }); - } - - /// Selects the Nth session of the list the rail renders by default - /// (non-archived, unfiltered), using the same select action as the rail. - void _selectSessionAt(int index) { - final visible = widget.state.sessions - .where((session) => !session.archived) - .toList(growable: false); - if (index < 0 || index >= visible.length) return; - unawaited(_selectSession(visible[index].sessionId, closeDrawer: false)); - } - - /// Same create flow (gate + dialog) as the session rail's new-session - /// button, reachable from the keyboard. - Future _createSessionFromShortcut() async { - final canCreate = - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('sessions.manage') && - !widget.state.sessionOperationPending && - widget.state.sessions.isNotEmpty; - if (!canCreate) return; - final projects = {}; - for (final session in widget.state.sessions) { - projects.putIfAbsent(session.projectId, () => session.projectName); - } - if (projects.isEmpty) return; - await showDialog( - context: context, - builder: (context) => - _CreateSessionDialog(actions: widget.actions, projects: projects), - ); - } - - Map _shortcutBindings() { - final useMeta = - defaultTargetPlatform == TargetPlatform.macOS || - defaultTargetPlatform == TargetPlatform.iOS; - SingleActivator mod(LogicalKeyboardKey key, {bool shift = false}) => - SingleActivator(key, meta: useMeta, control: !useMeta, shift: shift); - - const digits = [ - LogicalKeyboardKey.digit1, - LogicalKeyboardKey.digit2, - LogicalKeyboardKey.digit3, - LogicalKeyboardKey.digit4, - LogicalKeyboardKey.digit5, - LogicalKeyboardKey.digit6, - LogicalKeyboardKey.digit7, - LogicalKeyboardKey.digit8, - LogicalKeyboardKey.digit9, - ]; - - return { - mod(LogicalKeyboardKey.keyK): () => unawaited(_openQuickOpen()), - mod(LogicalKeyboardKey.keyN): () => - unawaited(_createSessionFromShortcut()), - mod(LogicalKeyboardKey.keyF, shift: true): _toggleSearch, - mod(LogicalKeyboardKey.comma): _toggleSettings, - mod(LogicalKeyboardKey.keyJ): _toggleDeveloper, - mod(LogicalKeyboardKey.keyI): _toggleContextPanel, - mod(LogicalKeyboardKey.keyP, shift: true): () => - unawaited(_openCommandPalette()), - for (var i = 0; i < digits.length; i++) - mod(digits[i]): () => _selectSessionAt(i), - const SingleActivator(LogicalKeyboardKey.escape): _dismissTakeovers, - }; - } - - /// mod+shift+P: command palette over the shell's global actions. The - /// shortcut labels mirror the platform modifier used by [_shortcutBindings]. - Future _openCommandPalette() async { - final useMeta = - defaultTargetPlatform == TargetPlatform.macOS || - defaultTargetPlatform == TargetPlatform.iOS; - final mod = useMeta ? '\u2318' : 'Ctrl+'; - await showCommandPalette( - context, - commands: [ - PaletteCommand( - id: 'quick-open', - title: 'Quick open project file', - shortcutLabel: '${mod}K', - enabled: _canQuickOpen, - run: () => unawaited(_openQuickOpen()), - ), - PaletteCommand( - id: 'new-session', - title: 'New session', - shortcutLabel: '${mod}N', - enabled: - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('sessions.manage'), - run: () => unawaited(_createSessionFromShortcut()), - ), - PaletteCommand( - id: 'search-transcripts', - title: 'Search transcripts', - shortcutLabel: '$mod\u21e7F', - run: _toggleSearch, - ), - PaletteCommand( - id: 'developer-tools', - title: 'Toggle developer tools', - shortcutLabel: '${mod}J', - run: _toggleDeveloper, - ), - PaletteCommand( - id: 'context-panel', - title: 'Toggle context panel', - shortcutLabel: '${mod}I', - run: _toggleContextPanel, - ), - PaletteCommand( - id: 'usage', - title: 'Usage and accounts', - run: () => _openUsage(closeDrawer: false), - ), - PaletteCommand( - id: 'settings', - title: 'Settings', - shortcutLabel: '$mod,', - run: _toggleSettings, - ), - PaletteCommand( - id: 'manage-hosts', - title: 'Manage hosts', - run: () => _openHostManager(closeDrawer: false), - ), - ], - ); - } - - Widget _contextRow(BuildContext context, String label, String value) { - final theme = Theme.of(context); - return Padding( - padding: const EdgeInsets.symmetric(vertical: _T4Space.xxs), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 88, - child: Text( - label, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - value, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall, - ), - ), - ], - ), - ); - } - - /// Section list for the right context panel. Wave 4 extends this with - /// richer sections; keep it as the single mount point. - List _buildContextSections(BuildContext context) { - final session = widget.state.selectedSession; - final profile = widget.state.hostDirectory.activeProfile; - final modelLabel = widget.state.composer.modelLabel; - final capabilities = widget.state.grantedCapabilities; - final ready = - widget.state.connectionPhase == ConnectionPhase.ready && - session != null; - return [ - ContextPanelSection( - id: 'session', - title: 'Session', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _contextRow(context, 'Title', _displaySessionTitle(session)), - if (session != null) - _contextRow(context, 'Project', session.projectName), - if (session != null && session.status.trim().isNotEmpty) - _contextRow(context, 'Status', session.status), - _contextRow( - context, - 'Connection', - widget.state.connectionPhase.label, - ), - if (profile != null) _contextRow(context, 'Host', profile.label), - if (modelLabel != null) _contextRow(context, 'Model', modelLabel), - ], - ), - ), - if (ready && capabilities.contains('files.diff')) - ContextPanelSection( - id: 'review', - title: 'Review', - child: SizedBox( - height: 380, - child: ReviewPanelBody( - state: widget.state, - actions: widget.actions, - ), - ), - ), - if (ready && - capabilities.contains('files.list') && - capabilities.contains('files.read')) - ContextPanelSection( - id: 'files', - title: 'Files', - initiallyExpanded: false, - child: SizedBox( - height: 380, - child: FilesPanelBody(state: widget.state, actions: widget.actions), - ), - ), - if (ready && capabilities.contains('audit.read')) - ContextPanelSection( - id: 'activity', - title: 'Activity', - initiallyExpanded: false, - child: SizedBox( - height: 320, - child: ActivityPanelBody( - state: widget.state, - actions: widget.actions, - ), - ), - ), - ]; - } - - Widget _contextPanelToggle(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return IconButton( - onPressed: _toggleContextPanel, - tooltip: 'Toggle context panel', - iconSize: _T4Size.indicator, - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - constraints: const BoxConstraints.tightFor(width: 28, height: 28), - color: scheme.onSurfaceVariant, - icon: const Icon(Icons.view_sidebar_outlined), - ); - } - - Widget _surfaceNavigationEntries({ - required bool closeDrawer, - required bool rail, - }) { - final scheme = Theme.of(context).colorScheme; - return Material( - color: rail ? scheme.surfaceContainerLowest : scheme.surface, - child: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.xs, - _T4Space.xxs, - _T4Space.xs, - _T4Space.sm, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Semantics( - button: true, - selected: _showSearch, - label: 'Search transcripts', - child: ListTile( - selected: _showSearch, - selectedTileColor: scheme.secondaryContainer, - leading: const Icon(Icons.manage_search), - title: const Text('Search'), - trailing: const Icon(Icons.chevron_right), - onTap: () => _openSearch(closeDrawer: closeDrawer), - ), - ), - Semantics( - button: true, - selected: _showUsage, - label: 'Open usage and accounts', - child: ListTile( - selected: _showUsage, - selectedTileColor: scheme.secondaryContainer, - leading: const Icon(Icons.data_usage_outlined), - title: const Text('Usage'), - trailing: const Icon(Icons.chevron_right), - onTap: () => _openUsage(closeDrawer: closeDrawer), - ), - ), - Semantics( - button: true, - selected: _showSettings, - label: 'Open settings', - child: ListTile( - selected: _showSettings, - selectedTileColor: scheme.secondaryContainer, - leading: const Icon(Icons.settings_outlined), - title: const Text('Settings'), - trailing: const Icon(Icons.chevron_right), - onTap: () => _openSettings(closeDrawer: closeDrawer), - ), - ), - ], - ), - ), - ), - ); - } - - Widget _primaryContent({required bool showHeader}) { - if (_showSearch) { - return _TranscriptSearchPane( - actions: widget.actions, - showHeader: showHeader, - onDone: _closeSearch, - onOpenSession: (sessionId) async { - await _selectSession(sessionId, closeDrawer: false); - if (mounted) _closeSearch(); - }, - ); - } - if (_showUsage) { - return _UsageStatusPane( - state: widget.state, - actions: widget.actions, - showHeader: showHeader, - onDone: _closeUsage, - ); - } - if (_showSettings) { - return _SettingsPane( - state: widget.state, - actions: widget.actions, - platformState: widget.platformState, - platformActions: widget.platformActions, - showHeader: showHeader, - onDone: _closeSettings, - ); - } - if (_showAttention) { - return _AttentionPane( - state: widget.state, - actions: widget.actions, - onDone: _closeAttention, - onOpenSession: (sessionId) async { - await _selectSession(sessionId, closeDrawer: false); - if (mounted) _closeAttention(); - }, - ); - } - if (_showHostManager) { - return _HostManagerPane( - state: widget.state, - actions: widget.actions, - onDone: _closeHostManager, - ); - } - - if (widget.state.authenticationPhase == - AuthenticationPhase.pairingRequired || - widget.state.authenticationPhase == AuthenticationPhase.pairing) { - return _PairingPane(state: widget.state, actions: widget.actions); - } - - if (_showDeveloper) { - return _DeveloperSurfacesPane( - state: widget.state, - actions: widget.actions, - initialTab: _developerInitialTab, - showHeader: showHeader, - onDone: _closeDeveloper, - ); - } - - return _ConversationPane( - state: widget.state, - actions: widget.actions, - showHeader: showHeader, - onConnect: _connect, - onOpenSessions: showHeader ? null : _openNavigation, - onOpenAttention: _openAttention, - onOpenDeveloper: _openDeveloper, - onOpenQuickOpen: _openQuickOpen, - onSelectSession: (sessionId) => - _selectSession(sessionId, closeDrawer: false), - ); - } - - @override - Widget build(BuildContext context) { - final needsOnboarding = - !widget.state.targetConfigured && - widget.state.hostDirectory.profiles.isEmpty && - widget.state.connectionPhase == ConnectionPhase.disconnected; - if (needsOnboarding) { - return _HostOnboardingPage(state: widget.state, actions: widget.actions); - } - - return CallbackShortcuts( - bindings: _shortcutBindings(), - child: Focus( - autofocus: true, - child: LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth >= _T4Breakpoints.wide) { - return _buildWide(context); - } - return _buildCompact(context); - }, - ), - ), - ); - } - - Widget _buildWide(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Row( - children: [ - SizedBox( - width: _T4Layout.sessionRailWidth, - child: Column( - children: [ - Expanded( - child: _SessionNavigation( - state: widget.state, - actions: widget.actions, - mode: _SessionNavigationMode.rail, - connecting: _connecting, - disconnecting: _disconnecting, - selectingSessionId: _selectingSessionId, - showingHostManager: _showHostManager, - onConnect: _connect, - onDisconnect: _disconnect, - onManageHosts: () => _openHostManager(closeDrawer: false), - onSelectSession: (sessionId) => - _selectSession(sessionId, closeDrawer: false), - ), - ), - _surfaceNavigationEntries(closeDrawer: false, rail: true), - ], - ), - ), - const VerticalDivider(width: _T4Size.divider), - Expanded( - child: Stack( - children: [ - Positioned.fill(child: _primaryContent(showHeader: true)), - Positioned( - top: _T4Space.sm, - right: _T4Space.sm, - child: _contextPanelToggle(context), - ), - ], - ), - ), - if (_showContextPanel) - ContextPanel( - sections: _buildContextSections(context), - onClose: _toggleContextPanel, - ), - ], - ), - ), - ); - } - - Widget _buildCompact(BuildContext context) { - final phase = widget.state.connectionPhase; - final actionLabel = phase.actionLabel; - - return Scaffold( - key: _scaffoldKey, - appBar: AppBar( - toolbarHeight: _T4Layout.compactToolbarHeight, - leading: IconButton( - onPressed: _openNavigation, - tooltip: 'Open navigation', - icon: const Icon(Icons.menu), - ), - titleSpacing: 0, - title: _showSearch - ? Text('Search', style: Theme.of(context).textTheme.titleMedium) - : _showUsage - ? Text('Usage', style: Theme.of(context).textTheme.titleMedium) - : _showSettings - ? Text('Settings', style: Theme.of(context).textTheme.titleMedium) - : _showHostManager - ? Text('Hosts', style: Theme.of(context).textTheme.titleMedium) - : _showDeveloper - ? Text( - 'Developer tools', - style: Theme.of(context).textTheme.titleMedium, - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _displaySessionTitle(widget.state.selectedSession), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: _T4Space.xxs), - _CompactConnectionLabel( - phase: phase, - actionPending: _connecting, - ), - ], - ), - actions: [ - if (_showSearch || _showUsage || _showSettings) - IconButton( - onPressed: _showSearch - ? _closeSearch - : _showUsage - ? _closeUsage - : _closeSettings, - tooltip: _showSearch - ? 'Close search' - : _showUsage - ? 'Close usage' - : 'Close settings', - icon: const Icon(Icons.close), - ) - else ...[ - if (!_showHostManager && - !_showAttention && - !_showDeveloper && - !_showSearch && - !_showUsage) ...[ - if (_canQuickOpen) - IconButton( - onPressed: () => unawaited(_openQuickOpen()), - tooltip: 'Quick open project file', - icon: const Icon(Icons.search), - ), - Badge( - isLabelVisible: widget.state.urgentAttentionCount > 0, - label: Text('${widget.state.urgentAttentionCount}'), - child: IconButton( - onPressed: _openAttention, - tooltip: 'Open inbox', - icon: const Icon(Icons.inbox_outlined), - ), - ), - ], - if (!_showHostManager && - !_showAttention && - !_showSearch && - !_showUsage) - IconButton( - onPressed: _showDeveloper ? _closeDeveloper : _openDeveloper, - tooltip: _showDeveloper - ? 'Close developer tools' - : 'Open developer tools', - icon: Icon(_showDeveloper ? Icons.close : Icons.code), - ), - if (!_showHostManager && !_showSearch && !_showUsage) - IconButton( - onPressed: _connecting || _disconnecting - ? null - : () => unawaited(_runConnectionAction()), - tooltip: actionLabel, - icon: Icon( - phase.canDisconnect - ? Icons.link_off - : phase == ConnectionPhase.failed - ? Icons.refresh - : Icons.power_settings_new, - ), - ), - if (!_showHostManager && !_showSearch && !_showUsage) - IconButton( - onPressed: () => - _scaffoldKey.currentState?.openEndDrawer(), - tooltip: 'Toggle context panel', - icon: const Icon(Icons.view_sidebar_outlined), - ), - ], - ], - ), - drawerEnableOpenDragGesture: true, - drawerEdgeDragWidth: _T4Layout.minimumTouchTarget, - drawer: Drawer( - child: Column( - children: [ - Expanded( - child: _SessionNavigation( - state: widget.state, - actions: widget.actions, - mode: _SessionNavigationMode.drawer, - connecting: _connecting, - selectingSessionId: _selectingSessionId, - disconnecting: _disconnecting, - showingHostManager: _showHostManager, - onConnect: _connect, - onDisconnect: _disconnect, - onManageHosts: () => _openHostManager(closeDrawer: true), - onSelectSession: (sessionId) => - _selectSession(sessionId, closeDrawer: true), - onClose: () => _scaffoldKey.currentState?.closeDrawer(), - ), - ), - _surfaceNavigationEntries(closeDrawer: true, rail: false), - ], - ), - ), - endDrawer: Drawer( - child: SafeArea( - child: ContextPanel( - sections: _buildContextSections(context), - onClose: () => _scaffoldKey.currentState?.closeEndDrawer(), - ), - ), - ), - body: _primaryContent(showHeader: false), - ); - } -} - -final class _CompactConnectionLabel extends StatelessWidget { - const _CompactConnectionLabel({ - required this.phase, - required this.actionPending, - }); - - final ConnectionPhase phase; - final bool actionPending; - - @override - Widget build(BuildContext context) { - final active = phase.isActive || actionPending; - final scheme = Theme.of(context).colorScheme; - - return Semantics( - label: 'Connection status: ${phase.label}', - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox.square( - dimension: _T4Space.sm, - child: active - ? CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - color: scheme.primary, - semanticsLabel: phase.label, - ) - : Icon( - Icons.circle, - size: _T4Space.xs, - color: phase == ConnectionPhase.ready - ? scheme.primary - : scheme.outline, - ), - ), - const SizedBox(width: _T4Space.xs), - Flexible( - child: Text( - phase.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), - ), - ), - ], - ), - ); - } -} diff --git a/apps/flutter/lib/src/ui/attention_pane.dart b/apps/flutter/lib/src/ui/attention_pane.dart deleted file mode 100644 index 7af82245..00000000 --- a/apps/flutter/lib/src/ui/attention_pane.dart +++ /dev/null @@ -1,801 +0,0 @@ -part of 't4_app.dart'; - -final class _AttentionPane extends StatelessWidget { - const _AttentionPane({ - required this.state, - required this.actions, - required this.onDone, - required this.onOpenSession, - }); - - final T4ViewState state; - final T4Actions actions; - final VoidCallback onDone; - final Future Function(String sessionId) onOpenSession; - - @override - Widget build(BuildContext context) => InboxFlyoutContent( - state: state, - actions: actions, - onDone: onDone, - onOpenSession: onOpenSession, - ); -} - -/// The inbox content column (Needs you / Updates / Agents), embeddable in a -/// ~400 px anchored popover or, via [_AttentionPane], the full-width takeover. -final class InboxFlyoutContent extends StatefulWidget { - const InboxFlyoutContent({ - required this.state, - required this.actions, - required this.onDone, - required this.onOpenSession, - this.showTitle = true, - super.key, - }); - - final T4ViewState state; - final T4Actions actions; - final VoidCallback onDone; - final Future Function(String sessionId) onOpenSession; - final bool showTitle; - - @override - State createState() => _InboxFlyoutContentState(); -} - -final class _InboxFlyoutContentState extends State - with SingleTickerProviderStateMixin { - late final TabController _tabs; - final Set _responding = {}; - - @override - void initState() { - super.initState(); - _tabs = TabController(length: 3, vsync: this); - } - - @override - void dispose() { - _tabs.dispose(); - super.dispose(); - } - - Future _respond(AttentionItem item, AttentionResponse response) async { - if (!_responding.add(item.key)) return; - setState(() {}); - try { - final accepted = await widget.actions.respondToAttention(item, response); - if (!mounted) return; - if (!accepted) _showError('The host did not accept that response.'); - } on Object catch (error) { - if (mounted) _showError(error.toString().replaceFirst('Bad state: ', '')); - } finally { - if (mounted) setState(() => _responding.remove(item.key)); - } - } - - void _showError(String message) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(message))); - } - - Future _askForText({ - required String title, - String hint = 'Add a note', - }) async { - final controller = TextEditingController(); - final result = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(title), - content: TextField( - controller: controller, - autofocus: true, - minLines: 2, - maxLines: 6, - decoration: InputDecoration(hintText: hint), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.pop(context, controller.text.trim()), - child: const Text('Send'), - ), - ], - ), - ); - controller.dispose(); - return result; - } - - @override - Widget build(BuildContext context) { - final needsYou = widget.state.attentionItems - .where((item) => item.needsResponse) - .toList(growable: false); - final updates = widget.state.attentionItems - .where((item) => !item.needsResponse) - .toList(growable: false); - return Column( - children: [ - if (widget.showTitle) - _AttentionHeader( - urgentCount: widget.state.urgentAttentionCount, - onDone: widget.onDone, - ), - if (widget.state.attentionPartial) - MaterialBanner( - content: Text( - widget.state.omittedAttentionCount > 0 - ? '${widget.state.omittedAttentionCount} more requests are available on the host.' - : 'Some attention data could not be read. Refresh the host before acting.', - ), - actions: [ - TextButton(onPressed: widget.onDone, child: const Text('Close')), - ], - ), - TabBar( - controller: _tabs, - tabs: [ - Tab( - text: needsYou.isEmpty - ? 'Needs you' - : 'Needs you (${needsYou.length})', - ), - Tab( - text: updates.isEmpty ? 'Updates' : 'Updates (${updates.length})', - ), - Tab( - text: widget.state.agentActivities.isEmpty - ? 'Agents' - : 'Agents (${widget.state.agentActivities.length})', - ), - ], - ), - Expanded( - child: TabBarView( - controller: _tabs, - children: [ - _attentionList( - needsYou, - emptyLabel: 'Nothing needs your response.', - ), - _attentionList(updates, emptyLabel: 'No recent session updates.'), - _AgentActivityList( - activities: widget.state.agentActivities, - actions: widget.actions, - canControl: - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('agents.control'), - ), - ], - ), - ), - ], - ); - } - - Widget _attentionList( - List items, { - required String emptyLabel, - }) { - if (items.isEmpty) return _AttentionEmpty(label: emptyLabel); - return ListView.separated( - padding: const EdgeInsets.all(_T4Space.lg), - itemCount: items.length, - separatorBuilder: (_, _) => const SizedBox(height: _T4Space.md), - itemBuilder: (context, index) { - final item = items[index]; - return _AttentionCard( - item: item, - busy: _responding.contains(item.key), - canRespond: - widget.state.connectionPhase == ConnectionPhase.ready && - item.actionable, - onRespond: (response) => _respond(item, response), - onRevise: () async { - final note = await _askForText( - title: 'Request plan changes', - hint: 'What should change?', - ); - if (note != null) { - await _respond( - item, - AttentionResponse( - decision: AttentionDecision.revise, - text: note, - ), - ); - } - }, - onCustomAnswer: () async { - final answer = await _askForText( - title: item.title, - hint: 'Type your answer', - ); - if (answer != null && answer.isNotEmpty) { - await _respond( - item, - AttentionResponse( - decision: AttentionDecision.approve, - text: answer, - ), - ); - } - }, - onOpenSession: () => widget.onOpenSession(item.sessionId), - onRetry: item.isProblem - ? () async { - try { - await widget.actions.retrySession(item.sessionId); - } on Object catch (error) { - if (mounted) _showError(error.toString()); - } - } - : null, - ); - }, - ); - } -} - -final class _AttentionHeader extends StatelessWidget { - const _AttentionHeader({required this.urgentCount, required this.onDone}); - - final int urgentCount; - final VoidCallback onDone; - - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.lg, - _T4Space.md, - _T4Space.sm, - _T4Space.sm, - ), - child: Row( - children: [ - Expanded( - child: Text( - urgentCount == 0 ? 'Inbox' : 'Inbox · $urgentCount waiting', - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - IconButton( - onPressed: onDone, - tooltip: 'Close inbox', - icon: const Icon(Icons.close), - ), - ], - ), - ); -} - -IconData _attentionKindIcon(AttentionKind kind) => switch (kind) { - AttentionKind.approval => Icons.shield_outlined, - AttentionKind.question => Icons.help_outline, - AttentionKind.plan => Icons.account_tree_outlined, - AttentionKind.confirmation => Icons.verified_user_outlined, - AttentionKind.completed => Icons.check_circle_outline, - AttentionKind.failed => Icons.error_outline, - AttentionKind.cancelled => Icons.cancel_outlined, -}; - -Color _attentionKindColor(AttentionKind kind, ColorScheme scheme) => - switch (kind) { - AttentionKind.failed => scheme.error, - AttentionKind.cancelled => scheme.onSurfaceVariant, - AttentionKind.completed => scheme.primary, - _ => scheme.tertiary, - }; - -/// Question choice buttons shared by [_AttentionCard] and -/// [InlineApprovalCard]: one outlined button per choice plus the optional -/// free-text answer affordance. -final class _AttentionQuestionActions extends StatelessWidget { - const _AttentionQuestionActions({ - required this.item, - required this.enabled, - required this.onRespond, - required this.onCustomAnswer, - }); - - final AttentionItem item; - final bool enabled; - final ValueChanged onRespond; - final VoidCallback onCustomAnswer; - - @override - Widget build(BuildContext context) => Wrap( - spacing: _T4Space.sm, - runSpacing: _T4Space.sm, - children: [ - for (final choice in item.choices) - OutlinedButton( - onPressed: !enabled - ? null - : () => onRespond( - AttentionResponse( - decision: AttentionDecision.approve, - optionIds: [choice.id], - ), - ), - child: Text(choice.label), - ), - if (item.allowText) - FilledButton.tonal( - onPressed: !enabled ? null : onCustomAnswer, - child: const Text('Type an answer'), - ), - ], - ); -} - -/// Approve / deny / reject / revise buttons shared by [_AttentionCard] and -/// [InlineApprovalCard]; label and decision semantics depend on the item kind. -final class _AttentionDecisionActions extends StatelessWidget { - const _AttentionDecisionActions({ - required this.item, - required this.enabled, - required this.onRespond, - required this.onRevise, - }); - - final AttentionItem item; - final bool enabled; - final ValueChanged onRespond; - final VoidCallback onRevise; - - @override - Widget build(BuildContext context) => Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (item.kind == AttentionKind.plan) ...[ - TextButton( - onPressed: !enabled ? null : onRevise, - child: const Text('Revise'), - ), - const SizedBox(width: _T4Space.sm), - ], - OutlinedButton( - onPressed: !enabled - ? null - : () => onRespond( - AttentionResponse( - decision: item.kind == AttentionKind.plan - ? AttentionDecision.reject - : AttentionDecision.deny, - ), - ), - child: Text(item.kind == AttentionKind.plan ? 'Reject' : 'Deny'), - ), - const SizedBox(width: _T4Space.sm), - FilledButton( - onPressed: !enabled - ? null - : () => onRespond( - const AttentionResponse(decision: AttentionDecision.approve), - ), - child: const Text('Approve'), - ), - ], - ); -} - -final class _AttentionCard extends StatelessWidget { - const _AttentionCard({ - required this.item, - required this.busy, - required this.canRespond, - required this.onRespond, - required this.onRevise, - required this.onCustomAnswer, - required this.onOpenSession, - this.onRetry, - }); - - final AttentionItem item; - final bool busy; - final bool canRespond; - final ValueChanged onRespond; - final VoidCallback onRevise; - final VoidCallback onCustomAnswer; - final VoidCallback onOpenSession; - final VoidCallback? onRetry; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(_T4Space.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - _attentionKindIcon(item.kind), - size: 20, - color: _attentionKindColor(item.kind, scheme), - ), - const SizedBox(width: _T4Space.sm), - Expanded( - child: Text( - item.title, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - if (busy) - const SizedBox.square( - dimension: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ], - ), - const SizedBox(height: _T4Space.sm), - Text(item.summary), - const SizedBox(height: _T4Space.sm), - TextButton.icon( - onPressed: busy ? null : onOpenSession, - icon: const Icon(Icons.forum_outlined), - label: Text(item.sessionTitle), - ), - if (item.needsResponse) ...[ - const SizedBox(height: _T4Space.sm), - if (item.kind == AttentionKind.question) - _AttentionQuestionActions( - item: item, - enabled: canRespond && !busy, - onRespond: onRespond, - onCustomAnswer: onCustomAnswer, - ) - else - _AttentionDecisionActions( - item: item, - enabled: canRespond && !busy, - onRespond: onRespond, - onRevise: onRevise, - ), - if (!canRespond) - Padding( - padding: const EdgeInsets.only(top: _T4Space.sm), - child: Text( - 'Connect with write access to respond.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ), - ] else if (onRetry != null) ...[ - const SizedBox(height: _T4Space.sm), - OutlinedButton.icon( - onPressed: busy ? null : onRetry, - icon: const Icon(Icons.refresh), - label: const Text('Retry session'), - ), - ], - ], - ), - ), - ); - } -} - -/// A single actionable attention item (approval or question) rendered inline -/// in the conversation transcript. Shares choice/decision button semantics -/// with the inbox cards via [_AttentionQuestionActions] and -/// [_AttentionDecisionActions]. -final class InlineApprovalCard extends StatelessWidget { - const InlineApprovalCard({ - required this.item, - required this.onRespond, - required this.onRevise, - required this.onCustomAnswer, - required this.onOpenInbox, - this.busy = false, - this.canRespond = true, - super.key, - }); - - final AttentionItem item; - final ValueChanged onRespond; - final VoidCallback onRevise; - final VoidCallback onCustomAnswer; - final VoidCallback onOpenInbox; - final bool busy; - final bool canRespond; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final enabled = canRespond && !busy && item.actionable; - return Align( - alignment: Alignment.centerLeft, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: Container( - padding: const EdgeInsets.all(_T4Space.md), - decoration: BoxDecoration( - color: scheme.surface, - borderRadius: BorderRadius.circular(_T4Radius.md), - border: Border.all( - width: 1, - color: scheme.tertiary.withValues(alpha: 0.4), - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - _attentionKindIcon(item.kind), - size: 18, - color: _attentionKindColor(item.kind, scheme), - ), - const SizedBox(width: _T4Space.sm), - Expanded( - child: Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall, - ), - ), - if (busy) - const SizedBox.square( - dimension: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - else - IconButton( - onPressed: onOpenInbox, - tooltip: 'Open inbox', - iconSize: 18, - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.inbox_outlined), - ), - ], - ), - const SizedBox(height: _T4Space.xs), - Text( - item.summary, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall, - ), - if (item.needsResponse) ...[ - const SizedBox(height: _T4Space.sm), - if (item.kind == AttentionKind.question) - _AttentionQuestionActions( - item: item, - enabled: enabled, - onRespond: onRespond, - onCustomAnswer: onCustomAnswer, - ) - else - _AttentionDecisionActions( - item: item, - enabled: enabled, - onRespond: onRespond, - onRevise: onRevise, - ), - ], - ], - ), - ), - ), - ); - } -} - -final class _AgentActivityList extends StatefulWidget { - const _AgentActivityList({ - required this.activities, - required this.actions, - required this.canControl, - }); - - final List activities; - final T4Actions actions; - final bool canControl; - - @override - State<_AgentActivityList> createState() => _AgentActivityListState(); -} - -final class _AgentActivityListState extends State<_AgentActivityList> { - final Set _cancelling = {}; - - Future _cancel(AgentActivity activity) async { - if (_cancelling.contains(activity.agentId)) return; - try { - final approved = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Stop background agent?'), - content: Text( - '${activity.label} will be asked to stop. Work already completed is kept.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Keep running'), - ), - FilledButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Stop agent'), - ), - ], - ), - ); - if (approved != true || !mounted) return; - _cancelling.add(activity.agentId); - setState(() {}); - await widget.actions.cancelAgent(activity.agentId); - } on Object catch (error) { - if (!mounted) return; - final message = error.toString().replaceFirst('Bad state: ', ''); - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(message))); - } finally { - if (mounted) setState(() => _cancelling.remove(activity.agentId)); - } - } - - @override - Widget build(BuildContext context) { - if (widget.activities.isEmpty) { - return const _AttentionEmpty(label: 'No background agent activity.'); - } - final rows = _agentHierarchy(widget.activities); - return ListView.separated( - padding: const EdgeInsets.all(_T4Space.lg), - itemCount: rows.length, - separatorBuilder: (_, _) => const Divider(height: _T4Space.lg), - itemBuilder: (context, index) { - final row = rows[index]; - final activity = row.activity; - final terminal = const { - 'completed', - 'failed', - 'cancelled', - }.contains(activity.status); - final detail = [ - activity.status, - ?activity.model, - ?activity.currentTool, - ].join(' · '); - return Padding( - padding: EdgeInsets.only(left: row.depth * _T4Space.lg), - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon( - row.depth == 0 - ? Icons.hub_outlined - : Icons.subdirectory_arrow_right, - ), - title: Text(activity.label), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(detail), - if (activity.description case final description?) - Text( - description, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - if (activity.evidence case final evidence?) - Text( - evidence, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ), - if (activity.progress case final progress?) - Padding( - padding: const EdgeInsets.only(top: _T4Space.xs), - child: LinearProgressIndicator(value: progress), - ), - ], - ), - trailing: terminal - ? Icon( - activity.status == 'completed' - ? Icons.check_circle_outline - : Icons.stop_circle_outlined, - ) - : IconButton( - onPressed: - widget.canControl && - !_cancelling.contains(activity.agentId) - ? () => unawaited(_cancel(activity)) - : null, - tooltip: widget.canControl - ? 'Stop ${activity.label}' - : 'Agent control unavailable', - icon: _cancelling.contains(activity.agentId) - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.stop_circle_outlined), - ), - ), - ); - }, - ); - } -} - -List<({AgentActivity activity, double depth})> _agentHierarchy( - List activities, -) { - final byId = {for (final activity in activities) activity.agentId: activity}; - final children = >{}; - final roots = []; - for (final activity in activities) { - final parentId = activity.parentAgentId; - if (parentId == null || !byId.containsKey(parentId)) { - roots.add(activity); - } else { - children.putIfAbsent(parentId, () => []).add(activity); - } - } - int recentFirst(AgentActivity left, AgentActivity right) => - right.updatedAt.compareTo(left.updatedAt); - roots.sort(recentFirst); - for (final values in children.values) { - values.sort(recentFirst); - } - final rows = <({AgentActivity activity, double depth})>[]; - final visited = {}; - void append(AgentActivity activity, int depth) { - if (!visited.add(activity.agentId)) return; - rows.add((activity: activity, depth: depth.clamp(0, 4).toDouble())); - for (final child in children[activity.agentId] ?? const []) { - append(child, depth + 1); - } - } - - for (final root in roots) { - append(root, 0); - } - for (final activity in activities) { - append(activity, 0); - } - return rows; -} - -final class _AttentionEmpty extends StatelessWidget { - const _AttentionEmpty({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) => Center( - child: Padding( - padding: const EdgeInsets.all(_T4Space.xl), - child: Text( - label, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ); -} diff --git a/apps/flutter/lib/src/ui/command_palette.dart b/apps/flutter/lib/src/ui/command_palette.dart deleted file mode 100644 index d138a489..00000000 --- a/apps/flutter/lib/src/ui/command_palette.dart +++ /dev/null @@ -1,341 +0,0 @@ -part of 't4_app.dart'; - -/// One executable entry in the command palette. -class PaletteCommand { - const PaletteCommand({ - required this.id, - required this.title, - this.shortcutLabel, - this.enabled = true, - required this.run, - }); - - /// Stable identifier (used for widget keys and de-duplication by callers). - final String id; - - /// Human-readable command title; the fuzzy filter matches against this. - final String title; - - /// Optional right-aligned shortcut hint (e.g. `⌘K`). - final String? shortcutLabel; - - /// Disabled commands render dimmed and cannot be selected or run. - final bool enabled; - - /// Invoked after the palette dialog has been popped. - final VoidCallback run; -} - -/// Shows the centered command palette dialog over [context]. -/// -/// Fully keyboard drivable: the filter field autofocuses, ArrowUp/Down move -/// the active row, Enter pops the dialog and runs the active command, Escape -/// closes. Clicking a row does the same as Enter for that row. -Future showCommandPalette( - BuildContext context, { - required List commands, -}) { - return showDialog( - context: context, - barrierDismissible: true, - builder: (context) => _CommandPaletteDialog(commands: commands), - ); -} - -/// A [PaletteCommand] paired with the title indices its match highlighted. -final class _PaletteMatch { - const _PaletteMatch({required this.command, required this.highlight}); - - final PaletteCommand command; - final Set highlight; -} - -/// Case-insensitive subsequence match of [query] inside [title]. -/// -/// Characters at word starts are preferred so multi-word queries behave like -/// word-prefix matching (`"op set"` matches **Op**en **Set**tings). Returns -/// the matched character indices for highlighting, or null when [query] is -/// not a subsequence of [title]. -Set? _fuzzyMatch(String query, String title) { - final needle = query.toLowerCase(); - final haystack = title.toLowerCase(); - if (needle.isEmpty) return const {}; - final matched = {}; - var from = 0; - for (var i = 0; i < needle.length; i++) { - final ch = needle[i]; - if (ch == ' ') continue; - var at = -1; - // Prefer a word-start occurrence at/after the cursor, else any occurrence. - for (var j = from; j < haystack.length; j++) { - if (haystack[j] != ch) continue; - final wordStart = j == 0 || haystack[j - 1] == ' '; - if (wordStart) { - at = j; - break; - } - if (at < 0) at = j; - } - if (at < 0) return null; - matched.add(at); - from = at + 1; - } - return matched; -} - -final class _CommandPaletteDialog extends StatefulWidget { - const _CommandPaletteDialog({required this.commands}); - - final List commands; - - @override - State<_CommandPaletteDialog> createState() => _CommandPaletteDialogState(); -} - -final class _CommandPaletteDialogState extends State<_CommandPaletteDialog> { - final TextEditingController _queryController = TextEditingController(); - final FocusNode _fieldFocus = FocusNode(debugLabel: 'Command palette query'); - final ScrollController _scrollController = ScrollController(); - List<_PaletteMatch> _matches = const <_PaletteMatch>[]; - int _active = -1; - - @override - void initState() { - super.initState(); - _refilter(''); - } - - @override - void dispose() { - _queryController.dispose(); - _fieldFocus.dispose(); - _scrollController.dispose(); - super.dispose(); - } - - void _refilter(String query) { - final matches = <_PaletteMatch>[]; - for (final command in widget.commands) { - final highlight = _fuzzyMatch(query.trim(), command.title); - if (highlight == null) continue; - matches.add(_PaletteMatch(command: command, highlight: highlight)); - } - setState(() { - _matches = matches; - _active = matches.indexWhere((match) => match.command.enabled); - }); - } - - /// Moves the active row by [delta], skipping disabled commands. - void _moveActive(int delta) { - if (_matches.isEmpty) return; - var index = _active; - for (var step = 0; step < _matches.length; step++) { - index = (index + delta) % _matches.length; - if (index < 0) index += _matches.length; - if (_matches[index].command.enabled) { - setState(() => _active = index); - _revealActive(); - return; - } - } - } - - void _revealActive() { - if (_active < 0 || !_scrollController.hasClients) return; - const rowExtent = 40.0; - final viewport = _scrollController.position.viewportDimension; - final top = _active * rowExtent; - final offset = _scrollController.offset; - if (top < offset) { - _scrollController.jumpTo(top); - } else if (top + rowExtent > offset + viewport) { - _scrollController.jumpTo(top + rowExtent - viewport); - } - } - - void _runCommand(PaletteCommand command) { - if (!command.enabled) return; - Navigator.of(context).pop(); - command.run(); - } - - KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { - if (event is KeyUpEvent) return KeyEventResult.ignored; - final key = event.logicalKey; - if (key == LogicalKeyboardKey.arrowDown) { - _moveActive(1); - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.arrowUp) { - _moveActive(-1); - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.enter || - key == LogicalKeyboardKey.numpadEnter) { - if (_active >= 0 && _active < _matches.length) { - _runCommand(_matches[_active].command); - } - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.escape) { - Navigator.of(context).pop(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final topInset = MediaQuery.sizeOf(context).height * 0.15; - return SafeArea( - child: Align( - alignment: Alignment.topCenter, - child: Padding( - padding: EdgeInsets.only(top: topInset), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560, maxHeight: 420), - child: Material( - color: scheme.surface, - elevation: 8, - borderRadius: BorderRadius.circular(_T4Radius.md), - clipBehavior: Clip.antiAlias, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Focus( - onKeyEvent: _onKeyEvent, - child: TextField( - controller: _queryController, - focusNode: _fieldFocus, - autofocus: true, - onChanged: _refilter, - style: theme.textTheme.bodyMedium, - decoration: const InputDecoration( - hintText: 'Type a command…', - prefixIcon: Icon(Icons.search, size: 18), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: EdgeInsets.symmetric( - horizontal: _T4Space.sm, - vertical: _T4Space.sm, - ), - ), - ), - ), - Divider(height: 1, color: scheme.outlineVariant), - Flexible( - child: _matches.isEmpty - ? Padding( - padding: const EdgeInsets.all(_T4Space.md), - child: Text( - 'No matching commands.', - textAlign: TextAlign.center, - style: theme.textTheme.bodySmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ) - : ListView.builder( - controller: _scrollController, - shrinkWrap: true, - padding: const EdgeInsets.symmetric( - vertical: _T4Space.xxs, - ), - itemExtent: 40, - itemCount: _matches.length, - itemBuilder: (context, index) => _PaletteRow( - match: _matches[index], - active: index == _active, - onTap: () => _runCommand(_matches[index].command), - ), - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -final class _PaletteRow extends StatelessWidget { - const _PaletteRow({ - required this.match, - required this.active, - required this.onTap, - }); - - final _PaletteMatch match; - final bool active; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final command = match.command; - final enabled = command.enabled; - final baseColor = enabled ? scheme.onSurface : scheme.outline; - final title = Text.rich( - TextSpan( - children: [ - for (var i = 0; i < command.title.length; i++) - TextSpan( - text: command.title[i], - style: match.highlight.contains(i) - ? TextStyle( - color: scheme.primary, - fontWeight: FontWeight.w600, - ) - : null, - ), - ], - style: TextStyle(fontSize: 13, color: baseColor), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - return InkWell( - key: ValueKey('palette-command-${command.id}'), - onTap: enabled ? onTap : null, - child: Container( - color: active ? scheme.primary.withValues(alpha: 0.08) : null, - padding: const EdgeInsets.symmetric(horizontal: _T4Space.sm), - alignment: Alignment.centerLeft, - child: Row( - children: [ - Expanded(child: title), - if (command.shortcutLabel case final shortcut?) ...[ - const SizedBox(width: _T4Space.xs), - Container( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.xxs + 1, - vertical: 1, - ), - decoration: BoxDecoration( - border: Border.all(color: scheme.outlineVariant), - borderRadius: BorderRadius.circular(_T4Radius.xs - 2), - ), - child: Text( - shortcut, - style: TextStyle( - fontFamily: _T4Typography.monoFamily, - fontSize: 11, - color: scheme.onSurfaceVariant, - ), - ), - ), - ], - ], - ), - ), - ); - } -} diff --git a/apps/flutter/lib/src/ui/context_panel.dart b/apps/flutter/lib/src/ui/context_panel.dart deleted file mode 100644 index cf1421fb..00000000 --- a/apps/flutter/lib/src/ui/context_panel.dart +++ /dev/null @@ -1,180 +0,0 @@ -part of 't4_app.dart'; - -/// One collapsible section hosted by a [ContextPanel]. -final class ContextPanelSection { - const ContextPanelSection({ - required this.id, - required this.title, - required this.child, - this.trailing, - this.initiallyExpanded = true, - }); - - final String id; - final String title; - final Widget child; - final Widget? trailing; - final bool initiallyExpanded; -} - -/// Right-docked contextual side panel with collapsible sections. -/// -/// Renders a fixed-width column with a hairline left divider, one header row -/// per section (title, optional trailing widget, collapse chevron), and a -/// single shared scroll view. Hosts no [Scaffold] or [AppBar] — the shell -/// mounts it directly beside the primary surface. -final class ContextPanel extends StatelessWidget { - const ContextPanel({ - required this.sections, - this.onClose, - this.width = 340, - super.key, - }); - - final List sections; - final VoidCallback? onClose; - final double width; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Container( - width: width, - decoration: BoxDecoration( - color: scheme.surface, - border: Border( - left: BorderSide( - color: scheme.outlineVariant, - width: _T4Size.divider, - ), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (onClose case final close?) - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.xs, - _T4Space.xs, - 0, - ), - child: Row( - children: [ - Expanded( - child: Text( - 'Context', - style: Theme.of(context).textTheme.titleSmall, - ), - ), - IconButton( - onPressed: close, - tooltip: 'Close context panel', - iconSize: _T4Size.indicator, - visualDensity: VisualDensity.compact, - color: scheme.onSurfaceVariant, - icon: const Icon(Icons.close), - ), - ], - ), - ), - Expanded( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final section in sections) - _ContextPanelSectionView( - key: ValueKey('context-section-${section.id}'), - section: section, - ), - ], - ), - ), - ), - ], - ), - ); - } -} - -final class _ContextPanelSectionView extends StatefulWidget { - const _ContextPanelSectionView({required this.section, super.key}); - - final ContextPanelSection section; - - @override - State<_ContextPanelSectionView> createState() => - _ContextPanelSectionViewState(); -} - -final class _ContextPanelSectionViewState - extends State<_ContextPanelSectionView> { - late bool _expanded = widget.section.initiallyExpanded; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final section = widget.section; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Semantics( - button: true, - expanded: _expanded, - label: '${section.title} section', - child: InkWell( - onTap: () => setState(() => _expanded = !_expanded), - child: Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.sm, - _T4Space.sm, - _T4Space.sm, - ), - child: Row( - children: [ - Expanded( - child: Text( - section.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall, - ), - ), - if (section.trailing case final trailing?) ...[ - trailing, - const SizedBox(width: _T4Space.xs), - ], - Icon( - _expanded ? Icons.expand_less : Icons.expand_more, - size: _T4Size.indicator, - color: scheme.onSurfaceVariant, - ), - ], - ), - ), - ), - ), - if (_expanded) - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.md, - 0, - _T4Space.md, - _T4Space.sm, - ), - child: section.child, - ), - Divider( - height: _T4Size.divider, - thickness: _T4Size.divider, - color: scheme.outlineVariant, - ), - ], - ); - } -} diff --git a/apps/flutter/lib/src/ui/conversation_pane.dart b/apps/flutter/lib/src/ui/conversation_pane.dart deleted file mode 100644 index 9bb6b47b..00000000 --- a/apps/flutter/lib/src/ui/conversation_pane.dart +++ /dev/null @@ -1,2364 +0,0 @@ -part of 't4_app.dart'; - -final class _ConversationPane extends StatelessWidget { - const _ConversationPane({ - required this.state, - required this.actions, - required this.showHeader, - required this.onConnect, - this.onOpenSessions, - required this.onOpenAttention, - required this.onOpenDeveloper, - required this.onOpenQuickOpen, - required this.onSelectSession, - }); - - final T4ViewState state; - final T4Actions actions; - final bool showHeader; - final Future Function() onConnect; - final VoidCallback? onOpenSessions; - final VoidCallback onOpenAttention; - final VoidCallback onOpenDeveloper; - final Future Function() onOpenQuickOpen; - final Future Function(String sessionId) onSelectSession; - - @override - Widget build(BuildContext context) { - final error = state.errorMessage?.trim(); - final showError = - (error != null && error.isNotEmpty) || - state.connectionPhase == ConnectionPhase.failed; - - return Column( - children: [ - if (showHeader) - _ConversationHeader( - state: state, - actions: actions, - onOpenDeveloper: onOpenDeveloper, - onOpenQuickOpen: onOpenQuickOpen, - onSelectSession: onSelectSession, - ), - if (showError) - _ConnectionErrorBanner( - message: error == null || error.isEmpty - ? 'The connection could not be established.' - : error, - canRetry: state.connectionPhase == ConnectionPhase.failed, - onRetry: onConnect, - ), - Expanded( - child: _TranscriptView( - state: state, - actions: actions, - onOpenSessions: onOpenSessions, - onOpenAttention: onOpenAttention, - onOpenDeveloper: onOpenDeveloper, - ), - ), - _PromptComposer(state: state, actions: actions), - ], - ); - } -} - -/// Wide-layout conversation header. Only the wide shell renders this widget -/// (compact keeps the app bar), so the inbox button anchors a 400 px flyout -/// here instead of toggling the full-screen attention takeover. -final class _ConversationHeader extends StatefulWidget { - const _ConversationHeader({ - required this.state, - required this.actions, - required this.onOpenDeveloper, - required this.onOpenQuickOpen, - required this.onSelectSession, - }); - - final T4ViewState state; - final T4Actions actions; - final VoidCallback onOpenDeveloper; - final Future Function() onOpenQuickOpen; - final Future Function(String sessionId) onSelectSession; - - @override - State<_ConversationHeader> createState() => _ConversationHeaderState(); -} - -final class _ConversationHeaderState extends State<_ConversationHeader> { - final MenuController _inboxMenu = MenuController(); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final state = widget.state; - final session = state.selectedSession; - final streaming = state.composer.turnActive; - - return DecoratedBox( - decoration: BoxDecoration( - color: scheme.surface, - border: Border(bottom: BorderSide(color: scheme.outlineVariant)), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.lg, - vertical: _T4Space.md, - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _displaySessionTitle(session), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleLarge, - ), - if (session != null && session.status.trim().isNotEmpty) ...[ - const SizedBox(height: _T4Space.xxs), - Text( - session.status, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ], - ), - ), - IconButton( - onPressed: - state.connectionPhase == ConnectionPhase.ready && - state.grantedFeatures.contains('files.search') && - state.grantedCapabilities.contains('files.list') && - state.grantedCapabilities.contains('files.read') && - session != null - ? () => unawaited(widget.onOpenQuickOpen()) - : null, - tooltip: 'Quick open project file', - icon: const Icon(Icons.search), - ), - IconButton( - onPressed: widget.onOpenDeveloper, - tooltip: 'Open developer tools', - icon: const Icon(Icons.code), - ), - Badge( - isLabelVisible: state.urgentAttentionCount > 0, - label: Text('${state.urgentAttentionCount}'), - child: MenuAnchor( - controller: _inboxMenu, - alignmentOffset: const Offset(0, _T4Space.xs), - menuChildren: [ - SizedBox( - width: 400, - height: 480, - child: InboxFlyoutContent( - state: state, - actions: widget.actions, - showTitle: false, - onDone: _inboxMenu.close, - onOpenSession: (sessionId) async { - await widget.onSelectSession(sessionId); - if (mounted) _inboxMenu.close(); - }, - ), - ), - ], - builder: (context, controller, child) => IconButton( - onPressed: () => _toggleMenu(controller), - tooltip: 'Open inbox', - icon: const Icon(Icons.inbox_outlined), - ), - ), - ), - const SizedBox(width: _T4Space.sm), - if (streaming) const _StreamingLabel(), - ], - ), - ), - ); - } -} - -final class _StreamingLabel extends StatelessWidget { - const _StreamingLabel(); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Semantics( - liveRegion: true, - label: 'Assistant is streaming a response', - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - color: scheme.primary, - ), - ), - const SizedBox(width: _T4Space.xs), - Text( - 'Streaming', - style: Theme.of( - context, - ).textTheme.labelMedium?.copyWith(color: scheme.primary), - ), - ], - ), - ); - } -} - -final class _ConnectionErrorBanner extends StatelessWidget { - const _ConnectionErrorBanner({ - required this.message, - required this.canRetry, - required this.onRetry, - }); - - final String message; - final bool canRetry; - final Future Function() onRetry; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Semantics( - container: true, - liveRegion: true, - label: 'Connection error: $message', - child: ColoredBox( - color: scheme.errorContainer, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.md, - vertical: _T4Space.xs, - ), - child: Row( - children: [ - Icon(Icons.error_outline, color: scheme.onErrorContainer), - const SizedBox(width: _T4Space.sm), - Expanded( - child: Text( - message, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.onErrorContainer, - ), - ), - ), - if (canRetry) ...[ - const SizedBox(width: _T4Space.xs), - TextButton( - onPressed: () => unawaited(onRetry()), - style: TextButton.styleFrom( - foregroundColor: scheme.onErrorContainer, - ), - child: const Text('Retry'), - ), - ], - ], - ), - ), - ), - ); - } -} - -final class _TranscriptView extends StatefulWidget { - const _TranscriptView({ - required this.state, - required this.actions, - required this.onOpenAttention, - this.onOpenSessions, - this.onOpenDeveloper, - }); - - final T4ViewState state; - final T4Actions actions; - final VoidCallback onOpenAttention; - final VoidCallback? onOpenSessions; - final VoidCallback? onOpenDeveloper; - - @override - State<_TranscriptView> createState() => _TranscriptViewState(); -} - -final class _TranscriptViewState extends State<_TranscriptView> { - final ScrollController _scrollController = ScrollController(); - final Set _expandedWorkGroups = {}; - final Set _respondingAttention = {}; - bool _followEnd = true; - bool _userScrolling = false; - - @override - void initState() { - super.initState(); - _scheduleScrollToEnd(animate: false); - } - - @override - void didUpdateWidget(covariant _TranscriptView oldWidget) { - super.didUpdateWidget(oldWidget); - final sessionChanged = - oldWidget.state.selectedSessionId != widget.state.selectedSessionId; - final messagesChanged = _visibleMessagesChanged( - oldWidget.state.messages, - widget.state.messages, - ); - final historyPrepended = - !sessionChanged && - !_followEnd && - _historyWasPrepended(oldWidget.state.messages, widget.state.messages); - if (historyPrepended && _scrollController.hasClients) { - final position = _scrollController.position; - _restoreHistoryDistanceFromEnd( - position.maxScrollExtent - position.pixels, - ); - } - if (!sessionChanged && !messagesChanged) return; - if (sessionChanged) _followEnd = true; - - final shouldFollow = sessionChanged || _followEnd; - if (shouldFollow) _scheduleScrollToEnd(animate: !sessionChanged); - } - - bool get _isNearEnd { - if (!_scrollController.hasClients) return true; - final position = _scrollController.position; - return position.maxScrollExtent - position.pixels <= - _T4Layout.followScrollThreshold; - } - - bool _trackScroll(ScrollNotification notification) { - if (notification is UserScrollNotification) { - if (notification.direction == ScrollDirection.idle) { - _followEnd = _isNearEnd; - _userScrolling = false; - } else { - _userScrolling = true; - _followEnd = false; - } - } else if (notification is ScrollStartNotification && - notification.dragDetails != null) { - _userScrolling = true; - _followEnd = false; - } else if (_userScrolling && notification is ScrollEndNotification) { - _followEnd = _isNearEnd; - _userScrolling = false; - } - return false; - } - - bool _trackScrollMetrics(ScrollMetricsNotification notification) { - if (_followEnd) _scheduleScrollToEnd(animate: false); - return false; - } - - bool _visibleMessagesChanged( - List previous, - List current, - ) { - if (previous.length != current.length) return true; - if (previous.isEmpty) return false; - final oldLast = previous.last; - final newLast = current.last; - return oldLast.id != newLast.id || - oldLast.text != newLast.text || - oldLast.streaming != newLast.streaming; - } - - bool _historyWasPrepended( - List previous, - List current, - ) { - if (previous.isEmpty || current.length <= previous.length) return false; - final offset = current.indexWhere( - (message) => message.id == previous.first.id, - ); - if (offset <= 0 || current.length < offset + previous.length) return false; - for (var index = 0; index < previous.length; index++) { - if (current[offset + index].id != previous[index].id) return false; - } - return true; - } - - void _restoreHistoryDistanceFromEnd( - double distanceFromEnd, { - int framesRemaining = 3, - }) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !_scrollController.hasClients) return; - final position = _scrollController.position; - final target = (position.maxScrollExtent - distanceFromEnd).clamp( - position.minScrollExtent, - position.maxScrollExtent, - ); - if ((position.pixels - target).abs() > 0.5) { - position.jumpTo(target); - } - if (framesRemaining > 1) { - _restoreHistoryDistanceFromEnd( - distanceFromEnd, - framesRemaining: framesRemaining - 1, - ); - } - }); - } - - void _scheduleScrollToEnd({required bool animate}) { - _followEnd = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !_scrollController.hasClients || !_followEnd) return; - final end = _scrollController.position.maxScrollExtent; - if (animate) { - unawaited( - _scrollController.animateTo( - end, - duration: _T4Motion.short, - curve: _T4Motion.standard, - ), - ); - } else { - _scrollController.jumpTo(end); - } - }); - } - - /// Collapses each maximal run of two or more completed tool/reasoning - /// steps between chat messages into one collapsible work group. The - /// trailing run of the currently streaming turn stays expanded/ungrouped. - List<_TranscriptEntry> _transcriptEntries() { - final messages = widget.state.messages; - final entries = <_TranscriptEntry>[]; - var index = 0; - while (index < messages.length) { - if (!_isWorkedMessage(messages[index])) { - entries.add(_SingleTranscriptEntry(messages[index])); - index += 1; - continue; - } - var end = index; - while (end < messages.length && _isWorkedMessage(messages[end])) { - end += 1; - } - final run = messages.sublist(index, end); - final trailingActiveRun = - end == messages.length && widget.state.composer.turnActive; - if (run.length < 2 || trailingActiveRun) { - entries.addAll(run.map(_SingleTranscriptEntry.new)); - } else { - entries.add(_WorkGroupTranscriptEntry(_TranscriptWorkGroup(run))); - } - index = end; - } - return entries; - } - - void _toggleWorkGroup(String key) { - setState(() { - if (!_expandedWorkGroups.remove(key)) _expandedWorkGroups.add(key); - }); - } - - void _showError(String message) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(message))); - } - - /// Mirrors the inbox pane's respond wiring (busy set, acceptance check, - /// error surfacing) for the inline approval cards. - Future _respondAttention( - AttentionItem item, - AttentionResponse response, - ) async { - if (!_respondingAttention.add(item.key)) return; - setState(() {}); - try { - final accepted = await widget.actions.respondToAttention(item, response); - if (!mounted) return; - if (!accepted) _showError('The host did not accept that response.'); - } on Object catch (error) { - if (mounted) _showError(error.toString().replaceFirst('Bad state: ', '')); - } finally { - if (mounted) setState(() => _respondingAttention.remove(item.key)); - } - } - - Future _askForText({ - required String title, - String hint = 'Add a note', - }) async { - final controller = TextEditingController(); - final result = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(title), - content: TextField( - controller: controller, - autofocus: true, - minLines: 2, - maxLines: 6, - decoration: InputDecoration(hintText: hint), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.pop(context, controller.text.trim()), - child: const Text('Send'), - ), - ], - ), - ); - controller.dispose(); - return result; - } - - Future _reviseAttention(AttentionItem item) async { - final note = await _askForText( - title: 'Request plan changes', - hint: 'What should change?', - ); - if (note != null) { - await _respondAttention( - item, - AttentionResponse(decision: AttentionDecision.revise, text: note), - ); - } - } - - Future _customAnswerAttention(AttentionItem item) async { - final answer = await _askForText( - title: item.title, - hint: 'Type your answer', - ); - if (answer != null && answer.isNotEmpty) { - await _respondAttention( - item, - AttentionResponse(decision: AttentionDecision.approve, text: answer), - ); - } - } - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final session = widget.state.selectedSession; - if (session == null) { - return _TranscriptEmptyState( - title: 'Choose a session', - message: widget.onOpenSessions == null - ? 'Select a session from the session rail.' - : 'Open sessions to choose a conversation.', - actionLabel: widget.onOpenSessions == null ? null : 'Open sessions', - onAction: widget.onOpenSessions, - ); - } - - if (widget.state.messages.isEmpty) { - final ready = widget.state.connectionPhase == ConnectionPhase.ready; - return _TranscriptEmptyState( - title: 'Start the conversation', - message: ready - ? 'Send a prompt when you’re ready.' - : 'You can send a prompt once the connection is ready.', - ); - } - - final showHistoryControl = - widget.state.transcriptHistoryLoading || - widget.state.transcriptHistoryHasMore || - widget.state.transcriptHistoryError != null; - final messageOffset = showHistoryControl ? 1 : 0; - final entries = _transcriptEntries(); - final inlineAttention = widget.state.attentionItems - .where( - (item) => - item.sessionId == widget.state.selectedSessionId && - item.needsResponse && - item.actionable, - ) - .toList(growable: false); - - return Column( - children: [ - if (widget.state.transcriptTailFromCache) - const _CachedTranscriptNotice(), - Expanded( - child: NotificationListener( - onNotification: _trackScrollMetrics, - child: NotificationListener( - onNotification: _trackScroll, - child: Scrollbar( - controller: _scrollController, - child: ListView.separated( - controller: _scrollController, - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - padding: const EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.lg, - _T4Space.md, - _T4Space.xl, - ), - itemCount: - entries.length + messageOffset + inlineAttention.length, - separatorBuilder: (context, index) => - const SizedBox(height: _T4Space.lg), - itemBuilder: (context, index) { - final Widget item; - if (showHistoryControl && index == 0) { - item = _TranscriptHistoryControl( - loading: widget.state.transcriptHistoryLoading, - hasMore: widget.state.transcriptHistoryHasMore, - error: widget.state.transcriptHistoryError, - onLoad: widget.actions.loadEarlierTranscript, - ); - } else if (index - messageOffset < entries.length) { - item = switch (entries[index - messageOffset]) { - _SingleTranscriptEntry(:final message) => - _TranscriptMessageView( - message: message, - actions: widget.actions, - ), - _WorkGroupTranscriptEntry(:final group) => - _WorkGroupView( - group: group, - expanded: _expandedWorkGroups.contains(group.key), - onToggle: () => _toggleWorkGroup(group.key), - actions: widget.actions, - onReview: widget.onOpenDeveloper, - ), - }; - } else { - final attention = - inlineAttention[index - - messageOffset - - entries.length]; - item = InlineApprovalCard( - item: attention, - busy: _respondingAttention.contains(attention.key), - canRespond: - widget.state.connectionPhase == - ConnectionPhase.ready && - attention.actionable, - onRespond: (response) => - unawaited(_respondAttention(attention, response)), - onRevise: () => unawaited(_reviseAttention(attention)), - onCustomAnswer: () => - unawaited(_customAnswerAttention(attention)), - onOpenInbox: widget.onOpenAttention, - ); - } - // Share one centered column axis with the composer: the - // transcript content is capped at the same - // [_T4Layout.contentMaxWidth] the composer uses. - return Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: SizedBox(width: double.infinity, child: item), - ), - ); - }, - ), - ), - ), - ), - ), - ], - ); - } -} - -/// A transcript row: either one message or a collapsed run of work steps. -sealed class _TranscriptEntry { - const _TranscriptEntry(); -} - -final class _SingleTranscriptEntry extends _TranscriptEntry { - const _SingleTranscriptEntry(this.message); - - final TranscriptMessage message; -} - -final class _WorkGroupTranscriptEntry extends _TranscriptEntry { - const _WorkGroupTranscriptEntry(this.group); - - final _TranscriptWorkGroup group; -} - -final class _TranscriptWorkGroup { - _TranscriptWorkGroup(this.messages) - : editedFiles = _editedFilePaths(messages); - - final List messages; - - /// Best-effort file paths touched by file-mutating tool calls in the group. - final List editedFiles; - - String get key => messages.first.id; -} - -/// Whether a message renders as pure background work (a completed tool card -/// or a text-less reasoning disclosure) and may collapse into a work group. -bool _isWorkedMessage(TranscriptMessage message) { - if (message.streaming) return false; - if (message.kind == TranscriptKind.tool) return !message.toolRunning; - return message.kind == TranscriptKind.message && - message.role == MessageRole.assistant && - message.text.isEmpty && - message.images.isEmpty && - message.reasoning.isNotEmpty; -} - -/// Matches the file-mutating wire commands (`files.write`, `files.patch`, -/// `review.apply`, apply_patch-style names) without inventing new ones. -bool _isFileMutatingToolName(String? name) { - if (name == null) return false; - final lower = name.toLowerCase(); - if (lower == 'review.apply' || lower.contains('apply_patch')) return true; - if (!lower.startsWith('files.')) return false; - final verb = lower.substring('files.'.length); - return verb.startsWith('write') || - verb.startsWith('patch') || - verb.startsWith('apply'); -} - -/// Derives file paths from tool-call argument JSON ('path'/'file'/'files'). -List _editedFilePaths(List messages) { - final paths = {}; - for (final message in messages) { - if (message.kind != TranscriptKind.tool) continue; - if (!_isFileMutatingToolName(message.toolName)) continue; - final raw = message.toolArguments; - if (raw == null || raw.isEmpty) continue; - Object? decoded; - try { - decoded = jsonDecode(raw); - } on FormatException { - continue; - } - if (decoded is! Map) continue; - void addPath(Object? value) { - if (value is String && value.trim().isNotEmpty) paths.add(value.trim()); - } - - addPath(decoded['path']); - addPath(decoded['file']); - if (decoded['files'] case final List files) { - for (final entry in files) { - if (entry is Map) { - addPath(entry['path']); - addPath(entry['file']); - } else { - addPath(entry); - } - } - } - } - return paths.toList(growable: false); -} - -/// Collapsible 'Worked · N steps' row wrapping a run of completed steps. -/// Expanded rendering is identical to the ungrouped transcript. -final class _WorkGroupView extends StatelessWidget { - const _WorkGroupView({ - required this.group, - required this.expanded, - required this.onToggle, - required this.actions, - this.onReview, - }); - - final _TranscriptWorkGroup group; - final bool expanded; - final VoidCallback onToggle; - final T4Actions actions; - final VoidCallback? onReview; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final labelStyle = Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: scheme.onSurfaceVariant); - final steps = group.messages.length; - final edited = group.editedFiles; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: _T4Space.xxs), - child: Semantics( - button: true, - label: - 'Worked · $steps steps, ${expanded ? 'expanded' : 'collapsed'}', - child: InkWell( - borderRadius: BorderRadius.circular(_T4Radius.sm), - onTap: onToggle, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.xxs, - vertical: _T4Space.xxs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - expanded ? Icons.expand_more : Icons.chevron_right, - size: 14, - color: scheme.onSurfaceVariant, - ), - const SizedBox(width: _T4Space.xxs), - Text('Worked · $steps steps', style: labelStyle), - ], - ), - ), - ), - ), - ), - if (edited.isNotEmpty) - Padding( - padding: const EdgeInsets.only( - left: _T4Space.xxs, - bottom: _T4Space.xxs, - ), - child: Wrap( - spacing: _T4Space.xs, - runSpacing: _T4Space.xxs, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - edited.length == 1 - ? 'Edited 1 file' - : 'Edited ${edited.length} files', - style: labelStyle, - ), - for (final path in edited.take(3)) _EditedFileChip(path: path), - if (edited.length > 3) - Text('+${edited.length - 3} more', style: labelStyle), - if (onReview != null) - TextButton( - onPressed: onReview, - style: TextButton.styleFrom( - foregroundColor: scheme.onSurfaceVariant, - textStyle: const TextStyle(fontSize: 12), - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.xs, - ), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: const Text('Review'), - ), - ], - ), - ), - if (expanded) ...[ - const SizedBox(height: _T4Space.xs), - for (var index = 0; index < group.messages.length; index++) ...[ - if (index > 0) const SizedBox(height: _T4Space.lg), - _TranscriptMessageView( - message: group.messages[index], - actions: actions, - ), - ], - ], - ], - ); - } -} - -final class _EditedFileChip extends StatelessWidget { - const _EditedFileChip({required this.path}); - - final String path; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final basename = path.split('/').last.split('\\').last; - return Tooltip( - message: path, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.xs, - vertical: 1, - ), - decoration: BoxDecoration( - color: scheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 140), - child: Text( - basename, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: _T4Typography.monoFamily, - fontSize: 11, - color: scheme.onSurfaceVariant, - ), - ), - ), - ), - ); - } -} - -final class _CachedTranscriptNotice extends StatelessWidget { - const _CachedTranscriptNotice(); - - @override - Widget build(BuildContext context) => Semantics( - liveRegion: true, - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.md, - vertical: _T4Space.xs, - ), - color: Theme.of(context).colorScheme.surfaceContainerLow, - child: Text( - 'Showing encrypted saved messages while the live transcript connects.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ); -} - -final class _TranscriptHistoryControl extends StatelessWidget { - const _TranscriptHistoryControl({ - required this.loading, - required this.hasMore, - required this.error, - required this.onLoad, - }); - - final bool loading; - final bool hasMore; - final String? error; - final Future Function() onLoad; - - @override - Widget build(BuildContext context) { - return Semantics( - container: true, - label: loading - ? 'Loading earlier messages' - : 'Earlier transcript history', - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (error != null) - Padding( - padding: const EdgeInsets.only(bottom: _T4Space.xs), - child: Text( - error!, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.error, - ), - ), - ), - OutlinedButton.icon( - onPressed: loading || !hasMore ? null : () => unawaited(onLoad()), - icon: loading - ? const SizedBox.square( - dimension: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.history, size: 18), - label: Text( - loading ? 'Loading earlier messages' : 'Load earlier messages', - ), - ), - ], - ), - ); - } -} - -final class _TranscriptEmptyState extends StatelessWidget { - const _TranscriptEmptyState({ - required this.title, - required this.message, - this.actionLabel, - this.onAction, - }); - - final String title; - final String message; - final String? actionLabel; - final VoidCallback? onAction; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: _T4Layout.contentMaxWidth), - child: Padding( - padding: const EdgeInsets.all(_T4Space.xl), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.chat_bubble_outline, - size: _T4Size.emptyIcon, - color: scheme.outline, - ), - const SizedBox(height: _T4Space.md), - Text( - title, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: _T4Space.xs), - Text( - message, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - if (actionLabel case final label?) ...[ - const SizedBox(height: _T4Space.lg), - TextButton(onPressed: onAction, child: Text(label)), - ], - ], - ), - ), - ), - ); - } -} - -extension on MessageRole { - String get label => switch (this) { - MessageRole.user => 'You', - MessageRole.assistant => 'Assistant', - MessageRole.system => 'System', - MessageRole.tool => 'Tool', - }; -} - -final class _TranscriptMessageView extends StatelessWidget { - const _TranscriptMessageView({required this.message, required this.actions}); - - final TranscriptMessage message; - final T4Actions actions; - - @override - Widget build(BuildContext context) { - if (message.kind == TranscriptKind.tool) { - return _ToolTranscriptCard(message: message); - } - final scheme = Theme.of(context).colorScheme; - final isUser = message.role == MessageRole.user; - final isAuxiliary = message.role == MessageRole.system; - final label = message.kind == TranscriptKind.compaction - ? 'Earlier chat summary' - : message.role.label; - // User and assistant messages carry no visible role text; screen readers - // still announce the role through the enclosing [Semantics] label. - final showVisualLabel = - isAuxiliary || message.kind == TranscriptKind.compaction; - final background = isUser - ? scheme.surfaceContainerHigh - : isAuxiliary - ? scheme.surfaceContainerLow - : Colors.transparent; - - Widget block = DecoratedBox( - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(_T4Radius.md), - border: isAuxiliary ? Border.all(color: scheme.outlineVariant) : null, - ), - child: Padding( - padding: isUser || isAuxiliary - ? const EdgeInsets.all(_T4Space.md) - : const EdgeInsets.symmetric(vertical: _T4Space.xs), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showVisualLabel) - Text( - label.toUpperCase(), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - if (message.reasoning.isNotEmpty) ...[ - if (showVisualLabel) const SizedBox(height: _T4Space.xs), - _ReasoningDisclosure( - reasoning: message.reasoning, - streaming: message.streaming, - ), - ], - if (message.text.isNotEmpty) ...[ - if (showVisualLabel || message.reasoning.isNotEmpty) - const SizedBox(height: _T4Space.xs), - TranscriptMarkdown(data: message.text), - ], - if (message.images.isNotEmpty) ...[ - const SizedBox(height: _T4Space.sm), - Wrap( - spacing: _T4Space.sm, - runSpacing: _T4Space.sm, - children: [ - for (final image in message.images) - _TranscriptImage( - entryId: message.id, - image: image, - actions: actions, - ), - ], - ), - ], - if (message.streaming) ...[ - const SizedBox(height: _T4Space.sm), - const _StreamingLabel(), - ], - ], - ), - ), - ); - if (message.role == MessageRole.assistant && - message.kind == TranscriptKind.message && - message.text.isNotEmpty) { - block = _MessageCopyAffordance(markdown: message.text, child: block); - } - - return Align( - alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: _T4Layout.contentMaxWidth), - child: Semantics( - container: true, - label: '$label message${message.streaming ? ', streaming' : ''}', - child: block, - ), - ), - ); - } -} - -/// Hover-revealed (or long-press-revealed on touch) copy affordance for an -/// assistant message; copies the raw markdown source. Icon swaps to a check -/// briefly, matching the [CodeBlock] copy pattern. -final class _MessageCopyAffordance extends StatefulWidget { - const _MessageCopyAffordance({required this.markdown, required this.child}); - - final String markdown; - final Widget child; - - @override - State<_MessageCopyAffordance> createState() => _MessageCopyAffordanceState(); -} - -final class _MessageCopyAffordanceState extends State<_MessageCopyAffordance> { - static const Duration _copiedFeedback = Duration(milliseconds: 1500); - static const Duration _longPressReveal = Duration(seconds: 4); - - bool _hovering = false; - bool _revealed = false; - bool _copied = false; - Timer? _copyTimer; - Timer? _revealTimer; - - @override - void dispose() { - _copyTimer?.cancel(); - _revealTimer?.cancel(); - super.dispose(); - } - - void _revealFromLongPress() { - setState(() => _revealed = true); - _revealTimer?.cancel(); - _revealTimer = Timer(_longPressReveal, () { - if (mounted) setState(() => _revealed = false); - }); - } - - Future _copy() async { - await Clipboard.setData(ClipboardData(text: widget.markdown)); - if (!mounted) return; - setState(() => _copied = true); - _copyTimer?.cancel(); - _copyTimer = Timer(_copiedFeedback, () { - if (mounted) setState(() => _copied = false); - }); - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final visible = _hovering || _revealed || _copied; - return MouseRegion( - onEnter: (_) => setState(() => _hovering = true), - onExit: (_) => setState(() => _hovering = false), - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onLongPress: _revealFromLongPress, - child: Stack( - clipBehavior: Clip.none, - children: [ - widget.child, - if (visible) - Positioned( - top: 0, - right: 0, - child: IconButton( - onPressed: () => unawaited(_copy()), - tooltip: _copied ? 'Copied' : 'Copy message', - iconSize: 14, - visualDensity: VisualDensity.compact, - icon: Icon( - _copied ? Icons.check_rounded : Icons.copy_rounded, - color: _copied ? scheme.primary : scheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ), - ); - } -} - -final class _ReasoningDisclosure extends StatelessWidget { - const _ReasoningDisclosure({ - required this.reasoning, - required this.streaming, - }); - - final String reasoning; - final bool streaming; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return DecoratedBox( - decoration: BoxDecoration( - color: scheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - child: ExpansionTile( - dense: true, - shape: const Border(), - collapsedShape: const Border(), - leading: Icon( - streaming ? Icons.psychology_alt_outlined : Icons.psychology_outlined, - size: 20, - ), - title: Text(streaming ? 'Reasoning · streaming' : 'Reasoning'), - childrenPadding: const EdgeInsets.fromLTRB( - _T4Space.md, - 0, - _T4Space.md, - _T4Space.md, - ), - children: [TranscriptMarkdown(data: reasoning)], - ), - ); - } -} - -final class _ToolTranscriptCard extends StatelessWidget { - const _ToolTranscriptCard({required this.message}); - - final TranscriptMessage message; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final semantic = T4SemanticColors.of(context); - final statusIcon = message.toolRunning - ? Icons.sync - : message.toolSucceeded == false - ? Icons.error_outline - : Icons.check_circle_outline; - final statusColor = message.toolRunning - ? semantic.statusWorking - : message.toolSucceeded == false - ? semantic.statusError - : semantic.statusDone; - return Align( - alignment: Alignment.centerLeft, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: _T4Layout.contentMaxWidth), - child: DecoratedBox( - decoration: BoxDecoration( - color: scheme.surfaceContainerLow, - border: Border.all(color: scheme.outlineVariant), - borderRadius: BorderRadius.circular(_T4Radius.md), - ), - child: ExpansionTile( - shape: const Border(), - collapsedShape: const Border(), - leading: Icon(statusIcon, color: statusColor), - title: Text(message.toolTitle ?? message.toolName ?? 'Tool'), - subtitle: message.text.isEmpty ? null : Text(message.text), - trailing: message.toolRunning - ? SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - value: message.toolProgress, - strokeWidth: _T4Size.thinStroke, - ), - ) - : null, - childrenPadding: const EdgeInsets.fromLTRB( - _T4Space.md, - 0, - _T4Space.md, - _T4Space.md, - ), - children: [ - if (message.toolArguments case final arguments?) - _ToolPayload(label: 'Arguments', value: arguments), - if (message.toolOutput case final output?) - _ToolPayload(label: 'Result', value: output), - ], - ), - ), - ), - ); - } -} - -final class _ToolPayload extends StatelessWidget { - const _ToolPayload({required this.label, required this.value}); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.only(top: _T4Space.sm), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - label.toUpperCase(), - style: Theme.of(context).textTheme.labelSmall, - ), - const SizedBox(height: _T4Space.xs), - DecoratedBox( - decoration: BoxDecoration( - color: scheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - child: Padding( - padding: const EdgeInsets.all(_T4Space.sm), - child: SelectionArea( - child: Text( - value, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: _T4Typography.monoFamily, - ), - ), - ), - ), - ), - ], - ), - ); - } -} - -final class _TranscriptImage extends StatefulWidget { - const _TranscriptImage({ - required this.entryId, - required this.image, - required this.actions, - }); - - final String entryId; - final TranscriptImageMetadata image; - final T4Actions actions; - - @override - State<_TranscriptImage> createState() => _TranscriptImageState(); -} - -final class _TranscriptImageState extends State<_TranscriptImage> { - late Future _image; - - @override - void initState() { - super.initState(); - _image = widget.actions.readTranscriptImage(widget.entryId, widget.image); - } - - @override - void didUpdateWidget(covariant _TranscriptImage oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.entryId != widget.entryId || - oldWidget.image.sha256 != widget.image.sha256) { - _image = widget.actions.readTranscriptImage(widget.entryId, widget.image); - } - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return FutureBuilder( - future: _image, - builder: (context, snapshot) { - if (snapshot.hasData) { - return ClipRRect( - borderRadius: BorderRadius.circular(_T4Radius.sm), - child: Image.memory( - snapshot.data!, - width: 160, - height: 120, - fit: BoxFit.cover, - semanticLabel: 'Transcript image', - ), - ); - } - return Container( - width: 160, - height: 120, - alignment: Alignment.center, - decoration: BoxDecoration( - color: scheme.surfaceContainerLow, - border: Border.all(color: scheme.outlineVariant), - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - child: snapshot.hasError - ? IconButton( - tooltip: 'Retry image', - onPressed: () => setState(() { - _image = widget.actions.readTranscriptImage( - widget.entryId, - widget.image, - ); - }), - icon: const Icon(Icons.refresh), - ) - : const CircularProgressIndicator(), - ); - }, - ); - } -} - -final class _PromptComposer extends StatefulWidget { - const _PromptComposer({required this.state, required this.actions}); - - final T4ViewState state; - final T4Actions actions; - - @override - State<_PromptComposer> createState() => _PromptComposerState(); -} - -final class _PromptComposerState extends State<_PromptComposer> { - static const int _maximumImages = 8; - static const int _maximumImageBytes = 20 * 1024 * 1024; - - final TextEditingController _textController = TextEditingController(); - final FocusNode _focusNode = FocusNode(debugLabel: 'Prompt composer'); - final Map _drafts = {}; - final Map> _attachments = - >{}; - String? _sessionId; - bool _hasText = false; - bool _sending = false; - - List get _currentAttachments => _sessionId == null - ? const [] - : _attachments[_sessionId] ?? const []; - - bool get _ready => - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.selectedSession != null && - widget.state.grantedCapabilities.contains('sessions.prompt') && - !widget.state.composer.isPaused && - !_sending; - - bool get _canSubmit => - _ready && - (_hasText || _currentAttachments.isNotEmpty) && - (!widget.state.composer.turnActive || _currentAttachments.isEmpty); - - bool get _canQueue => _ready && widget.state.composer.turnActive && _hasText; - - @override - void initState() { - super.initState(); - _sessionId = widget.state.selectedSessionId; - _textController.addListener(_handleTextChanged); - } - - @override - void didUpdateWidget(covariant _PromptComposer oldWidget) { - super.didUpdateWidget(oldWidget); - final nextSessionId = widget.state.selectedSessionId; - if (nextSessionId == _sessionId) return; - final previousSessionId = _sessionId; - if (previousSessionId != null) { - _drafts[previousSessionId] = _textController.text; - } - _sessionId = nextSessionId; - _textController.text = nextSessionId == null - ? '' - : _drafts[nextSessionId] ?? ''; - _textController.selection = TextSelection.collapsed( - offset: _textController.text.length, - ); - } - - void _handleTextChanged() { - final sessionId = _sessionId; - if (sessionId != null) _drafts[sessionId] = _textController.text; - final hasText = _textController.text.trim().isNotEmpty; - if (hasText != _hasText && mounted) { - setState(() => _hasText = hasText); - } else if (mounted && _textController.text.startsWith('/')) { - setState(() {}); - } - } - - void _showError(String message) { - final messenger = ScaffoldMessenger.of(context); - messenger - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(message))); - } - - Future _submit() async { - if (!_canSubmit) return; - final message = _textController.text.trim(); - final attachments = List.of(_currentAttachments); - final draftAtSubmission = _textController.text; - setState(() => _sending = true); - try { - final accepted = await widget.actions.submitPrompt( - message, - images: attachments, - ); - if (!mounted || !accepted) return; - if (_textController.text == draftAtSubmission) _textController.clear(); - final sessionId = _sessionId; - if (sessionId != null) _attachments.remove(sessionId); - _focusNode.requestFocus(); - } on Object { - if (mounted) _showError('Could not send the prompt. Try again.'); - } finally { - if (mounted) setState(() => _sending = false); - } - } - - Future _queue() async { - if (!_canQueue) return; - final message = _textController.text.trim(); - try { - final accepted = await widget.actions.queuePrompt(message); - if (!mounted || !accepted) return; - _textController.clear(); - _focusNode.requestFocus(); - } on Object { - if (mounted) _showError('Could not queue the follow-up.'); - } - } - - Future _pickImages() async { - final available = _maximumImages - _currentAttachments.length; - if (!_ready || available <= 0) return; - try { - final files = await openFiles( - acceptedTypeGroups: const [ - XTypeGroup( - label: 'Images', - extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'], - ), - ], - ); - if (!mounted || files.isEmpty) return; - final selected = []; - for (final file in files.take(available)) { - final size = await file.length(); - if (size <= 0) continue; - if (size > _maximumImageBytes) { - _showError('${file.name} is larger than 20 MB.'); - continue; - } - final extension = file.name.split('.').last.toLowerCase(); - final mimeType = switch (extension) { - 'png' => 'image/png', - 'jpg' || 'jpeg' => 'image/jpeg', - 'gif' => 'image/gif', - 'webp' => 'image/webp', - _ => null, - }; - if (mimeType == null) continue; - final bytes = await file.readAsBytes(); - selected.add( - PromptImageAttachment( - id: '${DateTime.now().microsecondsSinceEpoch}-${file.name}', - name: file.name, - mimeType: mimeType, - bytes: bytes, - ), - ); - } - final sessionId = _sessionId; - if (sessionId != null && selected.isNotEmpty) { - setState(() { - _attachments[sessionId] = [ - ..._currentAttachments, - ...selected, - ]; - }); - } - } on Object { - if (mounted) _showError('Could not open the selected image.'); - } - } - - void _removeAttachment(String id) { - final sessionId = _sessionId; - if (sessionId == null) return; - setState(() { - _attachments[sessionId] = _currentAttachments - .where((attachment) => attachment.id != id) - .toList(growable: false); - }); - } - - void _selectSlashCommand(ComposerSlashCommand command) { - if (command.disabledReason != null) return; - _textController - ..text = command.insert - ..selection = TextSelection.collapsed(offset: command.insert.length); - _focusNode.requestFocus(); - } - - Future _runControl(Future Function() operation) async { - try { - await operation(); - } on Object { - if (mounted) _showError('Could not update the session control.'); - } - } - - @override - void dispose() { - _textController - ..removeListener(_handleTextChanged) - ..dispose(); - _focusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final composer = widget.state.composer; - final showControls = - widget.state.selectedSession != null && - widget.state.grantedCapabilities.contains('sessions.control'); - final canControl = - showControls && - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('sessions.control') && - !widget.state.sessionOperationPending; - final slashQuery = _textController.text.startsWith('/') - ? _textController.text.split(RegExp(r'\s')).first.toLowerCase() - : ''; - final slashCommands = slashQuery.isEmpty - ? const [] - : composer.slashCommands - .where( - (command) => - command.name.toLowerCase().contains(slashQuery) || - command.aliases.any( - (alias) => alias.toLowerCase().contains(slashQuery), - ), - ) - .take(5) - .toList(growable: false); - - return DecoratedBox( - decoration: BoxDecoration(color: scheme.surface), - child: SafeArea( - top: false, - minimum: const EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.sm, - _T4Space.md, - _T4Space.sm, - ), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (slashCommands.isNotEmpty) - Card( - margin: const EdgeInsets.only(bottom: _T4Space.xs), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final command in slashCommands) - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - enabled: command.disabledReason == null, - title: Text( - command.name, - style: const TextStyle(fontSize: 12), - ), - subtitle: Text( - command.disabledReason ?? - (command.aliases.isEmpty - ? command.description - : '${command.description} · ${command.aliases.join(' ')}'), - style: const TextStyle(fontSize: 12), - ), - onTap: command.disabledReason == null - ? () => _selectSlashCommand(command) - : null, - ), - ], - ), - ), - // Single Codex-style composer container: text field on top, - // controls embedded along the bottom edge. - Container( - decoration: BoxDecoration( - color: scheme.surface, - borderRadius: BorderRadius.circular(_composerRadius), - border: Border.all(color: scheme.outlineVariant), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_currentAttachments.isNotEmpty) - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.sm, - _T4Space.xs, - _T4Space.sm, - 0, - ), - child: SizedBox( - height: 72, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: _currentAttachments.length, - separatorBuilder: (context, index) => - const SizedBox(width: _T4Space.xs), - itemBuilder: (context, index) { - final attachment = _currentAttachments[index]; - return InputChip( - visualDensity: VisualDensity.compact, - labelStyle: const TextStyle(fontSize: 12), - avatar: ClipRRect( - borderRadius: BorderRadius.circular( - _T4Radius.sm, - ), - child: Image.memory( - attachment.bytes, - width: 40, - height: 40, - fit: BoxFit.cover, - ), - ), - label: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 120, - ), - child: Text( - attachment.name, - overflow: TextOverflow.ellipsis, - ), - ), - onDeleted: _sending - ? null - : () => _removeAttachment(attachment.id), - ); - }, - ), - ), - ), - CallbackShortcuts( - bindings: { - const SingleActivator(LogicalKeyboardKey.enter): () => - unawaited(_submit()), - }, - child: Semantics( - textField: true, - label: 'Prompt message', - child: TextField( - controller: _textController, - focusNode: _focusNode, - readOnly: _sending, - minLines: 1, - maxLines: 6, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.newline, - decoration: InputDecoration( - hintText: widget.state.selectedSession == null - ? 'Choose a session to begin' - : composer.isPaused - ? 'Resume the session to continue' - : composer.turnActive - ? 'Steer the active turn' - : 'Message T4', - isDense: true, - filled: false, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - contentPadding: const EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.sm, - _T4Space.md, - _T4Space.xs, - ), - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.xxs, - 0, - _T4Space.xs, - _T4Space.xxs, - ), - child: Wrap( - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.center, - runSpacing: _T4Space.xxs, - children: [ - Wrap( - spacing: _T4Space.xxs, - runSpacing: _T4Space.xxs, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - IconButton( - tooltip: 'Attach image', - visualDensity: VisualDensity.compact, - iconSize: 18, - onPressed: - _ready && - _currentAttachments.length < - _maximumImages - ? () => unawaited(_pickImages()) - : null, - icon: const Icon(Icons.attach_file), - ), - if (showControls && composer.isPaused) - _ComposerQuietButton( - icon: Icons.play_arrow, - label: 'Resume', - onPressed: canControl - ? () => unawaited( - _runControl( - widget.actions.resumeSession, - ), - ) - : null, - ) - else if (showControls && composer.turnActive) - _ComposerQuietButton( - icon: Icons.pause, - label: 'Pause', - onPressed: canControl - ? () => unawaited( - _runControl( - widget.actions.pauseSession, - ), - ) - : null, - ), - if (composer.turnActive) ...[ - _ComposerQuietButton( - icon: Icons.stop, - label: 'Stop', - onPressed: () => unawaited( - _runControl(widget.actions.cancelTurn), - ), - ), - _ComposerQuietButton( - icon: Icons.playlist_add, - label: composer.queuedFollowUpCount == 0 - ? 'Queue' - : 'Queue (${composer.queuedFollowUpCount})', - onPressed: _canQueue - ? () => unawaited(_queue()) - : null, - ), - ], - ], - ), - Wrap( - spacing: _T4Space.xxs, - runSpacing: _T4Space.xxs, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - _AccessSummaryPill(state: widget.state), - _ThinkingSelectorMenu( - composer: composer, - onSelect: (level) => unawaited( - _runControl( - () => widget.actions.setSessionThinking( - level, - ), - ), - ), - ), - _ModelSelectorMenu( - composer: composer, - onSelect: (selector) => unawaited( - _runControl( - () => widget.actions.setSessionModel( - selector, - ), - ), - ), - onToggleFast: (enabled) => unawaited( - _runControl( - () => widget.actions.setSessionFast( - enabled, - ), - ), - ), - showCompact: - showControls && - !composer.isPaused && - !composer.turnActive, - onCompact: canControl - ? () => unawaited( - _runControl( - widget.actions.compactSession, - ), - ) - : null, - ), - SizedBox.square( - dimension: _composerSendSize, - child: IconButton.filled( - tooltip: composer.turnActive - ? 'Steer' - : 'Send', - style: IconButton.styleFrom( - minimumSize: const Size.square( - _composerSendSize, - ), - fixedSize: const Size.square( - _composerSendSize, - ), - padding: EdgeInsets.zero, - tapTargetSize: - MaterialTapTargetSize.shrinkWrap, - ), - iconSize: 16, - onPressed: _canSubmit - ? () => unawaited(_submit()) - : null, - icon: _sending - ? const SizedBox.square( - dimension: 14, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - semanticsLabel: 'Sending', - ), - ) - : const Icon(Icons.arrow_upward), - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -/// Composer control metrics shared by the redesigned prompt container. -const double _composerRadius = 12; -const double _composerSendSize = 32; - -/// Shared style for composer selector menus. The pills sit on the bottom edge -/// of a bottom-docked composer, so the menu layout always flips the surface -/// upward above the anchor (with the [Offset] gap) instead of clipping below -/// or overlapping the text field row. -const MenuStyle _composerMenuStyle = MenuStyle( - visualDensity: VisualDensity.compact, - maximumSize: WidgetStatePropertyAll(Size(280, 360)), -); -const Offset _composerMenuOffset = Offset(0, _T4Space.xs); - -final ButtonStyle _composerMenuItemStyle = MenuItemButton.styleFrom( - visualDensity: VisualDensity.compact, - textStyle: const TextStyle(fontSize: 12), - minimumSize: const Size.fromHeight(36), -); - -/// Quiet 12 px text control for the composer's turn controls -/// (pause/resume/stop/queue). -final class _ComposerQuietButton extends StatelessWidget { - const _ComposerQuietButton({ - required this.icon, - required this.label, - required this.onPressed, - }); - - final IconData icon; - final String label; - final VoidCallback? onPressed; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return TextButton( - onPressed: onPressed, - style: TextButton.styleFrom( - foregroundColor: scheme.onSurfaceVariant, - textStyle: const TextStyle(fontSize: 12), - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.xs, - vertical: _T4Space.xxs, - ), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - shape: const StadiumBorder(), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14), - const SizedBox(width: _T4Space.xxs), - Flexible( - child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), - ), - ], - ), - ); - } -} - -/// Quiet pill anchor shared by the thinking and model selector menus. -final class _ComposerPill extends StatelessWidget { - const _ComposerPill({ - required this.label, - required this.onTap, - this.maxLabelWidth, - this.tooltip, - }); - - final String label; - final VoidCallback? onTap; - final double? maxLabelWidth; - final String? tooltip; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final enabled = onTap != null; - final color = enabled ? scheme.onSurfaceVariant : scheme.outline; - Widget pill = Material( - color: Colors.transparent, - shape: const StadiumBorder(), - child: InkWell( - customBorder: const StadiumBorder(), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.xs, - vertical: _T4Space.xxs + 1, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: maxLabelWidth ?? double.infinity, - ), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 12, color: color), - ), - ), - const SizedBox(width: 2), - Icon(Icons.keyboard_arrow_up, size: 14, color: color), - ], - ), - ), - ), - ); - if (tooltip case final message?) { - pill = Tooltip(message: message, child: pill); - } - return pill; - } -} - -/// Thinking-level selector as a quiet pill; the menu opens upward above the -/// pill and never overlaps the text field. -final class _ThinkingSelectorMenu extends StatelessWidget { - const _ThinkingSelectorMenu({required this.composer, required this.onSelect}); - - final SessionComposerState composer; - final ValueChanged onSelect; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final enabled = composer.thinkingLevels.isNotEmpty; - return Semantics( - label: 'Thinking: ${composer.thinking ?? 'Default'}', - button: true, - child: MenuAnchor( - style: _composerMenuStyle, - alignmentOffset: _composerMenuOffset, - menuChildren: [ - for (final level in composer.thinkingLevels) - MenuItemButton( - key: ValueKey('thinking-$level'), - style: _composerMenuItemStyle, - leadingIcon: composer.thinking == level - ? Icon( - Icons.check, - size: 16, - color: theme.colorScheme.primary, - ) - : null, - onPressed: () => onSelect(level), - child: Text(level), - ), - ], - builder: (context, controller, child) => _ComposerPill( - label: composer.thinking ?? 'Thinking', - maxLabelWidth: 88, - tooltip: 'Choose thinking level', - onTap: enabled ? () => _toggleMenu(controller) : null, - ), - ), - ); - } -} - -void _toggleMenu(MenuController controller) { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } -} - -/// Provider-grouped model selector for the composer. -/// -/// Replaces the giant flat catalog list with a navigable provider → model menu -/// that mirrors the Electron/web T4 model picker. Human model names render as -/// the primary label; raw `provider/modelId` selectors are carried through as -/// the submitted values. Unknown providers/models stay selectable under an -/// 'Other models' group. A single-provider host collapses to a flat list so -/// the user is never forced through a one-item submenu. -/// -/// The menu also hosts the session's Fast toggle (checkable item) and the -/// manual Compact action, so the pill stays enabled whenever any of those -/// entries are present. -final class _ModelSelectorMenu extends StatelessWidget { - const _ModelSelectorMenu({ - required this.composer, - required this.onSelect, - this.onToggleFast, - this.showCompact = false, - this.onCompact, - }); - - final SessionComposerState composer; - final ValueChanged onSelect; - - /// Receives the requested Fast state; the item only renders when the host - /// advertises Fast availability. - final ValueChanged? onToggleFast; - final bool showCompact; - final VoidCallback? onCompact; - - @override - Widget build(BuildContext context) { - final groups = composer.modelGroups; - final hasModels = groups.any((group) => group.choices.isNotEmpty); - final showFast = composer.fastAvailable && onToggleFast != null; - final enabled = hasModels || showFast || showCompact; - final theme = Theme.of(context); - return Semantics( - label: 'Model: ${composer.modelLabel ?? 'None'}', - button: true, - child: MenuAnchor( - style: _composerMenuStyle, - alignmentOffset: _composerMenuOffset, - menuChildren: [ - if (groups.length == 1) - for (final choice in groups.single.choices) - _modelItem(choice, theme) - else - for (final group in groups) - if (group.choices.isNotEmpty) - SubmenuButton( - key: ValueKey('model-provider-${group.provider}'), - style: _composerMenuItemStyle, - menuStyle: _composerMenuStyle, - leadingIcon: const Icon(Icons.folder_outlined, size: 18), - menuChildren: [ - for (final choice in group.choices) - _modelItem(choice, theme), - ], - child: Text(group.label, overflow: TextOverflow.ellipsis), - ), - if (hasModels && (showFast || showCompact)) const Divider(height: 1), - if (showFast) - CheckboxMenuButton( - value: composer.fastEnabled, - style: _composerMenuItemStyle, - onChanged: (checked) => onToggleFast!(checked ?? false), - child: const Text('Fast'), - ), - if (showCompact) - MenuItemButton( - style: _composerMenuItemStyle, - leadingIcon: const Icon(Icons.compress, size: 16), - onPressed: onCompact, - child: const Text('Compact'), - ), - ], - builder: (context, controller, child) => _ComposerPill( - label: composer.modelLabel ?? 'Model', - maxLabelWidth: 120, - tooltip: 'Choose model', - onTap: enabled ? () => _toggleMenu(controller) : null, - ), - ), - ); - } - - Widget _modelItem(ResolvedModelChoice choice, ThemeData theme) { - final selected = composer.modelSelector == choice.selector; - final label = choice.supported - ? choice.label - : '${choice.label} · ${choice.reason ?? 'Unavailable'}'; - return MenuItemButton( - key: ValueKey('model-${choice.selector}'), - style: _composerMenuItemStyle, - onPressed: choice.supported ? () => onSelect(choice.selector) : null, - leadingIcon: selected - ? Icon(Icons.check, size: 18, color: theme.colorScheme.primary) - : null, - child: Text(label, overflow: TextOverflow.ellipsis), - ); - } -} - -/// Informational access pill for the composer controls: summarizes the -/// capabilities the host actually granted. Selection is a no-op — the wire -/// protocol has no capability-mutation command. -final class _AccessSummaryPill extends StatelessWidget { - const _AccessSummaryPill({required this.state}); - - final T4ViewState state; - - /// Display order and labels for the summarized capability groups, keyed by - /// the capability prefix used on the wire (`term.input`, `files.write`, …). - static const Map _groupLabels = { - 'files': 'Files', - 'term': 'Terminal', - 'preview': 'Preview', - 'sessions': 'Sessions', - 'usage': 'Usage', - }; - - static List _options(Set granted) { - final byGroup = >{}; - for (final capability in granted) { - final prefix = capability.split('.').first; - if (!_groupLabels.containsKey(prefix)) continue; - byGroup.putIfAbsent(prefix, () => []).add(capability); - } - return [ - for (final entry in _groupLabels.entries) - if (byGroup[entry.key] case final capabilities?) - AccessModeOption( - id: entry.key, - label: entry.value, - detail: (capabilities..sort()).join(', '), - selected: true, - ), - ]; - } - - @override - Widget build(BuildContext context) { - final granted = state.grantedCapabilities; - final canWriteFiles = granted.contains('files.write'); - final label = canWriteFiles && granted.contains('term.input') - ? 'Full access' - : canWriteFiles - ? 'Write access' - : 'Read only'; - return Tooltip( - message: 'Granted permissions', - child: AccessModeSelector( - label: label, - options: _options(granted), - onSelected: (_) {}, - enabled: state.connectionPhase == ConnectionPhase.ready, - readOnly: true, - ), - ); - } -} diff --git a/apps/flutter/lib/src/ui/developer_surfaces.dart b/apps/flutter/lib/src/ui/developer_surfaces.dart deleted file mode 100644 index 74e05929..00000000 --- a/apps/flutter/lib/src/ui/developer_surfaces.dart +++ /dev/null @@ -1,2422 +0,0 @@ -part of 't4_app.dart'; - -final class _DeveloperSurfacesPane extends StatefulWidget { - const _DeveloperSurfacesPane({ - required this.state, - required this.actions, - required this.initialTab, - required this.onDone, - required this.showHeader, - }); - - final T4ViewState state; - final T4Actions actions; - final int initialTab; - final VoidCallback onDone; - final bool showHeader; - - @override - State<_DeveloperSurfacesPane> createState() => _DeveloperSurfacesPaneState(); -} - -final class _DeveloperSurfacesPaneState extends State<_DeveloperSurfacesPane> - with SingleTickerProviderStateMixin { - late final TabController _tabs; - late final DeveloperPanelActionController _panelActions; - late final ActivityPanelController _activity; - late final FilesPanelController _files; - late final PreviewPanelController _preview; - - bool get _busy => - _panelActions.busyAction != null || - widget.state.developerOperationPending; - - @override - void initState() { - super.initState(); - _tabs = TabController( - length: 5, - initialIndex: _boundedDeveloperTab(widget.initialTab), - vsync: this, - ); - _panelActions = DeveloperPanelActionController() - ..addListener(_onPanelChanged); - _activity = ActivityPanelController(widget.state) - ..addListener(_onPanelChanged); - _files = FilesPanelController(widget.state)..addListener(_onPanelChanged); - _preview = PreviewPanelController(widget.state) - ..addListener(_onPanelChanged); - } - - void _onPanelChanged() { - if (mounted) setState(() {}); - } - - @override - void didUpdateWidget(covariant _DeveloperSurfacesPane oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.initialTab != widget.initialTab && - _tabs.index != widget.initialTab) { - _tabs.animateTo(_boundedDeveloperTab(widget.initialTab)); - } - _activity.syncFromState(oldWidget.state, widget.state); - _preview.syncFromState(oldWidget.state, widget.state); - _files.syncFromState(oldWidget.state, widget.state); - } - - @override - void dispose() { - _tabs.dispose(); - _panelActions.dispose(); - _activity.dispose(); - _files.dispose(); - _preview.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Column( - children: [ - if (widget.showHeader) _DeveloperSurfacesHeader(onDone: widget.onDone), - if (_panelActions.actionError case final error?) - _DeveloperErrorBanner( - message: error, - onDismiss: _panelActions.clearError, - ), - if (_busy) - const LinearProgressIndicator( - minHeight: _T4Size.thinStroke, - semanticsLabel: 'Developer operation in progress', - ), - TabBar( - controller: _tabs, - isScrollable: true, - tabAlignment: TabAlignment.start, - tabs: const [ - Tab(icon: Icon(Icons.receipt_long_outlined), text: 'Activity'), - Tab(icon: Icon(Icons.folder_outlined), text: 'Files'), - Tab(icon: Icon(Icons.difference_outlined), text: 'Review'), - Tab(icon: Icon(Icons.terminal), text: 'Terminal'), - Tab(icon: Icon(Icons.preview_outlined), text: 'Preview'), - ], - ), - Expanded( - child: TabBarView( - controller: _tabs, - children: [ - ActivityPanelBody( - state: widget.state, - actions: widget.actions, - controller: _activity, - actionController: _panelActions, - ), - FilesPanelBody( - state: widget.state, - actions: widget.actions, - controller: _files, - actionController: _panelActions, - ), - ReviewPanelBody( - state: widget.state, - actions: widget.actions, - selectedFilePath: _files.selectedFilePath, - onOpenFiles: () => _tabs.animateTo(1), - actionController: _panelActions, - ), - TerminalPanelBody(state: widget.state, actions: widget.actions), - PreviewPanelBody( - state: widget.state, - actions: widget.actions, - controller: _preview, - actionController: _panelActions, - ), - ], - ), - ), - ], - ); - } -} - -/// Base for developer panel state holders: setState-style [mutate] with safe -/// change notification after disposal. -abstract base class DeveloperPanelController extends ChangeNotifier { - bool _panelControllerDisposed = false; - - void mutate(VoidCallback fn) { - fn(); - if (!_panelControllerDisposed) notifyListeners(); - } - - @override - void dispose() { - _panelControllerDisposed = true; - super.dispose(); - } -} - -/// Shared busy/error state for developer panel operations. The tabbed -/// takeover owns one instance across all tabs; an embedded panel body creates -/// its own when none is supplied. -final class DeveloperPanelActionController extends DeveloperPanelController { - String? _busyAction; - String? _actionError; - - String? get busyAction => _busyAction; - String? get actionError => _actionError; - - void clearError() => mutate(() => _actionError = null); - - Future run( - BuildContext context, { - required bool externallyPending, - required String key, - required String failureMessage, - required Future Function() action, - }) async { - if (_busyAction != null || externallyPending) return; - final messenger = ScaffoldMessenger.maybeOf(context); - mutate(() { - _busyAction = key; - _actionError = null; - }); - try { - await action(); - } on Object catch (error) { - if (_panelControllerDisposed) return; - final detail = error.toString().replaceFirst('Bad state: ', '').trim(); - mutate(() { - _actionError = detail.isEmpty ? failureMessage : detail; - }); - messenger - ?..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(failureMessage))); - } finally { - if (!_panelControllerDisposed) { - mutate(() => _busyAction = null); - } - } - } -} - -/// Per-tab state for the Activity panel. -final class ActivityPanelController extends DeveloperPanelController { - ActivityPanelController(T4ViewState state) - : snapshot = List.of(state.activities); - - List snapshot; - String? category; - bool paused = false; - - void selectCategory(String? value) => mutate(() => category = value); - - void togglePause(T4ViewState state) => mutate(() { - paused = !paused; - if (!paused) { - snapshot = List.of(state.activities); - } - }); - - /// Refreshes the snapshot on state updates. Intentionally silent: callers - /// invoke this from didUpdateWidget and rebuild right after. - void syncFromState(T4ViewState oldState, T4ViewState newState) { - if (!paused && !identical(oldState.activities, newState.activities)) { - snapshot = List.of(newState.activities); - } - } -} - -/// Per-tab state for the Files panel; also feeds the Review panel's selected -/// file when both are driven by the tabbed takeover. -final class FilesPanelController extends DeveloperPanelController { - FilesPanelController(T4ViewState state) { - final workspace = state.fileWorkspace; - if (workspace.content != null && workspace.path.isNotEmpty) { - selectedFilePath = workspace.path; - directoryPath = _parentPath(workspace.path); - } else { - directoryPath = workspace.path; - } - syncEditor(state, force: true); - } - - final TextEditingController editor = TextEditingController(); - String? selectedFilePath; - String directoryPath = ''; - String? editedFilePath; - bool dirty = false; - - void syncEditor(T4ViewState state, {bool force = false}) { - final workspace = state.fileWorkspace; - final content = workspace.content; - if (content == null || workspace.path.isEmpty) return; - selectedFilePath = workspace.path; - if (!force && dirty && editedFilePath == workspace.path) return; - editedFilePath = workspace.path; - dirty = false; - editor.value = TextEditingValue( - text: content, - selection: TextSelection.collapsed(offset: content.length), - ); - } - - /// Mirrors the editor sync previously done in the pane's didUpdateWidget. - void syncFromState(T4ViewState oldState, T4ViewState newState) { - final oldWorkspace = oldState.fileWorkspace; - final workspace = newState.fileWorkspace; - if (oldWorkspace.path != workspace.path || - oldWorkspace.content != workspace.content) { - syncEditor(newState); - } - } - - @override - void dispose() { - editor.dispose(); - super.dispose(); - } -} - -/// Per-tab state for the Preview panel. -final class PreviewPanelController extends DeveloperPanelController { - PreviewPanelController(T4ViewState state) { - syncAddress(state, force: true); - } - - final TextEditingController launchUrl = TextEditingController(); - final TextEditingController navigationUrl = TextEditingController(); - final FocusNode navigationFocus = FocusNode(); - - void syncAddress(T4ViewState state, {bool force = false}) { - if (!force && navigationFocus.hasFocus) return; - final url = state.activePreview?.url ?? ''; - navigationUrl.value = TextEditingValue( - text: url, - selection: TextSelection.collapsed(offset: url.length), - ); - } - - /// Mirrors the address sync previously done in the pane's didUpdateWidget. - void syncFromState(T4ViewState oldState, T4ViewState newState) { - final oldPreview = oldState.activePreview; - final preview = newState.activePreview; - if (oldPreview?.previewId != preview?.previewId || - oldPreview?.url != preview?.url) { - syncAddress(newState); - } - } - - @override - void dispose() { - launchUrl.dispose(); - navigationUrl.dispose(); - navigationFocus.dispose(); - super.dispose(); - } -} - -/// Error banner and progress indicator wrapper used by panel bodies that own -/// their action controller (embedded use). The tabbed takeover renders these -/// itself, above the tab strip. -final class _PanelActionChrome extends StatelessWidget { - const _PanelActionChrome({ - required this.controller, - required this.busy, - required this.child, - }); - - final DeveloperPanelActionController controller; - final bool busy; - final Widget child; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (controller.actionError case final error?) - _DeveloperErrorBanner( - message: error, - onDismiss: controller.clearError, - ), - if (busy) - const LinearProgressIndicator( - minHeight: _T4Size.thinStroke, - semanticsLabel: 'Developer operation in progress', - ), - Expanded(child: child), - ], - ); - } -} - -/// Embeddable body of the developer Activity tab. -final class ActivityPanelBody extends StatefulWidget { - const ActivityPanelBody({ - required this.state, - required this.actions, - this.controller, - this.actionController, - super.key, - }); - - final T4ViewState state; - final T4Actions actions; - - /// Optional external per-tab state; the body owns its own when omitted. - final ActivityPanelController? controller; - - /// Optional shared busy/error state; the body owns its own (and renders its - /// own error banner and progress indicator) when omitted. - final DeveloperPanelActionController? actionController; - - @override - State createState() => _ActivityPanelBodyState(); -} - -final class _ActivityPanelBodyState extends State { - late ActivityPanelController _activity; - late DeveloperPanelActionController _action; - - bool get _ownsController => widget.controller == null; - bool get _ownsActionController => widget.actionController == null; - bool get _connected => widget.state.connectionPhase == ConnectionPhase.ready; - bool get _busy => - _action.busyAction != null || widget.state.developerOperationPending; - - bool _hasCapability(String capability) => - widget.state.grantedCapabilities.contains(capability); - - @override - void initState() { - super.initState(); - _activity = widget.controller ?? ActivityPanelController(widget.state); - _action = widget.actionController ?? DeveloperPanelActionController(); - _activity.addListener(_onPanelChanged); - _action.addListener(_onPanelChanged); - } - - @override - void didUpdateWidget(covariant ActivityPanelBody oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - _activity.removeListener(_onPanelChanged); - if (oldWidget.controller == null) _activity.dispose(); - _activity = widget.controller ?? ActivityPanelController(widget.state); - _activity.addListener(_onPanelChanged); - } - if (oldWidget.actionController != widget.actionController) { - _action.removeListener(_onPanelChanged); - if (oldWidget.actionController == null) _action.dispose(); - _action = widget.actionController ?? DeveloperPanelActionController(); - _action.addListener(_onPanelChanged); - } - if (_ownsController) { - _activity.syncFromState(oldWidget.state, widget.state); - } - } - - @override - void dispose() { - _activity.removeListener(_onPanelChanged); - _action.removeListener(_onPanelChanged); - if (_ownsController) _activity.dispose(); - if (_ownsActionController) _action.dispose(); - super.dispose(); - } - - void _onPanelChanged() { - if (mounted) setState(() {}); - } - - Future _runAction( - String key, - String failureMessage, - Future Function() action, - ) => _action.run( - context, - externallyPending: widget.state.developerOperationPending, - key: key, - failureMessage: failureMessage, - action: action, - ); - - @override - Widget build(BuildContext context) { - final content = _buildContent(context); - if (!_ownsActionController) return content; - return _PanelActionChrome(controller: _action, busy: _busy, child: content); - } - - Widget _buildContent(BuildContext context) { - if (!_connected && _activity.snapshot.isEmpty) { - return const _DeveloperUnavailable( - icon: Icons.cloud_off_outlined, - title: 'Activity is offline', - message: 'Connect to a host to inspect protocol activity.', - ); - } - if (!_hasCapability('audit.read')) { - return const _DeveloperUnavailable( - icon: Icons.lock_outline, - title: 'Activity is unavailable', - message: 'The paired host did not grant the audit.read capability.', - ); - } - - final categories = - _activity.snapshot - .map((activity) => activity.category.trim()) - .where((category) => category.isNotEmpty) - .toSet() - .toList(growable: false) - ..sort(); - if (_activity.category != null && - !categories.contains(_activity.category)) { - _activity.category = null; - } - final activities = - _activity.snapshot - .where( - (activity) => - _activity.category == null || - activity.category == _activity.category, - ) - .toList(growable: false) - ..sort((a, b) => b.at.compareTo(a.at)); - - return Column( - children: [ - _ActivityToolbar( - categories: categories, - selectedCategory: _activity.category, - paused: _activity.paused, - busy: _busy, - onSelectCategory: _activity.selectCategory, - onTogglePause: () => _activity.togglePause(widget.state), - onRefresh: !_connected - ? null - : () => _runAction( - 'activity-refresh', - 'Could not refresh activity.', - widget.actions.refreshActivity, - ), - ), - if (_activity.paused) - const _DeveloperNotice( - icon: Icons.pause_circle_outline, - message: 'Activity is paused. New rows are held until you resume.', - ), - Expanded( - child: activities.isEmpty - ? _DeveloperUnavailable( - icon: Icons.filter_alt_off_outlined, - title: _activity.snapshot.isEmpty - ? 'No activity yet' - : 'No matching activity', - message: _activity.snapshot.isEmpty - ? 'Refresh to request the latest host activity.' - : 'Choose a different category filter.', - ) - : ListView.separated( - padding: const EdgeInsets.all(_T4Space.md), - itemCount: activities.length, - separatorBuilder: (_, _) => - const SizedBox(height: _T4Space.xs), - itemBuilder: (context, index) => - _ActivityRow(activity: activities[index]), - ), - ), - ], - ); - } -} - -/// Embeddable body of the developer Files tab. -final class FilesPanelBody extends StatefulWidget { - const FilesPanelBody({ - required this.state, - required this.actions, - this.controller, - this.actionController, - super.key, - }); - - final T4ViewState state; - final T4Actions actions; - - /// Optional external per-tab state; the body owns its own when omitted. - final FilesPanelController? controller; - - /// Optional shared busy/error state; the body owns its own (and renders its - /// own error banner and progress indicator) when omitted. - final DeveloperPanelActionController? actionController; - - @override - State createState() => _FilesPanelBodyState(); -} - -final class _FilesPanelBodyState extends State { - late FilesPanelController _files; - late DeveloperPanelActionController _action; - - bool get _ownsController => widget.controller == null; - bool get _ownsActionController => widget.actionController == null; - bool get _connected => widget.state.connectionPhase == ConnectionPhase.ready; - bool get _busy => - _action.busyAction != null || widget.state.developerOperationPending; - - bool _hasCapability(String capability) => - widget.state.grantedCapabilities.contains(capability); - - @override - void initState() { - super.initState(); - _files = widget.controller ?? FilesPanelController(widget.state); - _action = widget.actionController ?? DeveloperPanelActionController(); - _files.addListener(_onPanelChanged); - _action.addListener(_onPanelChanged); - } - - @override - void didUpdateWidget(covariant FilesPanelBody oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - _files.removeListener(_onPanelChanged); - if (oldWidget.controller == null) _files.dispose(); - _files = widget.controller ?? FilesPanelController(widget.state); - _files.addListener(_onPanelChanged); - } - if (oldWidget.actionController != widget.actionController) { - _action.removeListener(_onPanelChanged); - if (oldWidget.actionController == null) _action.dispose(); - _action = widget.actionController ?? DeveloperPanelActionController(); - _action.addListener(_onPanelChanged); - } - if (_ownsController) { - _files.syncFromState(oldWidget.state, widget.state); - } - } - - @override - void dispose() { - _files.removeListener(_onPanelChanged); - _action.removeListener(_onPanelChanged); - if (_ownsController) _files.dispose(); - if (_ownsActionController) _action.dispose(); - super.dispose(); - } - - void _onPanelChanged() { - if (mounted) setState(() {}); - } - - Future _runAction( - String key, - String failureMessage, - Future Function() action, - ) => _action.run( - context, - externallyPending: widget.state.developerOperationPending, - key: key, - failureMessage: failureMessage, - action: action, - ); - - Future _confirmDiscardFileChanges() async { - if (!_files.dirty) return true; - final discard = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Discard unsaved changes?'), - content: Text( - 'Your edits to ${_files.editedFilePath ?? 'this file'} have not been saved.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Keep editing'), - ), - FilledButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Discard'), - ), - ], - ), - ); - if (discard == true && mounted) { - _files.mutate(() => _files.dirty = false); - } - return discard == true; - } - - void _onFileChanged(String content) { - final workspace = widget.state.fileWorkspace; - final dirty = - _files.editedFilePath == workspace.path && content != workspace.content; - if (dirty != _files.dirty) _files.mutate(() => _files.dirty = dirty); - } - - Future _saveFile() async { - final path = _files.editedFilePath; - if (path == null || !_files.dirty) return; - await _runAction('files-write', 'Could not save the file.', () async { - await widget.actions.writeFile(path, _files.editor.text); - _files.mutate(() => _files.dirty = false); - }); - } - - Future _listDirectory(String path) async { - if (!await _confirmDiscardFileChanges() || !mounted) return; - _files.mutate(() { - _files.directoryPath = path; - _files.selectedFilePath = null; - _files.editedFilePath = null; - _files.editor.clear(); - }); - await _runAction( - 'files-list', - 'Could not list that directory.', - () => widget.actions.listFiles(path), - ); - } - - Future _selectFile(String path) async { - if (path != _files.editedFilePath && - (!await _confirmDiscardFileChanges() || !mounted)) { - return; - } - _files.mutate(() => _files.selectedFilePath = path); - await _runAction( - 'files-read', - 'Could not read that file.', - () => widget.actions.readFile(path), - ); - } - - @override - Widget build(BuildContext context) { - final content = _buildContent(context); - if (!_ownsActionController) return content; - return _PanelActionChrome(controller: _action, busy: _busy, child: content); - } - - Widget _buildContent(BuildContext context) { - final canList = _hasCapability('files.list'); - final canRead = _hasCapability('files.read'); - if (!_connected && widget.state.fileWorkspace.entries.isEmpty) { - return const _DeveloperUnavailable( - icon: Icons.cloud_off_outlined, - title: 'Files are offline', - message: 'Connect to a host to browse the selected session workspace.', - ); - } - if (!canList || !canRead) { - return _DeveloperUnavailable( - icon: Icons.lock_outline, - title: 'Files are unavailable', - message: - 'Browsing requires ${!canList ? 'files.list' : 'files.read'} access from the paired host.', - ); - } - - final workspace = widget.state.fileWorkspace; - final filePath = - _files.selectedFilePath ?? - (workspace.content == null ? null : workspace.path); - final content = filePath == workspace.path ? workspace.content : null; - return LayoutBuilder( - builder: (context, constraints) { - final list = _FileBrowser( - directoryPath: _files.directoryPath, - entries: workspace.entries, - selectedPath: filePath, - busy: _busy || workspace.loading, - error: workspace.error, - onRefresh: _connected && !_busy - ? () => _listDirectory(_files.directoryPath) - : null, - onParent: _connected && !_busy && _files.directoryPath.isNotEmpty - ? () => _listDirectory(_parentPath(_files.directoryPath)) - : null, - onOpenDirectory: _connected && !_busy ? _listDirectory : null, - onOpenFile: _connected && !_busy ? _selectFile : null, - ); - final viewer = _SourceViewer( - path: filePath, - content: content, - loading: _action.busyAction == 'files-read', - controller: _files.editor, - editable: _hasCapability('files.write') && _connected && !_busy, - dirty: _files.dirty, - onChanged: _onFileChanged, - onSave: _files.dirty && !_busy ? _saveFile : null, - onDiscard: _files.dirty - ? () => _files.mutate(() { - _files.editor.text = widget.state.fileWorkspace.content ?? ''; - _files.dirty = false; - }) - : null, - ); - if (constraints.maxWidth >= _T4Layout.contentMaxWidth) { - return Row( - children: [ - SizedBox(width: _T4Layout.sessionRailWidth, child: list), - const VerticalDivider(width: _T4Size.divider), - Expanded(child: viewer), - ], - ); - } - return Column( - children: [ - Flexible(child: list), - const Divider(height: _T4Size.divider), - Expanded(child: viewer), - ], - ); - }, - ); - } -} - -/// Embeddable body of the developer Review tab. -final class ReviewPanelBody extends StatefulWidget { - const ReviewPanelBody({ - required this.state, - required this.actions, - this.selectedFilePath, - this.onOpenFiles, - this.actionController, - super.key, - }); - - final T4ViewState state; - final T4Actions actions; - - /// Externally selected file (from the Files panel); falls back to the - /// workspace's loaded file when omitted. - final String? selectedFilePath; - - /// Invoked by the "Open files" affordance; the affordance is disabled when - /// omitted. - final VoidCallback? onOpenFiles; - - /// Optional shared busy/error state; the body owns its own (and renders its - /// own error banner and progress indicator) when omitted. - final DeveloperPanelActionController? actionController; - - @override - State createState() => _ReviewPanelBodyState(); -} - -final class _ReviewPanelBodyState extends State { - late DeveloperPanelActionController _action; - - bool get _ownsActionController => widget.actionController == null; - bool get _connected => widget.state.connectionPhase == ConnectionPhase.ready; - bool get _busy => - _action.busyAction != null || widget.state.developerOperationPending; - - bool _hasCapability(String capability) => - widget.state.grantedCapabilities.contains(capability); - - @override - void initState() { - super.initState(); - _action = widget.actionController ?? DeveloperPanelActionController(); - _action.addListener(_onPanelChanged); - } - - @override - void didUpdateWidget(covariant ReviewPanelBody oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.actionController != widget.actionController) { - _action.removeListener(_onPanelChanged); - if (oldWidget.actionController == null) _action.dispose(); - _action = widget.actionController ?? DeveloperPanelActionController(); - _action.addListener(_onPanelChanged); - } - } - - @override - void dispose() { - _action.removeListener(_onPanelChanged); - if (_ownsActionController) _action.dispose(); - super.dispose(); - } - - void _onPanelChanged() { - if (mounted) setState(() {}); - } - - Future _runAction( - String key, - String failureMessage, - Future Function() action, - ) => _action.run( - context, - externallyPending: widget.state.developerOperationPending, - key: key, - failureMessage: failureMessage, - action: action, - ); - - @override - Widget build(BuildContext context) { - final content = _buildContent(context); - if (!_ownsActionController) return content; - return _PanelActionChrome(controller: _action, busy: _busy, child: content); - } - - Widget _buildContent(BuildContext context) { - final workspace = widget.state.fileWorkspace; - final reviews = widget.state.reviews; - final selectedPath = - widget.selectedFilePath ?? - (workspace.content == null ? null : workspace.path); - if (!_connected && workspace.diff == null && reviews.isEmpty) { - return const _DeveloperUnavailable( - icon: Icons.cloud_off_outlined, - title: 'Review is offline', - message: 'Connect to load reviews and file diffs.', - ); - } - - final canReadReview = _hasCapability('files.read'); - final canApplyReview = _hasCapability('files.write'); - final canLoadDiff = _hasCapability('files.diff'); - final diff = selectedPath != null && workspace.path == selectedPath - ? workspace.diff - : null; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (reviews.isNotEmpty) ...[ - SizedBox( - height: (112 + reviews.length * 96).clamp(0, 300).toDouble(), - child: _ReviewQueue( - reviews: reviews, - busy: _busy, - canRead: _connected && canReadReview, - canApply: _connected && canApplyReview, - onRefresh: (review) => _runAction( - 'review-read-${review.reviewId}', - 'Could not refresh the review.', - () => widget.actions.refreshReview(review.reviewId), - ), - onApply: (review) => _runAction( - 'review-apply-${review.reviewId}', - 'Could not apply the review.', - () => widget.actions.applyReview(review.reviewId), - ), - ), - ), - const Divider(height: _T4Size.divider), - ], - if (selectedPath == null || selectedPath.isEmpty) - Expanded( - child: _DeveloperUnavailable( - icon: Icons.find_in_page_outlined, - title: reviews.isEmpty - ? 'Choose a file first' - : 'No file diff selected', - message: 'Select a file in Files to inspect its current diff.', - action: TextButton.icon( - onPressed: widget.onOpenFiles, - icon: const Icon(Icons.folder_open_outlined), - label: const Text('Open files'), - ), - ), - ) - else if (!canLoadDiff) - const Expanded( - child: _DeveloperUnavailable( - icon: Icons.lock_outline, - title: 'File diff unavailable', - message: 'The paired host did not grant files.diff access.', - ), - ) - else ...[ - Padding( - padding: const EdgeInsets.all(_T4Space.md), - child: Wrap( - spacing: _T4Space.sm, - runSpacing: _T4Space.xs, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Icon( - Icons.description_outlined, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - Text( - selectedPath, - style: Theme.of(context).textTheme.titleSmall, - ), - FilledButton.tonalIcon( - onPressed: _connected && !_busy - ? () => _runAction( - 'files-diff', - 'Could not load the file diff.', - widget.actions.loadSessionDiff, - ) - : null, - icon: const Icon(Icons.refresh), - label: Text(diff == null ? 'Load diff' : 'Reload diff'), - ), - ], - ), - ), - const Divider(height: _T4Size.divider), - Expanded( - child: diff == null - ? const _DeveloperUnavailable( - icon: Icons.difference_outlined, - title: 'Diff not loaded', - message: 'Load the selected file diff to begin review.', - ) - : diff.trim().isEmpty - ? const _DeveloperUnavailable( - icon: Icons.check_circle_outline, - title: 'No changes', - message: 'The selected file has no changes to review.', - ) - : _CodeViewer( - text: diff, - semanticsLabel: 'Diff for $selectedPath', - ), - ), - ], - ], - ); - } -} - -/// Embeddable body of the developer Terminal tab. -final class TerminalPanelBody extends StatelessWidget { - const TerminalPanelBody({ - required this.state, - required this.actions, - super.key, - }); - - final T4ViewState state; - final T4Actions actions; - - @override - Widget build(BuildContext context) => - _TerminalPane(state: state, actions: actions); -} - -/// Embeddable body of the developer Preview tab. -final class PreviewPanelBody extends StatefulWidget { - const PreviewPanelBody({ - required this.state, - required this.actions, - this.controller, - this.actionController, - super.key, - }); - - final T4ViewState state; - final T4Actions actions; - - /// Optional external per-tab state; the body owns its own when omitted. - final PreviewPanelController? controller; - - /// Optional shared busy/error state; the body owns its own (and renders its - /// own error banner and progress indicator) when omitted. - final DeveloperPanelActionController? actionController; - - @override - State createState() => _PreviewPanelBodyState(); -} - -final class _PreviewPanelBodyState extends State { - late PreviewPanelController _preview; - late DeveloperPanelActionController _action; - - bool get _ownsController => widget.controller == null; - bool get _ownsActionController => widget.actionController == null; - bool get _connected => widget.state.connectionPhase == ConnectionPhase.ready; - bool get _busy => - _action.busyAction != null || widget.state.developerOperationPending; - - bool _hasCapability(String capability) => - widget.state.grantedCapabilities.contains(capability); - - @override - void initState() { - super.initState(); - _preview = widget.controller ?? PreviewPanelController(widget.state); - _action = widget.actionController ?? DeveloperPanelActionController(); - _preview.addListener(_onPanelChanged); - _action.addListener(_onPanelChanged); - } - - @override - void didUpdateWidget(covariant PreviewPanelBody oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - _preview.removeListener(_onPanelChanged); - if (oldWidget.controller == null) _preview.dispose(); - _preview = widget.controller ?? PreviewPanelController(widget.state); - _preview.addListener(_onPanelChanged); - } - if (oldWidget.actionController != widget.actionController) { - _action.removeListener(_onPanelChanged); - if (oldWidget.actionController == null) _action.dispose(); - _action = widget.actionController ?? DeveloperPanelActionController(); - _action.addListener(_onPanelChanged); - } - if (_ownsController) { - _preview.syncFromState(oldWidget.state, widget.state); - } - } - - @override - void dispose() { - _preview.removeListener(_onPanelChanged); - _action.removeListener(_onPanelChanged); - if (_ownsController) _preview.dispose(); - if (_ownsActionController) _action.dispose(); - super.dispose(); - } - - void _onPanelChanged() { - if (mounted) setState(() {}); - } - - Future _runAction( - String key, - String failureMessage, - Future Function() action, - ) => _action.run( - context, - externallyPending: widget.state.developerOperationPending, - key: key, - failureMessage: failureMessage, - action: action, - ); - - Future _launchPreview() async { - final url = _preview.launchUrl.text.trim(); - if (url.isEmpty) return; - await _runAction( - 'preview-launch', - 'Could not launch the preview.', - () async { - await widget.actions.launchPreview(url); - _preview.launchUrl.clear(); - }, - ); - } - - Future _navigatePreview(PreviewWorkspaceState preview) async { - final url = _preview.navigationUrl.text.trim(); - if (url.isEmpty || url == preview.url) return; - await _runAction( - 'preview-navigate', - 'Could not navigate the preview.', - () => widget.actions.navigatePreview(preview.previewId, url), - ); - } - - Future _previewAction(PreviewWorkspaceState preview, String action) => - _runAction( - 'preview-$action', - 'Could not $action the preview.', - () => widget.actions.runPreviewAction(preview.previewId, action), - ); - - Future _openPreviewInteraction( - PreviewWorkspaceState preview, { - required bool allowInput, - required bool allowHandoff, - }) async { - final request = await showModalBottomSheet<_PreviewInteractionRequest>( - context: context, - isScrollControlled: true, - useSafeArea: true, - builder: (context) => _PreviewInteractionSheet( - allowInput: allowInput, - allowHandoff: allowHandoff, - ), - ); - if (request == null || !mounted) return; - await _runAction( - 'preview-${request.action}', - 'Could not ${request.action} in the preview.', - () => widget.actions.runPreviewInteraction( - preview.previewId, - request.action, - request.args, - ), - ); - } - - @override - Widget build(BuildContext context) { - final content = _buildContent(context); - if (!_ownsActionController) return content; - return _PanelActionChrome(controller: _action, busy: _busy, child: content); - } - - Widget _buildContent(BuildContext context) { - final canRead = _hasCapability('preview.read'); - final canControl = _hasCapability('preview.control'); - final canInput = _hasCapability('preview.input'); - if (!_connected && widget.state.previews.isEmpty) { - return const _DeveloperUnavailable( - icon: Icons.cloud_off_outlined, - title: 'Preview is offline', - message: 'Connect to a host to open a protocol-rendered preview.', - ); - } - if (!canRead && !canControl && !canInput) { - return const _DeveloperUnavailable( - icon: Icons.lock_outline, - title: 'Preview is unavailable', - message: - 'The paired host did not grant preview.read, preview.control, or preview.input.', - ); - } - - final preview = widget.state.activePreview; - return Column( - children: [ - _PreviewLaunchBar( - controller: _preview.launchUrl, - enabled: _connected && canControl && !_busy, - onLaunch: _launchPreview, - ), - if (widget.state.previews.isNotEmpty) - _PreviewSelector( - previews: widget.state.previews, - activePreviewId: preview?.previewId, - enabled: _connected && canControl && !_busy, - onSelected: (previewId) => _runAction( - 'preview-select', - 'Could not select the preview.', - () => widget.actions.selectPreview(previewId), - ), - ), - const Divider(height: _T4Size.divider), - Expanded( - child: preview == null - ? const _DeveloperUnavailable( - icon: Icons.preview_outlined, - title: 'No preview open', - message: - 'Enter a URL above. T4 will display only captured image bytes returned by the host protocol.', - ) - : _buildActivePreview(preview, canRead, canControl, canInput), - ), - ], - ); - } - - Widget _buildActivePreview( - PreviewWorkspaceState preview, - bool canRead, - bool canControl, - bool canInput, - ) { - final controlsEnabled = _connected && canControl && !_busy; - return Column( - children: [ - _PreviewNavigationBar( - preview: preview, - controller: _preview.navigationUrl, - focusNode: _preview.navigationFocus, - controlsEnabled: controlsEnabled, - captureEnabled: _connected && canRead && !_busy, - interactionEnabled: _connected && (canInput || canControl) && !_busy, - onNavigate: () => _navigatePreview(preview), - onAction: (action) => _previewAction(preview, action), - onInteract: () => _openPreviewInteraction( - preview, - allowInput: canInput, - allowHandoff: canControl, - ), - onCapture: () => _runAction( - 'preview-capture', - 'Could not capture the preview.', - () => widget.actions.capturePreview(preview.previewId), - ), - ), - _PreviewStatus(preview: preview), - if (preview.error case final error?) - _DeveloperErrorBanner(message: error), - const _DeveloperNotice( - icon: Icons.verified_user_outlined, - message: - 'Safe preview: page code never runs in this app. Only capture bytes delivered by the paired host protocol are rendered.', - ), - Expanded(child: _PreviewCapture(preview: preview)), - ], - ); - } -} - -int _boundedDeveloperTab(int index) => index < 0 - ? 0 - : index > 4 - ? 4 - : index; - -final class _DeveloperSurfacesHeader extends StatelessWidget { - const _DeveloperSurfacesHeader({required this.onDone}); - - final VoidCallback onDone; - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).colorScheme.outlineVariant, - ), - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.sm, - vertical: _T4Space.xs, - ), - child: Row( - children: [ - IconButton( - onPressed: onDone, - tooltip: 'Close developer tools', - icon: const Icon(Icons.arrow_back), - ), - const SizedBox(width: _T4Space.xs), - Expanded( - child: Text( - 'Developer tools', - style: Theme.of(context).textTheme.titleLarge, - ), - ), - ], - ), - ), - ); - } -} - -final class _DeveloperErrorBanner extends StatelessWidget { - const _DeveloperErrorBanner({required this.message, this.onDismiss}); - - final String message; - final VoidCallback? onDismiss; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Semantics( - container: true, - liveRegion: true, - label: 'Developer tools error: $message', - child: ColoredBox( - color: scheme.errorContainer, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.md, - vertical: _T4Space.xs, - ), - child: Row( - children: [ - Icon(Icons.error_outline, color: scheme.onErrorContainer), - const SizedBox(width: _T4Space.sm), - Expanded( - child: Text( - message, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.onErrorContainer, - ), - ), - ), - if (onDismiss != null) - IconButton( - onPressed: onDismiss, - tooltip: 'Dismiss error', - color: scheme.onErrorContainer, - icon: const Icon(Icons.close), - ), - ], - ), - ), - ), - ); - } -} - -final class _DeveloperNotice extends StatelessWidget { - const _DeveloperNotice({required this.icon, required this.message}); - - final IconData icon; - final String message; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return ColoredBox( - color: scheme.surfaceContainerLow, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.md, - vertical: _T4Space.xs, - ), - child: Row( - children: [ - Icon(icon, size: _T4Space.md, color: scheme.onSurfaceVariant), - const SizedBox(width: _T4Space.xs), - Expanded( - child: Text( - message, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), - ), - ), - ], - ), - ), - ); - } -} - -final class _DeveloperUnavailable extends StatelessWidget { - const _DeveloperUnavailable({ - required this.icon, - required this.title, - required this.message, - this.action, - }); - - final IconData icon; - final String title; - final String message; - final Widget? action; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(_T4Space.xl), - child: Semantics( - container: true, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: _T4Size.emptyIcon, color: scheme.outline), - const SizedBox(height: _T4Space.md), - Text( - title, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: _T4Space.xs), - ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: Text( - message, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ), - if (action != null) ...[ - const SizedBox(height: _T4Space.md), - action!, - ], - ], - ), - ), - ), - ); - } -} - -final class _ActivityToolbar extends StatelessWidget { - const _ActivityToolbar({ - required this.categories, - required this.selectedCategory, - required this.paused, - required this.busy, - required this.onSelectCategory, - required this.onTogglePause, - required this.onRefresh, - }); - - final List categories; - final String? selectedCategory; - final bool paused; - final bool busy; - final ValueChanged onSelectCategory; - final VoidCallback onTogglePause; - final VoidCallback? onRefresh; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(_T4Space.sm), - child: Row( - children: [ - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Wrap( - spacing: _T4Space.xs, - children: [ - FilterChip( - label: const Text('All'), - selected: selectedCategory == null, - onSelected: (_) => onSelectCategory(null), - ), - for (final category in categories) - FilterChip( - label: Text(category), - selected: selectedCategory == category, - onSelected: (_) => onSelectCategory(category), - ), - ], - ), - ), - ), - const SizedBox(width: _T4Space.xs), - IconButton( - onPressed: busy ? null : onTogglePause, - tooltip: paused ? 'Resume activity' : 'Pause activity', - icon: Icon(paused ? Icons.play_arrow : Icons.pause), - ), - IconButton( - onPressed: busy ? null : onRefresh, - tooltip: 'Refresh activity', - icon: const Icon(Icons.refresh), - ), - ], - ), - ); - } -} - -final class _ActivityRow extends StatelessWidget { - const _ActivityRow({required this.activity}); - - final DeveloperActivity activity; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final raw = _redactActivityRaw(activity.raw); - return Material( - color: scheme.surfaceContainerLow, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - clipBehavior: Clip.antiAlias, - child: ExpansionTile( - key: ValueKey(activity.id), - leading: const Icon(Icons.data_object), - title: Text(activity.title), - subtitle: Text( - '${_formatActivityTime(activity.at)} · ${activity.category}\n${activity.detail}', - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - expandedCrossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Divider(height: _T4Size.divider), - Padding( - padding: const EdgeInsets.all(_T4Space.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text( - 'Redacted protocol JSON', - style: Theme.of(context).textTheme.labelLarge, - ), - ), - IconButton( - onPressed: () async { - await Clipboard.setData(ClipboardData(text: raw)); - if (!context.mounted) return; - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - const SnackBar( - content: Text('Redacted activity JSON copied.'), - ), - ); - }, - tooltip: 'Copy redacted JSON', - icon: const Icon(Icons.copy_outlined), - ), - ], - ), - _CodeViewer( - text: raw.isEmpty ? '{}' : raw, - semanticsLabel: - 'Redacted protocol JSON for ${activity.title}', - expand: false, - ), - ], - ), - ), - ], - ), - ); - } -} - -final class _ReviewQueue extends StatelessWidget { - const _ReviewQueue({ - required this.reviews, - required this.busy, - required this.canRead, - required this.canApply, - required this.onRefresh, - required this.onApply, - }); - - final List reviews; - final bool busy; - final bool canRead; - final bool canApply; - final ValueChanged onRefresh; - final ValueChanged onApply; - - @override - Widget build(BuildContext context) { - return ListView.separated( - padding: const EdgeInsets.all(_T4Space.sm), - itemCount: reviews.length, - separatorBuilder: (_, _) => const SizedBox(height: _T4Space.xs), - itemBuilder: (context, index) { - final review = reviews[index]; - final complete = - review.status == 'applied' || review.status == 'discarded'; - final finding = review.findings - .map(_reviewFindingLabel) - .where((label) => label.isNotEmpty) - .firstOrNull; - return Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(_T4Space.sm), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - complete - ? Icons.task_alt_outlined - : Icons.rate_review_outlined, - color: complete - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(width: _T4Space.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - review.path ?? review.reviewId, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall, - ), - Text( - '${review.status} · ${review.findings.length} ${review.findings.length == 1 ? 'finding' : 'findings'}', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - if (finding != null) - Text( - finding, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - const SizedBox(width: _T4Space.sm), - Column( - children: [ - IconButton( - tooltip: 'Refresh review', - onPressed: canRead && !busy - ? () => onRefresh(review) - : null, - icon: const Icon(Icons.refresh), - ), - IconButton.filledTonal( - tooltip: complete ? 'Review complete' : 'Apply review', - onPressed: canApply && !busy && !complete - ? () => onApply(review) - : null, - icon: const Icon(Icons.done_all), - ), - ], - ), - ], - ), - ), - ); - }, - ); - } -} - -String _reviewFindingLabel(Map finding) { - for (final key in const ['message', 'summary', 'title', 'description']) { - final value = finding[key]; - if (value is String && value.trim().isNotEmpty) return value.trim(); - } - return ''; -} - -final class _FileBrowser extends StatelessWidget { - const _FileBrowser({ - required this.directoryPath, - required this.entries, - required this.selectedPath, - required this.busy, - required this.error, - required this.onRefresh, - required this.onParent, - required this.onOpenDirectory, - required this.onOpenFile, - }); - - final String directoryPath; - final List entries; - final String? selectedPath; - final bool busy; - final String? error; - final VoidCallback? onRefresh; - final VoidCallback? onParent; - final ValueChanged? onOpenDirectory; - final ValueChanged? onOpenFile; - - @override - Widget build(BuildContext context) { - final sorted = List.of(entries) - ..sort((a, b) { - final aDirectory = _isDirectory(a); - final bDirectory = _isDirectory(b); - if (aDirectory != bDirectory) return aDirectory ? -1 : 1; - return a.path.toLowerCase().compareTo(b.path.toLowerCase()); - }); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.all(_T4Space.sm), - child: Row( - children: [ - IconButton( - onPressed: onParent, - tooltip: 'Open parent directory', - icon: const Icon(Icons.arrow_upward), - ), - const SizedBox(width: _T4Space.xs), - Expanded( - child: Tooltip( - message: directoryPath.isEmpty - ? 'Workspace root' - : directoryPath, - child: Text( - directoryPath.isEmpty ? 'Workspace root' : directoryPath, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall, - ), - ), - ), - IconButton( - onPressed: onRefresh, - tooltip: 'Refresh directory', - icon: const Icon(Icons.refresh), - ), - ], - ), - ), - if (error case final message?) _DeveloperErrorBanner(message: message), - Expanded( - child: busy && sorted.isEmpty - ? const Center( - child: CircularProgressIndicator( - semanticsLabel: 'Loading files', - ), - ) - : sorted.isEmpty - ? const _DeveloperUnavailable( - icon: Icons.folder_off_outlined, - title: 'Directory is empty', - message: 'Refresh to check for workspace files.', - ) - : ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: _T4Space.xs), - itemCount: sorted.length, - itemBuilder: (context, index) { - final entry = sorted[index]; - final directory = _isDirectory(entry); - return ListTile( - selected: entry.path == selectedPath, - enabled: directory - ? onOpenDirectory != null - : onOpenFile != null, - leading: Icon( - directory - ? Icons.folder_outlined - : Icons.insert_drive_file_outlined, - ), - title: Text( - _baseName(entry.path), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: directory - ? const Text('Directory') - : entry.size == null - ? null - : Text(_formatByteCount(entry.size!)), - trailing: directory - ? const Icon(Icons.chevron_right) - : null, - onTap: () => directory - ? onOpenDirectory?.call(entry.path) - : onOpenFile?.call(entry.path), - ); - }, - ), - ), - ], - ); - } -} - -final class _SourceViewer extends StatelessWidget { - const _SourceViewer({ - required this.path, - required this.content, - required this.loading, - required this.controller, - required this.editable, - required this.dirty, - required this.onChanged, - this.onSave, - this.onDiscard, - }); - - final String? path; - final String? content; - final bool loading; - final TextEditingController controller; - final bool editable; - final bool dirty; - final ValueChanged onChanged; - final VoidCallback? onSave; - final VoidCallback? onDiscard; - - @override - Widget build(BuildContext context) { - if (loading) { - return const Center( - child: CircularProgressIndicator(semanticsLabel: 'Reading file'), - ); - } - if (path == null) { - return const _DeveloperUnavailable( - icon: Icons.code_outlined, - title: 'Select a source file', - message: 'Choose a file to inspect its protocol-provided contents.', - ); - } - if (content == null) { - return const _DeveloperUnavailable( - icon: Icons.error_outline, - title: 'Source unavailable', - message: 'Select the file again to read its contents.', - ); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.all(_T4Space.md), - child: Row( - children: [ - Expanded( - child: Text( - path!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall, - ), - ), - if (dirty) - TextButton(onPressed: onDiscard, child: const Text('Discard')), - FilledButton.tonalIcon( - onPressed: onSave, - icon: const Icon(Icons.save_outlined), - label: Text(dirty ? 'Save' : 'Saved'), - ), - ], - ), - ), - const Divider(height: _T4Size.divider), - Expanded( - child: ColoredBox( - color: Theme.of(context).colorScheme.surfaceContainerLowest, - child: Semantics( - label: 'Source editor for $path', - textField: true, - child: TextField( - controller: controller, - enabled: editable, - readOnly: !editable, - expands: true, - minLines: null, - maxLines: null, - textAlignVertical: TextAlignVertical.top, - keyboardType: TextInputType.multiline, - onChanged: onChanged, - decoration: const InputDecoration( - border: InputBorder.none, - contentPadding: EdgeInsets.all(_T4Space.md), - ), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: _T4Typography.monoFamily, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ), - ), - ], - ); - } -} - -final class _CodeViewer extends StatelessWidget { - const _CodeViewer({ - required this.text, - required this.semanticsLabel, - this.expand = true, - }); - - final String text; - final String semanticsLabel; - final bool expand; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final viewer = ColoredBox( - color: scheme.surfaceContainerLowest, - child: Scrollbar( - child: SingleChildScrollView( - padding: const EdgeInsets.all(_T4Space.md), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: SelectionArea( - child: Text( - text, - semanticsLabel: semanticsLabel, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: _T4Typography.monoFamily, - color: scheme.onSurface, - ), - ), - ), - ), - ), - ), - ); - return expand ? SizedBox.expand(child: viewer) : viewer; - } -} - -final class _PreviewLaunchBar extends StatelessWidget { - const _PreviewLaunchBar({ - required this.controller, - required this.enabled, - required this.onLaunch, - }); - - final TextEditingController controller; - final bool enabled; - final Future Function() onLaunch; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(_T4Space.sm), - child: Row( - children: [ - Expanded( - child: TextField( - controller: controller, - enabled: enabled, - keyboardType: TextInputType.url, - textInputAction: TextInputAction.go, - autocorrect: false, - decoration: const InputDecoration( - labelText: 'Launch URL', - hintText: 'https://localhost:3000', - prefixIcon: Icon(Icons.add_link), - ), - onSubmitted: enabled ? (_) => onLaunch() : null, - ), - ), - const SizedBox(width: _T4Space.sm), - FilledButton.icon( - onPressed: enabled ? onLaunch : null, - icon: const Icon(Icons.rocket_launch_outlined), - label: const Text('Launch'), - ), - ], - ), - ); - } -} - -final class _PreviewSelector extends StatelessWidget { - const _PreviewSelector({ - required this.previews, - required this.activePreviewId, - required this.enabled, - required this.onSelected, - }); - - final List previews; - final String? activePreviewId; - final bool enabled; - final ValueChanged onSelected; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: _T4Layout.minimumTouchTarget + _T4Space.sm, - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: _T4Space.sm), - itemCount: previews.length, - separatorBuilder: (_, _) => const SizedBox(width: _T4Space.xs), - itemBuilder: (context, index) { - final preview = previews[index]; - final title = preview.title?.trim(); - return ChoiceChip( - label: Text( - title == null || title.isEmpty ? preview.url : title, - overflow: TextOverflow.ellipsis, - ), - selected: preview.previewId == activePreviewId, - onSelected: enabled ? (_) => onSelected(preview.previewId) : null, - ); - }, - ), - ); - } -} - -final class _PreviewNavigationBar extends StatelessWidget { - const _PreviewNavigationBar({ - required this.preview, - required this.controller, - required this.focusNode, - required this.controlsEnabled, - required this.captureEnabled, - required this.interactionEnabled, - required this.onNavigate, - required this.onAction, - required this.onInteract, - required this.onCapture, - }); - - final PreviewWorkspaceState preview; - final TextEditingController controller; - final FocusNode focusNode; - final bool controlsEnabled; - final bool captureEnabled; - final bool interactionEnabled; - final Future Function() onNavigate; - final ValueChanged onAction; - final VoidCallback onInteract; - final VoidCallback onCapture; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(_T4Space.sm), - child: Column( - children: [ - Row( - children: [ - IconButton( - onPressed: controlsEnabled && preview.canGoBack - ? () => onAction('back') - : null, - tooltip: 'Preview back', - icon: const Icon(Icons.arrow_back), - ), - IconButton( - onPressed: controlsEnabled && preview.canGoForward - ? () => onAction('forward') - : null, - tooltip: 'Preview forward', - icon: const Icon(Icons.arrow_forward), - ), - IconButton( - onPressed: controlsEnabled ? () => onAction('reload') : null, - tooltip: 'Reload preview', - icon: const Icon(Icons.refresh), - ), - const SizedBox(width: _T4Space.xs), - Expanded( - child: TextField( - controller: controller, - focusNode: focusNode, - enabled: controlsEnabled, - keyboardType: TextInputType.url, - textInputAction: TextInputAction.go, - autocorrect: false, - decoration: const InputDecoration( - labelText: 'Preview address', - prefixIcon: Icon(Icons.link), - ), - onSubmitted: controlsEnabled ? (_) => onNavigate() : null, - ), - ), - const SizedBox(width: _T4Space.xs), - IconButton.filledTonal( - onPressed: controlsEnabled ? onNavigate : null, - tooltip: 'Navigate preview', - icon: const Icon(Icons.arrow_forward), - ), - ], - ), - const SizedBox(height: _T4Space.xs), - Wrap( - alignment: WrapAlignment.end, - spacing: _T4Space.xs, - children: [ - TextButton.icon( - onPressed: interactionEnabled ? onInteract : null, - icon: const Icon(Icons.touch_app_outlined), - label: const Text('Interact'), - ), - TextButton.icon( - onPressed: captureEnabled ? onCapture : null, - icon: const Icon(Icons.camera_alt_outlined), - label: const Text('Capture'), - ), - TextButton.icon( - onPressed: controlsEnabled ? () => onAction('close') : null, - icon: const Icon(Icons.close), - label: const Text('Close'), - ), - ], - ), - ], - ), - ); - } -} - -final class _PreviewInteractionRequest { - const _PreviewInteractionRequest({required this.action, required this.args}); - - final String action; - final Map args; -} - -final class _PreviewInteractionSheet extends StatefulWidget { - const _PreviewInteractionSheet({ - required this.allowInput, - required this.allowHandoff, - }); - - final bool allowInput; - final bool allowHandoff; - - @override - State<_PreviewInteractionSheet> createState() => - _PreviewInteractionSheetState(); -} - -final class _PreviewInteractionSheetState - extends State<_PreviewInteractionSheet> { - static const _templates = { - 'click': '{"selector":"button"}', - 'fill': '{"selector":"input","text":"value"}', - 'type': '{"selector":"input","text":"value"}', - 'select': '{"selector":"select","value":"option"}', - 'press': '{"key":"Enter"}', - 'scroll': '{"deltaX":0,"deltaY":480}', - 'upload': '{"selector":"input[type=file]","path":"relative/file.txt"}', - 'handoff': '{"message":"Complete this step","mode":"manual"}', - }; - - late final List _actions; - late String _action; - late final TextEditingController _argumentsController; - String? _error; - - @override - void initState() { - super.initState(); - _actions = [ - if (widget.allowInput) ...const [ - 'click', - 'fill', - 'type', - 'select', - 'press', - 'scroll', - 'upload', - ], - if (widget.allowHandoff) 'handoff', - ]; - _action = _actions.first; - _argumentsController = TextEditingController(text: _templates[_action]); - } - - @override - void dispose() { - _argumentsController.dispose(); - super.dispose(); - } - - void _selectAction(String? action) { - if (action == null) return; - setState(() { - _action = action; - _argumentsController.text = _templates[action] ?? '{}'; - _error = null; - }); - } - - void _submit() { - try { - final decoded = jsonDecode(_argumentsController.text); - if (decoded is! Map) { - throw const FormatException('Arguments must be a JSON object.'); - } - Navigator.pop( - context, - _PreviewInteractionRequest( - action: _action, - args: Map.from(decoded), - ), - ); - } on FormatException catch (error) { - setState(() => _error = error.message); - } - } - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - padding: EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.md, - _T4Space.md, - MediaQuery.viewInsetsOf(context).bottom + _T4Space.md, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Preview interaction', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: _T4Space.md), - DropdownButtonFormField( - initialValue: _action, - decoration: const InputDecoration(labelText: 'Action'), - items: [ - for (final action in _actions) - DropdownMenuItem(value: action, child: Text(action)), - ], - onChanged: _selectAction, - ), - const SizedBox(height: _T4Space.md), - TextField( - controller: _argumentsController, - minLines: 5, - maxLines: 10, - autocorrect: false, - enableSuggestions: false, - decoration: InputDecoration( - labelText: 'Arguments (JSON)', - alignLabelWithHint: true, - errorText: _error, - ), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: _T4Typography.monoFamily, - ), - ), - const SizedBox(height: _T4Space.md), - FilledButton.icon( - onPressed: _submit, - icon: const Icon(Icons.play_arrow), - label: Text('Run $_action'), - ), - ], - ), - ); - } -} - -final class _PreviewStatus extends StatelessWidget { - const _PreviewStatus({required this.preview}); - - final PreviewWorkspaceState preview; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final captured = preview.capture != null; - return Semantics( - container: true, - label: - 'Preview status ${preview.state}. ${captured ? 'Capture available' : 'No capture available'}', - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.md, - vertical: _T4Space.xs, - ), - child: Row( - children: [ - Icon( - preview.error == null ? Icons.circle : Icons.error, - size: _T4Space.xs, - color: preview.error == null ? scheme.primary : scheme.error, - ), - const SizedBox(width: _T4Space.xs), - Expanded( - child: Text( - '${preview.state} · ${captured ? 'Protocol capture ready' : 'Capture required'}', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), - ), - ), - if (preview.captureMimeType case final mimeType?) - Text( - mimeType, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ); - } -} - -final class _PreviewCapture extends StatelessWidget { - const _PreviewCapture({required this.preview}); - - final PreviewWorkspaceState preview; - - @override - Widget build(BuildContext context) { - final capture = preview.capture; - if (capture == null || capture.isEmpty) { - return const _DeveloperUnavailable( - icon: Icons.photo_outlined, - title: 'No protocol capture', - message: - 'Capture this preview to request rendered image bytes from the host. T4 does not execute the page or its HTML.', - ); - } - return Semantics( - container: true, - label: 'Protocol capture of ${preview.title ?? preview.url}', - image: true, - child: ColoredBox( - color: Theme.of(context).colorScheme.surfaceContainerLowest, - child: Padding( - padding: const EdgeInsets.all(_T4Space.sm), - child: Center( - child: Image.memory( - capture, - fit: BoxFit.contain, - gaplessPlayback: true, - semanticLabel: - 'Captured preview of ${preview.title ?? preview.url}', - errorBuilder: (context, error, stackTrace) => - const _DeveloperUnavailable( - icon: Icons.broken_image_outlined, - title: 'Capture could not be displayed', - message: - 'The host returned image bytes that this device cannot decode.', - ), - ), - ), - ), - ), - ); - } -} - -bool _isDirectory(DeveloperFileEntry entry) { - final kind = entry.kind.toLowerCase(); - return kind == 'directory' || kind == 'dir' || kind == 'folder'; -} - -String _parentPath(String path) { - final normalized = path.replaceAll('\\', '/').replaceAll(RegExp(r'/+$'), ''); - final separator = normalized.lastIndexOf('/'); - return separator <= 0 ? '' : normalized.substring(0, separator); -} - -String _baseName(String path) { - final normalized = path.replaceAll('\\', '/').replaceAll(RegExp(r'/+$'), ''); - final separator = normalized.lastIndexOf('/'); - return separator < 0 ? normalized : normalized.substring(separator + 1); -} - -String _formatByteCount(int bytes) { - if (bytes < 1024) return '$bytes B'; - final kibibytes = bytes / 1024; - if (kibibytes < 1024) return '${kibibytes.toStringAsFixed(1)} KiB'; - return '${(kibibytes / 1024).toStringAsFixed(1)} MiB'; -} - -String _formatActivityTime(DateTime value) { - final local = value.toLocal(); - String twoDigits(int number) => number.toString().padLeft(2, '0'); - return '${local.year}-${twoDigits(local.month)}-${twoDigits(local.day)} ' - '${twoDigits(local.hour)}:${twoDigits(local.minute)}:${twoDigits(local.second)}'; -} - -String _redactActivityRaw(String raw) { - const sensitiveKeys = - r'authorization|cookie|password|passphrase|secret|token|api[_-]?key|credential|pairingCode'; - final keyedValue = RegExp( - '("(?:$sensitiveKeys)"\\s*:\\s*)("(?:\\\\.|[^"\\\\])*"|[^,}\\s]+)', - caseSensitive: false, - ); - final bearer = RegExp(r'Bearer\s+[A-Za-z0-9._~+/=-]+', caseSensitive: false); - return raw - .replaceAllMapped(keyedValue, (match) => '${match.group(1)}"[REDACTED]"') - .replaceAll(bearer, 'Bearer [REDACTED]'); -} diff --git a/apps/flutter/lib/src/ui/host_management.dart b/apps/flutter/lib/src/ui/host_management.dart deleted file mode 100644 index 4b06627c..00000000 --- a/apps/flutter/lib/src/ui/host_management.dart +++ /dev/null @@ -1,760 +0,0 @@ -part of 't4_app.dart'; - -const int _pairingCodeLength = 6; - -String _hostActionError(Object error, String fallback) { - final Object? message = switch (error) { - FormatException() => error.message, - StateError() => error.message, - ArgumentError() => error.message, - _ => null, - }; - if (message case final String text when text.trim().isNotEmpty) { - return text.trim(); - } - return fallback; -} - -extension on AuthenticationPhase { - String get label => switch (this) { - AuthenticationPhase.unknown => 'Authentication pending', - AuthenticationPhase.local => 'Local access', - AuthenticationPhase.pairingRequired => 'Pairing required', - AuthenticationPhase.pairing => 'Pairing', - AuthenticationPhase.paired => 'Paired', - }; -} - -final class _HostOnboardingPage extends StatelessWidget { - const _HostOnboardingPage({required this.state, required this.actions}); - - final T4ViewState state; - final T4Actions actions; - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: SingleChildScrollView( - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - padding: const EdgeInsets.all(_T4Space.lg), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: _T4Space.xl), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Connect to T4', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: _T4Space.sm), - Text( - 'Add the private Tailnet address shown by T4 on your computer. ' - 'The app will remember this host on this device.', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: _T4Space.xl), - _HostForm( - actions: actions, - operationPending: state.hostOperationPending, - submitLabel: 'Add host', - clearOnSuccess: false, - ), - ], - ), - ), - ), - ), - ), - ), - ); - } -} - -final class _HostManagerPane extends StatefulWidget { - const _HostManagerPane({ - required this.state, - required this.actions, - required this.onDone, - }); - - final T4ViewState state; - final T4Actions actions; - final VoidCallback onDone; - - @override - State<_HostManagerPane> createState() => _HostManagerPaneState(); -} - -final class _HostManagerPaneState extends State<_HostManagerPane> { - String? _pendingProfileKey; - - Future _runProfileAction({ - required String endpointKey, - required Future Function() action, - required String failureMessage, - }) async { - if (_pendingProfileKey != null || widget.state.hostOperationPending) return; - setState(() => _pendingProfileKey = endpointKey); - try { - await action(); - } on Object catch (error) { - if (!mounted) return; - final messenger = ScaffoldMessenger.of(context); - messenger - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar(content: Text(_hostActionError(error, failureMessage))), - ); - } finally { - if (mounted) setState(() => _pendingProfileKey = null); - } - } - - Future _confirmRemove({ - required String endpointKey, - required String label, - }) async { - if (_pendingProfileKey != null || widget.state.hostOperationPending) return; - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text('Remove $label?'), - content: const Text( - 'This removes the saved address and pairing credential from this ' - 'device. The host and its sessions are not changed.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Keep host'), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Remove host'), - ), - ], - ), - ); - if (confirmed != true || !mounted) return; - await _runProfileAction( - endpointKey: endpointKey, - action: () => widget.actions.removeHost(endpointKey), - failureMessage: 'Could not remove this host. Try again.', - ); - } - - Widget _buildProfile(BuildContext context, int index) { - final profile = widget.state.hostDirectory.profiles[index]; - final active = - profile.endpointKey == widget.state.hostDirectory.activeEndpointKey; - final pending = - widget.state.hostOperationPending || - _pendingProfileKey == profile.endpointKey; - final scheme = Theme.of(context).colorScheme; - final status = active - ? 'Current host · ${widget.state.connectionPhase.label} · ' - '${widget.state.authenticationPhase.label}' - : 'Saved host'; - - return Semantics( - container: true, - label: '${profile.label}, $status', - child: Padding( - padding: const EdgeInsets.symmetric(vertical: _T4Space.sm), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - profile.label, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: _T4Space.xxs), - Text( - profile.origin, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - const SizedBox(height: _T4Space.xxs), - Text( - 'Profile: ${profile.profileId}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - if (active) - Padding( - padding: const EdgeInsets.only(left: _T4Space.sm), - child: Icon( - Icons.check_circle, - color: scheme.primary, - semanticLabel: 'Current host', - ), - ), - ], - ), - const SizedBox(height: _T4Space.xs), - Text( - status, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: active ? scheme.primary : scheme.onSurfaceVariant, - ), - ), - if (active && - widget.state.authenticationPhase != - AuthenticationPhase.unknown) ...[ - const SizedBox(height: _T4Space.xxs), - _HostPermissionSummary(state: widget.state), - ], - const SizedBox(height: _T4Space.xs), - Wrap( - spacing: _T4Space.xs, - runSpacing: _T4Space.xs, - children: [ - if (!active) - Semantics( - button: true, - label: 'Switch to ${profile.label}', - child: OutlinedButton( - onPressed: pending - ? null - : () => unawaited( - _runProfileAction( - endpointKey: profile.endpointKey, - action: () => widget.actions.activateHost( - profile.endpointKey, - ), - failureMessage: - 'Could not switch hosts. Try again.', - ), - ), - child: const Text('Switch'), - ), - ), - Semantics( - button: true, - label: 'Remove ${profile.label}', - child: TextButton( - onPressed: pending - ? null - : () => unawaited( - _confirmRemove( - endpointKey: profile.endpointKey, - label: profile.label, - ), - ), - child: pending - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - semanticsLabel: 'Updating saved host', - ), - ) - : const Text('Remove'), - ), - ), - ], - ), - ], - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - final profiles = widget.state.hostDirectory.profiles; - return SafeArea( - top: false, - child: CustomScrollView( - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - slivers: [ - SliverPadding( - padding: const EdgeInsets.fromLTRB( - _T4Space.lg, - _T4Space.md, - _T4Space.lg, - _T4Space.xl, - ), - sliver: SliverToBoxAdapter( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text( - 'Saved hosts', - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - TextButton( - onPressed: widget.onDone, - child: const Text('Done'), - ), - ], - ), - const SizedBox(height: _T4Space.sm), - Text( - 'Choose which T4 host this app connects to.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: _T4Space.lg), - for (var index = 0; index < profiles.length; index++) ...[ - if (index > 0) const Divider(), - _buildProfile(context, index), - ], - const SizedBox(height: _T4Space.xl), - const Divider(), - const SizedBox(height: _T4Space.lg), - Text( - 'Add another host', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: _T4Space.md), - _HostForm( - actions: widget.actions, - operationPending: widget.state.hostOperationPending, - submitLabel: 'Add host', - clearOnSuccess: true, - ), - ], - ), - ), - ), - ), - ), - ], - ), - ); - } -} - -final class _HostPermissionSummary extends StatelessWidget { - const _HostPermissionSummary({required this.state}); - - final T4ViewState state; - - @override - Widget build(BuildContext context) { - final granted = state.grantedCapabilities; - final missing = t4RequestedCapabilities - .where((capability) => !granted.contains(capability)) - .toList(growable: false); - final colors = Theme.of(context).colorScheme; - return Semantics( - container: true, - label: - 'Granted ${granted.length} of ' - '${t4RequestedCapabilities.length} requested permissions', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Permissions: ${granted.length} of ' - '${t4RequestedCapabilities.length} granted', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant), - ), - if (missing.isNotEmpty) - Text( - 'Not granted: ${missing.join(', ')}', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: colors.error), - ), - ], - ), - ); - } -} - -final class _HostForm extends StatefulWidget { - const _HostForm({ - required this.actions, - required this.operationPending, - required this.submitLabel, - required this.clearOnSuccess, - }); - - final T4Actions actions; - final bool operationPending; - final String submitLabel; - final bool clearOnSuccess; - - @override - State<_HostForm> createState() => _HostFormState(); -} - -final class _HostFormState extends State<_HostForm> { - final TextEditingController _addressController = TextEditingController(); - final TextEditingController _profileController = TextEditingController(); - final FocusNode _profileFocusNode = FocusNode(debugLabel: 'Host profile ID'); - String? _errorMessage; - bool _submitting = false; - - bool get _pending => widget.operationPending || _submitting; - - Future _submit() async { - if (_pending) return; - setState(() { - _submitting = true; - _errorMessage = null; - }); - try { - final profileId = _profileController.text; - if (profileId.trim().isEmpty) { - await widget.actions.addHost(_addressController.text); - } else { - await widget.actions.addHost( - _addressController.text, - profileId: profileId, - ); - } - if (!mounted) return; - if (widget.clearOnSuccess) { - _addressController.clear(); - _profileController.clear(); - } - } on Object catch (error) { - if (!mounted) return; - setState( - () => _errorMessage = _hostActionError( - error, - 'Could not add this host. Check the address and try again.', - ), - ); - } finally { - if (mounted) setState(() => _submitting = false); - } - } - - @override - void dispose() { - widget.actions.cancelHostProbe(); - _addressController.dispose(); - _profileController.dispose(); - _profileFocusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final error = _errorMessage; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Semantics( - textField: true, - label: 'Tailnet HTTPS address', - child: TextField( - controller: _addressController, - enabled: !_pending, - keyboardType: TextInputType.url, - textInputAction: TextInputAction.next, - autofillHints: const [AutofillHints.url], - onSubmitted: (_) => _profileFocusNode.requestFocus(), - decoration: const InputDecoration( - labelText: 'Tailnet HTTPS address', - hintText: 'https://your-host.your-tailnet.ts.net', - ), - ), - ), - const SizedBox(height: _T4Space.md), - Semantics( - textField: true, - label: 'Profile ID, optional', - child: TextField( - controller: _profileController, - focusNode: _profileFocusNode, - enabled: !_pending, - textInputAction: TextInputAction.done, - onSubmitted: (_) => unawaited(_submit()), - decoration: const InputDecoration( - labelText: 'Profile ID (optional)', - hintText: 'default', - ), - ), - ), - if (error != null) ...[ - const SizedBox(height: _T4Space.sm), - Semantics( - liveRegion: true, - label: 'Host error: $error', - child: Text( - error, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.error, - ), - ), - ), - ], - const SizedBox(height: _T4Space.lg), - Semantics( - button: true, - label: _pending ? 'Adding host' : widget.submitLabel, - child: FilledButton( - onPressed: _pending ? null : () => unawaited(_submit()), - child: _pending - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - semanticsLabel: 'Adding host', - ), - ) - : Text(widget.submitLabel), - ), - ), - ], - ); - } -} - -final class _PairingPane extends StatefulWidget { - const _PairingPane({required this.state, required this.actions}); - - final T4ViewState state; - final T4Actions actions; - - @override - State<_PairingPane> createState() => _PairingPaneState(); -} - -final class _PairingPaneState extends State<_PairingPane> { - final TextEditingController _codeController = TextEditingController(); - final FocusNode _focusNode = FocusNode(debugLabel: 'Pairing code'); - bool _hasCompleteCode = false; - bool _submitting = false; - String? _errorMessage; - bool _copiedCommand = false; - - bool get _pending => - widget.state.authenticationPhase == AuthenticationPhase.pairing || - widget.state.hostOperationPending || - _submitting; - - @override - void initState() { - super.initState(); - _codeController.addListener(_handleCodeChanged); - } - - void _handleCodeChanged() { - final complete = _codeController.text.length == _pairingCodeLength; - if (complete == _hasCompleteCode) return; - setState(() => _hasCompleteCode = complete); - } - - Future _submit() async { - if (_pending || !_hasCompleteCode) return; - final code = _codeController.text; - _codeController.clear(); - setState(() { - _submitting = true; - _errorMessage = null; - }); - try { - await widget.actions.pairHost(code); - } on Object { - if (!mounted) return; - setState( - () => _errorMessage = 'Pairing failed. Check the code and try again.', - ); - _focusNode.requestFocus(); - } finally { - if (mounted) setState(() => _submitting = false); - } - } - - Future _copyPairCommand() async { - await Clipboard.setData(ClipboardData(text: t4PairCommand)); - if (!mounted) return; - setState(() => _copiedCommand = true); - } - - @override - void dispose() { - _codeController - ..removeListener(_handleCodeChanged) - ..dispose(); - _focusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final error = - _errorMessage ?? - (widget.state.authenticationPhase == AuthenticationPhase.pairingRequired - ? widget.state.errorMessage - : null); - return SafeArea( - top: false, - child: SingleChildScrollView( - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - padding: const EdgeInsets.all(_T4Space.lg), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: _T4Space.xl), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Pair this device', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: _T4Space.sm), - Text( - 'Enter the six-digit code shown by T4 on your computer.', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - Text( - 'On the host computer, run this command to create a ' - 'one-time code:', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: _T4Space.sm), - DecoratedBox( - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(_T4Radius.md), - ), - child: Padding( - padding: const EdgeInsets.all(_T4Space.sm), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: SelectableText( - t4PairCommand, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - fontFamily: _T4Typography.monoFamily, - ), - ), - ), - IconButton( - onPressed: _copyPairCommand, - tooltip: 'Copy pair command', - icon: Icon( - _copiedCommand ? Icons.check : Icons.copy, - ), - ), - ], - ), - ), - ), - Semantics( - liveRegion: true, - child: Text( - _copiedCommand ? 'Pair command copied.' : '', - style: Theme.of(context).textTheme.bodySmall, - ), - ), - const SizedBox(height: _T4Space.lg), - const SizedBox(height: _T4Space.xl), - Semantics( - textField: true, - label: 'Six-digit pairing code', - child: TextField( - controller: _codeController, - focusNode: _focusNode, - enabled: !_pending, - autofocus: true, - obscureText: true, - obscuringCharacter: '•', - keyboardType: TextInputType.number, - textInputAction: TextInputAction.done, - autofillHints: const [AutofillHints.oneTimeCode], - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(_pairingCodeLength), - ], - onSubmitted: (_) => unawaited(_submit()), - decoration: const InputDecoration( - labelText: 'Pairing code', - hintText: '6 digits', - ), - ), - ), - if (error != null) ...[ - const SizedBox(height: _T4Space.sm), - Semantics( - liveRegion: true, - label: error, - child: Text( - error, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.error, - ), - ), - ), - ], - const SizedBox(height: _T4Space.lg), - Semantics( - button: true, - label: _pending ? 'Pairing device' : 'Pair device', - child: FilledButton( - onPressed: _hasCompleteCode && !_pending - ? () => unawaited(_submit()) - : null, - child: _pending - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - semanticsLabel: 'Pairing device', - ), - ) - : const Text('Pair device'), - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/apps/flutter/lib/src/ui/quick_open_dialog.dart b/apps/flutter/lib/src/ui/quick_open_dialog.dart deleted file mode 100644 index 8e271d46..00000000 --- a/apps/flutter/lib/src/ui/quick_open_dialog.dart +++ /dev/null @@ -1,205 +0,0 @@ -part of 't4_app.dart'; - -final class _QuickOpenDialog extends StatefulWidget { - const _QuickOpenDialog({required this.actions}); - - final T4Actions actions; - - @override - State<_QuickOpenDialog> createState() => _QuickOpenDialogState(); -} - -final class _QuickOpenDialogState extends State<_QuickOpenDialog> { - final TextEditingController _queryController = TextEditingController(); - final FocusNode _queryFocus = FocusNode(debugLabel: 'Quick open query'); - Timer? _debounce; - int _requestGeneration = 0; - List _paths = const []; - bool _loading = false; - bool _truncated = false; - String? _error; - - @override - void dispose() { - _debounce?.cancel(); - _queryController.dispose(); - _queryFocus.dispose(); - super.dispose(); - } - - void _scheduleSearch(String value) { - _debounce?.cancel(); - final query = value.trim(); - if (query.isEmpty) { - _requestGeneration += 1; - setState(() { - _paths = const []; - _loading = false; - _truncated = false; - _error = null; - }); - return; - } - _requestGeneration += 1; - setState(() { - _paths = const []; - _loading = true; - _truncated = false; - _error = null; - }); - _debounce = Timer(const Duration(milliseconds: 220), () { - unawaited(_search(query)); - }); - } - - Future _search(String query) async { - final generation = ++_requestGeneration; - setState(() { - _loading = true; - _error = null; - }); - try { - final result = await widget.actions.searchProjectFiles(query); - if (!mounted || generation != _requestGeneration) return; - setState(() { - _paths = result.paths; - _truncated = result.truncated; - _loading = false; - }); - } on Object { - if (!mounted || generation != _requestGeneration) return; - setState(() { - _paths = const []; - _truncated = false; - _loading = false; - _error = 'Project search failed. Try again.'; - }); - } - } - - @override - Widget build(BuildContext context) { - final query = _queryController.text.trim(); - return Dialog( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 620, maxHeight: 560), - child: Padding( - padding: const EdgeInsets.all(_T4Space.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text( - 'Quick open', - style: Theme.of(context).textTheme.titleLarge, - ), - ), - IconButton( - onPressed: () => Navigator.pop(context), - tooltip: 'Close quick open', - icon: const Icon(Icons.close), - ), - ], - ), - const SizedBox(height: _T4Space.sm), - TextField( - controller: _queryController, - focusNode: _queryFocus, - autofocus: true, - onChanged: _scheduleSearch, - onSubmitted: (value) { - _debounce?.cancel(); - final normalized = value.trim(); - if (normalized.isNotEmpty) unawaited(_search(normalized)); - }, - decoration: const InputDecoration( - prefixIcon: Icon(Icons.search), - labelText: 'Find a project file', - hintText: 'Type part of a file name or path', - ), - ), - const SizedBox(height: _T4Space.sm), - if (_loading) const LinearProgressIndicator(), - if (_error case final error?) ...[ - const SizedBox(height: _T4Space.sm), - Text( - error, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ], - Flexible( - child: query.isEmpty - ? const _QuickOpenMessage( - icon: Icons.keyboard_outlined, - message: 'Start typing to search this project.', - ) - : !_loading && _error == null && _paths.isEmpty - ? const _QuickOpenMessage( - icon: Icons.search_off_outlined, - message: 'No matching project files.', - ) - : ListView.builder( - shrinkWrap: true, - itemCount: _paths.length, - itemBuilder: (context, index) { - final path = _paths[index]; - return ListTile( - leading: const Icon( - Icons.insert_drive_file_outlined, - ), - title: Text( - path.split('/').last, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - path, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - onTap: () => Navigator.pop(context, path), - ); - }, - ), - ), - if (_truncated) - const Padding( - padding: EdgeInsets.only(top: _T4Space.sm), - child: Text( - 'More matches exist. Keep typing to narrow the list.', - ), - ), - ], - ), - ), - ), - ); - } -} - -final class _QuickOpenMessage extends StatelessWidget { - const _QuickOpenMessage({required this.icon, required this.message}); - - final IconData icon; - final String message; - - @override - Widget build(BuildContext context) { - return Center( - child: Padding( - padding: const EdgeInsets.all(_T4Space.xl), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(height: _T4Space.sm), - Text(message, textAlign: TextAlign.center), - ], - ), - ), - ); - } -} diff --git a/apps/flutter/lib/src/ui/session_navigation.dart b/apps/flutter/lib/src/ui/session_navigation.dart deleted file mode 100644 index 40df6866..00000000 --- a/apps/flutter/lib/src/ui/session_navigation.dart +++ /dev/null @@ -1,899 +0,0 @@ -part of 't4_app.dart'; - -enum _SessionNavigationMode { rail, drawer } - -enum _SessionListView { current, archived } - -extension on ConnectionPhase { - String get label => switch (this) { - ConnectionPhase.disconnected => 'Disconnected', - ConnectionPhase.connecting => 'Connecting', - ConnectionPhase.synchronizing => 'Synchronizing', - ConnectionPhase.ready => 'Ready', - ConnectionPhase.retrying => 'Retrying', - ConnectionPhase.failed => 'Connection failed', - }; - - bool get isActive => switch (this) { - ConnectionPhase.connecting || - ConnectionPhase.synchronizing || - ConnectionPhase.retrying => true, - ConnectionPhase.disconnected || - ConnectionPhase.ready || - ConnectionPhase.failed => false, - }; - - bool get canDisconnect => switch (this) { - ConnectionPhase.disconnected || ConnectionPhase.failed => false, - ConnectionPhase.connecting || - ConnectionPhase.synchronizing || - ConnectionPhase.ready || - ConnectionPhase.retrying => true, - }; - - String get actionLabel => canDisconnect - ? 'Disconnect' - : switch (this) { - ConnectionPhase.disconnected => 'Connect', - ConnectionPhase.failed => 'Retry', - ConnectionPhase.connecting || - ConnectionPhase.synchronizing || - ConnectionPhase.ready || - ConnectionPhase.retrying => throw StateError( - 'disconnectable phase has no connection action', - ), - }; -} - -String _displaySessionTitle(SessionSummary? session) { - final title = session?.title.trim(); - return title == null || title.isEmpty ? 'No session selected' : title; -} - -final class _SessionNavigation extends StatefulWidget { - const _SessionNavigation({ - required this.state, - required this.actions, - required this.mode, - required this.connecting, - required this.selectingSessionId, - required this.disconnecting, - required this.showingHostManager, - required this.onConnect, - required this.onDisconnect, - required this.onManageHosts, - required this.onSelectSession, - this.onClose, - }); - - final T4ViewState state; - final T4Actions actions; - final _SessionNavigationMode mode; - final bool disconnecting; - final bool connecting; - final String? selectingSessionId; - final bool showingHostManager; - final Future Function() onDisconnect; - final Future Function() onConnect; - final VoidCallback onManageHosts; - final Future Function(String sessionId) onSelectSession; - final VoidCallback? onClose; - - @override - State<_SessionNavigation> createState() => _SessionNavigationState(); -} - -final class _SessionNavigationState extends State<_SessionNavigation> { - final TextEditingController _searchController = TextEditingController(); - _SessionListView _view = _SessionListView.current; - - @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } - - Future _createSession() async { - final projects = {}; - for (final session in widget.state.sessions) { - projects.putIfAbsent(session.projectId, () => session.projectName); - } - if (projects.isEmpty) return; - final created = await showDialog( - context: context, - builder: (context) => - _CreateSessionDialog(actions: widget.actions, projects: projects), - ); - if (created == true && widget.mode == _SessionNavigationMode.drawer) { - widget.onClose?.call(); - } - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final activeProfile = widget.state.hostDirectory.activeProfile; - final query = _searchController.text.trim().toLowerCase(); - final visible = widget.state.sessions - .where((session) { - if (session.archived != (_view == _SessionListView.archived)) { - return false; - } - if (query.isEmpty) return true; - return session.title.toLowerCase().contains(query) || - session.projectName.toLowerCase().contains(query); - }) - .toList(growable: false); - final groups = >{}; - for (final session in visible) { - groups - .putIfAbsent(session.projectId, () => []) - .add(session); - } - final canCreate = - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('sessions.manage') && - !widget.state.sessionOperationPending && - widget.state.sessions.isNotEmpty; - - return Material( - color: widget.mode == _SessionNavigationMode.rail - ? scheme.surfaceContainerLowest - : scheme.surface, - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.sm, - _T4Space.xs, - _T4Space.xs, - ), - child: Row( - children: [ - Expanded( - child: Text( - widget.mode == _SessionNavigationMode.rail - ? 'T4' - : 'Navigation', - style: Theme.of(context).textTheme.titleSmall, - ), - ), - if (widget.onClose case final close?) - IconButton( - onPressed: close, - tooltip: 'Close navigation', - icon: const Icon(Icons.close), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.md, - 0, - _T4Space.md, - _T4Space.xxs, - ), - child: Align( - alignment: Alignment.centerLeft, - child: _HostChip( - phase: widget.state.connectionPhase, - hostLabel: activeProfile?.label ?? 'No host', - actionPending: widget.connecting || widget.disconnecting, - showingHostManager: widget.showingHostManager, - onConnect: widget.onConnect, - onDisconnect: widget.onDisconnect, - onManageHosts: widget.onManageHosts, - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.sm, - _T4Space.xs, - 0, - ), - child: Row( - children: [ - Expanded( - child: Text( - 'SESSIONS', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ), - IconButton( - onPressed: canCreate - ? () => unawaited(_createSession()) - : null, - tooltip: canCreate - ? 'New session' - : 'Connect with session management permission to create', - icon: const Icon(Icons.add), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: _T4Space.md), - child: TextField( - controller: _searchController, - onChanged: (_) => setState(() {}), - textInputAction: TextInputAction.search, - decoration: InputDecoration( - hintText: 'Search sessions', - prefixIcon: const Icon(Icons.search), - suffixIcon: query.isEmpty - ? null - : IconButton( - onPressed: () { - _searchController.clear(); - setState(() {}); - }, - tooltip: 'Clear search', - icon: const Icon(Icons.close), - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.sm, - _T4Space.md, - _T4Space.xs, - ), - child: SegmentedButton<_SessionListView>( - segments: const [ - ButtonSegment( - value: _SessionListView.current, - label: Text('Current'), - ), - ButtonSegment( - value: _SessionListView.archived, - label: Text('Archived'), - ), - ], - selected: <_SessionListView>{_view}, - showSelectedIcon: false, - onSelectionChanged: (selection) { - setState(() => _view = selection.single); - }, - ), - ), - Expanded( - child: Semantics( - label: 'Sessions', - explicitChildNodes: true, - child: visible.isEmpty - ? _EmptySessions( - phase: widget.state.connectionPhase, - filtered: widget.state.sessions.isNotEmpty, - ) - : ListView( - padding: const EdgeInsets.fromLTRB( - _T4Space.xs, - 0, - _T4Space.xs, - _T4Space.md, - ), - children: [ - for (final entry in groups.entries) ...[ - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.sm, - _T4Space.xs, - _T4Space.sm, - _T4Space.xxs, - ), - child: Text( - entry.value.first.projectName.toUpperCase(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: scheme.onSurfaceVariant, - letterSpacing: 0.6, - ), - ), - ), - for (final session in entry.value) - Padding( - padding: const EdgeInsets.only( - bottom: _T4Space.xxs, - ), - child: _SessionTile( - session: session, - state: widget.state, - actions: widget.actions, - selected: - session.sessionId == - widget.state.selectedSessionId, - pending: - session.sessionId == - widget.selectingSessionId, - onTap: () => - widget.onSelectSession(session.sessionId), - ), - ), - ], - ], - ), - ), - ), - ], - ), - ), - ); - } -} - -final class _HostChip extends StatelessWidget { - const _HostChip({ - required this.phase, - required this.hostLabel, - required this.actionPending, - required this.showingHostManager, - required this.onConnect, - required this.onDisconnect, - required this.onManageHosts, - }); - - final ConnectionPhase phase; - final String hostLabel; - final bool actionPending; - final bool showingHostManager; - final Future Function() onConnect; - final Future Function() onDisconnect; - final VoidCallback onManageHosts; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final semantic = T4SemanticColors.of(context); - final dotColor = switch (phase) { - ConnectionPhase.ready => semantic.statusDone, - ConnectionPhase.connecting || - ConnectionPhase.synchronizing || - ConnectionPhase.retrying => semantic.statusWorking, - ConnectionPhase.failed => semantic.statusError, - ConnectionPhase.disconnected => scheme.outline, - }; - final action = phase.canDisconnect ? onDisconnect : onConnect; - - return Semantics( - container: true, - label: 'Connection status: ${phase.label}', - child: MenuAnchor( - menuChildren: [ - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.sm, - _T4Space.xs, - _T4Space.sm, - _T4Space.xxs, - ), - child: Text( - '${phase.label} · $hostLabel', - style: theme.textTheme.labelSmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ), - MenuItemButton( - onPressed: onManageHosts, - leadingIcon: const Icon(Icons.dns_outlined), - child: const Text('Manage hosts'), - ), - MenuItemButton( - onPressed: actionPending ? null : () => unawaited(action()), - style: phase.canDisconnect - ? MenuItemButton.styleFrom(foregroundColor: scheme.error) - : null, - child: Text(actionPending ? 'Working…' : phase.actionLabel), - ), - ], - builder: (context, controller, _) => Tooltip( - message: 'Host menu', - child: Semantics( - button: true, - child: InkWell( - borderRadius: BorderRadius.circular(_T4Radius.lg), - onTap: () => - controller.isOpen ? controller.close() : controller.open(), - child: Ink( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.sm, - vertical: _T4Space.xxs, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(_T4Radius.lg), - border: Border.all( - color: showingHostManager - ? scheme.primary - : scheme.outlineVariant, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.circle, size: 6, color: dotColor), - const SizedBox(width: _T4Space.xs), - Flexible( - child: Text( - hostLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall, - ), - ), - const SizedBox(width: _T4Space.xxs), - Icon( - Icons.expand_more, - size: _T4Size.indicator, - color: scheme.onSurfaceVariant, - ), - ], - ), - ), - ), - ), - ), - ), - ); - } -} - -final class _EmptySessions extends StatelessWidget { - const _EmptySessions({required this.phase, this.filtered = false}); - - final ConnectionPhase phase; - final bool filtered; - - @override - Widget build(BuildContext context) { - final ready = phase == ConnectionPhase.ready; - return Center( - child: Padding( - padding: const EdgeInsets.all(_T4Space.lg), - child: Text( - filtered - ? 'No matching sessions.' - : ready - ? 'No sessions are available.' - : 'Connect to load your sessions.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ); - } -} - -final class _SessionTile extends StatefulWidget { - const _SessionTile({ - required this.session, - required this.state, - required this.actions, - required this.selected, - required this.pending, - required this.onTap, - }); - - final SessionSummary session; - final T4ViewState state; - final T4Actions actions; - final bool selected; - final bool pending; - final Future Function() onTap; - - @override - State<_SessionTile> createState() => _SessionTileState(); -} - -enum _SessionAction { rename, terminate, archive, restore, delete } - -final class _SessionTileState extends State<_SessionTile> { - bool _hovered = false; - - bool get _canManage => - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('sessions.manage') && - !widget.state.sessionOperationPending; - - Future _run(_SessionAction action) async { - try { - switch (action) { - case _SessionAction.rename: - await _rename(); - case _SessionAction.terminate: - if (await _confirm( - title: 'Terminate runtime?', - message: - 'This stops the running agent for “${widget.session.title}”. ' - 'The session and transcript remain available.', - actionLabel: 'Terminate', - )) { - await widget.actions.terminateSession(widget.session.sessionId); - } - case _SessionAction.archive: - await widget.actions.archiveSession(widget.session.sessionId); - case _SessionAction.restore: - await widget.actions.restoreSession(widget.session.sessionId); - case _SessionAction.delete: - if (await _confirmDelete()) { - await widget.actions.deleteSession(widget.session.sessionId); - } - } - } on Object catch (error) { - if (!mounted) return; - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar(content: Text('Session action failed: $error')), - ); - } - } - - Future _rename() async { - final controller = TextEditingController(text: widget.session.title); - final title = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Rename session'), - content: TextField( - controller: controller, - autofocus: true, - maxLength: 512, - decoration: const InputDecoration(labelText: 'Session title'), - onSubmitted: (value) { - if (value.trim().isNotEmpty) { - Navigator.of(context).pop(value.trim()); - } - }, - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final value = controller.text.trim(); - if (value.isNotEmpty) Navigator.of(context).pop(value); - }, - child: const Text('Rename'), - ), - ], - ), - ); - controller.dispose(); - if (title != null && title != widget.session.title) { - await widget.actions.renameSession(widget.session.sessionId, title); - } - } - - Future _confirm({ - required String title, - required String message, - required String actionLabel, - }) async { - return await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(title), - content: Text(message), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(true), - child: Text(actionLabel), - ), - ], - ), - ) ?? - false; - } - - Future _confirmDelete() async { - final controller = TextEditingController(); - final confirmationText = widget.session.title.trim().isEmpty - ? 'delete' - : widget.session.title; - var valid = false; - final confirmed = await showDialog( - context: context, - builder: (context) => StatefulBuilder( - builder: (context, setDialogState) => AlertDialog( - title: const Text('Permanently delete session?'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - 'This permanently deletes the session and its transcript. ' - 'This cannot be undone.', - ), - const SizedBox(height: _T4Space.md), - Text('Type “$confirmationText” to confirm.'), - const SizedBox(height: _T4Space.xs), - TextField( - controller: controller, - autofocus: true, - decoration: const InputDecoration(labelText: 'Session title'), - onChanged: (value) { - setDialogState( - () => valid = value.trim() == confirmationText, - ); - }, - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: valid ? () => Navigator.of(context).pop(true) : null, - style: FilledButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.error, - foregroundColor: Theme.of(context).colorScheme.onError, - ), - child: const Text('Delete permanently'), - ), - ], - ), - ), - ); - controller.dispose(); - return confirmed ?? false; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final session = widget.session; - final title = session.title.trim().isEmpty - ? 'Untitled session' - : session.title; - final status = session.status.trim().isEmpty ? 'idle' : session.status; - final canArchiveOrDelete = _canManage && !session.working; - final touch = switch (theme.platform) { - TargetPlatform.android || - TargetPlatform.iOS || - TargetPlatform.fuchsia => true, - TargetPlatform.linux || - TargetPlatform.macOS || - TargetPlatform.windows => false, - }; - final showMenu = touch || _hovered || widget.selected; - final dotColor = session.archived || session.status == 'closed' - ? scheme.onSurfaceVariant.withValues(alpha: 0.5) - : session.working - ? scheme.primary - : scheme.outlineVariant; - - return Semantics( - button: true, - selected: widget.selected, - label: - '$title, ${session.projectName}, ' - '${session.archived ? 'archived, ' : ''}${session.status}', - child: MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: ListTile( - selected: widget.selected, - selectedTileColor: scheme.secondaryContainer, - title: Tooltip( - message: '$title — $status', - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith(fontSize: 13), - ), - ), - trailing: widget.pending - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - semanticsLabel: 'Selecting session', - ), - ) - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (session.working) - SizedBox.square( - dimension: 12, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - color: scheme.primary, - semanticsLabel: 'Session working', - ), - ) - else - Icon(Icons.circle, size: 6, color: dotColor), - Visibility( - visible: showMenu, - maintainSize: true, - maintainAnimation: true, - maintainState: true, - child: PopupMenuButton<_SessionAction>( - tooltip: 'Session actions', - enabled: _canManage, - onSelected: (action) => unawaited(_run(action)), - itemBuilder: (context) => [ - if (!session.archived) - const PopupMenuItem( - value: _SessionAction.rename, - child: Text('Rename'), - ), - if (!session.archived && session.status != 'closed') - const PopupMenuItem( - value: _SessionAction.terminate, - child: Text('Terminate runtime'), - ), - if (!session.archived) - PopupMenuItem( - value: _SessionAction.archive, - enabled: canArchiveOrDelete, - child: Text( - session.working - ? 'Archive (terminate runtime first)' - : 'Archive', - ), - ), - if (session.archived) - const PopupMenuItem( - value: _SessionAction.restore, - child: Text('Restore'), - ), - PopupMenuItem( - value: _SessionAction.delete, - enabled: canArchiveOrDelete, - child: Text( - session.working - ? 'Delete (terminate runtime first)' - : 'Delete permanently', - ), - ), - ], - icon: widget.selected - ? const Icon(Icons.more_horiz) - : const Icon(Icons.more_vert), - ), - ), - ], - ), - onTap: widget.pending ? null : () => unawaited(widget.onTap()), - ), - ), - ); - } -} - -final class _CreateSessionDialog extends StatefulWidget { - const _CreateSessionDialog({required this.actions, required this.projects}); - - final T4Actions actions; - final Map projects; - - @override - State<_CreateSessionDialog> createState() => _CreateSessionDialogState(); -} - -final class _CreateSessionDialogState extends State<_CreateSessionDialog> { - final TextEditingController _titleController = TextEditingController(); - late String _projectId = widget.projects.keys.first; - bool _pending = false; - String? _error; - - @override - void dispose() { - _titleController.dispose(); - super.dispose(); - } - - Future _submit() async { - if (_pending) return; - setState(() { - _pending = true; - _error = null; - }); - try { - await widget.actions.createSession( - _projectId, - title: _titleController.text, - ); - if (mounted) Navigator.of(context).pop(true); - } on Object catch (error) { - if (!mounted) return; - setState(() { - _pending = false; - _error = 'Could not create session: $error'; - }); - } - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('New session'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - DropdownButtonFormField( - initialValue: _projectId, - isExpanded: true, - decoration: const InputDecoration(labelText: 'Project'), - items: [ - for (final entry in widget.projects.entries) - DropdownMenuItem( - value: entry.key, - child: Text( - entry.value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - onChanged: _pending - ? null - : (value) { - if (value != null) setState(() => _projectId = value); - }, - ), - const SizedBox(height: _T4Space.md), - TextField( - controller: _titleController, - enabled: !_pending, - autofocus: true, - maxLength: 512, - textInputAction: TextInputAction.done, - decoration: const InputDecoration( - labelText: 'Title', - hintText: 'Session', - ), - onSubmitted: (_) => unawaited(_submit()), - ), - if (_error case final error?) ...[ - const SizedBox(height: _T4Space.xs), - Text( - error, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.error, - ), - ), - ], - ], - ), - actions: [ - TextButton( - onPressed: _pending ? null : () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: _pending ? null : () => unawaited(_submit()), - child: Text(_pending ? 'Creating…' : 'Create'), - ), - ], - ); - } -} diff --git a/apps/flutter/lib/src/ui/settings_pane.dart b/apps/flutter/lib/src/ui/settings_pane.dart deleted file mode 100644 index 0ddef5fd..00000000 --- a/apps/flutter/lib/src/ui/settings_pane.dart +++ /dev/null @@ -1,1681 +0,0 @@ -part of 't4_app.dart'; - -const String _settingsReadCapability = 'config.read'; -const String _settingsWriteCapability = 'config.write'; -const String _diagnosticsKind = 't4-code.flutter-diagnostics'; - -enum _SettingsCategory { - appearance('Appearance', Icons.palette_outlined), - host('OMP settings', Icons.tune_outlined), - app('App and runtime', Icons.system_update_alt_outlined), - diagnostics('Diagnostics', Icons.monitor_heart_outlined); - - const _SettingsCategory(this.label, this.icon); - - final String label; - final IconData icon; -} - -/// Builds the allowlisted, redacted diagnostics payload used by the settings UI. -/// -/// This deliberately does not serialize [T4ViewState]. Credentials, raw wire -/// frames, transcripts, terminal contents, and file contents therefore cannot -/// enter the export by accident as new state fields are added. -String buildT4DiagnosticsJson( - T4ViewState state, { - DateTime? generatedAt, - PlatformLifecycleViewState? platformState, -}) { - final capabilities = state.grantedCapabilities.toList()..sort(); - final features = state.grantedFeatures.toList()..sort(); - final settings = >[]; - for (final entry in state.settings.entries) { - final redacted = - entry.sensitive || entry.control == HostSettingControlKind.secret; - final diagnostic = { - 'path': entry.path, - 'section': entry.section, - 'control': entry.control.name, - 'configured': entry.configured, - 'effectiveSource': entry.effectiveSource, - 'restartRequired': entry.restartRequired, - 'available': entry.available, - 'redacted': redacted, - }; - if (!redacted && _isSafeDiagnosticValue(entry.effectiveValue)) { - diagnostic['effectiveValue'] = _copySafeDiagnosticValue( - entry.effectiveValue, - ); - } - settings.add(diagnostic); - } - - final payload = { - 'kind': _diagnosticsKind, - 'schemaVersion': 1, - 'generatedAt': (generatedAt ?? DateTime.now().toUtc()).toIso8601String(), - 'connection': state.connectionPhase.name, - 'authentication': state.authenticationPhase.name, - 'hostLabel': state.hostDirectory.activeProfile?.label, - 'settingsRevision': state.settings.revision, - 'lifecycle': state.lifecyclePhase.name, - 'capabilityNames': capabilities, - 'featureNames': features, - 'protocolIssues': List.of(state.settings.issues), - 'settings': settings, - if (platformState != null) - 'platform': { - 'runtime': { - 'supported': platformState.runtime.supported, - 'available': platformState.runtime.available, - 'definition': platformState.runtime.definition.name, - 'service': platformState.runtime.service.name, - 'issueCode': platformState.runtime.issueCode, - }, - 'update': { - 'supported': platformState.update.supported, - 'currentVersion': platformState.update.currentVersion, - 'phase': platformState.update.phase.name, - 'latestVersion': platformState.update.latestVersion, - 'checkedAt': platformState.update.checkedAt, - 'revision': platformState.update.revision, - 'error': platformState.update.error, - }, - }, - }; - return const JsonEncoder.withIndent(' ').convert(payload); -} - -bool _isSafeDiagnosticValue(Object? value) { - if (value == null || value is bool || value is String) return true; - if (value case final num number) return number.isFinite; - if (value case final List values) { - return values.every(_isSafeDiagnosticValue); - } - if (value case final Map values) { - return values.entries.every( - (entry) => entry.key is String && _isSafeDiagnosticValue(entry.value), - ); - } - return false; -} - -Object? _copySafeDiagnosticValue(Object? value) { - if (value case final List values) { - return values.map(_copySafeDiagnosticValue).toList(growable: false); - } - if (value case final Map values) { - return { - for (final entry in values.entries) - entry.key as String: _copySafeDiagnosticValue(entry.value), - }; - } - return value; -} - -final class _SettingsPane extends StatefulWidget { - const _SettingsPane({ - required this.state, - required this.actions, - required this.showHeader, - required this.platformState, - required this.platformActions, - required this.onDone, - }); - - final T4ViewState state; - final T4Actions actions; - final bool showHeader; - final VoidCallback onDone; - final PlatformLifecycleViewState platformState; - final PlatformLifecycleActions? platformActions; - - @override - State<_SettingsPane> createState() => _SettingsPaneState(); -} - -final class _SettingsPaneState extends State<_SettingsPane> { - final Map _staged = {}; - final Map _scopeByPath = {}; - final Map _validationErrors = {}; - String? _baseRevision; - String? _hostKey; - int _draftGeneration = 0; - bool _saving = false; - bool _refreshing = false; - bool _themePending = false; - late T4ThemePreference _themeSelection; - _SettingsCategory _activeCategory = _SettingsCategory.appearance; - String? _activeHostGroup; - - @override - void initState() { - super.initState(); - _baseRevision = widget.state.settings.revision; - _hostKey = widget.state.hostDirectory.activeProfile?.endpointKey; - _themeSelection = widget.state.themePreference; - if (_canReadHostSettings(widget.state)) unawaited(_refresh(silent: true)); - } - - @override - void didUpdateWidget(covariant _SettingsPane oldWidget) { - super.didUpdateWidget(oldWidget); - final nextHostKey = widget.state.hostDirectory.activeProfile?.endpointKey; - if (nextHostKey != _hostKey) { - _hostKey = nextHostKey; - _activeHostGroup = null; - _discardDrafts(); - _baseRevision = widget.state.settings.revision; - } else if (_staged.isEmpty && !_saving) { - _baseRevision = widget.state.settings.revision; - } - if (!_themePending) _themeSelection = widget.state.themePreference; - } - - bool get _hasConflict => - _staged.isNotEmpty && - _baseRevision != null && - widget.state.settings.revision != null && - _baseRevision != widget.state.settings.revision; - - bool get _operationPending => - _saving || widget.state.settingsOperationPending; - - Future _refresh({bool silent = false}) async { - if (_refreshing) return; - setState(() => _refreshing = true); - try { - await widget.actions.refreshSettings(); - } on Object { - if (!silent && mounted) { - _showMessage('Could not refresh host settings. Try again.'); - } - } finally { - if (mounted) setState(() => _refreshing = false); - } - } - - Future _setTheme(T4ThemePreference preference) async { - if (_themePending || preference == _themeSelection) return; - setState(() { - _themeSelection = preference; - _themePending = true; - }); - try { - await widget.actions.setThemePreference(preference); - } on Object { - if (!mounted) return; - setState(() => _themeSelection = widget.state.themePreference); - _showMessage('Could not update appearance. Try again.'); - } finally { - if (mounted) setState(() => _themePending = false); - } - } - - void _stage(HostSettingEntry entry, {Object? value, bool reset = false}) { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - final scope = _scopeByPath[entry.path] ?? entry.writableScopes.first; - setState(() { - _validationErrors.remove(entry.path); - _staged[entry.path] = _StagedSetting( - scope: scope, - value: value, - reset: reset, - ); - }); - } - - void _setValidationError(String path, String? message) { - setState(() { - if (message == null) { - _validationErrors.remove(path); - } else { - _validationErrors[path] = message; - _staged.remove(path); - } - }); - } - - void _undo(String path) { - setState(() { - _staged.remove(path); - _validationErrors.remove(path); - _draftGeneration += 1; - }); - } - - void _discardDrafts() { - setState(() { - _staged.clear(); - _validationErrors.clear(); - _scopeByPath.clear(); - _draftGeneration += 1; - _baseRevision = widget.state.settings.revision; - }); - } - - Future _save() async { - if (_operationPending || _staged.isEmpty || _hasConflict) return; - if (_validationErrors.isNotEmpty) { - _showMessage('Resolve invalid values before saving.'); - return; - } - final changes = List>.of(_staged.entries); - setState(() => _saving = true); - try { - for (final change in changes) { - await widget.actions.writeSetting( - change.key, - change.value.scope, - value: change.value.value, - reset: change.value.reset, - ); - } - if (!mounted) return; - setState(() { - _staged.clear(); - _validationErrors.clear(); - _draftGeneration += 1; - _baseRevision = widget.state.settings.revision; - }); - _showMessage( - 'Saved. Host-scoped changes may still need approval in the inbox.', - ); - } on Object { - if (mounted) _showMessage('Could not save settings. No draft was lost.'); - } finally { - if (mounted) setState(() => _saving = false); - } - } - - Future _exportDiagnostics() async { - final contents = buildT4DiagnosticsJson( - widget.state, - platformState: widget.platformState, - ); - final bytes = Uint8List.fromList(utf8.encode(contents)); - try { - final location = await getSaveLocation( - suggestedName: 't4-diagnostics.json', - acceptedTypeGroups: const [ - XTypeGroup( - label: 'JSON', - extensions: ['json'], - mimeTypes: ['application/json'], - ), - ], - ); - if (location == null) return; - final file = XFile.fromData( - bytes, - mimeType: 'application/json', - name: 't4-diagnostics.json', - ); - await file.saveTo(location.path); - if (mounted) _showMessage('Redacted diagnostics saved.'); - return; - } on Object { - // Save dialogs are not available on every Flutter target. The clipboard - // fallback preserves the same allowlisted payload. - } - - try { - await Clipboard.setData(ClipboardData(text: contents)); - if (mounted) _showMessage('Redacted diagnostics copied to clipboard.'); - } on Object { - if (mounted) _showMessage('Could not export diagnostics on this device.'); - } - } - - Future _runPlatformAction( - Future Function() operation, - String failureMessage, - ) async { - try { - await operation(); - } on Object { - if (mounted) _showMessage(failureMessage); - } - } - - void _showMessage(String message) { - final messenger = ScaffoldMessenger.of(context); - messenger - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(message))); - } - - @override - Widget build(BuildContext context) { - final horizontal = widget.showHeader ? _T4Space.xl : _T4Space.md; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (widget.showHeader) _buildHeader(context), - if (widget.state.settings.loading || - _refreshing || - widget.platformState.runtimeOperationPending || - widget.platformState.updateOperationPending) - const LinearProgressIndicator( - semanticsLabel: 'Loading settings and platform status', - ), - Expanded(child: _buildCategoryLayout(context, horizontal)), - if (_staged.isNotEmpty || _validationErrors.isNotEmpty) - _buildSaveBar(context), - ], - ); - } - - Widget _buildCategoryLayout(BuildContext context, double horizontal) { - return LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth >= _T4Breakpoints.wide) { - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - width: 240, - child: NavigationRail( - key: const Key('settings-category-rail'), - extended: true, - minExtendedWidth: 240, - groupAlignment: -1, - selectedIndex: _activeCategory.index, - onDestinationSelected: (index) { - setState(() { - _activeCategory = _SettingsCategory.values[index]; - }); - }, - destinations: [ - for (final category in _SettingsCategory.values) - NavigationRailDestination( - icon: Icon(category.icon), - label: Text(category.label), - ), - ], - ), - ), - const VerticalDivider(width: 1), - Expanded( - child: _buildActiveCategory(context, horizontal: horizontal), - ), - ], - ); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: EdgeInsets.fromLTRB( - horizontal, - _T4Space.md, - horizontal, - 0, - ), - child: DropdownButtonFormField<_SettingsCategory>( - key: const Key('settings-category-picker'), - initialValue: _activeCategory, - decoration: const InputDecoration(labelText: 'Category'), - isExpanded: true, - items: [ - for (final category in _SettingsCategory.values) - DropdownMenuItem( - value: category, - child: Row( - children: [ - Icon(category.icon, size: 20), - const SizedBox(width: _T4Space.sm), - Text(category.label), - ], - ), - ), - ], - onChanged: (category) { - if (category == null || category == _activeCategory) return; - setState(() => _activeCategory = category); - }, - ), - ), - Expanded( - child: _buildActiveCategory(context, horizontal: horizontal), - ), - ], - ); - }, - ); - } - - Widget _buildActiveCategory( - BuildContext context, { - required double horizontal, - }) { - final section = switch (_activeCategory) { - _SettingsCategory.appearance => _buildAppearance(context), - _SettingsCategory.host => _buildHostSettings(context), - _SettingsCategory.app => _buildAppStatus(context), - _SettingsCategory.diagnostics => _buildDiagnostics(context), - }; - return ListView( - key: PageStorageKey( - 'settings-pane-${_activeCategory.name}-scroll', - ), - padding: EdgeInsets.fromLTRB( - horizontal, - _T4Space.lg, - horizontal, - _T4Space.xl, - ), - children: [ - Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: section, - ), - ), - ], - ); - } - - Widget _buildHeader(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.xl, - _T4Space.lg, - _T4Space.md, - _T4Space.sm, - ), - child: Row( - children: [ - Expanded( - child: Text( - 'Settings', - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - IconButton( - onPressed: widget.onDone, - tooltip: 'Close settings', - icon: const Icon(Icons.close), - ), - ], - ), - ); - } - - Widget _buildAppearance(BuildContext context) { - return _SettingsSection( - title: 'Appearance', - description: 'Choose how T4 follows your device appearance.', - child: Semantics( - label: 'Theme preference', - child: SegmentedButton( - segments: const [ - ButtonSegment( - value: T4ThemePreference.system, - icon: Icon(Icons.brightness_auto_outlined), - label: Text('System'), - ), - ButtonSegment( - value: T4ThemePreference.light, - icon: Icon(Icons.light_mode_outlined), - label: Text('Light'), - ), - ButtonSegment( - value: T4ThemePreference.dark, - icon: Icon(Icons.dark_mode_outlined), - label: Text('Dark'), - ), - ], - selected: {_themeSelection}, - showSelectedIcon: false, - onSelectionChanged: _themePending - ? null - : (selection) => unawaited(_setTheme(selection.single)), - ), - ), - ); - } - - Widget _buildAppStatus(BuildContext context) { - final platform = widget.platformState; - final actions = widget.platformActions; - final runtime = platform.runtime; - final update = platform.update; - final runtimeBusy = platform.runtimeOperationPending; - final updateBusy = platform.updateOperationPending; - final hasNativeSurface = runtime.supported || update.supported; - - return _SettingsSection( - title: 'App and runtime', - description: - 'Inspect app lifecycle, the desktop OMP service, and signed release updates.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _DiagnosticLine( - label: 'App lifecycle', - value: widget.state.lifecyclePhase.name, - ), - if (platform.errorMessage case final error?) - _SettingsNotice( - icon: Icons.error_outline, - title: 'Platform operation failed', - message: error, - error: true, - ), - if (!hasNativeSurface) - const _SettingsNotice( - icon: Icons.info_outline, - title: 'Managed by this platform', - message: - 'Desktop runtime controls and direct update controls are not available on this target.', - ), - if (runtime.supported) ...[ - const Divider(), - Text('OMP runtime', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: _T4Space.xs), - _DiagnosticLine( - label: 'Executable', - value: runtime.available ? 'Compatible' : 'Not available', - ), - _DiagnosticLine(label: 'Service', value: runtime.service.name), - _DiagnosticLine( - label: 'Definition', - value: runtime.definition.name, - ), - if (runtime.message case final message? when message.isNotEmpty) - _SettingsNotice( - icon: runtime.issueCode == null - ? Icons.info_outline - : Icons.warning_amber_outlined, - title: runtime.issueCode == null - ? 'Runtime status' - : 'Runtime needs attention', - message: message, - error: runtime.issueCode != null, - ), - if (runtime.diagnostics.isNotEmpty) - _SettingsNotice( - icon: Icons.terminal_outlined, - title: 'Runtime diagnostic', - message: runtime.diagnostics, - ), - Wrap( - spacing: _T4Space.xs, - runSpacing: _T4Space.xs, - children: [ - OutlinedButton.icon( - key: const Key('runtime-check'), - onPressed: actions == null || runtimeBusy - ? null - : () => unawaited( - _runPlatformAction( - actions.refreshPlatformState, - 'Could not inspect the OMP runtime.', - ), - ), - icon: const Icon(Icons.refresh), - label: const Text('Check'), - ), - if (runtime.available && - runtime.definition != RuntimeDefinitionState.current) - FilledButton.icon( - key: const Key('runtime-install'), - onPressed: actions == null || runtimeBusy - ? null - : () => unawaited( - _runPlatformAction( - actions.installRuntime, - 'Could not install the OMP service.', - ), - ), - icon: const Icon(Icons.install_desktop_outlined), - label: const Text('Install service'), - ), - if (runtime.available && - runtime.definition == RuntimeDefinitionState.current && - runtime.service != RuntimeServicePhase.running && - runtime.service != RuntimeServicePhase.starting) - FilledButton.icon( - key: const Key('runtime-start'), - onPressed: actions == null || runtimeBusy - ? null - : () => unawaited( - _runPlatformAction( - actions.startRuntime, - 'Could not start the OMP service.', - ), - ), - icon: const Icon(Icons.play_arrow), - label: const Text('Start'), - ), - if (runtime.service == RuntimeServicePhase.running || - runtime.service == RuntimeServicePhase.starting) - OutlinedButton.icon( - key: const Key('runtime-stop'), - onPressed: actions == null || runtimeBusy - ? null - : () => unawaited( - _runPlatformAction( - actions.stopRuntime, - 'Could not stop the OMP service.', - ), - ), - icon: const Icon(Icons.stop), - label: const Text('Stop'), - ), - if (runtime.service == RuntimeServicePhase.running) - OutlinedButton.icon( - key: const Key('runtime-restart'), - onPressed: actions == null || runtimeBusy - ? null - : () => unawaited( - _runPlatformAction( - actions.restartRuntime, - 'Could not restart the OMP service.', - ), - ), - icon: const Icon(Icons.restart_alt), - label: const Text('Restart'), - ), - if (runtime.definition != RuntimeDefinitionState.missing) - TextButton.icon( - key: const Key('runtime-uninstall'), - onPressed: actions == null || runtimeBusy - ? null - : () => unawaited( - _runPlatformAction( - actions.uninstallRuntime, - 'Could not remove the OMP service.', - ), - ), - icon: const Icon(Icons.delete_outline), - label: const Text('Remove service'), - ), - ], - ), - ], - if (update.supported) ...[ - const Divider(), - Text('App updates', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: _T4Space.xs), - _DiagnosticLine( - label: 'Current version', - value: update.currentVersion, - ), - _DiagnosticLine(label: 'Status', value: update.phase.name), - if (update.latestVersion case final version?) - _DiagnosticLine(label: 'Latest version', value: version), - if (update.progressPercent case final progress?) - _DiagnosticLine( - label: 'Download', - value: '${progress.toStringAsFixed(0)}%', - ), - if (update.message case final message? when message.isNotEmpty) - _SettingsNotice( - icon: update.phase == PlatformUpdatePhase.error - ? Icons.error_outline - : Icons.info_outline, - title: update.phase == PlatformUpdatePhase.error - ? 'Update failed' - : 'Update status', - message: message, - error: update.phase == PlatformUpdatePhase.error, - ), - Wrap( - spacing: _T4Space.xs, - runSpacing: _T4Space.xs, - children: [ - OutlinedButton.icon( - key: const Key('update-check'), - onPressed: actions == null || updateBusy - ? null - : () => unawaited( - _runPlatformAction( - actions.checkForUpdates, - 'Could not check for updates.', - ), - ), - icon: const Icon(Icons.system_update_alt), - label: const Text('Check for updates'), - ), - if (update.phase == PlatformUpdatePhase.available || - update.phase == PlatformUpdatePhase.manual) - FilledButton.icon( - key: const Key('update-download'), - onPressed: actions == null || updateBusy - ? null - : () => unawaited( - _runPlatformAction( - actions.downloadUpdate, - 'Could not download the update.', - ), - ), - icon: const Icon(Icons.download), - label: const Text('Download'), - ), - if (update.phase == PlatformUpdatePhase.installer || - update.phase == PlatformUpdatePhase.manual) - OutlinedButton.icon( - key: const Key('update-install'), - onPressed: actions == null || updateBusy - ? null - : () => unawaited( - _runPlatformAction( - actions.installUpdate, - 'Could not open the update installer.', - ), - ), - icon: const Icon(Icons.install_mobile_outlined), - label: Text( - update.phase == PlatformUpdatePhase.manual - ? 'Installation help' - : 'Install', - ), - ), - ], - ), - ], - ], - ), - ); - } - - Widget _buildHostSettings(BuildContext context) { - final state = widget.state; - final settings = state.settings; - final hasReadPermission = state.grantedCapabilities.contains( - _settingsReadCapability, - ); - final canRead = _canReadHostSettings(state); - final groups = >{}; - for (final entry in settings.entries) { - final group = _hostSettingGroupLabel(entry); - groups.putIfAbsent(group, () => []).add(entry); - } - final selectedGroup = groups.containsKey(_activeHostGroup) - ? _activeHostGroup - : groups.isEmpty - ? null - : groups.keys.first; - final selectedEntries = selectedGroup == null - ? const [] - : groups[selectedGroup]!; - - return _SettingsSection( - title: 'OMP settings', - description: - 'Changes are staged here. Save sends them to the host, where host-scoped changes may require inbox approval.', - trailing: IconButton( - onPressed: _refreshing || !_canReadHostSettings(state) - ? null - : () => unawaited(_refresh()), - tooltip: 'Refresh settings', - icon: const Icon(Icons.refresh), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_hasConflict) - _SettingsNotice( - icon: Icons.sync_problem_outlined, - title: 'Settings changed on the host', - message: - 'Discard the staged changes and review the latest values before saving.', - actionLabel: 'Discard draft', - onAction: _discardDrafts, - ), - if (settings.error case final error?) - _SettingsNotice( - icon: Icons.error_outline, - title: 'Could not load settings', - message: error, - actionLabel: canRead ? 'Retry' : null, - onAction: canRead ? () => unawaited(_refresh()) : null, - error: true, - ), - if (state.connectionPhase != ConnectionPhase.ready) - const _SettingsNotice( - icon: Icons.link_off, - title: 'Host settings unavailable', - message: 'Connect to a host to review and change its settings.', - ) - else if (!hasReadPermission) - const _SettingsNotice( - icon: Icons.lock_outline, - title: 'Permission denied', - message: 'This device was not granted config.read on this host.', - error: true, - ) - else if (!canRead) - const _SettingsNotice( - icon: Icons.tune_outlined, - title: 'Settings metadata unavailable', - message: - 'This host did not grant the catalog and settings metadata required for live controls.', - ) - else if (settings.entries.isEmpty && - !settings.loading && - settings.error == null) - const _SettingsNotice( - icon: Icons.tune_outlined, - title: 'No settings published', - message: 'Refresh after the host publishes its settings catalog.', - ) - else ...[ - DropdownButtonFormField( - key: const Key('host-settings-group-picker'), - initialValue: selectedGroup, - decoration: const InputDecoration(labelText: 'Setting group'), - isExpanded: true, - items: [ - for (final group in groups.entries) - DropdownMenuItem( - value: group.key, - child: Text('${group.key} (${group.value.length})'), - ), - ], - onChanged: (group) { - if (group == null || group == selectedGroup) return; - setState(() => _activeHostGroup = group); - }, - ), - const SizedBox(height: _T4Space.sm), - for (final entry in selectedEntries) ...[ - _buildSettingRow(context, entry), - const Divider(), - ], - ], - ], - ), - ); - } - - Widget _buildSettingRow(BuildContext context, HostSettingEntry entry) { - final scheme = Theme.of(context).colorScheme; - final metadataValid = _settingMetadataIsValid(entry); - final writeGranted = widget.state.grantedCapabilities.contains( - _settingsWriteCapability, - ); - final sensitive = - entry.sensitive || entry.control == HostSettingControlKind.secret; - final canEdit = - entry.available && - entry.writableScopes.isNotEmpty && - writeGranted && - metadataValid && - !sensitive && - entry.control != HostSettingControlKind.unsupported; - final staged = _staged[entry.path]; - final error = _validationErrors[entry.path]; - - final status = switch (( - entry.available, - metadataValid, - writeGranted, - entry.writableScopes.isNotEmpty, - sensitive, - entry.control, - )) { - (false, _, _, _, _, _) => 'Unavailable', - (_, false, _, _, _, _) => 'Invalid metadata', - (_, _, _, _, true, _) => - entry.configured ? 'Configured' : 'Not configured', - (_, _, false, _, _, _) => 'Permission denied', - (_, _, _, false, _, _) => 'Read only', - (_, _, _, _, _, HostSettingControlKind.unsupported) => 'Unsupported', - _ => null, - }; - - return Padding( - padding: const EdgeInsets.symmetric(vertical: _T4Space.sm), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - entry.label, - style: Theme.of(context).textTheme.titleSmall, - ), - if (entry.help.isNotEmpty) ...[ - const SizedBox(height: _T4Space.xxs), - Text( - entry.help, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ], - ), - ), - const SizedBox(width: _T4Space.sm), - Wrap( - spacing: _T4Space.xs, - runSpacing: _T4Space.xxs, - alignment: WrapAlignment.end, - children: [ - if (entry.restartRequired) - const _SettingsStatus( - label: 'Restart required', - warning: true, - ), - if (status != null) - _SettingsStatus( - label: status, - error: - status == 'Permission denied' || - status == 'Invalid metadata', - ), - ], - ), - ], - ), - const SizedBox(height: _T4Space.sm), - if (sensitive) - Text( - entry.configured - ? 'A value is configured. Its contents are hidden.' - : 'No value is configured.', - style: Theme.of(context).textTheme.bodyMedium, - ) - else ...[ - Text( - _effectiveDescription(entry), - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), - ), - if (canEdit) ...[ - const SizedBox(height: _T4Space.sm), - if (staged?.reset ?? false) - _SettingsNotice( - icon: Icons.restore, - title: 'Reset staged', - message: - 'Save to remove the configured value and use the inherited value.', - actionLabel: 'Undo', - onAction: () => _undo(entry.path), - ) - else - _buildControl(context, entry, staged), - if (error != null) ...[ - const SizedBox(height: _T4Space.xs), - Text( - error, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: scheme.error), - ), - ], - const SizedBox(height: _T4Space.xs), - Row( - children: [ - if (entry.writableScopes.length > 1) - Expanded(child: _buildScopeSelector(entry)), - if (entry.writableScopes.length > 1) - const SizedBox(width: _T4Space.sm), - if (entry.configured && !(staged?.reset ?? false)) - TextButton.icon( - onPressed: _operationPending - ? null - : () => _stage(entry, reset: true), - icon: const Icon(Icons.restore), - label: const Text('Reset'), - ), - if (staged != null && !staged.reset) - TextButton( - onPressed: _operationPending - ? null - : () => _undo(entry.path), - child: const Text('Undo'), - ), - ], - ), - ] else if (status != null) ...[ - const SizedBox(height: _T4Space.xs), - Text( - _disabledReason(status), - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), - ), - ], - ], - ], - ), - ); - } - - Widget _buildControl( - BuildContext context, - HostSettingEntry entry, - _StagedSetting? staged, - ) { - final current = staged?.value ?? entry.effectiveValue; - final fieldKey = ValueKey( - 'setting-${entry.path}-$_draftGeneration', - ); - switch (entry.control) { - case HostSettingControlKind.boolean: - final enabled = current is bool && current; - return Semantics( - label: entry.label, - child: Row( - children: [ - Expanded(child: Text(enabled ? 'Enabled' : 'Disabled')), - Switch( - key: Key('setting-control-${entry.path}'), - value: enabled, - onChanged: _operationPending - ? null - : (value) => _stage(entry, value: value), - ), - ], - ), - ); - case HostSettingControlKind.number: - return TextFormField( - key: fieldKey, - initialValue: current?.toString() ?? '', - enabled: !_operationPending, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - signed: true, - ), - decoration: InputDecoration( - labelText: entry.unit == null - ? entry.label - : '${entry.label} (${entry.unit})', - ), - onChanged: (raw) { - final value = num.tryParse(raw.trim()); - if (value == null) { - _setValidationError(entry.path, 'Enter a valid number.'); - } else if (entry.min case final minimum? when value < minimum) { - _setValidationError(entry.path, 'Enter ${entry.min} or greater.'); - } else if (entry.max case final maximum? when value > maximum) { - _setValidationError(entry.path, 'Enter ${entry.max} or less.'); - } else { - _stage(entry, value: value); - } - }, - ); - case HostSettingControlKind.text: - return TextFormField( - key: fieldKey, - initialValue: current as String? ?? '', - enabled: !_operationPending, - decoration: InputDecoration(labelText: entry.label), - onChanged: (value) => _stage(entry, value: value), - ); - case HostSettingControlKind.enumeration: - final selected = - current is String && - entry.options.any((option) => option.value == current) - ? current - : null; - return DropdownButtonFormField( - key: fieldKey, - initialValue: selected, - isExpanded: true, - decoration: InputDecoration(labelText: entry.label), - items: [ - for (final option in entry.options) - DropdownMenuItem( - value: option.value, - child: Text(option.label, overflow: TextOverflow.ellipsis), - ), - ], - onChanged: _operationPending - ? null - : (value) { - if (value != null) _stage(entry, value: value); - }, - ); - case HostSettingControlKind.list: - case HostSettingControlKind.map: - return TextFormField( - key: fieldKey, - initialValue: _jsonEditorValue(current, entry.control), - enabled: !_operationPending, - minLines: 2, - maxLines: 6, - keyboardType: TextInputType.multiline, - decoration: InputDecoration( - labelText: entry.control == HostSettingControlKind.list - ? '${entry.label} (JSON list)' - : '${entry.label} (JSON object)', - alignLabelWithHint: true, - ), - onChanged: (raw) { - try { - final value = jsonDecode(raw); - final valid = entry.control == HostSettingControlKind.list - ? value is List - : value is Map; - if (!valid || !_isSafeDiagnosticValue(value)) { - _setValidationError( - entry.path, - entry.control == HostSettingControlKind.list - ? 'Enter a valid JSON list.' - : 'Enter a valid JSON object.', - ); - return; - } - _stage(entry, value: _copySafeDiagnosticValue(value)); - } on FormatException { - _setValidationError( - entry.path, - entry.control == HostSettingControlKind.list - ? 'Enter a valid JSON list.' - : 'Enter a valid JSON object.', - ); - } - }, - ); - case HostSettingControlKind.secret: - case HostSettingControlKind.unsupported: - return const SizedBox.shrink(); - } - } - - Widget _buildScopeSelector(HostSettingEntry entry) { - final scope = _scopeByPath[entry.path] ?? entry.writableScopes.first; - return DropdownButtonFormField( - initialValue: scope, - isExpanded: true, - decoration: const InputDecoration(labelText: 'Write scope'), - items: [ - for (final candidate in entry.writableScopes) - DropdownMenuItem(value: candidate, child: Text(candidate)), - ], - onChanged: _operationPending - ? null - : (value) { - if (value == null) return; - setState(() { - _scopeByPath[entry.path] = value; - final staged = _staged[entry.path]; - if (staged != null) { - _staged[entry.path] = staged.withScope(value); - } - }); - }, - ); - } - - Widget _buildDiagnostics(BuildContext context) { - final state = widget.state; - final profile = state.hostDirectory.activeProfile; - final capabilities = state.grantedCapabilities.toList()..sort(); - final features = state.grantedFeatures.toList()..sort(); - return _SettingsSection( - title: 'Diagnostics', - description: - 'Safe connection metadata for troubleshooting. Exports are allowlisted and redact sensitive setting values.', - trailing: OutlinedButton.icon( - key: const Key('export-diagnostics'), - onPressed: _exportDiagnostics, - icon: const Icon(Icons.ios_share_outlined), - label: const Text('Export'), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _DiagnosticLine( - label: 'Connection', - value: state.connectionPhase.label, - ), - _DiagnosticLine( - label: 'Authentication', - value: state.authenticationPhase.label, - ), - _DiagnosticLine( - label: 'Host', - value: profile?.label ?? 'Not configured', - ), - _DiagnosticLine( - label: 'Settings revision', - value: state.settings.revision ?? 'Not received', - ), - _DiagnosticLine(label: 'Lifecycle', value: state.lifecyclePhase.name), - const SizedBox(height: _T4Space.md), - Text('Capabilities', style: Theme.of(context).textTheme.titleSmall), - const SizedBox(height: _T4Space.xs), - _NameWrap(names: capabilities, emptyLabel: 'None granted'), - const SizedBox(height: _T4Space.md), - Text( - 'Negotiated features', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: _T4Space.xs), - _NameWrap(names: features, emptyLabel: 'None published'), - const SizedBox(height: _T4Space.md), - Text( - 'Protocol issues', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: _T4Space.xs), - if (state.settings.issues.isEmpty) - Text( - 'No protocol issues reported.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ) - else - for (final issue in state.settings.issues) - Padding( - padding: const EdgeInsets.only(bottom: _T4Space.xs), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.warning_amber_outlined, size: _T4Space.lg), - const SizedBox(width: _T4Space.xs), - Expanded(child: Text(issue)), - ], - ), - ), - ], - ), - ); - } - - Widget _buildSaveBar(BuildContext context) { - final count = _staged.length; - final scheme = Theme.of(context).colorScheme; - return Material( - color: scheme.surfaceContainer, - child: SafeArea( - top: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Divider(), - Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.md, - vertical: _T4Space.sm, - ), - child: Row( - children: [ - Expanded( - child: Text( - _hasConflict - ? 'Review the host conflict before saving.' - : _validationErrors.isNotEmpty - ? 'Resolve invalid values before saving.' - : '$count staged ${count == 1 ? 'change' : 'changes'}', - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - TextButton( - onPressed: _operationPending ? null : _discardDrafts, - child: const Text('Discard'), - ), - const SizedBox(width: _T4Space.xs), - FilledButton.icon( - key: const Key('save-settings'), - onPressed: - _operationPending || - _hasConflict || - _validationErrors.isNotEmpty || - count == 0 - ? null - : () => unawaited(_save()), - icon: _operationPending - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - ), - ) - : const Icon(Icons.save_outlined), - label: const Text('Save'), - ), - ], - ), - ), - ), - ), - ], - ), - ), - ); - } -} - -final class _StagedSetting { - const _StagedSetting({ - required this.scope, - required this.value, - required this.reset, - }); - - final String scope; - final Object? value; - final bool reset; - - _StagedSetting withScope(String scope) => - _StagedSetting(scope: scope, value: value, reset: reset); -} - -final class _SettingsSection extends StatelessWidget { - const _SettingsSection({ - required this.title, - required this.description, - required this.child, - this.trailing, - }); - - final String title; - final String description; - final Widget child; - final Widget? trailing; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Semantics( - container: true, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: _T4Space.xxs), - Text( - description, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - if (trailing case final widget?) ...[ - const SizedBox(width: _T4Space.sm), - widget, - ], - ], - ), - const SizedBox(height: _T4Space.md), - child, - ], - ), - ); - } -} - -final class _SettingsNotice extends StatelessWidget { - const _SettingsNotice({ - required this.icon, - required this.title, - required this.message, - this.actionLabel, - this.onAction, - this.error = false, - }); - - final IconData icon; - final String title; - final String message; - final String? actionLabel; - final VoidCallback? onAction; - final bool error; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final background = error ? scheme.errorContainer : scheme.surfaceContainer; - final foreground = error ? scheme.onErrorContainer : scheme.onSurface; - return Padding( - padding: const EdgeInsets.only(bottom: _T4Space.sm), - child: Material( - color: background, - borderRadius: BorderRadius.circular(_T4Radius.sm), - child: Padding( - padding: const EdgeInsets.all(_T4Space.sm), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, color: foreground), - const SizedBox(width: _T4Space.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of( - context, - ).textTheme.titleSmall?.copyWith(color: foreground), - ), - const SizedBox(height: _T4Space.xxs), - Text( - message, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: foreground), - ), - ], - ), - ), - if (actionLabel != null && onAction != null) ...[ - const SizedBox(width: _T4Space.xs), - TextButton(onPressed: onAction, child: Text(actionLabel!)), - ], - ], - ), - ), - ), - ); - } -} - -final class _SettingsStatus extends StatelessWidget { - const _SettingsStatus({ - required this.label, - this.warning = false, - this.error = false, - }); - - final String label; - final bool warning; - final bool error; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final background = error - ? scheme.errorContainer - : warning - ? scheme.tertiaryContainer - : scheme.secondaryContainer; - final foreground = error - ? scheme.onErrorContainer - : warning - ? scheme.onTertiaryContainer - : scheme.onSecondaryContainer; - return Semantics( - label: label, - child: DecoratedBox( - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _T4Space.xs, - vertical: _T4Space.xxs, - ), - child: Text( - label, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: foreground), - ), - ), - ), - ); - } -} - -final class _DiagnosticLine extends StatelessWidget { - const _DiagnosticLine({required this.label, required this.value}); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: _T4Space.xs), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: Text(label)), - const SizedBox(width: _T4Space.md), - Expanded( - child: Text( - value, - textAlign: TextAlign.end, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ); - } -} - -final class _NameWrap extends StatelessWidget { - const _NameWrap({required this.names, required this.emptyLabel}); - - final List names; - final String emptyLabel; - - @override - Widget build(BuildContext context) { - if (names.isEmpty) { - return Text( - emptyLabel, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ); - } - return Wrap( - spacing: _T4Space.xs, - runSpacing: _T4Space.xs, - children: [for (final name in names) Chip(label: Text(name))], - ); - } -} - -bool _canReadHostSettings(T4ViewState state) => - state.connectionPhase == ConnectionPhase.ready && - state.grantedCapabilities.contains('catalog.read') && - state.grantedFeatures.contains('catalog.metadata') && - state.grantedCapabilities.contains(_settingsReadCapability) && - state.grantedFeatures.contains('settings.metadata'); -String _hostSettingGroupLabel(HostSettingEntry entry) { - final separator = entry.label.indexOf(' · '); - if (separator > 0) return entry.label.substring(0, separator); - final section = entry.section.trim(); - if (section.isEmpty) return 'General'; - return '${section[0].toUpperCase()}${section.substring(1)}'; -} - -bool _settingMetadataIsValid(HostSettingEntry entry) { - if (entry.path.trim().isEmpty || - entry.section.trim().isEmpty || - entry.label.trim().isEmpty) { - return false; - } - if (entry.min != null && !entry.min!.isFinite) return false; - if (entry.max != null && !entry.max!.isFinite) return false; - if (entry.min != null && entry.max != null && entry.min! > entry.max!) { - return false; - } - final value = entry.effectiveValue; - return switch (entry.control) { - HostSettingControlKind.boolean => value == null || value is bool, - HostSettingControlKind.number => - value == null || - value is num && - value.isFinite && - (entry.min == null || value >= entry.min!) && - (entry.max == null || value <= entry.max!), - HostSettingControlKind.text => value == null || value is String, - HostSettingControlKind.enumeration => - entry.options.isNotEmpty && - entry.options.every((option) => option.value.isNotEmpty) && - entry.options.map((option) => option.value).toSet().length == - entry.options.length && - (value == null || - value is String && - entry.options.any((option) => option.value == value)), - HostSettingControlKind.list => - (value == null || value is List) && - _isSafeDiagnosticValue(value), - HostSettingControlKind.map => - (value == null || value is Map) && - _isSafeDiagnosticValue(value), - HostSettingControlKind.secret => true, - HostSettingControlKind.unsupported => false, - }; -} - -String _effectiveDescription(HostSettingEntry entry) { - final value = entry.effectiveValue; - final formatted = value == null - ? 'Not set' - : value is String - ? value - : const JsonEncoder.withIndent(' ').convert(value); - final source = entry.effectiveSource; - return source == null - ? 'Effective: $formatted' - : 'Effective: $formatted · Source: $source'; -} - -String _jsonEditorValue(Object? value, HostSettingControlKind control) { - if (value != null) return const JsonEncoder.withIndent(' ').convert(value); - return control == HostSettingControlKind.list ? '[]' : '{}'; -} - -String _disabledReason(String status) => switch (status) { - 'Unavailable' => 'This setting is unavailable on the connected host.', - 'Invalid metadata' => - 'The host published metadata that cannot be edited safely.', - 'Permission denied' => - 'This device was not granted settings.write on this host.', - 'Read only' => 'The host did not publish a writable scope for this setting.', - 'Unsupported' => 'This version of T4 cannot edit the published control type.', - _ => '', -}; diff --git a/apps/flutter/lib/src/ui/t4_app.dart b/apps/flutter/lib/src/ui/t4_app.dart deleted file mode 100644 index 99ed6496..00000000 --- a/apps/flutter/lib/src/ui/t4_app.dart +++ /dev/null @@ -1,193 +0,0 @@ -library; - -import 'dart:async'; -import 'dart:convert'; - -import 'package:file_selector/file_selector.dart'; -import 'package:flutter/foundation.dart' show defaultTargetPlatform; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; -import 'package:xterm/xterm.dart'; - -import 'transcript_markdown.dart'; -import '../client/app_state.dart'; -import '../client/model_labels.dart'; -import '../platform/platform_lifecycle.dart'; -import '../protocol/protocol.dart'; -import '../platform/platform_lifecycle_controller.dart'; - -part 'adaptive_session_shell.dart'; -part 'access_mode_selector.dart'; -part 'command_palette.dart'; -part 'attention_pane.dart'; -part 'conversation_pane.dart'; -part 'context_panel.dart'; -part 'developer_surfaces.dart'; -part 'host_management.dart'; -part 'quick_open_dialog.dart'; -part 'transcript_search_pane.dart'; -part 'usage_status_pane.dart'; -part 'settings_pane.dart'; -part 't4_theme.dart'; -part 'session_navigation.dart'; -part 'terminal_pane.dart'; - -/// Material 3 application shell for T4. -/// -/// Protocol and connection behavior stay behind [T4Actions]; this widget only -/// renders the immutable [T4ViewState] supplied by its owner. -final class T4App extends StatelessWidget { - const T4App({ - required this.state, - required this.actions, - required this.credentialsAreVolatile, - this.demoMode = false, - this.platformState = const PlatformLifecycleViewState.initial(), - this.platformActions, - super.key, - }); - - final T4ViewState state; - final T4Actions actions; - final bool credentialsAreVolatile; - final bool demoMode; - final PlatformLifecycleViewState platformState; - final PlatformLifecycleActions? platformActions; - - @override - Widget build(BuildContext context) { - return MaterialApp( - debugShowCheckedModeBanner: false, - title: 'T4', - theme: _T4Theme.light(), - darkTheme: _T4Theme.dark(), - themeMode: switch (state.themePreference) { - T4ThemePreference.system => ThemeMode.system, - T4ThemePreference.light => ThemeMode.light, - T4ThemePreference.dark => ThemeMode.dark, - }, - home: demoMode - ? _DemoModeShell( - child: _AdaptiveSessionShell( - state: state, - actions: actions, - platformState: platformState, - platformActions: platformActions, - ), - ) - : credentialsAreVolatile - ? _VolatileCredentialsShell( - child: _AdaptiveSessionShell( - state: state, - actions: actions, - platformState: platformState, - platformActions: platformActions, - ), - ) - : _AdaptiveSessionShell( - state: state, - actions: actions, - platformState: platformState, - platformActions: platformActions, - ), - ); - } -} - -final class _DemoModeShell extends StatelessWidget { - const _DemoModeShell({required this.child}); - - final Widget child; - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - return Column( - children: [ - Semantics( - container: true, - label: 'Public preview using sample data. Actions are disabled.', - child: Material( - color: colors.secondaryContainer, - child: SafeArea( - bottom: false, - child: SizedBox( - height: 32, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.visibility_outlined, - size: 16, - color: colors.onSecondaryContainer, - ), - const SizedBox(width: 8), - Flexible( - child: Text( - 'Public preview · sample data · actions disabled', - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium - ?.copyWith(color: colors.onSecondaryContainer), - ), - ), - ], - ), - ), - ), - ), - ), - Expanded(child: child), - ], - ); - } -} - -final class _VolatileCredentialsShell extends StatelessWidget { - const _VolatileCredentialsShell({required this.child}); - - final Widget child; - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - return Column( - children: [ - Semantics( - container: true, - label: - 'Unsigned development mode. Credentials reset when the app quits.', - child: Material( - color: colors.tertiaryContainer, - child: SafeArea( - bottom: false, - child: SizedBox( - height: 32, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.lock_open_outlined, - size: 16, - color: colors.onTertiaryContainer, - ), - const SizedBox(width: 8), - Flexible( - child: Text( - 'Unsigned development · credentials reset on quit', - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium - ?.copyWith(color: colors.onTertiaryContainer), - ), - ), - ], - ), - ), - ), - ), - ), - Expanded(child: child), - ], - ); - } -} diff --git a/apps/flutter/lib/src/ui/t4_theme.dart b/apps/flutter/lib/src/ui/t4_theme.dart deleted file mode 100644 index 724e16a1..00000000 --- a/apps/flutter/lib/src/ui/t4_theme.dart +++ /dev/null @@ -1,599 +0,0 @@ -part of 't4_app.dart'; - -abstract final class _T4Breakpoints { - static const double wide = 980; -} - -abstract final class _T4Layout { - static const double sessionRailWidth = 300; - static const double contentMaxWidth = 760; - static const double compactToolbarHeight = 72; - static const double minimumTouchTarget = 44; - static const double followScrollThreshold = 96; -} - -abstract final class _T4Space { - static const double xxs = 4; - static const double xs = 8; - static const double sm = 12; - static const double md = 16; - static const double lg = 24; - static const double xl = 32; -} - -abstract final class _T4Radius { - static const double xs = 6; - static const double sm = 8; - static const double md = 10; - static const double lg = 16; -} - -abstract final class _T4Size { - static const double indicator = 16; - static const double emptyIcon = 32; - static const double thinStroke = 2; - static const double divider = 1; -} - -abstract final class _T4Motion { - static const Duration short = Duration(milliseconds: 120); - static const Curve standard = Curves.easeOut; -} - -abstract final class _T4Typography { - static const String sansFamily = 'DM Sans'; - static const String monoFamily = 'JetBrains Mono'; -} - -/// Semantic T4 colors that do not map cleanly onto Material's color roles. -/// -/// These values mirror `packages/ui/src/tokens.css`; widgets consume this -/// extension instead of defining platform-specific colors. -@immutable -final class T4SemanticColors extends ThemeExtension { - const T4SemanticColors({ - required this.brand, - required this.accentText, - required this.info, - required this.infoForeground, - required this.success, - required this.successForeground, - required this.warning, - required this.warningForeground, - required this.statusWorking, - required this.statusApproval, - required this.statusInput, - required this.statusPlan, - required this.statusDone, - required this.statusError, - }); - - final Color brand; - final Color accentText; - final Color info; - final Color infoForeground; - final Color success; - final Color successForeground; - final Color warning; - final Color warningForeground; - final Color statusWorking; - final Color statusApproval; - final Color statusInput; - final Color statusPlan; - final Color statusDone; - final Color statusError; - - static T4SemanticColors of(BuildContext context) => - Theme.of(context).extension()!; - - @override - T4SemanticColors copyWith({ - Color? brand, - Color? accentText, - Color? info, - Color? infoForeground, - Color? success, - Color? successForeground, - Color? warning, - Color? warningForeground, - Color? statusWorking, - Color? statusApproval, - Color? statusInput, - Color? statusPlan, - Color? statusDone, - Color? statusError, - }) => T4SemanticColors( - brand: brand ?? this.brand, - accentText: accentText ?? this.accentText, - info: info ?? this.info, - infoForeground: infoForeground ?? this.infoForeground, - success: success ?? this.success, - successForeground: successForeground ?? this.successForeground, - warning: warning ?? this.warning, - warningForeground: warningForeground ?? this.warningForeground, - statusWorking: statusWorking ?? this.statusWorking, - statusApproval: statusApproval ?? this.statusApproval, - statusInput: statusInput ?? this.statusInput, - statusPlan: statusPlan ?? this.statusPlan, - statusDone: statusDone ?? this.statusDone, - statusError: statusError ?? this.statusError, - ); - - @override - T4SemanticColors lerp(covariant T4SemanticColors? other, double t) { - if (other == null) return this; - return T4SemanticColors( - brand: Color.lerp(brand, other.brand, t)!, - accentText: Color.lerp(accentText, other.accentText, t)!, - info: Color.lerp(info, other.info, t)!, - infoForeground: Color.lerp(infoForeground, other.infoForeground, t)!, - success: Color.lerp(success, other.success, t)!, - successForeground: Color.lerp( - successForeground, - other.successForeground, - t, - )!, - warning: Color.lerp(warning, other.warning, t)!, - warningForeground: Color.lerp( - warningForeground, - other.warningForeground, - t, - )!, - statusWorking: Color.lerp(statusWorking, other.statusWorking, t)!, - statusApproval: Color.lerp(statusApproval, other.statusApproval, t)!, - statusInput: Color.lerp(statusInput, other.statusInput, t)!, - statusPlan: Color.lerp(statusPlan, other.statusPlan, t)!, - statusDone: Color.lerp(statusDone, other.statusDone, t)!, - statusError: Color.lerp(statusError, other.statusError, t)!, - ); - } -} - -abstract final class _T4Palette { - static const Color brand = Color(0xffe83174); - - static const ColorScheme lightScheme = ColorScheme( - brightness: Brightness.light, - primary: Color(0xffb8245b), - onPrimary: Color(0xffffffff), - primaryContainer: Color(0xfff8e9ef), - onPrimaryContainer: Color(0xffb8245b), - secondary: Color(0xfff5f5f5), - onSecondary: Color(0xff262626), - secondaryContainer: Color(0xfff5f5f5), - onSecondaryContainer: Color(0xff262626), - tertiary: Color(0xff1447e6), - onTertiary: Color(0xffffffff), - tertiaryContainer: Color(0xffedf2fc), - onTertiaryContainer: Color(0xff1447e6), - error: Color(0xfffb2c36), - onError: Color(0xffffffff), - errorContainer: Color(0xffffe9ea), - onErrorContainer: Color(0xffc10007), - surface: Color(0xffffffff), - onSurface: Color(0xff262626), - surfaceDim: Color(0xfff5f5f5), - surfaceBright: Color(0xffffffff), - surfaceContainerLowest: Color(0xffffffff), - surfaceContainerLow: Color(0xfffafafa), - surfaceContainer: Color(0xfff5f5f5), - surfaceContainerHigh: Color(0xfff0f0f0), - surfaceContainerHighest: Color(0xffe8e8e8), - onSurfaceVariant: Color(0xff696969), - outline: Color(0x1a000000), - outlineVariant: Color(0x14000000), - shadow: Color(0x1a000000), - scrim: Color(0x66000000), - inverseSurface: Color(0xff262626), - onInverseSurface: Color(0xfff5f5f5), - inversePrimary: Color(0xfff67399), - ); - - static const T4SemanticColors lightSemantic = T4SemanticColors( - brand: brand, - accentText: Color(0xffb8245b), - info: Color(0xff2b7fff), - infoForeground: Color(0xff1447e6), - success: Color(0xff00bc7d), - successForeground: Color(0xff007a55), - warning: Color(0xfffe9a00), - warningForeground: Color(0xffbb4d00), - statusWorking: Color(0xff0084d1), - statusApproval: Color(0xffe17100), - statusInput: Color(0xff4f39f6), - statusPlan: Color(0xff7f22fe), - statusDone: Color(0xff009966), - statusError: Color(0xffe7000b), - ); - - static const ColorScheme darkScheme = ColorScheme( - brightness: Brightness.dark, - primary: Color(0xfff67399), - onPrimary: Color(0xff0a0a0a), - primaryContainer: Color(0xff3a252b), - onPrimaryContainer: Color(0xfffc93ae), - secondary: Color(0xff1f1f1f), - onSecondary: Color(0xfff5f5f5), - secondaryContainer: Color(0xff1f1f1f), - onSecondaryContainer: Color(0xfff5f5f5), - tertiary: Color(0xff51a2ff), - onTertiary: Color(0xff0a0a0a), - tertiaryContainer: Color(0xff1f2d42), - onTertiaryContainer: Color(0xff51a2ff), - error: Color(0xfffc414a), - onError: Color(0xffffffff), - errorContainer: Color(0xff3b1d1f), - onErrorContainer: Color(0xffff6467), - surface: Color(0xff161616), - onSurface: Color(0xfff5f5f5), - surfaceDim: Color(0xff0a0a0a), - surfaceBright: Color(0xff292929), - surfaceContainerLowest: Color(0xff1b1b1b), - surfaceContainerLow: Color(0xff1f1f1f), - surfaceContainer: Color(0xff1f1f1f), - surfaceContainerHigh: Color(0xff242424), - surfaceContainerHighest: Color(0xff292929), - onSurfaceVariant: Color(0xffa1a1a1), - outline: Color(0x14ffffff), - outlineVariant: Color(0x0fffffff), - shadow: Color(0x80000000), - scrim: Color(0x99000000), - inverseSurface: Color(0xfff5f5f5), - onInverseSurface: Color(0xff262626), - inversePrimary: Color(0xffb8245b), - ); - - static const T4SemanticColors darkSemantic = T4SemanticColors( - brand: brand, - accentText: Color(0xfffc93ae), - info: Color(0xff2b7fff), - infoForeground: Color(0xff51a2ff), - success: Color(0xff00bc7d), - successForeground: Color(0xff00d492), - warning: Color(0xfffe9a00), - warningForeground: Color(0xffffb900), - statusWorking: Color(0xff00a6f4), - statusApproval: Color(0xffffb900), - statusInput: Color(0xff7c6cff), - statusPlan: Color(0xffa970ff), - statusDone: Color(0xff00d492), - statusError: Color(0xffff6467), - ); -} - -abstract final class _T4Theme { - static ThemeData light() => _build( - scheme: _T4Palette.lightScheme, - semantic: _T4Palette.lightSemantic, - ); - - static ThemeData dark() => - _build(scheme: _T4Palette.darkScheme, semantic: _T4Palette.darkSemantic); - - /// Compact density on desktop targets (native and desktop web); standard on - /// touch platforms. On web, [defaultTargetPlatform] reflects the host OS, so - /// desktop browsers resolve to macOS/Windows/Linux and stay compact. - static VisualDensity get _adaptiveDensity { - switch (defaultTargetPlatform) { - case TargetPlatform.macOS: - case TargetPlatform.windows: - case TargetPlatform.linux: - return VisualDensity.compact; - case TargetPlatform.android: - case TargetPlatform.iOS: - case TargetPlatform.fuchsia: - return VisualDensity.standard; - } - } - - static ThemeData _build({ - required ColorScheme scheme, - required T4SemanticColors semantic, - }) { - final base = ThemeData( - useMaterial3: true, - brightness: scheme.brightness, - colorScheme: scheme, - fontFamily: _T4Typography.sansFamily, - ); - final baseText = base.textTheme.apply( - fontFamily: _T4Typography.sansFamily, - bodyColor: scheme.onSurface, - displayColor: scheme.onSurface, - ); - final textTheme = baseText.copyWith( - headlineSmall: baseText.headlineSmall?.copyWith( - fontSize: 20, - fontWeight: FontWeight.w600, - fontVariations: const [FontVariation('wght', 650)], - ), - titleLarge: baseText.titleLarge?.copyWith( - fontSize: 17, - fontWeight: FontWeight.w600, - fontVariations: const [FontVariation('wght', 650)], - ), - titleMedium: baseText.titleMedium?.copyWith( - fontSize: 15, - fontWeight: FontWeight.w600, - ), - titleSmall: baseText.titleSmall?.copyWith( - fontSize: 13, - fontWeight: FontWeight.w600, - ), - bodyLarge: baseText.bodyLarge?.copyWith(fontSize: 14), - bodyMedium: baseText.bodyMedium?.copyWith(fontSize: 13, height: 1.45), - bodySmall: baseText.bodySmall?.copyWith(fontSize: 12), - labelLarge: baseText.labelLarge?.copyWith( - fontSize: 13, - fontWeight: FontWeight.w600, - ), - labelMedium: baseText.labelMedium?.copyWith( - fontSize: 12, - fontWeight: FontWeight.w500, - ), - labelSmall: baseText.labelSmall?.copyWith( - fontSize: 11, - fontWeight: FontWeight.w500, - letterSpacing: 0.2, - ), - ); - const minimumSize = WidgetStatePropertyAll(Size(0, 32)); - final touchPlatform = - defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.iOS; - final iconButtonMinimumSize = WidgetStatePropertyAll( - Size.square(touchPlatform ? _T4Layout.minimumTouchTarget : 28), - ); - final iconButtonPadding = WidgetStatePropertyAll( - EdgeInsets.all(touchPlatform ? _T4Space.xs : _T4Space.xxs), - ); - final controlShape = WidgetStatePropertyAll( - RoundedRectangleBorder(borderRadius: BorderRadius.circular(_T4Radius.sm)), - ); - const controlPadding = WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: 14, vertical: 8), - ); - final menuShape = RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_T4Radius.md), - side: BorderSide(color: scheme.outlineVariant), - ); - final menuStyle = MenuStyle( - backgroundColor: WidgetStatePropertyAll( - scheme.surfaceContainerLowest, - ), - surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent), - elevation: const WidgetStatePropertyAll(6), - shape: WidgetStatePropertyAll(menuShape), - minimumSize: const WidgetStatePropertyAll(Size(180, 0)), - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(vertical: _T4Space.xxs), - ), - ); - final isDark = scheme.brightness == Brightness.dark; - final tooltipBackground = isDark - ? scheme.surfaceContainerHighest - : scheme.inverseSurface; - final tooltipForeground = isDark - ? scheme.onSurface - : scheme.onInverseSurface; - - return base.copyWith( - scaffoldBackgroundColor: scheme.surface, - textTheme: textTheme, - visualDensity: _adaptiveDensity, - iconTheme: base.iconTheme.copyWith(size: 20), - extensions: >[semantic], - appBarTheme: AppBarTheme( - backgroundColor: scheme.surface, - foregroundColor: scheme.onSurface, - surfaceTintColor: Colors.transparent, - elevation: 0, - scrolledUnderElevation: 0, - titleTextStyle: textTheme.titleMedium, - ), - dividerTheme: DividerThemeData( - color: scheme.outlineVariant, - space: _T4Size.divider, - thickness: _T4Size.divider, - ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: scheme.onSurface.withValues(alpha: 0.03), - isDense: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), - hintStyle: textTheme.bodyMedium?.copyWith( - fontSize: 13, - color: scheme.onSurfaceVariant.withValues(alpha: 0.75), - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(_T4Radius.sm), - borderSide: BorderSide(color: scheme.outlineVariant), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(_T4Radius.sm), - borderSide: BorderSide(color: scheme.outlineVariant), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(_T4Radius.sm), - borderSide: BorderSide(color: scheme.primary, width: 1.5), - ), - disabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(_T4Radius.sm), - borderSide: BorderSide(color: scheme.outlineVariant), - ), - floatingLabelStyle: TextStyle(color: scheme.primary), - ), - filledButtonTheme: FilledButtonThemeData( - style: ButtonStyle( - minimumSize: minimumSize, - shape: controlShape, - padding: controlPadding, - elevation: const WidgetStatePropertyAll(0), - textStyle: WidgetStatePropertyAll(textTheme.labelLarge), - ), - ), - outlinedButtonTheme: OutlinedButtonThemeData( - style: ButtonStyle( - minimumSize: minimumSize, - shape: controlShape, - padding: controlPadding, - side: WidgetStatePropertyAll( - BorderSide(color: scheme.outline), - ), - textStyle: WidgetStatePropertyAll(textTheme.labelLarge), - ), - ), - textButtonTheme: TextButtonThemeData( - style: ButtonStyle( - minimumSize: minimumSize, - shape: controlShape, - padding: controlPadding, - textStyle: WidgetStatePropertyAll(textTheme.labelLarge), - ), - ), - iconButtonTheme: IconButtonThemeData( - style: ButtonStyle( - iconSize: const WidgetStatePropertyAll(19), - minimumSize: iconButtonMinimumSize, - padding: iconButtonPadding, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - shape: controlShape, - ), - ), - cardTheme: CardThemeData( - color: scheme.surfaceContainerLowest, - surfaceTintColor: Colors.transparent, - elevation: 0, - margin: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_T4Radius.md), - side: BorderSide(color: scheme.outlineVariant), - ), - ), - dialogTheme: DialogThemeData( - backgroundColor: scheme.surfaceContainerLowest, - surfaceTintColor: Colors.transparent, - elevation: 16, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: scheme.outlineVariant), - ), - titleTextStyle: textTheme.titleMedium, - contentTextStyle: textTheme.bodyMedium, - ), - bottomSheetTheme: BottomSheetThemeData( - backgroundColor: scheme.surfaceContainerLowest, - modalBackgroundColor: scheme.surfaceContainerLowest, - surfaceTintColor: Colors.transparent, - elevation: 16, - modalElevation: 16, - shape: RoundedRectangleBorder( - borderRadius: const BorderRadius.vertical( - top: Radius.circular(_T4Radius.lg), - ), - side: BorderSide(color: scheme.outlineVariant), - ), - ), - chipTheme: base.chipTheme.copyWith( - backgroundColor: scheme.surfaceContainer, - selectedColor: scheme.primaryContainer, - disabledColor: scheme.surfaceContainer, - side: BorderSide(color: scheme.outlineVariant), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_T4Radius.xs), - ), - labelStyle: textTheme.labelMedium, - padding: const EdgeInsets.symmetric(horizontal: _T4Space.xs), - ), - listTileTheme: ListTileThemeData( - dense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: _T4Space.sm), - minVerticalPadding: 6, - horizontalTitleGap: _T4Space.xs, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - iconColor: scheme.onSurfaceVariant, - ), - drawerTheme: DrawerThemeData( - backgroundColor: scheme.surface, - surfaceTintColor: Colors.transparent, - shape: const RoundedRectangleBorder(), - ), - navigationDrawerTheme: NavigationDrawerThemeData( - backgroundColor: scheme.surface, - surfaceTintColor: Colors.transparent, - indicatorColor: scheme.primaryContainer, - indicatorShape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - ), - menuTheme: MenuThemeData(style: menuStyle), - menuButtonTheme: MenuButtonThemeData( - style: ButtonStyle( - minimumSize: const WidgetStatePropertyAll(Size(0, 34)), - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric( - horizontal: _T4Space.sm, - vertical: _T4Space.xxs, - ), - ), - textStyle: WidgetStatePropertyAll(textTheme.labelMedium), - ), - ), - dropdownMenuTheme: DropdownMenuThemeData( - textStyle: textTheme.labelMedium, - menuStyle: menuStyle, - ), - popupMenuTheme: PopupMenuThemeData( - color: scheme.surfaceContainerLowest, - surfaceTintColor: Colors.transparent, - elevation: 6, - shape: menuShape, - textStyle: textTheme.labelMedium, - menuPadding: const EdgeInsets.symmetric(vertical: _T4Space.xxs), - ), - snackBarTheme: SnackBarThemeData( - behavior: SnackBarBehavior.floating, - backgroundColor: scheme.inverseSurface, - contentTextStyle: textTheme.bodyMedium?.copyWith( - color: scheme.onInverseSurface, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - ), - tooltipTheme: TooltipThemeData( - waitDuration: const Duration(milliseconds: 450), - decoration: BoxDecoration( - color: tooltipBackground, - borderRadius: BorderRadius.circular(_T4Radius.xs), - ), - textStyle: textTheme.labelSmall?.copyWith( - fontSize: 11.5, - color: tooltipForeground, - ), - ), - scrollbarTheme: ScrollbarThemeData( - radius: const Radius.circular(3), - thickness: const WidgetStatePropertyAll(6), - thumbColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.hovered) - ? scheme.onSurface.withValues(alpha: 0.25) - : scheme.onSurface.withValues(alpha: 0.15), - ), - ), - textSelectionTheme: TextSelectionThemeData( - cursorColor: scheme.primary, - selectionColor: scheme.primary.withValues(alpha: 0.22), - selectionHandleColor: scheme.primary, - ), - progressIndicatorTheme: ProgressIndicatorThemeData( - color: scheme.primary, - linearTrackColor: scheme.surfaceContainerHighest, - circularTrackColor: scheme.surfaceContainerHighest, - ), - ); - } -} diff --git a/apps/flutter/lib/src/ui/terminal_pane.dart b/apps/flutter/lib/src/ui/terminal_pane.dart deleted file mode 100644 index 9ed687b9..00000000 --- a/apps/flutter/lib/src/ui/terminal_pane.dart +++ /dev/null @@ -1,932 +0,0 @@ -part of 't4_app.dart'; - -const int _terminalScrollbackLines = 5000; -const int _terminalOutputOverlapProbeLength = 64; -const int _terminalPastePreviewCharacters = 1000; -const int _terminalPastePreviewLines = 8; - -final class _TerminalPane extends StatefulWidget { - const _TerminalPane({required this.state, required this.actions}); - - final T4ViewState state; - final T4Actions actions; - - @override - State<_TerminalPane> createState() => _TerminalPaneState(); -} - -final class _TerminalPaneState extends State<_TerminalPane> { - final Map _bindings = {}; - String? _selectedTerminalId; - bool _opening = false; - bool _reconnecting = false; - - @override - void initState() { - super.initState(); - _selectedTerminalId = _preferredTerminalId(widget.state); - _synchronizeBindings(); - } - - @override - void didUpdateWidget(covariant _TerminalPane oldWidget) { - super.didUpdateWidget(oldWidget); - _synchronizeBindings(); - - final availableIds = widget.state.terminals - .map((terminal) => terminal.terminalId) - .toSet(); - final selectedStillExists = availableIds.contains(_selectedTerminalId); - final activeId = widget.state.activeTerminalId; - final activeWasAdded = - activeId != null && - !oldWidget.state.terminals.any( - (terminal) => terminal.terminalId == activeId, - ); - - if (!selectedStillExists || activeWasAdded) { - _selectedTerminalId = _preferredTerminalId(widget.state); - } - } - - String? _preferredTerminalId(T4ViewState state) { - final activeId = state.activeTerminalId; - if (activeId != null && - state.terminals.any((terminal) => terminal.terminalId == activeId)) { - return activeId; - } - return state.terminals.isEmpty ? null : state.terminals.first.terminalId; - } - - void _synchronizeBindings() { - final currentIds = widget.state.terminals - .map((terminal) => terminal.terminalId) - .toSet(); - final removedIds = _bindings.keys - .where((terminalId) => !currentIds.contains(terminalId)) - .toList(growable: false); - for (final terminalId in removedIds) { - _bindings.remove(terminalId)?.dispose(); - } - - for (final session in widget.state.terminals) { - final binding = _bindings.putIfAbsent( - session.terminalId, - () => _TerminalBinding( - terminalId: session.terminalId, - onOutput: _forwardOutput, - onResize: _forwardResize, - ), - ); - _appendUnseenOutput(binding, session.output); - } - } - - void _appendUnseenOutput(_TerminalBinding binding, String output) { - final initialSynchronization = !binding.hasSynchronizedOutput; - final previous = binding.modelOutput; - if (output == previous) { - binding.hasSynchronizedOutput = true; - return; - } - - var unseenStart = 0; - var resetBaseline = false; - if (output.startsWith(previous)) { - unseenStart = previous.length; - } else { - unseenStart = _terminalOutputOverlap(previous, output); - resetBaseline = unseenStart == 0 && previous.isNotEmpty; - } - - final unseen = output.substring(unseenStart); - binding.modelOutput = output; - binding.hasSynchronizedOutput = true; - if (unseen.isEmpty && !resetBaseline) return; - - binding.replayingInitialOutput = initialSynchronization || resetBaseline; - try { - if (resetBaseline) _clearTerminal(binding.terminal); - binding.terminal.write(unseen); - } finally { - binding.replayingInitialOutput = false; - } - } - - void _clearTerminal(Terminal terminal) { - terminal.useMainBuffer(); - terminal.mainBuffer.clear(); - terminal.clearAltBuffer(); - terminal.setCursor(0, 0); - terminal.resetCursorStyle(); - } - - int _terminalOutputOverlap(String previous, String current) { - final maximum = previous.length < current.length - ? previous.length - : current.length; - if (maximum == 0) return 0; - - final probeLength = maximum < _terminalOutputOverlapProbeLength - ? maximum - : _terminalOutputOverlapProbeLength; - final probe = current.substring(0, probeLength); - var candidate = previous.indexOf(probe, previous.length - maximum); - while (candidate >= 0) { - final overlap = previous.length - candidate; - if (overlap <= current.length && - _regionsMatch(previous, candidate, current, overlap)) { - return overlap; - } - candidate = previous.indexOf(probe, candidate + 1); - } - for (var overlap = probeLength - 1; overlap > 0; overlap -= 1) { - if (_regionsMatch( - previous, - previous.length - overlap, - current, - overlap, - )) { - return overlap; - } - } - return 0; - } - - bool _regionsMatch( - String previous, - int previousStart, - String current, - int length, - ) { - for (var index = 0; index < length; index += 1) { - if (previous.codeUnitAt(previousStart + index) != - current.codeUnitAt(index)) { - return false; - } - } - return true; - } - - TerminalSession? _sessionFor(String terminalId) { - for (final session in widget.state.terminals) { - if (session.terminalId == terminalId) return session; - } - return null; - } - - TerminalSession? get _selectedSession { - final terminalId = _selectedTerminalId; - return terminalId == null ? null : _sessionFor(terminalId); - } - - bool _canInput(TerminalSession session) => - session.running && - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('term.input'); - - bool _canResize(TerminalSession session) => - session.running && - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('term.resize'); - - bool get _canOpen => - widget.state.selectedSession != null && - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('term.open') && - !widget.state.developerOperationPending && - !_opening; - - void _forwardOutput(String terminalId, String data) { - final binding = _bindings[terminalId]; - final session = _sessionFor(terminalId); - if (binding == null || - binding.replayingInitialOutput || - session == null || - !_canInput(session)) { - return; - } - widget.actions.sendTerminalInput(terminalId, data); - } - - void _forwardResize(String terminalId, int columns, int rows) { - final binding = _bindings[terminalId]; - final session = _sessionFor(terminalId); - if (binding == null || - session == null || - !_canResize(session) || - (binding.lastColumns == columns && binding.lastRows == rows)) { - return; - } - binding - ..lastColumns = columns - ..lastRows = rows; - widget.actions.resizeTerminal(terminalId, columns, rows); - } - - Future _openTerminal() async { - if (!_canOpen) return; - setState(() => _opening = true); - try { - final terminalId = await widget.actions.openTerminal(); - if (mounted) setState(() => _selectedTerminalId = terminalId); - } on Object catch (error) { - if (mounted) { - _showFailure( - _actionErrorMessage(error, 'Could not open a terminal. Try again.'), - ); - } - } finally { - if (mounted) setState(() => _opening = false); - } - } - - Future _reconnect() async { - if (_reconnecting || - (widget.state.connectionPhase != ConnectionPhase.disconnected && - widget.state.connectionPhase != ConnectionPhase.failed)) { - return; - } - setState(() => _reconnecting = true); - try { - await widget.actions.connect(); - } on Object catch (error) { - if (mounted) { - _showFailure( - _actionErrorMessage(error, 'Could not reconnect. Try again.'), - ); - } - } finally { - if (mounted) setState(() => _reconnecting = false); - } - } - - String _actionErrorMessage(Object error, String fallback) { - final message = error.toString().replaceFirst('Bad state: ', '').trim(); - return message.isEmpty ? fallback : message; - } - - void _showFailure(String message) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(message))); - } - - void _closeSelectedTerminal() { - final terminalId = _selectedTerminalId; - if (terminalId == null) return; - widget.actions.closeTerminal(terminalId); - } - - KeyEventResult _handleTerminalKey( - TerminalSession session, - _TerminalBinding binding, - KeyEvent event, - ) { - final keyboard = HardwareKeyboard.instance; - final platform = Theme.of(context).platform; - final isApple = - platform == TargetPlatform.iOS || platform == TargetPlatform.macOS; - final pasteShortcut = - event.logicalKey == LogicalKeyboardKey.keyV && - (isApple ? keyboard.isMetaPressed : keyboard.isControlPressed); - if (!pasteShortcut) return KeyEventResult.ignored; - - if (event is KeyDownEvent && _canInput(session)) { - unawaited(_guardedPaste(session.terminalId, binding)); - } - return KeyEventResult.handled; - } - - Future _guardedPaste( - String terminalId, - _TerminalBinding binding, - ) async { - final session = _sessionFor(terminalId); - if (session == null || !_canInput(session)) return; - - ClipboardData? clipboard; - try { - clipboard = await Clipboard.getData(Clipboard.kTextPlain); - } on Object { - if (mounted) _showFailure('Could not read the clipboard.'); - return; - } - if (!mounted) return; - final text = clipboard?.text; - if (text == null || text.isEmpty) { - _showFailure('The clipboard does not contain text.'); - return; - } - - final lineCount = '\n'.allMatches(text).length + 1; - final preview = text.length > _terminalPastePreviewCharacters - ? '${text.substring(0, _terminalPastePreviewCharacters)}…' - : text; - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Paste into terminal?'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - lineCount == 1 - ? '${text.length} characters may execute immediately.' - : '$lineCount lines may execute immediately.', - ), - const SizedBox(height: _T4Space.sm), - SingleChildScrollView( - child: SelectableText( - preview, - maxLines: _terminalPastePreviewLines, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Paste'), - ), - ], - ), - ); - if (confirmed != true || !mounted) return; - - final current = _sessionFor(terminalId); - if (current == null || !_canInput(current)) { - _showFailure('The terminal is no longer writable.'); - return; - } - binding.terminal.paste(text); - } - - @override - void dispose() { - for (final binding in _bindings.values) { - binding.dispose(); - } - _bindings.clear(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final selected = _selectedSession; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _TerminalTabStrip( - sessions: widget.state.terminals, - selectedTerminalId: selected?.terminalId, - canOpen: _canOpen, - opening: _opening, - onSelected: (terminalId) { - setState(() => _selectedTerminalId = terminalId); - _bindings[terminalId]?.focusNode.requestFocus(); - }, - onOpen: _openTerminal, - onClose: selected == null ? null : _closeSelectedTerminal, - ), - const Divider(height: _T4Size.divider), - Expanded( - child: selected == null - ? _TerminalEmptyState( - state: widget.state, - canOpen: _canOpen, - opening: _opening, - reconnecting: _reconnecting, - onOpen: _openTerminal, - onReconnect: _reconnect, - ) - : _buildTerminal(selected), - ), - ], - ); - } - - Widget _buildTerminal(TerminalSession session) { - final binding = _bindings[session.terminalId]!; - final writable = _canInput(session); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _TerminalStatusBar( - session: session, - phase: widget.state.connectionPhase, - writable: writable, - reconnecting: _reconnecting, - onReconnect: - widget.state.connectionPhase == ConnectionPhase.disconnected || - widget.state.connectionPhase == ConnectionPhase.failed - ? _reconnect - : null, - onPaste: writable - ? () => _guardedPaste(session.terminalId, binding) - : null, - ), - Expanded( - child: Semantics( - container: true, - label: 'Terminal output for ${_terminalTitle(session)}', - child: TerminalView( - binding.terminal, - key: ValueKey(session.terminalId), - controller: binding.controller, - scrollController: binding.scrollController, - focusNode: binding.focusNode, - theme: _terminalTheme(Theme.of(context).colorScheme), - autofocus: true, - readOnly: !writable, - padding: const EdgeInsets.all(_T4Space.sm), - onKeyEvent: (focusNode, event) => - _handleTerminalKey(session, binding, event), - ), - ), - ), - ], - ); - } -} - -final class _TerminalBinding { - _TerminalBinding({ - required this.terminalId, - required void Function(String terminalId, String data) onOutput, - required void Function(String terminalId, int columns, int rows) onResize, - }) : terminal = Terminal(maxLines: _terminalScrollbackLines) { - terminal.onOutput = (data) => onOutput(terminalId, data); - terminal.onResize = (columns, rows, pixelWidth, pixelHeight) => - onResize(terminalId, columns, rows); - } - - final String terminalId; - final Terminal terminal; - final TerminalController controller = TerminalController(); - final ScrollController scrollController = ScrollController(); - final FocusNode focusNode = FocusNode(); - String modelOutput = ''; - bool hasSynchronizedOutput = false; - bool replayingInitialOutput = false; - int? lastColumns; - int? lastRows; - - void dispose() { - terminal - ..onOutput = null - ..onResize = null; - controller.dispose(); - scrollController.dispose(); - focusNode.dispose(); - } -} - -final class _TerminalTabStrip extends StatelessWidget { - const _TerminalTabStrip({ - required this.sessions, - required this.selectedTerminalId, - required this.canOpen, - required this.opening, - required this.onSelected, - required this.onOpen, - required this.onClose, - }); - - final List sessions; - final String? selectedTerminalId; - final bool canOpen; - final bool opening; - final ValueChanged onSelected; - final Future Function() onOpen; - final VoidCallback? onClose; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Material( - color: scheme.surfaceContainerLow, - child: SizedBox( - height: _T4Layout.minimumTouchTarget, - child: Row( - children: [ - Expanded( - child: LayoutBuilder( - builder: (context, constraints) => Semantics( - container: true, - explicitChildNodes: true, - label: 'Terminal tabs', - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var index = 0; index < sessions.length; index += 1) - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: constraints.maxWidth, - minHeight: _T4Layout.minimumTouchTarget, - ), - child: _TerminalTab( - session: sessions[index], - index: index, - count: sessions.length, - selected: - sessions[index].terminalId == - selectedTerminalId, - onTap: () => - onSelected(sessions[index].terminalId), - ), - ), - ], - ), - ), - ), - ), - ), - IconButton( - onPressed: canOpen ? () => unawaited(onOpen()) : null, - tooltip: canOpen - ? 'New terminal' - : 'Connect with terminal access to open a terminal', - icon: opening - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - ), - ) - : const Icon(Icons.add), - ), - IconButton( - onPressed: onClose, - tooltip: onClose == null - ? 'No terminal to close' - : 'Close terminal', - icon: const Icon(Icons.close), - ), - ], - ), - ), - ); - } -} - -final class _TerminalTab extends StatelessWidget { - const _TerminalTab({ - required this.session, - required this.index, - required this.count, - required this.selected, - required this.onTap, - }); - - final TerminalSession session; - final int index; - final int count; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final status = session.running ? 'running' : _terminalExitLabel(session); - return Semantics( - button: true, - selected: selected, - label: - 'Terminal tab ${index + 1} of $count: ${_terminalTitle(session)}, $status', - excludeSemantics: true, - child: InkWell( - onTap: onTap, - child: AnimatedContainer( - duration: _T4Motion.short, - curve: _T4Motion.standard, - padding: const EdgeInsets.symmetric(horizontal: _T4Space.md), - decoration: BoxDecoration( - color: selected ? scheme.secondaryContainer : Colors.transparent, - border: Border( - bottom: BorderSide( - color: selected ? scheme.primary : Colors.transparent, - width: _T4Size.thinStroke, - ), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - session.running ? Icons.terminal : Icons.stop_circle_outlined, - size: _T4Size.indicator, - color: selected - ? scheme.onSecondaryContainer - : scheme.onSurfaceVariant, - ), - const SizedBox(width: _T4Space.xs), - Flexible( - child: Text( - _terminalTitle(session), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: selected - ? scheme.onSecondaryContainer - : scheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ), - ), - ); - } -} - -final class _TerminalStatusBar extends StatelessWidget { - const _TerminalStatusBar({ - required this.session, - required this.phase, - required this.writable, - required this.reconnecting, - required this.onReconnect, - required this.onPaste, - }); - - final TerminalSession session; - final ConnectionPhase phase; - final bool writable; - final bool reconnecting; - final Future Function()? onReconnect; - final Future Function()? onPaste; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final semantic = T4SemanticColors.of(context); - final status = _terminalStatus( - session, - phase, - writable, - reconnecting, - scheme, - semantic, - ); - return Material( - color: scheme.surfaceContainerLowest, - child: Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.xxs, - _T4Space.xs, - _T4Space.xxs, - ), - child: Wrap( - spacing: _T4Space.sm, - runSpacing: _T4Space.xxs, - crossAxisAlignment: WrapCrossAlignment.center, - alignment: WrapAlignment.spaceBetween, - children: [ - Semantics( - liveRegion: true, - label: 'Terminal status: ${status.label}', - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - status.icon, - size: _T4Size.indicator, - color: status.color, - ), - const SizedBox(width: _T4Space.xs), - Text( - status.label, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (onReconnect != null) - TextButton.icon( - onPressed: reconnecting - ? null - : () => unawaited(onReconnect!()), - icon: reconnecting - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - ), - ) - : const Icon(Icons.refresh), - label: const Text('Reconnect'), - ), - TextButton.icon( - onPressed: onPaste == null - ? null - : () => unawaited(onPaste!()), - icon: const Icon(Icons.content_paste_outlined), - label: const Text('Paste'), - ), - ], - ), - ], - ), - ), - ); - } -} - -final class _TerminalEmptyState extends StatelessWidget { - const _TerminalEmptyState({ - required this.state, - required this.canOpen, - required this.opening, - required this.reconnecting, - required this.onOpen, - required this.onReconnect, - }); - - final T4ViewState state; - final bool canOpen; - final bool opening; - final bool reconnecting; - final Future Function() onOpen; - final Future Function() onReconnect; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final offline = - state.connectionPhase == ConnectionPhase.disconnected || - state.connectionPhase == ConnectionPhase.failed; - final message = offline - ? 'Reconnect to open a terminal for this session.' - : !state.grantedCapabilities.contains('term.open') - ? 'Terminal access was not granted for this host.' - : state.selectedSession == null - ? 'Choose a session before opening a terminal.' - : 'Open a terminal to run commands and inspect their output.'; - - return Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(_T4Space.lg), - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.terminal, - size: _T4Size.emptyIcon, - color: scheme.outline, - ), - const SizedBox(height: _T4Space.md), - Text( - 'No terminal open', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: _T4Space.xs), - Text( - message, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - const SizedBox(height: _T4Space.md), - if (offline) - FilledButton.icon( - onPressed: reconnecting - ? null - : () => unawaited(onReconnect()), - icon: reconnecting - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - ), - ) - : const Icon(Icons.refresh), - label: const Text('Reconnect'), - ) - else - FilledButton.icon( - onPressed: canOpen ? () => unawaited(onOpen()) : null, - icon: opening - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - ), - ) - : const Icon(Icons.add), - label: const Text('New terminal'), - ), - ], - ), - ), - ), - ); - } -} - -final class _TerminalStatus { - const _TerminalStatus(this.label, this.icon, this.color); - - final String label; - final IconData icon; - final Color color; -} - -_TerminalStatus _terminalStatus( - TerminalSession session, - ConnectionPhase phase, - bool writable, - bool reconnecting, - ColorScheme scheme, - T4SemanticColors semantic, -) { - if (!session.running) { - return _TerminalStatus( - _terminalExitLabel(session), - Icons.stop_circle_outlined, - scheme.outline, - ); - } - if (reconnecting || phase == ConnectionPhase.retrying) { - return _TerminalStatus('Reconnecting', Icons.sync, semantic.statusWorking); - } - if (phase == ConnectionPhase.connecting || - phase == ConnectionPhase.synchronizing) { - return _TerminalStatus('Connecting', Icons.sync, semantic.statusWorking); - } - if (phase != ConnectionPhase.ready) { - return _TerminalStatus( - 'Offline', - Icons.cloud_off_outlined, - semantic.statusError, - ); - } - if (!writable) { - return _TerminalStatus( - 'Read-only', - Icons.lock_outline, - semantic.warningForeground, - ); - } - return _TerminalStatus('Connected', Icons.circle, semantic.statusDone); -} - -String _terminalTitle(TerminalSession session) { - final title = session.title.trim(); - return title.isEmpty ? 'Terminal' : title; -} - -String _terminalExitLabel(TerminalSession session) { - final signal = session.signal?.trim(); - if (signal != null && signal.isNotEmpty) return 'Exited · $signal'; - final exitCode = session.exitCode; - return exitCode == null ? 'Exited' : 'Exited · code $exitCode'; -} - -TerminalTheme _terminalTheme(ColorScheme scheme) { - return TerminalTheme( - cursor: scheme.primary, - selection: scheme.secondaryContainer, - foreground: scheme.onSurface, - background: scheme.surfaceContainerLowest, - black: scheme.shadow, - white: scheme.surfaceContainerHighest, - red: scheme.error, - green: scheme.primary, - yellow: scheme.tertiary, - blue: scheme.primary, - magenta: scheme.secondary, - cyan: scheme.tertiary, - brightBlack: scheme.outline, - brightRed: scheme.onErrorContainer, - brightGreen: scheme.onPrimaryContainer, - brightYellow: scheme.onTertiaryContainer, - brightBlue: scheme.onPrimaryContainer, - brightMagenta: scheme.onSecondaryContainer, - brightCyan: scheme.onTertiaryContainer, - brightWhite: scheme.onSurface, - searchHitBackground: scheme.tertiaryContainer, - searchHitBackgroundCurrent: scheme.primaryContainer, - searchHitForeground: scheme.onTertiaryContainer, - ); -} diff --git a/apps/flutter/lib/src/ui/transcript_markdown.dart b/apps/flutter/lib/src/ui/transcript_markdown.dart deleted file mode 100644 index 742ebcb5..00000000 --- a/apps/flutter/lib/src/ui/transcript_markdown.dart +++ /dev/null @@ -1,370 +0,0 @@ -// Markdown rendering for transcript content. -// -// Standalone library (not a `part of` t4_app.dart) so it can be consumed by -// later waves without pulling in the shell. Styling is derived entirely from -// `Theme.of(context)`; no private theme tokens are referenced. - -// The `markdown` package is a direct dependency of flutter_markdown_plus and -// is required here only for the `MarkdownElementBuilder` override signatures. -// ignore_for_file: depend_on_referenced_packages - -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; -import 'package:markdown/markdown.dart' as md; -import 'package:re_highlight/languages/bash.dart'; -import 'package:re_highlight/languages/c.dart'; -import 'package:re_highlight/languages/cpp.dart'; -import 'package:re_highlight/languages/css.dart'; -import 'package:re_highlight/languages/dart.dart'; -import 'package:re_highlight/languages/go.dart'; -import 'package:re_highlight/languages/java.dart'; -import 'package:re_highlight/languages/javascript.dart'; -import 'package:re_highlight/languages/json.dart'; -import 'package:re_highlight/languages/kotlin.dart'; -import 'package:re_highlight/languages/markdown.dart'; -import 'package:re_highlight/languages/python.dart'; -import 'package:re_highlight/languages/rust.dart'; -import 'package:re_highlight/languages/sql.dart'; -import 'package:re_highlight/languages/swift.dart'; -import 'package:re_highlight/languages/typescript.dart'; -import 'package:re_highlight/languages/xml.dart'; -import 'package:re_highlight/languages/yaml.dart'; -import 'package:re_highlight/re_highlight.dart'; -import 'package:re_highlight/styles/atom-one-dark.dart'; -import 'package:re_highlight/styles/atom-one-light.dart'; - -const String _monoFamily = 'JetBrains Mono'; - -/// Renders markdown transcript content with T4 typography. -/// -/// Fenced code blocks render through [CodeBlock]. Body text uses plain -/// `Text.rich` spans, so the widget is selectable when embedded in an -/// ambient [SelectionArea]; set [selectable] to false to opt a subtree out. -class TranscriptMarkdown extends StatelessWidget { - const TranscriptMarkdown({ - required this.data, - this.selectable = true, - super.key, - }); - - /// Raw markdown source. - final String data; - - /// Whether an ambient [SelectionArea] may select this content. - final bool selectable; - - @override - Widget build(BuildContext context) { - final ThemeData theme = Theme.of(context); - final Widget body = MarkdownBody( - data: data, - // Plain RichText spans cooperate with an ambient SelectionArea; - // SelectableText (selectable: true) would fight it. - selectable: false, - styleSheet: _styleSheetFor(theme), - builders: {'code': _FencedCodeBuilder()}, - ); - if (selectable) { - return SelectionArea(child: body); - } - return SelectionContainer.disabled(child: body); - } - - static MarkdownStyleSheet _styleSheetFor(ThemeData theme) { - final ColorScheme scheme = theme.colorScheme; - final TextTheme text = theme.textTheme; - final TextStyle body = (text.bodyMedium ?? const TextStyle()).copyWith( - fontSize: 13, - height: 1.55, - ); - TextStyle heading(TextStyle? base, double size) => - (base ?? body).copyWith(fontSize: size, height: 1.3); - - return MarkdownStyleSheet.fromTheme(theme).copyWith( - p: body, - pPadding: EdgeInsets.zero, - blockSpacing: 10, - listIndent: 20, - listBullet: body.copyWith(color: scheme.onSurfaceVariant), - h1: heading(text.titleLarge, 19), - h2: heading(text.titleMedium, 16.5), - h3: heading(text.titleSmall, 14.5), - h4: heading(text.labelLarge, 13.5), - h5: heading(text.labelLarge, 13), - h6: heading(text.labelMedium, 12.5), - // Inline code chip; fenced blocks are replaced by CodeBlock below. - code: body.copyWith( - fontFamily: _monoFamily, - fontSize: 12.5, - height: 1.4, - backgroundColor: scheme.surfaceContainerHighest.withValues(alpha: 0.5), - ), - blockquote: body.copyWith(color: scheme.onSurfaceVariant), - blockquotePadding: const EdgeInsets.fromLTRB(12, 4, 8, 4), - blockquoteDecoration: BoxDecoration( - border: Border( - left: BorderSide( - color: scheme.primary.withValues(alpha: 0.45), - width: 3, - ), - ), - ), - tableHead: body.copyWith(fontSize: 12.5, fontWeight: FontWeight.w600), - tableBody: body.copyWith(fontSize: 12.5), - tableBorder: TableBorder.all(color: scheme.outlineVariant, width: 1), - tableCellsPadding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - tableHeadCellsDecoration: BoxDecoration( - color: scheme.surfaceContainerHighest.withValues(alpha: 0.5), - ), - // CodeBlock paints its own container; keep the default `pre` wrapper - // from painting a second background behind it. - codeblockDecoration: const BoxDecoration(), - codeblockPadding: EdgeInsets.zero, - horizontalRuleDecoration: BoxDecoration( - border: Border(top: BorderSide(color: scheme.outlineVariant, width: 1)), - ), - ); - } -} - -/// Routes fenced/indented code blocks to [CodeBlock]. -/// -/// Registered under the `code` tag: returning a widget replaces the default -/// `pre` scroll view, while returning null for inline `code` spans keeps the -/// stylesheet-driven inline rendering (and unbroken text flow). -class _FencedCodeBuilder extends MarkdownElementBuilder { - @override - Widget? visitElementAfterWithContext( - BuildContext context, - md.Element element, - TextStyle? preferredStyle, - TextStyle? parentStyle, - ) { - final String classAttribute = element.attributes['class'] ?? ''; - final String? language = classAttribute.startsWith('language-') - ? classAttribute.substring('language-'.length) - : null; - String code = element.textContent; - final bool isBlock = language != null || code.contains('\n'); - if (!isBlock) { - return null; - } - if (code.endsWith('\n')) { - code = code.substring(0, code.length - 1); - } - return CodeBlock(code: code, language: language); - } -} - -/// A fenced code block with a language header, copy button, and -/// `re_highlight` syntax highlighting. -class CodeBlock extends StatefulWidget { - const CodeBlock({required this.code, this.language, super.key}); - - /// Code text without the trailing fence newline. - final String code; - - /// Info-string language (e.g. `dart`), if one was provided. - final String? language; - - @override - State createState() => _CodeBlockState(); -} - -class _CodeBlockState extends State { - static const Duration _copyFeedback = Duration(milliseconds: 1500); - - bool _copied = false; - Timer? _copyTimer; - - @override - void dispose() { - _copyTimer?.cancel(); - super.dispose(); - } - - Future _copy() async { - await Clipboard.setData(ClipboardData(text: widget.code)); - if (!mounted) { - return; - } - setState(() => _copied = true); - _copyTimer?.cancel(); - _copyTimer = Timer(_copyFeedback, () { - if (mounted) { - setState(() => _copied = false); - } - }); - } - - @override - Widget build(BuildContext context) { - final ThemeData theme = Theme.of(context); - final ColorScheme scheme = theme.colorScheme; - final Map syntaxTheme = - theme.brightness == Brightness.dark - ? atomOneDarkTheme - : atomOneLightTheme; - final TextStyle baseStyle = TextStyle( - fontFamily: _monoFamily, - fontSize: 12.5, - height: 1.55, - color: syntaxTheme['root']?.color ?? scheme.onSurface, - ); - final String label = switch (widget.language?.trim()) { - final String language when language.isNotEmpty => language, - _ => 'text', - }; - - return Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: scheme.surfaceContainerHighest.withValues(alpha: 0.35), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: scheme.outlineVariant, width: 1), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildHeader(theme, label), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), - child: Text.rich( - _highlightedSpan( - widget.code, - widget.language, - baseStyle, - syntaxTheme, - ), - softWrap: false, - ), - ), - ], - ), - ); - } - - Widget _buildHeader(ThemeData theme, String label) { - final ColorScheme scheme = theme.colorScheme; - return Container( - padding: const EdgeInsetsDirectional.only(start: 12, end: 4), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: scheme.outlineVariant, width: 1), - ), - ), - child: Row( - children: [ - Expanded( - child: SelectionContainer.disabled( - child: Text( - label, - style: (theme.textTheme.labelSmall ?? const TextStyle()) - .copyWith( - fontFamily: _monoFamily, - color: scheme.onSurfaceVariant, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ), - IconButton( - onPressed: _copy, - tooltip: _copied ? 'Copied' : 'Copy', - iconSize: 15, - visualDensity: VisualDensity.compact, - icon: Icon( - _copied ? Icons.check_rounded : Icons.copy_rounded, - color: _copied ? scheme.primary : scheme.onSurfaceVariant, - ), - ), - ], - ), - ); - } -} - -/// Lazily constructed highlighter with the transcript's supported grammars. -final Highlight _highlighter = () { - final Highlight highlight = Highlight(); - highlight.registerLanguages({ - 'bash': langBash, - 'c': langC, - 'cpp': langCpp, - 'css': langCss, - 'dart': langDart, - 'go': langGo, - 'java': langJava, - 'javascript': langJavascript, - 'json': langJson, - 'kotlin': langKotlin, - 'markdown': langMarkdown, - 'python': langPython, - 'rust': langRust, - 'sql': langSql, - 'swift': langSwift, - 'typescript': langTypescript, - 'xml': langXml, - 'yaml': langYaml, - }); - return highlight; -}(); - -const Map _languageAliases = { - 'c++': 'cpp', - 'cc': 'cpp', - 'cjs': 'javascript', - 'golang': 'go', - 'htm': 'xml', - 'html': 'xml', - 'hpp': 'cpp', - 'js': 'javascript', - 'jsx': 'javascript', - 'kt': 'kotlin', - 'md': 'markdown', - 'mjs': 'javascript', - 'py': 'python', - 'rs': 'rust', - 'sh': 'bash', - 'shell': 'bash', - 'ts': 'typescript', - 'tsx': 'typescript', - 'yml': 'yaml', - 'zsh': 'bash', -}; - -TextSpan _highlightedSpan( - String code, - String? language, - TextStyle baseStyle, - Map syntaxTheme, -) { - final String? normalized = _normalizeLanguage(language); - if (normalized == null) { - return TextSpan(text: code, style: baseStyle); - } - final HighlightResult result = _highlighter.highlight( - code: code, - language: normalized, - ); - final TextSpanRenderer renderer = TextSpanRenderer(baseStyle, syntaxTheme); - result.render(renderer); - return renderer.span ?? TextSpan(text: code, style: baseStyle); -} - -String? _normalizeLanguage(String? language) { - final String? raw = language?.trim().toLowerCase(); - if (raw == null || raw.isEmpty) { - return null; - } - final String canonical = _languageAliases[raw] ?? raw; - return _highlighter.getLanguage(canonical) != null ? canonical : null; -} diff --git a/apps/flutter/lib/src/ui/transcript_search_pane.dart b/apps/flutter/lib/src/ui/transcript_search_pane.dart deleted file mode 100644 index a274df67..00000000 --- a/apps/flutter/lib/src/ui/transcript_search_pane.dart +++ /dev/null @@ -1,521 +0,0 @@ -part of 't4_app.dart'; - -final class _TranscriptSearchPane extends StatefulWidget { - const _TranscriptSearchPane({ - required this.actions, - required this.showHeader, - required this.onDone, - required this.onOpenSession, - }); - - final T4Actions actions; - final bool showHeader; - final VoidCallback onDone; - final Future Function(String sessionId) onOpenSession; - - @override - State<_TranscriptSearchPane> createState() => _TranscriptSearchPaneState(); -} - -final class _TranscriptSearchPaneState extends State<_TranscriptSearchPane> { - final TextEditingController _query = TextEditingController(); - final TextEditingController _project = TextEditingController(); - final Set _roles = {}; - final Map _contexts = - {}; - final Set _loadingContexts = {}; - TranscriptSearchResult? _result; - String _archived = 'include'; - String? _error; - bool _searching = false; - int _requestGeneration = 0; - - @override - void dispose() { - _query.dispose(); - _project.dispose(); - super.dispose(); - } - - Future _search({bool append = false}) async { - final query = _query.text.trim(); - if (query.isEmpty || _searching) return; - final generation = ++_requestGeneration; - setState(() { - _searching = true; - _error = null; - if (!append) { - _result = null; - _contexts.clear(); - _loadingContexts.clear(); - } - }); - try { - final next = await widget.actions.searchTranscripts( - query: query, - cursor: append ? _result?.nextCursor : null, - projectId: _project.text.trim().isEmpty ? null : _project.text.trim(), - roles: _roles.isEmpty ? null : _roles.toList(growable: false), - archived: _archived, - ); - if (!mounted || generation != _requestGeneration) return; - final current = _result; - setState(() { - if (append && current != null) { - _result = TranscriptSearchResult( - items: List.unmodifiable( - [...current.items, ...next.items], - ), - nextCursor: next.nextCursor, - incomplete: next.incomplete, - index: next.index, - ); - } else { - _result = next; - } - }); - } on Object catch (error) { - if (!mounted || generation != _requestGeneration) return; - setState(() => _error = 'Search failed: $error'); - } finally { - if (mounted && generation == _requestGeneration) { - setState(() => _searching = false); - } - } - } - - Future _loadContext(TranscriptSearchItem item) async { - if (_loadingContexts.contains(item.anchorId)) return; - setState(() { - _loadingContexts.add(item.anchorId); - _error = null; - }); - try { - final context = await widget.actions.loadTranscriptContext( - sessionId: item.sessionId, - anchorId: item.anchorId, - ); - if (!mounted) return; - setState(() => _contexts[item.anchorId] = context); - } on Object catch (error) { - if (!mounted) return; - setState(() => _error = 'Context failed: $error'); - } finally { - if (mounted) setState(() => _loadingContexts.remove(item.anchorId)); - } - } - - @override - Widget build(BuildContext context) { - final result = _result; - return Column( - children: [ - if (widget.showHeader) - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.xl, - _T4Space.lg, - _T4Space.md, - _T4Space.sm, - ), - child: Row( - children: [ - Expanded( - child: Text( - 'Search transcripts', - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - IconButton( - onPressed: widget.onDone, - tooltip: 'Close search', - icon: const Icon(Icons.close), - ), - ], - ), - ), - Expanded( - child: CustomScrollView( - slivers: [ - SliverPadding( - padding: const EdgeInsets.all(_T4Space.lg), - sliver: SliverList.list( - children: [ - TextField( - controller: _query, - autofocus: true, - textInputAction: TextInputAction.search, - onSubmitted: (_) => unawaited(_search()), - decoration: InputDecoration( - labelText: 'Search transcripts', - hintText: 'Error text, decision, command…', - prefixIcon: const Icon(Icons.search), - suffixIcon: IconButton( - tooltip: 'Search', - onPressed: _searching - ? null - : () => unawaited(_search()), - icon: _searching - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.arrow_forward), - ), - ), - ), - const SizedBox(height: _T4Space.sm), - TextField( - controller: _project, - textInputAction: TextInputAction.done, - onSubmitted: (_) => unawaited(_search()), - decoration: const InputDecoration( - labelText: 'Project ID (optional)', - prefixIcon: Icon(Icons.folder_outlined), - ), - ), - const SizedBox(height: _T4Space.sm), - Wrap( - spacing: _T4Space.xs, - runSpacing: _T4Space.xs, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - for (final role in TranscriptSearchRole.values) - FilterChip( - label: Text(_roleLabel(role)), - selected: _roles.contains(role), - onSelected: (selected) => setState(() { - if (selected) { - _roles.add(role); - } else { - _roles.remove(role); - } - }), - ), - Tooltip( - message: 'Archived sessions', - child: DropdownButton( - value: _archived, - items: const [ - DropdownMenuItem( - value: 'include', - child: Text('All sessions'), - ), - DropdownMenuItem( - value: 'exclude', - child: Text('Current only'), - ), - DropdownMenuItem( - value: 'only', - child: Text('Archived only'), - ), - ], - onChanged: _searching - ? null - : (value) => setState(() => _archived = value!), - ), - ), - ], - ), - if (_error case final error?) ...[ - const SizedBox(height: _T4Space.sm), - Card( - color: Theme.of(context).colorScheme.errorContainer, - child: ListTile( - leading: const Icon(Icons.error_outline), - title: const Text('Search unavailable'), - subtitle: Text(error), - ), - ), - ], - if (result != null) ...[ - const SizedBox(height: _T4Space.md), - _SearchIndexStatus(status: result.index), - if (result.items.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: _T4Space.xl), - child: Column( - children: [ - Icon(Icons.search_off, size: _T4Size.emptyIcon), - SizedBox(height: _T4Space.sm), - Text('No matching transcripts'), - SizedBox(height: _T4Space.xxs), - Text('Try fewer filters or a different phrase.'), - ], - ), - ), - ], - ], - ), - ), - if (result != null) - SliverPadding( - padding: const EdgeInsets.fromLTRB( - _T4Space.lg, - 0, - _T4Space.lg, - _T4Space.xl, - ), - sliver: SliverList.separated( - itemCount: result.items.length, - separatorBuilder: (_, _) => - const SizedBox(height: _T4Space.sm), - itemBuilder: (context, index) { - final item = result.items[index]; - return _TranscriptSearchCard( - item: item, - contextResult: _contexts[item.anchorId], - loadingContext: _loadingContexts.contains( - item.anchorId, - ), - onLoadContext: () => unawaited(_loadContext(item)), - onOpenSession: item.archivedAt == null - ? () => unawaited( - widget.onOpenSession(item.sessionId), - ) - : null, - ); - }, - ), - ), - if (result?.nextCursor != null) - SliverPadding( - padding: const EdgeInsets.fromLTRB( - _T4Space.lg, - 0, - _T4Space.lg, - _T4Space.xl, - ), - sliver: SliverToBoxAdapter( - child: OutlinedButton.icon( - onPressed: _searching - ? null - : () => unawaited(_search(append: true)), - icon: const Icon(Icons.expand_more), - label: const Text('Load more'), - ), - ), - ), - ], - ), - ), - ], - ); - } -} - -final class _SearchIndexStatus extends StatelessWidget { - const _SearchIndexStatus({required this.status}); - - final TranscriptSearchIndexStatus status; - - @override - Widget build(BuildContext context) { - final indexed = status.indexedSessions; - final known = status.knownSessions; - final label = switch (status.state) { - TranscriptSearchIndexState.ready => '$known sessions indexed', - TranscriptSearchIndexState.building => - 'Indexing $indexed of $known sessions', - TranscriptSearchIndexState.stale => - 'Results may be incomplete while the index refreshes', - }; - return Row( - children: [ - Icon( - status.state == TranscriptSearchIndexState.ready - ? Icons.check_circle_outline - : Icons.sync, - size: _T4Size.indicator, - ), - const SizedBox(width: _T4Space.xs), - Expanded( - child: Text(label, style: Theme.of(context).textTheme.bodySmall), - ), - ], - ); - } -} - -final class _TranscriptSearchCard extends StatelessWidget { - const _TranscriptSearchCard({ - required this.item, - required this.contextResult, - required this.loadingContext, - required this.onLoadContext, - required this.onOpenSession, - }); - - final TranscriptSearchItem item; - final TranscriptContextResult? contextResult; - final bool loadingContext; - final VoidCallback onLoadContext; - final VoidCallback? onOpenSession; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(_T4Space.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - item.sessionTitle.isEmpty - ? 'Untitled session' - : item.sessionTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall, - ), - ), - if (item.archivedAt != null) - const Padding( - padding: EdgeInsets.only(left: _T4Space.xs), - child: Chip(label: Text('Archived')), - ), - ], - ), - const SizedBox(height: _T4Space.xxs), - Text( - '${_roleLabel(item.role)} · ${_formatTranscriptTime(item.timestamp)}', - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: scheme.onSurfaceVariant), - ), - const SizedBox(height: _T4Space.sm), - SelectionArea(child: _HighlightedSnippet(item: item)), - const SizedBox(height: _T4Space.sm), - Wrap( - spacing: _T4Space.xs, - runSpacing: _T4Space.xs, - children: [ - TextButton.icon( - onPressed: loadingContext || contextResult != null - ? null - : onLoadContext, - icon: loadingContext - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.history), - label: Text( - contextResult == null ? 'Show context' : 'Context loaded', - ), - ), - if (onOpenSession != null) - TextButton.icon( - onPressed: onOpenSession, - icon: const Icon(Icons.open_in_new), - label: const Text('Open session'), - ), - ], - ), - if (contextResult case final contextResult?) ...[ - const Divider(), - for (var index = 0; index < contextResult.rows.length; index++) - _TranscriptContextRowView( - row: contextResult.rows[index], - anchor: index == contextResult.anchorIndex, - ), - if (contextResult.hasBefore || contextResult.hasAfter) - Padding( - padding: const EdgeInsets.only(top: _T4Space.xs), - child: Text( - 'More transcript exists outside this context window.', - style: Theme.of(context).textTheme.labelSmall, - ), - ), - ], - ], - ), - ), - ); - } -} - -final class _HighlightedSnippet extends StatelessWidget { - const _HighlightedSnippet({required this.item}); - - final TranscriptSearchItem item; - - @override - Widget build(BuildContext context) { - final base = Theme.of(context).textTheme.bodyMedium; - final highlight = base?.copyWith( - fontWeight: FontWeight.w700, - backgroundColor: Theme.of(context).colorScheme.tertiaryContainer, - ); - final spans = []; - var offset = 0; - for (final range in item.highlights) { - if (range.end <= offset) continue; - final start = range.start < offset ? offset : range.start; - if (start > offset) { - spans.add(TextSpan(text: item.snippet.substring(offset, start))); - } - spans.add( - TextSpan( - text: item.snippet.substring(start, range.end), - style: highlight, - ), - ); - offset = range.end; - } - if (offset < item.snippet.length) { - spans.add(TextSpan(text: item.snippet.substring(offset))); - } - return Text.rich(TextSpan(style: base, children: spans)); - } -} - -final class _TranscriptContextRowView extends StatelessWidget { - const _TranscriptContextRowView({required this.row, required this.anchor}); - - final TranscriptContextRow row; - final bool anchor; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Container( - width: double.infinity, - margin: const EdgeInsets.only(bottom: _T4Space.xs), - padding: const EdgeInsets.all(_T4Space.sm), - decoration: BoxDecoration( - color: anchor ? scheme.secondaryContainer : scheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(_T4Radius.sm), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${_roleLabel(row.role)} · ${_formatTranscriptTime(row.timestamp)}', - style: Theme.of(context).textTheme.labelSmall, - ), - const SizedBox(height: _T4Space.xxs), - SelectionArea(child: Text(row.text)), - ], - ), - ); - } -} - -String _roleLabel(TranscriptSearchRole role) => switch (role) { - TranscriptSearchRole.user => 'User', - TranscriptSearchRole.assistant => 'Assistant', - TranscriptSearchRole.summary => 'Summary', -}; - -String _formatTranscriptTime(String value) { - final parsed = DateTime.tryParse(value); - return parsed == null ? value : _formatActivityTime(parsed); -} diff --git a/apps/flutter/lib/src/ui/usage_status_pane.dart b/apps/flutter/lib/src/ui/usage_status_pane.dart deleted file mode 100644 index c454e1d6..00000000 --- a/apps/flutter/lib/src/ui/usage_status_pane.dart +++ /dev/null @@ -1,483 +0,0 @@ -part of 't4_app.dart'; - -final class _UsageStatusPane extends StatefulWidget { - const _UsageStatusPane({ - required this.state, - required this.actions, - required this.showHeader, - required this.onDone, - }); - - final T4ViewState state; - final T4Actions actions; - final bool showHeader; - final VoidCallback onDone; - - @override - State<_UsageStatusPane> createState() => _UsageStatusPaneState(); -} - -final class _UsageStatusPaneState extends State<_UsageStatusPane> { - UsageReadResult? _usage; - BrokerStatusResult? _broker; - String? _usageError; - String? _brokerError; - bool _loadingUsage = false; - bool _loadingBroker = false; - - bool get _canReadUsage => - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('usage.read'); - - bool get _canReadBroker => - widget.state.connectionPhase == ConnectionPhase.ready && - widget.state.grantedCapabilities.contains('broker.read'); - - @override - void initState() { - super.initState(); - unawaited(_refresh()); - } - - Future _refresh() async { - if (_loadingUsage || _loadingBroker) return; - setState(() { - _loadingUsage = _canReadUsage; - _loadingBroker = _canReadBroker; - _usageError = null; - _brokerError = null; - }); - await Future.wait([ - if (_canReadUsage) _refreshUsage(), - if (_canReadBroker) _refreshBroker(), - ]); - } - - Future _refreshUsage() async { - try { - final usage = await widget.actions.readUsage(); - if (mounted) setState(() => _usage = usage); - } on Object { - if (mounted) setState(() => _usageError = 'Usage could not be loaded.'); - } finally { - if (mounted) setState(() => _loadingUsage = false); - } - } - - Future _refreshBroker() async { - try { - final broker = await widget.actions.readBrokerStatus(); - if (mounted) setState(() => _broker = broker); - } on Object { - if (mounted) { - setState( - () => _brokerError = 'Account broker status could not be loaded.', - ); - } - } finally { - if (mounted) setState(() => _loadingBroker = false); - } - } - - @override - Widget build(BuildContext context) { - final horizontal = widget.showHeader ? _T4Space.xl : _T4Space.md; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (widget.showHeader) - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.xl, - _T4Space.lg, - _T4Space.md, - _T4Space.sm, - ), - child: Row( - children: [ - Expanded( - child: Text( - 'Usage & accounts', - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - IconButton( - onPressed: widget.onDone, - tooltip: 'Close usage', - icon: const Icon(Icons.close), - ), - ], - ), - ), - if (_loadingUsage || _loadingBroker) - const LinearProgressIndicator( - semanticsLabel: 'Loading usage and account status', - ), - Expanded( - child: RefreshIndicator( - onRefresh: _refresh, - child: ListView( - padding: EdgeInsets.fromLTRB( - horizontal, - _T4Space.lg, - horizontal, - _T4Space.xl, - ), - children: [ - Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _T4Layout.contentMaxWidth, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildBroker(context), - const SizedBox(height: _T4Space.xl), - _buildUsage(context), - ], - ), - ), - ), - ], - ), - ), - ), - ], - ); - } - - Widget _buildBroker(BuildContext context) { - final broker = _broker; - return _StatusSection( - title: 'Account source', - trailing: IconButton( - onPressed: _loadingBroker ? null : () => unawaited(_refresh()), - tooltip: 'Refresh usage and account status', - icon: const Icon(Icons.refresh), - ), - child: !_canReadBroker - ? const _StatusNotice( - icon: Icons.lock_outline, - title: 'Broker status unavailable', - message: 'This host did not grant broker.read.', - ) - : _brokerError != null - ? _StatusNotice( - icon: Icons.error_outline, - title: 'Broker status unavailable', - message: _brokerError!, - ) - : broker == null - ? const _StatusNotice( - icon: Icons.hourglass_empty, - title: 'Loading account source', - message: 'Waiting for the connected host.', - ) - : _BrokerStatusCard(status: broker), - ); - } - - Widget _buildUsage(BuildContext context) { - final usage = _usage; - return _StatusSection( - title: 'Usage limits', - child: !_canReadUsage - ? const _StatusNotice( - icon: Icons.lock_outline, - title: 'Usage unavailable', - message: 'This host did not grant usage.read.', - ) - : _usageError != null - ? _StatusNotice( - icon: Icons.error_outline, - title: 'Usage unavailable', - message: _usageError!, - ) - : usage == null - ? const _StatusNotice( - icon: Icons.hourglass_empty, - title: 'Loading usage', - message: 'Waiting for provider reports.', - ) - : _UsageReportList(result: usage), - ); - } -} - -final class _StatusSection extends StatelessWidget { - const _StatusSection({ - required this.title, - required this.child, - this.trailing, - }); - - final String title; - final Widget child; - final Widget? trailing; - - @override - Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text(title, style: Theme.of(context).textTheme.titleLarge), - ), - ?trailing, - ], - ), - const SizedBox(height: _T4Space.sm), - child, - ], - ); -} - -final class _StatusNotice extends StatelessWidget { - const _StatusNotice({ - required this.icon, - required this.title, - required this.message, - }); - - final IconData icon; - final String title; - final String message; - - @override - Widget build(BuildContext context) => Card( - margin: EdgeInsets.zero, - child: ListTile( - leading: Icon(icon), - title: Text(title), - subtitle: Text(message), - ), - ); -} - -final class _BrokerStatusCard extends StatelessWidget { - const _BrokerStatusCard({required this.status}); - - final BrokerStatusResult status; - - @override - Widget build(BuildContext context) { - final (icon, title, message) = switch (status.state) { - BrokerState.local => ( - Icons.computer, - 'Local accounts', - 'Provider accounts are configured on this OMP host.', - ), - BrokerState.connected => ( - Icons.cloud_done_outlined, - 'Broker connected', - status.endpoint ?? 'The remote account broker is connected.', - ), - BrokerState.missingToken => ( - Icons.key_off_outlined, - 'Broker token missing', - status.endpoint ?? 'The host needs a broker token.', - ), - BrokerState.unreachable => ( - Icons.cloud_off_outlined, - 'Broker unreachable', - status.endpoint ?? 'The configured broker cannot be reached.', - ), - }; - return Card( - margin: EdgeInsets.zero, - child: ListTile( - leading: Icon(icon), - title: Text(title), - subtitle: Text(message), - ), - ); - } -} - -final class _UsageReportList extends StatelessWidget { - const _UsageReportList({required this.result}); - - final UsageReadResult result; - - @override - Widget build(BuildContext context) { - final generated = result.generatedAt > 0 - ? DateTime.fromMillisecondsSinceEpoch(result.generatedAt) - : null; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (generated != null) ...[ - Text( - 'Updated ${_formatActivityTime(generated)}', - style: Theme.of(context).textTheme.labelSmall, - ), - const SizedBox(height: _T4Space.sm), - ], - if (result.reports.isEmpty) - const _StatusNotice( - icon: Icons.data_usage, - title: 'No usage reports', - message: 'No provider returned a usage report.', - ) - else - for (final report in result.reports) ...[ - _UsageProviderCard(report: report), - const SizedBox(height: _T4Space.sm), - ], - if (result.accountsWithoutUsage.isNotEmpty) ...[ - const SizedBox(height: _T4Space.sm), - Text( - 'Accounts without usage data', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: _T4Space.xs), - for (final account in result.accountsWithoutUsage) - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.account_circle_outlined), - title: Text(account.provider), - subtitle: Text( - account.email ?? account.orgName ?? account.type.name, - ), - ), - ], - ], - ); - } -} - -final class _UsageProviderCard extends StatelessWidget { - const _UsageProviderCard({required this.report}); - - final UsageReport report; - - @override - Widget build(BuildContext context) => Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(_T4Space.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text( - report.provider, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - if (report.availableResetCredits case final credits?) - Chip(label: Text('$credits reset credits')), - ], - ), - if (report.limits.isEmpty) - const Padding( - padding: EdgeInsets.only(top: _T4Space.sm), - child: Text('No limits reported.'), - ) - else - for (final limit in report.limits) ...[ - const SizedBox(height: _T4Space.md), - _UsageLimitMeter(limit: limit), - ], - for (final note in report.notes) - Padding( - padding: const EdgeInsets.only(top: _T4Space.xs), - child: Text(note, style: Theme.of(context).textTheme.bodySmall), - ), - ], - ), - ), - ); -} - -final class _UsageLimitMeter extends StatelessWidget { - const _UsageLimitMeter({required this.limit}); - - final UsageLimit limit; - - @override - Widget build(BuildContext context) { - final amount = limit.amount; - final fraction = _usageFraction(amount); - final detail = _usageAmountLabel(amount); - final scheme = Theme.of(context).colorScheme; - final color = switch (limit.status) { - UsageStatus.exhausted => scheme.error, - UsageStatus.warning => scheme.tertiary, - _ => scheme.primary, - }; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded(child: Text(limit.label)), - Text(detail, style: Theme.of(context).textTheme.labelMedium), - ], - ), - const SizedBox(height: _T4Space.xs), - LinearProgressIndicator( - value: fraction, - color: color, - semanticsLabel: '${limit.label}: $detail', - ), - if (limit.window case final window?) - Padding( - padding: const EdgeInsets.only(top: _T4Space.xxs), - child: Text( - window.resetsAt == null || window.resetsAt! <= 0 - ? window.label - : '${window.label} · resets ${_formatActivityTime(DateTime.fromMillisecondsSinceEpoch(window.resetsAt!))}', - style: Theme.of(context).textTheme.labelSmall, - ), - ), - for (final note in limit.notes) - Padding( - padding: const EdgeInsets.only(top: _T4Space.xxs), - child: Text(note, style: Theme.of(context).textTheme.bodySmall), - ), - ], - ); - } -} - -double? _usageFraction(UsageAmount amount) { - final explicit = - amount.usedFraction ?? - (amount.remainingFraction == null ? null : 1 - amount.remainingFraction!); - final calculated = - amount.used != null && amount.limit != null && amount.limit != 0 - ? amount.used! / amount.limit! - : null; - final value = explicit ?? calculated; - return value?.clamp(0, 1).toDouble(); -} - -String _usageAmountLabel(UsageAmount amount) { - final unit = switch (amount.unit) { - UsageUnit.percent => '%', - UsageUnit.usd => ' USD', - UsageUnit.tokens => ' tokens', - UsageUnit.requests => ' requests', - UsageUnit.minutes => ' min', - UsageUnit.bytes => ' bytes', - UsageUnit.unknown => '', - }; - if (amount.used != null && amount.limit != null) { - return '${_compactNumber(amount.used!)} / ${_compactNumber(amount.limit!)}$unit'; - } - if (amount.remaining != null) { - return '${_compactNumber(amount.remaining!)}$unit remaining'; - } - final fraction = _usageFraction(amount); - return fraction == null ? 'Reported' : '${(fraction * 100).round()}% used'; -} - -String _compactNumber(double value) => value == value.roundToDouble() - ? value.toInt().toString() - : value.toStringAsFixed(2); diff --git a/apps/flutter/linux/.gitignore b/apps/flutter/linux/.gitignore deleted file mode 100644 index d3896c98..00000000 --- a/apps/flutter/linux/.gitignore +++ /dev/null @@ -1 +0,0 @@ -flutter/ephemeral diff --git a/apps/flutter/linux/CMakeLists.txt b/apps/flutter/linux/CMakeLists.txt deleted file mode 100644 index 4f6a7733..00000000 --- a/apps/flutter/linux/CMakeLists.txt +++ /dev/null @@ -1,128 +0,0 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.13) -project(runner LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "t4code") -# The unique GTK application identifier for this application. See: -# https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.lycaonsolutions.t4code") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(SET CMP0063 NEW) - -# Load bundled libraries from the lib/ directory relative to the binary. -set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") - -# Root filesystem for cross-building. -if(FLUTTER_TARGET_PLATFORM_SYSROOT) - set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) - set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) -endif() - -# Define build configuration options. -if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") -endif() - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_14) - target_compile_options(${TARGET} PRIVATE -Wall -Werror) - target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") - target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) - -# Only the install-generated bundle's copy of the executable will launch -# correctly, since the resources must in the right relative locations. To avoid -# people trying to run the unbundled copy, put it in a subdirectory instead of -# the default top-level location. -set_target_properties(${BINARY_NAME} - PROPERTIES - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" -) - - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# By default, "installing" just makes a relocatable bundle in the build -# directory. -set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -# Start with a clean build bundle directory every time. -install(CODE " - file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") - " COMPONENT Runtime) - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) - install(FILES "${bundled_library}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endforeach(bundled_library) - -# Copy the native assets provided by the build.dart from all packages. -set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") - install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() diff --git a/apps/flutter/linux/flutter/CMakeLists.txt b/apps/flutter/linux/flutter/CMakeLists.txt deleted file mode 100644 index d5bd0164..00000000 --- a/apps/flutter/linux/flutter/CMakeLists.txt +++ /dev/null @@ -1,88 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.10) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. - -# Serves the same purpose as list(TRANSFORM ... PREPEND ...), -# which isn't available in 3.10. -function(list_prepend LIST_NAME PREFIX) - set(NEW_LIST "") - foreach(element ${${LIST_NAME}}) - list(APPEND NEW_LIST "${PREFIX}${element}") - endforeach(element) - set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) -endfunction() - -# === Flutter Library === -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) -pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) -pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) - -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "fl_basic_message_channel.h" - "fl_binary_codec.h" - "fl_binary_messenger.h" - "fl_dart_project.h" - "fl_engine.h" - "fl_json_message_codec.h" - "fl_json_method_codec.h" - "fl_message_codec.h" - "fl_method_call.h" - "fl_method_channel.h" - "fl_method_codec.h" - "fl_method_response.h" - "fl_plugin_registrar.h" - "fl_plugin_registry.h" - "fl_standard_message_codec.h" - "fl_standard_method_codec.h" - "fl_string_codec.h" - "fl_value.h" - "fl_view.h" - "flutter_linux.h" -) -list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") -target_link_libraries(flutter INTERFACE - PkgConfig::GTK - PkgConfig::GLIB - PkgConfig::GIO -) -add_dependencies(flutter flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CMAKE_CURRENT_BINARY_DIR}/_phony_ - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" - ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} -) diff --git a/apps/flutter/linux/flutter/generated_plugin_registrant.cc b/apps/flutter/linux/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 85a24130..00000000 --- a/apps/flutter/linux/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,19 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include -#include - -void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); - file_selector_plugin_register_with_registrar(file_selector_linux_registrar); - g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); - flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); -} diff --git a/apps/flutter/linux/flutter/generated_plugin_registrant.h b/apps/flutter/linux/flutter/generated_plugin_registrant.h deleted file mode 100644 index e0f0a47b..00000000 --- a/apps/flutter/linux/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void fl_register_plugins(FlPluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/apps/flutter/linux/flutter/generated_plugins.cmake b/apps/flutter/linux/flutter/generated_plugins.cmake deleted file mode 100644 index 7aea3ec1..00000000 --- a/apps/flutter/linux/flutter/generated_plugins.cmake +++ /dev/null @@ -1,26 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - file_selector_linux - flutter_secure_storage_linux -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/apps/flutter/linux/runner/CMakeLists.txt b/apps/flutter/linux/runner/CMakeLists.txt deleted file mode 100644 index e97dabc7..00000000 --- a/apps/flutter/linux/runner/CMakeLists.txt +++ /dev/null @@ -1,26 +0,0 @@ -cmake_minimum_required(VERSION 3.13) -project(runner LANGUAGES CXX) - -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} - "main.cc" - "my_application.cc" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add preprocessor definitions for the application ID. -add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") - -# Add dependency libraries. Add any application-specific dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter) -target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) - -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/apps/flutter/linux/runner/main.cc b/apps/flutter/linux/runner/main.cc deleted file mode 100644 index e7c5c543..00000000 --- a/apps/flutter/linux/runner/main.cc +++ /dev/null @@ -1,6 +0,0 @@ -#include "my_application.h" - -int main(int argc, char** argv) { - g_autoptr(MyApplication) app = my_application_new(); - return g_application_run(G_APPLICATION(app), argc, argv); -} diff --git a/apps/flutter/linux/runner/my_application.cc b/apps/flutter/linux/runner/my_application.cc deleted file mode 100644 index e753ec73..00000000 --- a/apps/flutter/linux/runner/my_application.cc +++ /dev/null @@ -1,148 +0,0 @@ -#include "my_application.h" - -#include -#ifdef GDK_WINDOWING_X11 -#include -#endif - -#include "flutter/generated_plugin_registrant.h" - -struct _MyApplication { - GtkApplication parent_instance; - char** dart_entrypoint_arguments; -}; - -G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) - -// Called when first Flutter frame received. -static void first_frame_cb(MyApplication* self, FlView* view) { - gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); -} - -// Implements GApplication::activate. -static void my_application_activate(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); - GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); - - // Use a header bar when running in GNOME as this is the common style used - // by applications and is the setup most users will be using (e.g. Ubuntu - // desktop). - // If running on X and not using GNOME then just use a traditional title bar - // in case the window manager does more exotic layout, e.g. tiling. - // If running on Wayland assume the header bar will work (may need changing - // if future cases occur). - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "t4code"); - gtk_header_bar_set_show_close_button(header_bar, TRUE); - gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); - } else { - gtk_window_set_title(window, "t4code"); - } - - gtk_window_set_default_size(window, 1280, 720); - - g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments( - project, self->dart_entrypoint_arguments); - - FlView* view = fl_view_new(project); - GdkRGBA background_color; - // Background defaults to black, override it here if necessary, e.g. #00000000 - // for transparent. - gdk_rgba_parse(&background_color, "#000000"); - fl_view_set_background_color(view, &background_color); - gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); - - // Show the window when Flutter renders. - // Requires the view to be realized so we can start rendering. - g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), - self); - gtk_widget_realize(GTK_WIDGET(view)); - - fl_register_plugins(FL_PLUGIN_REGISTRY(view)); - - gtk_widget_grab_focus(GTK_WIDGET(view)); -} - -// Implements GApplication::local_command_line. -static gboolean my_application_local_command_line(GApplication* application, - gchar*** arguments, - int* exit_status) { - MyApplication* self = MY_APPLICATION(application); - // Strip out the first argument as it is the binary name. - self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); - - g_autoptr(GError) error = nullptr; - if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; - } - - g_application_activate(application); - *exit_status = 0; - - return TRUE; -} - -// Implements GApplication::startup. -static void my_application_startup(GApplication* application) { - // MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application startup. - - G_APPLICATION_CLASS(my_application_parent_class)->startup(application); -} - -// Implements GApplication::shutdown. -static void my_application_shutdown(GApplication* application) { - // MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application shutdown. - - G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); -} - -// Implements GObject::dispose. -static void my_application_dispose(GObject* object) { - MyApplication* self = MY_APPLICATION(object); - g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); - G_OBJECT_CLASS(my_application_parent_class)->dispose(object); -} - -static void my_application_class_init(MyApplicationClass* klass) { - G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = - my_application_local_command_line; - G_APPLICATION_CLASS(klass)->startup = my_application_startup; - G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; - G_OBJECT_CLASS(klass)->dispose = my_application_dispose; -} - -static void my_application_init(MyApplication* self) {} - -MyApplication* my_application_new() { - // Set the program name to the application ID, which helps various systems - // like GTK and desktop environments map this running application to its - // corresponding .desktop file. This ensures better integration by allowing - // the application to be recognized beyond its binary name. - g_set_prgname(APPLICATION_ID); - - return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, "flags", - G_APPLICATION_NON_UNIQUE, nullptr)); -} diff --git a/apps/flutter/linux/runner/my_application.h b/apps/flutter/linux/runner/my_application.h deleted file mode 100644 index db16367a..00000000 --- a/apps/flutter/linux/runner/my_application.h +++ /dev/null @@ -1,21 +0,0 @@ -#ifndef FLUTTER_MY_APPLICATION_H_ -#define FLUTTER_MY_APPLICATION_H_ - -#include - -G_DECLARE_FINAL_TYPE(MyApplication, - my_application, - MY, - APPLICATION, - GtkApplication) - -/** - * my_application_new: - * - * Creates a new Flutter-based application. - * - * Returns: a new #MyApplication. - */ -MyApplication* my_application_new(); - -#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/apps/flutter/macos/.gitignore b/apps/flutter/macos/.gitignore deleted file mode 100644 index 746adbb6..00000000 --- a/apps/flutter/macos/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Flutter-related -**/Flutter/ephemeral/ -**/Pods/ - -# Xcode-related -**/dgph -**/xcuserdata/ diff --git a/apps/flutter/macos/Flutter/Flutter-Debug.xcconfig b/apps/flutter/macos/Flutter/Flutter-Debug.xcconfig deleted file mode 100644 index c2efd0b6..00000000 --- a/apps/flutter/macos/Flutter/Flutter-Debug.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/apps/flutter/macos/Flutter/Flutter-Release.xcconfig b/apps/flutter/macos/Flutter/Flutter-Release.xcconfig deleted file mode 100644 index c2efd0b6..00000000 --- a/apps/flutter/macos/Flutter/Flutter-Release.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/apps/flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/flutter/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index 5d648153..00000000 --- a/apps/flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import file_selector_macos -import flutter_secure_storage_darwin -import shared_preferences_foundation - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) - FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) - SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) -} diff --git a/apps/flutter/macos/Runner.xcodeproj/project.pbxproj b/apps/flutter/macos/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index f3cfcf83..00000000 --- a/apps/flutter/macos/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,743 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXAggregateTarget section */ - 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { - isa = PBXAggregateTarget; - buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; - buildPhases = ( - 33CC111E2044C6BF0003C045 /* ShellScript */, - ); - dependencies = ( - ); - name = "Flutter Assemble"; - productName = FLX; - }; -/* End PBXAggregateTarget section */ - -/* Begin PBXBuildFile section */ - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; - A4C1F1012E2C000100000001 /* PlatformLifecycleChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4C1F1002E2C000100000001 /* PlatformLifecycleChannel.swift */; }; - A4C1F1032E2C000100000003 /* t4-host in Embed T4 Host */ = {isa = PBXBuildFile; fileRef = A4C1F1022E2C000100000002 /* t4-host */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC10EC2044A3C60003C045; - remoteInfo = Runner; - }; - 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC111A2044C6BA0003C045; - remoteInfo = FLX; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 33CC110E2044A8840003C045 /* Bundle Framework */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Bundle Framework"; - runOnlyForDeploymentPostprocessing = 0; - }; - A4C1F1042E2C000100000004 /* Embed T4 Host */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = runtime; - dstSubfolderSpec = 7; - files = ( - A4C1F1032E2C000100000003 /* t4-host in Embed T4 Host */, - ); - name = "Embed T4 Host"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* t4code.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "t4code.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; - 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; - 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; - 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; - 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; - 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; - A4C1F1002E2C000100000001 /* PlatformLifecycleChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformLifecycleChannel.swift; sourceTree = ""; }; - A4C1F1022E2C000100000002 /* t4-host */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = ../../../packages/host-daemon/dist/t4-host; sourceTree = SOURCE_ROOT; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 331C80D2294CF70F00263BE5 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EA2044A3C60003C045 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C80D6294CF71000263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C80D7294CF71000263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 33BA886A226E78AF003329D5 /* Configs */ = { - isa = PBXGroup; - children = ( - 33E5194F232828860026EE4D /* AppInfo.xcconfig */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, - ); - path = Configs; - sourceTree = ""; - }; - 33CC10E42044A3C60003C045 = { - isa = PBXGroup; - children = ( - 33FAB671232836740065AC1E /* Runner */, - 33CEB47122A05771004F2AC0 /* Flutter */, - 331C80D6294CF71000263BE5 /* RunnerTests */, - 33CC10EE2044A3C60003C045 /* Products */, - D73912EC22F37F3D000D13A0 /* Frameworks */, - ); - sourceTree = ""; - }; - 33CC10EE2044A3C60003C045 /* Products */ = { - isa = PBXGroup; - children = ( - 33CC10ED2044A3C60003C045 /* t4code.app */, - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 33CC11242044D66E0003C045 /* Resources */ = { - isa = PBXGroup; - children = ( - A4C1F1022E2C000100000002 /* t4-host */, - 33CC10F22044A3C60003C045 /* Assets.xcassets */, - 33CC10F42044A3C60003C045 /* MainMenu.xib */, - 33CC10F72044A3C60003C045 /* Info.plist */, - ); - name = Resources; - path = ..; - sourceTree = ""; - }; - 33CEB47122A05771004F2AC0 /* Flutter */ = { - isa = PBXGroup; - children = ( - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, - ); - path = Flutter; - sourceTree = ""; - }; - 33FAB671232836740065AC1E /* Runner */ = { - isa = PBXGroup; - children = ( - 33CC10F02044A3C60003C045 /* AppDelegate.swift */, - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, - A4C1F1002E2C000100000001 /* PlatformLifecycleChannel.swift */, - 33E51913231747F40026EE4D /* DebugProfile.entitlements */, - 33E51914231749380026EE4D /* Release.entitlements */, - 33CC11242044D66E0003C045 /* Resources */, - 33BA886A226E78AF003329D5 /* Configs */, - ); - path = Runner; - sourceTree = ""; - }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { - isa = PBXGroup; - children = ( - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C80D4294CF70F00263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - 331C80D1294CF70F00263BE5 /* Sources */, - 331C80D2294CF70F00263BE5 /* Frameworks */, - 331C80D3294CF70F00263BE5 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 331C80DA294CF71000263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 33CC10EC2044A3C60003C045 /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 33CC10E92044A3C60003C045 /* Sources */, - 33CC10EA2044A3C60003C045 /* Frameworks */, - 33CC10EB2044A3C60003C045 /* Resources */, - 33CC110E2044A8840003C045 /* Bundle Framework */, - A4C1F1042E2C000100000004 /* Embed T4 Host */, - 3399D490228B24CF009A79C7 /* ShellScript */, - ); - buildRules = ( - ); - dependencies = ( - 33CC11202044C79F0003C045 /* PBXTargetDependency */, - ); - name = Runner; - packageProductDependencies = ( - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, - ); - productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* t4code.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 33CC10E52044A3C60003C045 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C80D4294CF70F00263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 33CC10EC2044A3C60003C045; - }; - 33CC10EC2044A3C60003C045 = { - CreatedOnToolsVersion = 9.2; - LastSwiftMigration = 1100; - ProvisioningStyle = Automatic; - }; - 33CC111A2044C6BA0003C045 = { - CreatedOnToolsVersion = 9.2; - ProvisioningStyle = Manual; - }; - }; - }; - buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 33CC10E42044A3C60003C045; - packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, - ); - productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 33CC10EC2044A3C60003C045 /* Runner */, - 331C80D4294CF70F00263BE5 /* RunnerTests */, - 33CC111A2044C6BA0003C045 /* Flutter Assemble */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C80D3294CF70F00263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EB2044A3C60003C045 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3399D490228B24CF009A79C7 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; - }; - 33CC111E2044C6BF0003C045 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - Flutter/ephemeral/FlutterInputs.xcfilelist, - ); - inputPaths = ( - Flutter/ephemeral/tripwire, - ); - outputFileListPaths = ( - Flutter/ephemeral/FlutterOutputs.xcfilelist, - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C80D1294CF70F00263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10E92044A3C60003C045 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, - A4C1F1012E2C000100000001 /* PlatformLifecycleChannel.swift in Sources */, - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC10EC2044A3C60003C045 /* Runner */; - targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; - }; - 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; - targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { - isa = PBXVariantGroup; - children = ( - 33CC10F52044A3C60003C045 /* Base */, - ); - name = MainMenu.xib; - path = Runner; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 331C80DB294CF71000263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/t4code.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/t4code"; - }; - name = Debug; - }; - 331C80DC294CF71000263BE5 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/t4code.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/t4code"; - }; - name = Release; - }; - 331C80DD294CF71000263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/t4code.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/t4code"; - }; - name = Profile; - }; - 338D0CE9231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Profile; - }; - 338D0CEA231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Profile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Profile; - }; - 338D0CEB231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Profile; - }; - 33CC10F92044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 33CC10FA2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Release; - }; - 33CC10FC2044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 33CC10FD2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 33CC111C2044C6BA0003C045 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Debug; - }; - 33CC111D2044C6BA0003C045 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C80DB294CF71000263BE5 /* Debug */, - 331C80DC294CF71000263BE5 /* Release */, - 331C80DD294CF71000263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10F92044A3C60003C045 /* Debug */, - 33CC10FA2044A3C60003C045 /* Release */, - 338D0CE9231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10FC2044A3C60003C045 /* Debug */, - 33CC10FD2044A3C60003C045 /* Release */, - 338D0CEA231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC111C2044C6BA0003C045 /* Debug */, - 33CC111D2044C6BA0003C045 /* Release */, - 338D0CEB231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; - }; -/* End XCLocalSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { - isa = XCSwiftPackageProductDependency; - productName = FlutterGeneratedPluginSwiftPackage; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = 33CC10E52044A3C60003C045 /* Project object */; -} diff --git a/apps/flutter/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/flutter/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/apps/flutter/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/apps/flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index 0cc27e0a..00000000 --- a/apps/flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/flutter/macos/Runner.xcworkspace/contents.xcworkspacedata b/apps/flutter/macos/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a16..00000000 --- a/apps/flutter/macos/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/apps/flutter/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/flutter/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/apps/flutter/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/apps/flutter/macos/Runner/AppDelegate.swift b/apps/flutter/macos/Runner/AppDelegate.swift deleted file mode 100644 index d7e1a26a..00000000 --- a/apps/flutter/macos/Runner/AppDelegate.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Cocoa -import FlutterMacOS - -@main -class AppDelegate: FlutterAppDelegate { - private var platformLifecycleChannel: PlatformLifecycleChannel? - - override func applicationDidFinishLaunching(_ notification: Notification) { - super.applicationDidFinishLaunching(notification) - guard let flutterViewController = mainFlutterWindow?.contentViewController as? FlutterViewController else { - return - } - platformLifecycleChannel = PlatformLifecycleChannel( - messenger: flutterViewController.engine.binaryMessenger - ) - } - - override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true - } - - override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { - return true - } -} diff --git a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index a2ec33f1..00000000 --- a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" - }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png deleted file mode 100644 index 7bbdb63f..00000000 Binary files a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and /dev/null differ diff --git a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png deleted file mode 100644 index b04a4e29..00000000 Binary files a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and /dev/null differ diff --git a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png deleted file mode 100644 index 9a5cd0fd..00000000 Binary files a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and /dev/null differ diff --git a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png deleted file mode 100644 index 83a68d86..00000000 Binary files a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and /dev/null differ diff --git a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png deleted file mode 100644 index e2df182c..00000000 Binary files a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and /dev/null differ diff --git a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png deleted file mode 100644 index 225c077d..00000000 Binary files a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and /dev/null differ diff --git a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png deleted file mode 100644 index b31a93bc..00000000 Binary files a/apps/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and /dev/null differ diff --git a/apps/flutter/macos/Runner/Base.lproj/MainMenu.xib b/apps/flutter/macos/Runner/Base.lproj/MainMenu.xib deleted file mode 100644 index 80e867a4..00000000 --- a/apps/flutter/macos/Runner/Base.lproj/MainMenu.xib +++ /dev/null @@ -1,343 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/flutter/macos/Runner/Configs/AppInfo.xcconfig b/apps/flutter/macos/Runner/Configs/AppInfo.xcconfig deleted file mode 100644 index c280418e..00000000 --- a/apps/flutter/macos/Runner/Configs/AppInfo.xcconfig +++ /dev/null @@ -1,14 +0,0 @@ -// Application-level settings for the Runner target. -// -// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the -// future. If not, the values below would default to using the project name when this becomes a -// 'flutter create' template. - -// The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = t4code - -// The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.lycaonsolutions.t4code - -// The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2026 com.lycaonsolutions. All rights reserved. diff --git a/apps/flutter/macos/Runner/Configs/Debug.xcconfig b/apps/flutter/macos/Runner/Configs/Debug.xcconfig deleted file mode 100644 index 36b0fd94..00000000 --- a/apps/flutter/macos/Runner/Configs/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Debug.xcconfig" -#include "Warnings.xcconfig" diff --git a/apps/flutter/macos/Runner/Configs/Release.xcconfig b/apps/flutter/macos/Runner/Configs/Release.xcconfig deleted file mode 100644 index dff4f495..00000000 --- a/apps/flutter/macos/Runner/Configs/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Release.xcconfig" -#include "Warnings.xcconfig" diff --git a/apps/flutter/macos/Runner/Configs/Warnings.xcconfig b/apps/flutter/macos/Runner/Configs/Warnings.xcconfig deleted file mode 100644 index 42bcbf47..00000000 --- a/apps/flutter/macos/Runner/Configs/Warnings.xcconfig +++ /dev/null @@ -1,13 +0,0 @@ -WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings -GCC_WARN_UNDECLARED_SELECTOR = YES -CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES -CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE -CLANG_WARN__DUPLICATE_METHOD_MATCH = YES -CLANG_WARN_PRAGMA_PACK = YES -CLANG_WARN_STRICT_PROTOTYPES = YES -CLANG_WARN_COMMA = YES -GCC_WARN_STRICT_SELECTOR_MATCH = YES -CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES -CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES -GCC_WARN_SHADOW = YES -CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/apps/flutter/macos/Runner/DebugProfile.entitlements b/apps/flutter/macos/Runner/DebugProfile.entitlements deleted file mode 100644 index 6dd0d082..00000000 --- a/apps/flutter/macos/Runner/DebugProfile.entitlements +++ /dev/null @@ -1,14 +0,0 @@ - - - - - com.apple.security.cs.allow-jit - - com.apple.security.network.server - - com.apple.security.network.client - - com.apple.security.files.user-selected.read-only - - - diff --git a/apps/flutter/macos/Runner/Info.plist b/apps/flutter/macos/Runner/Info.plist deleted file mode 100644 index 4789daa6..00000000 --- a/apps/flutter/macos/Runner/Info.plist +++ /dev/null @@ -1,32 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconFile - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication - - diff --git a/apps/flutter/macos/Runner/MainFlutterWindow.swift b/apps/flutter/macos/Runner/MainFlutterWindow.swift deleted file mode 100644 index 3cc05eb2..00000000 --- a/apps/flutter/macos/Runner/MainFlutterWindow.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Cocoa -import FlutterMacOS - -class MainFlutterWindow: NSWindow { - override func awakeFromNib() { - let flutterViewController = FlutterViewController() - let windowFrame = self.frame - self.contentViewController = flutterViewController - self.setFrame(windowFrame, display: true) - - RegisterGeneratedPlugins(registry: flutterViewController) - - super.awakeFromNib() - } -} diff --git a/apps/flutter/macos/Runner/PlatformLifecycleChannel.swift b/apps/flutter/macos/Runner/PlatformLifecycleChannel.swift deleted file mode 100644 index b8d02291..00000000 --- a/apps/flutter/macos/Runner/PlatformLifecycleChannel.swift +++ /dev/null @@ -1,1304 +0,0 @@ -import CoreFoundation -import Darwin -import FlutterMacOS -import Foundation - -private let runtimeOutputLimit = 16 * 1024 -private let runtimeProbeTimeout: TimeInterval = 1.5 -private let runtimeCommandTimeout: TimeInterval = 8 -private let runtimeLabel = "dev.oh-my-pi.appserver" -private let authorityBridgeHelpMarkers = [ - "Expose the private OMP authority bridge used by T4 Code", - "--stdio", -] - -struct RuntimeProcessResult { - let exitCode: Int32? - let output: String - let timedOut: Bool - let overflowed: Bool -} - -protocol RuntimeProcessRunning { - func run( - executableURL: URL, - arguments: [String], - environment: [String: String], - timeout: TimeInterval, - maxOutputBytes: Int - ) throws -> RuntimeProcessResult -} - -final class BoundedRuntimeProcessRunner: RuntimeProcessRunning { - func run( - executableURL: URL, - arguments: [String], - environment: [String: String], - timeout: TimeInterval, - maxOutputBytes: Int - ) throws -> RuntimeProcessResult { - let process = Process() - process.executableURL = executableURL - process.arguments = arguments - process.environment = environment - - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - - let lock = NSLock() - var output = Data() - var overflowed = false - pipe.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - lock.lock() - if output.count < maxOutputBytes + 1 { - output.append(data.prefix(maxOutputBytes + 1 - output.count)) - } - if output.count > maxOutputBytes { - overflowed = true - } - lock.unlock() - if overflowed, process.isRunning { - process.terminate() - } - } - - let terminated = DispatchSemaphore(value: 0) - process.terminationHandler = { _ in terminated.signal() } - do { - try process.run() - } catch { - pipe.fileHandleForReading.readabilityHandler = nil - throw error - } - - var timedOut = false - if terminated.wait(timeout: .now() + timeout) == .timedOut { - timedOut = true - if process.isRunning { process.terminate() } - if terminated.wait(timeout: .now() + 0.2) == .timedOut, process.isRunning { - kill(process.processIdentifier, SIGKILL) - _ = terminated.wait(timeout: .now() + 0.2) - } - } - - pipe.fileHandleForReading.readabilityHandler = nil - let tail = pipe.fileHandleForReading.readDataToEndOfFile() - lock.lock() - if output.count < maxOutputBytes + 1 { - output.append(tail.prefix(maxOutputBytes + 1 - output.count)) - } - overflowed = overflowed || output.count > maxOutputBytes - let bounded = output.prefix(maxOutputBytes) - lock.unlock() - - return RuntimeProcessResult( - exitCode: process.isRunning ? nil : process.terminationStatus, - output: String(decoding: bounded, as: UTF8.self), - timedOut: timedOut, - overflowed: overflowed - ) - } -} - -enum RuntimeDiscovery: Equatable { - case found(String) - case incompatible - case missing -} - -final class OmpRuntimeDiscovery { - private let environment: [String: String] - private let homeDirectory: String - private let runner: RuntimeProcessRunning - - init( - environment: [String: String] = ProcessInfo.processInfo.environment, - homeDirectory: String = FileManager.default.homeDirectoryForCurrentUser.path, - runner: RuntimeProcessRunning = BoundedRuntimeProcessRunner() - ) { - self.environment = environment - self.homeDirectory = homeDirectory - self.runner = runner - } - - func discover() -> RuntimeDiscovery { - var candidates: [String] = [] - if let explicit = environment["OMP_EXECUTABLE"], !explicit.isEmpty { - candidates.append(explicit) - } - let pathEntries = (environment["PATH"] ?? "") - .split(separator: ":", omittingEmptySubsequences: true) - .prefix(64) - candidates.append(contentsOf: pathEntries.map { "\($0)/omp" }) - candidates.append(contentsOf: [ - "\(homeDirectory)/.local/bin/omp", - "\(homeDirectory)/bin/omp", - "/usr/local/bin/omp", - "/usr/bin/omp", - "/opt/omp/bin/omp", - ]) - - var seen = Set() - var foundIncompatible = false - for candidate in candidates { - guard isExecutableCandidate(candidate), seen.insert(candidate).inserted else { continue } - switch probe(candidate) { - case .running, .stopped: - return .found(candidate) - case .incompatible: - foundIncompatible = true - case .invalid: - continue - } - } - return foundIncompatible ? .incompatible : .missing - } - - private func isExecutableCandidate(_ path: String) -> Bool { - guard path.utf8.count <= 4096, - path.first == "/", - !path.utf8.contains(0), - URL(fileURLWithPath: path).lastPathComponent == "omp" - else { return false } - guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), - attributes[.type] as? FileAttributeType == .typeRegular - else { return false } - return Darwin.access(path, X_OK) == 0 - } - - private enum ProbeResult { case running, stopped, incompatible, invalid } - - private func probe(_ executable: String) -> ProbeResult { - var safeEnvironment: [String: String] = [:] - for key in ["HOME", "PATH", "TMPDIR"] { - if let value = environment[key] { safeEnvironment[key] = value } - } - safeEnvironment["OMP_PROFILE"] = "default" - - guard - let bridge = try? runner.run( - executableURL: URL(fileURLWithPath: executable), - arguments: ["bridge", "--help"], - environment: safeEnvironment, - timeout: runtimeProbeTimeout, - maxOutputBytes: runtimeOutputLimit - ), bridge.exitCode == 0, !bridge.timedOut, !bridge.overflowed, - authorityBridgeHelpMarkers.allSatisfy({ bridge.output.contains($0) }), - let result = try? runner.run( - executableURL: URL(fileURLWithPath: executable), - arguments: ["appserver", "status", "--json"], - environment: safeEnvironment, - timeout: runtimeProbeTimeout, - maxOutputBytes: runtimeOutputLimit - ), !result.timedOut, !result.overflowed - else { return .invalid } - - if isUnsupportedJSONDiagnostic(result.output) { return .incompatible } - guard result.exitCode == 0 || result.exitCode == 1, - let data = result.output.data(using: .utf8), - let object = try? JSONSerialization.jsonObject(with: data), - let status = object as? [String: Any], - let state = status["state"] as? String - else { return .invalid } - - if state == "running", - let health = status["health"] as? [String: Any], - health["ok"] as? Bool == true, - let hostID = health["hostId"] as? String, !hostID.isEmpty, - let epoch = health["epoch"] as? String, !epoch.isEmpty - { - return .running - } - if state == "stopped", - let reason = status["reason"] as? String, - ["unreachable", "malformed", "failed"].contains(reason) - { - return .stopped - } - return .invalid - } - - private func isUnsupportedJSONDiagnostic(_ output: String) -> Bool { - let value = output.lowercased() - guard value.contains("--json") || value.contains("-json") else { return false } - return value.contains("unknown flag") - || value.contains("unknown option") - || value.contains("unrecognized flag") - || value.contains("unrecognized option") - || value.contains("flag provided but not defined") - } -} - -final class T4HostRuntimeDiscovery { - private let environment: [String: String] - private let homeDirectory: String - private let packagedExecutable: String? - - init( - environment: [String: String] = ProcessInfo.processInfo.environment, - homeDirectory: String = FileManager.default.homeDirectoryForCurrentUser.path, - packagedExecutable: String? = Bundle.main.resourceURL? - .appendingPathComponent("runtime/t4-host").path - ) { - self.environment = environment - self.homeDirectory = homeDirectory - self.packagedExecutable = packagedExecutable - } - - func discover() -> String? { - var candidates: [String] = [] - if let explicit = environment["T4_HOST_EXECUTABLE"], !explicit.isEmpty { - candidates.append(explicit) - } - if let packagedExecutable, !packagedExecutable.isEmpty { - candidates.append(packagedExecutable) - } - let pathEntries = (environment["PATH"] ?? "") - .split(separator: ":", omittingEmptySubsequences: true) - .prefix(64) - candidates.append(contentsOf: pathEntries.map { "\($0)/t4-host" }) - candidates.append(contentsOf: [ - "\(homeDirectory)/.local/bin/t4-host", - "\(homeDirectory)/bin/t4-host", - "/usr/local/bin/t4-host", - "/usr/bin/t4-host", - ]) - - var seen = Set() - for candidate in candidates where seen.insert(candidate).inserted { - guard candidate.utf8.count <= 4096, - candidate.first == "/", - !candidate.utf8.contains(0), - URL(fileURLWithPath: candidate).lastPathComponent == "t4-host", - let attributes = try? FileManager.default.attributesOfItem(atPath: candidate), - attributes[.type] as? FileAttributeType == .typeRegular, - Darwin.access(candidate, X_OK) == 0 - else { continue } - return candidate - } - return nil - } -} - -enum SecureRuntimeFileError: Error { - case invalidPath, notFound, unsafePath - case io(String) -} - -struct RuntimeFileSnapshot { - let content: String? - let mode: mode_t -} - -protocol RuntimeFileStoring { - func read(_ path: String, maxBytes: Int) throws -> RuntimeFileSnapshot - func ensureDirectory(_ path: String) throws - func writeAtomically(_ path: String, content: String, mode: mode_t) throws - func remove(_ path: String) throws -} - -final class SecureRuntimeFileStore: RuntimeFileStoring { - private func components(_ absolutePath: String) throws -> [String] { - guard absolutePath.first == "/", !absolutePath.utf8.contains(0), absolutePath.utf8.count <= 4096 - else { - throw SecureRuntimeFileError.invalidPath - } - let parts = absolutePath.split(separator: "/", omittingEmptySubsequences: true).map(String.init) - guard !parts.isEmpty, !parts.contains("."), !parts.contains("..") else { - throw SecureRuntimeFileError.invalidPath - } - return parts - } - - private func directoryDescriptor(_ parts: ArraySlice, create: Bool) throws -> Int32 { - var descriptor = Darwin.open("/", O_RDONLY | O_DIRECTORY) - guard descriptor >= 0 else { throw SecureRuntimeFileError.io("open root") } - do { - for component in parts { - if create, mkdirat(descriptor, component, 0o700) != 0, errno != EEXIST { - throw SecureRuntimeFileError.io("create directory") - } - let next = openat(descriptor, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW) - if next < 0 { - if errno == ENOENT { throw SecureRuntimeFileError.notFound } - if errno == ELOOP || errno == ENOTDIR { throw SecureRuntimeFileError.unsafePath } - throw SecureRuntimeFileError.io("open directory") - } - Darwin.close(descriptor) - descriptor = next - } - return descriptor - } catch { - Darwin.close(descriptor) - throw error - } - } - - func ensureDirectory(_ path: String) throws { - let parts = try components(path) - let descriptor = try directoryDescriptor(parts[...], create: true) - Darwin.close(descriptor) - } - - func read(_ path: String, maxBytes: Int = 64 * 1024) throws -> RuntimeFileSnapshot { - let parts = try components(path) - guard let name = parts.last else { throw SecureRuntimeFileError.invalidPath } - let descriptor: Int32 - do { - descriptor = try directoryDescriptor(parts.dropLast(), create: false) - } catch SecureRuntimeFileError.notFound { - return RuntimeFileSnapshot(content: nil, mode: 0o600) - } - defer { Darwin.close(descriptor) } - - let file = openat(descriptor, name, O_RDONLY | O_NOFOLLOW) - if file < 0 { - if errno == ENOENT { return RuntimeFileSnapshot(content: nil, mode: 0o600) } - if errno == ELOOP { throw SecureRuntimeFileError.unsafePath } - throw SecureRuntimeFileError.io("read definition") - } - defer { Darwin.close(file) } - var info = stat() - guard fstat(file, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else { - throw SecureRuntimeFileError.unsafePath - } - var data = Data() - var buffer = [UInt8](repeating: 0, count: 4096) - while data.count <= maxBytes { - let count = Darwin.read(file, &buffer, min(buffer.count, maxBytes + 1 - data.count)) - if count < 0 { throw SecureRuntimeFileError.io("read definition") } - if count == 0 { break } - data.append(buffer, count: count) - } - guard data.count <= maxBytes else { throw SecureRuntimeFileError.io("definition too large") } - return RuntimeFileSnapshot( - content: String(decoding: data, as: UTF8.self), - mode: info.st_mode & 0o777 - ) - } - - func writeAtomically(_ path: String, content: String, mode: mode_t) throws { - let parts = try components(path) - guard let name = parts.last, let data = content.data(using: .utf8) else { - throw SecureRuntimeFileError.invalidPath - } - let descriptor = try directoryDescriptor(parts.dropLast(), create: true) - defer { Darwin.close(descriptor) } - - let temporary = ".\(name).tmp-\(UUID().uuidString)" - let file = openat(descriptor, temporary, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, mode) - guard file >= 0 else { throw SecureRuntimeFileError.io("create temporary definition") } - var shouldRemoveTemporary = true - defer { - Darwin.close(file) - if shouldRemoveTemporary { unlinkat(descriptor, temporary, 0) } - } - try data.withUnsafeBytes { rawBuffer in - guard var pointer = rawBuffer.baseAddress else { return } - var remaining = rawBuffer.count - while remaining > 0 { - let count = Darwin.write(file, pointer, remaining) - if count <= 0 { throw SecureRuntimeFileError.io("write definition") } - remaining -= count - pointer = pointer.advanced(by: count) - } - } - guard fchmod(file, mode) == 0, fsync(file) == 0 else { - throw SecureRuntimeFileError.io("sync definition") - } - guard renameat(descriptor, temporary, descriptor, name) == 0 else { - throw SecureRuntimeFileError.io("replace definition") - } - shouldRemoveTemporary = false - guard fsync(descriptor) == 0 else { - throw SecureRuntimeFileError.io("sync definition directory") - } - } - - func remove(_ path: String) throws { - let parts = try components(path) - guard let name = parts.last else { throw SecureRuntimeFileError.invalidPath } - let descriptor: Int32 - do { - descriptor = try directoryDescriptor(parts.dropLast(), create: false) - } catch SecureRuntimeFileError.notFound { - return - } - defer { Darwin.close(descriptor) } - - var info = stat() - if fstatat(descriptor, name, &info, AT_SYMLINK_NOFOLLOW) != 0 { - if errno == ENOENT { return } - throw SecureRuntimeFileError.io("inspect definition") - } - guard (info.st_mode & S_IFMT) == S_IFREG else { throw SecureRuntimeFileError.unsafePath } - guard unlinkat(descriptor, name, 0) == 0, fsync(descriptor) == 0 else { - throw SecureRuntimeFileError.io("remove definition") - } - } -} - -struct RuntimeBridgeFailure: Error { - let code: String - let message: String -} - -final class MacRuntimeLifecycle { - private let environment: [String: String] - private let homeDirectory: String - private let uid: uid_t - private let runner: RuntimeProcessRunning - private let files: RuntimeFileStoring - private let packagedHostExecutable: String? - - init( - environment: [String: String] = ProcessInfo.processInfo.environment, - homeDirectory: String = FileManager.default.homeDirectoryForCurrentUser.path, - uid: uid_t = getuid(), - runner: RuntimeProcessRunning = BoundedRuntimeProcessRunner(), - files: RuntimeFileStoring = SecureRuntimeFileStore(), - packagedHostExecutable: String? = Bundle.main.resourceURL? - .appendingPathComponent("runtime/t4-host").path - ) { - self.environment = environment - self.homeDirectory = homeDirectory - self.uid = uid - self.runner = runner - self.files = files - self.packagedHostExecutable = packagedHostExecutable - } - - private var definitionPath: String { - "\(homeDirectory)/Library/LaunchAgents/\(runtimeLabel).plist" - } - - private var logsDirectory: String { - "\(homeDirectory)/Library/Logs/T4 Code/appserver" - } - - private var domain: String { "gui/\(uid)" } - private var target: String { "\(domain)/\(runtimeLabel)" } - - func inspect() -> [String: Any] { - inspect(discovery: discovery()) - } - - func install() throws -> [String: Any] { - let discovery = discovery() - let ompExecutable = try requireExecutable(discovery) - let hostExecutable = try requireHostExecutable() - let definition = try renderDefinition( - hostExecutable: hostExecutable, - ompExecutable: ompExecutable - ) - let previous = try fileSnapshotForMutation() - let status = try launchctl(["print", target], allowMissing: true) - let registered = !isMissingService(status) - let changed = previous.content != definition || previous.mode != 0o600 - - do { - if changed { - try files.ensureDirectory(logsDirectory) - try files.writeAtomically(definitionPath, content: definition, mode: 0o600) - } - if registered && changed { _ = try launchctl(["bootout", target]) } - if !registered || changed { _ = try launchctl(["bootstrap", domain, definitionPath]) } - _ = try launchctl(["kickstart", "-k", target]) - } catch { - if changed { - if let old = previous.content { - try? files.writeAtomically(definitionPath, content: old, mode: previous.mode) - } else { - try? files.remove(definitionPath) - } - _ = try? launchctl(["bootout", target], allowMissing: true) - if registered, previous.content != nil { - _ = try? launchctl(["bootstrap", domain, definitionPath]) - if serviceState(status) == "running" { - _ = try? launchctl(["kickstart", "-k", target]) - } - } - } - throw bridgeFailure(error, fallbackCode: "runtime_install_failed") - } - return inspect(discovery: discovery) - } - - func start() throws -> [String: Any] { - let discovery = discovery() - let ompExecutable = try requireExecutable(discovery) - let hostExecutable = try requireHostExecutable() - try requireCurrentDefinition( - hostExecutable: hostExecutable, - ompExecutable: ompExecutable - ) - let status = try launchctl(["print", target], allowMissing: true) - if isMissingService(status) { _ = try launchctl(["bootstrap", domain, definitionPath]) } - _ = try launchctl(["kickstart", "-k", target]) - return inspect(discovery: discovery) - } - - func stop() throws -> [String: Any] { - _ = try launchctl(["bootout", target]) - return inspect(discovery: discovery()) - } - - func restart() throws -> [String: Any] { - let discovery = discovery() - let ompExecutable = try requireExecutable(discovery) - let hostExecutable = try requireHostExecutable() - try requireCurrentDefinition( - hostExecutable: hostExecutable, - ompExecutable: ompExecutable - ) - let status = try launchctl(["print", target], allowMissing: true) - if isMissingService(status) { _ = try launchctl(["bootstrap", domain, definitionPath]) } - _ = try launchctl(["kickstart", "-k", target]) - return inspect(discovery: discovery) - } - - func uninstall() throws -> [String: Any] { - let previous = try fileSnapshotForMutation() - let status = try launchctl(["print", target], allowMissing: true) - if !isMissingService(status) { _ = try launchctl(["bootout", target]) } - do { - try files.remove(definitionPath) - } catch { - if !isMissingService(status), previous.content != nil { - _ = try? launchctl(["bootstrap", domain, definitionPath]) - if serviceState(status) == "running" { - _ = try? launchctl(["kickstart", "-k", target]) - } - } - throw bridgeFailure(error, fallbackCode: "runtime_uninstall_failed") - } - return inspect(discovery: discovery()) - } - - private func discovery() -> RuntimeDiscovery { - OmpRuntimeDiscovery( - environment: environment, - homeDirectory: homeDirectory, - runner: runner - ).discover() - } - - private func inspect(discovery: RuntimeDiscovery) -> [String: Any] { - var map: [String: Any] = [ - "available": false, - "definition": "missing", - "service": "unknown", - "diagnostics": "", - ] - - var ompExecutable: String? - var hostExecutable: String? - switch discovery { - case .found(let path): - ompExecutable = path - map["executable"] = String(path.prefix(4096)) - case .incompatible: - map["issueCode"] = "omp_authority_bridge_required" - map["message"] = boundedDiagnostic( - "Installed OMP is incompatible with this T4 Code build. T4 Code requires the versioned `omp bridge --stdio` authority bridge. Update OMP, then choose Check again." - ) - case .missing: - map["issueCode"] = "omp_not_found" - map["message"] = "A compatible system OMP executable was not found." - } - - if ompExecutable != nil { - hostExecutable = hostDiscovery().discover() - if let hostExecutable { - map["available"] = true - map["hostExecutable"] = String(hostExecutable.prefix(4096)) - } else { - map["issueCode"] = "t4_host_not_found" - map["message"] = "The standalone T4 host executable is missing from this build." - } - } - - do { - let snapshot = try files.read(definitionPath, maxBytes: 64 * 1024) - if let ompExecutable, let hostExecutable, - let expected = try? renderDefinition( - hostExecutable: hostExecutable, - ompExecutable: ompExecutable - ), - snapshot.content == expected, - snapshot.mode == 0o600 - { - map["definition"] = "current" - } else if snapshot.content == nil { - map["definition"] = "missing" - } else { - map["definition"] = "drifted" - } - } catch { - map["definition"] = "drifted" - map["diagnostics"] = boundedDiagnostic("Unable to safely inspect the LaunchAgent definition.") - if map["issueCode"] == nil { - map["issueCode"] = "runtime_definition_unsafe" - map["message"] = "The LaunchAgent definition path is unsafe or unreadable." - } - } - - do { - let status = try launchctl(["print", target], allowMissing: true) - map["service"] = serviceState(status) - let diagnostic = boundedDiagnostic(status.output) - if !diagnostic.isEmpty { map["diagnostics"] = diagnostic } - } catch { - map["service"] = "unknown" - map["diagnostics"] = boundedDiagnostic("Unable to inspect the per-user LaunchAgent.") - if map["issueCode"] == nil { - map["issueCode"] = "runtime_status_failed" - map["message"] = "The per-user LaunchAgent status could not be read." - } - } - return map - } - - private func fileSnapshotForMutation() throws -> RuntimeFileSnapshot { - do { - return try files.read(definitionPath, maxBytes: 64 * 1024) - } catch { - throw RuntimeBridgeFailure( - code: "runtime_definition_unsafe", - message: "The LaunchAgent definition path is unsafe or unreadable." - ) - } - } - - private func requireExecutable(_ discovery: RuntimeDiscovery) throws -> String { - switch discovery { - case .found(let path): return path - case .incompatible: - throw RuntimeBridgeFailure( - code: "omp_authority_bridge_required", - message: - "Installed OMP is incompatible with this T4 Code build. T4 Code requires the versioned `omp bridge --stdio` authority bridge." - ) - case .missing: - throw RuntimeBridgeFailure( - code: "omp_not_found", message: "A compatible system OMP executable was not found.") - } - } - - private func hostDiscovery() -> T4HostRuntimeDiscovery { - T4HostRuntimeDiscovery( - environment: environment, - homeDirectory: homeDirectory, - packagedExecutable: packagedHostExecutable - ) - } - - private func requireHostExecutable() throws -> String { - guard let executable = hostDiscovery().discover() else { - throw RuntimeBridgeFailure( - code: "t4_host_not_found", - message: "The standalone T4 host executable is missing from this build." - ) - } - return executable - } - - private func requireCurrentDefinition(hostExecutable: String, ompExecutable: String) throws { - let snapshot = try fileSnapshotForMutation() - let expected = try renderDefinition( - hostExecutable: hostExecutable, - ompExecutable: ompExecutable - ) - guard snapshot.content == expected, snapshot.mode == 0o600 else { - throw RuntimeBridgeFailure( - code: "runtime_definition_not_current", - message: "Install the current LaunchAgent definition before starting the runtime." - ) - } - } - - private func renderDefinition(hostExecutable: String, ompExecutable: String) throws -> String { - let values = [hostExecutable, ompExecutable, logsDirectory] - guard - values.allSatisfy({ value in - !value.isEmpty && value.utf8.count <= 4096 - && !value.unicodeScalars.contains(where: { $0.value < 0x20 || $0.value == 0x7f }) - }) - else { - throw RuntimeBridgeFailure( - code: "runtime_path_invalid", message: "The runtime path is invalid.") - } - let hostExecutableXML = escapeXML(hostExecutable) - let ompExecutableXML = escapeXML(ompExecutable) - let logsXML = escapeXML(logsDirectory) - return [ - "", - "", - "", - " ", - " Label\(runtimeLabel)", - " ProgramArguments", - " ", - " \(hostExecutableXML)", - " serve", - " --omp", - " \(ompExecutableXML)", - " --profile", - " default", - " ", - " RunAtLoad", - " KeepAlive", - " SuccessfulExit", - " Umask63", - " StandardOutPath\(logsXML)/appserver.log", - " StandardErrorPath\(logsXML)/appserver.error.log", - " EnvironmentVariables", - " ", - " OMP_PROFILE", - " default", - " ", - " ", - "", - "", - ].joined(separator: "\n") - } - - private func escapeXML(_ value: String) -> String { - value - .replacingOccurrences(of: "&", with: "&") - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - .replacingOccurrences(of: "\"", with: """) - .replacingOccurrences(of: "'", with: "'") - } - - private func launchctl(_ arguments: [String], allowMissing: Bool = false) throws - -> RuntimeProcessResult - { - let result = try runLaunchctl(arguments) - if result.timedOut { - throw RuntimeBridgeFailure( - code: "runtime_command_timeout", message: "The LaunchAgent command timed out.") - } - if result.overflowed { - throw RuntimeBridgeFailure( - code: "runtime_command_output_limit", - message: "The LaunchAgent command produced too much output.") - } - guard result.exitCode == 0 || allowMissing && isMissingService(result) else { - let message = result.output.isEmpty ? "The LaunchAgent command failed." : result.output - throw RuntimeBridgeFailure( - code: "runtime_command_failed", - message: boundedDiagnostic(message) - ) - } - return result - } - private func runLaunchctl(_ arguments: [String]) throws -> RuntimeProcessResult { - do { - return try runner.run( - executableURL: URL(fileURLWithPath: "/bin/launchctl"), - arguments: arguments, - environment: safeEnvironment(), - timeout: runtimeCommandTimeout, - maxOutputBytes: runtimeOutputLimit - ) - } catch { - throw RuntimeBridgeFailure( - code: "runtime_command_failed", message: "The LaunchAgent command could not be executed.") - } - } - - private func safeEnvironment() -> [String: String] { - var safe: [String: String] = [:] - for key in ["HOME", "PATH", "TMPDIR"] { - if let value = environment[key] { safe[key] = value } - } - return safe - } - - private func isMissingService(_ result: RuntimeProcessResult) -> Bool { - let output = result.output.lowercased() - return output.contains("could not find") - || output.contains("not loaded") - || output.contains("no such process") - } - - private func serviceState(_ result: RuntimeProcessResult) -> String { - let output = result.output.lowercased() - if output.contains("state = running") || output.contains("state=running") { return "running" } - if output.contains("state = starting") || output.contains("state=starting") { - return "starting" - } - if output.contains("state = exited") || output.contains("state=exited") - || isMissingService(result) - { - return "stopped" - } - if output.contains("failed") { return "failed" } - return result.exitCode == 0 ? "running" : "unknown" - } - - private func bridgeFailure(_ error: Error, fallbackCode: String) -> RuntimeBridgeFailure { - if let failure = error as? RuntimeBridgeFailure { return failure } - return RuntimeBridgeFailure( - code: fallbackCode, message: "The runtime lifecycle operation failed safely.") - } -} - -func boundedDiagnostic(_ input: String) -> String { - var value = input - let patterns = [ - "(?i)(Bearer\\s+)[^\\s]+", - "(?i)([A-Za-z0-9_-]*(?:token|secret|password|credential|authorization|api[_-]?key|private[_-]?key)[A-Za-z0-9_-]*\\s*[=:]\\s*)[^\\s,;]+", - ] - for (index, pattern) in patterns.enumerated() { - guard let regex = try? NSRegularExpression(pattern: pattern) else { continue } - let replacement = index == 0 ? "$1[redacted]" : "$1[redacted]" - value = regex.stringByReplacingMatches( - in: value, - range: NSRange(value.startIndex..., in: value), - withTemplate: replacement - ) - } - value = String( - value.unicodeScalars.map { scalar in - scalar.value < 0x20 || scalar.value == 0x7f ? " " : Character(String(scalar)) - }) - return String(value.trimmingCharacters(in: .whitespacesAndNewlines).prefix(512)) -} - -private let updateManifestURL = URL(string: "https://t4code.net/releases/latest.json")! -private let updateManifestLimit = 256 * 1024 - -protocol UpdateManifestFetching { - func fetch() throws -> Data -} - -final class PinnedUpdateManifestFetcher: NSObject, UpdateManifestFetching, URLSessionDataDelegate, - URLSessionTaskDelegate -{ - private let lock = NSLock() - private var received = Data() - private var responseError: Error? - private var completed = DispatchSemaphore(value: 0) - private var responseAccepted = false - - func fetch() throws -> Data { - lock.lock() - received = Data() - responseError = nil - completed = DispatchSemaphore(value: 0) - responseAccepted = false - lock.unlock() - - let configuration = URLSessionConfiguration.ephemeral - configuration.timeoutIntervalForRequest = 10 - configuration.timeoutIntervalForResource = 10 - configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData - configuration.httpCookieAcceptPolicy = .never - configuration.httpCookieStorage = nil - configuration.urlCredentialStorage = nil - configuration.urlCache = nil - let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) - var request = URLRequest(url: updateManifestURL) - request.timeoutInterval = 10 - request.setValue("application/json", forHTTPHeaderField: "Accept") - let task = session.dataTask(with: request) - task.resume() - if completed.wait(timeout: .now() + 10.5) == .timedOut { - task.cancel() - session.invalidateAndCancel() - throw RuntimeBridgeFailure(code: "update_timeout", message: "The update check timed out.") - } - session.finishTasksAndInvalidate() - - lock.lock() - defer { lock.unlock() } - if let error = responseError { throw error } - guard responseAccepted, received.count <= updateManifestLimit else { - throw RuntimeBridgeFailure( - code: "update_invalid_response", message: "The update service response was invalid.") - } - return received - } - - func urlSession( - _ session: URLSession, - task: URLSessionTask, - willPerformHTTPRedirection response: HTTPURLResponse, - newRequest request: URLRequest, - completionHandler: @escaping (URLRequest?) -> Void - ) { - completionHandler(nil) - } - - func urlSession( - _ session: URLSession, - dataTask: URLSessionDataTask, - didReceive response: URLResponse, - completionHandler: @escaping (URLSession.ResponseDisposition) -> Void - ) { - guard let http = response as? HTTPURLResponse, - http.statusCode == 200, - http.url == updateManifestURL, - http.url?.scheme == "https", - http.url?.host == "t4code.net", - http.expectedContentLength <= Int64(updateManifestLimit) - || http.expectedContentLength == -1 - else { - lock.lock() - responseError = RuntimeBridgeFailure( - code: "update_invalid_response", - message: "The update service response was invalid." - ) - lock.unlock() - completionHandler(.cancel) - return - } - lock.lock() - responseAccepted = true - lock.unlock() - completionHandler(.allow) - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - lock.lock() - if data.count > updateManifestLimit - received.count { - responseError = RuntimeBridgeFailure( - code: "update_manifest_too_large", - message: "The update manifest was too large." - ) - lock.unlock() - dataTask.cancel() - return - } - received.append(data) - lock.unlock() - } - - func urlSession( - _ session: URLSession, - task: URLSessionTask, - didCompleteWithError error: Error? - ) { - lock.lock() - if responseError == nil, let error = error { - responseError = RuntimeBridgeFailure( - code: "update_network_error", - message: error is URLError && (error as? URLError)?.code == .timedOut - ? "The update check timed out." - : "The update service could not be reached." - ) - } - lock.unlock() - completed.signal() - } -} - -struct MacReleaseManifest { - let version: String - let downloadURL: URL -} - -final class MacUpdateLifecycle { - private let currentVersion: String - private let fetcher: UpdateManifestFetching - private let openURL: (URL) -> Bool - private var state: [String: Any] - private var selectedDownloadURL: URL? - - init( - currentVersion: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") - as? String - ?? "0.0.0", - fetcher: UpdateManifestFetching = PinnedUpdateManifestFetcher(), - openURL: @escaping (URL) -> Bool = { url in - if Thread.isMainThread { return NSWorkspace.shared.open(url) } - return DispatchQueue.main.sync { NSWorkspace.shared.open(url) } - } - ) { - let safeCurrentVersion = MacUpdateLifecycle.isVersion(currentVersion) ? currentVersion : "0.0.0" - self.currentVersion = safeCurrentVersion - self.fetcher = fetcher - self.openURL = openURL - state = ["currentVersion": safeCurrentVersion, "phase": "idle"] - } - - func getState() -> [String: Any] { state } - - func check() -> [String: Any] { - state = ["currentVersion": currentVersion, "phase": "checking"] - selectedDownloadURL = nil - let checkedAt = Int(Date().timeIntervalSince1970 * 1000) - do { - let manifest = try decodeManifest(fetcher.fetch()) - if compareVersions(manifest.version, currentVersion) <= 0 { - state = [ - "currentVersion": currentVersion, - "phase": "current", - "checkedAt": checkedAt, - "message": "T4 Code is up to date.", - ] - } else { - selectedDownloadURL = manifest.downloadURL - state = [ - "currentVersion": currentVersion, - "phase": "manual", - "latestVersion": manifest.version, - "checkedAt": checkedAt, - "message": "T4 Code \(manifest.version) is available from the official release.", - ] - } - } catch let failure as RuntimeBridgeFailure { - state = [ - "currentVersion": currentVersion, - "phase": "error", - "checkedAt": checkedAt, - "error": String(failure.code.prefix(128)), - "message": boundedDiagnostic(failure.message), - ] - } catch { - state = [ - "currentVersion": currentVersion, - "phase": "error", - "checkedAt": checkedAt, - "error": "update_manifest_invalid", - "message": "The update manifest was invalid.", - ] - } - return state - } - - func download() -> [String: Any] { - guard state["phase"] as? String == "manual", let url = selectedDownloadURL else { return state } - guard isPinnedReleaseURL(url), openURL(url) else { - state = [ - "currentVersion": currentVersion, - "phase": "error", - "checkedAt": state["checkedAt"] as? Int ?? Int(Date().timeIntervalSince1970 * 1000), - "error": "update_open_failed", - "message": "The official release could not be opened.", - ] - return state - } - return state - } - - func install() -> [String: Any] { - guard state["phase"] as? String == "manual" else { return state } - var manual = state - manual["message"] = "Install the downloaded signed DMG manually from the official release." - state = manual - return state - } - - private func decodeManifest(_ data: Data) throws -> MacReleaseManifest { - guard data.count <= updateManifestLimit, - String(data: data, encoding: .utf8) != nil, - let object = try? JSONSerialization.jsonObject(with: data), - let root = object as? [String: Any], - Set(root.keys) - == Set([ - "schemaVersion", "channel", "version", "tag", "publishedAt", "releaseUrl", "assets", - ]), - (root["schemaVersion"] as? NSNumber)?.intValue == 1, - !Self.isJSONBoolean(root["schemaVersion"]), - root["channel"] as? String == "stable", - let version = root["version"] as? String, - Self.isVersion(version), - root["tag"] as? String == "v\(version)", - root["releaseUrl"] as? String - == "https://github.com/LycaonLLC/t4-code/releases/tag/v\(version)", - let publishedAt = root["publishedAt"] as? String, - publishedAt.utf8.count <= 64, - Self.isTimestamp(publishedAt), - let assets = root["assets"] as? [Any], - assets.count == 5 - else { - throw RuntimeBridgeFailure( - code: "update_manifest_invalid", message: "The update manifest was invalid.") - } - - let canonical: [(String, String, String, String)] = [ - ("android", "apk", "universal", "T4-Code-\(version)-android.apk"), - ("linux", "deb", "x86_64", "T4-Code-\(version)-linux-amd64.deb"), - ("linux", "appimage", "x86_64", "T4-Code-\(version)-linux-x86_64.AppImage"), - ("mac", "dmg", "arm64", "T4-Code-\(version)-mac-arm64.dmg"), - ("mac", "zip", "arm64", "T4-Code-\(version)-mac-arm64.zip"), - ] - var decoded: [(String, String, String, String, URL)] = [] - var names = Set() - for rawAsset in assets { - guard let asset = rawAsset as? [String: Any], - Set(asset.keys) == Set(["platform", "kind", "arch", "name", "url", "size", "sha256"]), - let platform = asset["platform"] as? String, - ["android", "linux", "mac"].contains(platform), - let kind = asset["kind"] as? String, - ["apk", "deb", "appimage", "dmg", "zip"].contains(kind), - let arch = asset["arch"] as? String, - ["universal", "x86_64", "arm64"].contains(arch), - let name = asset["name"] as? String, - !name.isEmpty, name.utf8.count <= 160, - names.insert(name).inserted, - let urlString = asset["url"] as? String, - urlString - == "https://github.com/LycaonLLC/t4-code/releases/download/v\(version)/\(name)", - let url = URL(string: urlString), - isPinnedReleaseURL(url), - let size = asset["size"] as? NSNumber, - !Self.isJSONBoolean(size), - size.doubleValue.rounded() == size.doubleValue, - size.int64Value > 0, size.int64Value <= 2 * 1024 * 1024 * 1024, - let digest = asset["sha256"] as? String, - digest.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil - else { - throw RuntimeBridgeFailure( - code: "update_manifest_invalid", message: "The update manifest was invalid.") - } - decoded.append((platform, kind, arch, name, url)) - } - for expected in canonical { - guard - decoded.filter({ - $0.0 == expected.0 && $0.1 == expected.1 && $0.2 == expected.2 && $0.3 == expected.3 - }).count == 1 - else { - throw RuntimeBridgeFailure( - code: "update_manifest_invalid", message: "The update manifest was invalid.") - } - } - guard - let dmg = decoded.first(where: { - $0.0 == "mac" && $0.1 == "dmg" && $0.2 == "arm64" - && $0.3 == "T4-Code-\(version)-mac-arm64.dmg" - }) - else { - throw RuntimeBridgeFailure( - code: "update_manifest_invalid", message: "The update manifest was invalid.") - } - return MacReleaseManifest(version: version, downloadURL: dmg.4) - } - - private static func isVersion(_ value: String) -> Bool { - value.range( - of: #"^\d{1,6}\.\d{1,6}\.\d{1,6}(?:-[0-9A-Za-z](?:[0-9A-Za-z.-]{0,62}[0-9A-Za-z])?)?$"#, - options: .regularExpression - ) != nil - } - - private func compareVersions(_ left: String, _ right: String) -> Int { - func parsed(_ version: String) -> ([Int], [String]) { - let pieces = version.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) - let core = pieces[0].split(separator: ".").map { Int($0) ?? 0 } - let prerelease = pieces.count == 2 ? pieces[1].split(separator: ".").map(String.init) : [] - return (core, prerelease) - } - let a = parsed(left) - let b = parsed(right) - for index in 0..<3 { - if a.0[index] != b.0[index] { return a.0[index] < b.0[index] ? -1 : 1 } - } - if a.1.isEmpty || b.1.isEmpty { - if a.1.isEmpty == b.1.isEmpty { return 0 } - return a.1.isEmpty ? 1 : -1 - } - for index in 0..= a.1.count { return -1 } - if index >= b.1.count { return 1 } - if a.1[index] == b.1[index] { continue } - let leftNumber = Int(a.1[index]) - let rightNumber = Int(b.1[index]) - if let leftNumber = leftNumber, let rightNumber = rightNumber { - return leftNumber < rightNumber ? -1 : 1 - } - if (leftNumber != nil) != (rightNumber != nil) { return leftNumber != nil ? -1 : 1 } - return a.1[index] < b.1[index] ? -1 : 1 - } - return 0 - } - - private static func isTimestamp(_ value: String) -> Bool { - let formatter = ISO8601DateFormatter() - if formatter.date(from: value) != nil { return true } - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.date(from: value) != nil - } - - private static func isJSONBoolean(_ value: Any?) -> Bool { - guard let number = value as? NSNumber else { return false } - return CFGetTypeID(number) == CFBooleanGetTypeID() - } - - private func isPinnedReleaseURL(_ url: URL) -> Bool { - guard url.scheme == "https", url.host == "github.com", url.user == nil, url.password == nil, - url.port == nil, url.query == nil, url.fragment == nil - else { return false } - return url.path.hasPrefix("/LycaonLLC/t4-code/releases/download/v") - } -} - -final class PlatformLifecycleChannel { - static let name = "com.lycaonsolutions.t4code/platform_lifecycle" - - private let channel: FlutterMethodChannel - private let lifecycle: MacRuntimeLifecycle - private let updates: MacUpdateLifecycle - private let executor = DispatchQueue( - label: "com.lycaonsolutions.t4code.platform-lifecycle", qos: .userInitiated) - - init( - messenger: FlutterBinaryMessenger, - lifecycle: MacRuntimeLifecycle = MacRuntimeLifecycle(), - updates: MacUpdateLifecycle = MacUpdateLifecycle() - ) { - channel = FlutterMethodChannel(name: Self.name, binaryMessenger: messenger) - self.lifecycle = lifecycle - self.updates = updates - channel.setMethodCallHandler { [weak self] call, result in - self?.handle(call, result: result) - } - } - - private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - let operation: (() throws -> [String: Any])? - switch call.method { - case "runtime.inspect": operation = { self.lifecycle.inspect() } - case "runtime.install": operation = { try self.lifecycle.install() } - case "runtime.start": operation = { try self.lifecycle.start() } - case "runtime.stop": operation = { try self.lifecycle.stop() } - case "runtime.restart": operation = { try self.lifecycle.restart() } - case "runtime.uninstall": operation = { try self.lifecycle.uninstall() } - case "update.getState": operation = { self.updates.getState() } - case "update.check": operation = { self.updates.check() } - case "update.download": operation = { self.updates.download() } - case "update.install": operation = { self.updates.install() } - default: - result(FlutterMethodNotImplemented) - return - } - - executor.async { - do { - let response = try operation?() ?? [:] - DispatchQueue.main.async { result(response) } - } catch let failure as RuntimeBridgeFailure { - DispatchQueue.main.async { - result( - FlutterError( - code: failure.code, message: boundedDiagnostic(failure.message), details: nil)) - } - } catch { - DispatchQueue.main.async { - result( - FlutterError( - code: "platform_internal", message: "The platform lifecycle operation failed safely.", - details: nil)) - } - } - } - } -} diff --git a/apps/flutter/macos/Runner/Profile.entitlements b/apps/flutter/macos/Runner/Profile.entitlements deleted file mode 100644 index 0fa6d7d2..00000000 --- a/apps/flutter/macos/Runner/Profile.entitlements +++ /dev/null @@ -1,14 +0,0 @@ - - - - - com.apple.security.cs.allow-jit - - com.apple.security.network.server - - com.apple.security.network.client - - keychain-access-groups - - - diff --git a/apps/flutter/macos/Runner/Release.entitlements b/apps/flutter/macos/Runner/Release.entitlements deleted file mode 100644 index 8f14e295..00000000 --- a/apps/flutter/macos/Runner/Release.entitlements +++ /dev/null @@ -1,12 +0,0 @@ - - - - - com.apple.security.network.client - - com.apple.security.files.user-selected.read-only - - keychain-access-groups - - - diff --git a/apps/flutter/macos/RunnerTests/RunnerTests.swift b/apps/flutter/macos/RunnerTests/RunnerTests.swift deleted file mode 100644 index 2e1e2981..00000000 --- a/apps/flutter/macos/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,334 +0,0 @@ -import Darwin -import Foundation -import XCTest -@testable import t4code - -private final class StubRuntimeRunner: RuntimeProcessRunning { - struct Invocation { - let executable: String - let arguments: [String] - let environment: [String: String] - } - - var probeOutput: String - var probeExitCode: Int32 - var bridgeOutput = "Expose the private OMP authority bridge used by T4 Code\n--stdio\n" - var registered = false - var invocations: [Invocation] = [] - - init(probeOutput: String, probeExitCode: Int32 = 0) { - self.probeOutput = probeOutput - self.probeExitCode = probeExitCode - } - - func run( - executableURL: URL, - arguments: [String], - environment: [String: String], - timeout: TimeInterval, - maxOutputBytes: Int - ) throws -> RuntimeProcessResult { - invocations.append(Invocation( - executable: executableURL.path, - arguments: arguments, - environment: environment - )) - if executableURL.path != "/bin/launchctl" { - if arguments == ["bridge", "--help"] { - return RuntimeProcessResult( - exitCode: 0, - output: bridgeOutput, - timedOut: false, - overflowed: false - ) - } - return RuntimeProcessResult( - exitCode: probeExitCode, - output: probeOutput, - timedOut: false, - overflowed: false - ) - } - switch arguments.first { - case "print": - return RuntimeProcessResult( - exitCode: registered ? 0 : 113, - output: registered ? "state = running" : "Could not find service", - timedOut: false, - overflowed: false - ) - case "bootstrap": - registered = true - case "bootout": - registered = false - case "kickstart": - registered = true - default: - XCTFail("unexpected launchctl command: \(arguments)") - } - return RuntimeProcessResult(exitCode: 0, output: "", timedOut: false, overflowed: false) - } -} - -private struct StubManifestFetcher: UpdateManifestFetching { - let data: Data - func fetch() throws -> Data { data } -} - -final class RunnerTests: XCTestCase { - private var temporaryDirectories: [URL] = [] - - override func tearDownWithError() throws { - for directory in temporaryDirectories { - try? FileManager.default.removeItem(at: directory) - } - temporaryDirectories.removeAll() - } - - func testDiscoveryAcceptsExactRunningStatusWithCredentialFreeEnvironment() throws { - let home = try temporaryDirectory() - let executable = try makeExecutable(home: home) - let runner = StubRuntimeRunner( - probeOutput: #"{"state":"running","health":{"ok":true,"hostId":"host-a","epoch":"epoch-a"}}"# - ) - let discovery = OmpRuntimeDiscovery( - environment: [ - "HOME": home.path, - "PATH": "\(home.path)/bin", - "TMPDIR": home.path, - "OMP_EXECUTABLE": executable, - "OPENAI_API_KEY": "must-not-leak", - ], - homeDirectory: home.path, - runner: runner - ).discover() - - XCTAssertEqual(discovery, .found(executable)) - XCTAssertEqual(runner.invocations.count, 2) - XCTAssertEqual( - Set(runner.invocations[0].environment.keys), - Set(["HOME", "PATH", "TMPDIR", "OMP_PROFILE"]) - ) - XCTAssertEqual(runner.invocations[0].environment["OMP_PROFILE"], "default") - XCTAssertNil(runner.invocations[0].environment["OPENAI_API_KEY"]) - XCTAssertEqual(runner.invocations[0].arguments, ["bridge", "--help"]) - XCTAssertEqual(runner.invocations[1].arguments, ["appserver", "status", "--json"]) - } - - func testDiscoveryAcceptsExactStoppedStatus() throws { - let home = try temporaryDirectory() - let executable = try makeExecutable(home: home) - let runner = StubRuntimeRunner( - probeOutput: #"{"state":"stopped","reason":"unreachable"}"#, - probeExitCode: 1 - ) - let result = OmpRuntimeDiscovery( - environment: ["OMP_EXECUTABLE": executable], - homeDirectory: home.path, - runner: runner - ).discover() - XCTAssertEqual(result, .found(executable)) - } - - func testDiscoveryReportsUnsupportedJSONDistinctly() throws { - let home = try temporaryDirectory() - let executable = try makeExecutable(home: home) - let runner = StubRuntimeRunner(probeOutput: "unknown flag: --json", probeExitCode: 2) - let result = OmpRuntimeDiscovery( - environment: ["OMP_EXECUTABLE": executable], - homeDirectory: home.path, - runner: runner - ).discover() - XCTAssertEqual(result, .incompatible) - } - - func testDiscoveryRejectsMalformedAndMissingCandidates() throws { - let home = try temporaryDirectory() - let executable = try makeExecutable(home: home) - let runner = StubRuntimeRunner(probeOutput: #"{"state":"stopped","reason":"other"}"#) - let malformed = OmpRuntimeDiscovery( - environment: ["OMP_EXECUTABLE": executable], - homeDirectory: home.path, - runner: runner - ).discover() - XCTAssertEqual(malformed, .missing) - - let missing = OmpRuntimeDiscovery( - environment: ["OMP_EXECUTABLE": "\(home.path)/missing/omp", "PATH": ""], - homeDirectory: home.path, - runner: runner - ).discover() - XCTAssertEqual(missing, .missing) - } - - func testT4HostDiscoveryRequiresAnExecutableWithTheExactName() throws { - let home = try temporaryDirectory() - let hostExecutable = try makeExecutable(home: home, name: "t4-host") - XCTAssertEqual( - T4HostRuntimeDiscovery( - environment: ["T4_HOST_EXECUTABLE": hostExecutable, "PATH": ""], - homeDirectory: home.path, - packagedExecutable: nil - ).discover(), - hostExecutable - ) - - let wrongHome = try temporaryDirectory() - let wrongExecutable = try makeExecutable(home: wrongHome, name: "not-t4-host") - XCTAssertNil( - T4HostRuntimeDiscovery( - environment: ["T4_HOST_EXECUTABLE": wrongExecutable, "PATH": ""], - homeDirectory: wrongHome.path, - packagedExecutable: nil - ).discover() - ) - } - - func testLaunchAgentInstallInspectAndUninstallLifecycle() throws { - let home = try temporaryDirectory() - let executable = try makeExecutable(home: home) - let hostExecutable = try makeExecutable(home: home, name: "t4-host") - let runner = StubRuntimeRunner(probeOutput: #"{"state":"stopped","reason":"unreachable"}"#) - let files = SecureRuntimeFileStore() - let lifecycle = MacRuntimeLifecycle( - environment: [ - "HOME": home.path, - "OMP_EXECUTABLE": executable, - "T4_HOST_EXECUTABLE": hostExecutable, - ], - homeDirectory: home.path, - uid: 501, - runner: runner, - files: files - ) - - let installed = try lifecycle.install() - XCTAssertEqual(installed["definition"] as? String, "current") - XCTAssertEqual(installed["service"] as? String, "running") - let definitionPath = "\(home.path)/Library/LaunchAgents/dev.oh-my-pi.appserver.plist" - let snapshot = try files.read(definitionPath, maxBytes: 64 * 1024) - XCTAssertEqual(snapshot.mode, 0o600) - XCTAssertTrue(snapshot.content?.contains("\(hostExecutable)") == true) - XCTAssertTrue(snapshot.content?.contains("\(executable)") == true) - XCTAssertTrue(snapshot.content?.contains("serve") == true) - XCTAssertTrue(snapshot.content?.contains("--omp") == true) - XCTAssertTrue(snapshot.content?.contains("--profile") == true) - XCTAssertTrue(snapshot.content?.contains("OMP_PROFILE") == true) - XCTAssertTrue(snapshot.content?.contains("default") == true) - XCTAssertTrue(snapshot.content?.contains("Library/Logs/T4 Code/appserver/appserver.log") == true) - XCTAssertFalse(snapshot.content?.contains("must-not-leak") == true) - XCTAssertTrue(runner.invocations.contains(where: { - $0.executable == "/bin/launchctl" - && $0.arguments == ["bootstrap", "gui/501", definitionPath] - })) - XCTAssertTrue(runner.invocations.contains(where: { - $0.executable == "/bin/launchctl" - && $0.arguments == ["kickstart", "-k", "gui/501/dev.oh-my-pi.appserver"] - })) - - let uninstalled = try lifecycle.uninstall() - XCTAssertEqual(uninstalled["definition"] as? String, "missing") - XCTAssertEqual(uninstalled["service"] as? String, "stopped") - XCTAssertNil(try files.read(definitionPath, maxBytes: 1024).content) - } - - func testSecureStoreRefusesSymlinkDefinition() throws { - let home = try temporaryDirectory() - let launchAgents = home.appendingPathComponent("Library/LaunchAgents", isDirectory: true) - try FileManager.default.createDirectory(at: launchAgents, withIntermediateDirectories: true) - let target = home.appendingPathComponent("elsewhere.plist") - try Data("safe".utf8).write(to: target) - let definition = launchAgents.appendingPathComponent("dev.oh-my-pi.appserver.plist") - try FileManager.default.createSymbolicLink(at: definition, withDestinationURL: target) - - XCTAssertThrowsError(try SecureRuntimeFileStore().read(definition.path, maxBytes: 1024)) - XCTAssertEqual(try String(contentsOf: target, encoding: .utf8), "safe") - } - - func testMacUpdateCheckSelectsCanonicalDMGAndOnlyOpensValidatedURL() throws { - let manifest = try releaseManifest(version: "0.1.25") - var opened: URL? - let updates = MacUpdateLifecycle( - currentVersion: "0.1.24", - fetcher: StubManifestFetcher(data: manifest), - openURL: { url in opened = url; return true } - ) - - let checked = updates.check() - XCTAssertEqual(checked["phase"] as? String, "manual") - XCTAssertEqual(checked["latestVersion"] as? String, "0.1.25") - XCTAssertNil(checked["revision"]) - let downloaded = updates.download() - XCTAssertEqual(downloaded["phase"] as? String, "manual") - XCTAssertEqual( - opened?.absoluteString, - "https://github.com/LycaonLLC/t4-code/releases/download/v0.1.25/T4-Code-0.1.25-mac-arm64.dmg" - ) - let installed = updates.install() - XCTAssertEqual(installed["phase"] as? String, "manual") - XCTAssertTrue((installed["message"] as? String)?.contains("signed DMG") == true) - } - - func testMacUpdateRejectsNonCanonicalReleaseURL() throws { - var object = try XCTUnwrap( - JSONSerialization.jsonObject(with: releaseManifest(version: "0.1.25")) as? [String: Any] - ) - var assets = try XCTUnwrap(object["assets"] as? [[String: Any]]) - assets[3]["url"] = "https://evil.example/T4-Code-0.1.25-mac-arm64.dmg" - object["assets"] = assets - let updates = MacUpdateLifecycle( - currentVersion: "0.1.24", - fetcher: StubManifestFetcher(data: try JSONSerialization.data(withJSONObject: object)), - openURL: { _ in XCTFail("invalid URL must not open"); return true } - ) - let result = updates.check() - XCTAssertEqual(result["phase"] as? String, "error") - XCTAssertEqual(result["error"] as? String, "update_manifest_invalid") - } - - private func temporaryDirectory() throws -> URL { - let base = FileManager.default.homeDirectoryForCurrentUser - let directory = base.appendingPathComponent(".t4-macos-tests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) - temporaryDirectories.append(directory) - return directory - } - - private func makeExecutable(home: URL, name: String = "omp") throws -> String { - let directory = home.appendingPathComponent("bin", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let executable = directory.appendingPathComponent(name) - XCTAssertTrue(FileManager.default.createFile(atPath: executable.path, contents: Data())) - XCTAssertEqual(chmod(executable.path, 0o700), 0) - return executable.path - } - - private func releaseManifest(version: String) throws -> Data { - func asset(_ platform: String, _ kind: String, _ arch: String, _ name: String) -> [String: Any] { - [ - "platform": platform, - "kind": kind, - "arch": arch, - "name": name, - "url": "https://github.com/LycaonLLC/t4-code/releases/download/v\(version)/\(name)", - "size": 1024, - "sha256": String(repeating: "a", count: 64), - ] - } - return try JSONSerialization.data(withJSONObject: [ - "schemaVersion": 1, - "channel": "stable", - "version": version, - "tag": "v\(version)", - "publishedAt": "2026-07-19T12:00:00.000Z", - "releaseUrl": "https://github.com/LycaonLLC/t4-code/releases/tag/v\(version)", - "assets": [ - asset("android", "apk", "universal", "T4-Code-\(version)-android.apk"), - asset("linux", "deb", "x86_64", "T4-Code-\(version)-linux-amd64.deb"), - asset("linux", "appimage", "x86_64", "T4-Code-\(version)-linux-x86_64.AppImage"), - asset("mac", "dmg", "arm64", "T4-Code-\(version)-mac-arm64.dmg"), - asset("mac", "zip", "arm64", "T4-Code-\(version)-mac-arm64.zip"), - ], - ]) - } -} diff --git a/apps/flutter/package.json b/apps/flutter/package.json deleted file mode 100644 index 635bab51..00000000 --- a/apps/flutter/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@t4-code/flutter", - "version": "0.1.30", - "private": true, - "scripts": { - "dev": "flutter run", - "typecheck": "flutter analyze", - "test": "flutter test", - "test:coverage": "flutter test --coverage && node ../../scripts/check-flutter-coverage.mjs", - "test:device": "flutter test integration_test/app_smoke_test.dart", - "build:web": "flutter build web", - "build:macos": "flutter build macos", - "build:android": "flutter build apk --debug", - "build:ios": "flutter build ios --simulator" - } -} diff --git a/apps/flutter/pubspec.lock b/apps/flutter/pubspec.lock deleted file mode 100644 index 5fdfd177..00000000 --- a/apps/flutter/pubspec.lock +++ /dev/null @@ -1,729 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - async: - dependency: transitive - description: - name: async - sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.dev" - source: hosted - version: "2.13.1" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - characters: - dependency: transitive - description: - name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" - source: hosted - version: "1.4.1" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" - url: "https://pub.dev" - source: hosted - version: "0.3.5+4" - crypto: - dependency: "direct main" - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" - equatable: - dependency: transitive - description: - name: equatable - sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - file_selector: - dependency: "direct main" - description: - name: file_selector - sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a - url: "https://pub.dev" - source: hosted - version: "1.1.0" - file_selector_android: - dependency: transitive - description: - name: file_selector_android - sha256: "6a26687fa65cbc28a5345c7ae6f227e89f0b47740978a4c475b1a625da7a331b" - url: "https://pub.dev" - source: hosted - version: "0.5.2+8" - file_selector_ios: - dependency: transitive - description: - name: file_selector_ios - sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca - url: "https://pub.dev" - source: hosted - version: "0.5.3+5" - file_selector_linux: - dependency: transitive - description: - name: file_selector_linux - sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" - url: "https://pub.dev" - source: hosted - version: "0.9.4" - file_selector_macos: - dependency: transitive - description: - name: file_selector_macos - sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" - url: "https://pub.dev" - source: hosted - version: "0.9.5" - file_selector_platform_interface: - dependency: transitive - description: - name: file_selector_platform_interface - sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" - url: "https://pub.dev" - source: hosted - version: "2.7.0" - file_selector_web: - dependency: transitive - description: - name: file_selector_web - sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" - url: "https://pub.dev" - source: hosted - version: "0.9.5" - file_selector_windows: - dependency: transitive - description: - name: file_selector_windows - sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" - url: "https://pub.dev" - source: hosted - version: "0.9.3+5" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_driver: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_markdown_plus: - dependency: "direct main" - description: - name: flutter_markdown_plus - sha256: fce641d6c2106cc495de1cd603f97a4f9d615a97a64011928ded380dbdade935 - url: "https://pub.dev" - source: hosted - version: "1.0.12" - flutter_secure_storage: - dependency: "direct main" - description: - name: flutter_secure_storage - sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" - url: "https://pub.dev" - source: hosted - version: "10.3.1" - flutter_secure_storage_darwin: - dependency: transitive - description: - name: flutter_secure_storage_darwin - sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" - url: "https://pub.dev" - source: hosted - version: "0.3.2" - flutter_secure_storage_linux: - dependency: transitive - description: - name: flutter_secure_storage_linux - sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 - url: "https://pub.dev" - source: hosted - version: "3.0.1" - flutter_secure_storage_platform_interface: - dependency: transitive - description: - name: flutter_secure_storage_platform_interface - sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" - url: "https://pub.dev" - source: hosted - version: "2.0.1" - flutter_secure_storage_web: - dependency: transitive - description: - name: flutter_secure_storage_web - sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - flutter_secure_storage_windows: - dependency: transitive - description: - name: flutter_secure_storage_windows - sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" - url: "https://pub.dev" - source: hosted - version: "4.1.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - fuchsia_remote_debug_protocol: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - hooks: - dependency: transitive - description: - name: hooks - sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" - url: "https://pub.dev" - source: hosted - version: "2.0.2" - http: - dependency: transitive - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" - source: hosted - version: "1.6.0" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - integration_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - jni: - dependency: transitive - description: - name: jni - sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f - url: "https://pub.dev" - source: hosted - version: "1.0.0" - jni_flutter: - dependency: transitive - description: - name: jni_flutter - sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.dev" - source: hosted - version: "6.1.0" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - markdown: - dependency: transitive - description: - name: markdown - sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 - url: "https://pub.dev" - source: hosted - version: "7.3.1" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.dev" - source: hosted - version: "0.12.19" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" - source: hosted - version: "0.13.0" - meta: - dependency: transitive - description: - name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" - url: "https://pub.dev" - source: hosted - version: "1.18.0" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" - url: "https://pub.dev" - source: hosted - version: "9.4.1" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_provider: - dependency: transitive - description: - name: path_provider - sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 - url: "https://pub.dev" - source: hosted - version: "2.1.6" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" - url: "https://pub.dev" - source: hosted - version: "2.3.1" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" - url: "https://pub.dev" - source: hosted - version: "2.6.0" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" - url: "https://pub.dev" - source: hosted - version: "2.2.2" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" - url: "https://pub.dev" - source: hosted - version: "2.1.3" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - process: - dependency: transitive - description: - name: process - sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 - url: "https://pub.dev" - source: hosted - version: "5.0.5" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - quiver: - dependency: transitive - description: - name: quiver - sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - re_highlight: - dependency: "direct main" - description: - name: re_highlight - sha256: "6c4ac3f76f939fb7ca9df013df98526634e17d8f7460e028bd23a035870024f2" - url: "https://pub.dev" - source: hosted - version: "0.0.3" - record_use: - dependency: transitive - description: - name: record_use - sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" - url: "https://pub.dev" - source: hosted - version: "0.6.0" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf - url: "https://pub.dev" - source: hosted - version: "2.5.5" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" - url: "https://pub.dev" - source: hosted - version: "2.4.27" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" - url: "https://pub.dev" - source: hosted - version: "2.5.6" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" - url: "https://pub.dev" - source: hosted - version: "2.4.2" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.dev" - source: hosted - version: "2.4.3" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_span: - dependency: transitive - description: - name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" - source: hosted - version: "1.10.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - sync_http: - dependency: transitive - description: - name: sync_http - sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" - url: "https://pub.dev" - source: hosted - version: "0.3.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" - url: "https://pub.dev" - source: hosted - version: "0.7.11" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" - source: hosted - version: "2.2.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" - url: "https://pub.dev" - source: hosted - version: "15.2.0" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: "direct main" - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" - webdriver: - dependency: transitive - description: - name: webdriver - sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - win32: - dependency: transitive - description: - name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e - url: "https://pub.dev" - source: hosted - version: "5.15.0" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - xterm: - dependency: "direct main" - description: - name: xterm - sha256: "168dfedca77cba33fdb6f52e2cd001e9fde216e398e89335c19b524bb22da3a2" - url: "https://pub.dev" - source: hosted - version: "4.0.0" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" - zmodem: - dependency: transitive - description: - name: zmodem - sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf" - url: "https://pub.dev" - source: hosted - version: "0.0.6" -sdks: - dart: ">=3.12.2 <4.0.0" - flutter: ">=3.44.0" diff --git a/apps/flutter/pubspec.yaml b/apps/flutter/pubspec.yaml deleted file mode 100644 index e3456461..00000000 --- a/apps/flutter/pubspec.yaml +++ /dev/null @@ -1,36 +0,0 @@ -name: t4code -description: "T4 Code Flutter client" -publish_to: 'none' -version: 0.1.30+1 - -environment: - sdk: ^3.12.2 - -dependencies: - flutter: - sdk: flutter - web_socket_channel: ^3.0.3 - flutter_secure_storage: 10.3.1 - shared_preferences: 2.5.5 - crypto: ^3.0.7 - flutter_markdown_plus: ^1.0.12 - file_selector: ^1.1.0 - xterm: ^4.0.0 - re_highlight: ^0.0.3 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^6.0.0 - integration_test: - sdk: flutter - -flutter: - uses-material-design: true - fonts: - - family: DM Sans - fonts: - - asset: assets/fonts/DMSans-Variable.ttf - - family: JetBrains Mono - fonts: - - asset: assets/fonts/JetBrainsMono-Variable.ttf diff --git a/apps/flutter/test/client/model_labels_test.dart b/apps/flutter/test/client/model_labels_test.dart deleted file mode 100644 index 7cdf87fc..00000000 --- a/apps/flutter/test/client/model_labels_test.dart +++ /dev/null @@ -1,385 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/client/model_labels.dart'; -import 'package:t4code/src/protocol/models.dart'; - -void main() { - group('humanizeIdentifier', () { - test('splits camelCase, hyphens, underscores and title-cases words', () { - expect(humanizeIdentifier('gpt-engineer'), 'GPT Engineer'); - expect(humanizeIdentifier('claude-fable-5'), 'Claude Fable 5'); - expect(humanizeIdentifier('quickTask'), 'Quick Task'); - expect(humanizeIdentifier('gemini_3_flash'), 'Gemini 3 Flash'); - }); - - test('keeps known acronyms uppercase', () { - expect(humanizeIdentifier('gpt-5-6-sol'), 'GPT 5 6 Sol'); - expect(humanizeIdentifier('glm-4-api'), 'GLM 4 API'); - }); - - test('passes through unimprovable tokens capitalized', () { - expect(humanizeIdentifier('ollama'), 'Ollama'); - expect(humanizeIdentifier('mistral'), 'Mistral'); - }); - }); - - group('providerDisplayName', () { - test('maps known providers to friendly names', () { - expect(providerDisplayName('anthropic'), 'Anthropic'); - expect(providerDisplayName('openai'), 'OpenAI'); - expect(providerDisplayName('openai-codex'), 'OpenAI'); - expect(providerDisplayName('xai-oauth'), 'xAI'); - expect(providerDisplayName('google-vertex'), 'Google'); - expect(providerDisplayName('github-copilot'), 'GitHub Copilot'); - }); - - test('humanizes unknown providers deterministically', () { - expect(providerDisplayName('ollama'), 'Ollama'); - expect(providerDisplayName('together-ai'), 'Together AI'); - }); - }); - - group('modelItemSelector', () { - test('builds provider/modelId from metadata', () { - final item = _catalogItem( - kind: 'model', - name: 'GPT-5.6 Sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ); - expect(modelItemSelector(item), 'openai-codex/gpt-5.6-sol'); - }); - - test('falls back to name when it contains a slash', () { - final item = _catalogItem( - kind: 'model', - name: 'anthropic/claude-fable-5', - metadata: null, - ); - expect(modelItemSelector(item), 'anthropic/claude-fable-5'); - }); - - test('returns null when no provider/modelId and name has no slash', () { - final item = _catalogItem( - kind: 'model', - name: 'claude-fable-5', - metadata: null, - ); - expect(modelItemSelector(item), isNull); - }); - }); - - group('modelLabelFor', () { - test('uses catalog name when it is a human label', () { - final item = _catalogItem( - kind: 'model', - name: 'GPT-5.6 Sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ); - final label = modelLabelFor(item); - expect(label.selector, 'openai-codex/gpt-5.6-sol'); - expect(label.label, 'GPT-5.6 Sol'); - expect(label.provider, 'openai-codex'); - expect(label.providerLabel, 'OpenAI'); - expect(label.inCatalog, isTrue); - }); - - test('humanizes the model id when the catalog name equals the raw id', () { - final item = _catalogItem( - kind: 'model', - name: 'openai-codex/gpt-5.6-sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ); - final label = modelLabelFor(item); - expect(label.selector, 'openai-codex/gpt-5.6-sol'); - expect(label.label, 'GPT 5.6 Sol'); - expect(label.providerLabel, 'OpenAI'); - }); - - test('raw protocol id never leaks as the primary known-model label', () { - final item = _catalogItem( - kind: 'model', - name: 'openai-codex/gpt-5.6-sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ); - final label = modelLabelFor(item); - expect(label.label, isNot(equals(label.selector))); - expect(label.label, isNot(contains('/'))); - }); - }); - - group('groupModelChoices', () { - test('groups models by provider with friendly provider labels', () { - final catalog = [ - _catalogItem( - kind: 'model', - name: 'GPT-5.6 Sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ), - _catalogItem( - kind: 'model', - name: 'GPT-5.6', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6', - }, - ), - _catalogItem( - kind: 'model', - name: 'Claude Fable 5', - metadata: { - 'provider': 'anthropic', - 'modelId': 'claude-fable-5', - }, - ), - ]; - final groups = groupModelChoices(catalog); - expect(groups, hasLength(2)); - // Known providers sort alphabetically by friendly label. - expect(groups.first.label, 'Anthropic'); - expect(groups.first.choices, hasLength(1)); - expect(groups.first.choices.single.label, 'Claude Fable 5'); - expect(groups.last.label, 'OpenAI'); - expect(groups.last.choices, hasLength(2)); - // Within a group, choices sort by label. - expect(groups.last.choices.first.label, 'GPT-5.6'); - expect(groups.last.choices.last.label, 'GPT-5.6 Sol'); - }); - - test('collapses duplicate selectors to the first occurrence', () { - final catalog = [ - _catalogItem( - kind: 'model', - name: 'GPT-5.6 Sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ), - _catalogItem( - kind: 'model', - name: 'Duplicate', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ), - ]; - final groups = groupModelChoices(catalog); - expect(groups, hasLength(1)); - expect(groups.single.choices, hasLength(1)); - expect(groups.single.choices.single.label, 'GPT-5.6 Sol'); - }); - - test('unknown providers sort after known providers', () { - final catalog = [ - _catalogItem( - kind: 'model', - name: 'Claude Fable 5', - metadata: { - 'provider': 'anthropic', - 'modelId': 'claude-fable-5', - }, - ), - _catalogItem( - kind: 'model', - name: 'Mistral Large', - metadata: { - 'provider': 'mistral', - 'modelId': 'large', - }, - ), - ]; - final groups = groupModelChoices(catalog); - expect(groups, hasLength(2)); - expect(groups.first.label, 'Anthropic'); - expect(groups.last.label, 'Mistral'); - expect(groups.last.choices.single.selector, 'mistral/large'); - }); - - test('models that cannot resolve to a selector are dropped safely', () { - final catalog = [ - _catalogItem(kind: 'model', name: 'local-model', metadata: null), - _catalogItem( - kind: 'model', - name: 'anthropic/claude-fable-5', - metadata: null, - ), - ]; - final groups = groupModelChoices(catalog); - // The slash-less name with no metadata cannot resolve to a switchable - // selector, so it is dropped rather than offered as a non-functional - // choice. The slash-named model still resolves and groups under Anthropic. - expect(groups, hasLength(1)); - expect(groups.single.label, 'Anthropic'); - expect(groups.single.choices.single.selector, 'anthropic/claude-fable-5'); - }); - - test('ignores non-model catalog items', () { - final catalog = [ - _catalogItem(kind: 'command', name: '/model', metadata: null), - _catalogItem( - kind: 'model', - name: 'GPT-5.6 Sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ), - ]; - final groups = groupModelChoices(catalog); - expect(groups, hasLength(1)); - expect(groups.single.choices, hasLength(1)); - }); - - test('retains unsupported models as selectable but marked', () { - final catalog = [ - _catalogItem( - kind: 'model', - name: 'GPT-5.6 Sol', - supported: false, - reason: 'No API key', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ), - ]; - final groups = groupModelChoices(catalog); - expect(groups.single.choices.single.supported, isFalse); - expect(groups.single.choices.single.reason, 'No API key'); - // Selector is still present so the model stays selectable. - expect(groups.single.choices.single.selector, 'openai-codex/gpt-5.6-sol'); - }); - }); - - group('sessionModelLabel', () { - final catalog = [ - _catalogItem( - kind: 'model', - name: 'GPT-5.6 Sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ), - ]; - - test('prefers the host-reported display name', () { - expect( - sessionModelLabel('openai-codex/gpt-5.6-sol', 'GPT-5.6 Sol', catalog), - 'GPT-5.6 Sol', - ); - }); - - test('falls back to the catalog label when display name is absent', () { - expect( - sessionModelLabel('openai-codex/gpt-5.6-sol', null, catalog), - 'GPT-5.6 Sol', - ); - }); - - test('humanizes an unknown selector so the raw id never shows', () { - expect( - sessionModelLabel('unknown-vendor/some-model-7', null, catalog), - 'Some Model 7', - ); - }); - - test('returns the display name when selector is null', () { - expect(sessionModelLabel(null, 'Host model', catalog), 'Host model'); - }); - - test('returns null when both selector and display name are absent', () { - expect(sessionModelLabel(null, null, catalog), isNull); - }); - - test('strips a trailing thinking-level suffix before matching', () { - expect( - sessionModelLabel('openai-codex/gpt-5.6-sol:high', null, catalog), - 'GPT-5.6 Sol', - ); - }); - }); - - group('submission of the unchanged raw id', () { - test('groupModelChoices carries the exact provider/modelId selector', () { - final catalog = [ - _catalogItem( - kind: 'model', - name: 'GPT-5.6 Sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ), - ]; - final groups = groupModelChoices(catalog); - // The submitted value is the raw protocol id, not the humanized label. - expect(groups.single.choices.single.selector, 'openai-codex/gpt-5.6-sol'); - expect( - groups.single.choices.single.selector, - isNot(equals(groups.single.choices.single.label)), - ); - }); - - test('modelLabelFor never rewrites the selector', () { - final item = _catalogItem( - kind: 'model', - name: 'GPT-5.6 Sol', - metadata: { - 'provider': 'openai-codex', - 'modelId': 'gpt-5.6-sol', - }, - ); - final label = modelLabelFor(item); - expect(label.selector, 'openai-codex/gpt-5.6-sol'); - expect(label.label, 'GPT-5.6 Sol'); - // The human label and the submitted selector are distinct values. - expect(label.label, isNot(equals(label.selector))); - }); - }); -} - -CatalogItem _catalogItem({ - required String kind, - required String name, - Map? metadata, - bool? supported, - String? reason, -}) { - final raw = { - 'id': '$kind-$name', - 'kind': kind, - 'name': name, - 'description': null, - 'capabilities': null, - 'supported': supported, - 'reason': reason, - 'metadata': metadata, - }; - return CatalogItem( - id: '$kind-$name', - kind: kind, - name: name, - description: null, - capabilities: null, - supported: supported, - reason: reason, - metadata: metadata, - raw: raw, - ); -} diff --git a/apps/flutter/test/client/t4_client_controller_fixture_test.dart b/apps/flutter/test/client/t4_client_controller_fixture_test.dart deleted file mode 100644 index 1b34d951..00000000 --- a/apps/flutter/test/client/t4_client_controller_fixture_test.dart +++ /dev/null @@ -1,667 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/client/t4_client_controller.dart'; -import 'package:t4code/src/client/app_state.dart'; -import 'package:t4code/src/host/host_profile.dart'; -import 'package:t4code/src/protocol/protocol.dart'; - -const _startupTimeout = Duration(seconds: 10); -const _operationTimeout = Duration(seconds: 5); - -void main() { - test( - 'streams, settles, and resumes against the real stream-v1 fixture', - () async { - final fixture = await _FixtureProcess.start(); - final controller = T4ClientController( - hostDirectoryStore: _MemoryDirectoryStore(), - hostCredentialStore: _MemoryCredentialStore(), - developmentEndpoint: fixture.wsUrl, - ); - addTearDown(() async { - controller.dispose(); - await fixture.stop(); - }); - - await controller.initialize().timeout(_operationTimeout); - final initiallyReady = await _waitForState( - controller, - (state) => state.connectionPhase == ConnectionPhase.ready, - description: 'the initial snapshot to become ready', - ); - - expect(initiallyReady.errorMessage, isNull); - expect(initiallyReady.submitting, isFalse); - expect(initiallyReady.sessions, hasLength(1)); - expect(initiallyReady.selectedSessionId, 'session-stream'); - expect(initiallyReady.messages, hasLength(1)); - final snapshotMessage = initiallyReady.messages.single; - expect(snapshotMessage.role, MessageRole.assistant); - expect(snapshotMessage.text, 'Hello world'); - expect(snapshotMessage.streaming, isFalse); - final session = initiallyReady.selectedSession; - expect(session, isNotNull); - expect(session!.hostId, 'host-stream'); - expect(session.sessionId, 'session-stream'); - expect(session.title, 'stream-v1 fixture'); - expect(session.revision, 'rev-stream-1'); - - const prompt = 'Flutter fixture prompt'; - final promptAccepted = _waitForState( - controller, - (state) => - state.submitting && - state.messages.any( - (message) => - message.role == MessageRole.user && message.text == prompt, - ), - description: 'the fixture to accept the prompt', - ); - await controller.submitPrompt(prompt).timeout(_operationTimeout); - final submitting = await promptAccepted; - expect(submitting.errorMessage, isNull); - expect( - submitting.messages.where((message) => message.text == prompt), - hasLength(1), - ); - - final settledFuture = _waitForState( - controller, - (state) => - !state.submitting && - state.messages.any( - (message) => - message.id != snapshotMessage.id && - message.role == MessageRole.assistant && - message.text == 'Hello world' && - !message.streaming, - ), - description: 'the streamed response to settle durably', - ); - final advance = await fixture.advanceBy(30); - expect(advance, containsPair('ok', true)); - expect(advance, containsPair('nowMs', 30)); - final settled = await settledFuture; - - expect(settled.connectionPhase, ConnectionPhase.ready); - expect(settled.errorMessage, isNull); - expect( - settled.messages, - hasLength(2), - reason: settled.messages - .map( - (message) => - '${message.id}|${message.role.name}|${message.text}|${message.streaming}', - ) - .join(', '), - ); - expect( - settled.messages - .where( - (message) => - message.role == MessageRole.assistant && - message.text == 'Hello world', - ) - .length, - 2, - ); - expect(settled.messages.where((message) => message.streaming), isEmpty); - expect( - settled.messages.map((message) => message.id).toSet(), - hasLength(settled.messages.length), - ); - - final retainedTranscript = settled.messages - .map( - (message) => ( - id: message.id, - role: message.role, - text: message.text, - streaming: message.streaming, - ), - ) - .toList(growable: false); - - final retryingFuture = _waitForState( - controller, - (state) => state.connectionPhase == ConnectionPhase.retrying, - description: 'the forced disconnect to enter retrying', - ); - await fixture.disconnectClients(); - final retrying = await retryingFuture; - expect( - retrying.messages.map((message) => message.id), - retainedTranscript.map((message) => message.id), - ); - - final reconnected = await _waitForState( - controller, - (state) => state.connectionPhase == ConnectionPhase.ready, - description: 'the controller to reconnect automatically', - ); - expect(reconnected.errorMessage, isNull); - expect(reconnected.submitting, isFalse); - expect( - reconnected.messages - .map( - (message) => ( - id: message.id, - role: message.role, - text: message.text, - streaming: message.streaming, - ), - ) - .toList(growable: false), - retainedTranscript, - ); - expect( - reconnected.messages.where((message) => message.streaming), - isEmpty, - ); - expect( - reconnected.messages.where((message) => message.text == 'Hello world'), - hasLength(2), - ); - - final fixtureState = await fixture.state(); - expect(fixtureState, containsPair('scenario', 'stream-v1')); - expect(fixtureState, containsPair('clients', 1)); - expect(fixtureState, containsPair('connections', 2)); - }, - ); - test( - 'reads and explicitly confirms settings writes against the real fixture', - () async { - final fixture = await _FixtureProcess.start(); - final controller = T4ClientController( - hostDirectoryStore: _MemoryDirectoryStore(), - hostCredentialStore: _MemoryCredentialStore(), - developmentEndpoint: fixture.wsUrl, - ); - addTearDown(() async { - controller.dispose(); - await fixture.stop(); - }); - - await controller.initialize().timeout(_operationTimeout); - final settingsReady = await _waitForState( - controller, - (state) => - state.connectionPhase == ConnectionPhase.ready && - !state.settings.loading && - state.settings.entries.any( - (entry) => entry.path == 'appearance.mode', - ), - description: 'fixture settings metadata to load', - ); - final appearance = settingsReady.settings.entries.singleWhere( - (entry) => entry.path == 'appearance.mode', - ); - expect(appearance.effectiveValue, 'system'); - expect(appearance.restartRequired, isTrue); - expect(appearance.writableScopes, contains('global')); - final secret = settingsReady.settings.entries.singleWhere( - (entry) => entry.path == 'provider.apiKey', - ); - expect(secret.sensitive, isTrue); - expect(secret.effectiveValue, isNull); - - final write = controller.writeSetting( - 'appearance.mode', - 'global', - value: 'dark', - ); - final challenged = await _waitForState( - controller, - (state) => state.attentionItems.any( - (item) => - item.kind == AttentionKind.confirmation && - item.sessionId.isEmpty && - item.needsResponse, - ), - description: 'fixture settings confirmation', - ); - final confirmation = challenged.attentionItems.singleWhere( - (item) => - item.kind == AttentionKind.confirmation && - item.sessionId.isEmpty && - item.needsResponse, - ); - await controller.respondToAttention( - confirmation, - const AttentionResponse(decision: AttentionDecision.approve), - ); - await write.timeout(_operationTimeout); - - final applied = controller.state.settings.entries.singleWhere( - (entry) => entry.path == 'appearance.mode', - ); - expect(applied.effectiveValue, 'dark'); - expect(applied.effectiveSource, 'global'); - expect(controller.state.settings.error, isNull); - expect(controller.state.settingsOperationPending, isFalse); - - final resumeWrite = controller.writeSetting( - 'session.autoResume', - 'global', - value: false, - ); - final resumeChallenge = await _waitForState( - controller, - (state) => state.attentionItems.any( - (item) => - item.kind == AttentionKind.confirmation && - item.sessionId.isEmpty && - item.needsResponse, - ), - description: 'fixture resume setting confirmation', - ); - final resumeConfirmation = resumeChallenge.attentionItems.singleWhere( - (item) => - item.kind == AttentionKind.confirmation && - item.sessionId.isEmpty && - item.needsResponse, - ); - await controller.respondToAttention( - resumeConfirmation, - const AttentionResponse(decision: AttentionDecision.approve), - ); - await resumeWrite.timeout(_operationTimeout); - expect( - controller.state.settings.entries - .singleWhere((entry) => entry.path == 'session.autoResume') - .effectiveValue, - false, - ); - }, - ); - test( - 'runs implemented session and developer workflows against the real fixture', - () async { - final fixture = await _FixtureProcess.start(); - final controller = T4ClientController( - hostDirectoryStore: _MemoryDirectoryStore(), - hostCredentialStore: _MemoryCredentialStore(), - developmentEndpoint: fixture.wsUrl, - ); - addTearDown(() async { - controller.dispose(); - await fixture.stop(); - }); - - await controller.initialize().timeout(_operationTimeout); - await _waitForState( - controller, - (state) => state.connectionPhase == ConnectionPhase.ready, - description: 'fixture session to become ready', - ); - const sessionId = 'session-stream'; - - final search = await controller - .searchTranscripts( - query: 'fixture', - roles: const [TranscriptSearchRole.assistant], - ) - .timeout(_operationTimeout); - expect(search.items.single.sessionId, sessionId); - expect(search.items.single.snippet, contains('transcript search')); - expect(search.index.state, TranscriptSearchIndexState.ready); - final context = await controller - .loadTranscriptContext( - sessionId: sessionId, - anchorId: search.items.single.anchorId, - ) - .timeout(_operationTimeout); - expect(context.rows, hasLength(2)); - expect(context.rows[context.anchorIndex].anchorId, context.anchorId); - - final usage = await controller.readUsage().timeout(_operationTimeout); - expect(usage.reports.single.provider, 'fixture-provider'); - expect(usage.reports.single.limits.single.amount.used, 4); - final broker = await controller.readBrokerStatus().timeout( - _operationTimeout, - ); - expect(broker.state, BrokerState.connected); - expect(broker.endpoint, 'https://broker.fixture.invalid'); - - await _waitForState( - controller, - (state) => state.agentActivities.any( - (activity) => - activity.agentId == 'agent-fixture' && - activity.parentAgentId == 'agent-parent' && - activity.currentTool == 'read', - ), - description: 'fixture agent hierarchy projection', - ); - await controller.cancelAgent('agent-fixture').timeout(_operationTimeout); - await _waitForState( - controller, - (state) => state.agentActivities.any( - (activity) => - activity.agentId == 'agent-fixture' && - activity.status == 'cancelled', - ), - description: 'cancelled fixture child agent', - ); - - await controller - .renameSession(sessionId, 'Renamed fixture') - .timeout(_operationTimeout); - await _waitForState( - controller, - (state) => - state.sessions - .where((session) => session.sessionId == sessionId) - .single - .title == - 'Renamed fixture', - description: 'renamed session projection', - ); - - await controller.refreshActivity().timeout(_operationTimeout); - await controller.listFiles().timeout(_operationTimeout); - expect(controller.state.fileWorkspace.entries, isEmpty); - await controller.readFile('README.md').timeout(_operationTimeout); - expect(controller.state.fileWorkspace.path, 'README.md'); - expect(controller.state.fileWorkspace.content, ''); - await controller.loadSessionDiff().timeout(_operationTimeout); - expect(controller.state.fileWorkspace.diff, ''); - expect(controller.state.fileWorkspace.revision, isNotNull); - expect(controller.state.reviews.single.reviewId, 'review-fixture'); - await controller - .writeFile('README.md', 'fixture edit') - .timeout(_operationTimeout); - expect(controller.state.fileWorkspace.content, 'fixture edit'); - await controller.applyReview('review-fixture').timeout(_operationTimeout); - await _waitForState( - controller, - (state) => state.reviews.single.status == 'applied', - description: 'applied fixture review projection', - ); - - final terminalId = await controller - .openTerminal(cwd: 'src') - .timeout(_operationTimeout); - expect(terminalId, 'terminal-fixture'); - expect(controller.state.activeTerminalId, terminalId); - expect(controller.state.terminals.single.running, isTrue); - controller.sendTerminalInput(terminalId, 'pwd\n'); - controller.resizeTerminal(terminalId, 100, 30); - controller.closeTerminal(terminalId); - expect(controller.state.terminals, isEmpty); - - final previewId = await controller - .launchPreview('http://127.0.0.1/fixture') - .timeout(_operationTimeout); - expect(controller.state.activePreviewId, previewId); - await controller.capturePreview(previewId).timeout(_operationTimeout); - expect(controller.state.activePreview?.capture, isNotEmpty); - await controller - .runPreviewInteraction(previewId, 'press', { - 'key': 'Enter', - }) - .timeout(_operationTimeout); - expect(controller.state.activePreviewId, previewId); - await controller - .navigatePreview(previewId, 'http://127.0.0.1/next') - .timeout(_operationTimeout); - expect(controller.state.activePreview?.url, 'http://127.0.0.1/next'); - await controller - .runPreviewAction(previewId, 'close') - .timeout(_operationTimeout); - expect(controller.state.previews, isEmpty); - - await controller.archiveSession(sessionId).timeout(_operationTimeout); - await _waitForState( - controller, - (state) => state.sessions - .where((session) => session.sessionId == sessionId) - .single - .archived, - description: 'archived session projection', - ); - await controller.restoreSession(sessionId).timeout(_operationTimeout); - await _waitForState( - controller, - (state) => !state.sessions - .where((session) => session.sessionId == sessionId) - .single - .archived, - description: 'restored session projection', - ); - - await controller.deleteSession(sessionId).timeout(_operationTimeout); - await _waitForState( - controller, - (state) => - state.sessions.every((session) => session.sessionId != sessionId), - description: 'deleted session projection', - ); - }, - ); -} - -Future _waitForState( - T4ClientController controller, - bool Function(T4ViewState state) predicate, { - required String description, -}) { - final completer = Completer(); - late void Function() listener; - - listener = () { - if (completer.isCompleted) return; - final state = controller.state; - if (!predicate(state)) return; - controller.removeListener(listener); - completer.complete(state); - }; - - controller.addListener(listener); - listener(); - return completer.future.timeout( - _operationTimeout, - onTimeout: () { - controller.removeListener(listener); - final state = controller.state; - throw TimeoutException( - 'Timed out waiting for $description; ' - 'phase=${state.connectionPhase.name}, ' - 'messages=${state.messages.map((message) => '${message.id}:${message.text}:${message.streaming}').toList()}, ' - 'error=${state.errorMessage}', - ); - }, - ); -} - -final class _MemoryDirectoryStore implements HostDirectoryStore { - HostDirectory directory = const HostDirectory.empty(); - - @override - Future load() async => directory; - - @override - Future save(HostDirectory directory) async { - this.directory = directory; - } -} - -final class _MemoryCredentialStore implements HostCredentialStore { - @override - Future delete(HostProfile profile) async {} - - @override - Future read(HostProfile profile) async => null; - - @override - Future write( - HostProfile profile, - DeviceCredentials credentials, - ) async {} -} - -final class _FixtureProcess { - _FixtureProcess._({ - required this._process, - required this.wsUrl, - required this._controlUrl, - required this._stdoutSubscription, - required this._stderrSubscription, - }); - - final Process _process; - final Uri wsUrl; - final Uri _controlUrl; - final StreamSubscription _stdoutSubscription; - final StreamSubscription _stderrSubscription; - final HttpClient _httpClient = HttpClient(); - bool _stopped = false; - - static Future<_FixtureProcess> start() async { - final process = await Process.start( - '../../node_modules/.bin/jiti', - const ['../../e2e/fixture-process.ts'], - environment: const {'T4_FIXTURE_SCENARIO': 'stream-v1'}, - ).timeout(_operationTimeout); - final stderr = StringBuffer(); - final ready = Completer<({Uri wsUrl, Uri controlUrl})>(); - - final stdoutSubscription = process.stdout - .transform(utf8.decoder) - .transform(const LineSplitter()) - .listen( - (line) { - const prefix = 'T4_FIXTURE_READY '; - if (ready.isCompleted || !line.startsWith(prefix)) return; - try { - final decoded = jsonDecode(line.substring(prefix.length)); - if (decoded is! Map) { - throw const FormatException( - 'fixture ready payload is not an object', - ); - } - final wsUrl = Uri.parse(decoded['wsUrl'] as String); - final controlUrl = Uri.parse(decoded['controlUrl'] as String); - if (wsUrl.scheme != 'ws' || wsUrl.host.isEmpty) { - throw FormatException('invalid fixture WebSocket URL: $wsUrl'); - } - if (controlUrl.scheme != 'http' || controlUrl.host.isEmpty) { - throw FormatException( - 'invalid fixture control URL: $controlUrl', - ); - } - ready.complete((wsUrl: wsUrl, controlUrl: controlUrl)); - } on Object catch (error, stackTrace) { - ready.completeError(error, stackTrace); - } - }, - onError: (Object error, StackTrace stackTrace) { - if (!ready.isCompleted) ready.completeError(error, stackTrace); - }, - ); - final stderrSubscription = process.stderr - .transform(utf8.decoder) - .listen(stderr.write); - - unawaited( - process.exitCode.then((exitCode) { - if (!ready.isCompleted) { - ready.completeError( - StateError( - 'Fixture process exited before ready with code $exitCode. ' - 'stderr: $stderr', - ), - ); - } - }), - ); - - try { - final endpoints = await ready.future.timeout( - _startupTimeout, - onTimeout: () => throw TimeoutException( - 'Fixture process did not become ready. stderr: $stderr', - _startupTimeout, - ), - ); - return _FixtureProcess._( - process: process, - wsUrl: endpoints.wsUrl, - controlUrl: endpoints.controlUrl, - stdoutSubscription: stdoutSubscription, - stderrSubscription: stderrSubscription, - ); - } on Object { - await _terminate(process); - await Future.wait([ - stdoutSubscription.cancel(), - stderrSubscription.cancel(), - ]).timeout(_operationTimeout); - rethrow; - } - } - - Future> advanceBy(int milliseconds) => _request( - 'POST', - _controlUrl - .resolve('/advance') - .replace(queryParameters: {'ms': '$milliseconds'}), - ); - - Future> disconnectClients() => - _request('POST', _controlUrl.resolve('/disconnect')); - - Future> state() => - _request('GET', _controlUrl.resolve('/state')); - - Future> _request(String method, Uri uri) async { - final request = await _httpClient - .openUrl(method, uri) - .timeout(_operationTimeout); - final response = await request.close().timeout(_operationTimeout); - final body = await response - .transform(utf8.decoder) - .join() - .timeout(_operationTimeout); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw HttpException( - 'Fixture control $method $uri failed with ${response.statusCode}: $body', - uri: uri, - ); - } - final decoded = jsonDecode(body); - if (decoded is! Map) { - throw FormatException('Fixture control response is not an object: $body'); - } - return decoded; - } - - Future stop() async { - if (_stopped) return; - _stopped = true; - _httpClient.close(force: true); - try { - await _terminate(_process); - } finally { - await Future.wait([ - _stdoutSubscription.cancel(), - _stderrSubscription.cancel(), - ]).timeout(_operationTimeout); - } - } - - static Future _terminate(Process process) async { - process.kill(ProcessSignal.sigterm); - await process.exitCode.timeout( - _operationTimeout, - onTimeout: () async { - process.kill(ProcessSignal.sigkill); - return process.exitCode.timeout(_operationTimeout); - }, - ); - } -} diff --git a/apps/flutter/test/client/t4_client_controller_test.dart b/apps/flutter/test/client/t4_client_controller_test.dart deleted file mode 100644 index 8acabb4a..00000000 --- a/apps/flutter/test/client/t4_client_controller_test.dart +++ /dev/null @@ -1,3267 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/client/app_state.dart'; -import 'package:t4code/src/client/t4_client_controller.dart'; -import 'package:t4code/src/client/transcript_tail_store.dart'; -import 'package:t4code/src/client/web_socket_connector.dart'; -import 'package:t4code/src/host/host_profile.dart'; -import 'package:t4code/src/host/app_preferences.dart'; -import 'package:t4code/src/protocol/protocol.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -void main() { - test( - 'loads credentials before connecting and sends authenticated hello', - () async { - final profile = _profile('alpha'); - final events = []; - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - events: events, - ); - final credentials = _MemoryCredentialStore(events: events) - ..values[profile.endpointKey] = DeviceCredentials( - deviceId: 'device-alpha', - deviceToken: _token, - ); - final connector = _FakeConnector(events: events); - final controller = _controller(directory, credentials, connector); - addTearDown(controller.dispose); - - await controller.initialize(); - await _flush(); - - expect(events.take(3), [ - 'load', - 'read:${profile.endpointKey}', - 'connect:${profile.webSocketUrl}', - ]); - final hello = connector.channels.single.sentJson.single; - expect(hello['type'], 'hello'); - expect(hello['capabilities'], { - 'client': t4RequestedCapabilities, - }); - expect(hello['authentication'], { - 'deviceId': 'device-alpha', - 'deviceToken': _token, - }); - }, - ); - - test( - 'probes before saving a host and then connects to the saved profile', - () async { - final events = []; - final directory = _MemoryDirectoryStore(events: events); - final credentials = _MemoryCredentialStore(events: events); - final connector = _FakeConnector(events: events); - final controller = _controller(directory, credentials, connector); - addTearDown(controller.dispose); - await controller.initialize(); - - await controller.addHost('alpha.example.ts.net'); - - final saveIndex = events.indexWhere((event) => event.startsWith('save:')); - final probeIndex = events.indexOf( - 'connect:wss://alpha.example.ts.net/v1/ws', - ); - final probeCloseIndex = events.indexOf('close:0'); - expect(probeIndex, greaterThanOrEqualTo(0)); - expect(probeCloseIndex, greaterThan(probeIndex)); - expect(saveIndex, greaterThan(probeCloseIndex)); - expect(connector.uris, hasLength(2)); - expect( - directory.directory.activeProfile?.webSocketUrl, - connector.uris.last, - ); - }, - ); - - test( - 'welcome bootstraps session.list then host.watch with index cursor', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final credentials = _MemoryCredentialStore(); - final connector = _FakeConnector(); - final controller = _controller(directory, credentials, connector); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - final hello = channel.sentJson.single; - final requestedFeatures = (hello['requestedFeatures']! as List) - .cast(); - expect(requestedFeatures, t4RequestedFeatures); - channel.emit(_welcome('host-alpha', features: requestedFeatures)); - await _flush(); - expect( - controller.state.grantedCapabilities, - containsAll(['sessions.read']), - ); - expect(controller.state.grantedFeatures, containsAll(requestedFeatures)); - final list = channel.sentJson.last; - expect(list, containsPair('command', 'session.list')); - - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResult( - 'host-alpha', - epoch: 'index-epoch', - seq: 7, - ), - ), - ); - await _flush(); - - final watch = channel.sentJson.lastWhere( - (frame) => frame['command'] == 'host.watch', - ); - expect(watch, containsPair('command', 'host.watch')); - expect(watch['args'], { - 'cursor': {'epoch': 'index-epoch', 'seq': 7}, - }); - expect(controller.state.sessions.single.sessionId, 'session-alpha'); - }, - ); - - test( - 'pages a cold transcript before attach and prepends older history separately', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final cache = InMemoryTranscriptTailStore(); - await cache.save( - hostId: 'host-alpha', - sessionId: 'session-alpha', - generation: 'cached-generation', - entries: [ - _durableMessage( - 'cached-entry', - hostId: 'host-alpha', - sessionId: 'session-alpha', - text: 'saved recent answer', - ), - ], - ); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - transcriptTailStore: cache, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - Map entry(String id, String text) => { - 'id': id, - 'parentId': null, - 'hostId': 'host-alpha', - 'sessionId': 'session-alpha', - 'kind': 'message', - 'timestamp': '2026-07-20T09:00:00.000Z', - 'data': {'role': 'assistant', 'text': text}, - }; - - channel.emit( - _welcome('host-alpha', features: const ['transcript.page']), - ); - await _flush(); - final list = channel.sentJson.last; - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResult('host-alpha'), - ), - ); - channel.emit(_sessions('host-alpha')); - await _until( - () => channel.sentJson.any( - (frame) => frame['command'] == 'transcript.page', - ), - ); - - expect(controller.state.messages.single.id, 'cached-entry'); - expect(controller.state.transcriptTailFromCache, isTrue); - expect( - channel.sentJson.where( - (frame) => frame['command'] == 'transcript.page', - ), - hasLength(1), - ); - - final page = channel.sentJson.lastWhere( - (frame) => frame['command'] == 'transcript.page', - ); - expect( - channel.sentJson.where( - (frame) => frame['command'] == 'transcript.page', - ), - hasLength(1), - ); - expect( - channel.sentJson.where((frame) => frame['command'] == 'session.attach'), - isEmpty, - ); - expect(page['command'], 'transcript.page'); - expect(page['args'], { - 'limit': 64, - 'maxBytes': 256 * 1024, - }); - expect( - channel.sentJson.where((frame) => frame['command'] == 'session.attach'), - isEmpty, - ); - channel.emit( - _response( - page, - command: 'transcript.page', - result: { - 'entries': [ - entry('page-1', 'paged one'), - entry('page-2', 'paged two'), - ], - 'nextCursor': 'older-page', - 'hasMore': true, - 'generation': 'page-generation', - }, - ), - ); - await _flush(); - - final attach = channel.sentJson.last; - expect(attach['command'], 'session.attach'); - expect(controller.state.messages.map((message) => message.id), [ - 'page-1', - 'page-2', - ]); - expect(controller.state.transcriptHistoryHasMore, isTrue); - expect(controller.state.transcriptTailFromCache, isFalse); - - channel.emit({ - ..._snapshot( - 'host-alpha', - 'session-alpha', - revision: 'revision-session-alpha', - ), - 'entries': [ - entry('page-2', 'paged two'), - entry('live-3', 'live three'), - ], - }); - await _flush(); - expect(controller.state.messages.map((message) => message.id), [ - 'page-1', - 'page-2', - 'live-3', - ]); - - final loadOlder = controller.loadEarlierTranscript(); - await _flush(); - final older = channel.sentJson.last; - expect(older['command'], 'transcript.page'); - expect(older['args'], { - 'before': 'older-page', - 'limit': 128, - 'maxBytes': 512 * 1024, - }); - channel.emit( - _response( - older, - command: 'transcript.page', - result: { - 'entries': [entry('older-0', 'older zero')], - 'hasMore': false, - 'generation': 'page-generation', - }, - ), - ); - await loadOlder; - - expect(controller.state.messages.map((message) => message.id), [ - 'older-0', - 'page-1', - 'page-2', - 'live-3', - ]); - expect(controller.state.transcriptHistoryHasMore, isFalse); - expect(controller.state.transcriptHistoryLoading, isFalse); - }, - ); - - test( - 'hydrates Fast availability after attaching an existing session', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit(_welcome('host-alpha')); - await _flush(); - final list = channel.sentJson.last; - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResult('host-alpha'), - ), - ); - await _flush(); - final attach = channel.sentJson.singleWhere( - (frame) => frame['command'] == 'session.attach', - ); - channel.emit( - _response( - attach, - command: 'session.attach', - result: const {}, - ), - ); - await _flush(); - - final stateGet = channel.sentJson.last; - expect(stateGet['command'], 'session.state.get'); - expect(stateGet['sessionId'], 'session-alpha'); - channel.emit( - _response( - stateGet, - command: 'session.state.get', - result: { - 'isStreaming': false, - 'isCompacting': false, - 'isPaused': false, - 'messageCount': 99, - 'queuedMessageCount': 0, - 'steeringMode': 'one-at-a-time', - 'followUpMode': 'one-at-a-time', - 'interruptMode': 'immediate', - 'model': { - 'id': 'gpt-5.6-sol', - 'provider': 'openai-codex', - 'displayName': 'GPT-5.6-Sol', - 'selector': 'openai-codex/gpt-5.6-sol:high', - 'role': 'default', - }, - 'thinking': 'high', - 'thinkingLevels': [ - 'low', - 'medium', - 'high', - 'xhigh', - 'max', - ], - 'thinkingSupported': true, - 'fast': false, - 'fastAvailable': true, - 'fastActive': false, - }, - ), - ); - await _flush(); - - expect(controller.state.composer.fastAvailable, isTrue); - expect(controller.state.composer.fastEnabled, isFalse); - expect(controller.state.composer.modelLabel, 'GPT-5.6-Sol'); - expect(controller.state.composer.thinking, 'high'); - }, - ); - - test( - 'keeps observer sessions ready when state hydration is locked', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit(_welcome('host-alpha')); - await _flush(); - final list = channel.sentJson.last; - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResult('host-alpha'), - ), - ); - await _flush(); - final attach = channel.sentJson.singleWhere( - (frame) => frame['command'] == 'session.attach', - ); - channel.emit( - _response( - attach, - command: 'session.attach', - result: const {}, - ), - ); - await _flush(); - final stateGet = channel.sentJson.last; - expect(stateGet['command'], 'session.state.get'); - - channel.emit( - _snapshot( - 'host-alpha', - 'session-alpha', - revision: 'revision-session-alpha', - ), - ); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'response', - 'requestId': stateGet['requestId'], - 'commandId': stateGet['commandId'], - 'hostId': stateGet['hostId'], - 'sessionId': stateGet['sessionId'], - 'command': 'session.state.get', - 'ok': false, - 'error': { - 'code': 'session_locked', - 'message': 'session is locked by another process', - }, - }); - await _flush(); - - expect(controller.state.connectionPhase, ConnectionPhase.ready); - expect(controller.state.errorMessage, isNull); - expect(controller.state.composer.fastAvailable, isFalse); - }, - ); - - test( - 'composer uses official OMP operation capabilities and keeps terminal-only commands disabled', - () async { - final profile = _profile('alpha'); - final connector = _FakeConnector(); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit( - _welcome( - 'host-alpha', - capabilities: const [ - 'sessions.read', - 'sessions.prompt', - 'catalog.read', - ], - features: const ['catalog.metadata'], - ), - ); - await _flush(); - final list = channel.sentJson.firstWhere( - (frame) => frame['command'] == 'session.list', - ); - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResult('host-alpha'), - ), - ); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'catalog', - 'hostId': 'host-alpha', - 'revision': 'catalog-operation-capabilities', - 'items': [ - { - 'id': 'command:session.cancel', - 'kind': 'command', - 'name': 'session.cancel', - }, - ], - 'operations': [ - { - 'operationId': 'session.prompt', - 'label': 'Prompt', - 'execution': 'typed', - 'supported': true, - }, - { - 'operationId': 'slash.compact', - 'label': '/compact', - 'description': 'Compact the active conversation', - 'execution': 'headless', - 'supported': true, - 'metadata': { - 'aliases': ['compress'], - }, - }, - { - 'operationId': 'slash.plan', - 'label': '/plan', - 'description': 'Toggle plan mode', - 'execution': 'terminal-only', - 'supported': false, - 'disabledReason': { - 'code': 'terminal_only', - 'message': '/plan requires the OMP terminal interface.', - }, - }, - ], - }); - await _flush(); - - final commands = controller.state.composer.slashCommands; - expect(commands.map((command) => command.name), [ - '/compact', - '/plan', - ]); - expect(commands.first.aliases, ['/compress']); - expect(commands.first.disabledReason, isNull); - expect( - commands.last.disabledReason, - '/plan requires the OMP terminal interface.', - ); - - channel.emit({ - 'v': 'omp-app/1', - 'type': 'catalog', - 'hostId': 'host-alpha', - 'revision': 'catalog-authoritative-empty', - 'items': [ - { - 'id': 'command:legacy-compact', - 'kind': 'command', - 'name': '/compact', - }, - { - 'id': 'command:session.cancel', - 'kind': 'command', - 'name': 'session.cancel', - }, - ], - 'operations': [], - }); - await _flush(); - expect(controller.state.composer.slashCommands, isEmpty); - }, - ); - - test( - 'read-only catalog clients see official headless commands disabled', - () async { - final profile = _profile('alpha'); - final connector = _FakeConnector(); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit( - _welcome( - 'host-alpha', - capabilities: const ['sessions.read', 'catalog.read'], - features: const ['catalog.metadata'], - ), - ); - await _flush(); - final list = channel.sentJson.firstWhere( - (frame) => frame['command'] == 'session.list', - ); - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResult('host-alpha'), - ), - ); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'catalog', - 'hostId': 'host-alpha', - 'revision': 'catalog-read-only', - 'items': [], - 'operations': [ - { - 'operationId': 'slash.compact', - 'label': '/compact', - 'execution': 'headless', - 'supported': true, - }, - ], - }); - await _flush(); - - expect( - controller.state.composer.slashCommands.single.disabledReason, - 'Not granted on this host', - ); - }, - ); - - test('command ids are unique across controller restarts', () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final firstConnector = _FakeConnector(); - final secondConnector = _FakeConnector(); - final first = _controller( - directory, - _MemoryCredentialStore(), - firstConnector, - ); - final second = _controller( - directory, - _MemoryCredentialStore(), - secondConnector, - ); - addTearDown(first.dispose); - addTearDown(second.dispose); - - await first.initialize(); - await second.initialize(); - final firstChannel = firstConnector.channels.single; - final secondChannel = secondConnector.channels.single; - firstChannel.emit(_welcome('host-alpha')); - secondChannel.emit(_welcome('host-alpha')); - await _flush(); - - expect( - firstChannel.sentJson.last['commandId'], - isNot(secondChannel.sentJson.last['commandId']), - ); - }); - - test( - 'duplicate inventories share an attach while a new selection stays isolated', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit(_welcome('host-alpha')); - await _flush(); - final list = channel.sentJson.last; - final inventory = _sessionListResultFor('host-alpha', const [ - 'session-alpha', - 'session-beta', - ]); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'sessions', - 'hostId': 'host-alpha', - ...inventory, - }); - await _flush(); - final firstAttach = channel.sentJson.singleWhere( - (frame) => - frame['command'] == 'session.attach' && - frame['sessionId'] == 'session-alpha', - ); - - channel.emit(_response(list, command: 'session.list', result: inventory)); - await _flush(); - expect( - channel.sentJson.where( - (frame) => - frame['command'] == 'session.attach' && - frame['sessionId'] == 'session-alpha', - ), - hasLength(1), - ); - - await controller.selectSession('session-beta'); - expect( - channel.sentJson.where((frame) => frame['command'] == 'session.attach'), - hasLength(2), - ); - expect(channel.sentJson.last['sessionId'], 'session-beta'); - expect(controller.state.connectionPhase, ConnectionPhase.synchronizing); - - channel.emit( - _response( - firstAttach, - command: 'session.attach', - result: const {}, - ), - ); - await _flush(); - expect(controller.state.selectedSessionId, 'session-beta'); - expect(controller.state.connectionPhase, ConnectionPhase.synchronizing); - }, - ); - - test( - 'ignores transcript frames from a previously selected session', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit(_welcome('host-alpha')); - await _flush(); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'sessions', - 'hostId': 'host-alpha', - ..._sessionListResultFor('host-alpha', const [ - 'session-alpha', - 'session-beta', - ]), - }); - await _flush(); - channel.emit( - _snapshot('host-alpha', 'session-alpha', revision: 'revision-alpha'), - ); - await _flush(); - - await controller.selectSession('session-beta'); - channel.emit( - _snapshot('host-alpha', 'session-beta', revision: 'revision-beta'), - ); - channel.emit( - _transcriptEvent( - 'host-alpha', - 'session-beta', - seq: 1, - event: const { - 'type': 'message.update', - 'entryId': 'live-message', - 'role': 'assistant', - 'text': 'Beta response', - }, - ), - ); - await _flush(); - expect(controller.state.messages.single.text, 'Beta response'); - - channel.emit( - _transcriptMessageEntry( - 'host-alpha', - 'session-alpha', - seq: 1, - entryId: 'stale-durable-system', - role: 'system', - text: 'Stale durable system prompt', - ), - ); - channel.emit( - _transcriptEvent( - 'host-alpha', - 'session-alpha', - seq: 2, - event: const { - 'type': 'message.update', - 'entryId': 'live-message', - 'role': 'system', - 'text': 'Stale system prompt', - }, - ), - ); - channel.emit( - _transcriptEvent( - 'host-alpha', - 'session-alpha', - seq: 3, - event: const { - 'type': 'tool.start', - 'callId': 'alpha-tool', - 'tool': 'bash', - }, - ), - ); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'gap', - 'hostId': 'host-alpha', - 'sessionId': 'session-alpha', - 'from': {'epoch': 'transcript', 'seq': 4}, - 'to': {'epoch': 'transcript', 'seq': 5}, - 'reason': 'stale alpha gap', - }); - await _flush(); - - expect(controller.state.selectedSessionId, 'session-beta'); - expect(controller.state.connectionPhase, ConnectionPhase.ready); - expect(controller.state.messages, hasLength(1)); - expect(controller.state.messages.single.role, MessageRole.assistant); - expect(controller.state.messages.single.text, 'Beta response'); - }, - ); - - test( - 'gap recovery requests a cursorless snapshot before returning ready', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit(_welcome('host-alpha')); - await _flush(); - final list = channel.sentJson.last; - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResult('host-alpha'), - ), - ); - await _flush(); - final initialAttach = channel.sentJson.singleWhere( - (frame) => frame['command'] == 'session.attach', - ); - channel.emit( - _response( - initialAttach, - command: 'session.attach', - result: const {}, - ), - ); - channel.emit( - _snapshot('host-alpha', 'session-alpha', revision: 'revision-initial'), - ); - await _flush(); - expect(controller.state.connectionPhase, ConnectionPhase.ready); - - channel.emit({ - 'v': 'omp-app/1', - 'type': 'gap', - 'hostId': 'host-alpha', - 'sessionId': 'session-alpha', - 'from': {'epoch': 'transcript', 'seq': 1}, - 'to': {'epoch': 'transcript', 'seq': 5}, - 'reason': 'rebase_budget_exceeded', - }); - await _flush(); - - final recoveryAttach = channel.sentJson.lastWhere( - (frame) => - frame['command'] == 'session.attach' && - frame['requestId'] != initialAttach['requestId'], - ); - expect(recoveryAttach['args'], isEmpty); - expect(controller.state.connectionPhase, ConnectionPhase.synchronizing); - expect( - controller.state.errorMessage, - 'Recovering transcript continuity…', - ); - - channel.emit( - _response( - recoveryAttach, - command: 'session.attach', - result: const {}, - ), - ); - await _flush(); - expect(controller.state.connectionPhase, ConnectionPhase.synchronizing); - channel.emit({ - ..._snapshot( - 'host-alpha', - 'session-alpha', - revision: 'revision-recovered', - ), - 'cursor': {'epoch': 'transcript', 'seq': 5}, - }); - await _flush(); - - expect(controller.state.connectionPhase, ConnectionPhase.ready); - expect(controller.state.errorMessage, isNull); - }, - ); - - test('returning to a session requests its complete transcript', () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit(_welcome('host-alpha')); - await _flush(); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'sessions', - 'hostId': 'host-alpha', - ..._sessionListResultFor('host-alpha', const [ - 'session-alpha', - 'session-beta', - ]), - }); - await _flush(); - final firstSessionId = controller.state.selectedSessionId!; - final secondSessionId = controller.state.sessions - .firstWhere((session) => session.sessionId != firstSessionId) - .sessionId; - final firstAttach = channel.sentJson.last; - channel.emit( - _response( - firstAttach, - command: 'session.attach', - result: const {}, - ), - ); - channel.emit( - _snapshot('host-alpha', firstSessionId, revision: 'revision-first'), - ); - await _flush(); - - await controller.selectSession(secondSessionId); - expect(controller.state.selectedSessionId, secondSessionId); - final secondAttach = channel.sentJson.last; - channel.emit( - _response( - secondAttach, - command: 'session.attach', - result: const {}, - ), - ); - channel.emit( - _snapshot('host-alpha', secondSessionId, revision: 'revision-second'), - ); - await _flush(); - expect(controller.state.selectedSessionId, secondSessionId); - expect( - controller.state.sessions.map((session) => session.sessionId), - contains(firstSessionId), - ); - await controller.selectSession(firstSessionId); - expect(controller.state.selectedSessionId, firstSessionId); - - final attach = channel.sentJson.last; - expect(attach, containsPair('command', 'session.attach')); - expect(attach, containsPair('sessionId', firstSessionId)); - expect(attach['args'], isEmpty); - }); - - test( - 'switching hosts clears projections without deleting credentials', - () async { - final alpha = _profile('alpha'); - final beta = _profile('beta'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(beta).upsert(alpha), - ); - final credentials = _MemoryCredentialStore(); - final connector = _FakeConnector(); - final controller = _controller(directory, credentials, connector); - addTearDown(controller.dispose); - await controller.initialize(); - final first = connector.channels.single; - first.emit(_welcome('host-alpha')); - first.emit(_sessions('host-alpha')); - await _flush(); - expect(controller.state.sessions, isNotEmpty); - - await controller.activateHost(beta.endpointKey); - - expect( - controller.state.hostDirectory.activeEndpointKey, - beta.endpointKey, - ); - expect(controller.state.sessions, isEmpty); - expect(controller.state.messages, isEmpty); - expect(controller.state.selectedSessionId, isNull); - expect(credentials.deleted, isEmpty); - expect(connector.uris.last, beta.webSocketUrl); - }, - ); - - testWidgets('failed handshake close cannot stall automatic reconnect', ( - tester, - ) async { - final readyGate = Completer(); - final connector = _FakeConnector( - readyGate: readyGate, - hangFirstClose: true, - ); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(_profile('alpha')), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - final initialization = controller.initialize(); - await tester.pump(); - expect(connector.channels, hasLength(1)); - - readyGate.completeError(StateError('server unavailable')); - await initialization; - expect(controller.state.connectionPhase, ConnectionPhase.retrying); - await tester.pump(const Duration(milliseconds: 600)); - - expect(connector.channels.length, greaterThan(1)); - await controller.disconnect(); - await tester.pump(const Duration(seconds: 1)); - }); - - test('deliberate disconnect cancels an automatic reconnect', () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final first = connector.channels.single; - first.fail(StateError('network lost')); - await _flush(); - expect(controller.state.connectionPhase, ConnectionPhase.retrying); - - await controller.disconnect(); - await Future.delayed(const Duration(milliseconds: 600)); - - expect(controller.state.connectionPhase, ConnectionPhase.disconnected); - expect(connector.channels, hasLength(1)); - await controller.connect(); - expect(connector.channels, hasLength(2)); - }); - - test('cancelling a pending host probe never saves it', () async { - final readyGate = Completer(); - final directory = _MemoryDirectoryStore(); - final connector = _FakeConnector(readyGate: readyGate); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - - final adding = controller.addHost('alpha.example.ts.net'); - await _until(() => connector.channels.isNotEmpty); - expect(controller.state.hostOperationPending, isTrue); - controller.cancelHostProbe(); - readyGate.complete(); - await adding; - - expect(controller.state.hostOperationPending, isFalse); - expect(directory.saved, isEmpty); - expect(directory.directory.profiles, isEmpty); - }); - - test('credential deletion failure rolls host metadata back', () async { - final alpha = _profile('alpha'); - final beta = _profile('beta'); - final original = const HostDirectory.empty().upsert(beta).upsert(alpha); - final events = []; - final directory = _MemoryDirectoryStore( - directory: original, - events: events, - ); - final credentials = _MemoryCredentialStore(events: events) - ..deleteError = StateError('vault unavailable'); - final connector = _FakeConnector(); - final controller = _controller(directory, credentials, connector); - addTearDown(controller.dispose); - await controller.initialize(); - - await expectLater( - controller.removeHost(beta.endpointKey), - throwsA(isA()), - ); - - expect(directory.saved, hasLength(2)); - expect(directory.saved.first.profiles, isNot(contains(beta))); - expect(directory.saved.last.activeEndpointKey, original.activeEndpointKey); - final firstSave = events.indexWhere((event) => event.startsWith('save:')); - final deletion = events.indexOf('delete:${beta.endpointKey}'); - final rollbackSave = events.lastIndexWhere( - (event) => event.startsWith('save:'), - ); - expect(deletion, greaterThan(firstSave)); - expect(rollbackSave, greaterThan(deletion)); - expect( - directory.directory.profiles.map((item) => item.endpointKey), - original.profiles.map((item) => item.endpointKey), - ); - expect(controller.state.hostDirectory.profiles, original.profiles); - expect(controller.state.errorMessage, contains('host was restored')); - }); - - test( - 'pairing persists scoped credentials and reconnects authenticated', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final credentials = _MemoryCredentialStore(); - final connector = _FakeConnector(); - final controller = _controller(directory, credentials, connector); - addTearDown(controller.dispose); - await controller.initialize(); - final first = connector.channels.single; - first.emit( - _welcome( - 'host-alpha', - authentication: 'pairing-required', - capabilities: const [], - ), - ); - await _flush(); - - await controller.pairHost('12345'); - expect(controller.state.errorMessage, contains('six-digit')); - final sentBeforePair = first.sent.length; - await controller.pairHost('123456'); - final pair = first.sentJson.last; - expect(first.sent, hasLength(sentBeforePair + 1)); - expect(pair['type'], 'pair.start'); - expect(pair['code'], '123456'); - expect(pair['deviceId'], matches(RegExp(r'^[A-Za-z0-9_-]{32}$'))); - - first.emit({ - 'v': 'omp-app/1', - 'type': 'pair.ok', - 'requestId': pair['requestId'], - 'pairingId': 'pair-alpha', - 'deviceId': pair['deviceId'], - 'deviceName': pair['deviceName'], - 'platform': pair['platform'], - 'requestedCapabilities': pair['requestedCapabilities'], - 'grantedCapabilities': pair['requestedCapabilities'], - 'deviceToken': _token, - 'expiresAt': DateTime.now() - .toUtc() - .add(const Duration(hours: 1)) - .toIso8601String(), - }); - await _until(() => connector.channels.length == 2); - - final saved = credentials.values[profile.endpointKey]; - expect(saved?.deviceId, pair['deviceId']); - expect(saved?.deviceToken, _token); - final authenticatedHello = connector.channels.last.sentJson.single; - expect(authenticatedHello['authentication'], { - 'deviceId': pair['deviceId'], - 'deviceToken': _token, - }); - }, - ); - - test( - 'stale credential read cannot connect the previous active host', - () async { - final alpha = _profile('alpha'); - final beta = _profile('beta'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(beta).upsert(alpha), - ); - final delayedRead = Completer(); - final credentials = _MemoryCredentialStore() - ..delayedReads[alpha.endpointKey] = delayedRead; - final connector = _FakeConnector(); - final controller = _controller(directory, credentials, connector); - addTearDown(controller.dispose); - final initializing = controller.initialize(); - await _until(() => credentials.readProfiles.contains(alpha.endpointKey)); - - await controller.activateHost(beta.endpointKey); - delayedRead.complete(null); - await initializing; - await _flush(); - - expect(connector.uris, [beta.webSocketUrl]); - expect( - controller.state.hostDirectory.activeEndpointKey, - beta.endpointKey, - ); - }, - ); - test('uploads prompt images and reads transcript image chunks', () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit( - _welcome( - 'host-alpha', - capabilities: const [ - 'sessions.read', - 'sessions.prompt', - 'sessions.control', - 'sessions.manage', - ], - ), - ); - await _flush(); - final list = channel.sentJson.last; - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResultFor('host-alpha', const [ - 'session-alpha', - ]), - ), - ); - await _flush(); - channel.emit( - _snapshot( - 'host-alpha', - 'session-alpha', - revision: 'revision-session-alpha-2', - ), - ); - await _flush(); - expect(controller.state.connectionPhase, ConnectionPhase.ready); - - final sending = controller.submitPrompt( - 'Inspect this image', - images: [ - PromptImageAttachment( - id: 'local-image', - name: 'fixture.png', - mimeType: 'image/png', - bytes: Uint8List.fromList([1, 2, 3]), - ), - ], - ); - await _flush(); - final begin = channel.sentJson.last; - expect(begin['command'], 'session.image.begin'); - expect(begin['args'], { - 'mimeType': 'image/png', - 'size': 3, - 'sha256': isA(), - }); - channel.emit( - _response( - begin, - command: 'session.image.begin', - result: { - 'imageId': 'uploaded-image', - 'chunkBytes': 2, - 'expiresAt': '2030-01-01T00:00:00.000Z', - }, - ), - ); - await _flush(); - - final firstChunk = channel.sentJson.last; - expect(firstChunk['command'], 'session.image.chunk'); - expect(firstChunk['args'], { - 'imageId': 'uploaded-image', - 'offset': 0, - 'content': 'AQI=', - }); - channel.emit( - _response( - firstChunk, - command: 'session.image.chunk', - result: { - 'imageId': 'uploaded-image', - 'received': 2, - 'complete': false, - }, - ), - ); - await _flush(); - - final finalChunk = channel.sentJson.last; - expect(finalChunk['command'], 'session.image.chunk'); - expect(finalChunk['args'], { - 'imageId': 'uploaded-image', - 'offset': 2, - 'content': 'Aw==', - }); - channel.emit( - _response( - finalChunk, - command: 'session.image.chunk', - result: { - 'imageId': 'uploaded-image', - 'received': 3, - 'complete': true, - }, - ), - ); - await _flush(); - - final prompt = channel.sentJson.last; - expect(prompt['command'], 'session.prompt'); - expect(prompt['expectedRevision'], 'revision-session-alpha-2'); - expect(prompt['args'], { - 'message': 'Inspect this image', - 'images': [ - {'imageId': 'uploaded-image'}, - ], - }); - channel.emit( - _response( - prompt, - command: 'session.prompt', - result: {'accepted': true}, - ), - ); - expect(await sending, isTrue); - - const contentSha256 = - '039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81'; - final reading = controller.readTranscriptImage( - 'entry-image', - TranscriptImageMetadata(sha256: contentSha256, mimeType: 'image/png'), - ); - await _flush(); - final firstRead = channel.sentJson.last; - expect(firstRead['command'], 'session.image.read'); - expect(firstRead['args'], { - 'entryId': 'entry-image', - 'sha256': contentSha256, - 'offset': 0, - }); - channel.emit( - _response( - firstRead, - command: 'session.image.read', - result: { - 'sha256': contentSha256, - 'mimeType': 'image/png', - 'size': 3, - 'offset': 0, - 'nextOffset': 2, - 'complete': false, - 'content': 'AQI=', - }, - ), - ); - await _flush(); - final finalRead = channel.sentJson.last; - expect(finalRead['args'], { - 'entryId': 'entry-image', - 'sha256': contentSha256, - 'offset': 2, - }); - channel.emit( - _response( - finalRead, - command: 'session.image.read', - result: { - 'sha256': contentSha256, - 'mimeType': 'image/png', - 'size': 3, - 'offset': 2, - 'nextOffset': 3, - 'complete': true, - 'content': 'Aw==', - }, - ), - ); - expect(await reading, [1, 2, 3]); - - final queueing = controller.queuePrompt('Follow up later'); - await _flush(); - final followUp = channel.sentJson.last; - expect(followUp['command'], 'session.followUp'); - expect(followUp['args'], {'message': 'Follow up later'}); - channel.emit( - _response( - followUp, - command: 'session.followUp', - result: {'accepted': true}, - ), - ); - expect(await queueing, isTrue); - - final settingModel = controller.setSessionModel('fixture/model-pro'); - await _flush(); - final model = channel.sentJson.last; - expect(model['command'], 'session.model.set'); - expect(model['args'], { - 'selector': 'fixture/model-pro', - 'persistence': 'session', - }); - channel.emit( - _response( - model, - command: 'session.model.set', - result: {'updated': true}, - ), - ); - await settingModel; - - final settingThinking = controller.setSessionThinking('high'); - await _flush(); - final thinking = channel.sentJson.last; - expect(thinking['command'], 'session.thinking.set'); - expect(thinking['args'], {'level': 'high'}); - channel.emit( - _response( - thinking, - command: 'session.thinking.set', - result: {'updated': true}, - ), - ); - await settingThinking; - - final settingFast = controller.setSessionFast(true); - await _flush(); - final fast = channel.sentJson.last; - expect(fast['command'], 'session.fast.set'); - expect(fast['args'], {'enabled': true}); - channel.emit( - _response( - fast, - command: 'session.fast.set', - result: {'updated': true}, - ), - ); - await settingFast; - }); - - test( - 'creates and manages sessions through index deltas and confirmations', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit( - _welcome( - 'host-alpha', - capabilities: const [ - 'sessions.read', - 'sessions.control', - 'sessions.manage', - ], - features: const ['host.watch'], - ), - ); - await _flush(); - final list = channel.sentJson.last; - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResultFor('host-alpha', const [ - 'session-alpha', - 'session-beta', - ]), - ), - ); - await _flush(); - channel.emit( - _snapshot( - 'host-alpha', - 'session-alpha', - revision: 'revision-session-alpha-2', - ), - ); - await _flush(); - expect(controller.state.connectionPhase, ConnectionPhase.ready); - expect(controller.state.sessions.first.projectName, 'Project Alpha'); - - final creating = controller.createSession( - 'project-session-alpha', - title: 'New investigation', - ); - await _flush(); - final create = channel.sentJson.last; - expect(create['command'], 'session.create'); - expect(create['args'], { - 'projectId': 'project-session-alpha', - 'title': 'New investigation', - }); - final createdRef = _sessionRef( - 'host-alpha', - 'session-created', - projectId: 'project-session-alpha', - projectName: 'Project Alpha', - revision: 'revision-created', - title: 'New investigation', - ); - channel.emit( - _sessionDelta( - 'host-alpha', - 'session-created', - revision: 'revision-created', - upsert: createdRef, - seq: 2, - ), - ); - channel.emit( - _response( - create, - command: 'session.create', - result: {'session': createdRef}, - ), - ); - await creating; - expect(controller.state.selectedSessionId, 'session-created'); - expect(controller.state.selectedSession?.title, 'New investigation'); - expect(channel.sentJson.last, containsPair('command', 'session.attach')); - channel.emit( - _snapshot( - 'host-alpha', - 'session-created', - revision: 'revision-created-2', - ), - ); - await _flush(); - - final renaming = controller.renameSession( - 'session-created', - 'Renamed investigation', - ); - await _flush(); - final rename = channel.sentJson.last; - expect(rename['command'], 'session.rename'); - expect(rename['expectedRevision'], 'revision-created-2'); - final renamedRef = _sessionRef( - 'host-alpha', - 'session-created', - projectId: 'project-session-alpha', - projectName: 'Project Alpha', - revision: 'revision-created-3', - title: 'Renamed investigation', - ); - channel.emit( - _sessionDelta( - 'host-alpha', - 'session-created', - revision: 'revision-created-3', - upsert: renamedRef, - seq: 3, - ), - ); - channel.emit( - _response( - rename, - command: 'session.rename', - result: {'renamed': true}, - ), - ); - await renaming; - expect(controller.state.selectedSession?.title, 'Renamed investigation'); - - final archiving = controller.archiveSession('session-created'); - await _flush(); - final archive = channel.sentJson.last; - final archivedRef = { - ...renamedRef, - 'revision': 'revision-created-4', - 'archivedAt': '2026-07-19T01:00:00.000Z', - }; - channel.emit( - _sessionDelta( - 'host-alpha', - 'session-created', - revision: 'revision-created-4', - upsert: archivedRef, - seq: 4, - ), - ); - channel.emit( - _response( - archive, - command: 'session.archive', - result: {'archived': true}, - ), - ); - await archiving; - expect( - controller.state.sessions - .singleWhere((session) => session.sessionId == 'session-created') - .archived, - isTrue, - ); - expect(controller.state.selectedSessionId, isNot('session-created')); - channel.emit( - _snapshot( - 'host-alpha', - controller.state.selectedSessionId!, - revision: 'revision-replacement', - ), - ); - await _flush(); - - final restoring = controller.restoreSession('session-created'); - await _flush(); - final restore = channel.sentJson.last; - final restoredRef = { - ...renamedRef, - 'revision': 'revision-created-5', - }; - channel.emit( - _sessionDelta( - 'host-alpha', - 'session-created', - revision: 'revision-created-5', - upsert: restoredRef, - seq: 5, - ), - ); - channel.emit( - _response( - restore, - command: 'session.restore', - result: {'restored': true}, - ), - ); - await restoring; - - final terminating = controller.terminateSession('session-created'); - await _flush(); - final terminate = channel.sentJson.last; - expect(terminate['command'], 'session.close'); - channel.emit(_confirmation(terminate, 'confirmation-close')); - await _flush(); - final closeConfirmation = channel.sentJson.last; - expect(closeConfirmation, containsPair('type', 'confirm')); - expect(closeConfirmation, containsPair('decision', 'approve')); - final closedRef = { - ...restoredRef, - 'revision': 'revision-created-6', - 'status': 'closed', - }; - channel.emit( - _sessionDelta( - 'host-alpha', - 'session-created', - revision: 'revision-created-6', - upsert: closedRef, - seq: 6, - ), - ); - channel.emit( - _response( - terminate, - command: 'session.close', - result: { - 'closed': true, - 'sessionId': 'session-created', - }, - ), - ); - await terminating; - - final deleting = controller.deleteSession('session-created'); - await _flush(); - final delete = channel.sentJson.last; - expect(delete['command'], 'session.delete'); - channel.emit(_confirmation(delete, 'confirmation-delete')); - await _flush(); - channel.emit( - _sessionDelta( - 'host-alpha', - 'session-created', - revision: 'revision-created-7', - remove: 'session-created', - seq: 7, - ), - ); - channel.emit( - _response( - delete, - command: 'session.delete', - result: {'deleted': true}, - ), - ); - await deleting; - expect( - controller.state.sessions.any( - (session) => session.sessionId == 'session-created', - ), - isFalse, - ); - expect(controller.state.sessionOperationPending, isFalse); - }, - ); - - test('projects attention, responds, retries, and tracks agents', () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - - channel.emit( - _welcome( - 'host-alpha', - capabilities: const [ - 'sessions.read', - 'sessions.prompt', - 'sessions.control', - ], - features: const ['prompt.lease'], - ), - ); - await _flush(); - final list = channel.sentJson.last; - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResultFor( - 'host-alpha', - const ['session-alpha'], - attention: { - 'pending': [ - { - 'kind': 'question', - 'id': 'question-1', - 'question': 'Which environment?', - 'options': [ - {'id': 'staging', 'label': 'Staging'}, - {'id': 'production', 'label': 'Production'}, - ], - 'allowText': true, - 'requestedAt': '2026-07-19T00:00:00.000Z', - }, - ], - 'pendingCount': 1, - 'truncated': false, - 'latestOutcome': { - 'id': 'outcome-1', - 'kind': 'failed', - 'at': '2026-07-19T00:01:00.000Z', - 'summary': 'The last turn failed.', - }, - }, - ), - ), - ); - await _flush(); - channel.emit( - _snapshot( - 'host-alpha', - 'session-alpha', - revision: 'revision-session-alpha', - ), - ); - await _flush(); - - expect(controller.state.urgentAttentionCount, 1); - expect(controller.state.attentionItems, hasLength(2)); - final question = controller.state.attentionItems.firstWhere( - (item) => item.kind == AttentionKind.question, - ); - expect(question.choices.map((choice) => choice.id), [ - 'staging', - 'production', - ]); - - final responding = controller.respondToAttention( - question, - const AttentionResponse( - decision: AttentionDecision.approve, - optionIds: ['staging'], - ), - ); - await _flush(); - final acquisition = channel.sentJson.last; - expect(acquisition['command'], 'prompt.lease.acquire'); - expect(acquisition['args'], { - 'ownerId': 't4-code-flutter', - }); - channel.emit( - _response( - acquisition, - command: 'prompt.lease.acquire', - result: { - 'accepted': true, - 'leaseId': 'prompt-lease-1', - 'expiresAt': '2030-01-01T00:00:00.000Z', - }, - ), - ); - await _flush(); - final responseCommand = channel.sentJson.last; - expect(responseCommand['command'], 'session.ui.respond'); - expect(responseCommand['expectedRevision'], 'revision-session-alpha'); - expect(responseCommand['args'], { - 'requestId': 'question-1', - 'value': 'staging', - 'leaseId': 'prompt-lease-1', - }); - channel.emit( - _response( - responseCommand, - command: 'session.ui.respond', - result: {'accepted': true}, - ), - ); - expect(await responding, isTrue); - - final retrying = controller.retrySession('session-alpha'); - await _flush(); - final retry = channel.sentJson.last; - expect(retry['command'], 'session.retry'); - channel.emit( - _response( - retry, - command: 'session.retry', - result: {'retried': true}, - ), - ); - await retrying; - - channel.emit({ - 'v': 'omp-app/1', - 'type': 'agent.progress', - 'hostId': 'host-alpha', - 'sessionId': 'session-alpha', - 'agentId': 'agent-1', - 'cursor': {'epoch': 'agent', 'seq': 1}, - 'revision': 'revision-session-alpha', - 'progress': 0.5, - 'detail': {'title': 'Reviewing changes'}, - }); - await _flush(); - expect(controller.state.agentActivities.single.label, 'Reviewing changes'); - expect(controller.state.agentActivities.single.progress, 0.5); - - channel.emit( - _confirmation({ - 'commandId': 'remote-command', - 'hostId': 'host-alpha', - 'sessionId': 'session-alpha', - 'expectedRevision': 'revision-session-alpha', - 'command': 'files.write', - }, 'confirmation-remote'), - ); - await _flush(); - final confirmation = controller.state.attentionItems.firstWhere( - (item) => item.kind == AttentionKind.confirmation, - ); - expect(controller.state.urgentAttentionCount, 2); - expect( - await controller.respondToAttention( - confirmation, - const AttentionResponse(decision: AttentionDecision.deny), - ), - isTrue, - ); - expect(channel.sentJson.last, containsPair('type', 'confirm')); - expect(channel.sentJson.last, containsPair('decision', 'deny')); - }); - - test( - 'searches project files and runs pause resume and compact controls', - () async { - final profile = _profile('alpha'); - final connector = _FakeConnector(); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - channel.emit( - _welcome( - 'host-alpha', - capabilities: t4RequestedCapabilities, - features: const ['files.search'], - ), - ); - await _flush(); - final list = channel.sentJson.last; - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResult('host-alpha'), - ), - ); - await _flush(); - channel.emit( - _snapshot( - 'host-alpha', - 'session-alpha', - revision: 'revision-session-alpha', - ), - ); - await _flush(); - - final searching = controller.searchProjectFiles(' main ', limit: 5); - await _flush(); - final search = channel.sentJson.last; - expect(search['command'], 'files.search'); - expect(search['args'], {'query': 'main', 'limit': 5}); - channel.emit( - _response( - search, - command: 'files.search', - result: { - 'matches': [ - {'path': 'lib/main.dart'}, - {'path': 'test/main_test.dart'}, - ], - 'truncated': true, - }, - ), - ); - final searchResult = await searching; - expect(searchResult.paths, [ - 'lib/main.dart', - 'test/main_test.dart', - ]); - expect(searchResult.truncated, isTrue); - - final pausing = controller.pauseSession(); - await _flush(); - final pause = channel.sentJson.last; - expect(pause['command'], 'session.pause'); - channel.emit( - _response( - pause, - command: 'session.pause', - result: {'paused': true, 'changed': true}, - ), - ); - await pausing; - expect(controller.state.composer.isPaused, isTrue); - - final resuming = controller.resumeSession(); - await _flush(); - final resume = channel.sentJson.last; - expect(resume['command'], 'session.resume'); - channel.emit( - _response( - resume, - command: 'session.resume', - result: {'resumed': true, 'paused': false}, - ), - ); - await resuming; - expect(controller.state.composer.isPaused, isFalse); - - final compacting = controller.compactSession( - instructions: 'Keep the current implementation plan.', - ); - await _flush(); - final compact = channel.sentJson.last; - expect(compact['command'], 'session.compact'); - expect(compact['args'], { - 'instructions': 'Keep the current implementation plan.', - }); - channel.emit( - _response( - compact, - command: 'session.compact', - result: {'compacted': true}, - ), - ); - await compacting; - }, - ); - - test( - 'projects terminal, files, audit, and preview developer state', - () async { - final profile = _profile('alpha'); - final connector = _FakeConnector(); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - channel.emit( - _welcome('host-alpha', capabilities: t4RequestedCapabilities), - ); - await _flush(); - final list = channel.sentJson.last; - channel.emit( - _response( - list, - command: 'session.list', - result: _sessionListResultFor('host-alpha', const [ - 'session-alpha', - ]), - ), - ); - await _flush(); - channel.emit( - _snapshot( - 'host-alpha', - 'session-alpha', - revision: 'revision-session-alpha', - ), - ); - await _flush(); - - final opening = controller.openTerminal(cwd: 'packages/client'); - await _flush(); - final open = channel.sentJson.last; - expect(open['command'], 'term.open'); - expect(open['args'], containsPair('cwd', 'packages/client')); - channel.emit(_confirmation(open, 'confirmation-terminal')); - await _flush(); - expect(channel.sentJson.last, containsPair('decision', 'approve')); - channel.emit( - _response( - open, - command: 'term.open', - result: {'terminalId': 'terminal-alpha'}, - ), - ); - expect(await opening, 'terminal-alpha'); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'terminal.output', - 'hostId': 'host-alpha', - 'sessionId': 'session-alpha', - 'terminalId': 'terminal-alpha', - 'cursor': {'epoch': 'terminal', 'seq': 1}, - 'stream': 'stdout', - 'data': r'$ ready', - }); - await _flush(); - expect(controller.state.activeTerminal?.output, r'$ ready'); - controller.sendTerminalInput('terminal-alpha', 'pwd\n'); - expect(channel.sentJson.last, containsPair('type', 'terminal.input')); - expect(channel.sentJson.last, containsPair('data', 'pwd\n')); - controller.resizeTerminal('terminal-alpha', 120, 40); - expect(channel.sentJson.last, containsPair('type', 'terminal.resize')); - expect(channel.sentJson.last, containsPair('cols', 120)); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'terminal.exit', - 'hostId': 'host-alpha', - 'sessionId': 'session-alpha', - 'terminalId': 'terminal-alpha', - 'cursor': {'epoch': 'terminal', 'seq': 2}, - 'exitCode': 0, - }); - await _flush(); - expect(controller.state.activeTerminal?.running, isFalse); - controller.closeTerminal('terminal-alpha'); - expect(channel.sentJson.last, containsPair('type', 'terminal.close')); - expect(controller.state.activeTerminal, isNull); - - final listing = controller.listFiles(); - await _flush(); - final filesCommand = channel.sentJson.last; - expect(filesCommand['command'], 'files.list'); - channel.emit( - _response( - filesCommand, - command: 'files.list', - result: { - 'entries': [ - { - 'path': 'lib/main.dart', - 'kind': 'file', - 'size': 42, - 'revision': 'revision-file', - }, - ], - 'revision': 'revision-files', - }, - ), - ); - await listing; - expect( - controller.state.fileWorkspace.entries.single.path, - 'lib/main.dart', - ); - - final refreshing = controller.refreshActivity(); - await _flush(); - final auditCommand = channel.sentJson.last; - expect(auditCommand['command'], 'audit.read'); - channel.emit( - _response( - auditCommand, - command: 'audit.read', - result: { - 'events': [ - { - 'eventId': 'operation-audit-1', - 'hostId': 'host-alpha', - 'sessionId': 'session-alpha', - 'action': 'files.read', - 'actor': 'fixture', - 'timestamp': '2026-07-19T00:00:00.000Z', - 'detail': {'token': 'must-not-render'}, - }, - ], - }, - ), - ); - await refreshing; - final audit = controller.state.activities.firstWhere( - (activity) => activity.id == 'operation-audit-1', - ); - expect(audit.raw, contains('')); - expect(audit.raw, isNot(contains('must-not-render'))); - - final launching = controller.launchPreview('https://example.test'); - await _flush(); - final launch = channel.sentJson.last; - expect(launch['command'], 'preview.launch'); - channel.emit(_confirmation(launch, 'confirmation-preview')); - await _flush(); - channel.emit( - _response( - launch, - command: 'preview.launch', - result: { - 'preview': { - 'previewId': 'preview-alpha', - 'state': 'ready', - 'url': 'https://example.test/', - 'revision': 'revision-preview', - 'cursor': {'epoch': 'preview', 'seq': 1}, - 'title': 'Fixture preview', - 'canGoBack': false, - 'canGoForward': false, - }, - }, - ), - ); - expect(await launching, 'preview-alpha'); - expect(controller.state.activePreview?.title, 'Fixture preview'); - expect(controller.state.developerOperationPending, isFalse); - }, - ); - - test('IO transport sends the exact native Origin', () async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - final origin = Completer(); - final accepted = Completer(); - server.listen((request) async { - if (!origin.isCompleted) { - origin.complete(request.headers.value('origin')); - } - final socket = await WebSocketTransformer.upgrade(request); - accepted.complete(socket); - }); - addTearDown(() async { - if (accepted.isCompleted) (await accepted.future).close(); - await server.close(force: true); - }); - - final channel = await connectPlatformWebSocket( - Uri.parse('ws://127.0.0.1:${server.port}/v1/ws'), - ); - await channel.ready; - expect(await origin.future, 'https://localhost'); - await channel.sink.close(); - }); - test('loads and saves the injected theme preference', () async { - final preferences = InMemoryAppPreferenceStore(themePreference: 'dark'); - final controller = T4ClientController( - hostDirectoryStore: _MemoryDirectoryStore(), - hostCredentialStore: _MemoryCredentialStore(), - appPreferenceStore: preferences, - webSocketConnector: _FakeConnector().call, - ); - addTearDown(controller.dispose); - - await controller.initialize(); - expect(controller.state.themePreference, T4ThemePreference.dark); - - await controller.setThemePreference(T4ThemePreference.light); - expect(controller.state.themePreference, T4ThemePreference.light); - expect(preferences.themePreference, 'light'); - }); - - test( - 'projects live settings defensively and explicitly confirms exact writes', - () async { - final profile = _profile('settings'); - final connector = _FakeConnector(); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - channel.emit( - _welcome( - 'host-settings', - capabilities: const [ - 'sessions.read', - 'catalog.read', - 'config.read', - 'config.write', - ], - features: const ['catalog.metadata', 'settings.metadata'], - ), - ); - await _flush(); - expect( - channel.sentJson.map((frame) => frame['command']), - containsAll(['catalog.get', 'settings.read']), - ); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'sessions', - 'hostId': 'host-settings', - ..._sessionListResultFor('host-settings', const []), - }); - channel.emit(_settingsCatalog()); - channel.emit(_settingsValues()); - await _flush(); - - final compact = controller.state.settings.entries.singleWhere( - (entry) => entry.path == 'appearance.compact', - ); - expect(compact.control, HostSettingControlKind.boolean); - expect(compact.effectiveValue, false); - final secret = controller.state.settings.entries.singleWhere( - (entry) => entry.path == 'provider.token', - ); - expect(secret.sensitive, isTrue); - expect(secret.configured, isTrue); - expect(secret.effectiveValue, isNull); - expect(secret.control, HostSettingControlKind.unsupported); - expect( - controller.state.settings.issues, - contains('provider.token: sensitive setting arrived with a value'), - ); - expect( - controller.state.settings.entries - .singleWhere((entry) => entry.path == 'future.setting') - .control, - HostSettingControlKind.unsupported, - ); - - final write = controller.writeSetting( - 'appearance.compact', - 'global', - value: true, - ); - await _flush(); - final writeFrame = channel.sentJson.lastWhere( - (frame) => frame['command'] == 'settings.write', - ); - expect(writeFrame['expectedRevision'], 'settings-rev-1'); - expect(writeFrame.containsKey('sessionId'), isFalse); - expect(writeFrame['args'], { - 'edits': [ - { - 'path': 'appearance.compact', - 'scope': 'global', - 'value': true, - }, - ], - 'expectedRevision': 'settings-rev-1', - }); - - channel.emit(_confirmation(writeFrame, 'confirm-settings')); - await _flush(); - expect( - channel.sentJson.where((frame) => frame['type'] == 'confirm'), - isEmpty, - ); - final confirmation = controller.state.attentionItems.singleWhere( - (item) => item.confirmationId == 'confirm-settings', - ); - expect(confirmation.sessionId, isEmpty); - await controller.respondToAttention( - confirmation, - const AttentionResponse(decision: AttentionDecision.approve), - ); - final confirmFrame = channel.sentJson.last; - expect(confirmFrame['type'], 'confirm'); - expect(confirmFrame.containsKey('sessionId'), isFalse); - expect(confirmFrame['decision'], 'approve'); - - channel.emit( - _response( - writeFrame, - command: 'settings.write', - result: const { - 'accepted': true, - 'revision': 'settings-rev-2', - }, - ), - ); - await _flush(); - expect( - channel.sentJson - .where( - (frame) => - frame['command'] == 'catalog.get' || - frame['command'] == 'settings.read', - ) - .length, - 4, - ); - channel.emit(_settingsCatalog(revision: 'catalog-rev-2')); - channel.emit(_settingsValues(revision: 'settings-rev-2')); - await write; - expect(controller.state.settings.revision, 'settings-rev-2'); - expect(controller.state.settingsOperationPending, isFalse); - - final conflict = controller.writeSetting( - 'appearance.compact', - 'global', - value: false, - ); - await _flush(); - final conflictFrame = channel.sentJson.lastWhere( - (frame) => frame['command'] == 'settings.write', - ); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'response', - 'requestId': conflictFrame['requestId'], - 'commandId': conflictFrame['commandId'], - 'hostId': 'host-settings', - 'command': 'settings.write', - 'ok': false, - 'error': { - 'code': 'revision_conflict', - 'message': 'settings changed on the host', - }, - }); - await expectLater(conflict, throwsStateError); - expect( - controller.state.settings.error, - contains('settings changed on the host'), - ); - - final reset = controller.writeSetting( - 'appearance.compact', - 'session', - reset: true, - ); - await _flush(); - final resetFrame = channel.sentJson.lastWhere( - (frame) => frame['command'] == 'settings.write', - ); - expect(resetFrame['args'], { - 'edits': [ - { - 'path': 'appearance.compact', - 'scope': 'session', - 'reset': true, - }, - ], - 'expectedRevision': 'settings-rev-2', - }); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'response', - 'requestId': resetFrame['requestId'], - 'commandId': resetFrame['commandId'], - 'hostId': 'host-settings', - 'command': 'settings.write', - 'ok': false, - 'error': {'code': 'denied', 'message': 'reset denied'}, - }); - await expectLater(reset, throwsStateError); - }, - ); - - test('settings writes require granted permission', () async { - final profile = _profile('readonly'); - final connector = _FakeConnector(); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - channel.emit( - _welcome( - 'host-readonly', - capabilities: const [ - 'sessions.read', - 'catalog.read', - 'config.read', - ], - features: const ['catalog.metadata', 'settings.metadata'], - ), - ); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'sessions', - 'hostId': 'host-readonly', - ..._sessionListResultFor('host-readonly', const []), - }); - channel.emit(_settingsCatalog(hostId: 'host-readonly')); - channel.emit(_settingsValues(hostId: 'host-readonly')); - await _flush(); - - await expectLater( - controller.writeSetting('appearance.compact', 'global', value: true), - throwsStateError, - ); - }); - - testWidgets('settings refresh settles with a bounded timeout', ( - tester, - ) async { - final profile = _profile('refresh-timeout'); - final connector = _FakeConnector(); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - channel.emit( - _welcome( - 'host-timeout', - capabilities: const [ - 'sessions.read', - 'catalog.read', - 'config.read', - ], - features: const ['catalog.metadata', 'settings.metadata'], - ), - ); - channel.emit({ - 'v': 'omp-app/1', - 'type': 'sessions', - 'hostId': 'host-timeout', - ..._sessionListResultFor('host-timeout', const []), - }); - channel.emit(_settingsCatalog(hostId: 'host-timeout')); - channel.emit(_settingsValues(hostId: 'host-timeout')); - await tester.pump(); - expect(controller.state.settings.loading, isFalse); - - final expectation = expectLater( - controller.refreshSettings(), - throwsA(isA()), - ); - expect(controller.state.settings.loading, isTrue); - await tester.pump(const Duration(seconds: 11)); - await expectation; - expect(controller.state.settings.loading, isFalse); - expect(controller.state.settings.error, contains('timed out')); - }); - - test('background resume reuses the loaded settings projection', () async { - final profile = _profile('settings-resume'); - final connector = _FakeConnector(); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final initialChannel = connector.channels.single; - const capabilities = [ - 'sessions.read', - 'catalog.read', - 'config.read', - ]; - const features = ['catalog.metadata', 'settings.metadata']; - initialChannel.emit( - _welcome( - 'host-settings-resume', - capabilities: capabilities, - features: features, - ), - ); - await _flush(); - initialChannel.emit({ - 'v': 'omp-app/1', - 'type': 'sessions', - 'hostId': 'host-settings-resume', - ..._sessionListResultFor('host-settings-resume', const []), - }); - initialChannel.emit(_settingsCatalog(hostId: 'host-settings-resume')); - initialChannel.emit(_settingsValues(hostId: 'host-settings-resume')); - await _flush(); - expect(controller.state.settings.entries, isNotEmpty); - expect(controller.state.settings.loading, isFalse); - - await controller.handleLifecyclePhase(T4LifecyclePhase.background); - await controller.handleLifecyclePhase(T4LifecyclePhase.resumed); - final resumedChannel = connector.channels.last; - resumedChannel.emit( - _welcome( - 'host-settings-resume', - capabilities: capabilities, - features: features, - ), - ); - await _flush(); - - expect( - resumedChannel.sentJson.where( - (frame) => - frame['command'] == 'catalog.get' || - frame['command'] == 'settings.read', - ), - isEmpty, - ); - expect(controller.state.settings.loading, isFalse); - }); - - test( - 'background resume reconnects once but deliberate disconnect stays closed', - () async { - final profile = _profile('lifecycle'); - final connector = _FakeConnector(); - final controller = _controller( - _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ), - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - expect(connector.channels, hasLength(1)); - - await controller.handleLifecyclePhase(T4LifecyclePhase.background); - expect(controller.state.lifecyclePhase, T4LifecyclePhase.background); - expect(controller.state.connectionPhase, ConnectionPhase.disconnected); - await controller.handleLifecyclePhase(T4LifecyclePhase.resumed); - expect(connector.channels, hasLength(2)); - await controller.handleLifecyclePhase(T4LifecyclePhase.resumed); - expect(connector.channels, hasLength(2)); - - await controller.handleLifecyclePhase(T4LifecyclePhase.background); - await controller.disconnect(); - await controller.handleLifecyclePhase(T4LifecyclePhase.resumed); - expect(connector.channels, hasLength(2)); - expect( - controller.state.hostDirectory.activeEndpointKey, - profile.endpointKey, - ); - }, - ); - test( - 'thinking menu exposes advertised levels and keeps a valid current value', - () async { - final profile = _profile('alpha'); - final directory = _MemoryDirectoryStore( - directory: const HostDirectory.empty().upsert(profile), - ); - final connector = _FakeConnector(); - final controller = _controller( - directory, - _MemoryCredentialStore(), - connector, - ); - addTearDown(controller.dispose); - await controller.initialize(); - final channel = connector.channels.single; - channel.emit( - _welcome('host-alpha', capabilities: t4RequestedCapabilities), - ); - await _flush(); - - Map sessionFrame({ - required String? thinking, - required bool? thinkingSupported, - required List thinkingLevels, - }) => { - 'v': 'omp-app/1', - 'type': 'sessions', - 'hostId': 'host-alpha', - 'cursor': {'epoch': 'index', 'seq': 1}, - 'sessions': [ - { - 'hostId': 'host-alpha', - 'sessionId': 'session-alpha', - 'project': { - 'projectId': 'project-alpha', - 'name': 'Project Alpha', - }, - 'revision': 'revision-alpha', - 'title': 'session-alpha title', - 'status': 'idle', - 'updatedAt': '2026-07-19T00:00:00.000Z', - 'liveState': { - 'thinking': ?thinking, - 'thinkingSupported': ?thinkingSupported, - if (thinkingLevels.isNotEmpty) 'thinkingLevels': thinkingLevels, - }, - }, - ], - 'totalCount': 1, - 'truncated': false, - }; - - // Model advertises off/low/medium/high/auto; current value is high. - channel.emit( - sessionFrame( - thinking: 'high', - thinkingSupported: true, - thinkingLevels: const ['low', 'medium', 'high'], - ), - ); - await _flush(); - await controller.selectSession('session-alpha'); - await _flush(); - channel.emit( - _snapshot('host-alpha', 'session-alpha', revision: 'revision-alpha'), - ); - await _flush(); - - var composer = controller.state.composer; - expect(composer.thinking, 'high'); - expect(composer.thinkingLevels, [ - 'off', - 'auto', - 'low', - 'medium', - 'high', - ]); - - // Capability refresh arrives without concrete efforts yet: the still-valid - // current value (high) must remain selectable rather than collapsing to - // off/auto only. - channel.emit( - sessionFrame( - thinking: 'high', - thinkingSupported: true, - thinkingLevels: const [], - ), - ); - await _flush(); - composer = controller.state.composer; - expect(composer.thinking, 'high'); - expect(composer.thinkingLevels, contains('off')); - expect(composer.thinkingLevels, contains('auto')); - expect(composer.thinkingLevels, contains('high')); - expect(composer.thinkingLevels, isNot(contains('low'))); - expect(composer.thinkingLevels, isNot(contains('medium'))); - - // A model that cannot reason exposes no levels. - channel.emit( - sessionFrame( - thinking: 'high', - thinkingSupported: false, - thinkingLevels: const ['low', 'medium', 'high'], - ), - ); - await _flush(); - composer = controller.state.composer; - expect(composer.thinkingLevels, isEmpty); - - // Submitting the current level sends the canonical raw value. - channel.emit( - sessionFrame( - thinking: 'high', - thinkingSupported: true, - thinkingLevels: const ['low', 'medium', 'high'], - ), - ); - await _flush(); - final setting = controller.setSessionThinking('high'); - await _flush(); - final sent = channel.sentJson.lastWhere( - (frame) => frame['command'] == 'session.thinking.set', - ); - expect(sent['args'], {'level': 'high'}); - channel.emit( - _response( - sent, - command: 'session.thinking.set', - result: {'updated': true}, - ), - ); - await setting; - }, - ); -} - -const String _token = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; -Map _settingsCatalog({ - String hostId = 'host-settings', - String revision = 'catalog-rev-1', -}) => { - 'v': 'omp-app/1', - 'type': 'catalog', - 'hostId': hostId, - 'revision': revision, - 'items': [ - { - 'id': 'setting:appearance.compact', - 'kind': 'setting', - 'name': 'appearance.compact', - 'metadata': { - 'path': 'appearance.compact', - 'label': 'Compact appearance', - 'description': 'Use less space.', - 'controlType': 'boolean', - 'scopes': ['global', 'session'], - 'tab': 'appearance', - }, - }, - { - 'id': 'setting:provider.token', - 'kind': 'setting', - 'name': 'provider.token', - 'metadata': { - 'path': 'provider.token', - 'label': 'Provider token', - 'controlType': 'string', - 'sensitive': true, - 'configured': true, - 'tab': 'providers', - }, - }, - { - 'id': 'setting:future.setting', - 'kind': 'setting', - 'name': 'future.setting', - 'metadata': { - 'path': 'future.setting', - 'label': 'Future setting', - 'controlType': 'boolean', - 'unreviewedKey': true, - }, - }, - ], -}; - -Map _settingsValues({ - String hostId = 'host-settings', - String revision = 'settings-rev-1', -}) => { - 'v': 'omp-app/1', - 'type': 'settings', - 'hostId': hostId, - 'revision': revision, - 'settings': { - 'appearance.compact': { - 'effective': false, - 'effectiveSource': 'global', - 'configured': true, - }, - 'provider.token': { - 'sensitive': true, - 'configured': true, - 'effective': 'must-never-render', - 'effectiveSource': 'global', - }, - }, -}; - -T4ClientController _controller( - HostDirectoryStore directory, - HostCredentialStore credentials, - _FakeConnector connector, { - TranscriptTailStore? transcriptTailStore, -}) => T4ClientController( - hostDirectoryStore: directory, - hostCredentialStore: credentials, - transcriptTailStore: transcriptTailStore, - webSocketConnector: connector.call, -); - -DurableEntry _durableMessage( - String id, { - required String hostId, - required String sessionId, - required String text, -}) { - final raw = { - 'id': id, - 'parentId': null, - 'hostId': hostId, - 'sessionId': sessionId, - 'kind': 'message', - 'timestamp': '2026-07-20T08:00:00.000Z', - 'data': {'role': 'assistant', 'text': text}, - }; - return DurableEntry( - id: id, - parentId: null, - hostId: hostId, - sessionId: sessionId, - kind: 'message', - timestamp: raw['timestamp']! as String, - data: raw['data']! as Map, - raw: raw, - ); -} - -HostProfile _profile(String name) => - HostProfile.parseTailnetAddress('$name.example.ts.net'); - -Map _welcome( - String hostId, { - String authentication = 'paired', - List capabilities = const ['sessions.read'], - List features = const [], -}) => { - 'v': 'omp-app/1', - 'type': 'welcome', - 'selectedProtocol': 'omp-app/1', - 'hostId': hostId, - 'ompVersion': 'test', - 'ompBuild': 'test', - 'appserverVersion': 'test', - 'appserverBuild': 'test', - 'epoch': 'host-epoch', - 'authentication': authentication, - 'grantedCapabilities': capabilities, - 'grantedFeatures': features, - 'negotiatedLimits': {}, - 'resumed': false, -}; - -Map _sessions(String hostId) => { - 'v': 'omp-app/1', - 'type': 'sessions', - 'hostId': hostId, - ..._sessionListResult(hostId), -}; - -Map _sessionListResult( - String hostId, { - String epoch = 'index', - int seq = 1, -}) => _sessionListResultFor( - hostId, - const ['session-alpha'], - epoch: epoch, - seq: seq, -); - -Map _sessionListResultFor( - String hostId, - List sessionIds, { - String epoch = 'index', - Map? attention, - int seq = 1, -}) => { - 'cursor': {'epoch': epoch, 'seq': seq}, - 'sessions': sessionIds - .map( - (sessionId) => _sessionRef( - hostId, - sessionId, - projectId: 'project-$sessionId', - projectName: sessionId == 'session-alpha' - ? 'Project Alpha' - : 'Project Beta', - revision: 'revision-$sessionId', - title: '$sessionId title', - attention: sessionId == 'session-alpha' ? attention : null, - ), - ) - .toList(growable: false), - 'totalCount': sessionIds.length, - 'truncated': false, -}; - -Map _response( - Map request, { - required String command, - required Object? result, -}) => { - 'v': 'omp-app/1', - 'type': 'response', - 'requestId': request['requestId'], - 'commandId': request['commandId'], - 'hostId': request['hostId'], - if (request['sessionId'] != null) 'sessionId': request['sessionId'], - 'command': command, - 'ok': true, - 'result': result, -}; - -Map _sessionRef( - String hostId, - String sessionId, { - required String projectId, - required String projectName, - required String revision, - required String title, - String status = 'idle', - Map? attention, -}) => { - 'hostId': hostId, - 'sessionId': sessionId, - 'project': {'projectId': projectId, 'name': projectName}, - 'revision': revision, - 'title': title, - 'status': status, - 'updatedAt': '2026-07-19T00:00:00.000Z', - 'attention': ?attention, -}; - -Map _snapshot( - String hostId, - String sessionId, { - required String revision, -}) => { - 'v': 'omp-app/1', - 'type': 'snapshot', - 'hostId': hostId, - 'sessionId': sessionId, - 'cursor': {'epoch': 'transcript', 'seq': 0}, - 'revision': revision, - 'entries': [], -}; - -Map _transcriptEvent( - String hostId, - String sessionId, { - required int seq, - required Map event, -}) => { - 'v': 'omp-app/1', - 'type': 'event', - 'hostId': hostId, - 'sessionId': sessionId, - 'cursor': {'epoch': 'transcript', 'seq': seq}, - 'event': event, -}; - -Map _transcriptMessageEntry( - String hostId, - String sessionId, { - required int seq, - required String entryId, - required String role, - required String text, -}) => { - 'v': 'omp-app/1', - 'type': 'entry', - 'hostId': hostId, - 'sessionId': sessionId, - 'cursor': {'epoch': 'transcript', 'seq': seq}, - 'revision': 'revision-$seq', - 'entry': { - 'id': entryId, - 'parentId': null, - 'hostId': hostId, - 'sessionId': sessionId, - 'kind': 'message', - 'timestamp': '2026-07-20T00:00:00.000Z', - 'data': {'role': role, 'text': text}, - }, -}; - -Map _sessionDelta( - String hostId, - String sessionId, { - required String revision, - required int seq, - Map? upsert, - String? remove, -}) => { - 'v': 'omp-app/1', - 'type': 'session.delta', - 'hostId': hostId, - 'sessionId': sessionId, - 'cursor': {'epoch': 'index', 'seq': seq}, - 'revision': revision, - 'upsert': ?upsert, - 'remove': ?remove, -}; - -Map _confirmation( - Map command, - String confirmationId, -) => { - 'v': 'omp-app/1', - 'type': 'confirmation', - 'confirmationId': confirmationId, - 'commandId': command['commandId'], - 'hostId': command['hostId'], - if (command['sessionId'] != null) 'sessionId': command['sessionId'], - 'commandHash': 'sha256:test', - 'revision': command['expectedRevision'], - 'expiresAt': '2030-01-01T00:00:00.000Z', - 'summary': command['command'], -}; - -Future _flush() async { - await Future.delayed(Duration.zero); - await Future.delayed(Duration.zero); -} - -Future _until(bool Function() predicate) async { - for (var attempt = 0; attempt < 100; attempt++) { - if (predicate()) return; - await Future.delayed(Duration.zero); - } - fail('condition was not reached'); -} - -final class _MemoryDirectoryStore implements HostDirectoryStore { - _MemoryDirectoryStore({ - this.directory = const HostDirectory.empty(), - List? events, - }) : events = events ?? []; - - HostDirectory directory; - final List events; - final List saved = []; - - @override - Future load() async { - events.add('load'); - return directory; - } - - @override - Future save(HostDirectory directory) async { - events.add('save:${directory.activeEndpointKey}'); - saved.add(directory); - this.directory = directory; - } -} - -final class _MemoryCredentialStore implements HostCredentialStore { - _MemoryCredentialStore({List? events}) - : events = events ?? []; - - final List events; - final Map values = {}; - final Map> delayedReads = - >{}; - final List readProfiles = []; - final List deleted = []; - Object? deleteError; - - @override - Future read(HostProfile profile) async { - events.add('read:${profile.endpointKey}'); - readProfiles.add(profile.endpointKey); - final delayed = delayedReads[profile.endpointKey]; - if (delayed != null) return delayed.future; - return values[profile.endpointKey]; - } - - @override - Future write(HostProfile profile, DeviceCredentials credentials) async { - values[profile.endpointKey] = credentials; - } - - @override - Future delete(HostProfile profile) async { - deleted.add(profile.endpointKey); - events.add('delete:${profile.endpointKey}'); - final error = deleteError; - if (error != null) throw error; - values.remove(profile.endpointKey); - } -} - -final class _FakeConnector { - _FakeConnector({ - List? events, - this.readyGate, - this.hangFirstClose = false, - }) : events = events ?? []; - - final Completer? readyGate; - final bool hangFirstClose; - // Constructor is declared above so tests can delay a probe handshake. - - final List events; - final List uris = []; - final List<_FakeWebSocketChannel> channels = <_FakeWebSocketChannel>[]; - - Future call(Uri uri) async { - events.add('connect:$uri'); - uris.add(uri); - final channel = _FakeWebSocketChannel( - channels.length, - events, - readyGate: readyGate, - hangClose: hangFirstClose && channels.isEmpty, - ); - channels.add(channel); - return channel; - } -} - -final class _FakeWebSocketChannel implements WebSocketChannel { - _FakeWebSocketChannel( - this.index, - this.events, { - this.readyGate, - bool hangClose = false, - }) : sink = _FakeWebSocketSink(index, events, hangClose: hangClose); - - final int index; - final List events; - final Completer? readyGate; - final StreamController _incoming = StreamController(); - @override - final _FakeWebSocketSink sink; - - List get sent => sink.sent.cast(); - List> get sentJson => sent - .map((value) => (jsonDecode(value) as Map)) - .toList(growable: false); - - void emit(Map frame) => _incoming.add(jsonEncode(frame)); - void fail(Object error) => _incoming.addError(error); - - @override - Future get ready => readyGate?.future ?? Future.value(); - @override - Stream get stream => _incoming.stream; - @override - String? get protocol => null; - @override - int? get closeCode => null; - @override - String? get closeReason => null; - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -final class _FakeWebSocketSink implements WebSocketSink { - _FakeWebSocketSink(this.index, this.events, {this.hangClose = false}); - - final int index; - final List events; - final bool hangClose; - final List sent = []; - final Completer _done = Completer(); - - @override - void add(Object? data) => sent.add(data); - @override - void addError(Object error, [StackTrace? stackTrace]) => - _done.completeError(error, stackTrace); - @override - Future addStream(Stream stream) async => - sent.addAll(await stream.toList()); - @override - Future close([int? closeCode, String? closeReason]) async { - events.add('close:$index'); - if (hangClose) return Completer().future; - if (!_done.isCompleted) _done.complete(); - } - - @override - Future get done => _done.future; -} diff --git a/apps/flutter/test/client/transcript_tail_store_test.dart b/apps/flutter/test/client/transcript_tail_store_test.dart deleted file mode 100644 index e35a907c..00000000 --- a/apps/flutter/test/client/transcript_tail_store_test.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/client/transcript_tail_store.dart'; -import 'package:t4code/src/protocol/protocol.dart'; - -void main() { - test('persistent tail cache keeps only the newest bounded entries', () async { - final storage = _MemoryStorage(); - final store = PersistentTranscriptTailStore(storage: storage); - final entries = [ - for (var index = 0; index < 70; index++) - _entry(index, hostId: 'host-a', sessionId: 'session-a'), - ]; - - await store.save( - hostId: 'host-a', - sessionId: 'session-a', - generation: 'generation-a', - entries: entries, - ); - final loaded = await store.load(hostId: 'host-a', sessionId: 'session-a'); - - expect(loaded?.entries, hasLength(64)); - expect(loaded?.entries.first.id, 'entry-6'); - expect(loaded?.entries.last.id, 'entry-69'); - expect(loaded?.generation, 'generation-a'); - expect(storage.values.values.join(), isNot(contains('nextCursor'))); - }); - - test( - 'persistent tail cache prunes old sessions and ignores damage', - () async { - final storage = _MemoryStorage(); - final store = PersistentTranscriptTailStore(storage: storage); - for (var index = 0; index < 9; index++) { - await Future.delayed(const Duration(milliseconds: 1)); - await store.save( - hostId: 'host-a', - sessionId: 'session-$index', - generation: 'generation-$index', - entries: [ - _entry(index, hostId: 'host-a', sessionId: 'session-$index'), - ], - ); - } - - expect( - await store.load(hostId: 'host-a', sessionId: 'session-0'), - isNull, - ); - expect( - await store.load(hostId: 'host-a', sessionId: 'session-8'), - isNotNull, - ); - - await store.save( - hostId: 'host-a', - sessionId: 'damaged', - generation: 'generation-damaged', - entries: [ - _entry(99, hostId: 'host-a', sessionId: 'damaged'), - ], - ); - final damagedKey = storage.values.entries - .where((entry) => entry.key != transcriptTailStorageKey) - .singleWhere((entry) { - final decoded = jsonDecode(entry.value); - return decoded is Map && - decoded['sessionId'] == 'damaged'; - }) - .key; - storage.values[damagedKey] = jsonEncode({ - 'version': 1, - 'hostId': 'host-a', - 'sessionId': 'damaged', - 'generation': 'generation-damaged', - 'entries': [ - {'id': 'missing-required-fields'}, - ], - }); - expect(await store.load(hostId: 'host-a', sessionId: 'damaged'), isNull); - }, - ); -} - -DurableEntry _entry( - int index, { - required String hostId, - required String sessionId, -}) { - final raw = { - 'id': 'entry-$index', - 'parentId': index == 0 ? null : 'entry-${index - 1}', - 'hostId': hostId, - 'sessionId': sessionId, - 'kind': 'message', - 'timestamp': DateTime.utc( - 2026, - 7, - 20, - 8, - ).add(Duration(minutes: index)).toIso8601String(), - 'data': { - 'role': index.isEven ? 'assistant' : 'user', - 'text': 'message $index', - }, - }; - return DurableEntry( - id: raw['id']! as String, - parentId: raw['parentId'] as String?, - hostId: hostId, - sessionId: sessionId, - kind: 'message', - timestamp: raw['timestamp']! as String, - data: raw['data']! as Map, - raw: raw, - ); -} - -final class _MemoryStorage implements TranscriptTailStorage { - final Map values = {}; - - @override - Future getString(String key) async => values[key]; - - @override - Future setString(String key, String value) async { - values[key] = value; - } - - @override - Future delete(String key) async { - values.remove(key); - } -} diff --git a/apps/flutter/test/demo/demo_app_test.dart b/apps/flutter/test/demo/demo_app_test.dart deleted file mode 100644 index e180b43d..00000000 --- a/apps/flutter/test/demo/demo_app_test.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/demo/demo_app.dart'; - -void main() { - testWidgets('public demo renders the canonical Flutter session workspace', ( - tester, - ) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(1280, 800); - addTearDown(tester.view.reset); - - await tester.pumpWidget(const T4DemoApp()); - // The demo seeds a working session whose rail spinner animates forever, - // so settle with bounded pumps instead of pumpAndSettle. - await tester.pump(); - await tester.pump(const Duration(milliseconds: 400)); - await tester.pump(const Duration(milliseconds: 400)); - - expect(find.text('T4 CODE'), findsWidgets); - expect( - find.text('Public preview · sample data · actions disabled'), - findsOneWidget, - ); - expect(find.text('Fix quick-open stale results'), findsWidgets); - expect( - find.textContaining( - 'Quick open now keys its cache', - findRichText: true, - ), - findsOneWidget, - ); - expect(find.text('Connect to T4'), findsNothing); - }); -} diff --git a/apps/flutter/test/host/legacy_credential_migration_test.dart b/apps/flutter/test/host/legacy_credential_migration_test.dart deleted file mode 100644 index 94a51664..00000000 --- a/apps/flutter/test/host/legacy_credential_migration_test.dart +++ /dev/null @@ -1,247 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/host/host_profile.dart'; -import 'package:t4code/src/host/persistent_host_stores.dart'; - -void main() { - group('legacy Android credential migration', () { - test( - 'migrates a discovered keyed endpoint into endpoint storage', - () async { - final profile = _profile(); - final storage = _MemoryCredentialStorage(); - final legacy = _MemoryLegacySource( - credentials: const LegacyHostCredentials( - deviceId: 'legacy-device', - deviceToken: 'legacy-token', - source: 'opaque-keyed-source', - ), - ); - final store = SecureHostCredentialStore( - storage: storage, - legacySource: legacy, - ); - - final credentials = await store.read(profile); - - expect(credentials!.deviceId, 'legacy-device'); - expect(legacy.hostKeyRequests, >[ - [profile.endpointKey, profile.origin], - ]); - expect(legacy.includeUnkeyedRequests, [true]); - expect(legacy.clearedSources, ['opaque-keyed-source']); - expect( - jsonDecode(storage.values[_credentialKey(profile.endpointKey)]!), - { - 'deviceId': 'legacy-device', - 'deviceToken': 'legacy-token', - }, - ); - }, - ); - - test( - 'checks endpoint, origin, then requests the unkeyed fallback', - () async { - final profile = _profile(); - final storage = _MemoryCredentialStorage(); - final legacy = _MemoryLegacySource(); - final store = SecureHostCredentialStore( - storage: storage, - legacySource: legacy, - ); - storage.values[_credentialKey(profile.origin)] = jsonEncode( - { - 'deviceId': 'origin-device', - 'deviceToken': 'origin-token', - }, - ); - - expect((await store.read(profile))!.deviceId, 'origin-device'); - expect(storage.readKeys, [ - _credentialKey(profile.endpointKey), - _credentialKey(profile.origin), - ]); - expect(legacy.hostKeyRequests, isEmpty); - - storage.values.clear(); - storage.readKeys.clear(); - expect(await store.read(profile), isNull); - expect(storage.readKeys, [ - _credentialKey(profile.endpointKey), - _credentialKey(profile.origin), - ]); - expect(legacy.hostKeyRequests.single, [ - profile.endpointKey, - profile.origin, - ]); - expect(legacy.includeUnkeyedRequests.single, isTrue); - }, - ); - - test( - 'named profiles never request origin or unkeyed credentials', - () async { - final profile = _profile(profileId: 'work'); - final storage = _MemoryCredentialStorage(); - final legacy = _MemoryLegacySource(); - storage.values[_credentialKey(profile.origin)] = jsonEncode( - { - 'deviceId': 'default-device', - 'deviceToken': 'default-token', - }, - ); - final store = SecureHostCredentialStore( - storage: storage, - legacySource: legacy, - ); - - expect(await store.read(profile), isNull); - expect(storage.readKeys, [_credentialKey(profile.endpointKey)]); - expect(legacy.hostKeyRequests.single, [profile.endpointKey]); - expect(legacy.includeUnkeyedRequests.single, isFalse); - expect(storage.values, contains(_credentialKey(profile.origin))); - }, - ); - - test( - 'failed endpoint write preserves the discovered legacy source', - () async { - final profile = _profile(); - final currentKey = _credentialKey(profile.endpointKey); - final storage = _MemoryCredentialStorage() - ..failingWriteKey = currentKey; - final legacy = _MemoryLegacySource( - credentials: const LegacyHostCredentials( - deviceId: 'legacy-device', - deviceToken: 'legacy-token', - source: 'opaque-source', - ), - ); - final store = SecureHostCredentialStore( - storage: storage, - legacySource: legacy, - ); - - await expectLater(store.read(profile), throwsStateError); - - expect(storage.values, isNot(contains(currentKey))); - expect(legacy.clearedSources, isEmpty); - expect(legacy.credentials, isNotNull); - }, - ); - - test('failed legacy clear rolls back and never reports success', () async { - final profile = _profile(); - final currentKey = _credentialKey(profile.endpointKey); - final storage = _MemoryCredentialStorage(); - final legacy = _MemoryLegacySource( - credentials: const LegacyHostCredentials( - deviceId: 'legacy-device', - deviceToken: 'legacy-token', - source: 'opaque-source', - ), - )..failClear = true; - final store = SecureHostCredentialStore( - storage: storage, - legacySource: legacy, - ); - - await expectLater(store.read(profile), throwsStateError); - - expect(storage.values, isNot(contains(currentKey))); - expect(storage.deletedKeys, contains(currentKey)); - expect(legacy.credentials, isNotNull); - expect(legacy.clearedSources, isEmpty); - await expectLater(store.read(profile), throwsStateError); - }); - - test( - 'validates discovered credentials before writing or clearing', - () async { - final profile = _profile(); - final storage = _MemoryCredentialStorage(); - final legacy = _MemoryLegacySource( - credentials: const LegacyHostCredentials( - deviceId: '', - deviceToken: 'legacy-token', - source: 'opaque-source', - ), - ); - final store = SecureHostCredentialStore( - storage: storage, - legacySource: legacy, - ); - - await expectLater(store.read(profile), throwsFormatException); - - expect(storage.values, isEmpty); - expect(legacy.clearedSources, isEmpty); - }, - ); - }); -} - -HostProfile _profile({String profileId = defaultHostProfileId}) => - HostProfile.parseTailnetAddress( - 'https://workstation.example.ts.net', - profileId: profileId, - ); - -String _credentialKey(String hostKey) => - 't4-code:device-credentials:v1:' - '${base64Url.encode(utf8.encode(hostKey)).replaceAll('=', '')}'; - -final class _MemoryCredentialStorage implements HostCredentialStorage { - final Map values = {}; - final List readKeys = []; - final List deletedKeys = []; - String? failingWriteKey; - - @override - Future read(String key) async { - readKeys.add(key); - return values[key]; - } - - @override - Future write(String key, String value) async { - if (key == failingWriteKey) throw StateError('injected write failure'); - values[key] = value; - } - - @override - Future delete(String key) async { - deletedKeys.add(key); - values.remove(key); - } -} - -final class _MemoryLegacySource implements LegacyHostCredentialSource { - _MemoryLegacySource({this.credentials}); - - LegacyHostCredentials? credentials; - bool failClear = false; - final List> hostKeyRequests = >[]; - final List includeUnkeyedRequests = []; - final List clearedSources = []; - - @override - Future discover({ - required List hostKeys, - required bool includeUnkeyed, - }) async { - hostKeyRequests.add(List.of(hostKeys)); - includeUnkeyedRequests.add(includeUnkeyed); - return credentials; - } - - @override - Future clear(String source) async { - if (failClear) throw StateError('injected clear failure'); - if (credentials?.source != source) throw StateError('wrong source'); - clearedSources.add(source); - credentials = null; - } -} diff --git a/apps/flutter/test/host/persistent_host_stores_test.dart b/apps/flutter/test/host/persistent_host_stores_test.dart deleted file mode 100644 index 815ab085..00000000 --- a/apps/flutter/test/host/persistent_host_stores_test.dart +++ /dev/null @@ -1,250 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/host/host_profile.dart'; -import 'package:t4code/src/host/persistent_host_stores.dart'; - -void main() { - group('PersistentHostDirectoryStore', () { - test('returns an empty directory when storage is absent', () async { - final preferences = _MemoryPreferences(); - final store = PersistentHostDirectoryStore(preferences: preferences); - - final directory = await store.load(); - - expect(directory.profiles, isEmpty); - expect(directory.activeEndpointKey, isNull); - }); - - test('round trips through the canonical preference key', () async { - final preferences = _MemoryPreferences(); - final store = PersistentHostDirectoryStore(preferences: preferences); - final profile = _profile(); - - await store.save(const HostDirectory.empty().upsert(profile)); - final restored = await store.load(); - - expect(preferences.values.keys, [hostDirectoryStorageKey]); - expect(restored.activeEndpointKey, profile.endpointKey); - expect(restored.profiles.single.endpointKey, profile.endpointKey); - }); - - test('deletes storage when the directory becomes empty', () async { - final preferences = _MemoryPreferences(); - final store = PersistentHostDirectoryStore(preferences: preferences); - final populated = const HostDirectory.empty().upsert(_profile()); - await store.save(populated); - - await store.save(populated.remove(populated.activeEndpointKey!)); - - expect(preferences.values, isEmpty); - expect(preferences.removedKeys, [hostDirectoryStorageKey]); - }); - - test('surfaces malformed and structurally damaged data', () async { - final preferences = _MemoryPreferences(); - final store = PersistentHostDirectoryStore(preferences: preferences); - - preferences.values[hostDirectoryStorageKey] = '{'; - await expectLater(store.load(), throwsFormatException); - - preferences.values[hostDirectoryStorageKey] = jsonEncode({ - 'version': hostProfileSchemaVersion, - 'activeEndpointKey': 'missing', - 'backends': [], - }); - await expectLater(store.load(), throwsFormatException); - expect(preferences.values, contains(hostDirectoryStorageKey)); - }); - }); - - group('VolatileHostCredentialStore', () { - test( - 'keeps credentials only in the owning process-local instance', - () async { - final profile = _profile(); - final store = VolatileHostCredentialStore(); - await store.write(profile, _credentials('volatile-device')); - - expect((await store.read(profile))!.deviceId, 'volatile-device'); - expect(await VolatileHostCredentialStore().read(profile), isNull); - - await store.delete(profile); - expect(await store.read(profile), isNull); - }, - ); - }); - - group('SecureHostCredentialStore', () { - test('round trips credentials and isolates profiles by endpoint', () async { - final storage = _MemoryCredentialStorage(); - final store = SecureHostCredentialStore(storage: storage); - final defaultProfile = _profile(); - final workProfile = _profile(profileId: 'work'); - final defaultCredentials = _credentials('default-device'); - final workCredentials = _credentials('work-device'); - - await store.write(defaultProfile, defaultCredentials); - await store.write(workProfile, workCredentials); - - expect((await store.read(defaultProfile))!.deviceId, 'default-device'); - expect((await store.read(workProfile))!.deviceId, 'work-device'); - expect( - storage.values.keys, - containsAll([ - _credentialKey(defaultProfile.endpointKey), - _credentialKey(workProfile.endpointKey), - ]), - ); - expect(storage.values, hasLength(2)); - }); - - test('surfaces malformed and non-exact credential records', () async { - final storage = _MemoryCredentialStorage(); - final store = SecureHostCredentialStore(storage: storage); - final profile = _profile(profileId: 'work'); - final key = _credentialKey(profile.endpointKey); - - for (final damaged in [ - '{', - jsonEncode({'deviceId': 'device'}), - jsonEncode({ - 'deviceId': 'device', - 'deviceToken': 'token', - 'unexpected': true, - }), - jsonEncode({'deviceId': 7, 'deviceToken': 'token'}), - jsonEncode({'deviceId': '', 'deviceToken': 'token'}), - ]) { - storage.values[key] = damaged; - await expectLater(store.read(profile), throwsFormatException); - } - expect(storage.values, contains(key)); - }); - - test('deletes only the selected endpoint credentials', () async { - final storage = _MemoryCredentialStorage(); - final store = SecureHostCredentialStore(storage: storage); - final first = _profile(profileId: 'first'); - final second = _profile(profileId: 'second'); - await store.write(first, _credentials('first-device')); - await store.write(second, _credentials('second-device')); - - await store.delete(first); - - expect(await store.read(first), isNull); - expect((await store.read(second))!.deviceId, 'second-device'); - }); - - test( - 'migrates the default origin key within the same secure-storage backend', - () async { - final storage = _MemoryCredentialStorage(); - final store = SecureHostCredentialStore(storage: storage); - final profile = _profile(); - final legacyKey = _credentialKey(profile.origin); - final currentKey = _credentialKey(profile.endpointKey); - storage.values[legacyKey] = jsonEncode({ - 'deviceId': 'legacy-device', - 'deviceToken': 'legacy-token', - }); - - final restored = await store.read(profile); - - expect(restored!.deviceId, 'legacy-device'); - expect(jsonDecode(storage.values[currentKey]!), { - 'deviceId': 'legacy-device', - 'deviceToken': 'legacy-token', - }); - expect(storage.values, isNot(contains(legacyKey))); - expect(storage.deletedKeys, contains(legacyKey)); - }, - ); - - test('keeps the legacy key when migration write fails', () async { - final storage = _MemoryCredentialStorage(); - final store = SecureHostCredentialStore(storage: storage); - final profile = _profile(); - final legacyKey = _credentialKey(profile.origin); - final currentKey = _credentialKey(profile.endpointKey); - storage.values[legacyKey] = jsonEncode({ - 'deviceId': 'legacy-device', - 'deviceToken': 'legacy-token', - }); - storage.failingWriteKey = currentKey; - - await expectLater(store.read(profile), throwsStateError); - - expect(storage.values, contains(legacyKey)); - expect(storage.values, isNot(contains(currentKey))); - expect(storage.deletedKeys, isNot(contains(legacyKey))); - }); - - test('does not use an origin fallback for named profiles', () async { - final storage = _MemoryCredentialStorage(); - final store = SecureHostCredentialStore(storage: storage); - final profile = _profile(profileId: 'work'); - storage.values[_credentialKey(profile.origin)] = jsonEncode( - { - 'deviceId': 'legacy-device', - 'deviceToken': 'legacy-token', - }, - ); - - expect(await store.read(profile), isNull); - }); - }); -} - -HostProfile _profile({String profileId = defaultHostProfileId}) => - HostProfile.parseTailnetAddress( - 'https://workstation.example.ts.net', - profileId: profileId, - ); - -DeviceCredentials _credentials(String deviceId) => - DeviceCredentials(deviceId: deviceId, deviceToken: 'device-token'); - -String _credentialKey(String hostKey) => - 't4-code:device-credentials:v1:' - '${base64Url.encode(utf8.encode(hostKey)).replaceAll('=', '')}'; - -final class _MemoryPreferences implements HostDirectoryPreferences { - final Map values = {}; - final List removedKeys = []; - - @override - Future getString(String key) async => values[key]; - - @override - Future setString(String key, String value) async { - values[key] = value; - } - - @override - Future remove(String key) async { - removedKeys.add(key); - values.remove(key); - } -} - -final class _MemoryCredentialStorage implements HostCredentialStorage { - final Map values = {}; - final List deletedKeys = []; - String? failingWriteKey; - - @override - Future read(String key) async => values[key]; - - @override - Future write(String key, String value) async { - if (key == failingWriteKey) throw StateError('injected write failure'); - values[key] = value; - } - - @override - Future delete(String key) async { - deletedKeys.add(key); - values.remove(key); - } -} diff --git a/apps/flutter/test/platform/platform_lifecycle_controller_test.dart b/apps/flutter/test/platform/platform_lifecycle_controller_test.dart deleted file mode 100644 index 0f10e463..00000000 --- a/apps/flutter/test/platform/platform_lifecycle_controller_test.dart +++ /dev/null @@ -1,176 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/platform/platform_lifecycle.dart'; -import 'package:t4code/src/platform/platform_lifecycle_controller.dart'; - -void main() { - test('initializes supported runtime and updater state', () async { - final bridge = _FakeBridge(runtimeSupported: true, updatesSupported: true); - final controller = PlatformLifecycleController.withBridge(bridge); - - await controller.initialize(); - - expect(controller.state.initializing, isFalse); - expect(controller.state.runtime.service, RuntimeServicePhase.stopped); - expect(controller.state.update.phase, PlatformUpdatePhase.idle); - expect(bridge.runtimeInspections, 1); - expect(bridge.updateReads, 1); - }); - - test( - 'keeps supported controls recoverable after initialization errors', - () async { - final bridge = _FakeBridge(runtimeSupported: true, updatesSupported: true) - ..runtimeInspectionFailure = StateError('runtime unavailable') - ..updateReadFailure = StateError('update unavailable'); - final controller = PlatformLifecycleController.withBridge(bridge); - - await controller.initialize(); - - expect(controller.state.runtime.supported, isTrue); - expect(controller.state.runtime.service, RuntimeServicePhase.unknown); - expect(controller.state.runtime.issueCode, 'runtime_status_unavailable'); - expect(controller.state.update.supported, isTrue); - expect(controller.state.update.phase, PlatformUpdatePhase.error); - expect(controller.state.update.error, 'update_status_unavailable'); - expect(controller.state.errorMessage, contains('runtime unavailable')); - }, - ); - - test('serializes runtime actions and publishes the returned state', () async { - final bridge = _FakeBridge(runtimeSupported: true); - final controller = PlatformLifecycleController.withBridge(bridge); - await controller.initialize(); - - await controller.startRuntime(); - await controller.restartRuntime(); - - expect(bridge.runtimeStarts, 1); - expect(bridge.runtimeRestarts, 1); - expect(controller.state.runtime.service, RuntimeServicePhase.running); - expect(controller.state.runtimeOperationPending, isFalse); - }); - - test('keeps unsupported operations inert', () async { - final bridge = _FakeBridge(); - final controller = PlatformLifecycleController.withBridge(bridge); - await controller.initialize(); - - await controller.startRuntime(); - await controller.checkForUpdates(); - - expect(bridge.runtimeStarts, 0); - expect(bridge.updateChecks, 0); - expect(controller.state.runtime.supported, isFalse); - expect(controller.state.update.supported, isFalse); - }); - - test( - 'reports bounded platform errors without leaving a pending action', - () async { - final bridge = _FakeBridge(runtimeSupported: true) - ..runtimeFailure = StateError('failed token=super-secret'); - final controller = PlatformLifecycleController.withBridge(bridge); - await controller.initialize(); - - await controller.startRuntime(); - - expect(controller.state.runtimeOperationPending, isFalse); - expect(controller.state.errorMessage, contains('failed')); - expect(controller.state.errorMessage, isNot(contains('super-secret'))); - expect(controller.state.errorMessage, contains('token=[REDACTED]')); - }, - ); -} - -final class _FakeBridge implements PlatformLifecycleBridge { - _FakeBridge({this.runtimeSupported = false, this.updatesSupported = false}); - - final bool runtimeSupported; - final bool updatesSupported; - int runtimeInspections = 0; - int runtimeStarts = 0; - int runtimeRestarts = 0; - int updateReads = 0; - int updateChecks = 0; - Object? runtimeFailure; - Object? runtimeInspectionFailure; - Object? updateReadFailure; - - @override - bool get supportsRuntimeService => runtimeSupported; - - @override - bool get supportsUpdates => updatesSupported; - - RuntimeServiceStatus get _stopped => const RuntimeServiceStatus( - supported: true, - available: true, - definition: RuntimeDefinitionState.current, - service: RuntimeServicePhase.stopped, - diagnostics: 'Stopped.', - executable: '/usr/local/bin/omp', - ); - - RuntimeServiceStatus get _running => const RuntimeServiceStatus( - supported: true, - available: true, - definition: RuntimeDefinitionState.current, - service: RuntimeServicePhase.running, - diagnostics: 'Running.', - executable: '/usr/local/bin/omp', - ); - - @override - Future inspectRuntime() async { - runtimeInspections += 1; - if (runtimeInspectionFailure case final Object error) throw error; - return _stopped; - } - - @override - Future installRuntime() async => _stopped; - - @override - Future startRuntime() async { - runtimeStarts += 1; - if (runtimeFailure case final Object error) throw error; - return _running; - } - - @override - Future stopRuntime() async => _stopped; - - @override - Future restartRuntime() async { - runtimeRestarts += 1; - return _running; - } - - @override - Future uninstallRuntime() async => _stopped; - - PlatformUpdateStatus get _update => const PlatformUpdateStatus( - supported: true, - currentVersion: '0.1.24', - phase: PlatformUpdatePhase.idle, - ); - - @override - Future getUpdateState() async { - updateReads += 1; - if (updateReadFailure case final Object error) throw error; - return _update; - } - - @override - Future checkForUpdates() async { - updateChecks += 1; - return _update; - } - - @override - Future downloadUpdate() async => _update; - - @override - Future installUpdate() async => _update; -} diff --git a/apps/flutter/test/platform/platform_lifecycle_test.dart b/apps/flutter/test/platform/platform_lifecycle_test.dart deleted file mode 100644 index 550bc670..00000000 --- a/apps/flutter/test/platform/platform_lifecycle_test.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/platform/platform_lifecycle.dart'; - -void main() { - test('runtime inspection accepts the bounded native contract', () { - final status = RuntimeServiceStatus.fromMap({ - 'available': true, - 'executable': '/usr/local/bin/omp', - 'definition': 'current', - 'service': 'running', - 'diagnostics': 'OMP appserver is healthy.', - }); - - expect(status.supported, isTrue); - expect(status.available, isTrue); - expect(status.definition, RuntimeDefinitionState.current); - expect(status.service, RuntimeServicePhase.running); - expect(status.executable, '/usr/local/bin/omp'); - }); - - test('runtime inspection rejects unknown native states', () { - expect( - () => RuntimeServiceStatus.fromMap({ - 'available': true, - 'definition': 'installed-ish', - 'service': 'running', - 'diagnostics': 'invalid', - }), - throwsFormatException, - ); - }); - - test('Android update state accepts progress and installer phases', () { - final status = PlatformUpdateStatus.fromMap({ - 'currentVersion': '0.1.24', - 'latestVersion': '0.1.25', - 'phase': 'downloading', - 'checkedAt': 1, - 'revision': 25, - 'progressPercent': 42.5, - 'message': 'Downloading verified package.', - }); - - expect(status.supported, isTrue); - expect(status.phase, PlatformUpdatePhase.downloading); - expect(status.progressPercent, 42.5); - }); - - test('update state rejects unbounded progress and messages', () { - expect( - () => PlatformUpdateStatus.fromMap({ - 'currentVersion': '0.1.24', - 'phase': 'downloading', - 'progressPercent': 101, - }), - throwsFormatException, - ); - expect( - () => PlatformUpdateStatus.fromMap({ - 'currentVersion': '0.1.24', - 'phase': 'error', - 'message': 'x' * 513, - }), - throwsFormatException, - ); - }); -} diff --git a/apps/flutter/test/protocol/wire_conformance_test.dart b/apps/flutter/test/protocol/wire_conformance_test.dart deleted file mode 100644 index b155f326..00000000 --- a/apps/flutter/test/protocol/wire_conformance_test.dart +++ /dev/null @@ -1,1402 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/protocol/protocol.dart'; - -const _corpusPath = - '../../packages/client/test/fixtures/protocol/omp-app-v1-corpus.json'; -const _operationCapabilitiesPath = - '../../packages/host-wire/test/fixtures/operation-capabilities.json'; - -void main() { - final corpus = _loadCorpus(); - - group('inbound wire conformance', () { - final supportedFamilies = { - 'welcome': isA(), - 'successful-response': isA(), - 'authorization-error': isA(), - 'session-list': isA(), - 'transcript-snapshot': isA(), - 'streaming-event': isA(), - 'confirmation-challenge': isA(), - 'terminal-output': isA(), - 'continuity-gap': isA(), - 'heartbeat-pong': isA(), - 'pair-ok': isA(), - }; - - for (final entry in supportedFamilies.entries) { - test('${entry.key} decodes to its sealed frame family', () { - final fixture = _namedCase(corpus, 'inbound', entry.key); - - expect( - WireDecoder.decode(jsonEncode(fixture['wire'])), - entry.value, - reason: entry.key, - ); - }); - } - - test('every canonical invalidInbound case is rejected', () { - final invalidCases = _caseList(corpus, 'invalidInbound'); - - expect(invalidCases, isNotEmpty); - for (final fixture in invalidCases) { - final name = fixture['name']! as String; - expect( - () => WireDecoder.decode(jsonEncode(fixture['wire'])), - throwsA(isA()), - reason: name, - ); - } - }); - - test('every canonical inbound case rejects a missing required field', () { - const requiredFields = { - 'welcome': 'hostId', - 'successful-response': 'requestId', - 'authorization-error': 'code', - 'session-list': 'cursor', - 'transcript-snapshot': 'entries', - 'streaming-event': 'event', - 'confirmation-challenge': 'confirmationId', - 'terminal-output': 'data', - 'continuity-gap': 'from', - 'heartbeat-pong': 'nonce', - 'pair-ok': 'pairingId', - }; - for (final entry in requiredFields.entries) { - final fixture = _namedCase(corpus, 'inbound', entry.key); - final malformed = _cloneMap(fixture['wire'])..remove(entry.value); - expect( - () => WireDecoder.decode(jsonEncode(malformed)), - throwsA(isA()), - reason: '${entry.key} requires ${entry.value}', - ); - } - }); - - test('additive and unknown event data is preserved but immutable', () { - final fixture = _namedCase(corpus, 'inbound', 'streaming-event'); - final wire = _cloneMap(fixture['wire']); - final event = _asMap(wire['event']); - event['type'] = 'future.message.decorated'; - event['futureData'] = { - 'enabled': true, - 'labels': ['preserved'], - }; - wire['futureEnvelopeData'] = {'generation': 2}; - final expectedEvent = _cloneMap(event); - - final frame = WireDecoder.decode(jsonEncode(wire)); - - expect(frame, isA()); - final eventFrame = frame as EventFrame; - expect(eventFrame.event, expectedEvent); - expect(eventFrame.raw['futureEnvelopeData'], { - 'generation': 2, - }); - expect(() => eventFrame.event['added'] = true, throwsUnsupportedError); - final futureData = _asMap(eventFrame.event['futureData']); - expect(() => futureData['enabled'] = false, throwsUnsupportedError); - final labels = futureData['labels']! as List; - expect(() => labels.add('changed'), throwsUnsupportedError); - expect( - () => eventFrame.raw['futureEnvelopeData'] = null, - throwsUnsupportedError, - ); - }); - - test('transcript image metadata survives durable entry decoding', () { - final fixture = _namedCase(corpus, 'inbound', 'transcript-snapshot'); - final wire = _cloneMap(fixture['wire']); - final entries = (wire['entries']! as List) - .map(_cloneMap) - .toList(growable: false); - wire['entries'] = entries; - final data = _asMap(entries.first['data']); - data['images'] = [ - { - 'sha256': ''.padRight(64, 'a'), - 'mimeType': 'image/png', - }, - ]; - entries.first['data'] = data; - - final frame = WireDecoder.decode(jsonEncode(wire)) as SnapshotFrame; - - expect(frame.entries.first.data['images'], [ - { - 'sha256': ''.padRight(64, 'a'), - 'mimeType': 'image/png', - }, - ]); - }); - - test('wrong protocol version fails at the version boundary', () { - final fixture = _namedCase(corpus, 'inbound', 'welcome'); - final wire = _cloneMap(fixture['wire'])..['v'] = 'omp-app/2'; - - expect( - () => WireDecoder.decode(jsonEncode(wire)), - throwsA( - isA() - .having((error) => error.path, 'path', 'v') - .having( - (error) => error.message, - 'message', - 'protocol version must be exactly omp-app/1', - ), - ), - ); - }); - - test('unknown top-level frame family fails at the type boundary', () { - final fixture = _namedCase(corpus, 'inbound', 'heartbeat-pong'); - final wire = _cloneMap(fixture['wire'])..['type'] = 'future.frame'; - - expect( - () => WireDecoder.decode(jsonEncode(wire)), - throwsA( - isA() - .having((error) => error.path, 'path', 'type') - .having( - (error) => error.message, - 'message', - 'unknown top-level frame family', - ), - ), - ); - }); - - test('inbound frames larger than 4 MiB are rejected before parsing', () { - final oversizedFrame = ''.padRight(4 * 1024 * 1024 + 1, 'x'); - - expect( - () => WireDecoder.decode(oversizedFrame), - throwsA( - isA().having( - (error) => error.message, - 'message', - 'inbound frame exceeds the 4 MiB UTF-8 limit', - ), - ), - ); - }); - - test('exact pinned ServerFrame union has typed sealed dispatch', () { - final frames = _authoritativeServerFrames(corpus); - final matchers = _authoritativeServerMatchers(); - - expect(frames.keys, unorderedEquals(matchers.keys)); - for (final entry in frames.entries) { - final decoded = WireDecoder.decode(jsonEncode(entry.value)); - expect(decoded, matchers[entry.key], reason: entry.key); - expect(() => decoded.raw['changed'] = true, throwsUnsupportedError); - } - }); - test('shared operation capability fixture decodes into typed models', () { - final scenario = _asMap( - jsonDecode(File(_operationCapabilitiesPath).readAsStringSync()), - ); - final catalog = - WireDecoder.decode(jsonEncode(scenario['catalog'])) as ResponseFrame; - final result = catalog.catalogResult!; - - expect(result.revision, 'capabilities-v1'); - expect( - result.operations!.map((operation) => operation.operationId), - [ - 'session.prompt', - 'slash.compact', - 'slash.plan', - 'goal.create', - ], - ); - expect( - result.operations!.map((operation) => operation.execution), - [ - OperationExecution.typed, - OperationExecution.headless, - OperationExecution.terminalOnly, - OperationExecution.unavailable, - ], - ); - expect( - result.operations! - .skip(2) - .map((operation) => operation.disabledReason!.code), - ['terminal_only', 'capability_unavailable'], - ); - expect( - () => result.operations!.add(result.operations!.first), - throwsUnsupportedError, - ); - - final rejections = _asList(scenario['rejections']) - .map((wire) => WireDecoder.decode(jsonEncode(wire)) as ResponseFrame) - .toList(growable: false); - expect(rejections.every((frame) => !frame.ok), isTrue); - expect(rejections.map((frame) => frame.error!.code), [ - 'terminal_only', - 'capability_unavailable', - ]); - }); - - test( - 'catalog decoding preserves missing versus authoritative empty operations', - () { - Map catalog([List? operations]) { - final frame = { - 'v': 'omp-app/1', - 'type': 'catalog', - 'hostId': 'host-alpha', - 'revision': operations == null ? 'legacy' : 'authoritative-empty', - 'items': [], - }; - if (operations != null) frame['operations'] = operations; - return frame; - } - - final legacy = - WireDecoder.decode(jsonEncode(catalog())) as CatalogFrame; - final authoritative = - WireDecoder.decode(jsonEncode(catalog([]))) - as CatalogFrame; - expect(legacy.operations, isNull); - expect(authoritative.operations, isEmpty); - }, - ); - - test( - 'every non-corpus ServerFrame branch rejects a missing requirement', - () { - const requiredFields = { - 'entry': 'entry', - 'agent': 'state', - 'terminal': 'stream', - 'files': 'path', - 'review': 'findings', - 'audit': 'actor', - 'pair.error': 'message', - 'bye': 'retryable', - 'host.watch': 'watchId', - 'session.watch': 'watchId', - 'session.state': 'state', - 'session.delta': 'remove', - 'lease': 'kind', - 'prompt.lease': 'kind', - 'agent.state': 'state', - 'agent.lifecycle': 'lifecycle', - 'agent.progress': 'progress', - 'agent.event': 'event', - 'agent.transcript': 'entries', - 'terminal.exit': 'exitCode', - 'files.list': 'entries', - 'files.read': 'content', - 'files.write': 'revision', - 'files.patch': 'patch', - 'files.diff': 'diff', - 'audit.tail': 'events', - 'audit.event': 'event', - 'catalog': 'items', - 'settings': 'settings', - 'preview.launch': 'url', - 'preview.state': 'state', - 'preview.navigation': 'cursor', - 'preview.capture': 'capture', - 'preview.error': 'code', - }; - final frames = _authoritativeServerFrames(corpus); - for (final entry in requiredFields.entries) { - final malformed = _cloneMap(frames[entry.key])..remove(entry.value); - expect( - () => WireDecoder.decode(jsonEncode(malformed)), - throwsA(isA()), - reason: '${entry.key} requires ${entry.value}', - ); - } - }, - ); - - test('usage and broker command results decode to strict typed models', () { - final responseFixture = _namedCase( - corpus, - 'inbound', - 'successful-response', - ); - final usageWire = _cloneMap(responseFixture['wire']); - usageWire['command'] = 'usage.read'; - usageWire['result'] = { - 'generatedAt': 1720000000000, - 'reports': [ - { - 'provider': 'openai', - 'fetchedAt': 1720000000000, - 'limits': [ - { - 'id': 'requests', - 'label': 'Requests', - 'scope': {'provider': 'openai'}, - 'amount': { - 'used': 4, - 'limit': 10, - 'unit': 'requests', - }, - 'status': 'ok', - }, - ], - 'notes': [], - }, - ], - 'accountsWithoutUsage': [], - 'capacity': { - 'openai': [ - { - 'window': 'team', - 'accounts': 2, - 'usedAccounts': 1.5, - 'remainingAccounts': 0.5, - }, - ], - }, - }; - final usage = WireDecoder.decode(jsonEncode(usageWire)) as ResponseFrame; - - expect(usage.usageReadResult?.reports.single.provider, 'openai'); - expect( - usage.usageReadResult?.reports.single.limits.single.amount.limit, - 10, - ); - expect( - usage.usageReadResult?.capacity['openai']?.single.usedAccounts, - 1.5, - ); - - final brokerWire = _cloneMap(responseFixture['wire']); - brokerWire['command'] = 'broker.status'; - brokerWire['result'] = { - 'state': 'connected', - 'endpoint': 'https://broker.example.test', - 'generation': 3, - }; - final broker = - WireDecoder.decode(jsonEncode(brokerWire)) as ResponseFrame; - - expect(broker.brokerStatusResult?.state, BrokerState.connected); - expect(broker.brokerStatusResult?.generation, 3); - }); - - test('usage and broker results reject unsafe additive data', () { - final responseFixture = _namedCase( - corpus, - 'inbound', - 'successful-response', - ); - final malformedUsage = _cloneMap(responseFixture['wire']); - malformedUsage['command'] = 'usage.read'; - malformedUsage['result'] = { - 'generatedAt': 1, - 'reports': [], - 'accountsWithoutUsage': [], - 'capacity': {}, - 'token': 'secret', - }; - expect( - () => WireDecoder.decode(jsonEncode(malformedUsage)), - throwsA(isA()), - ); - - final malformedBroker = _cloneMap(responseFixture['wire']); - malformedBroker['command'] = 'broker.status'; - malformedBroker['result'] = { - 'state': 'connected', - 'endpoint': 'https://user:secret@broker.example.test', - 'generation': 1, - }; - expect( - () => WireDecoder.decode(jsonEncode(malformedBroker)), - throwsA(isA()), - ); - - final malformedLocalBroker = _cloneMap(responseFixture['wire']); - malformedLocalBroker['command'] = 'broker.status'; - malformedLocalBroker['result'] = { - 'state': 'local', - 'endpoint': 'https://broker.example.test', - 'generation': 1, - }; - expect( - () => WireDecoder.decode(jsonEncode(malformedLocalBroker)), - throwsA(isA()), - ); - }); - - test('ping is client-only and rejected by server dispatch', () { - final fixture = _namedCase(corpus, 'outbound', 'heartbeat-ping'); - expect( - () => WireDecoder.decode(jsonEncode(fixture['wire'])), - throwsA(isA()), - ); - }); - }); - - group('outbound wire conformance', () { - test('hello matches the canonical correlated frame without mutation', () { - final fixture = _namedCase( - corpus, - 'outbound', - 'hello-with-resume-and-authentication', - ); - final message = _asMap(fixture['message']); - final client = _asMap(message['client']); - final authentication = _asMap(message['authentication']); - final requestedFeatures = _strings(message['requestedFeatures']); - final capabilities = _strings(message['capabilities']); - final savedCursors = (_asList(message['savedCursors'])).map((value) { - final saved = _asMap(value); - final cursor = _asMap(saved['cursor']); - return SavedCursor( - hostId: saved['hostId']! as String, - sessionId: saved['sessionId']! as String, - cursor: TranscriptCursor( - epoch: cursor['epoch']! as String, - seq: cursor['seq']! as int, - ), - ); - }).toList(); - final requestedFeaturesBefore = List.of(requestedFeatures); - final capabilitiesBefore = List.of(capabilities); - final savedCursorsBefore = List.of(savedCursors); - - final encoded = WireEncoder.hello( - client: ClientIdentity( - name: client['name']! as String, - version: client['version']! as String, - build: client['build']! as String, - platform: client['platform']! as String, - ), - requestedFeatures: requestedFeatures, - savedCursors: savedCursors, - capabilities: capabilities, - authentication: DeviceAuthentication( - deviceId: authentication['deviceId']! as String, - deviceToken: authentication['deviceToken']! as String, - ), - ); - - expect( - jsonDecode(encoded), - fixture['wire'], - reason: fixture['name']! as String, - ); - expect(requestedFeatures, requestedFeaturesBefore); - expect(capabilities, capabilitiesBefore); - expect(savedCursors, savedCursorsBefore); - }); - - test('session prompt matches the canonical correlated command', () { - final fixture = _namedCase(corpus, 'outbound', 'session-prompt-command'); - final message = _asMap(fixture['message']); - final args = _asMap(message['args']); - final before = jsonEncode(message); - - final encoded = WireEncoder.sessionPrompt( - requestId: message['requestId']! as String, - commandId: message['commandId']! as String, - hostId: message['hostId']! as String, - sessionId: message['sessionId']! as String, - expectedRevision: message['expectedRevision']! as String, - text: args['text']! as String, - ); - - expect( - jsonDecode(encoded), - fixture['wire'], - reason: fixture['name']! as String, - ); - expect(jsonEncode(message), before); - }); - - test('session prompt includes uploaded image references', () { - final encoded = WireEncoder.sessionPrompt( - requestId: 'request-image', - commandId: 'command-image', - hostId: 'host-image', - sessionId: 'session-image', - expectedRevision: 'revision-image', - text: 'Inspect these', - imageIds: const ['image-one', 'image-two'], - ); - - final frame = jsonDecode(encoded) as Map; - expect(frame['args'], { - 'message': 'Inspect these', - 'images': [ - {'imageId': 'image-one'}, - {'imageId': 'image-two'}, - ], - }); - }); - - test( - 'session attach uses canonical command correlation and cursor JSON', - () { - final commandFixture = _namedCase( - corpus, - 'outbound', - 'session-prompt-command', - ); - final snapshotFixture = _namedCase( - corpus, - 'inbound', - 'transcript-snapshot', - ); - final command = _asMap(commandFixture['message']); - final snapshotWire = _asMap(snapshotFixture['wire']); - final cursorJson = _asMap(snapshotWire['cursor']); - final cursor = TranscriptCursor( - epoch: cursorJson['epoch']! as String, - seq: cursorJson['seq']! as int, - ); - final commandBefore = jsonEncode(command); - final cursorBefore = _cloneMap(cursorJson); - final expected = _cloneMap(commandFixture['wire']); - expected - ..remove('expectedRevision') - ..['command'] = 'session.attach' - ..['args'] = {'cursor': _cloneMap(cursorJson)}; - - final encoded = WireEncoder.sessionAttach( - requestId: command['requestId']! as String, - commandId: command['commandId']! as String, - hostId: command['hostId']! as String, - sessionId: command['sessionId']! as String, - cursor: cursor, - ); - - expect(jsonDecode(encoded), expected); - expect(jsonEncode(command), commandBefore); - expect(cursorJson, cursorBefore); - expect( - cursor, - TranscriptCursor( - epoch: cursorBefore['epoch']! as String, - seq: cursorBefore['seq']! as int, - ), - ); - }, - ); - - test('approved-confirmation matches canonical wire without mutation', () { - final fixture = _namedCase(corpus, 'outbound', 'approved-confirmation'); - final message = _asMap(fixture['message']); - final before = jsonEncode(message); - final encoded = WireEncoder.confirm( - requestId: message['requestId']! as String, - confirmationId: message['confirmationId']! as String, - commandId: message['commandId']! as String, - hostId: message['hostId']! as String, - sessionId: message['sessionId']! as String, - decision: message['decision']! as String, - ); - expect( - jsonDecode(encoded), - fixture['wire'], - reason: fixture['name']! as String, - ); - expect(jsonEncode(message), before); - }); - - test('pair-start matches canonical wire without mutation', () { - final fixture = _namedCase(corpus, 'outbound', 'pair-start'); - final message = _asMap(fixture['message']); - final capabilities = _strings(message['requestedCapabilities']); - final before = jsonEncode(message); - final capabilitiesBefore = List.of(capabilities); - final encoded = WireEncoder.pairStart( - requestId: message['requestId']! as String, - code: message['code']! as String, - deviceId: message['deviceId']! as String, - deviceName: message['deviceName']! as String, - platform: message['platform']! as String, - requestedCapabilities: capabilities, - ); - expect( - jsonDecode(encoded), - fixture['wire'], - reason: fixture['name']! as String, - ); - expect(jsonEncode(message), before); - expect(capabilities, capabilitiesBefore); - }); - - test('terminal-input-base64 matches canonical wire without mutation', () { - final fixture = _namedCase(corpus, 'outbound', 'terminal-input-base64'); - final message = _asMap(fixture['message']); - final before = jsonEncode(message); - final encoded = WireEncoder.terminalInput( - hostId: message['hostId']! as String, - sessionId: message['sessionId']! as String, - terminalId: message['terminalId']! as String, - data: message['data']! as String, - encoding: message['encoding']! as String, - ); - expect( - jsonDecode(encoded), - fixture['wire'], - reason: fixture['name']! as String, - ); - expect(jsonEncode(message), before); - }); - - test('terminal-resize matches canonical wire without mutation', () { - final fixture = _namedCase(corpus, 'outbound', 'terminal-resize'); - final message = _asMap(fixture['message']); - final before = jsonEncode(message); - final encoded = WireEncoder.terminalResize( - hostId: message['hostId']! as String, - sessionId: message['sessionId']! as String, - terminalId: message['terminalId']! as String, - cols: message['cols']! as int, - rows: message['rows']! as int, - ); - expect( - jsonDecode(encoded), - fixture['wire'], - reason: fixture['name']! as String, - ); - expect(jsonEncode(message), before); - }); - - test('terminal-close matches canonical wire without mutation', () { - final fixture = _namedCase(corpus, 'outbound', 'terminal-close'); - final message = _asMap(fixture['message']); - final before = jsonEncode(message); - final encoded = WireEncoder.terminalClose( - hostId: message['hostId']! as String, - sessionId: message['sessionId']! as String, - terminalId: message['terminalId']! as String, - reason: message['reason']! as String, - ); - expect( - jsonDecode(encoded), - fixture['wire'], - reason: fixture['name']! as String, - ); - expect(jsonEncode(message), before); - }); - - test('heartbeat-ping matches canonical wire without mutation', () { - final fixture = _namedCase(corpus, 'outbound', 'heartbeat-ping'); - final message = _asMap(fixture['message']); - final before = jsonEncode(message); - final encoded = WireEncoder.ping( - nonce: message['nonce']! as String, - timestamp: message['timestamp']! as String, - ); - expect( - jsonDecode(encoded), - fixture['wire'], - reason: fixture['name']! as String, - ); - expect(jsonEncode(message), before); - }); - - test('bootstrap command encoders keep cursor domains distinct', () { - final promptFixture = _namedCase( - corpus, - 'outbound', - 'session-prompt-command', - ); - final sessionsFixture = _namedCase(corpus, 'inbound', 'session-list'); - final command = _asMap(promptFixture['message']); - final sessionWire = _asMap(sessionsFixture['wire']); - final cursorWire = _asMap(sessionWire['cursor']); - final cursor = SessionIndexCursor( - epoch: cursorWire['epoch']! as String, - seq: cursorWire['seq']! as int, - ); - - expect( - jsonDecode( - WireEncoder.sessionList( - requestId: command['requestId']! as String, - commandId: command['commandId']! as String, - hostId: command['hostId']! as String, - ), - ), - { - 'v': ompAppProtocolVersion, - 'type': 'command', - 'requestId': command['requestId'], - 'commandId': command['commandId'], - 'hostId': command['hostId'], - 'command': 'session.list', - 'args': {}, - }, - ); - expect( - jsonDecode( - WireEncoder.hostWatch( - requestId: command['requestId']! as String, - commandId: command['commandId']! as String, - hostId: command['hostId']! as String, - cursor: cursor, - ), - ), - { - 'v': ompAppProtocolVersion, - 'type': 'command', - 'requestId': command['requestId'], - 'commandId': command['commandId'], - 'hostId': command['hostId'], - 'command': 'host.watch', - 'args': {'cursor': cursorWire}, - }, - ); - }); - - test('session-list result decoder is strict, typed, and immutable', () { - final fixture = _namedCase(corpus, 'inbound', 'session-list'); - final wire = _asMap(fixture['wire']); - final source = { - 'cursor': _cloneMap(wire['cursor']), - 'sessions': jsonDecode(jsonEncode(wire['sessions'])), - }; - final before = jsonEncode(source); - - final decoded = WireDecoder.decodeSessionListResult(source); - - expect(decoded.cursor, isA()); - expect(decoded.sessions, hasLength(1)); - expect(decoded.totalCount, 1); - expect(decoded.truncated, isFalse); - expect(jsonEncode(source), before); - expect(() => decoded.raw['changed'] = true, throwsUnsupportedError); - expect( - () => decoded.sessions.add(decoded.sessions.single), - throwsUnsupportedError, - ); - - final responseFixture = _namedCase( - corpus, - 'inbound', - 'successful-response', - ); - final responseWire = _cloneMap(responseFixture['wire']) - ..['command'] = 'session.list' - ..['result'] = source; - final response = - WireDecoder.decode(jsonEncode(responseWire)) as ResponseFrame; - expect(response.sessionListResult, isA()); - expect(response.sessionListResult?.cursor, decoded.cursor); - - final malformed = _cloneMap(source)..['totalCount'] = 0; - expect( - () => WireDecoder.decodeSessionListResult(malformed), - throwsA(isA()), - ); - }); - - test( - 'transcript search response is decoded into bounded typed results', - () { - final responseWire = - _cloneMap( - _namedCase(corpus, 'inbound', 'successful-response')['wire'], - ) - ..['command'] = 'transcript.search' - ..['result'] = { - 'items': [ - { - 'sessionId': 'session-1', - 'projectId': 'project-1', - 'sessionTitle': 'Search fixture', - 'anchorId': 'entry-1', - 'role': 'assistant', - 'timestamp': '2026-07-19T12:00:00.000Z', - 'snippet': 'fixed the parser', - 'highlights': [ - {'start': 0, 'end': 5}, - ], - }, - ], - 'incomplete': false, - 'index': { - 'state': 'ready', - 'indexedSessions': 4, - 'knownSessions': 4, - 'generation': 'generation-1', - }, - }; - - final response = - WireDecoder.decode(jsonEncode(responseWire)) as ResponseFrame; - final result = response.transcriptSearchResult; - - expect(result, isNotNull); - expect(result?.items.single.sessionTitle, 'Search fixture'); - expect(result?.items.single.role, TranscriptSearchRole.assistant); - expect(result?.items.single.highlights.single.end, 5); - expect(result?.index.state, TranscriptSearchIndexState.ready); - expect( - () => result?.items.add(result.items.single), - throwsUnsupportedError, - ); - - final malformed = _cloneMap(responseWire); - final malformedResult = _cloneMap(malformed['result']); - final malformedItems = _asList(malformedResult['items']); - final malformedItem = _cloneMap(malformedItems.single); - malformedItem['highlights'] = [ - {'start': 0, 'end': 100}, - ]; - malformedResult['items'] = [malformedItem]; - malformed['result'] = malformedResult; - expect( - () => WireDecoder.decode(jsonEncode(malformed)), - throwsA(isA()), - ); - }, - ); - - test('transcript context response validates its anchor row', () { - final responseWire = - _cloneMap( - _namedCase(corpus, 'inbound', 'successful-response')['wire'], - ) - ..['command'] = 'transcript.context' - ..['sessionId'] = 'session-1' - ..['result'] = { - 'anchorId': 'entry-2', - 'rows': [ - { - 'anchorId': 'entry-1', - 'role': 'user', - 'timestamp': '2026-07-19T11:59:00.000Z', - 'text': 'Please fix the parser.', - }, - { - 'anchorId': 'entry-2', - 'role': 'assistant', - 'timestamp': '2026-07-19T12:00:00.000Z', - 'text': 'Fixed the parser.', - }, - ], - 'anchorIndex': 1, - 'hasBefore': true, - 'hasAfter': false, - 'generation': 'generation-1', - }; - - final response = - WireDecoder.decode(jsonEncode(responseWire)) as ResponseFrame; - final context = response.transcriptContextResult; - - expect(context?.rows, hasLength(2)); - expect(context?.rows[context.anchorIndex].anchorId, 'entry-2'); - expect(context?.hasBefore, isTrue); - - final malformed = _cloneMap(responseWire); - final malformedResult = _cloneMap(malformed['result']) - ..['anchorIndex'] = 0; - malformed['result'] = malformedResult; - expect( - () => WireDecoder.decode(jsonEncode(malformed)), - throwsA(isA()), - ); - }); - - test('transcript page response is bounded, typed, and immutable', () { - final entry = { - 'id': 'entry-latest', - 'parentId': null, - 'hostId': 'host-test', - 'sessionId': 'session-1', - 'kind': 'message', - 'timestamp': '2026-07-20T09:00:00.000Z', - 'data': { - 'role': 'assistant', - 'text': 'Latest durable response', - }, - }; - final responseWire = - _cloneMap( - _namedCase(corpus, 'inbound', 'successful-response')['wire'], - ) - ..['command'] = 'transcript.page' - ..['sessionId'] = 'session-1' - ..['result'] = { - 'entries': [entry], - 'nextCursor': 'older-page', - 'hasMore': true, - 'generation': 'generation-1', - }; - - final response = - WireDecoder.decode(jsonEncode(responseWire)) as ResponseFrame; - final page = response.transcriptPageResult; - - expect(page?.entries.single.id, 'entry-latest'); - expect(page?.entries.single.data['text'], 'Latest durable response'); - expect(page?.nextCursor, 'older-page'); - expect(page?.hasMore, isTrue); - expect(page?.generation, 'generation-1'); - expect( - () => page?.entries.add(page.entries.single), - throwsUnsupportedError, - ); - - final malformed = _cloneMap(responseWire); - final malformedResult = _cloneMap(malformed['result']) - ..['entries'] = List.filled(129, entry); - malformed['result'] = malformedResult; - expect( - () => WireDecoder.decode(jsonEncode(malformed)), - throwsA(isA()), - ); - - final inconsistent = _cloneMap(responseWire); - inconsistent['result'] = { - ..._cloneMap(inconsistent['result']), - 'hasMore': false, - }; - expect( - () => WireDecoder.decode(jsonEncode(inconsistent)), - throwsA(isA()), - ); - }); - }); -} - -Map _authoritativeServerMatchers() => { - 'welcome': isA(), - 'sessions': isA(), - 'snapshot': isA(), - 'entry': isA(), - 'event': isA(), - 'agent': isA(), - 'terminal': isA(), - 'files': isA(), - 'review': isA(), - 'audit': isA(), - 'pair.ok': isA(), - 'pair.error': isA(), - 'confirmation': isA(), - 'response': isA(), - 'gap': isA(), - 'error': isA(), - 'pong': isA(), - 'bye': isA(), - 'host.watch': isA(), - 'session.watch': isA(), - 'session.state': isA(), - 'session.delta': isA(), - 'lease': isA(), - 'prompt.lease': isA(), - 'agent.state': isA(), - 'agent.lifecycle': isA(), - 'agent.progress': isA(), - 'agent.event': isA(), - 'agent.transcript': isA(), - 'terminal.output': isA(), - 'terminal.exit': isA(), - 'files.list': isA(), - 'files.read': isA(), - 'files.write': isA(), - 'files.patch': isA(), - 'files.diff': isA(), - 'audit.tail': isA(), - 'audit.event': isA(), - 'catalog': isA(), - 'settings': isA(), - 'preview.launch': isA(), - 'preview.state': isA(), - 'preview.navigation': isA(), - 'preview.capture': isA(), - 'preview.error': isA(), -}; - -Map> _authoritativeServerFrames( - Map corpus, -) { - final frames = >{}; - for (final fixture in _caseList(corpus, 'inbound')) { - final wire = _cloneMap(fixture['wire']); - frames[wire['type']! as String] = wire; - } - const cursor = {'epoch': 'epoch-union', 'seq': 7}; - const hostId = 'host-union'; - const sessionId = 'session-union'; - const revision = 'revision-union'; - const entry = { - 'id': 'entry-union', - 'parentId': null, - 'hostId': hostId, - 'sessionId': sessionId, - 'kind': 'message', - 'timestamp': '2030-01-01T00:00:00.000Z', - 'data': {'role': 'assistant', 'text': 'union'}, - }; - - Map frame( - String type, [ - Map fields = const {}, - ]) => {'v': ompAppProtocolVersion, 'type': type, ...fields}; - - Map owned([Map fields = const {}]) => - {'hostId': hostId, 'sessionId': sessionId, ...fields}; - - frames.addAll(>{ - 'entry': frame( - 'entry', - owned({ - 'cursor': cursor, - 'revision': revision, - 'entry': entry, - }), - ), - 'agent': frame( - 'agent', - owned({ - 'agentId': 'agent-union', - 'state': 'running', - 'progress': 0.5, - 'detail': {'step': 'decode'}, - }), - ), - 'terminal': frame( - 'terminal', - owned({ - 'terminalId': 'terminal-union', - 'stream': 'stdout', - 'data': 'output', - }), - ), - 'files': frame( - 'files', - owned({ - 'path': 'src/main.dart', - 'content': 'void main() {}', - 'truncated': false, - }), - ), - 'review': frame( - 'review', - owned({ - 'reviewId': 'review-union', - 'status': 'complete', - 'findings': [ - {'severity': 'info'}, - ], - }), - ), - 'audit': frame('audit', { - 'hostId': hostId, - 'sessionId': sessionId, - 'action': 'session.read', - 'actor': 'device-union', - 'timestamp': '2030-01-01T00:00:00.000Z', - }), - 'pair.error': frame('pair.error', { - 'code': 'PAIRING_INVALID', - 'message': 'pairing rejected', - 'requestId': 'request-union', - }), - 'bye': frame('bye', { - 'code': 'shutdown', - 'reason': 'maintenance', - 'retryable': true, - }), - 'host.watch': frame('host.watch', { - 'watchId': 'watch-host', - 'hostId': hostId, - 'cursor': cursor, - 'state': 'ready', - 'revision': revision, - 'metadata': {'source': 'union'}, - }), - 'session.watch': frame( - 'session.watch', - owned({ - 'watchId': 'watch-session', - 'cursor': cursor, - 'state': 'started', - 'revision': revision, - }), - ), - 'session.state': frame( - 'session.state', - owned({ - 'cursor': cursor, - 'revision': revision, - 'state': 'working', - }), - ), - 'session.delta': frame( - 'session.delta', - owned({ - 'cursor': cursor, - 'revision': revision, - 'remove': sessionId, - }), - ), - 'lease': frame( - 'lease', - owned({ - 'leaseId': 'lease-controller', - 'cursor': cursor, - 'kind': 'controller', - 'state': 'acquired', - 'owner': 'device-union', - 'expiresAt': '2030-01-01T00:10:00.000Z', - }), - ), - 'prompt.lease': frame( - 'prompt.lease', - owned({ - 'leaseId': 'lease-prompt', - 'cursor': cursor, - 'kind': 'prompt', - 'state': 'renewed', - 'owner': 'device-union', - 'expiresAt': '2030-01-01T00:10:00.000Z', - 'revision': revision, - }), - ), - 'agent.state': frame( - 'agent.state', - owned({ - 'agentId': 'agent-union', - 'cursor': cursor, - 'state': 'running', - 'revision': revision, - }), - ), - 'agent.lifecycle': frame( - 'agent.lifecycle', - owned({ - 'agentId': 'agent-union', - 'cursor': cursor, - 'lifecycle': 'completed', - 'revision': revision, - }), - ), - 'agent.progress': frame( - 'agent.progress', - owned({ - 'agentId': 'agent-union', - 'cursor': cursor, - 'progress': 0.75, - 'revision': revision, - }), - ), - 'agent.event': frame( - 'agent.event', - owned({ - 'agentId': 'agent-union', - 'cursor': cursor, - 'event': 'tool.started', - 'revision': revision, - 'data': {'tool': 'read'}, - }), - ), - 'agent.transcript': frame( - 'agent.transcript', - owned({ - 'agentId': 'agent-union', - 'cursor': cursor, - 'entries': [entry], - 'revision': revision, - }), - ), - 'terminal.exit': frame( - 'terminal.exit', - owned({ - 'terminalId': 'terminal-union', - 'cursor': cursor, - 'exitCode': 0, - 'signal': 'SIGTERM', - }), - ), - 'files.list': frame( - 'files.list', - owned({ - 'path': 'src', - 'entries': [ - { - 'path': 'src/main.dart', - 'kind': 'file', - 'size': 64, - 'revision': revision, - }, - ], - 'cursor': cursor, - 'revision': revision, - }), - ), - 'files.read': frame( - 'files.read', - owned({ - 'path': 'src/main.dart', - 'content': 'void main() {}', - 'revision': revision, - }), - ), - 'files.write': frame( - 'files.write', - owned({ - 'path': 'src/main.dart', - 'content': 'void main() {}', - 'revision': revision, - }), - ), - 'files.patch': frame( - 'files.patch', - owned({ - 'path': 'src/main.dart', - 'patch': '@@ -1 +1 @@', - 'revision': revision, - }), - ), - 'files.diff': frame( - 'files.diff', - owned({ - 'path': 'src/main.dart', - 'diff': '@@ -1 +1 @@', - 'fromRevision': 'revision-before', - 'toRevision': revision, - }), - ), - }); - - final auditEvent = { - 'eventId': 'operation-union', - 'hostId': hostId, - 'sessionId': sessionId, - 'action': 'session.read', - 'actor': 'device-union', - 'timestamp': '2030-01-01T00:00:00.000Z', - }; - frames.addAll(>{ - 'audit.tail': frame('audit.tail', { - 'hostId': hostId, - 'cursor': cursor, - 'events': [auditEvent], - }), - 'audit.event': frame('audit.event', { - 'hostId': hostId, - 'cursor': cursor, - 'event': auditEvent, - }), - 'catalog': frame('catalog', { - 'hostId': hostId, - 'revision': revision, - 'items': [ - { - 'id': 'catalog-union', - 'kind': 'tool', - 'name': 'Read', - 'capabilities': ['files.read'], - 'supported': true, - }, - ], - }), - 'settings': frame('settings', { - 'hostId': hostId, - 'revision': revision, - 'settings': {}, - }), - }); - - final preview = { - 'hostId': hostId, - 'sessionId': sessionId, - 'previewId': 'preview-union', - 'state': 'ready', - 'url': 'https://example.test/', - 'revision': revision, - 'cursor': cursor, - 'title': 'Preview', - 'canGoBack': false, - 'canGoForward': true, - 'viewport': { - 'width': 1280, - 'height': 720, - 'deviceScaleFactor': 2, - }, - 'authority': { - 'id': 'isolated', - 'label': 'Isolated', - 'kind': 'isolated-session', - 'requiresExplicitOptIn': false, - }, - 'availableActions': ['navigate', 'capture'], - }; - final capture = { - 'captureId': 'capture-union', - 'mimeType': 'image/png', - 'size': 1024, - 'width': 1280, - 'height': 720, - 'capturedAt': 1893456000000, - 'sha256': ''.padRight(64, 'a'), - }; - frames.addAll(>{ - 'preview.launch': frame('preview.launch', preview), - 'preview.state': frame('preview.state', { - ...preview, - 'error': 'recoverable', - }), - 'preview.navigation': frame('preview.navigation', preview), - 'preview.capture': frame('preview.capture', { - ...preview, - 'capture': capture, - }), - 'preview.error': frame( - 'preview.error', - owned({ - 'previewId': 'preview-union', - 'cursor': cursor, - 'revision': revision, - 'code': 'capture_failed', - 'message': 'capture failed', - }), - ), - }); - return frames; -} - -Map _loadCorpus() { - final file = File(_corpusPath); - if (!file.existsSync()) { - throw StateError('Canonical protocol corpus not found at $_corpusPath'); - } - return jsonDecode(file.readAsStringSync()) as Map; -} - -List> _caseList( - Map corpus, - String section, -) => _asList(corpus[section]).map(_asMap).toList(growable: false); - -Map _namedCase( - Map corpus, - String section, - String name, -) => _caseList( - corpus, - section, -).singleWhere((fixture) => fixture['name'] == name); - -Map _cloneMap(Object? value) => - jsonDecode(jsonEncode(value)) as Map; - -Map _asMap(Object? value) => value! as Map; - -List _asList(Object? value) => value! as List; - -List _strings(Object? value) => _asList(value).cast().toList(); diff --git a/apps/flutter/test/ui/command_palette_test.dart b/apps/flutter/test/ui/command_palette_test.dart deleted file mode 100644 index 9b040090..00000000 --- a/apps/flutter/test/ui/command_palette_test.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/ui/t4_app.dart'; - -void main() { - testWidgets('command palette filters, runs on Enter, and closes', ( - tester, - ) async { - final ran = []; - final commands = [ - PaletteCommand( - id: 'open-settings', - title: 'Open Settings', - shortcutLabel: '⌘,', - run: () => ran.add('open-settings'), - ), - PaletteCommand( - id: 'new-session', - title: 'New Session', - shortcutLabel: '⌘N', - run: () => ran.add('new-session'), - ), - PaletteCommand( - id: 'toggle-inbox', - title: 'Toggle Inbox', - run: () => ran.add('toggle-inbox'), - ), - ]; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) => Center( - child: ElevatedButton( - onPressed: () => - showCommandPalette(context, commands: commands), - child: const Text('Palette'), - ), - ), - ), - ), - ), - ); - - await tester.tap(find.text('Palette')); - await tester.pumpAndSettle(); - - // All three commands listed initially. - expect(find.byType(TextField), findsOneWidget); - expect(find.textContaining('Session'), findsOneWidget); - - // Filter down to just "New Session" via word-prefix fuzzy match. - await tester.enterText(find.byType(TextField), 'new ses'); - await tester.pump(); - expect( - find.byKey(const ValueKey('palette-command-new-session')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('palette-command-open-settings')), - findsNothing, - ); - - // Enter runs the single remaining command and closes the dialog. - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pumpAndSettle(); - - expect(ran, ['new-session']); - expect(find.byType(TextField), findsNothing); - }); -} diff --git a/apps/flutter/test/ui/host_flow_test.dart b/apps/flutter/test/ui/host_flow_test.dart deleted file mode 100644 index 2128227b..00000000 --- a/apps/flutter/test/ui/host_flow_test.dart +++ /dev/null @@ -1,2075 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/client/app_state.dart'; -import 'package:t4code/src/host/host_profile.dart'; -import 'package:t4code/src/protocol/protocol.dart'; -import 'package:t4code/src/ui/t4_app.dart'; - -void main() { - const compactPhone = Size(390, 844); - const compactDesktop = Size(979, 800); - const wideDesktop = Size(980, 800); - - Future pumpApp( - WidgetTester tester, { - required T4ViewState state, - required _FakeActions actions, - required Size size, - bool credentialsAreVolatile = false, - }) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = size; - addTearDown(tester.view.reset); - await tester.pumpWidget( - T4App( - state: state, - actions: actions, - credentialsAreVolatile: credentialsAreVolatile, - ), - ); - await tester.pump(); - } - - T4ViewState keyboardTranscriptState({ - List? messages, - bool transcriptHistoryHasMore = false, - bool transcriptHistoryLoading = false, - String? transcriptHistoryError, - bool transcriptTailFromCache = false, - }) { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - return T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'Keyboard inset test', - revision: 'revision-alpha', - status: 'idle', - ), - ], - transcriptHistoryHasMore: transcriptHistoryHasMore, - transcriptHistoryLoading: transcriptHistoryLoading, - transcriptHistoryError: transcriptHistoryError, - transcriptTailFromCache: transcriptTailFromCache, - messages: - messages ?? - List.generate( - 24, - (index) => TranscriptMessage( - id: 'message-$index', - role: index.isEven ? MessageRole.user : MessageRole.assistant, - text: 'Transcript message ${index + 1}', - ), - ), - ); - } - - Finder transcriptList() => find.byWidgetPredicate( - (widget) => - widget is ListView && - widget.keyboardDismissBehavior == - ScrollViewKeyboardDismissBehavior.onDrag, - ); - - group('shared T4 theme', () { - testWidgets('applies canonical neutral light tokens and typography', ( - tester, - ) async { - await pumpApp( - tester, - state: const T4ViewState( - connectionPhase: ConnectionPhase.disconnected, - themePreference: T4ThemePreference.light, - ), - actions: _FakeActions(), - size: compactPhone, - ); - - final theme = Theme.of(tester.element(find.byType(Scaffold).first)); - final semantic = theme.extension()!; - expect(theme.colorScheme.primary, const Color(0xffb8245b)); - expect(theme.colorScheme.surface, const Color(0xffffffff)); - expect(theme.colorScheme.onSurface, const Color(0xff262626)); - expect(theme.textTheme.bodyMedium?.fontFamily, 'DM Sans'); - expect(semantic.brand, const Color(0xffe83174)); - expect(semantic.statusDone, const Color(0xff009966)); - }); - - testWidgets('applies canonical neutral dark tokens', (tester) async { - await pumpApp( - tester, - state: const T4ViewState( - connectionPhase: ConnectionPhase.disconnected, - themePreference: T4ThemePreference.dark, - ), - actions: _FakeActions(), - size: compactPhone, - ); - - final theme = Theme.of(tester.element(find.byType(Scaffold).first)); - final semantic = theme.extension()!; - expect(theme.colorScheme.primary, const Color(0xfff67399)); - expect(theme.colorScheme.surface, const Color(0xff161616)); - expect(theme.colorScheme.onSurface, const Color(0xfff5f5f5)); - expect(semantic.brand, const Color(0xffe83174)); - expect(semantic.statusDone, const Color(0xff00d492)); - }); - }); - - testWidgets('labels compaction entries as earlier chat summaries', ( - tester, - ) async { - await pumpApp( - tester, - state: keyboardTranscriptState( - messages: const [ - TranscriptMessage( - id: 'compaction', - role: MessageRole.system, - kind: TranscriptKind.compaction, - text: 'Recovered compacted history', - ), - ], - ), - actions: _FakeActions(), - size: compactPhone, - ); - - expect(find.text('EARLIER CHAT SUMMARY'), findsOneWidget); - expect(find.text('SYSTEM'), findsNothing); - }); - - group('host onboarding', () { - testWidgets('shows an empty-host onboarding form', (tester) async { - await pumpApp( - tester, - state: const T4ViewState.disconnected(), - actions: _FakeActions(), - size: compactPhone, - ); - - expect(find.text('Connect to T4'), findsOneWidget); - expect(find.text('Tailnet HTTPS address'), findsOneWidget); - expect(find.text('Profile ID (optional)'), findsOneWidget); - expect(find.widgetWithText(FilledButton, 'Add host'), findsOneWidget); - expect(find.byType(SafeArea), findsWidgets); - }); - - testWidgets('surfaces the controller validation error', (tester) async { - final actions = _FakeActions( - addHostError: const FormatException( - 'Use the full Tailscale hostname ending in .ts.net.', - ), - ); - await pumpApp( - tester, - state: const T4ViewState.disconnected(), - actions: actions, - size: compactPhone, - ); - - await tester.enterText( - find.byType(TextField).first, - 'https://not-a-tailnet.example', - ); - await tester.tap(find.widgetWithText(FilledButton, 'Add host')); - await tester.pumpAndSettle(); - - expect( - find.text('Use the full Tailscale hostname ending in .ts.net.'), - findsOneWidget, - ); - expect(actions.addedAddresses, ['https://not-a-tailnet.example']); - }); - - testWidgets('shows progress while adding a host', (tester) async { - final completion = Completer(); - final actions = _FakeActions(addHostCompletion: completion); - await pumpApp( - tester, - state: const T4ViewState.disconnected(), - actions: actions, - size: compactPhone, - ); - - await tester.enterText( - find.byType(TextField).first, - 'https://alpha.tailnet-name.ts.net', - ); - await tester.tap(find.widgetWithText(FilledButton, 'Add host')); - await tester.pump(); - - expect(find.bySemanticsLabel('Adding host'), findsWidgets); - final button = tester.widget(find.byType(FilledButton)); - expect(button.onPressed, isNull); - - completion.complete(); - await tester.pumpAndSettle(); - }); - }); - - testWidgets('host manager switches and removes saved hosts', (tester) async { - final beta = HostProfile.parseTailnetAddress( - 'https://beta.tailnet-name.ts.net', - ); - final alpha = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final directory = HostDirectory.empty().upsert(beta).upsert(alpha); - final actions = _FakeActions(); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: directory, - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - ), - actions: actions, - size: wideDesktop, - ); - - await tester.tap(find.byTooltip('Host menu')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Manage hosts')); - await tester.pumpAndSettle(); - - expect(find.text('Saved hosts'), findsOneWidget); - expect(find.text(alpha.origin), findsOneWidget); - expect(find.text(beta.origin), findsOneWidget); - expect(find.text('Current host · Ready · Paired'), findsOneWidget); - - await tester.tap(find.bySemanticsLabel('Switch to ${beta.label}')); - await tester.pumpAndSettle(); - expect(actions.activatedEndpointKeys, [beta.endpointKey]); - - await tester.ensureVisible(find.widgetWithText(TextButton, 'Remove').last); - await tester.pump(); - expect( - find.byWidgetPredicate( - (widget) => - widget is Semantics && - widget.properties.label == 'Remove ${alpha.label}', - ), - findsOneWidget, - ); - await tester.tap(find.widgetWithText(TextButton, 'Remove').last); - await tester.pumpAndSettle(); - expect(find.text('Remove ${alpha.label}?'), findsOneWidget); - expect( - find.textContaining('pairing credential from this device'), - findsOneWidget, - ); - await tester.tap(find.widgetWithText(FilledButton, 'Remove host')); - await tester.pumpAndSettle(); - expect(actions.removedEndpointKeys, [alpha.endpointKey]); - }); - - testWidgets('pairing-required state submits six digits and clears the form', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.synchronizing, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.pairingRequired, - ), - actions: actions, - size: compactPhone, - ); - - expect(find.text('Pair this device'), findsOneWidget); - expect(find.text(t4PairCommand), findsOneWidget); - final code = List.filled(6, '1').join(); - await tester.enterText(find.byType(TextField), code); - await tester.pump(); - await tester.ensureVisible( - find.widgetWithText(FilledButton, 'Pair device'), - ); - await tester.pump(); - await tester.tap(find.widgetWithText(FilledButton, 'Pair device')); - await tester.pump(); - - expect(actions.pairingCodes, [code]); - final codeField = tester.widget(find.byType(TextField)); - expect(codeField.controller?.text, isEmpty); - expect(find.text(code), findsNothing); - }); - - testWidgets('pairing-required state surfaces the host rejection', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.synchronizing, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.pairingRequired, - errorMessage: - 'Pairing failed (INVALID_CODE). Check the code and try again.', - ), - actions: _FakeActions(), - size: compactPhone, - ); - - expect( - find.textContaining('Pairing failed (INVALID_CODE)'), - findsOneWidget, - ); - }); - - testWidgets('connected host can be deliberately disconnected', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - ), - actions: actions, - size: wideDesktop, - ); - - await tester.tap(find.byTooltip('Host menu')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Disconnect')); - await tester.pumpAndSettle(); - expect(actions.disconnectCalls, 1); - }); - - testWidgets( - 'configured endpoint can reconnect after a deliberate disconnect', - (tester) async { - final actions = _FakeActions(); - await pumpApp( - tester, - state: const T4ViewState( - connectionPhase: ConnectionPhase.disconnected, - targetConfigured: true, - ), - actions: actions, - size: compactPhone, - ); - - expect(find.byTooltip('Connect'), findsOneWidget); - expect(find.text('Connect to T4'), findsNothing); - await tester.tap(find.byTooltip('Connect')); - await tester.pumpAndSettle(); - expect(actions.connectCalls, 1); - }, - ); - - testWidgets('compact layout exposes host manager in the drawer', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.disconnected, - hostDirectory: HostDirectory.empty().upsert(profile), - ), - actions: _FakeActions(), - size: compactPhone, - ); - - expect(find.byTooltip('Open navigation'), findsOneWidget); - await tester.tap(find.byTooltip('Open navigation')); - await tester.pumpAndSettle(); - - expect(find.text('Navigation'), findsOneWidget); - expect(find.byTooltip('Host menu'), findsOneWidget); - expect(find.byType(Drawer), findsOneWidget); - }); - - testWidgets('left-edge swipe opens compact session navigation', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - ), - actions: _FakeActions(), - size: compactPhone, - ); - - await tester.dragFrom(const Offset(36, 420), const Offset(260, 0)); - await tester.pumpAndSettle(); - - expect(find.text('Navigation'), findsOneWidget); - expect(find.byTooltip('Host menu'), findsOneWidget); - }); - - testWidgets('unsigned macOS development mode is visibly identified', ( - tester, - ) async { - await pumpApp( - tester, - state: const T4ViewState.disconnected(), - actions: _FakeActions(), - size: compactPhone, - credentialsAreVolatile: true, - ); - - expect( - find.text('Unsigned development · credentials reset on quit'), - findsOneWidget, - ); - }); - - testWidgets('responsive split changes from drawer to rail at 980 pixels', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final state = T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - ); - final actions = _FakeActions(); - - await pumpApp(tester, state: state, actions: actions, size: compactDesktop); - expect(find.byTooltip('Open navigation'), findsOneWidget); - - tester.view.physicalSize = wideDesktop; - await tester.pumpWidget( - T4App(state: state, actions: actions, credentialsAreVolatile: false), - ); - await tester.pumpAndSettle(); - - expect(find.byTooltip('Open navigation'), findsNothing); - expect(find.byTooltip('Host menu'), findsOneWidget); - expect(find.text('T4'), findsOneWidget); - }); - testWidgets( - 'session rail groups projects, searches, creates, archives, and restores', - (tester) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - final state = T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'First investigation', - revision: 'revision-alpha', - status: 'idle', - ), - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-beta', - projectId: 'project-beta', - projectName: 'Project Beta', - title: 'Second investigation', - revision: 'revision-beta', - status: 'idle', - ), - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-archived', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'Archived investigation', - revision: 'revision-archived', - status: 'closed', - archivedAt: '2026-07-18T00:00:00.000Z', - ), - ], - ); - await pumpApp(tester, state: state, actions: actions, size: wideDesktop); - - expect(find.text('PROJECT ALPHA'), findsOneWidget); - expect(find.text('PROJECT BETA'), findsOneWidget); - expect(find.text('Archived investigation'), findsNothing); - - await tester.enterText( - find.widgetWithText(TextField, 'Search sessions'), - 'second', - ); - await tester.pump(); - expect( - find.ancestor( - of: find.text('First investigation'), - matching: find.byType(ListTile), - ), - findsNothing, - ); - expect( - find.ancestor( - of: find.text('Second investigation'), - matching: find.byType(ListTile), - ), - findsOneWidget, - ); - await tester.tap(find.byTooltip('Clear search')); - await tester.pump(); - - await tester.tap(find.byTooltip('New session')); - await tester.pumpAndSettle(); - expect(find.text('New session'), findsOneWidget); - await tester.enterText(find.byType(TextField).last, 'Fresh session'); - await tester.tap(find.widgetWithText(FilledButton, 'Create')); - await tester.pumpAndSettle(); - expect(actions.createdSessions, <({String projectId, String? title})>[ - (projectId: 'project-alpha', title: 'Fresh session'), - ]); - - await tester.tap(find.byTooltip('Session actions').first); - await tester.pumpAndSettle(); - await tester.tap(find.text('Archive').last); - await tester.pumpAndSettle(); - expect(actions.archivedSessionIds, ['session-alpha']); - - await tester.tap(find.text('Archived')); - await tester.pumpAndSettle(); - expect(find.text('Archived investigation'), findsOneWidget); - await tester.tap(find.byTooltip('Session actions').first); - await tester.pumpAndSettle(); - await tester.tap(find.text('Restore').last); - await tester.pumpAndSettle(); - expect(actions.restoredSessionIds, ['session-archived']); - }, - ); - testWidgets('new-session dialog fits compact phones', (tester) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'First investigation', - revision: 'revision-alpha', - status: 'idle', - ), - ], - ), - actions: actions, - size: compactPhone, - ); - - await tester.tap(find.byTooltip('Open navigation')); - await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('New session')); - await tester.pumpAndSettle(); - expect(tester.takeException(), isNull); - await tester.enterText( - find.byType(TextField).last, - 'T4_IOS_SIM_ACCEPTANCE_20260719', - ); - await tester.pump(); - expect(tester.takeException(), isNull); - await tester.tap(find.widgetWithText(FilledButton, 'Create')); - await tester.pumpAndSettle(); - expect(tester.takeException(), isNull); - }); - testWidgets( - 'composer preserves per-session drafts and exposes turn controls', - (tester) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - T4ViewState stateFor( - String selectedSessionId, { - bool turnActive = false, - }) => T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: selectedSessionId, - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'T4_IOS_SIM_ACCEPTANCE_20260719', - revision: 'revision-alpha', - status: 'idle', - ), - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-beta', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'Second investigation', - revision: 'revision-beta', - status: 'idle', - ), - ], - composer: SessionComposerState( - modelLabel: 'openai-codex/gpt-5.6-sol', - modelSelector: 'fixture/model', - modelChoices: const [ - ComposerModelChoice( - label: 'Fixture model', - selector: 'fixture/model', - ), - ], - thinking: 'medium', - thinkingLevels: const ['off', 'medium', 'high'], - fastAvailable: true, - turnActive: turnActive, - queuedFollowUpCount: turnActive ? 2 : 0, - ), - ); - - await pumpApp( - tester, - state: stateFor('session-alpha'), - actions: actions, - size: compactPhone, - ); - await tester.enterText(find.byType(TextField).last, 'Alpha draft'); - - await tester.pumpWidget( - T4App( - state: stateFor('session-beta'), - actions: actions, - credentialsAreVolatile: false, - ), - ); - await tester.pumpAndSettle(); - expect(find.widgetWithText(TextField, 'Alpha draft'), findsNothing); - await tester.enterText(find.byType(TextField).last, 'Beta draft'); - - await tester.pumpWidget( - T4App( - state: stateFor('session-alpha', turnActive: true), - actions: actions, - credentialsAreVolatile: false, - ), - ); - await tester.pumpAndSettle(); - - expect(find.widgetWithText(TextField, 'Alpha draft'), findsOneWidget); - expect(find.text('openai-codex/gpt-5.6-sol'), findsOneWidget); - expect(find.text('medium'), findsOneWidget); - expect(find.text('Stop'), findsOneWidget); - expect(find.text('Queue (2)'), findsOneWidget); - - // The circular send button doubles as the steer affordance while a - // turn is active. - await tester.tap(find.byTooltip('Steer')); - await tester.pumpAndSettle(); - expect(actions.submittedPrompts, ['Alpha draft']); - }, - ); - testWidgets( - 'composer stays disabled when prompt permission was not granted', - (tester) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final state = T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: const {'sessions.read', 'catalog.read'}, - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'Read-only session', - revision: 'revision-alpha', - status: 'idle', - ), - ], - ); - - await pumpApp( - tester, - state: state, - actions: _FakeActions(), - size: compactPhone, - ); - await tester.enterText(find.byType(TextField).last, 'Cannot send'); - await tester.pump(); - final send = tester.widget( - find.ancestor( - of: find.byIcon(Icons.arrow_upward), - matching: find.byType(IconButton), - ), - ); - expect(send.onPressed, isNull); - }, - ); - testWidgets( - 'Fast toggles from the model selector menu and reflects the current state', - (tester) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - T4ViewState stateFor({required bool fastEnabled}) => T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'Fast toggle state', - revision: 'revision-alpha', - status: 'idle', - ), - ], - composer: SessionComposerState( - modelLabel: 'Fixture model', - modelSelector: 'fixture/model', - modelChoices: const [ - ComposerModelChoice( - label: 'Fixture model', - selector: 'fixture/model', - ), - ], - thinking: 'medium', - thinkingLevels: const ['off', 'medium', 'high'], - fastAvailable: true, - fastEnabled: fastEnabled, - ), - ); - - // Fast lives inside the model selector menu as a checkable item; it has - // no standalone chip in the composer row. - await pumpApp( - tester, - state: stateFor(fastEnabled: false), - actions: actions, - size: compactPhone, - ); - await tester.pumpAndSettle(); - expect(find.text('Fast'), findsNothing); - - await tester.tap(find.text('Fixture model')); - await tester.pumpAndSettle(); - final offItem = tester.widget( - find.widgetWithText(CheckboxMenuButton, 'Fast'), - ); - expect(offItem.value, isFalse); - - // Activating the unchecked item requests enabling Fast. - await tester.tap(find.text('Fast')); - await tester.pumpAndSettle(); - expect(actions.selectedFastModes, [true]); - - // On: the menu item reads as checked. - await tester.pumpWidget( - T4App( - state: stateFor(fastEnabled: true), - actions: actions, - credentialsAreVolatile: false, - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Fixture model')); - await tester.pumpAndSettle(); - final onItem = tester.widget( - find.widgetWithText(CheckboxMenuButton, 'Fast'), - ); - expect(onItem.value, isTrue); - }, - ); - - testWidgets('slash menu finds aliases and explains terminal-only commands', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final state = T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'Capability-aware slash menu', - revision: 'revision-alpha', - status: 'idle', - ), - ], - composer: const SessionComposerState( - slashCommands: [ - ComposerSlashCommand( - name: '/compact', - aliases: ['/compress'], - description: 'Compact the active conversation', - insert: '/compact ', - ), - ComposerSlashCommand( - name: '/plan', - description: 'Toggle plan mode', - insert: '/plan ', - disabledReason: '/plan requires the OMP terminal interface.', - ), - ], - ), - ); - - await pumpApp( - tester, - state: state, - actions: _FakeActions(), - size: compactPhone, - ); - await tester.enterText(find.byType(TextField).last, '/'); - await tester.pump(); - expect(find.text('/compact'), findsOneWidget); - expect(find.text('/plan'), findsOneWidget); - expect( - find.text('/plan requires the OMP terminal interface.'), - findsOneWidget, - ); - - await tester.enterText(find.byType(TextField).last, '/compress'); - await tester.pump(); - expect(find.text('/compact'), findsOneWidget); - expect(find.text('/plan'), findsNothing); - }); - testWidgets('keeps the latest message visible when the keyboard opens', ( - tester, - ) async { - await pumpApp( - tester, - state: keyboardTranscriptState(), - actions: _FakeActions(), - size: compactPhone, - ); - await tester.pumpAndSettle(); - - final list = transcriptList(); - final scrollable = tester.state( - find.descendant(of: list, matching: find.byType(Scrollable)).first, - ); - expect( - scrollable.position.pixels, - greaterThanOrEqualTo(scrollable.position.maxScrollExtent - 0.5), - ); - - tester.view.viewInsets = const FakeViewPadding(bottom: 320); - await tester.pump(); - await tester.pump(); - - final resizedScrollable = tester.state( - find.descendant(of: list, matching: find.byType(Scrollable)).first, - ); - expect( - resizedScrollable.position.pixels, - greaterThanOrEqualTo(resizedScrollable.position.maxScrollExtent - 0.5), - ); - }); - - testWidgets('a user drag cancels bottom follow before viewport changes', ( - tester, - ) async { - await pumpApp( - tester, - state: keyboardTranscriptState(), - actions: _FakeActions(), - size: compactPhone, - ); - await tester.pumpAndSettle(); - - final list = transcriptList(); - final scrollable = tester.state( - find.descendant(of: list, matching: find.byType(Scrollable)).first, - ); - final gesture = await tester.startGesture(tester.getCenter(list)); - await gesture.moveBy(const Offset(0, 48)); - await tester.pump(); - await gesture.moveBy(const Offset(0, 48)); - await tester.pump(); - final positionAfterDrag = scrollable.position.pixels; - expect( - scrollable.position.maxScrollExtent - positionAfterDrag, - greaterThan(0), - ); - - tester.view.viewInsets = const FakeViewPadding(bottom: 80); - await tester.pump(); - await tester.pump(); - - expect(scrollable.position.pixels, closeTo(positionAfterDrag, 0.5)); - await gesture.up(); - }); - - testWidgets('a desktop wheel scroll cancels bottom follow', (tester) async { - await pumpApp( - tester, - state: keyboardTranscriptState(), - actions: _FakeActions(), - size: compactDesktop, - ); - await tester.pumpAndSettle(); - - final list = transcriptList(); - final scrollable = tester.state( - find.descendant(of: list, matching: find.byType(Scrollable)).first, - ); - await tester.sendEventToBinding( - PointerScrollEvent( - position: tester.getCenter(list), - scrollDelta: const Offset(0, -320), - ), - ); - await tester.pump(); - final positionAfterWheel = scrollable.position.pixels; - expect( - scrollable.position.maxScrollExtent - positionAfterWheel, - greaterThan(96), - ); - - tester.view.viewInsets = const FakeViewPadding(bottom: 80); - await tester.pump(); - await tester.pump(); - - expect(scrollable.position.pixels, closeTo(positionAfterWheel, 0.5)); - }); - - testWidgets('preserves transcript history position when the keyboard opens', ( - tester, - ) async { - await pumpApp( - tester, - state: keyboardTranscriptState(), - actions: _FakeActions(), - size: compactPhone, - ); - await tester.pumpAndSettle(); - - final list = transcriptList(); - await tester.drag(list, const Offset(0, 320)); - await tester.pumpAndSettle(); - final scrollable = tester.state( - find.descendant(of: list, matching: find.byType(Scrollable)).first, - ); - final positionBeforeKeyboard = scrollable.position.pixels; - expect( - scrollable.position.maxScrollExtent - positionBeforeKeyboard, - greaterThan(96), - ); - - tester.view.viewInsets = const FakeViewPadding(bottom: 320); - await tester.pump(); - await tester.pump(); - - final resizedScrollable = tester.state( - find.descendant(of: list, matching: find.byType(Scrollable)).first, - ); - expect( - resizedScrollable.position.pixels, - closeTo(positionBeforeKeyboard, 0.5), - ); - }); - - testWidgets('older transcript pages preserve the visible history anchor', ( - tester, - ) async { - final actions = _FakeActions(); - final current = List.generate( - 24, - (index) => TranscriptMessage( - id: 'message-$index', - role: index.isEven ? MessageRole.user : MessageRole.assistant, - text: 'Transcript message ${index + 1}', - ), - ); - await pumpApp( - tester, - state: keyboardTranscriptState( - messages: current, - transcriptHistoryHasMore: true, - ), - actions: actions, - size: compactPhone, - ); - await tester.pumpAndSettle(); - - final list = transcriptList(); - await tester.drag(list, const Offset(0, 320)); - await tester.pumpAndSettle(); - final scrollable = tester.state( - find.descendant(of: list, matching: find.byType(Scrollable)).first, - ); - final distanceFromEnd = - scrollable.position.maxScrollExtent - scrollable.position.pixels; - expect(distanceFromEnd, greaterThan(96)); - - final older = List.generate( - 4, - (index) => TranscriptMessage( - id: 'older-$index', - role: MessageRole.assistant, - text: 'Older transcript message ${index + 1}', - ), - ); - await pumpApp( - tester, - state: keyboardTranscriptState( - messages: [...older, ...current], - transcriptHistoryHasMore: true, - ), - actions: actions, - size: compactPhone, - ); - await tester.pumpAndSettle(); - - expect( - scrollable.position.maxScrollExtent - scrollable.position.pixels, - closeTo(distanceFromEnd, 0.5), - ); - await tester.drag(list, const Offset(0, 2000)); - await tester.pumpAndSettle(); - expect(find.text('Load earlier messages'), findsOneWidget); - await tester.tap(find.text('Load earlier messages')); - await tester.pump(); - expect(actions.loadEarlierCalls, 1); - }); - - testWidgets('labels a cached transcript until the live refresh arrives', ( - tester, - ) async { - await pumpApp( - tester, - state: keyboardTranscriptState(transcriptTailFromCache: true), - actions: _FakeActions(), - size: compactPhone, - ); - - expect( - find.text( - 'Showing encrypted saved messages while the live transcript connects.', - ), - findsOneWidget, - ); - }); - - testWidgets('collapses completed tool runs into a work group', ( - tester, - ) async { - await pumpApp( - tester, - state: keyboardTranscriptState( - messages: const [ - TranscriptMessage( - id: 'message-user', - role: MessageRole.user, - text: 'Fix the flaky test.', - ), - TranscriptMessage( - id: 'message-write', - role: MessageRole.tool, - kind: TranscriptKind.tool, - text: '', - toolName: 'files.write', - toolTitle: 'Write lib/main.dart', - toolArguments: '{"path": "lib/main.dart", "content": ""}', - toolSucceeded: true, - ), - TranscriptMessage( - id: 'message-test', - role: MessageRole.tool, - kind: TranscriptKind.tool, - text: '', - toolName: 'terminal.run', - toolTitle: 'Run flutter test', - toolArguments: '{"command": "flutter test"}', - toolSucceeded: true, - ), - TranscriptMessage( - id: 'message-assistant', - role: MessageRole.assistant, - text: 'Done.', - ), - ], - ), - actions: _FakeActions(), - size: compactPhone, - ); - - expect(find.text('Worked · 2 steps'), findsOneWidget); - expect(find.text('Edited 1 file'), findsOneWidget); - expect(find.text('main.dart'), findsOneWidget); - expect(find.text('Write lib/main.dart'), findsNothing); - expect(find.text('Run flutter test'), findsNothing); - - await tester.tap(find.text('Worked · 2 steps')); - await tester.pumpAndSettle(); - expect(find.text('Write lib/main.dart'), findsOneWidget); - expect(find.text('Run flutter test'), findsOneWidget); - }); - - testWidgets('renders actionable approvals inline below the transcript', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - final approval = AttentionItem( - key: 'session-alpha:approval:approval-inline', - kind: AttentionKind.approval, - sessionId: 'session-alpha', - sessionTitle: 'First investigation', - revision: 'revision-alpha', - title: 'Allow file write?', - summary: 'OMP wants to update lib/main.dart.', - at: DateTime.utc(2026, 7, 21), - requestId: 'approval-inline', - actionable: true, - ); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'First investigation', - revision: 'revision-alpha', - status: 'active', - ), - ], - messages: const [ - TranscriptMessage( - id: 'message-user', - role: MessageRole.user, - text: 'Please update main.dart.', - ), - TranscriptMessage( - id: 'message-assistant', - role: MessageRole.assistant, - text: 'Requesting write access now.', - ), - ], - attentionItems: [approval], - ), - actions: actions, - size: compactPhone, - ); - - expect(find.text('Allow file write?'), findsOneWidget); - await tester.tap(find.widgetWithText(FilledButton, 'Approve')); - await tester.pump(); - expect(actions.attentionResponses, hasLength(1)); - expect( - actions.attentionResponses.single.response.decision, - AttentionDecision.approve, - ); - }); - - testWidgets('attention inbox exposes decisions and agent updates', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - final approval = AttentionItem( - key: 'session-alpha:approval:approval-1', - kind: AttentionKind.approval, - sessionId: 'session-alpha', - sessionTitle: 'First investigation', - revision: 'revision-alpha', - title: 'Allow file write?', - summary: 'OMP wants to update lib/main.dart.', - at: DateTime.utc(2026, 7, 19), - requestId: 'approval-1', - actionable: true, - ); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'First investigation', - revision: 'revision-alpha', - status: 'active', - ), - ], - attentionItems: [approval], - agentActivities: [ - AgentActivity( - agentId: 'agent-parent', - sessionId: 'session-alpha', - label: 'Coordinator', - status: 'running', - progress: 0.75, - updatedAt: DateTime.utc(2026, 7, 19), - ), - AgentActivity( - agentId: 'agent-child', - sessionId: 'session-alpha', - label: 'Review child', - status: 'running', - progress: 0.5, - updatedAt: DateTime.utc(2026, 7, 19, 0, 1), - parentAgentId: 'agent-parent', - description: 'Reviewing changes', - model: 'fixture-model', - currentTool: 'read', - ), - ], - ), - actions: actions, - size: compactPhone, - ); - - await tester.tap(find.byTooltip('Open inbox')); - await tester.pumpAndSettle(); - expect(find.text('Inbox · 1 waiting'), findsOneWidget); - expect(find.text('Needs you (1)'), findsOneWidget); - expect(find.text('Allow file write?'), findsOneWidget); - - await tester.tap(find.widgetWithText(FilledButton, 'Approve')); - await tester.pumpAndSettle(); - expect(actions.attentionResponses, hasLength(1)); - expect( - actions.attentionResponses.single.response.decision, - AttentionDecision.approve, - ); - - await tester.tap(find.text('Agents (2)')); - await tester.pumpAndSettle(); - expect(find.text('Coordinator'), findsOneWidget); - expect(find.text('Review child'), findsOneWidget); - expect(find.text('Reviewing changes'), findsOneWidget); - expect(find.text('running · fixture-model · read'), findsOneWidget); - - await tester.tap(find.byTooltip('Stop Review child')); - await tester.pumpAndSettle(); - expect(find.text('Stop background agent?'), findsOneWidget); - await tester.tap(find.widgetWithText(FilledButton, 'Stop agent')); - await tester.pumpAndSettle(); - expect(actions.cancelledAgentIds, ['agent-child']); - }); - testWidgets('phone transcript search loads highlighted historical context', ( - tester, - ) async { - const searchResult = TranscriptSearchResult( - items: [ - TranscriptSearchItem( - sessionId: 'session-history', - projectId: 'project-history', - sessionTitle: 'Parser investigation', - anchorId: 'entry-anchor', - role: TranscriptSearchRole.assistant, - timestamp: '2026-07-19T12:00:00.000Z', - snippet: 'Fixed the parser boundary', - highlights: [ - TranscriptSearchHighlight(start: 0, end: 5), - ], - ), - ], - incomplete: false, - index: TranscriptSearchIndexStatus( - state: TranscriptSearchIndexState.ready, - indexedSessions: 3, - knownSessions: 3, - generation: 'generation-1', - ), - ); - const contextResult = TranscriptContextResult( - anchorId: 'entry-anchor', - rows: [ - TranscriptContextRow( - anchorId: 'entry-before', - role: TranscriptSearchRole.user, - timestamp: '2026-07-19T11:59:00.000Z', - text: 'Please inspect the parser boundary.', - ), - TranscriptContextRow( - anchorId: 'entry-anchor', - role: TranscriptSearchRole.assistant, - timestamp: '2026-07-19T12:00:00.000Z', - text: 'The parser boundary is fixed.', - ), - ], - anchorIndex: 1, - hasBefore: false, - hasAfter: false, - generation: 'generation-1', - ); - final actions = _FakeActions( - transcriptSearchResult: searchResult, - transcriptContextResult: contextResult, - ); - await pumpApp( - tester, - state: const T4ViewState( - connectionPhase: ConnectionPhase.ready, - authenticationPhase: AuthenticationPhase.paired, - targetConfigured: true, - ), - actions: actions, - size: compactPhone, - ); - - await tester.tap(find.byTooltip('Open navigation')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Search').last); - await tester.pumpAndSettle(); - await tester.enterText( - find.widgetWithText(TextField, 'Search transcripts'), - 'parser', - ); - await tester.tap(find.byTooltip('Search')); - await tester.pumpAndSettle(); - - expect(actions.transcriptQueries, ['parser']); - expect(find.text('Parser investigation'), findsOneWidget); - expect(find.text('Fixed the parser boundary'), findsOneWidget); - expect(find.text('3 sessions indexed'), findsOneWidget); - - await tester.tap(find.text('Show context')); - await tester.pumpAndSettle(); - expect(actions.contextAnchors, ['entry-anchor']); - expect(find.text('Please inspect the parser boundary.'), findsOneWidget); - expect(find.text('The parser boundary is fixed.'), findsOneWidget); - }); - - testWidgets('phone usage surface shows provider and broker status', ( - tester, - ) async { - const usage = UsageReadResult( - generatedAt: 1720000000000, - reports: [ - UsageReport( - provider: 'OpenAI', - fetchedAt: 1720000000000, - limits: [ - UsageLimit( - id: 'requests', - label: 'Requests', - scope: UsageScope(provider: 'OpenAI'), - amount: UsageAmount(used: 4, limit: 10, unit: UsageUnit.requests), - status: UsageStatus.ok, - notes: [], - ), - ], - notes: [], - metadata: {}, - ), - ], - accountsWithoutUsage: [], - capacity: >{}, - ); - final actions = _FakeActions( - usageReadResult: usage, - brokerStatusResult: const BrokerStatusResult( - state: BrokerState.connected, - generation: 1, - endpoint: 'https://broker.example.test', - ), - ); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.ready, - authenticationPhase: AuthenticationPhase.paired, - targetConfigured: true, - grantedCapabilities: const {'usage.read', 'broker.read'}, - ), - actions: actions, - size: compactPhone, - ); - - await tester.tap(find.byTooltip('Open navigation')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Usage').last); - await tester.pumpAndSettle(); - - expect(find.text('Broker connected'), findsOneWidget); - expect(find.text('https://broker.example.test'), findsOneWidget); - expect(find.text('OpenAI'), findsOneWidget); - expect(find.text('Requests'), findsOneWidget); - expect(find.text('4 / 10 requests'), findsOneWidget); - }); - - testWidgets('developer tools expose activity, files, and review on phones', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'First investigation', - revision: 'revision-alpha', - status: 'active', - ), - ], - activities: [ - DeveloperActivity( - id: 'activity-1', - category: 'tool', - title: 'files.read', - detail: 'lib/main.dart', - at: DateTime.utc(2026, 7, 19), - raw: '{"path":"lib/main.dart"}', - ), - ], - fileWorkspace: const FileWorkspaceState( - path: 'lib/main.dart', - entries: [ - DeveloperFileEntry( - path: 'lib/main.dart', - kind: 'file', - size: 42, - revision: 'revision-file', - ), - ], - content: 'void main() {}', - diff: '-void old() {}\n+void main() {}', - revision: 'revision-file', - ), - reviews: const [ - ReviewWorkspaceItem( - reviewId: 'review-1', - sessionId: 'session-alpha', - status: 'pending', - path: 'lib/main.dart', - findings: >[ - {'message': 'Avoid an empty main body.'}, - ], - ), - ], - previews: const [ - PreviewWorkspaceState( - previewId: 'preview-1', - sessionId: 'session-alpha', - state: 'ready', - url: 'https://preview.example.test', - revision: 'revision-preview', - title: 'Fixture preview', - canGoBack: false, - canGoForward: false, - ), - ], - activePreviewId: 'preview-1', - ), - actions: actions, - size: compactPhone, - ); - - await tester.tap(find.byTooltip('Open developer tools')); - await tester.pumpAndSettle(); - expect(find.text('Activity'), findsOneWidget); - expect(find.text('files.read'), findsOneWidget); - - await tester.tap(find.text('Files')); - await tester.pumpAndSettle(); - expect(find.text('lib/main.dart'), findsWidgets); - expect(find.text('void main() {}'), findsOneWidget); - await tester.enterText(find.byType(TextField), 'void main() { run(); }'); - await tester.pump(); - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - expect(actions.fileWrites, <({String path, String content})>[ - (path: 'lib/main.dart', content: 'void main() { run(); }'), - ]); - - await tester.tap(find.text('Review')); - await tester.pumpAndSettle(); - expect(find.text('Reload diff'), findsOneWidget); - expect(find.textContaining('+void main() {}'), findsOneWidget); - expect(find.text('Avoid an empty main body.'), findsOneWidget); - await tester.tap(find.byTooltip('Apply review')); - await tester.pumpAndSettle(); - expect(actions.appliedReviewIds, ['review-1']); - - await tester.drag(find.byType(TabBar), const Offset(-300, 0)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Preview')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Interact')); - await tester.pumpAndSettle(); - expect(find.text('Preview interaction'), findsOneWidget); - await tester.tap(find.text('Run click')); - await tester.pumpAndSettle(); - expect(actions.previewInteractions.single.previewId, 'preview-1'); - expect(actions.previewInteractions.single.action, 'click'); - expect(actions.previewInteractions.single.args, { - 'selector': 'button', - }); - }); - - testWidgets('quick open searches the selected project and opens its file', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions( - projectFileSearchResult: const ProjectFileSearchResult( - paths: ['lib/main.dart', 'test/main_test.dart'], - truncated: false, - ), - ); - await pumpApp( - tester, - state: T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - grantedFeatures: const {'files.search'}, - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'Quick open fixture', - revision: 'revision-alpha', - status: 'idle', - ), - ], - ), - actions: actions, - size: compactPhone, - ); - - await tester.tap(find.byTooltip('Quick open project file')); - await tester.pumpAndSettle(); - expect(find.text('Quick open'), findsOneWidget); - - await tester.enterText( - find.widgetWithText(TextField, 'Find a project file'), - 'main', - ); - await tester.pump(const Duration(milliseconds: 250)); - await tester.pumpAndSettle(); - expect(actions.projectFileQueries, ['main']); - expect(find.text('lib/main.dart'), findsOneWidget); - - await tester.tap(find.text('lib/main.dart')); - await tester.pumpAndSettle(); - expect(actions.readFilePaths, ['lib/main.dart']); - expect(find.text('Developer tools'), findsOneWidget); - expect(find.text('Files'), findsOneWidget); - }); - - testWidgets('composer exposes pause, resume, and manual compaction', ( - tester, - ) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final actions = _FakeActions(); - T4ViewState stateFor({required bool turnActive, required bool isPaused}) => - T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: 'session-alpha', - sessions: [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'Control fixture', - revision: 'revision-alpha', - status: turnActive ? 'active' : 'idle', - turnActive: turnActive, - isPaused: isPaused, - ), - ], - composer: SessionComposerState( - turnActive: turnActive, - isPaused: isPaused, - ), - ); - - await pumpApp( - tester, - state: stateFor(turnActive: true, isPaused: false), - actions: actions, - size: compactPhone, - ); - expect(find.text('Pause'), findsOneWidget); - expect(find.text('Stop'), findsOneWidget); - await tester.tap(find.text('Pause')); - await tester.pumpAndSettle(); - expect(actions.pauseSessionCalls, 1); - - await tester.pumpWidget( - T4App( - state: stateFor(turnActive: false, isPaused: true), - actions: actions, - credentialsAreVolatile: false, - ), - ); - await tester.pumpAndSettle(); - expect(find.text('Resume'), findsOneWidget); - expect(find.text('Resume the session to continue'), findsOneWidget); - await tester.tap(find.text('Resume')); - await tester.pumpAndSettle(); - expect(actions.resumeSessionCalls, 1); - - await tester.pumpWidget( - T4App( - state: stateFor(turnActive: false, isPaused: false), - actions: actions, - credentialsAreVolatile: false, - ), - ); - await tester.pumpAndSettle(); - // Manual compaction moved into the model selector menu. - await tester.tap(find.text('Model')); - await tester.pumpAndSettle(); - expect(find.text('Compact'), findsOneWidget); - await tester.tap(find.text('Compact')); - await tester.pumpAndSettle(); - expect(actions.compactSessionCalls, 1); - }); -} - -final class _FakeActions implements T4Actions { - _FakeActions({ - this.addHostError, - this.addHostCompletion, - this.transcriptSearchResult, - this.transcriptContextResult, - this.usageReadResult, - this.brokerStatusResult, - this.projectFileSearchResult, - }); - - final Object? addHostError; - final Completer? addHostCompletion; - final TranscriptSearchResult? transcriptSearchResult; - final TranscriptContextResult? transcriptContextResult; - final UsageReadResult? usageReadResult; - final BrokerStatusResult? brokerStatusResult; - final ProjectFileSearchResult? projectFileSearchResult; - final List transcriptQueries = []; - final List contextAnchors = []; - final List addedAddresses = []; - final List addedProfileIds = []; - final List activatedEndpointKeys = []; - int connectCalls = 0; - final List removedEndpointKeys = []; - final List pairingCodes = []; - int cancelHostProbeCalls = 0; - int disconnectCalls = 0; - final List<({String projectId, String? title})> createdSessions = - <({String projectId, String? title})>[]; - final List<({String sessionId, String title})> renamedSessions = - <({String sessionId, String title})>[]; - final List terminatedSessionIds = []; - final List archivedSessionIds = []; - final List restoredSessionIds = []; - final List submittedPrompts = []; - final List queuedPrompts = []; - int cancelTurnCalls = 0; - int pauseSessionCalls = 0; - int resumeSessionCalls = 0; - int compactSessionCalls = 0; - int loadEarlierCalls = 0; - final List selectedModels = []; - final List selectedThinkingLevels = []; - final List selectedFastModes = []; - final List deletedSessionIds = []; - final List<({AttentionItem item, AttentionResponse response})> - attentionResponses = <({AttentionItem item, AttentionResponse response})>[]; - final List retriedSessionIds = []; - final List cancelledAgentIds = []; - final List projectFileQueries = []; - final List readFilePaths = []; - final List<({String path, String content})> fileWrites = - <({String path, String content})>[]; - final List refreshedReviewIds = []; - final List appliedReviewIds = []; - final List<({String previewId, String action, Map args})> - previewInteractions = - <({String previewId, String action, Map args})>[]; - - @override - Future setThemePreference(T4ThemePreference preference) async {} - - @override - Future refreshSettings() async {} - - @override - Future writeSetting( - String path, - String scope, { - Object? value, - bool reset = false, - }) async {} - - @override - Future handleLifecyclePhase(T4LifecyclePhase phase) async {} - - @override - Future addHost( - String address, { - String profileId = defaultHostProfileId, - }) async { - addedAddresses.add(address); - addedProfileIds.add(profileId); - final error = addHostError; - if (error != null) throw error; - await addHostCompletion?.future; - } - - @override - void cancelHostProbe() { - cancelHostProbeCalls += 1; - } - - @override - Future activateHost(String endpointKey) async { - activatedEndpointKeys.add(endpointKey); - } - - @override - Future createSession(String projectId, {String? title}) async { - createdSessions.add((projectId: projectId, title: title)); - } - - @override - Future renameSession(String sessionId, String title) async { - renamedSessions.add((sessionId: sessionId, title: title)); - } - - @override - Future terminateSession(String sessionId) async { - terminatedSessionIds.add(sessionId); - } - - @override - Future archiveSession(String sessionId) async { - archivedSessionIds.add(sessionId); - } - - @override - Future restoreSession(String sessionId) async { - restoredSessionIds.add(sessionId); - } - - @override - Future deleteSession(String sessionId) async { - deletedSessionIds.add(sessionId); - } - - @override - Future searchTranscripts({ - required String query, - String? cursor, - String? projectId, - List? roles, - String archived = 'include', - DateTime? from, - DateTime? to, - }) async { - transcriptQueries.add(query); - return transcriptSearchResult ?? - (throw UnsupportedError('transcript search is not configured')); - } - - @override - Future loadTranscriptContext({ - required String sessionId, - required String anchorId, - int before = 8, - int after = 8, - }) async { - contextAnchors.add(anchorId); - return transcriptContextResult ?? - (throw UnsupportedError('transcript context is not configured')); - } - - @override - Future loadEarlierTranscript() async { - loadEarlierCalls += 1; - } - - @override - Future readUsage() async => - usageReadResult ?? (throw UnsupportedError('usage is not configured')); - - @override - Future readBrokerStatus() async => - brokerStatusResult ?? - (throw UnsupportedError('broker status is not configured')); - - @override - Future cancelAgent(String agentId) async { - cancelledAgentIds.add(agentId); - } - - @override - Future connect() async { - connectCalls += 1; - } - - @override - Future disconnect() async { - disconnectCalls += 1; - } - - @override - Future pairHost(String code) async { - pairingCodes.add(code); - } - - @override - Future removeHost(String endpointKey) async { - removedEndpointKeys.add(endpointKey); - } - - @override - Future selectSession(String sessionId) async {} - - @override - Future submitPrompt( - String message, { - List images = const [], - }) async { - submittedPrompts.add(message); - return true; - } - - @override - Future queuePrompt(String message) async { - queuedPrompts.add(message); - return true; - } - - @override - Future cancelTurn() async { - cancelTurnCalls += 1; - } - - @override - Future pauseSession() async { - pauseSessionCalls += 1; - } - - @override - Future resumeSession() async { - resumeSessionCalls += 1; - } - - @override - Future compactSession({String? instructions}) async { - compactSessionCalls += 1; - } - - @override - Future setSessionModel(String selector) async { - selectedModels.add(selector); - } - - @override - Future setSessionThinking(String level) async { - selectedThinkingLevels.add(level); - } - - @override - Future setSessionFast(bool enabled) async { - selectedFastModes.add(enabled); - } - - @override - Future respondToAttention( - AttentionItem item, - AttentionResponse response, - ) async { - attentionResponses.add((item: item, response: response)); - return true; - } - - @override - Future retrySession(String sessionId) async { - retriedSessionIds.add(sessionId); - } - - @override - Future refreshActivity() async {} - - @override - Future openTerminal({String? cwd}) async => 'terminal-test'; - - @override - void sendTerminalInput(String terminalId, String data) {} - - @override - void resizeTerminal(String terminalId, int cols, int rows) {} - - @override - void closeTerminal(String terminalId) {} - - @override - Future listFiles([String path = '']) async {} - - @override - Future searchProjectFiles( - String query, { - int limit = 12, - }) async { - projectFileQueries.add(query); - return projectFileSearchResult ?? - (throw UnsupportedError('project file search is not configured')); - } - - @override - Future readFile(String path) async { - readFilePaths.add(path); - } - - @override - Future loadSessionDiff() async {} - - @override - Future writeFile(String path, String content) async { - fileWrites.add((path: path, content: content)); - } - - @override - Future refreshReview(String reviewId) async { - refreshedReviewIds.add(reviewId); - } - - @override - Future applyReview(String reviewId) async { - appliedReviewIds.add(reviewId); - } - - @override - Future runPreviewInteraction( - String previewId, - String action, - Map args, - ) async { - previewInteractions.add((previewId: previewId, action: action, args: args)); - } - - @override - Future launchPreview(String url) async => 'preview-test'; - - @override - Future selectPreview(String previewId) async {} - - @override - Future navigatePreview(String previewId, String url) async {} - - @override - Future runPreviewAction(String previewId, String action) async {} - - @override - Future capturePreview(String previewId) async {} - - @override - Future readTranscriptImage( - String entryId, - TranscriptImageMetadata image, - ) async => Uint8List(0); -} diff --git a/apps/flutter/test/ui/settings_pane_test.dart b/apps/flutter/test/ui/settings_pane_test.dart deleted file mode 100644 index 601f829b..00000000 --- a/apps/flutter/test/ui/settings_pane_test.dart +++ /dev/null @@ -1,614 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/client/app_state.dart'; -import 'package:t4code/src/host/host_profile.dart'; -import 'package:t4code/src/platform/platform_lifecycle.dart'; -import 'package:t4code/src/platform/platform_lifecycle_controller.dart'; -import 'package:t4code/src/ui/t4_app.dart'; - -void main() { - const phone = Size(390, 844); - const wide = Size(1440, 900); - - Future pumpApp( - WidgetTester tester, { - required T4ViewState state, - required _FakeActions actions, - required Size size, - PlatformLifecycleViewState platformState = - const PlatformLifecycleViewState.initial(), - PlatformLifecycleActions? platformActions, - }) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = size; - addTearDown(tester.view.reset); - await tester.pumpWidget( - T4App( - state: state, - actions: actions, - credentialsAreVolatile: false, - platformState: platformState, - platformActions: platformActions, - ), - ); - await tester.pump(); - } - - Future openSettings(WidgetTester tester, Size size) async { - if (size.width < 980) { - await tester.tap(find.byTooltip('Open navigation')); - await tester.pumpAndSettle(); - } - await tester.tap(find.text('Settings').last); - await tester.pumpAndSettle(); - } - - Future selectSettingsCategory( - WidgetTester tester, - Size size, - String label, - ) async { - if (size.width >= 980) { - await tester.tap(find.text(label).last); - } else { - await tester.tap(find.byKey(const Key('settings-category-picker'))); - await tester.pumpAndSettle(); - await tester.tap(find.text(label).last); - } - await tester.pumpAndSettle(); - } - - testWidgets('phone navigation changes the live theme preference', ( - tester, - ) async { - final actions = _FakeActions(); - final state = _state( - capabilities: const { - 'catalog.read', - 'config.read', - 'config.write', - }, - ); - await pumpApp(tester, state: state, actions: actions, size: phone); - - await openSettings(tester, phone); - - expect(find.byKey(const Key('settings-category-picker')), findsOneWidget); - expect(find.text('Appearance'), findsWidgets); - expect( - find.text('Choose how T4 follows your device appearance.'), - findsOneWidget, - ); - expect( - find.text( - 'Changes are staged here. Save sends them to the host, where host-scoped changes may require inbox approval.', - ), - findsNothing, - ); - await tester.tap(find.text('Dark')); - await tester.pump(); - - expect(actions.themePreferences, [ - T4ThemePreference.dark, - ]); - - await tester.pumpWidget( - T4App( - state: _state( - themePreference: T4ThemePreference.dark, - capabilities: const { - 'catalog.read', - 'config.read', - 'config.write', - }, - ), - actions: actions, - credentialsAreVolatile: false, - ), - ); - await tester.pump(); - - expect( - tester.widget(find.byType(MaterialApp)).themeMode, - ThemeMode.dark, - ); - }); - - testWidgets( - 'wide settings shows permission, unavailable, secret, and restart states', - (tester) async { - final actions = _FakeActions(); - final state = _state( - capabilities: const {'catalog.read', 'config.read'}, - entries: [ - _entry( - path: 'runtime.enabled', - label: 'Runtime enabled', - control: HostSettingControlKind.boolean, - effectiveValue: true, - ), - _entry( - path: 'runtime.preview', - label: 'Preview runtime', - control: HostSettingControlKind.boolean, - effectiveValue: false, - available: false, - restartRequired: true, - ), - _entry( - path: 'provider.token', - label: 'Provider token', - control: HostSettingControlKind.secret, - configured: true, - sensitive: true, - ), - ], - ); - await pumpApp(tester, state: state, actions: actions, size: wide); - - await openSettings(tester, wide); - await selectSettingsCategory(tester, wide, 'OMP settings'); - - expect(find.byTooltip('Close settings'), findsOneWidget); - expect(find.text('Permission denied'), findsOneWidget); - expect(find.text('Unavailable'), findsOneWidget); - expect(find.text('Restart required'), findsOneWidget); - expect(find.text('Configured'), findsOneWidget); - expect( - find.text('A value is configured. Its contents are hidden.'), - findsOneWidget, - ); - expect( - find.byKey(const Key('setting-control-runtime.enabled')), - findsNothing, - ); - expect(find.byType(Drawer), findsNothing); - }, - ); - - testWidgets('OMP settings renders only the selected setting group', ( - tester, - ) async { - final actions = _FakeActions(); - final state = _state( - capabilities: const { - 'catalog.read', - 'config.read', - 'config.write', - }, - entries: [ - _entry( - path: 'runtime.enabled', - label: 'Runtime · Enabled', - control: HostSettingControlKind.boolean, - effectiveValue: true, - ), - _entry( - path: 'provider.enabled', - label: 'Provider · Enabled', - control: HostSettingControlKind.boolean, - effectiveValue: false, - ), - ], - ); - await pumpApp(tester, state: state, actions: actions, size: wide); - await openSettings(tester, wide); - await selectSettingsCategory(tester, wide, 'OMP settings'); - - expect(find.byKey(const Key('host-settings-group-picker')), findsOneWidget); - expect(find.text('Runtime · Enabled'), findsOneWidget); - expect(find.text('Provider · Enabled'), findsNothing); - - await tester.tap(find.byKey(const Key('host-settings-group-picker'))); - await tester.pumpAndSettle(); - await tester.tap(find.text('Provider (1)').last); - await tester.pumpAndSettle(); - - expect(find.text('Runtime · Enabled'), findsNothing); - expect(find.text('Provider · Enabled'), findsOneWidget); - }); - - testWidgets( - 'phone controls stage changes and require Save for write and reset', - (tester) async { - final actions = _FakeActions(); - final state = _state( - capabilities: const { - 'catalog.read', - 'config.read', - 'config.write', - }, - entries: [ - _entry( - path: 'runtime.enabled', - label: 'Runtime enabled', - control: HostSettingControlKind.boolean, - effectiveValue: true, - configured: true, - ), - ], - ); - await pumpApp(tester, state: state, actions: actions, size: phone); - await openSettings(tester, phone); - await selectSettingsCategory(tester, phone, 'OMP settings'); - - final control = find.byKey(const Key('setting-control-runtime.enabled')); - await tester.ensureVisible(control); - await tester.tap(control); - await tester.pumpAndSettle(); - - expect(actions.settingWrites, isEmpty); - expect(find.text('1 staged change'), findsOneWidget); - await tester.tap(find.byKey(const Key('save-settings'))); - await tester.pumpAndSettle(); - - expect(actions.settingWrites, <_SettingWrite>[ - const _SettingWrite( - path: 'runtime.enabled', - scope: 'host', - value: false, - reset: false, - ), - ]); - - final reset = find.widgetWithText(TextButton, 'Reset'); - await tester.ensureVisible(reset); - await tester.tap(reset); - await tester.pumpAndSettle(); - expect(find.text('Reset staged'), findsOneWidget); - expect(actions.settingWrites, hasLength(1)); - - await tester.tap(find.byKey(const Key('save-settings'))); - await tester.pumpAndSettle(); - expect( - actions.settingWrites.last, - const _SettingWrite( - path: 'runtime.enabled', - scope: 'host', - value: null, - reset: true, - ), - ); - }, - ); - - testWidgets('host revision changes surface a staged-edit conflict', ( - tester, - ) async { - final actions = _FakeActions(); - final entry = _entry( - path: 'runtime.enabled', - label: 'Runtime enabled', - control: HostSettingControlKind.boolean, - effectiveValue: true, - configured: true, - ); - await pumpApp( - tester, - state: _state( - revision: 'revision-1', - capabilities: const { - 'catalog.read', - 'config.read', - 'config.write', - }, - entries: [entry], - ), - actions: actions, - size: wide, - ); - await openSettings(tester, wide); - await selectSettingsCategory(tester, wide, 'OMP settings'); - await tester.tap(find.byKey(const Key('setting-control-runtime.enabled'))); - await tester.pump(); - - await tester.pumpWidget( - T4App( - state: _state( - revision: 'revision-2', - capabilities: const { - 'catalog.read', - 'config.read', - 'config.write', - }, - entries: [entry], - ), - actions: actions, - credentialsAreVolatile: false, - ), - ); - await tester.pump(); - - expect(find.text('Settings changed on the host'), findsOneWidget); - expect( - tester - .widget(find.byKey(const Key('save-settings'))) - .onPressed, - isNull, - ); - }); - - testWidgets('native runtime and update controls dispatch explicit actions', ( - tester, - ) async { - final actions = _FakeActions(); - final platformActions = _FakePlatformActions(); - await pumpApp( - tester, - state: _state(), - actions: actions, - size: wide, - platformState: _nativePlatformState(), - platformActions: platformActions, - ); - await openSettings(tester, wide); - await selectSettingsCategory(tester, wide, 'App and runtime'); - - final runtimeRestart = find.byKey(const Key('runtime-restart')); - await tester.ensureVisible(runtimeRestart); - await tester.tap(runtimeRestart); - await tester.pumpAndSettle(); - expect(platformActions.calls, ['runtime.restart']); - - final updateCheck = find.byKey(const Key('update-check')); - await tester.ensureVisible(updateCheck); - await tester.tap(updateCheck); - await tester.pumpAndSettle(); - expect(platformActions.calls, ['runtime.restart', 'update.check']); - expect(find.text('1.2.4'), findsWidgets); - expect(find.text('1.2.5'), findsOneWidget); - }); - - test('diagnostics export includes only allowlisted redacted values', () { - final state = _state( - revision: 'revision-safe', - capabilities: const { - 'catalog.read', - 'config.write', - 'config.read', - }, - entries: [ - _entry( - path: 'ui.scale', - label: 'UI scale', - control: HostSettingControlKind.number, - effectiveValue: 1.25, - ), - _entry( - path: 'provider.token', - label: 'Provider token', - control: HostSettingControlKind.secret, - effectiveValue: 'HOST-CREDENTIAL-DO-NOT-EXPORT', - configured: true, - sensitive: true, - ), - _entry( - path: 'private.note', - label: 'Private note', - control: HostSettingControlKind.text, - effectiveValue: 'DEVICE-TOKEN-DO-NOT-EXPORT', - sensitive: true, - ), - ], - issues: const ['settings frame omitted optional metadata'], - ); - - final encoded = buildT4DiagnosticsJson( - state, - generatedAt: DateTime.utc(2026, 7, 19), - platformState: _nativePlatformState(), - ); - final decoded = jsonDecode(encoded) as Map; - final settings = decoded['settings']! as List; - - expect(encoded, isNot(contains('HOST-CREDENTIAL-DO-NOT-EXPORT'))); - expect(encoded, isNot(contains('DEVICE-TOKEN-DO-NOT-EXPORT'))); - expect(encoded, isNot(contains('rawFrame'))); - expect(encoded, isNot(contains('transcript'))); - expect(encoded, isNot(contains('PRIVATE-HOME'))); - expect(decoded['kind'], 't4-code.flutter-diagnostics'); - expect(decoded['hostLabel'], 'T4 on alpha'); - expect(decoded['capabilityNames'], [ - 'catalog.read', - 'config.read', - 'config.write', - ]); - final platform = decoded['platform']! as Map; - expect( - (platform['runtime']! as Map)['service'], - 'running', - ); - expect((platform['update']! as Map)['phase'], 'available'); - expect(settings.cast>().first['effectiveValue'], 1.25); - expect( - settings.cast>()[1].containsKey('effectiveValue'), - isFalse, - ); - expect( - settings.cast>()[2].containsKey('effectiveValue'), - isFalse, - ); - }); -} - -T4ViewState _state({ - T4ThemePreference themePreference = T4ThemePreference.system, - Set capabilities = const {}, - List entries = const [], - List issues = const [], - String revision = 'revision-1', -}) { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - return T4ViewState( - connectionPhase: ConnectionPhase.ready, - authenticationPhase: AuthenticationPhase.paired, - hostDirectory: HostDirectory.empty().upsert(profile), - targetConfigured: true, - grantedCapabilities: capabilities, - grantedFeatures: const {'catalog.metadata', 'settings.metadata'}, - themePreference: themePreference, - settings: HostSettingsState( - revision: revision, - entries: entries, - issues: issues, - ), - ); -} - -PlatformLifecycleViewState _nativePlatformState() => - const PlatformLifecycleViewState( - runtime: RuntimeServiceStatus( - supported: true, - available: true, - definition: RuntimeDefinitionState.current, - service: RuntimeServicePhase.running, - diagnostics: '', - executable: '/Users/PRIVATE-HOME/bin/omp', - ), - update: PlatformUpdateStatus( - supported: true, - currentVersion: '1.2.4', - phase: PlatformUpdatePhase.available, - latestVersion: '1.2.5', - checkedAt: 1784419200000, - revision: 7, - ), - ); - -HostSettingEntry _entry({ - required String path, - required String label, - required HostSettingControlKind control, - Object? effectiveValue, - bool configured = false, - bool restartRequired = false, - bool available = true, - bool sensitive = false, -}) => HostSettingEntry( - path: path, - section: 'Runtime', - label: label, - help: 'Published by the connected host.', - control: control, - effectiveValue: effectiveValue, - configured: configured, - effectiveSource: 'host', - options: const [], - writableScopes: const ['host'], - restartRequired: restartRequired, - available: available, - sensitive: sensitive, -); - -final class _FakeActions implements T4Actions { - final List themePreferences = []; - final List<_SettingWrite> settingWrites = <_SettingWrite>[]; - int refreshCalls = 0; - - @override - Future setThemePreference(T4ThemePreference preference) async { - themePreferences.add(preference); - } - - @override - Future refreshSettings() async { - refreshCalls += 1; - } - - @override - Future writeSetting( - String path, - String scope, { - Object? value, - bool reset = false, - }) async { - settingWrites.add( - _SettingWrite(path: path, scope: scope, value: value, reset: reset), - ); - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -final class _FakePlatformActions implements PlatformLifecycleActions { - final List calls = []; - - @override - Future refreshPlatformState() async { - calls.add('platform.refresh'); - } - - @override - Future installRuntime() async { - calls.add('runtime.install'); - } - - @override - Future startRuntime() async { - calls.add('runtime.start'); - } - - @override - Future stopRuntime() async { - calls.add('runtime.stop'); - } - - @override - Future restartRuntime() async { - calls.add('runtime.restart'); - } - - @override - Future uninstallRuntime() async { - calls.add('runtime.uninstall'); - } - - @override - Future checkForUpdates() async { - calls.add('update.check'); - } - - @override - Future downloadUpdate() async { - calls.add('update.download'); - } - - @override - Future installUpdate() async { - calls.add('update.install'); - } -} - -final class _SettingWrite { - const _SettingWrite({ - required this.path, - required this.scope, - required this.value, - required this.reset, - }); - - final String path; - final String scope; - final Object? value; - final bool reset; - - @override - bool operator ==(Object other) => - other is _SettingWrite && - path == other.path && - scope == other.scope && - value == other.value && - reset == other.reset; - - @override - int get hashCode => Object.hash(path, scope, value, reset); - - @override - String toString() => - '_SettingWrite(path: $path, scope: $scope, value: $value, reset: $reset)'; -} diff --git a/apps/flutter/test/ui/shell_shortcuts_test.dart b/apps/flutter/test/ui/shell_shortcuts_test.dart deleted file mode 100644 index b92f1931..00000000 --- a/apps/flutter/test/ui/shell_shortcuts_test.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:t4code/src/client/app_state.dart'; -import 'package:t4code/src/host/host_profile.dart'; -import 'package:t4code/src/ui/t4_app.dart'; - -void main() { - const wideDesktop = Size(1200, 800); - - T4ViewState shellState() { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - return T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'Shortcut test session', - revision: 'revision-alpha', - status: 'idle', - ), - ], - messages: const [ - TranscriptMessage( - id: 'message-0', - role: MessageRole.assistant, - text: 'Hello from the transcript', - ), - ], - ); - } - - Future pressChord( - WidgetTester tester, - List keys, - ) async { - for (final key in keys) { - await tester.sendKeyDownEvent(key); - } - for (final key in keys.reversed) { - await tester.sendKeyUpEvent(key); - } - await tester.pumpAndSettle(); - } - - testWidgets( - 'mod+shift+F toggles transcript search and Escape returns to conversation', - (tester) async { - // Linux → the shell binds control as the primary modifier, which keeps - // the test free of meta-key platform quirks. - debugDefaultTargetPlatformOverride = TargetPlatform.linux; - - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = wideDesktop; - addTearDown(tester.view.reset); - - await tester.pumpWidget( - T4App( - state: shellState(), - actions: _StubActions(), - credentialsAreVolatile: false, - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('Search transcripts'), findsNothing); - expect(find.text('Hello from the transcript'), findsOneWidget); - - // mod+shift+F opens the search surface. - await pressChord(tester, const [ - LogicalKeyboardKey.controlLeft, - LogicalKeyboardKey.shiftLeft, - LogicalKeyboardKey.keyF, - ]); - expect(find.text('Search transcripts'), findsWidgets); - - // Same chord toggles it back off. - await pressChord(tester, const [ - LogicalKeyboardKey.controlLeft, - LogicalKeyboardKey.shiftLeft, - LogicalKeyboardKey.keyF, - ]); - expect(find.text('Search transcripts'), findsNothing); - expect(find.text('Hello from the transcript'), findsOneWidget); - - // Reopen, then Escape returns to the conversation. - await pressChord(tester, const [ - LogicalKeyboardKey.controlLeft, - LogicalKeyboardKey.shiftLeft, - LogicalKeyboardKey.keyF, - ]); - expect(find.text('Search transcripts'), findsWidgets); - - await pressChord(tester, const [LogicalKeyboardKey.escape]); - expect(find.text('Search transcripts'), findsNothing); - expect(find.text('Hello from the transcript'), findsOneWidget); - - // Reset inside the body: the binding asserts foundation vars before - // tearDown callbacks run. - debugDefaultTargetPlatformOverride = null; - }, - ); -} - -/// Minimal stub: the shortcut flows under test never reach the host, so any -/// unexpected action call surfaces loudly as a type error instead of passing -/// silently. -final class _StubActions implements T4Actions { - @override - Object? noSuchMethod(Invocation invocation) => Future.value(); -} diff --git a/apps/flutter/web/favicon.png b/apps/flutter/web/favicon.png deleted file mode 100644 index 9a5cd0fd..00000000 Binary files a/apps/flutter/web/favicon.png and /dev/null differ diff --git a/apps/flutter/web/flutter_bootstrap.js b/apps/flutter/web/flutter_bootstrap.js deleted file mode 100644 index e4384b60..00000000 --- a/apps/flutter/web/flutter_bootstrap.js +++ /dev/null @@ -1,23 +0,0 @@ -// eslint-disable-next-line no-unused-expressions -- replaced by Flutter at build time -{{flutter_js}} -// eslint-disable-next-line no-unused-expressions -- replaced by Flutter at build time -{{flutter_build_config}} - -// The public demo follows current main. Remove service workers left by older -// builds before starting so they cannot keep an obsolete UI in front of the -// freshly invalidated CloudFront files. -const startT4Demo = () => _flutter.loader.load(); -if ('serviceWorker' in navigator) { - navigator.serviceWorker - .getRegistrations() - .then((registrations) => - Promise.all( - registrations - .filter((registration) => new URL(registration.scope).pathname.startsWith('/demo/')) - .map((registration) => registration.unregister()), - ), - ) - .then(startT4Demo, startT4Demo); -} else { - startT4Demo(); -} diff --git a/apps/flutter/web/icons/Icon-192.png b/apps/flutter/web/icons/Icon-192.png deleted file mode 100644 index 7b04e4bf..00000000 Binary files a/apps/flutter/web/icons/Icon-192.png and /dev/null differ diff --git a/apps/flutter/web/icons/Icon-512.png b/apps/flutter/web/icons/Icon-512.png deleted file mode 100644 index 225c077d..00000000 Binary files a/apps/flutter/web/icons/Icon-512.png and /dev/null differ diff --git a/apps/flutter/web/icons/Icon-maskable-192.png b/apps/flutter/web/icons/Icon-maskable-192.png deleted file mode 100644 index 7b04e4bf..00000000 Binary files a/apps/flutter/web/icons/Icon-maskable-192.png and /dev/null differ diff --git a/apps/flutter/web/icons/Icon-maskable-512.png b/apps/flutter/web/icons/Icon-maskable-512.png deleted file mode 100644 index 225c077d..00000000 Binary files a/apps/flutter/web/icons/Icon-maskable-512.png and /dev/null differ diff --git a/apps/flutter/web/index.html b/apps/flutter/web/index.html deleted file mode 100644 index 1c3f3913..00000000 --- a/apps/flutter/web/index.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - - - - - - T4 Code - - - - - - - diff --git a/apps/flutter/web/manifest.json b/apps/flutter/web/manifest.json deleted file mode 100644 index 369f1e82..00000000 --- a/apps/flutter/web/manifest.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "T4 Code", - "short_name": "T4", - "start_url": ".", - "display": "standalone", - "background_color": "#FFFFFF", - "theme_color": "#0D0D0D", - "description": "T4 Code Flutter client", - "orientation": "portrait-primary", - "prefer_related_applications": false, - "icons": [ - { - "src": "icons/Icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/Icon-512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "icons/Icon-maskable-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "icons/Icon-maskable-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -} diff --git a/apps/flutter/windows/.gitignore b/apps/flutter/windows/.gitignore deleted file mode 100644 index d492d0d9..00000000 --- a/apps/flutter/windows/.gitignore +++ /dev/null @@ -1,17 +0,0 @@ -flutter/ephemeral/ - -# Visual Studio user-specific files. -*.suo -*.user -*.userosscache -*.sln.docstates - -# Visual Studio build-related files. -x64/ -x86/ - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ diff --git a/apps/flutter/windows/CMakeLists.txt b/apps/flutter/windows/CMakeLists.txt deleted file mode 100644 index 6252dce1..00000000 --- a/apps/flutter/windows/CMakeLists.txt +++ /dev/null @@ -1,108 +0,0 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.14) -project(t4code LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "t4code") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(VERSION 3.14...3.25) - -# Define build configuration option. -get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) -if(IS_MULTICONFIG) - set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" - CACHE STRING "" FORCE) -else() - if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") - endif() -endif() -# Define settings for the Profile build mode. -set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") -set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") -set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") -set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") - -# Use Unicode for all projects. -add_definitions(-DUNICODE -D_UNICODE) - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_17) - target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") - target_compile_options(${TARGET} PRIVATE /EHsc) - target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") - target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# Support files are copied into place next to the executable, so that it can -# run in place. This is done instead of making a separate bundle (as on Linux) -# so that building and running from within Visual Studio will work. -set(BUILD_BUNDLE_DIR "$") -# Make the "install" step default, as it's required to run. -set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -if(PLUGIN_BUNDLED_LIBRARIES) - install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() - -# Copy the native assets provided by the build.dart from all packages. -set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - CONFIGURATIONS Profile;Release - COMPONENT Runtime) diff --git a/apps/flutter/windows/flutter/CMakeLists.txt b/apps/flutter/windows/flutter/CMakeLists.txt deleted file mode 100644 index 903f4899..00000000 --- a/apps/flutter/windows/flutter/CMakeLists.txt +++ /dev/null @@ -1,109 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.14) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. -set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") - -# Set fallback configurations for older versions of the flutter tool. -if (NOT DEFINED FLUTTER_TARGET_PLATFORM) - set(FLUTTER_TARGET_PLATFORM "windows-x64") -endif() - -# === Flutter Library === -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "flutter_export.h" - "flutter_windows.h" - "flutter_messenger.h" - "flutter_plugin_registrar.h" - "flutter_texture_registrar.h" -) -list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") -add_dependencies(flutter flutter_assemble) - -# === Wrapper === -list(APPEND CPP_WRAPPER_SOURCES_CORE - "core_implementations.cc" - "standard_codec.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_PLUGIN - "plugin_registrar.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_APP - "flutter_engine.cc" - "flutter_view_controller.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") - -# Wrapper sources needed for a plugin. -add_library(flutter_wrapper_plugin STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} -) -apply_standard_settings(flutter_wrapper_plugin) -set_target_properties(flutter_wrapper_plugin PROPERTIES - POSITION_INDEPENDENT_CODE ON) -set_target_properties(flutter_wrapper_plugin PROPERTIES - CXX_VISIBILITY_PRESET hidden) -target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) -target_include_directories(flutter_wrapper_plugin PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_plugin flutter_assemble) - -# Wrapper sources needed for the runner. -add_library(flutter_wrapper_app STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_APP} -) -apply_standard_settings(flutter_wrapper_app) -target_link_libraries(flutter_wrapper_app PUBLIC flutter) -target_include_directories(flutter_wrapper_app PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_app flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") -set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} - ${PHONY_OUTPUT} - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - ${FLUTTER_TARGET_PLATFORM} $ - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} -) diff --git a/apps/flutter/windows/flutter/generated_plugin_registrant.cc b/apps/flutter/windows/flutter/generated_plugin_registrant.cc deleted file mode 100644 index b53f20e2..00000000 --- a/apps/flutter/windows/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,17 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include -#include - -void RegisterPlugins(flutter::PluginRegistry* registry) { - FileSelectorWindowsRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FileSelectorWindows")); - FlutterSecureStorageWindowsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); -} diff --git a/apps/flutter/windows/flutter/generated_plugin_registrant.h b/apps/flutter/windows/flutter/generated_plugin_registrant.h deleted file mode 100644 index dc139d85..00000000 --- a/apps/flutter/windows/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void RegisterPlugins(flutter::PluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/apps/flutter/windows/flutter/generated_plugins.cmake b/apps/flutter/windows/flutter/generated_plugins.cmake deleted file mode 100644 index 4873bb84..00000000 --- a/apps/flutter/windows/flutter/generated_plugins.cmake +++ /dev/null @@ -1,26 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - file_selector_windows - flutter_secure_storage_windows -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/apps/flutter/windows/runner/CMakeLists.txt b/apps/flutter/windows/runner/CMakeLists.txt deleted file mode 100644 index 394917c0..00000000 --- a/apps/flutter/windows/runner/CMakeLists.txt +++ /dev/null @@ -1,40 +0,0 @@ -cmake_minimum_required(VERSION 3.14) -project(runner LANGUAGES CXX) - -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} WIN32 - "flutter_window.cpp" - "main.cpp" - "utils.cpp" - "win32_window.cpp" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" - "Runner.rc" - "runner.exe.manifest" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add preprocessor definitions for the build version. -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") - -# Disable Windows macros that collide with C++ standard library functions. -target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") - -# Add dependency libraries and include directories. Add any application-specific -# dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) -target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/apps/flutter/windows/runner/Runner.rc b/apps/flutter/windows/runner/Runner.rc deleted file mode 100644 index 76bfa601..00000000 --- a/apps/flutter/windows/runner/Runner.rc +++ /dev/null @@ -1,121 +0,0 @@ -// Microsoft Visual C++ generated resource script. -// -#pragma code_page(65001) -#include "resource.h" - -#define APSTUDIO_READONLY_SYMBOLS -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 2 resource. -// -#include "winres.h" - -///////////////////////////////////////////////////////////////////////////// -#undef APSTUDIO_READONLY_SYMBOLS - -///////////////////////////////////////////////////////////////////////////// -// English (United States) resources - -#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) -LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US - -#ifdef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// TEXTINCLUDE -// - -1 TEXTINCLUDE -BEGIN - "resource.h\0" -END - -2 TEXTINCLUDE -BEGIN - "#include ""winres.h""\r\n" - "\0" -END - -3 TEXTINCLUDE -BEGIN - "\r\n" - "\0" -END - -#endif // APSTUDIO_INVOKED - - -///////////////////////////////////////////////////////////////////////////// -// -// Icon -// - -// Icon with lowest ID value placed first to ensure application icon -// remains consistent on all systems. -IDI_APP_ICON ICON "resources\\app_icon.ico" - - -///////////////////////////////////////////////////////////////////////////// -// -// Version -// - -#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) -#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD -#else -#define VERSION_AS_NUMBER 1,0,0,0 -#endif - -#if defined(FLUTTER_VERSION) -#define VERSION_AS_STRING FLUTTER_VERSION -#else -#define VERSION_AS_STRING "1.0.0" -#endif - -VS_VERSION_INFO VERSIONINFO - FILEVERSION VERSION_AS_NUMBER - PRODUCTVERSION VERSION_AS_NUMBER - FILEFLAGSMASK VS_FFI_FILEFLAGSMASK -#ifdef _DEBUG - FILEFLAGS VS_FF_DEBUG -#else - FILEFLAGS 0x0L -#endif - FILEOS VOS__WINDOWS32 - FILETYPE VFT_APP - FILESUBTYPE 0x0L -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904e4" - BEGIN - VALUE "CompanyName", "com.lycaonsolutions" "\0" - VALUE "FileDescription", "t4code" "\0" - VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "t4code" "\0" - VALUE "LegalCopyright", "Copyright (C) 2026 com.lycaonsolutions. All rights reserved." "\0" - VALUE "OriginalFilename", "t4code.exe" "\0" - VALUE "ProductName", "t4code" "\0" - VALUE "ProductVersion", VERSION_AS_STRING "\0" - END - END - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x409, 1252 - END -END - -#endif // English (United States) resources -///////////////////////////////////////////////////////////////////////////// - - - -#ifndef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 3 resource. -// - - -///////////////////////////////////////////////////////////////////////////// -#endif // not APSTUDIO_INVOKED diff --git a/apps/flutter/windows/runner/flutter_window.cpp b/apps/flutter/windows/runner/flutter_window.cpp deleted file mode 100644 index 955ee303..00000000 --- a/apps/flutter/windows/runner/flutter_window.cpp +++ /dev/null @@ -1,71 +0,0 @@ -#include "flutter_window.h" - -#include - -#include "flutter/generated_plugin_registrant.h" - -FlutterWindow::FlutterWindow(const flutter::DartProject& project) - : project_(project) {} - -FlutterWindow::~FlutterWindow() {} - -bool FlutterWindow::OnCreate() { - if (!Win32Window::OnCreate()) { - return false; - } - - RECT frame = GetClientArea(); - - // The size here must match the window dimensions to avoid unnecessary surface - // creation / destruction in the startup path. - flutter_controller_ = std::make_unique( - frame.right - frame.left, frame.bottom - frame.top, project_); - // Ensure that basic setup of the controller was successful. - if (!flutter_controller_->engine() || !flutter_controller_->view()) { - return false; - } - RegisterPlugins(flutter_controller_->engine()); - SetChildContent(flutter_controller_->view()->GetNativeWindow()); - - flutter_controller_->engine()->SetNextFrameCallback([&]() { - this->Show(); - }); - - // Flutter can complete the first frame before the "show window" callback is - // registered. The following call ensures a frame is pending to ensure the - // window is shown. It is a no-op if the first frame hasn't completed yet. - flutter_controller_->ForceRedraw(); - - return true; -} - -void FlutterWindow::OnDestroy() { - if (flutter_controller_) { - flutter_controller_ = nullptr; - } - - Win32Window::OnDestroy(); -} - -LRESULT -FlutterWindow::MessageHandler(HWND hwnd, UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - // Give Flutter, including plugins, an opportunity to handle window messages. - if (flutter_controller_) { - std::optional result = - flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, - lparam); - if (result) { - return *result; - } - } - - switch (message) { - case WM_FONTCHANGE: - flutter_controller_->engine()->ReloadSystemFonts(); - break; - } - - return Win32Window::MessageHandler(hwnd, message, wparam, lparam); -} diff --git a/apps/flutter/windows/runner/flutter_window.h b/apps/flutter/windows/runner/flutter_window.h deleted file mode 100644 index 6da0652f..00000000 --- a/apps/flutter/windows/runner/flutter_window.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef RUNNER_FLUTTER_WINDOW_H_ -#define RUNNER_FLUTTER_WINDOW_H_ - -#include -#include - -#include - -#include "win32_window.h" - -// A window that does nothing but host a Flutter view. -class FlutterWindow : public Win32Window { - public: - // Creates a new FlutterWindow hosting a Flutter view running |project|. - explicit FlutterWindow(const flutter::DartProject& project); - virtual ~FlutterWindow(); - - protected: - // Win32Window: - bool OnCreate() override; - void OnDestroy() override; - LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, - LPARAM const lparam) noexcept override; - - private: - // The project to run. - flutter::DartProject project_; - - // The Flutter instance hosted by this window. - std::unique_ptr flutter_controller_; -}; - -#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/apps/flutter/windows/runner/main.cpp b/apps/flutter/windows/runner/main.cpp deleted file mode 100644 index e24a7da4..00000000 --- a/apps/flutter/windows/runner/main.cpp +++ /dev/null @@ -1,43 +0,0 @@ -#include -#include -#include - -#include "flutter_window.h" -#include "utils.h" - -int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, - _In_ wchar_t *command_line, _In_ int show_command) { - // Attach to console when present (e.g., 'flutter run') or create a - // new console when running with a debugger. - if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { - CreateAndAttachConsole(); - } - - // Initialize COM, so that it is available for use in the library and/or - // plugins. - ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - - flutter::DartProject project(L"data"); - - std::vector command_line_arguments = - GetCommandLineArguments(); - - project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); - - FlutterWindow window(project); - Win32Window::Point origin(10, 10); - Win32Window::Size size(1280, 720); - if (!window.Create(L"t4code", origin, size)) { - return EXIT_FAILURE; - } - window.SetQuitOnClose(true); - - ::MSG msg; - while (::GetMessage(&msg, nullptr, 0, 0)) { - ::TranslateMessage(&msg); - ::DispatchMessage(&msg); - } - - ::CoUninitialize(); - return EXIT_SUCCESS; -} diff --git a/apps/flutter/windows/runner/resource.h b/apps/flutter/windows/runner/resource.h deleted file mode 100644 index 66a65d1e..00000000 --- a/apps/flutter/windows/runner/resource.h +++ /dev/null @@ -1,16 +0,0 @@ -//{{NO_DEPENDENCIES}} -// Microsoft Visual C++ generated include file. -// Used by Runner.rc -// -#define IDI_APP_ICON 101 - -// Next default values for new objects -// -#ifdef APSTUDIO_INVOKED -#ifndef APSTUDIO_READONLY_SYMBOLS -#define _APS_NEXT_RESOURCE_VALUE 102 -#define _APS_NEXT_COMMAND_VALUE 40001 -#define _APS_NEXT_CONTROL_VALUE 1001 -#define _APS_NEXT_SYMED_VALUE 101 -#endif -#endif diff --git a/apps/flutter/windows/runner/resources/app_icon.ico b/apps/flutter/windows/runner/resources/app_icon.ico deleted file mode 100644 index cf755780..00000000 Binary files a/apps/flutter/windows/runner/resources/app_icon.ico and /dev/null differ diff --git a/apps/flutter/windows/runner/runner.exe.manifest b/apps/flutter/windows/runner/runner.exe.manifest deleted file mode 100644 index 153653e8..00000000 --- a/apps/flutter/windows/runner/runner.exe.manifest +++ /dev/null @@ -1,14 +0,0 @@ - - - - - PerMonitorV2 - - - - - - - - - diff --git a/apps/flutter/windows/runner/utils.cpp b/apps/flutter/windows/runner/utils.cpp deleted file mode 100644 index 3cb71466..00000000 --- a/apps/flutter/windows/runner/utils.cpp +++ /dev/null @@ -1,69 +0,0 @@ -#include "utils.h" - -#include -#include -#include -#include - -#include - -void CreateAndAttachConsole() { - if (::AllocConsole()) { - FILE *unused; - if (freopen_s(&unused, "CONOUT$", "w", stdout)) { - _dup2(_fileno(stdout), 1); - } - if (freopen_s(&unused, "CONOUT$", "w", stderr)) { - _dup2(_fileno(stdout), 2); - } - std::ios::sync_with_stdio(); - FlutterDesktopResyncOutputStreams(); - } -} - -std::vector GetCommandLineArguments() { - // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. - int argc; - wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); - if (argv == nullptr) { - return std::vector(); - } - - std::vector command_line_arguments; - - // Skip the first argument as it's the binary name. - for (int i = 1; i < argc; i++) { - command_line_arguments.push_back(Utf8FromUtf16(argv[i])); - } - - ::LocalFree(argv); - - return command_line_arguments; -} - -std::string Utf8FromUtf16(const wchar_t* utf16_string) { - if (utf16_string == nullptr) { - return std::string(); - } - // First, find the length of the string with a safe upper bound (CWE-126). - // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. - int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); - // Now use that bounded length to determine the required buffer size. - // When an explicit length is passed, WideCharToMultiByte does not include - // the null terminator in its returned size. - int target_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - input_length, nullptr, 0, nullptr, nullptr); - std::string utf8_string; - if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { - return utf8_string; - } - utf8_string.resize(target_length); - int converted_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - input_length, utf8_string.data(), target_length, nullptr, nullptr); - if (converted_length == 0) { - return std::string(); - } - return utf8_string; -} diff --git a/apps/flutter/windows/runner/utils.h b/apps/flutter/windows/runner/utils.h deleted file mode 100644 index 3879d547..00000000 --- a/apps/flutter/windows/runner/utils.h +++ /dev/null @@ -1,19 +0,0 @@ -#ifndef RUNNER_UTILS_H_ -#define RUNNER_UTILS_H_ - -#include -#include - -// Creates a console for the process, and redirects stdout and stderr to -// it for both the runner and the Flutter library. -void CreateAndAttachConsole(); - -// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string -// encoded in UTF-8. Returns an empty std::string on failure. -std::string Utf8FromUtf16(const wchar_t* utf16_string); - -// Gets the command line arguments passed in as a std::vector, -// encoded in UTF-8. Returns an empty std::vector on failure. -std::vector GetCommandLineArguments(); - -#endif // RUNNER_UTILS_H_ diff --git a/apps/flutter/windows/runner/win32_window.cpp b/apps/flutter/windows/runner/win32_window.cpp deleted file mode 100644 index 60608d0f..00000000 --- a/apps/flutter/windows/runner/win32_window.cpp +++ /dev/null @@ -1,288 +0,0 @@ -#include "win32_window.h" - -#include -#include - -#include "resource.h" - -namespace { - -/// Window attribute that enables dark mode window decorations. -/// -/// Redefined in case the developer's machine has a Windows SDK older than -/// version 10.0.22000.0. -/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute -#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE -#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 -#endif - -constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; - -/// Registry key for app theme preference. -/// -/// A value of 0 indicates apps should use dark mode. A non-zero or missing -/// value indicates apps should use light mode. -constexpr const wchar_t kGetPreferredBrightnessRegKey[] = - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; -constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; - -// The number of Win32Window objects that currently exist. -static int g_active_window_count = 0; - -using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); - -// Scale helper to convert logical scaler values to physical using passed in -// scale factor -int Scale(int source, double scale_factor) { - return static_cast(source * scale_factor); -} - -// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. -// This API is only needed for PerMonitor V1 awareness mode. -void EnableFullDpiSupportIfAvailable(HWND hwnd) { - HMODULE user32_module = LoadLibraryA("User32.dll"); - if (!user32_module) { - return; - } - auto enable_non_client_dpi_scaling = - reinterpret_cast( - GetProcAddress(user32_module, "EnableNonClientDpiScaling")); - if (enable_non_client_dpi_scaling != nullptr) { - enable_non_client_dpi_scaling(hwnd); - } - FreeLibrary(user32_module); -} - -} // namespace - -// Manages the Win32Window's window class registration. -class WindowClassRegistrar { - public: - ~WindowClassRegistrar() = default; - - // Returns the singleton registrar instance. - static WindowClassRegistrar* GetInstance() { - if (!instance_) { - instance_ = new WindowClassRegistrar(); - } - return instance_; - } - - // Returns the name of the window class, registering the class if it hasn't - // previously been registered. - const wchar_t* GetWindowClass(); - - // Unregisters the window class. Should only be called if there are no - // instances of the window. - void UnregisterWindowClass(); - - private: - WindowClassRegistrar() = default; - - static WindowClassRegistrar* instance_; - - bool class_registered_ = false; -}; - -WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; - -const wchar_t* WindowClassRegistrar::GetWindowClass() { - if (!class_registered_) { - WNDCLASS window_class{}; - window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); - window_class.lpszClassName = kWindowClassName; - window_class.style = CS_HREDRAW | CS_VREDRAW; - window_class.cbClsExtra = 0; - window_class.cbWndExtra = 0; - window_class.hInstance = GetModuleHandle(nullptr); - window_class.hIcon = - LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); - window_class.hbrBackground = 0; - window_class.lpszMenuName = nullptr; - window_class.lpfnWndProc = Win32Window::WndProc; - RegisterClass(&window_class); - class_registered_ = true; - } - return kWindowClassName; -} - -void WindowClassRegistrar::UnregisterWindowClass() { - UnregisterClass(kWindowClassName, nullptr); - class_registered_ = false; -} - -Win32Window::Win32Window() { - ++g_active_window_count; -} - -Win32Window::~Win32Window() { - --g_active_window_count; - Destroy(); -} - -bool Win32Window::Create(const std::wstring& title, - const Point& origin, - const Size& size) { - Destroy(); - - const wchar_t* window_class = - WindowClassRegistrar::GetInstance()->GetWindowClass(); - - const POINT target_point = {static_cast(origin.x), - static_cast(origin.y)}; - HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); - UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); - double scale_factor = dpi / 96.0; - - HWND window = CreateWindow( - window_class, title.c_str(), WS_OVERLAPPEDWINDOW, - Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), - Scale(size.width, scale_factor), Scale(size.height, scale_factor), - nullptr, nullptr, GetModuleHandle(nullptr), this); - - if (!window) { - return false; - } - - UpdateTheme(window); - - return OnCreate(); -} - -bool Win32Window::Show() { - return ShowWindow(window_handle_, SW_SHOWNORMAL); -} - -// static -LRESULT CALLBACK Win32Window::WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - if (message == WM_NCCREATE) { - auto window_struct = reinterpret_cast(lparam); - SetWindowLongPtr(window, GWLP_USERDATA, - reinterpret_cast(window_struct->lpCreateParams)); - - auto that = static_cast(window_struct->lpCreateParams); - EnableFullDpiSupportIfAvailable(window); - that->window_handle_ = window; - } else if (Win32Window* that = GetThisFromHandle(window)) { - return that->MessageHandler(window, message, wparam, lparam); - } - - return DefWindowProc(window, message, wparam, lparam); -} - -LRESULT -Win32Window::MessageHandler(HWND hwnd, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - switch (message) { - case WM_DESTROY: - window_handle_ = nullptr; - Destroy(); - if (quit_on_close_) { - PostQuitMessage(0); - } - return 0; - - case WM_DPICHANGED: { - auto newRectSize = reinterpret_cast(lparam); - LONG newWidth = newRectSize->right - newRectSize->left; - LONG newHeight = newRectSize->bottom - newRectSize->top; - - SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, - newHeight, SWP_NOZORDER | SWP_NOACTIVATE); - - return 0; - } - case WM_SIZE: { - RECT rect = GetClientArea(); - if (child_content_ != nullptr) { - // Size and position the child window. - MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, - rect.bottom - rect.top, TRUE); - } - return 0; - } - - case WM_ACTIVATE: - if (child_content_ != nullptr) { - SetFocus(child_content_); - } - return 0; - - case WM_DWMCOLORIZATIONCOLORCHANGED: - UpdateTheme(hwnd); - return 0; - } - - return DefWindowProc(window_handle_, message, wparam, lparam); -} - -void Win32Window::Destroy() { - OnDestroy(); - - if (window_handle_) { - DestroyWindow(window_handle_); - window_handle_ = nullptr; - } - if (g_active_window_count == 0) { - WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); - } -} - -Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { - return reinterpret_cast( - GetWindowLongPtr(window, GWLP_USERDATA)); -} - -void Win32Window::SetChildContent(HWND content) { - child_content_ = content; - SetParent(content, window_handle_); - RECT frame = GetClientArea(); - - MoveWindow(content, frame.left, frame.top, frame.right - frame.left, - frame.bottom - frame.top, true); - - SetFocus(child_content_); -} - -RECT Win32Window::GetClientArea() { - RECT frame; - GetClientRect(window_handle_, &frame); - return frame; -} - -HWND Win32Window::GetHandle() { - return window_handle_; -} - -void Win32Window::SetQuitOnClose(bool quit_on_close) { - quit_on_close_ = quit_on_close; -} - -bool Win32Window::OnCreate() { - // No-op; provided for subclasses. - return true; -} - -void Win32Window::OnDestroy() { - // No-op; provided for subclasses. -} - -void Win32Window::UpdateTheme(HWND const window) { - DWORD light_mode; - DWORD light_mode_size = sizeof(light_mode); - LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, - kGetPreferredBrightnessRegValue, - RRF_RT_REG_DWORD, nullptr, &light_mode, - &light_mode_size); - - if (result == ERROR_SUCCESS) { - BOOL enable_dark_mode = light_mode == 0; - DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, - &enable_dark_mode, sizeof(enable_dark_mode)); - } -} diff --git a/apps/flutter/windows/runner/win32_window.h b/apps/flutter/windows/runner/win32_window.h deleted file mode 100644 index e901dde6..00000000 --- a/apps/flutter/windows/runner/win32_window.h +++ /dev/null @@ -1,102 +0,0 @@ -#ifndef RUNNER_WIN32_WINDOW_H_ -#define RUNNER_WIN32_WINDOW_H_ - -#include - -#include -#include -#include - -// A class abstraction for a high DPI-aware Win32 Window. Intended to be -// inherited from by classes that wish to specialize with custom -// rendering and input handling -class Win32Window { - public: - struct Point { - unsigned int x; - unsigned int y; - Point(unsigned int x, unsigned int y) : x(x), y(y) {} - }; - - struct Size { - unsigned int width; - unsigned int height; - Size(unsigned int width, unsigned int height) - : width(width), height(height) {} - }; - - Win32Window(); - virtual ~Win32Window(); - - // Creates a win32 window with |title| that is positioned and sized using - // |origin| and |size|. New windows are created on the default monitor. Window - // sizes are specified to the OS in physical pixels, hence to ensure a - // consistent size this function will scale the inputted width and height as - // as appropriate for the default monitor. The window is invisible until - // |Show| is called. Returns true if the window was created successfully. - bool Create(const std::wstring& title, const Point& origin, const Size& size); - - // Show the current window. Returns true if the window was successfully shown. - bool Show(); - - // Release OS resources associated with window. - void Destroy(); - - // Inserts |content| into the window tree. - void SetChildContent(HWND content); - - // Returns the backing Window handle to enable clients to set icon and other - // window properties. Returns nullptr if the window has been destroyed. - HWND GetHandle(); - - // If true, closing this window will quit the application. - void SetQuitOnClose(bool quit_on_close); - - // Return a RECT representing the bounds of the current client area. - RECT GetClientArea(); - - protected: - // Processes and route salient window messages for mouse handling, - // size change and DPI. Delegates handling of these to member overloads that - // inheriting classes can handle. - virtual LRESULT MessageHandler(HWND window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Called when CreateAndShow is called, allowing subclass window-related - // setup. Subclasses should return false if setup fails. - virtual bool OnCreate(); - - // Called when Destroy is called. - virtual void OnDestroy(); - - private: - friend class WindowClassRegistrar; - - // OS callback called by message pump. Handles the WM_NCCREATE message which - // is passed when the non-client area is being created and enables automatic - // non-client DPI scaling so that the non-client area automatically - // responds to changes in DPI. All other messages are handled by - // MessageHandler. - static LRESULT CALLBACK WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Retrieves a class instance pointer for |window| - static Win32Window* GetThisFromHandle(HWND const window) noexcept; - - // Update the window frame's theme to match the system theme. - static void UpdateTheme(HWND const window); - - bool quit_on_close_ = false; - - // window handle for top level window. - HWND window_handle_ = nullptr; - - // window handle for hosted content. - HWND child_content_ = nullptr; -}; - -#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/apps/mobile/capacitor.config.json b/apps/mobile/capacitor.config.json index 7755eff6..88f0d4ba 100644 --- a/apps/mobile/capacitor.config.json +++ b/apps/mobile/capacitor.config.json @@ -3,7 +3,7 @@ "appName": "T4 Code", "webDir": "dist", "loggingBehavior": "debug", - "appendUserAgent": " T4CodeMobile/0.1.30", + "appendUserAgent": " T4CodeMobile/0.1.31", "android": { "path": "android", "minWebViewVersion": 60, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 50e5b7bd..be106ffa 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/mobile", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "scripts": { diff --git a/apps/site/package.json b/apps/site/package.json index b47f3790..b861c334 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/site", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "scripts": { diff --git a/apps/site/src/docs/content.ts b/apps/site/src/docs/content.ts index bd95867c..9f9d4675 100644 --- a/apps/site/src/docs/content.ts +++ b/apps/site/src/docs/content.ts @@ -109,11 +109,11 @@ const install: DocTopic = { { kind: "h2", id: "install-requirements", text: "Requirements" }, { kind: "p", - text: `T4 Code is a client for [Oh My Pi](${OMP_URL}). You need an \`omp\` build with desktop appserver support installed on the machine that runs your sessions, either this one or a remote host you pair with.`, + text: `T4 Code is a client for [Oh My Pi](${OMP_URL}). The installed Apple Silicon Mac app brings its matching OMP runtime. Source builds, Linux hosts, and remote hosts need the verified integration build listed below.`, }, { kind: "p", - text: `T4 Code v${RELEASE_VERSION} was verified with OMP ${OMP_RUNTIME_VERSION} integration tag [\`${OMP_RUNTIME_TAG}\`](${OMP_RUNTIME_URL}), commit \`${OMP_RUNTIME_COMMIT}\`. That public integration is based on the [official upstream v${OMP_RUNTIME_VERSION} tag](${OMP_UPSTREAM_URL}) at commit [\`${OMP_UPSTREAM_COMMIT.slice(0, 8)}\`](${OMP_URL}/commit/${OMP_UPSTREAM_COMMIT}). The build lets T4 Code follow sessions an \`omp\` TUI is running, reconciles the complete transcript before any takeover, and hands sessions over with \`/continue-in-t4\`, on top of profile-scoped app servers, host-scoped \`usage.read\` and \`broker.status\` queries with redacted results, and each model's real thinking levels and fast support. T4 Code vendors \`@oh-my-pi/app-wire\` ${APP_WIRE_VERSION}.`, + text: `T4 Code v${RELEASE_VERSION} was verified with OMP ${OMP_RUNTIME_VERSION} integration tag [\`${OMP_RUNTIME_TAG}\`](${OMP_RUNTIME_URL}), commit \`${OMP_RUNTIME_COMMIT}\`. That public integration is based on the [official upstream v${OMP_RUNTIME_VERSION} tag](${OMP_UPSTREAM_URL}) at commit [\`${OMP_UPSTREAM_COMMIT.slice(0, 8)}\`](${OMP_URL}/commit/${OMP_UPSTREAM_COMMIT}). The build lets T4 Code follow compatible terminal sessions, reconciles the complete transcript before any takeover, and hands sessions over with \`/continue-in-t4\`, on top of profile-scoped app servers, host-scoped \`usage.read\` and \`broker.status\` queries with redacted results, and each model's real thinking levels and fast support. T4 Code vendors \`@oh-my-pi/app-wire\` ${APP_WIRE_VERSION}.`, }, { kind: "note", @@ -129,25 +129,21 @@ const firstRun: DocTopic = { blocks: [ { kind: "note", - text: "On an Apple Silicon Mac, T4 Code installs its pinned backend inside its own Application Support folder. It does not replace the `omp` command you may already use in Terminal.", + text: "On an Apple Silicon Mac, T4 Code installs its pinned backend inside its own Application Support folder. It does not replace the `omp` command you may already use in Terminal. To start compatible terminal sessions, open **Settings → Hosts** and choose **Install t4-omp**.", }, - { kind: "h2", id: "first-run-discovery", text: "How desktop T4 finds omp" }, - { kind: "p", text: "T4 Code checks these places, in order, and uses the first match:" }, + { kind: "h2", id: "first-run-discovery", text: "Terminal integration" }, + { kind: "p", text: "The installed Mac app uses its bundled runtime. The optional `t4-omp` command points to that exact runtime while leaving your existing `omp` command untouched. T4 installs it at `~/.local/bin/t4-omp`." }, { - kind: "ol", + kind: "ul", items: [ - "The `OMP_EXECUTABLE` environment variable", - "Directories on your `PATH`", - "`~/.local/bin/omp`", - "`~/bin/omp`", - "`/usr/local/bin/omp`", - "`/usr/bin/omp`", - "`/opt/omp/bin/omp`", + "Use `t4-omp` for terminal or CMUX sessions you may want to move into T4 Code.", + "Run `/continue-in-t4` in that terminal session to hand it over safely.", + "Continue using your existing `omp` command when you specifically want the other installation; T4 never overwrites it.", ], }, { kind: "p", - text: "Before trusting a match, T4 runs `omp appserver status --json` and checks the answer. A build that cannot answer is skipped.", + text: "For source development and non-Mac hosts, T4 still validates `OMP_EXECUTABLE`, `PATH`, and the documented common installation locations before starting its host.", }, { kind: "h2", id: "first-run-phone", text: "Use your phone" }, { @@ -251,7 +247,7 @@ const localSessions: DocTopic = { { kind: "h2", id: "local-sessions-terminal", text: "Sessions started in the terminal" }, { kind: "p", - text: "A session running as a plain `omp` TUI on the host appears in T4 Code marked **Active elsewhere**. While the TUI owns it, T4 follows the session's durable transcript: complete records, including saved images, appear as they land on disk. Every write control is disabled with the reason. To continue in T4, run `/continue-in-t4` in the TUI, or just exit it.", + text: "A session started with `t4-omp` appears in T4 Code marked **Active elsewhere** while its terminal owns it. T4 follows the durable transcript, and `/continue-in-t4` hands it over safely. A session started by an OMP build without the compatible handoff signal remains readable but is marked **Read-only · use t4-omp** instead of pretending a takeover is in progress.", }, { kind: "p", @@ -259,7 +255,7 @@ const localSessions: DocTopic = { }, { kind: "note", - text: "Ownership copy never guesses. Only a confirmed live lock is called active in another app. A lock that has gone quiet reads as waiting to take over, and a malformed or unrecognized lock keeps the session read-only as ownership unclear. Following a terminal session needs the verified OMP integration build; see [Requirements](#install-requirements).", + text: "Ownership copy never guesses. Only a confirmed live lock is called active in another app. A lock that has gone quiet reads as waiting to take over. A malformed, unrecognized, or lockless session stays read-only. Start new terminal sessions with `t4-omp`; adopting historical lockless sessions is not yet supported.", }, { kind: "h2", id: "local-sessions-disconnects", text: "Disconnects" }, { diff --git a/apps/site/src/landing/Landing.tsx b/apps/site/src/landing/Landing.tsx index 4ea1015b..e13696c0 100644 --- a/apps/site/src/landing/Landing.tsx +++ b/apps/site/src/landing/Landing.tsx @@ -175,7 +175,7 @@ export function Landing() {

Start in the terminal, continue in T4

{renderInline( - `New in v${RELEASE_VERSION}: a session running as a plain \`omp\` TUI on the host shows up in T4 Code read-only, following along as complete transcript records land on disk. Run \`/continue-in-t4\` in the terminal (or just exit it) and T4 takes over. Input returns only once the transcript is confirmed complete; a live owner is never displaced, and unclear ownership stays read-only instead of guessing.`, + `A session started with the optional \`t4-omp\` terminal command shows up in T4 Code read-only while the terminal owns it, following along as complete transcript records land on disk. Run \`/continue-in-t4\` there (or just exit it) and T4 takes over. Your existing \`omp\` command stays untouched; sessions without the compatible handoff signal remain safely read-only.`, )}

diff --git a/apps/site/src/release.ts b/apps/site/src/release.ts index 898dbba3..aaa8b6a6 100644 --- a/apps/site/src/release.ts +++ b/apps/site/src/release.ts @@ -6,15 +6,15 @@ export const DOCS_URL = `${SITE_URL}/docs`; export const REPO_URL = "https://github.com/LycaonLLC/t4-code"; export const OMP_URL = "https://github.com/can1357/oh-my-pi"; export const OMP_RUNTIME_VERSION = "17.0.5"; -export const OMP_RUNTIME_COMMIT = "8476f4451ed95c5d5401785d279a93d3c659fac4"; -export const OMP_RUNTIME_TAG = "t4code-17.0.5-appserver-10"; -export const OMP_RUNTIME_URL = `https://github.com/lyc-aon/oh-my-pi/tree/${OMP_RUNTIME_TAG}`; +export const OMP_RUNTIME_COMMIT = "fc0c391334c08ab260057756aa84bd2b07741ee7"; +export const OMP_RUNTIME_TAG = "t4code-17.0.5-appserver-13"; +export const OMP_RUNTIME_URL = `https://github.com/wolfiesch/oh-my-pi/tree/${OMP_RUNTIME_TAG}`; export const OMP_UPSTREAM_TAG = "v17.0.5"; export const OMP_UPSTREAM_COMMIT = "9fd6e97113f5ed3a847e66d346970efdf8afcad9"; export const OMP_UPSTREAM_URL = `${OMP_URL}/tree/${OMP_UPSTREAM_TAG}`; export const APP_WIRE_VERSION = "0.7.0"; -export const RELEASE_TAG = "v0.1.30"; -export const RELEASE_VERSION = "0.1.30"; +export const RELEASE_TAG = "v0.1.31"; +export const RELEASE_VERSION = "0.1.31"; export const RELEASES_URL = `${REPO_URL}/releases/tag/${RELEASE_TAG}`; export const RELEASE_MANIFEST_URL = `${SITE_URL}/releases/latest.json`; @@ -49,11 +49,11 @@ function asset( } export const RELEASE_ASSETS: readonly ReleaseAsset[] = [ - asset("android", "apk", "universal", "T4-Code-0.1.30-android.apk", "Android APK"), - asset("linux", "deb", "x86_64", "T4-Code-0.1.30-linux-amd64.deb", "Linux .deb"), - asset("linux", "appimage", "x86_64", "T4-Code-0.1.30-linux-x86_64.AppImage", "Linux AppImage"), - asset("mac", "dmg", "arm64", "T4-Code-0.1.30-mac-arm64.dmg", "macOS .dmg"), - asset("mac", "zip", "arm64", "T4-Code-0.1.30-mac-arm64.zip", "macOS .zip"), + asset("android", "apk", "universal", "T4-Code-0.1.31-android.apk", "Android APK"), + asset("linux", "deb", "x86_64", "T4-Code-0.1.31-linux-amd64.deb", "Linux .deb"), + asset("linux", "appimage", "x86_64", "T4-Code-0.1.31-linux-x86_64.AppImage", "Linux AppImage"), + asset("mac", "dmg", "arm64", "T4-Code-0.1.31-mac-arm64.dmg", "macOS .dmg"), + asset("mac", "zip", "arm64", "T4-Code-0.1.31-mac-arm64.zip", "macOS .zip"), ]; export function assetsFor(platform: Platform): readonly ReleaseAsset[] { diff --git a/apps/site/test/release.test.ts b/apps/site/test/release.test.ts index b973b998..9db35d27 100644 --- a/apps/site/test/release.test.ts +++ b/apps/site/test/release.test.ts @@ -1,4 +1,4 @@ -// Release contract guard: exact v0.1.30 asset names and URLs, and the +// Release contract guard: exact v0.1.31 asset names and URLs, and the // platform-detection rule the hero download button relies on. import { describe, expect, it } from "vite-plus/test"; import { @@ -20,13 +20,13 @@ import { } from "../src/release.ts"; describe("release assets", () => { - it("carries the five contracted v0.1.30 filenames", () => { + it("carries the five contracted v0.1.31 filenames", () => { expect(RELEASE_ASSETS.map((a) => a.filename)).toEqual([ - "T4-Code-0.1.30-android.apk", - "T4-Code-0.1.30-linux-amd64.deb", - "T4-Code-0.1.30-linux-x86_64.AppImage", - "T4-Code-0.1.30-mac-arm64.dmg", - "T4-Code-0.1.30-mac-arm64.zip", + "T4-Code-0.1.31-android.apk", + "T4-Code-0.1.31-linux-amd64.deb", + "T4-Code-0.1.31-linux-x86_64.AppImage", + "T4-Code-0.1.31-mac-arm64.dmg", + "T4-Code-0.1.31-mac-arm64.zip", ]); }); @@ -38,8 +38,8 @@ describe("release assets", () => { it("targets the public LycaonLLC repo", () => { expect(REPO_URL).toBe("https://github.com/LycaonLLC/t4-code"); - expect(RELEASE_TAG).toBe("v0.1.30"); - expect(RELEASE_VERSION).toBe("0.1.30"); + expect(RELEASE_TAG).toBe("v0.1.31"); + expect(RELEASE_VERSION).toBe("0.1.31"); expect(RELEASE_MANIFEST_URL).toBe("https://t4code.net/releases/latest.json"); }); @@ -61,10 +61,10 @@ describe("release assets", () => { describe("OMP integration contract", () => { it("pins the verified runtime tag, commit, and app-wire package", () => { - expect(OMP_RUNTIME_TAG).toBe("t4code-17.0.5-appserver-10"); - expect(OMP_RUNTIME_COMMIT).toBe("8476f4451ed95c5d5401785d279a93d3c659fac4"); + expect(OMP_RUNTIME_TAG).toBe("t4code-17.0.5-appserver-13"); + expect(OMP_RUNTIME_COMMIT).toBe("fc0c391334c08ab260057756aa84bd2b07741ee7"); expect(OMP_RUNTIME_URL).toBe( - "https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-10", + "https://github.com/wolfiesch/oh-my-pi/tree/t4code-17.0.5-appserver-13", ); expect(OMP_UPSTREAM_TAG).toBe("v17.0.5"); expect(OMP_UPSTREAM_COMMIT).toBe("9fd6e97113f5ed3a847e66d346970efdf8afcad9"); diff --git a/apps/web/package.json b/apps/web/package.json index bdc96cd2..4a35c4af 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/web", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index d8e3456c..3f5605d0 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -18,7 +18,10 @@ import { handoffTranscriptSearchQuery } from "../features/transcript-search/inde import { composerStore } from "../features/composer/composer-store.ts"; import { TRANSCRIPT_SEARCH_ROUTE } from "../features/transcript-search/route.ts"; import { getInspectorStore } from "../features/panes/inspector-store.ts"; -import { startDesktopRuntime, useDesktopRuntimeSnapshot } from "../platform/desktop-runtime.ts"; +import { + startDesktopRuntime, + useDesktopRuntimeSelector, +} from "../platform/desktop-runtime.ts"; import { ATTENTION_INBOX_FIXTURES, buildAttentionInboxViewModel, @@ -35,6 +38,8 @@ import { ResizeHandle } from "./ResizeHandle.tsx"; import { Titlebar } from "./Titlebar.tsx"; import { resolveRailTogglePresentation } from "./rail-toggle.ts"; +const EXPANDED_RAIL_SESSION_LIMIT = 100; + export function AppShell() { const navigate = useNavigate(); const railOverlaid = useMediaQuery(RAIL_OVERLAY_QUERY); @@ -53,6 +58,7 @@ export function AppShell() { const projectExpandedById = useWorkspace((state) => state.projectExpandedById); const hiddenProjectIds = useWorkspace((state) => state.hiddenProjectIds); const projectAliasById = useWorkspace((state) => state.projectAliasById); + const activeSessionId = useWorkspace((state) => state.activeSessionId); const lastVisitedAtBySessionId = useWorkspace((state) => state.lastVisitedAtBySessionId); const lastSeenAttentionOutcomeBySessionKey = useWorkspace( (state) => state.lastSeenAttentionOutcomeBySessionKey, @@ -61,7 +67,7 @@ export function AppShell() { const [nowMs] = useState(() => Date.now()); const shellData = useShellData(); - const runtimeSnapshot = useDesktopRuntimeSnapshot(); + const defaultProjectsExpanded = shellData.sessions.length < EXPANDED_RAIL_SESSION_LIMIT; const currentGroups = useMemo( () => buildProjectGroups( @@ -74,6 +80,8 @@ export function AppShell() { filter: railFilter, query: railQuery, sort: railSort, + defaultExpanded: defaultProjectsExpanded, + activeSessionId, projectManualOrder, sessionManualOrderByProjectId, projectAliasById, @@ -82,6 +90,8 @@ export function AppShell() { [ shellData, projectExpandedById, + activeSessionId, + defaultProjectsExpanded, lastVisitedAtBySessionId, hiddenProjectIds, projectAliasById, @@ -104,6 +114,8 @@ export function AppShell() { filter: railFilter, query: railQuery, sort: railSort, + defaultExpanded: defaultProjectsExpanded, + activeSessionId, projectManualOrder, sessionManualOrderByProjectId, projectAliasById, @@ -112,6 +124,8 @@ export function AppShell() { [ shellData, projectExpandedById, + activeSessionId, + defaultProjectsExpanded, lastVisitedAtBySessionId, railFilter, railQuery, @@ -129,11 +143,20 @@ export function AppShell() { lastVisitedAtBySessionId, "current", {}, - { sort: railSort, projectManualOrder, sessionManualOrderByProjectId, projectAliasById }, + { + sort: railSort, + defaultExpanded: defaultProjectsExpanded, + activeSessionId, + projectManualOrder, + sessionManualOrderByProjectId, + projectAliasById, + }, ), [ shellData, projectExpandedById, + activeSessionId, + defaultProjectsExpanded, lastVisitedAtBySessionId, projectAliasById, railSort, @@ -149,11 +172,20 @@ export function AppShell() { lastVisitedAtBySessionId, "archived", {}, - { sort: railSort, projectManualOrder, sessionManualOrderByProjectId, projectAliasById }, + { + sort: railSort, + defaultExpanded: defaultProjectsExpanded, + activeSessionId, + projectManualOrder, + sessionManualOrderByProjectId, + projectAliasById, + }, ), [ shellData, projectExpandedById, + activeSessionId, + defaultProjectsExpanded, lastVisitedAtBySessionId, railSort, projectManualOrder, @@ -170,15 +202,15 @@ export function AppShell() { (session) => session.archivedAt === undefined, ).length; const archivedCount = shellData.sessions.length - currentCount; - const attentionCount = useMemo( - () => - runtimeSnapshot === null - ? buildAttentionInboxViewModel(ATTENTION_INBOX_FIXTURES.sample.items).urgentCount - : deriveAttentionInbox(runtimeSnapshot, { - seenOutcomeIdsBySessionKey: lastSeenAttentionOutcomeBySessionKey, - }).urgentCount, - [lastSeenAttentionOutcomeBySessionKey, runtimeSnapshot], + const liveAttentionCount = useDesktopRuntimeSelector( + (snapshot) => + deriveAttentionInbox(snapshot, { + seenOutcomeIdsBySessionKey: lastSeenAttentionOutcomeBySessionKey, + }).urgentCount, ); + const attentionCount = + liveAttentionCount ?? + buildAttentionInboxViewModel(ATTENTION_INBOX_FIXTURES.sample.items).urgentCount; const hiddenProjectIdSet = useMemo( () => new Set(Object.keys(hiddenProjectIds).filter((id) => hiddenProjectIds[id] === true)), [hiddenProjectIds], @@ -202,6 +234,9 @@ export function AppShell() { state.sessionListView, state.hiddenProjectIds, { + activeSessionId: state.activeSessionId, + defaultExpanded: + getShellData().sessions.length < EXPANDED_RAIL_SESSION_LIMIT, filter: state.railFilter, query: state.railQuery, sort: state.railSort, diff --git a/apps/web/src/components/Rail.tsx b/apps/web/src/components/Rail.tsx index ad853492..3e05ec49 100644 --- a/apps/web/src/components/Rail.tsx +++ b/apps/web/src/components/Rail.tsx @@ -83,8 +83,8 @@ import { terminateLiveSession, } from "../features/session-runtime/session-management.ts"; import { resolveSessionManagementNavigation } from "../features/session-runtime/session-navigation.ts"; -import { presentSessionControlKind } from "../features/session-runtime/session-observer.ts"; -import { desktopRuntime, useDesktopRuntimeSnapshot } from "../platform/desktop-runtime.ts"; +import { presentSessionState } from "../features/session-runtime/session-state.ts"; +import { desktopRuntime } from "../platform/desktop-runtime.ts"; import { deriveWorkspaceData, requiresProfileChoiceForCreate, @@ -92,19 +92,12 @@ import { resolveLiveProjectCreateTargets, resolveLiveSession, } from "../platform/live-workspace.ts"; +import { useWorkspaceRuntimeSnapshot } from "../state/shell-data.ts"; import { useWorkspace, workspaceStore } from "../state/store-instance.ts"; import { SessionListTabs } from "./SessionListTabs.tsx"; export function describeSessionState(session: WorkspaceSession): string { - if (session.freshness === "offline") return "Offline"; - if (session.freshness === "cached") return "Cached"; - // Owner kind is never proven across the wire, so labels stay generic — - // and only a confirmed live lock reads "Active elsewhere". - if (session.control !== undefined) return presentSessionControlKind(session.control).railLabel; - if (session.status !== null) return ""; - if (session.lifecycle === "idle") return "Idle"; - if (session.lifecycle === "closed") return "Stopped"; - return "Status unknown"; + return presentSessionState(session).label; } type SessionDialog = "rename" | "terminate" | "delete" | null; @@ -115,6 +108,7 @@ function SessionRowItem({ active, index, nowMs, + runtimeSnapshot, onAnnounce, contextLabel, manual, @@ -127,6 +121,7 @@ function SessionRowItem({ active: boolean; index: number; nowMs: number; + runtimeSnapshot: ReturnType; onAnnounce: (message: string) => void; contextLabel?: string; manual?: boolean; @@ -136,12 +131,12 @@ function SessionRowItem({ onDrop?: (sourceId: string) => void; }) { const navigate = useNavigate(); - const snapshot = useDesktopRuntimeSnapshot(); const controller = desktopRuntime(); const { session } = row; const pinned = useWorkspace((state) => state.pinnedSessionIds[session.id] === true); - const stateLabel = describeSessionState(session); - const ariaState = stateLabel !== "" ? stateLabel : (session.status ?? "Status unknown"); + const statePresentation = presentSessionState(session); + const stateLabel = statePresentation.label; + const ariaState = stateLabel; const [menuOpen, setMenuOpen] = useState(false); const [dialog, setDialog] = useState(null); const [renameValue, setRenameValue] = useState(session.title); @@ -149,7 +144,8 @@ function SessionRowItem({ const [pending, setPending] = useState(null); const pendingRef = useRef(null); const [error, setError] = useState(null); - const address = snapshot === null ? null : resolveLiveSession(snapshot, session.id); + const address = + runtimeSnapshot === null ? null : resolveLiveSession(runtimeSnapshot, session.id); const archived = session.archivedAt !== undefined; const support = ( @@ -160,9 +156,9 @@ function SessionRowItem({ | "session.restore" | "session.delete", ) => - snapshot === null || address === null + runtimeSnapshot === null || address === null ? { supported: false, reason: "Connect to this host to manage the session" } - : managementCommandSupport(snapshot, address, command); + : managementCommandSupport(runtimeSnapshot, address, command); const renameSupport = support("session.rename"); const terminateSupport = support("session.close"); const archiveSupport = support("session.archive"); @@ -358,11 +354,13 @@ function SessionRowItem({ )} {formatRelativeTime(session.updatedAt, nowMs)} - {session.status !== null ? ( - - ) : ( - stateLabel !== "" && {stateLabel} - )} + + {statePresentation.status !== null ? ( + + ) : ( + {stateLabel} + )} + } @@ -752,6 +750,7 @@ function ProjectHeaderRow({ onDrop, onPin, onAnnounce, + runtimeSnapshot, view, }: { group: ProjectGroup; @@ -768,10 +767,10 @@ function ProjectHeaderRow({ onDrop: (sourceId: string) => void; onPin: () => void; onAnnounce: (message: string) => void; + runtimeSnapshot: ReturnType; view: SessionListView; }) { const navigate = useNavigate(); - const snapshot = useDesktopRuntimeSnapshot(); const controller = desktopRuntime(); const [pending, setPending] = useState(false); const [renameOpen, setRenameOpen] = useState(false); @@ -781,6 +780,7 @@ function ProjectHeaderRow({ const [error, setError] = useState(null); const disclosureRef = useRef(null); + const snapshot = runtimeSnapshot; const address = snapshot !== null ? resolveLiveProject(snapshot, group.project.id) : null; const createTargets = snapshot === null @@ -1535,6 +1535,7 @@ export function Rail({ attentionCount: number; }) { const navigate = useNavigate(); + const runtimeSnapshot = useWorkspaceRuntimeSnapshot(); const activeSessionId = useWorkspace((state) => state.activeSessionId); const organization = useWorkspace((state) => state.railOrganization); const sort = useWorkspace((state) => state.railSort); @@ -1579,6 +1580,9 @@ export function Rail({ [allGroups], ); const matchCount = flatEntries.length; + const matchCountTruncated = allGroups.some( + (group) => group.host.sessionInventoryTruncated === true, + ); const moveProject = (projectId: string, direction: -1 | 1) => { const visibleIds = groups.map((group) => group.project.id); @@ -1667,7 +1671,17 @@ export function Rail({

Sessions

- {matchCount} matches + + {matchCount} + {matchCountTruncated ? "+" : ""} matches + hiddenProjectIds.has(group.project.id))} organization={organization} @@ -1797,6 +1811,7 @@ export function Rail({ nowMs={nowMs} onAnnounce={setAnnouncement} row={row} + runtimeSnapshot={runtimeSnapshot} /> ))}
@@ -1837,6 +1852,7 @@ export function Rail({ ) } row={row} + runtimeSnapshot={runtimeSnapshot} /> ))}
@@ -1885,6 +1901,7 @@ export function Rail({ ); }} pinned={pinnedProjectIds[group.project.id] === true} + runtimeSnapshot={runtimeSnapshot} shortcutHidden={hiddenProjectIds.has(group.project.id)} view={view} /> @@ -1907,6 +1924,7 @@ export function Rail({ dropSession(group.project.id, sessionIds, sourceId, row.session.id) } row={row} + runtimeSnapshot={runtimeSnapshot} /> ))} {group.sessions.length > limit && ( diff --git a/apps/web/src/components/SessionScreen.tsx b/apps/web/src/components/SessionScreen.tsx index ddeb8405..f08b2e2b 100644 --- a/apps/web/src/components/SessionScreen.tsx +++ b/apps/web/src/components/SessionScreen.tsx @@ -9,7 +9,6 @@ import { ScrollArea, Sheet, SheetPopup, - StatusPill, Tooltip, TooltipPopup, TooltipTrigger, @@ -45,10 +44,9 @@ import { type ExportMeta, } from "../features/transcript/export.ts"; import { - FreshnessBadge, - SessionLifecycleBadge, SessionMain, - SessionOwnershipBadge, + SessionConnectionBadge, + SessionStateBadge, } from "../features/transcript/SessionMain.tsx"; import { RIGHT_PANE_DOCK_QUERY, useMediaQuery } from "../hooks/useMediaQuery.ts"; import { rendererPlatform, useWorkspace, workspaceStore } from "../state/store-instance.ts"; @@ -373,6 +371,10 @@ export function SessionScreen({ const runtimeSnapshot = useDesktopRuntimeSnapshot(); const previewAddress = runtimeSnapshot === null ? null : resolveLiveSession(runtimeSnapshot, sessionId); + const connectionState = + runtimeSnapshot === null || previewAddress === null + ? null + : (runtimeSnapshot.connections.get(previewAddress.targetId) ?? null); const previewCount = rendererPlatform.demo ? 1 : previewAddress === null @@ -426,17 +428,22 @@ export function SessionScreen({ /> - {archived && Archived · read-only} - {session.status !== null && ( - <> - - - - )} - - - - + + {connectionState !== null && } + {!archived && + (connectionState === null || connectionState === "connected") && ( + + )} + {archived && ( + + Archived + Archived · read-only + + )} {previewCount > 0 && ( 0; const canSubmit = !disabled && !preparingAttachments && (draft.trim() !== "" || attachments.length > 0); @@ -856,14 +857,14 @@ export function Composer({ )}
@@ -897,7 +898,7 @@ export function Composer({ onSubmit={submit} primaryBusy={sending || preparingAttachments} primaryDisabled={!canSubmit || sending} - primaryLabel={primaryLabel} + primaryLabel={primaryDisplayLabel} queueDisabled={disabled || sending || preparingAttachments || draft.trim() === ""} turnActive={turnActive} /> diff --git a/apps/web/src/features/panes/FilesPane.tsx b/apps/web/src/features/panes/FilesPane.tsx index 2e8612d9..940f335d 100644 --- a/apps/web/src/features/panes/FilesPane.tsx +++ b/apps/web/src/features/panes/FilesPane.tsx @@ -68,9 +68,14 @@ function TreeRows({ api, query }: { readonly api: InspectorStoreApi; readonly qu } if (rootState === "error") { return ( -

- The host could not list this project's files right now. -

+
+

+ The host could not list this project's files right now. +

+ +
); } if (rows.length === 0) { @@ -265,6 +270,7 @@ export function FilesPane({ const query = useInspector(api, (state) => state.files.query); const selectedPath = useInspector(api, (state) => state.files.selectedPath); const preview = useInspector(api, (state) => state.files.preview); + const previewRevision = useInspector(api, (state) => state.files.previewRevision); const offline = useInspector(api, (state) => state.files.offline); const draftsByPath = useInspector(api, (state) => state.files.draftsByPath); const fileWrite = useInspector(api, (state) => state.actions.fileWrite); @@ -273,7 +279,11 @@ export function FilesPane({ (state) => state.contextItemsBySessionId[sessionId] ?? EMPTY_CONTEXT_ITEMS, ); const editablePreview = - preview !== null && preview !== "loading" && preview.kind === "code" && !preview.truncated; + preview !== null && + preview !== "loading" && + preview.kind === "code" && + !preview.truncated && + previewRevision !== null; const rootKnown = useInspector(api, (state) => state.files.childrenByPath[""] !== undefined); const contextPreview: FilePreview | null = draft !== undefined @@ -361,7 +371,9 @@ export function FilesPane({ title={ preview.truncated ? "The host returned only part of this file." - : (fileWrite.reason ?? undefined) + : previewRevision === null + ? "The host did not provide the file revision required for a safe edit." + : (fileWrite.reason ?? undefined) } variant="outline" > diff --git a/apps/web/src/features/panes/PaneContent.tsx b/apps/web/src/features/panes/PaneContent.tsx index 986b479d..e23565d4 100644 --- a/apps/web/src/features/panes/PaneContent.tsx +++ b/apps/web/src/features/panes/PaneContent.tsx @@ -10,7 +10,7 @@ import { rendererPlatform } from "../../state/store-instance.ts"; import type { SessionSurfaceId } from "../../state/workspace-store.ts"; import { installTerminalStoreFactory, createTerminalStore } from "../terminal/terminal-store.ts"; import { createFixturePtyBridge } from "../terminal/pty.ts"; -import { createLivePtySessionFactory } from "../terminal/live-pty.ts"; +import { createResolvingLivePtySessionFactory } from "../terminal/live-pty.ts"; import { ActivityPane } from "./ActivityPane.tsx"; import { AgentsPane } from "./AgentsPane.tsx"; import { AGENT_OWNED_TERMINAL_IDS, installFixtureInspector } from "./fixtures.ts"; @@ -52,18 +52,18 @@ if (rendererPlatform.mode === "browser") { installTerminalStoreFactory((viewId) => { const snapshot = controller.getSnapshot(); const address = resolveLiveSession(snapshot, viewId); - if (address === null) { - const bridge = { - kind: "desktop" as const, - open: () => { throw new Error("Live session unavailable"); }, - }; - return createTerminalStore({ sessionId: viewId, bridge, cwd: null, host: { label: "Unavailable host", remote: false } }); - } - const bridge = createLivePtySessionFactory(controller, () => controller.getSnapshot(), address); - const target = snapshot.targets.get(address.targetId); + const bridge = createResolvingLivePtySessionFactory( + controller, + () => controller.getSnapshot(), + () => resolveLiveSession(controller.getSnapshot(), viewId), + ); + const target = address === null ? undefined : snapshot.targets.get(address.targetId); return createTerminalStore({ sessionId: viewId, - host: { label: target?.label ?? address.hostId, remote: target?.kind !== "local" }, + host: { + label: target?.label ?? address?.hostId ?? "Connecting host", + remote: target?.kind === "remote", + }, bridge, cwd: null, }); diff --git a/apps/web/src/features/panes/hooks.ts b/apps/web/src/features/panes/hooks.ts index 2d5a5fd6..d9b1f212 100644 --- a/apps/web/src/features/panes/hooks.ts +++ b/apps/web/src/features/panes/hooks.ts @@ -42,11 +42,20 @@ export function useVirtualWindow( const element = containerRef.current; if (element === null) return; const onScroll = () => setScrollTop(element.scrollTop); - const observer = new ResizeObserver(() => setViewport(element.clientHeight)); + let frame = 0; + const measureViewport = () => { + frame = 0; + setViewport(element.clientHeight); + }; + const observer = new ResizeObserver(() => { + if (frame !== 0) cancelAnimationFrame(frame); + frame = requestAnimationFrame(measureViewport); + }); setViewport(element.clientHeight); element.addEventListener("scroll", onScroll, { passive: true }); observer.observe(element); return () => { + if (frame !== 0) cancelAnimationFrame(frame); element.removeEventListener("scroll", onScroll); observer.disconnect(); }; diff --git a/apps/web/src/features/panes/inspector-store.ts b/apps/web/src/features/panes/inspector-store.ts index fc373b00..46488df6 100644 --- a/apps/web/src/features/panes/inspector-store.ts +++ b/apps/web/src/features/panes/inspector-store.ts @@ -339,7 +339,8 @@ export function createInspectorStore(options: CreateInspectorStoreOptions): Insp }, requestDir: (path) => { if (!isLoadableDirectoryPath(path)) return; - if (get().files.childrenByPath[path] !== undefined) return; + const current = get().files.childrenByPath[path]; + if (current !== undefined && current !== "error") return; set((state) => ({ files: { ...state.files, @@ -369,7 +370,8 @@ export function createInspectorStore(options: CreateInspectorStoreOptions): Insp preview === "loading" || preview.kind !== "code" || preview.path !== path || - preview.truncated + preview.truncated || + state.files.previewRevision === null ) return state; const draft: FileDraft = { diff --git a/apps/web/src/features/panes/live-inspector.ts b/apps/web/src/features/panes/live-inspector.ts index a5aa9bc8..7f8be92f 100644 --- a/apps/web/src/features/panes/live-inspector.ts +++ b/apps/web/src/features/panes/live-inspector.ts @@ -58,6 +58,7 @@ import { } from "./turn-review.ts"; import type { AgentNode, + FilePreview, FileTreeNode, InspectorActionAvailability, PaneActionAvailability, @@ -258,6 +259,31 @@ function fileTreeNodesFromListResult(result: unknown): readonly FileTreeNode[] | return nodes; } +function filePreviewFromReadResult( + result: unknown, + expectedPath: string, +): { readonly preview: FilePreview; readonly revision: string } | null { + if (result === null || typeof result !== "object" || Array.isArray(result)) return null; + const record = result as Record; + if ( + (record.path !== undefined && record.path !== expectedPath) || + typeof record.content !== "string" || + typeof record.revision !== "string" || + record.revision.length === 0 + ) { + return null; + } + return { + preview: { + kind: "code", + path: expectedPath, + text: record.content, + truncated: record.content.length >= 8192, + }, + revision: record.revision, + }; +} + /** * One live inspector store for one desktop session. The store starts empty * (no sample data ever), fills from the warm projection, and stays @@ -280,14 +306,13 @@ export function createLiveInspectorStore( /** Directory paths resolved from pushed file frames; refreshed on sync. */ const frameDirs = new Set(); const pendingDirs = new Map(); - const pendingPreviews = new Map< - string, - { readonly path: string; readonly baseRevision: string | null } - >(); + const dirRetryAttempts = new Map(); + const pendingPreviews = new Map(); const pendingReviewApplies = new Map(); let reviewFiles: readonly ReviewFile[] = []; let reviewIdByPath: ReadonlyMap = new Map(); let availability: InspectorActionAvailability | null = null; + let filesListWasAvailable = false; const warmSession = (snapshot: DesktopRuntimeSnapshot): SessionProjection | undefined => snapshot.projection.sessions.get(projectionKey); @@ -340,6 +365,22 @@ export function createLiveInspectorStore( }); }; + const failDirLoad = (api: InspectorStoreApi, path: string): void => { + resolveDir(api, path, "error"); + const attempt = dirRetryAttempts.get(path) ?? 0; + if (attempt >= 3) return; + dirRetryAttempts.set(path, attempt + 1); + setTimeout(() => { + const snapshot = runtime.getSnapshot(); + if ( + snapshot.connections.get(address.targetId) === "connected" && + api.getState().files.childrenByPath[path] === "error" + ) { + api.getState().requestDir(path); + } + }, 750 * 2 ** attempt); + }; + const controller = (api: InspectorStoreApi): InspectorController => ({ kind: "desktop", performControl(scope) { @@ -475,10 +516,20 @@ export function createLiveInspectorStore( if (listable.enabled) { void sendCommand("files.list", path === "" ? {} : { path }, false) .then((result) => { - if (result.accepted) pendingDirs.set(result.requestId, path); - else resolveDir(api, path, "error"); + if (!result.accepted) { + failDirLoad(api, path); + return; + } + const listing = fileTreeNodesFromListResult(result.result); + if (listing !== "error") { + dirRetryAttempts.delete(path); + listedDirs.add(path); + resolveDir(api, path, listing); + return; + } + pendingDirs.set(result.requestId, path); }) - .catch(() => resolveDir(api, path, "error")); + .catch(() => failDirLoad(api, path)); return; } // No listing command: the pushed file frames are the whole tree. @@ -494,14 +545,6 @@ export function createLiveInspectorStore( const snapshot = runtime.getSnapshot(); const warm = warmSession(snapshot); const frame = warm?.files.get(path); - if (frame !== undefined && frame.content !== undefined) { - resolvePreview(api, previewFromFileFrame(frame), warm?.revision ?? null); - return; - } - if (snapshot.connections.get(address.targetId) !== "connected") { - resolvePreview(api, { kind: "offline", path }); - return; - } const readable = commandAvailability( snapshot, address.targetId, @@ -509,14 +552,15 @@ export function createLiveInspectorStore( "files.read", ); if (readable.enabled) { - const readRevision = expectedRevision(); void sendCommand("files.read", { path }, false) .then((result) => { if (result.accepted) { - pendingPreviews.set(result.requestId, { - path, - baseRevision: readRevision === undefined ? null : String(readRevision), - }); + const answer = filePreviewFromReadResult(result.result, path); + if (answer !== null) { + resolvePreview(api, answer.preview, answer.revision); + } else { + pendingPreviews.set(result.requestId, { path }); + } } else { resolvePreview(api, { kind: "diagnostic", @@ -534,12 +578,19 @@ export function createLiveInspectorStore( ); return; } + if (frame !== undefined && frame.content !== undefined) { + resolvePreview(api, previewFromFileFrame(frame)); + return; + } + if (snapshot.connections.get(address.targetId) !== "connected") { + resolvePreview(api, { kind: "offline", path }); + return; + } resolvePreview( api, frame !== undefined ? previewFromFileFrame(frame) : { kind: "diagnostic", path, message: "This host cannot read files from here." }, - warm?.revision ?? null, ); }, writeFile(path, content, baseRevision) { @@ -568,17 +619,25 @@ export function createLiveInspectorStore( args: { path, content }, expectedRevision: revisionValue, }) - .then((result) => - resolveFileWriteOutcome( - api, + .then((result) => { + const errorCode = result.error?.code.toUpperCase(); + if (!result.accepted) { + resolveFileWriteOutcome( + api, + path, + errorCode === "STALE_REVISION" ? "conflict" : "error", + ); + return; + } + const answer = filePreviewFromReadResult( + result.result === null || typeof result.result !== "object" + ? result.result + : { ...(result.result as Record), content }, path, - result.accepted - ? "saved" - : result.error?.code === "stale_revision" - ? "conflict" - : "error", - ), - ) + ); + resolveFileWriteOutcome(api, path, "saved"); + if (answer !== null) resolvePreview(api, answer.preview, answer.revision); + }) .catch(() => resolveFileWriteOutcome(api, path, "error")); }, }); @@ -618,6 +677,18 @@ export function createLiveInspectorStore( if (store.getState().files.offline !== offline) { store.setState((state) => ({ files: { ...state.files, offline } })); } + const filesListAvailable = commandAvailability( + snapshot, + address.targetId, + address.hostId, + "files.list", + ).enabled; + if (filesListAvailable && !filesListWasAvailable) { + for (const [path, listing] of Object.entries(store.getState().files.childrenByPath)) { + if (listing === "error") store.getState().requestDir(path); + } + } + filesListWasAvailable = filesListAvailable; if (warm === undefined) return; for (const frame of warm.agents.values()) { @@ -665,30 +736,32 @@ export function createLiveInspectorStore( pendingDirs.delete(requestId); if (result.ok) { const listing = fileTreeNodesFromListResult(result.result); - resolveDir(store, path, listing); - if (listing !== "error") listedDirs.add(path); + if (listing === "error") { + failDirLoad(store, path); + } else { + dirRetryAttempts.delete(path); + listedDirs.add(path); + resolveDir(store, path, listing); + } } else { - resolveDir(store, path, "error"); + failDirLoad(store, path); } } for (const [requestId, pending] of pendingPreviews) { const result = warm.results.get(requestId); if (result === undefined) continue; pendingPreviews.delete(requestId); - const content = - result.ok && result.result !== null && typeof result.result === "object" - ? (result.result as Record).content - : undefined; + const answer = result.ok ? filePreviewFromReadResult(result.result, pending.path) : null; resolvePreview( store, - typeof content === "string" - ? { kind: "code", path: pending.path, text: content, truncated: content.length >= 8192 } + answer !== null + ? answer.preview : { kind: "diagnostic", path: pending.path, message: "The host could not read this file.", }, - pending.baseRevision, + answer?.revision ?? null, ); } for (const [requestId, path] of pendingReviewApplies) { @@ -723,7 +796,7 @@ export function createLiveInspectorStore( if (selectedPath !== null && preview === "loading" && hasNewPreviewRevision) { const frame = warm.files.get(selectedPath); if (frame !== undefined && frame.content !== undefined) { - resolvePreview(store, previewFromFileFrame(frame), warm.revision ?? null); + resolvePreview(store, previewFromFileFrame(frame)); } } }; diff --git a/apps/web/src/features/session-runtime/live-create.ts b/apps/web/src/features/session-runtime/live-create.ts index cf9cc0cc..ba33b502 100644 --- a/apps/web/src/features/session-runtime/live-create.ts +++ b/apps/web/src/features/session-runtime/live-create.ts @@ -1,14 +1,15 @@ import { hostId as brandHostId } from "@t4-code/protocol"; import type { RendererServerEventEnvelope } from "@t4-code/protocol/desktop-ipc"; import { sessionViewId } from "../../platform/live-workspace.ts"; -import { hostSessionInventoryIsComplete } from "./session-inventory.ts"; +import { + sessionCreateSupport, + type SessionCreateSnapshot, +} from "./session-management.ts"; export interface LiveCreateAddress { readonly targetId: string; readonly hostId: string; readonly projectId: string; } export interface LiveCreateResult { readonly viewId: string; } export interface LiveCreateController { - getSnapshot(): { - connections: ReadonlyMap; - targetHosts: ReadonlyMap; + getSnapshot(): SessionCreateSnapshot & { projection: { sessionIndex: ReadonlyMap; sessionIndexMetadata: ReadonlyMap; @@ -31,24 +32,14 @@ export async function createLiveSession( timeoutMs = 15_000, ): Promise { // Dispatch-time guard, re-read at click time (UI support may be stale): - // the target must be connected, bound to this host, AND holding a - // complete session inventory before a mutating session.create leaves. - // No ownership field applies to a new session. + // the target must still be connected and bound to this host. A partial + // inventory is safe here: session.create returns the new authoritative ref, + // and the host retains that projection while discovery catches up. const assertCreatable = () => { const snapshot = controller.getSnapshot(); - if (snapshot.connections.get(address.targetId) !== "connected") { - throw new Error("Connect to this host to create a session."); - } - if (snapshot.targetHosts.get(address.targetId) !== address.hostId) { - throw new Error("Project host binding is no longer available."); - } - if ( - !hostSessionInventoryIsComplete( - snapshot as Parameters[0], - address.hostId, - ) - ) { - throw new Error("This host's session list is still syncing. Try again in a moment."); + const support = sessionCreateSupport(snapshot, address); + if (!support.supported) { + throw new Error(support.reason ?? "Session creation is unavailable."); } }; assertCreatable(); diff --git a/apps/web/src/features/session-runtime/live-runtime.ts b/apps/web/src/features/session-runtime/live-runtime.ts index f4574f62..13d98230 100644 --- a/apps/web/src/features/session-runtime/live-runtime.ts +++ b/apps/web/src/features/session-runtime/live-runtime.ts @@ -70,7 +70,7 @@ import { sessionControlForLink, WriteGateError, } from "./session-observer.ts"; -import { hostSessionInventoryIsComplete, sessionWriteLink } from "./session-inventory.ts"; +import { sessionRefIsCurrent, sessionWriteLink } from "./session-inventory.ts"; export interface LiveRuntimeOptions { readonly controller: DesktopRuntimeController; @@ -192,20 +192,21 @@ function sessionRefIsCompacting(ref: SessionRef | undefined): boolean { } /** - * Return working truth only when this connected host has supplied a complete - * live inventory. Cached or truncated rows may be useful to render, but they - * cannot settle a turn after a disconnect. + * Return working truth only when this connected host supplied this session's + * ref after the latest reconnect. A bounded/truncated inventory can still be + * authoritative for rows it actually returned. */ function authoritativeWorkingState( runtime: DesktopRuntimeSnapshot, targetId: string, hostId: string, + sessionId: string, projectionKey: string, retiredPendingPromptIds: ReadonlySet, ): boolean | null { if (runtime.connections.get(targetId) !== "connected") return null; if (runtime.targetHosts.get(targetId) !== hostId) return null; - if (!hostSessionInventoryIsComplete(runtime, hostId)) return null; + if (!sessionRefIsCurrent(runtime, hostId, sessionId)) return null; const ref = runtime.projection.sessionIndex.get(projectionKey); if (ref === undefined) return null; const pendingPrompts = pendingPromptsFromRef(ref).filter( @@ -774,6 +775,7 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu controller.getSnapshot(), targetId, options.hostId, + options.sessionId, projectionKey, retiredPendingPromptIds, ); @@ -806,6 +808,19 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu } } if (!hasAttachAuthority) return; + if ( + attached && + !attaching && + runtime.projection.sessions.get(projectionKey)?.freshness !== "fresh" + ) { + // A replacement host can publish its welcome before the renderer sees + // the transient disconnected state. The welcome correctly invalidates + // the old live projection; treat that loss of freshness as a new attach + // boundary instead of leaving the composer cached forever. + attached = false; + transcriptImagesAttached = false; + syncTranscriptImageAvailability(runtime); + } if (attached) return; if (attaching) { retryAfterAttach = true; @@ -875,6 +890,7 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu runtime, targetId, options.hostId, + options.sessionId, projectionKey, retiredPendingPromptIds, ); diff --git a/apps/web/src/features/session-runtime/session-inventory.ts b/apps/web/src/features/session-runtime/session-inventory.ts index 8242f415..ec709fde 100644 --- a/apps/web/src/features/session-runtime/session-inventory.ts +++ b/apps/web/src/features/session-runtime/session-inventory.ts @@ -16,13 +16,26 @@ export function hostSessionInventoryIsComplete( export type SessionWriteLink = "live" | "cached" | "offline"; +/** True only when this process received the ref after the latest reconnect boundary. */ +export function sessionRefIsCurrent( + snapshot: DesktopRuntimeSnapshot, + hostId: string, + sessionId: string, +): boolean { + const key = `${hostId}\u0000${sessionId}`; + return ( + snapshot.projection.sessionIndex.has(key) && + snapshot.projection.sessionRefArrivalOrdinals.has(key) + ); +} + /** * Dispatch-time freshness for one session, stricter than the render link: * offline when the target is not connected; live ONLY when the target is - * bound to this host, the host's session inventory is complete, THIS - * session is present in the index, and any warm projection is fresh. A - * missing indexed ref cannot prove the session's current state, so it - * stays cached/read-only for writes. + * bound to this host, THIS session has a ref from the current connection, + * and any warm projection is fresh. A truncated host inventory is safe for + * a ref it actually returned; retained refs that were not returned after a + * reconnect stay cached/read-only. */ export function sessionWriteLink( snapshot: DesktopRuntimeSnapshot, @@ -35,8 +48,7 @@ export function sessionWriteLink( const warm = snapshot.projection.sessions.get(key); const inventoryReady = snapshot.targetHosts.get(targetId) === hostId && - hostSessionInventoryIsComplete(snapshot, hostId) && - snapshot.projection.sessionIndex.get(key) !== undefined; + sessionRefIsCurrent(snapshot, hostId, sessionId); return !inventoryReady || (warm !== undefined && warm.freshness !== "fresh") ? "cached" : "live"; diff --git a/apps/web/src/features/session-runtime/session-management.ts b/apps/web/src/features/session-runtime/session-management.ts index 53ed0c33..08810fa2 100644 --- a/apps/web/src/features/session-runtime/session-management.ts +++ b/apps/web/src/features/session-runtime/session-management.ts @@ -13,7 +13,7 @@ import type { LiveProjectAddress, LiveSessionAddress } from "../../platform/live import { commandSupport } from "./session-controls.ts"; import { sessionActionRejectionReason } from "./command-errors.ts"; import { pendingPromptsFromRef } from "./pending-prompts.ts"; -import { hostSessionInventoryIsComplete, sessionWriteLink } from "./session-inventory.ts"; +import { sessionWriteLink } from "./session-inventory.ts"; import { presentSessionControl, readSessionControl } from "./session-observer.ts"; export type SessionManagementCommand = @@ -28,6 +28,11 @@ export interface SessionManagementSupport { readonly reason: string | null; } +export type SessionCreateSnapshot = Pick< + DesktopRuntimeSnapshot, + "connections" | "targetHosts" | "hosts" | "catalogs" +>; + /** Finder reveal is a host-native action and is never offered for remote targets. */ export function projectRevealSupport( snapshot: DesktopRuntimeSnapshot, @@ -94,7 +99,7 @@ const MANAGEMENT_SYNCING_REASON = "This session is still syncing from the host. Try again in a moment."; export function sessionCreateSupport( - snapshot: DesktopRuntimeSnapshot, + snapshot: SessionCreateSnapshot, address: LiveProjectAddress, ): SessionManagementSupport { if (snapshot.connections.get(address.targetId) !== "connected") { @@ -103,27 +108,30 @@ export function sessionCreateSupport( if (snapshot.targetHosts.get(address.targetId) !== address.hostId) { return { supported: false, reason: "Project host binding is no longer available." }; } - if (!hostSessionInventoryIsComplete(snapshot, address.hostId)) { - return { - supported: false, - reason: "This host's session list is still syncing. Try again in a moment.", - }; - } const host = snapshot.hosts.get(address.hostId); const granted = host?.grantedCapabilities ?? []; if (!granted.includes("sessions.manage")) { return { supported: false, reason: "Session creation is not granted on this host" }; } const catalog = commandSupport(snapshot.catalogs.get(address.hostId), granted, "session.create"); - return catalog.supported - ? { supported: true, reason: null } - : { + if (!catalog.supported) { + return { supported: false, reason: catalog.reason === "This host can't change this from here yet — use the terminal" ? "This host does not offer session creation yet" : catalog.reason, - }; + }; + } + const catalogFrame = snapshot.catalogs.get(address.hostId); + const modelItems = catalogFrame?.items.filter((item) => item.kind === "model") ?? []; + if (modelItems.length > 0 && !modelItems.some((item) => item.supported !== false)) { + return { + supported: false, + reason: "Configure a model for this OMP profile before creating a session", + }; + } + return { supported: true, reason: null }; } function sessionKey(address: LiveSessionAddress): string { @@ -208,10 +216,9 @@ export function managementCommandSupport( if (link === "offline") { return { supported: false, reason: "Connect to this host to manage the session" }; } - // Mirror assertSessionWritableNow(): a truncated/incomplete host inventory - // or stale cached projection cannot prove this session's current state, so - // freshness gates before any ownership or per-command reason — exactly the - // order the dispatch gate rejects in. + // Mirror assertSessionWritableNow(): this specific ref must come from the + // current connection and any warm transcript must be fresh. Global list + // truncation does not invalidate a row the host actually returned. if (link === "cached") { return { supported: false, reason: MANAGEMENT_SYNCING_REASON }; } @@ -231,11 +238,13 @@ export function managementCommandSupport( }; } const ref = sessionRefForAddress(snapshot, address); - // While another app owns the session (or this one is still taking it - // over), every lifecycle mutation — including restore — gates with the - // same honest reason the composer shows. + // A reconciling archived session is waiting on a missing/stale lock and may + // ask the host to re-check it. Concrete observer lock states remain blocked; + // the host is final authority and performs a fresh lock inspection. const control = readSessionControl(ref); - if (control !== null) { + const canProbeArchivedRestore = + command === "session.restore" && sessionIsArchived(ref) && control?.mode === "reconciling"; + if (control !== null && !canProbeArchivedRestore) { return { supported: false, reason: presentSessionControl(control).managementReason }; } if (command === "session.rename" && sessionIsArchived(ref)) { @@ -344,7 +353,7 @@ function currentRevision( * cached both explain themselves), then the strict ownership reader; the * server remains final authority either way. */ -function assertSessionWritableNow( +function assertSessionManagementFreshNow( controller: DesktopRuntimeController, address: LiveSessionAddress, ): void { @@ -356,6 +365,14 @@ function assertSessionWritableNow( if (link === "cached") { throw new Error(MANAGEMENT_SYNCING_REASON); } +} + +function assertSessionWritableNow( + controller: DesktopRuntimeController, + address: LiveSessionAddress, +): void { + assertSessionManagementFreshNow(controller, address); + const snapshot = controller.getSnapshot(); const control = readSessionControl(sessionRefForAddress(snapshot, address)); if (control !== null) { throw new Error(presentSessionControl(control).managementReason); @@ -399,7 +416,17 @@ async function runUnchallengedManagementCommand( address: LiveSessionAddress, command: "session.archive" | "session.restore", ): Promise { - assertSessionWritableNow(controller, address); + const initialRef = sessionRefForAddress(controller.getSnapshot(), address); + const control = readSessionControl(initialRef); + if ( + command === "session.restore" && + sessionIsArchived(initialRef) && + control?.mode === "reconciling" + ) { + assertSessionManagementFreshNow(controller, address); + } else { + assertSessionWritableNow(controller, address); + } const expectedRevision = currentRevision(controller, address); const ref = sessionRefForAddress(controller.getSnapshot(), address); if (command === "session.archive" && sessionIsWorking(ref)) { diff --git a/apps/web/src/features/session-runtime/session-observer.test.ts b/apps/web/src/features/session-runtime/session-observer.test.ts index 9e6797b2..3d8d7b48 100644 --- a/apps/web/src/features/session-runtime/session-observer.test.ts +++ b/apps/web/src/features/session-runtime/session-observer.test.ts @@ -62,6 +62,14 @@ describe("readSessionControl", () => { } }); + it("parses both unverified terminal-session shapes", () => { + for (const transcript of ["live", "snapshot"] as const) { + expect( + readSessionControl(refWith({ sessionControl: { mode: "unverified", transcript } })), + ).toEqual({ mode: "unverified", transcript }); + } + }); + it("reads extra keys on a known mode as unknown (exact shapes only)", () => { expect( readSessionControl( @@ -75,6 +83,11 @@ describe("readSessionControl", () => { refWith({ sessionControl: { mode: "reconciling", transcript: "live", extra: true } }), ), ).toEqual({ mode: "unknown" }); + expect( + readSessionControl( + refWith({ sessionControl: { mode: "unverified", transcript: "live", owner: "secret" } }), + ), + ).toEqual({ mode: "unknown" }); }); it("reads any present but unproven shape as unknown, never writable", () => { @@ -93,6 +106,8 @@ describe("readSessionControl", () => { { mode: "observer", lockStatus: "live", transcript: "durable" }, { mode: "reconciling" }, { mode: "reconciling", transcript: "partial" }, + { mode: "unverified" }, + { mode: "unverified", transcript: "partial" }, ]; for (const sessionControl of malformed) { expect(readSessionControl(refWith({ sessionControl }))).toEqual({ mode: "unknown" }); @@ -123,6 +138,8 @@ const EVERY_STATE: readonly SessionControlState[] = [ { mode: "observer", lockStatus: "malformed", transcript: "snapshot" }, { mode: "reconciling", transcript: "live" }, { mode: "reconciling", transcript: "snapshot" }, + { mode: "unverified", transcript: "live" }, + { mode: "unverified", transcript: "snapshot" }, { mode: "unknown" }, ]; @@ -148,7 +165,9 @@ describe("presentSessionControl", () => { expect(presentation.railLabel === "Active elsewhere", JSON.stringify(state)).toBe( state.mode === "observer" && state.lockStatus === "live", ); - expect(presentation.bannerDetail).not.toMatch(/TUI|terminal|CLI|lock|watermark|promot/i); + if (state.mode !== "unverified") { + expect(presentation.bannerDetail).not.toMatch(/TUI|terminal|CLI|lock|watermark|promot/i); + } } }); @@ -231,6 +250,14 @@ describe("presentSessionControl", () => { expect(presentation.composerReason.toLowerCase()).toContain("input returns"); } }); + + it("keeps unverified sessions calmly read-only and points future work to t4-omp", () => { + const presentation = presentSessionControl({ mode: "unverified", transcript: "live" }); + expect(presentation.bannerBusy).toBe(false); + expect(presentation.railLabel).toContain("Read-only"); + expect(presentation.bannerDetail).toContain("t4-omp"); + expect(presentation.bannerDetail).not.toContain("Taking over"); + }); }); describe("sessionControlDisplayKind", () => { @@ -251,6 +278,7 @@ describe("sessionControlDisplayKind", () => { sessionControlDisplayKind({ mode: "observer", lockStatus: "malformed", transcript }), ).toBe("unclear"); expect(sessionControlDisplayKind({ mode: "reconciling", transcript })).toBe("reconciling"); + expect(sessionControlDisplayKind({ mode: "unverified", transcript })).toBe("unverified"); } expect(sessionControlDisplayKind({ mode: "unknown" })).toBe("unclear"); }); @@ -258,7 +286,7 @@ describe("sessionControlDisplayKind", () => { describe("presentSessionControlKind", () => { it("says another app is active only for the observer kind", () => { - const kinds = ["observer", "suspect", "reconciling", "unclear"] as const; + const kinds = ["observer", "suspect", "reconciling", "unverified", "unclear"] as const; for (const kind of kinds) { const presentation = presentSessionControlKind(kind); expect(presentation.railLabel === "Active elsewhere", kind).toBe(kind === "observer"); diff --git a/apps/web/src/features/session-runtime/session-observer.ts b/apps/web/src/features/session-runtime/session-observer.ts index 71bc80bf..27a785cf 100644 --- a/apps/web/src/features/session-runtime/session-observer.ts +++ b/apps/web/src/features/session-runtime/session-observer.ts @@ -26,6 +26,7 @@ export type SessionControlState = readonly transcript: ObserverTranscript; } | { readonly mode: "reconciling"; readonly transcript: ObserverTranscript } + | { readonly mode: "unverified"; readonly transcript: ObserverTranscript } | { readonly mode: "unknown" }; function isRecord(value: unknown): value is Record { @@ -34,6 +35,7 @@ function isRecord(value: unknown): value is Record { const OBSERVER_KEYS: Record = { mode: true, lockStatus: true, transcript: true }; const RECONCILING_KEYS: Record = { mode: true, transcript: true }; +const UNVERIFIED_KEYS: Record = { mode: true, transcript: true }; /** * Strict reader for `liveState.sessionControl`. Only a truly absent @@ -68,6 +70,14 @@ export function readSessionControl(ref: SessionRef | undefined): SessionControlS if (transcript !== "live" && transcript !== "snapshot") return { mode: "unknown" }; return { mode: "reconciling", transcript }; } + if (raw.mode === "unverified") { + if (Object.keys(raw).some((key) => UNVERIFIED_KEYS[key] !== true)) { + return { mode: "unknown" }; + } + const { transcript } = raw; + if (transcript !== "live" && transcript !== "snapshot") return { mode: "unknown" }; + return { mode: "unverified", transcript }; + } return { mode: "unknown" }; } @@ -204,6 +214,23 @@ export function presentSessionControl(state: SessionControlState): SessionContro announcement: "Taking over this session. Input returns in a moment.", }; } + if (state.mode === "unverified") { + return { + railLabel: "Read-only · use t4-omp", + bannerTitle: "Read-only terminal session", + bannerDetail: + "This session did not publish a T4-compatible handoff signal. Keep using it in the terminal if it is still running. Start future terminal sessions with t4-omp to move them safely into T4 Code.", + bannerBusy: false, + composerReason: + "This session has no T4-compatible handoff signal. Start future terminal sessions with t4-omp.", + cancelReason: "Only the terminal process that started this session can stop it.", + controlReason: "This session is read-only because T4 cannot verify its writer.", + slashReason: "Read-only terminal session", + managementReason: + "T4 cannot safely manage this session because its terminal writer did not publish a compatible handoff signal.", + announcement: "Read-only terminal session. Use t4-omp for future terminal handoff.", + }; + } return { ...UNCLEAR_PRESENTATION, bannerDetail: UNCLEAR_DETAIL, @@ -218,10 +245,11 @@ export function presentSessionControl(state: SessionControlState): SessionContro * quiet lock reads as waiting, and a malformed lock or an unrecognized * shape stays an unclear read-only state that never names an owner. */ -export type SessionControlDisplayKind = "observer" | "suspect" | "reconciling" | "unclear"; +export type SessionControlDisplayKind = "observer" | "suspect" | "reconciling" | "unverified" | "unclear"; export function sessionControlDisplayKind(state: SessionControlState): SessionControlDisplayKind { if (state.mode === "reconciling") return "reconciling"; + if (state.mode === "unverified") return "unverified"; if (state.mode === "observer") { if (state.lockStatus === "live") return "observer"; if (state.lockStatus === "suspect") return "suspect"; @@ -236,6 +264,7 @@ const DISPLAY_KIND_STATE: Record observer: { mode: "observer", lockStatus: "live", transcript: "live" }, suspect: { mode: "observer", lockStatus: "suspect", transcript: "live" }, reconciling: { mode: "reconciling", transcript: "live" }, + unverified: { mode: "unverified", transcript: "live" }, unclear: { mode: "unknown" }, }; diff --git a/apps/web/src/features/session-runtime/session-state.ts b/apps/web/src/features/session-runtime/session-state.ts new file mode 100644 index 00000000..4adf13a9 --- /dev/null +++ b/apps/web/src/features/session-runtime/session-state.ts @@ -0,0 +1,133 @@ +import { STATUS_PILLS, type SessionStatus } from "@t4-code/ui"; + +import type { WorkspaceSession } from "../../lib/workspace-data.ts"; +import { presentSessionControlKind } from "./session-observer.ts"; + +type ActivityStatus = Exclude; + +export interface SessionStatePresentation { + /** Compact, stable label shared by the rail and active-session header. */ + readonly label: string; + /** Longer explanation for tooltips and assistive text. */ + readonly detail: string; + /** Semantic status styling when the state maps to the app-wide taxonomy. */ + readonly status: ActivityStatus | null; + /** A confirmed in-progress signal, including work owned by another app. */ + readonly busy: boolean; +} + +const STATUS_DETAIL: Record = { + working: "This session has a turn in progress.", + pendingApproval: "This session is waiting for approval before it can continue.", + awaitingInput: "This session is waiting for your answer.", + planReady: "A plan is ready for review.", + completed: "The latest turn completed.", + error: "The latest turn stopped with an error.", +}; + +function lifecyclePresentation(session: WorkspaceSession): SessionStatePresentation { + if (session.lifecycle === "idle") { + return { + label: "Idle", + detail: "The runtime is connected and has no work in progress.", + status: null, + busy: false, + }; + } + if (session.lifecycle === "closed") { + return { + label: "Stopped", + detail: "The runtime reports that this session has stopped.", + status: null, + busy: false, + }; + } + return { + label: "Status unknown", + detail: + "T4 has saved history for this session, but the runtime did not report whether it is running, idle, or stopped.", + status: null, + busy: false, + }; +} + +/** + * One session-state contract for compact surfaces. + * + * Connection is deliberately absent: the active-session header owns that + * independently, and a host reconnect must never turn every rail row into a + * pulsing "Connecting" item or reorder the Running/Priority views. + */ +export function presentSessionState(session: WorkspaceSession): SessionStatePresentation { + if (session.freshness === "offline") { + return { + label: "Offline", + detail: "The host is unreachable. This is the last state T4 received.", + status: null, + busy: false, + }; + } + if (session.freshness === "cached") { + return { + label: "Cached", + detail: "Showing the last synced copy. It refreshes when the connection returns.", + status: null, + busy: false, + }; + } + + if (session.control !== undefined) { + const control = presentSessionControlKind(session.control); + if (session.control === "reconciling") { + return { + label: "Taking over", + detail: control.composerReason, + status: null, + busy: true, + }; + } + if (session.control === "observer" && session.status === "working") { + return { + label: "Working elsewhere", + detail: control.composerReason, + status: null, + busy: true, + }; + } + if (session.control === "observer") { + return { + label: "Active elsewhere", + detail: control.composerReason, + status: null, + busy: false, + }; + } + if (session.control === "suspect") { + return { + label: "Waiting", + detail: control.composerReason, + status: null, + busy: false, + }; + } + return { + label: "Read-only", + detail: control.composerReason, + status: null, + busy: false, + }; + } + + // "connecting" is a legacy defensive case. New live projections never + // publish connection state as session activity. + if (session.status !== null && session.status !== "connecting") { + return { + label: STATUS_PILLS[session.status].label, + detail: STATUS_DETAIL[session.status], + status: session.status, + busy: session.status === "working", + }; + } + + return lifecyclePresentation(session); +} diff --git a/apps/web/src/features/session-runtime/useSessionRuntime.ts b/apps/web/src/features/session-runtime/useSessionRuntime.ts index 8393dcf2..01950c1a 100644 --- a/apps/web/src/features/session-runtime/useSessionRuntime.ts +++ b/apps/web/src/features/session-runtime/useSessionRuntime.ts @@ -5,7 +5,10 @@ import type { DesktopRuntimeController } from "@t4-code/client"; import { useEffect, useMemo, useSyncExternalStore } from "react"; -import { desktopRuntime } from "../../platform/desktop-runtime.ts"; +import { + desktopRuntime, + useDesktopRuntimeSelector, +} from "../../platform/desktop-runtime.ts"; import { resolveLiveSession } from "../../platform/live-workspace.ts"; import { createFixtureSessionRuntime, @@ -64,21 +67,24 @@ export function obtainLiveRuntime( controller: DesktopRuntimeController, sessionKey: string, cache: Map = runtimeCache, + targetIdOverride?: string, ): SessionRuntime { - // Live runtimes key on the session alone: link changes must reuse the - // same runtime (and its transcript) instead of recreating it. - const cacheKey = `live\u0000${sessionKey}`; - const cached = refreshRecency(cache, cacheKey); - if (cached !== undefined) return cached; const address = resolveLiveSession(controller.getSnapshot(), sessionKey); const separator = sessionKey.indexOf("/"); const hostId = address?.hostId ?? decodeURIComponent(separator > 0 ? sessionKey.slice(0, separator) : sessionKey); const sessionId = address?.sessionId ?? decodeURIComponent(separator > 0 ? sessionKey.slice(separator + 1) : ""); + const targetId = targetIdOverride ?? address?.targetId ?? "local"; + // Link changes on one target must reuse the same runtime and transcript. + // A restored route may mount before a named profile reconnects, however; + // once that host binds to its real target, replace the fallback runtime. + const cacheKey = `live\u0000${sessionKey}\u0000${targetId}`; + const cached = refreshRecency(cache, cacheKey); + if (cached !== undefined) return cached; const runtime = createLiveSessionRuntime({ controller, - targetId: address?.targetId ?? "local", + targetId, hostId, sessionId, }); @@ -86,9 +92,9 @@ export function obtainLiveRuntime( return runtime; } -function obtainRuntime(sessionKey: string, link: SessionLink): SessionRuntime { +function obtainRuntime(sessionKey: string, link: SessionLink, targetId?: string): SessionRuntime { const controller = desktopRuntime(); - if (controller !== null) return obtainLiveRuntime(controller, sessionKey); + if (controller !== null) return obtainLiveRuntime(controller, sessionKey, runtimeCache, targetId); const variant = typeof window !== "undefined" ? parseTranscriptVariant(window.location.search) : "default"; const cacheKey = `${sessionKey}\u0000${variant}\u0000${link}`; @@ -105,7 +111,15 @@ export interface UseSessionRuntimeResult { } export function useSessionRuntime(sessionKey: string, link: SessionLink): UseSessionRuntimeResult { - const runtime = useMemo(() => obtainRuntime(sessionKey, link), [sessionKey, link]); + const controller = desktopRuntime(); + const selectedTargetId = useDesktopRuntimeSelector( + (snapshot) => resolveLiveSession(snapshot, sessionKey)?.targetId, + ); + const targetId = controller === null ? undefined : (selectedTargetId ?? undefined); + const runtime = useMemo( + () => obtainRuntime(sessionKey, link, targetId), + [sessionKey, link, targetId], + ); useEffect(() => { runtime.resume(); diff --git a/apps/web/src/features/targets/TargetsScreen.tsx b/apps/web/src/features/targets/TargetsScreen.tsx index 08b863b0..a5e0dbd2 100644 --- a/apps/web/src/features/targets/TargetsScreen.tsx +++ b/apps/web/src/features/targets/TargetsScreen.tsx @@ -4,8 +4,8 @@ // runtime snapshot or a completed desktop call — connection words are the // runtime's words, and removing a host says exactly what it does: it // deletes the credential stored on this computer, nothing more. -import type { DesktopRuntimeController, DesktopRuntimeSnapshot } from "@t4-code/client"; -import type { LocalProfile, PhoneSetupState, ServiceInspection } from "@t4-code/protocol/desktop-ipc"; +import { redactedMessage, type DesktopRuntimeController, type DesktopRuntimeSnapshot } from "@t4-code/client"; +import type { LocalProfile, PhoneSetupState, ServiceInspection, T4OmpLauncherState } from "@t4-code/protocol/desktop-ipc"; import { Badge, Button, @@ -185,6 +185,101 @@ interface PhoneSetupApi { readonly configure: () => Promise; } +interface T4OmpLauncherApi { + readonly inspect: () => Promise; + readonly install: () => Promise; + readonly remove: () => Promise; +} + +function T4OmpLauncherCard({ api }: { readonly api: T4OmpLauncherApi }) { + const [state, setState] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const inspect = api.inspect; + const refresh = async (): Promise => { + if (busy) return; + setBusy(true); + setError(null); + try { + setState(await api.inspect()); + } catch { + setError("Could not inspect the t4-omp terminal launcher."); + } finally { + setBusy(false); + } + }; + useEffect(() => { + let active = true; + void inspect().then( + (next) => { if (active) setState(next); }, + () => { if (active) setError("Could not inspect the t4-omp terminal launcher."); }, + ); + return () => { active = false; }; + }, [inspect]); + const run = async (action: "install" | "remove"): Promise => { + if (busy) return; + setBusy(true); + setError(null); + try { + setState(await api[action]()); + } catch (cause) { + setError( + cause instanceof Error + ? redactedMessage(cause.message).slice(0, 300) + : `Could not ${action} t4-omp.`, + ); + } finally { + setBusy(false); + } + }; + const tone = state?.phase === "installed" + ? "success" + : state?.phase === "conflict" + ? "error" + : state?.phase === "update-available" + ? "working" + : "muted"; + const label = state?.phase === "installed" + ? "Installed" + : state?.phase === "update-available" + ? "Update available" + : state?.phase === "conflict" + ? "Conflict" + : state?.phase === "unsupported" + ? "Unavailable" + : "Not installed"; + return ( +
+
+

Terminal integration

+ +
+

+ {state?.message ?? "Checking whether t4-omp is available…"} +

+ {state !== null && state.phase !== "unsupported" && ( +

+ Run t4-omp in Terminal or CMUX. Your existing omp command stays unchanged. +

+ )} + {error !== null &&

{error}

} +
+ {(state?.phase === "not-installed" || state?.phase === "update-available") && ( + + )} + {state?.phase === "installed" && ( + + )} + +
+
+ ); +} + function PhoneSetupCard({ api }: { readonly api: PhoneSetupApi }) { const { configure: configurePhone, inspect } = api; const [state, setState] = useState(null); @@ -968,6 +1063,7 @@ export function TargetsScreen({ serviceAvailable, profilesAvailable, phoneSetup, + t4OmpLauncher, onBack, }: { readonly api: TargetsStoreApi; @@ -980,6 +1076,7 @@ export function TargetsScreen({ /** Whether this desktop build exposes isolated named-profile management. */ readonly profilesAvailable: boolean; readonly phoneSetup?: PhoneSetupApi; + readonly t4OmpLauncher?: T4OmpLauncherApi; readonly onBack: () => void; }) { const announcement = useTargets(api, (state) => state.announcement); @@ -1014,6 +1111,7 @@ export function TargetsScreen({ ) : serviceAvailable ? ( ) : null} + {t4OmpLauncher !== undefined && } {phoneSetup !== undefined && }

diff --git a/apps/web/src/features/terminal/TerminalViewport.tsx b/apps/web/src/features/terminal/TerminalViewport.tsx index 2864ee5e..00116259 100644 --- a/apps/web/src/features/terminal/TerminalViewport.tsx +++ b/apps/web/src/features/terminal/TerminalViewport.tsx @@ -150,14 +150,21 @@ export function TerminalViewport({ attributeFilter: ["class"], }); - const mountObserver = new ResizeObserver(() => { + let resizeFrame = 0; + const fitTerminal = () => { + resizeFrame = 0; const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; fitSafely(fitAddon); if (wasAtBottom) terminal.scrollToBottom(); + }; + const mountObserver = new ResizeObserver(() => { + if (resizeFrame !== 0) cancelAnimationFrame(resizeFrame); + resizeFrame = requestAnimationFrame(fitTerminal); }); mountObserver.observe(mount); return () => { + if (resizeFrame !== 0) cancelAnimationFrame(resizeFrame); inputDisposable.dispose(); resizeDisposable.dispose(); selectionDisposable.dispose(); diff --git a/apps/web/src/features/terminal/live-pty.ts b/apps/web/src/features/terminal/live-pty.ts index 8ba2e20d..c0d1a3a8 100644 --- a/apps/web/src/features/terminal/live-pty.ts +++ b/apps/web/src/features/terminal/live-pty.ts @@ -199,10 +199,12 @@ class LivePtySession implements PtySession { return; } const args: Record = { - cwd: request.cwd ?? ".", cols: clampCols(request.cols), rows: clampRows(request.rows), }; + // A null cwd means the project root. Omit it on the wire: "." is not a + // safeRelativePath segment and the host correctly rejects it. + if (request.cwd !== null) args.cwd = request.cwd; if (this.context.shellAdvertisedNow() && request.shell !== "") { args.shell = request.shell; } @@ -687,3 +689,64 @@ export function createLivePtySessionFactory( }, }; } + +/** + * A drawer can be restored from local storage before the desktop has rebound + * its cached session to a live target. Keep that drawer useful: resolve the + * address again on each open instead of permanently capturing an unavailable + * placeholder during the initial Offline → Connected sync. + */ +export function createResolvingLivePtySessionFactory( + controller: DesktopRuntimeController, + snapshotAccessor: () => DesktopRuntimeSnapshot, + resolveAddress: () => LiveSessionAddress | null, + options: LivePtyBridgeOptions = {}, +): LivePtyBridge { + let active: + | { + readonly key: string; + readonly bridge: LivePtyBridge; + } + | null = null; + let disposed = false; + + const current = (): LivePtyBridge | null => { + if (disposed) return null; + const address = resolveAddress(); + if (address === null) return null; + const key = `${address.targetId}\u0000${address.hostId}\u0000${address.sessionId}`; + if (active?.key === key) return active.bridge; + active?.bridge.dispose(); + const bridge = createLivePtySessionFactory( + controller, + snapshotAccessor, + address, + options, + ); + active = { key, bridge }; + return bridge; + }; + + return { + kind: "desktop", + availability: () => + current()?.availability() ?? { + available: false, + kind: "transport", + reason: MESSAGES.syncing, + }, + open(request) { + const bridge = current(); + if (bridge === null) { + throw new Error("This session is not bound to a live host yet."); + } + return bridge.open(request); + }, + dispose() { + if (disposed) return; + disposed = true; + active?.bridge.dispose(); + active = null; + }, + }; +} diff --git a/apps/web/src/features/transcript/SessionMain.tsx b/apps/web/src/features/transcript/SessionMain.tsx index 7c18db2d..92857ec3 100644 --- a/apps/web/src/features/transcript/SessionMain.tsx +++ b/apps/web/src/features/transcript/SessionMain.tsx @@ -7,6 +7,7 @@ import { Badge, cn, + STATUS_PILLS, StatusPill, Tooltip, TooltipPopup, @@ -45,13 +46,13 @@ import { advanceRecordArrival, initialControlAnnouncerState, presentSessionControl, - presentSessionControlKind, recordArrivalBaseline, reduceControlAnnouncement, type ControlAnnouncerState, type SessionControlPresentation, type SessionControlState, } from "../session-runtime/session-observer.ts"; +import { presentSessionState } from "../session-runtime/session-state.ts"; import { useSessionRuntime } from "../session-runtime/useSessionRuntime.ts"; import { computeStableRows, @@ -80,87 +81,115 @@ export interface SessionMainProps { const NO_FILE_CHILDREN: Readonly> = {}; -export function FreshnessBadge({ session }: { readonly session: WorkspaceSession }) { - if (session.freshness === "cached") { - return ( - - Cached} /> - - Showing the last synced copy. It refreshes when the connection returns. - - - ); - } - if (session.freshness === "offline") { - return ( - - Offline} /> - - This session's host is unreachable. Its record stays put until the host returns. - - - ); - } - return null; -} - -/** Plain lifecycle badge for sessions that have no richer live status pill. */ -export function SessionLifecycleBadge({ session }: { readonly session: WorkspaceSession }) { - if (session.freshness !== "live" || session.status !== null || session.control !== undefined) - return null; - - const label = - session.lifecycle === "idle" - ? "Idle" - : session.lifecycle === "closed" - ? "Stopped" - : "Status unknown"; - const detail = - label === "Status unknown" - ? "T4 has saved history for this task, but the runtime did not report whether it is running, idle, or stopped." - : label === "Stopped" - ? "The runtime reports that this task has stopped." - : "The runtime reports that this task is waiting and has no work in progress."; - +export function SessionConnectionBadge({ + state, +}: { + readonly state: + | "connected" + | "connecting" + | "disconnected" + | "pairing-required" + | "error"; +}) { + const connected = state === "connected"; + const label = connected + ? "Connected" + : state === "connecting" + ? "Reconnecting" + : state === "pairing-required" + ? "Pairing required" + : state === "error" + ? "Connection error" + : "Offline"; + const busy = state === "connecting"; return ( - {label}} /> - {detail} + + ); } -/** Subheader ownership badge; mirrors FreshnessBadge, which wins when set. */ -export function SessionOwnershipBadge({ session }: { readonly session: WorkspaceSession }) { - if (session.freshness !== "live" || session.control === undefined) return null; - if (session.control === "reconciling") { - return ( - - Taking over} /> - - Input returns once the transcript is confirmed complete. - - - ); - } - if (session.control === "observer") { - return ( - - Active elsewhere} /> - - Another app is running this session. You can read along here — run /continue-in-t4 there - to move it, or just exit it. - - - ); - } - // Waiting (quiet owner) or unclear (malformed/unrecognized control): the - // badge never claims another app is active — reuse the shared copy source. - const presentation = presentSessionControlKind(session.control); +/** One stable activity/ownership/freshness slot beside the connection badge. */ +export function SessionStateBadge({ session }: { readonly session: WorkspaceSession }) { + const presentation = presentSessionState(session); + const semantic = + presentation.status === null ? null : STATUS_PILLS[presentation.status]; return ( - {presentation.railLabel}} /> - {presentation.composerReason} + + ); } @@ -175,7 +204,27 @@ function useStableTranscriptRows(rows: ReturnType) }, [rows]); } -type SessionActivity = "compacting" | "working" | null; +export type SessionActivity = "compacting" | "working" | null; + +export function resolveSessionActivity(input: { + readonly archived: boolean; + readonly catchingUp: boolean; + readonly controlled: boolean; + readonly contextMaintenance: boolean; + readonly link: "live" | "cached" | "offline"; + readonly sessionActive: boolean; +}): SessionActivity { + if ( + input.archived || + input.link !== "live" || + input.catchingUp || + input.controlled || + !input.sessionActive + ) { + return null; + } + return input.contextMaintenance ? "compacting" : "working"; +} function activityLabel(activity: SessionActivity): string { if (activity === "compacting") return "Compacting context"; @@ -567,12 +616,21 @@ export function SessionMain({ useEffect(() => { const element = dockRef.current; if (element === null) return; - const observer = new ResizeObserver(() => { + let frame = 0; + const measure = () => { + frame = 0; const next = Math.ceil(element.getBoundingClientRect().height); setDockHeight((current) => (Math.abs(current - next) < 1 ? current : next)); + }; + const observer = new ResizeObserver(() => { + if (frame !== 0) cancelAnimationFrame(frame); + frame = requestAnimationFrame(measure); }); observer.observe(element); - return () => observer.disconnect(); + return () => { + if (frame !== 0) cancelAnimationFrame(frame); + observer.disconnect(); + }; }, []); const catchingUp = projection.phase === "resyncing" || projection.phase === "paused"; @@ -583,16 +641,14 @@ export function SessionMain({ sessionControl?.mode === "observer", projection.entries, ); - const sessionActivity: SessionActivity = - !archived && - sessionControl === null && - snapshot.link === "live" && - !catchingUp && - snapshot.sessionActive - ? projection.contextMaintenance === null - ? "working" - : "compacting" - : null; + const sessionActivity = resolveSessionActivity({ + archived, + catchingUp, + controlled: sessionControl !== null, + contextMaintenance: projection.contextMaintenance !== null, + link: snapshot.link, + sessionActive: snapshot.sessionActive, + }); const showAttention = shouldShowAttention( attention, revisingPlanId, diff --git a/apps/web/src/features/transcript/TranscriptTimeline.tsx b/apps/web/src/features/transcript/TranscriptTimeline.tsx index 52131892..342ea783 100644 --- a/apps/web/src/features/transcript/TranscriptTimeline.tsx +++ b/apps/web/src/features/transcript/TranscriptTimeline.tsx @@ -190,10 +190,10 @@ export const TranscriptTimeline = memo(function TranscriptTimeline({ // Follow pins at the TRUE max on every painted frame, not eventually: // 1. React-commit growth (streamed rows, composer/footer resize) pins in // the layout phase below, before that commit paints. - // 2. LegendList's own async remeasure pins via a ResizeObserver on the - // scroller content — RO callbacks run after layout, before paint. - // 3. One rAF fallback catches anything the list schedules for the next - // frame. Never runs scrolled away or during a disclosure, so the + // 2. LegendList's own async remeasure schedules one coalesced rAF pin from + // its ResizeObserver. Writing scrollTop inside the observer callback can + // feed back into list measurement and produce ResizeObserver loop errors. + // 3. The rAF pin never runs scrolled away or during a disclosure, so the // reading anchor is never yanked. // `rows` identity matters: streamed text grows within a row without // changing the count. @@ -208,11 +208,22 @@ export const TranscriptTimeline = memo(function TranscriptTimeline({ if (!listMounted || !following) return; const scroller = locateScroller(); if (scroller === null) return; - const observer = new ResizeObserver(pinToEnd); + let frame = 0; + const schedulePinToEnd = () => { + if (frame !== 0) cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + frame = 0; + pinToEnd(); + }); + }; + const observer = new ResizeObserver(schedulePinToEnd); observer.observe(scroller); const content = scroller.firstElementChild; if (content !== null) observer.observe(content); - return () => observer.disconnect(); + return () => { + if (frame !== 0) cancelAnimationFrame(frame); + observer.disconnect(); + }; }, [following, listMounted, locateScroller, pinToEnd]); const renderItem = useCallback( @@ -230,14 +241,6 @@ export const TranscriptTimeline = memo(function TranscriptTimeline({ [imageSource, nowMs, onCaptureMessage, toolHost], ); - const maintainScrollAtEnd = useMemo( - () => - following - ? { animated: false, on: { dataChange: true, itemLayout: true, layout: true } } - : false, - [following], - ); - const jumpToLatest = useCallback(() => { setNewOutputPending(false); void listRef.current?.scrollToEnd({ animated: !window.matchMedia("(prefers-reduced-motion: reduce)").matches }); @@ -357,7 +360,11 @@ export const TranscriptTimeline = memo(function TranscriptTimeline({ // viewport size. ListFooterComponent={
} ListHeaderComponent={listHeader} - maintainScrollAtEnd={maintainScrollAtEnd} + // T4 owns follow behavior above. Enabling LegendList's additional + // item-layout scroll owner makes both systems write during the + // same ResizeObserver delivery, producing visible scroll churn + // and browser ResizeObserver loop errors while a row streams. + maintainScrollAtEnd={false} maintainVisibleContentPosition={{ data: true, size: false }} onScroll={handleScroll} ref={listRef} diff --git a/apps/web/src/lib/session-tree.ts b/apps/web/src/lib/session-tree.ts index 6a96fd4e..850e6739 100644 --- a/apps/web/src/lib/session-tree.ts +++ b/apps/web/src/lib/session-tree.ts @@ -25,6 +25,10 @@ export interface RailViewOptions { readonly filter?: RailFilter; readonly query?: string; readonly sort?: RailSort; + /** New projects default closed in large inventories; explicit client state always wins. */ + readonly defaultExpanded?: boolean; + /** Keep the routed session discoverable when its project has no explicit disclosure state. */ + readonly activeSessionId?: string | null; readonly projectManualOrder?: readonly string[]; readonly sessionManualOrderByProjectId?: Readonly>; readonly projectAliasById?: Readonly>; @@ -55,7 +59,7 @@ function manualRank(order: readonly string[] | undefined, id: string): number { export function sessionPriority(row: SessionRow): number { if (row.session.pendingApprovals > 0 || row.session.status === "pendingApproval") return 6; if (row.session.status === "awaitingInput") return 5; - if (row.session.status === "working" || row.session.status === "connecting") return 4; + if (row.session.status === "working") return 4; if (row.unread) return 3; if (row.session.status === "error") return 2; if (row.session.status === "planReady") return 1; @@ -74,7 +78,7 @@ function matchesFilter(row: SessionRow, filter: RailFilter): boolean { return row.session.pendingApprovals > 0 || row.session.status === "awaitingInput" || row.unread; } if (filter === "running") { - return row.session.status === "working" || row.session.status === "connecting"; + return row.session.status === "working"; } if (filter === "unread") return row.unread; return row.session.status === "error"; @@ -231,7 +235,11 @@ export function buildProjectGroups( project, displayName, host, - expanded: projectExpandedById[project.id] ?? true, + expanded: + projectExpandedById[project.id] ?? + (sessions.some((row) => row.session.id === options.activeSessionId) + ? true + : (options.defaultExpanded ?? true)), sessions: sortSessionRows( sessions, sort, diff --git a/apps/web/src/lib/workspace-data.ts b/apps/web/src/lib/workspace-data.ts index d5f34f97..891456aa 100644 --- a/apps/web/src/lib/workspace-data.ts +++ b/apps/web/src/lib/workspace-data.ts @@ -60,13 +60,14 @@ export interface WorkspaceSession { /** * Ownership display state while this session is not writable here: * another app confirmed active (observer), waiting to take over after the - * other app went quiet (suspect), this app taking over (reconciling), or + * other app went quiet (suspect), this app taking over (reconciling), a + * terminal session with no compatible handoff signal (unverified), or * ownership unclear from a malformed/unrecognized control shape (unclear). * Only "observer" may say another app is active. Absent when writable, * and on cached/offline rows where freshness copy wins. Values mirror * SessionControlDisplayKind in session-observer.ts. */ - readonly control?: "observer" | "suspect" | "reconciling" | "unclear"; + readonly control?: "observer" | "suspect" | "reconciling" | "unverified" | "unclear"; /** Cluster runtime and GUI truth, present only after local opt-in and host grant. */ readonly cluster?: SessionClusterState; /** Strict CI correlation and progress from the authoritative session projection. */ diff --git a/apps/web/src/platform/browser-shell-port.ts b/apps/web/src/platform/browser-shell-port.ts index 6ee3c729..7dd36e13 100644 --- a/apps/web/src/platform/browser-shell-port.ts +++ b/apps/web/src/platform/browser-shell-port.ts @@ -339,7 +339,7 @@ export function createBrowserShellPort( }, client: { name: "T4 Code", - version: "0.1.30", + version: "0.1.31", build: mobilePlatform ?? "browser", platform: mobilePlatform ?? (platform === "darwin" ? "darwin" : "linux"), }, diff --git a/apps/web/src/platform/desktop-runtime.ts b/apps/web/src/platform/desktop-runtime.ts index feb8fa66..a90cb055 100644 --- a/apps/web/src/platform/desktop-runtime.ts +++ b/apps/web/src/platform/desktop-runtime.ts @@ -11,7 +11,7 @@ import { type DesktopShellPort, type ProjectionCacheStore, } from "@t4-code/client"; -import { useSyncExternalStore } from "react"; +import { useCallback, useRef, useSyncExternalStore } from "react"; import { bindAuthoritativeComposerCleanup } from "../features/composer/authoritative-cleanup.ts"; import { rendererPlatform } from "../state/store-instance.ts"; @@ -170,3 +170,51 @@ export function useDesktopRuntimeSnapshot(): DesktopRuntimeSnapshot | null { binding?.getSnapshot ?? getNull, ); } + +/** + * Subscribe to one derived piece of desktop state. The selected value keeps + * its previous reference while `isEqual` says the relevant truth is + * unchanged, so high-frequency transcript frames do not re-render unrelated + * shell surfaces such as the 1,000-session rail. + */ +export function useDesktopRuntimeSelector( + selector: (snapshot: DesktopRuntimeSnapshot) => T, + isEqual: (left: T, right: T) => boolean = Object.is, +): T | null { + const controller = desktopRuntime(); + const selectorRef = useRef(selector); + const equalityRef = useRef(isEqual); + const cacheRef = useRef< + | { + readonly controller: DesktopRuntimeController; + readonly value: T; + } + | undefined + >(undefined); + selectorRef.current = selector; + equalityRef.current = isEqual; + + const getSelected = useCallback((): T | null => { + if (controller === null) return null; + const next = selectorRef.current(controller.getSnapshot()); + const cached = cacheRef.current; + if (cached?.controller === controller && equalityRef.current(cached.value, next)) { + return cached.value; + } + cacheRef.current = { controller, value: next }; + return next; + }, [controller]); + + let binding: StoreBinding | undefined; + if (controller !== null) { + binding = bindings.get(controller); + if (binding === undefined) { + binding = { + subscribe: (onChange) => controller.subscribe(onChange), + getSnapshot: () => controller.getSnapshot(), + }; + bindings.set(controller, binding); + } + } + return useSyncExternalStore(binding?.subscribe ?? subscribeNoop, getSelected, getNull); +} diff --git a/apps/web/src/platform/live-workspace.ts b/apps/web/src/platform/live-workspace.ts index 0844df46..144c50c3 100644 --- a/apps/web/src/platform/live-workspace.ts +++ b/apps/web/src/platform/live-workspace.ts @@ -25,7 +25,7 @@ import { readSessionControl, sessionControlDisplayKind, } from "../features/session-runtime/session-observer.ts"; -import { hostSessionInventoryIsComplete } from "../features/session-runtime/session-inventory.ts"; +import { sessionRefIsCurrent } from "../features/session-runtime/session-inventory.ts"; /** Composite route id for one live session; unambiguous and URL-safe. */ export function sessionViewId(hostId: string, sessionId: string): string { @@ -188,6 +188,43 @@ function hostConnection( ? { targetId: null, state: null } : { targetId, state: snapshot.connections.get(targetId) ?? null }; } + +/** + * Confirmation challenges are volatile command handshakes, not durable + * session attention. Keep them visible only while they still target the + * current session revision and have no response. A cancel challenge can + * otherwise outlive the turn it tried to stop and leave the rail/header + * stuck on "Pending approval" after the transcript is already idle. + */ +function currentConfirmationCount( + warm: SessionProjection | undefined, + ref: Parameters[0], +): number { + if ( + warm === undefined || + ref === undefined || + String(warm.revision) !== String(ref.revision) + ) { + return 0; + } + let count = 0; + for (const challenge of warm.confirmations.values()) { + if (String(challenge.revision) !== String(ref.revision)) continue; + let resolved = false; + for (const result of warm.results.values()) { + if ( + result.commandId !== undefined && + String(result.commandId) === String(challenge.commandId) + ) { + resolved = true; + break; + } + } + if (!resolved) count += 1; + } + return count; +} + function clusterHostTarget( snapshot: DesktopRuntimeSnapshot, hostId: string, @@ -320,14 +357,19 @@ export function deriveWorkspaceData(snapshot: DesktopRuntimeSnapshot): Workspace } const connection = hostConnection(snapshot, hostId); const warm = warmSessionProjection(snapshot, hostId, sessionId); - const offline = connection.state !== "connected"; - const inventoryReady = hostSessionInventoryIsComplete(snapshot, hostId); - const freshness = offline - ? "offline" - : !inventoryReady || (warm !== undefined && warm.freshness !== "fresh") + const inventoryReady = sessionRefIsCurrent(snapshot, hostId, sessionId); + const freshness = + connection.state === "connecting" + ? "cached" + : connection.state !== "connected" + ? "offline" + : !inventoryReady || (warm !== undefined && warm.freshness !== "fresh") ? "cached" : "live"; - const pendingApprovals = warm?.confirmations.size ?? (ref.pendingApproval === true ? 1 : 0); + const pendingApprovals = Math.max( + currentConfirmationCount(warm, ref), + ref.pendingApproval === true ? 1 : 0, + ); const rawArchivedAt = (ref as unknown as { readonly archivedAt?: unknown }).archivedAt; const archivedAt = typeof rawArchivedAt === "string" && Number.isFinite(Date.parse(rawArchivedAt)) @@ -350,12 +392,22 @@ export function deriveWorkspaceData(snapshot: DesktopRuntimeSnapshot): Workspace else if (ref.status === "idle") lifecycle = "idle"; else if (ref.status === "closed") lifecycle = "closed"; let status: SessionStatus | null = null; - if (connection.state === "connecting") status = "connecting"; - else if (freshness === "live" && controlKind === undefined) { - if (pendingApprovals > 0) status = "pendingApproval"; - else if (ref.pendingUserInput === true) status = "awaitingInput"; - else if (ref.proposedPlan !== undefined && ref.proposedPlan !== "") status = "planReady"; - else if (displayWorking) status = "working"; + if (freshness === "live") { + if (controlKind === undefined) { + if (pendingApprovals > 0) status = "pendingApproval"; + else if (ref.pendingUserInput === true) status = "awaitingInput"; + else if (ref.proposedPlan !== undefined && ref.proposedPlan !== "") status = "planReady"; + } + // Ownership answers who may write; activity answers whether a turn is + // progressing. Keep both when a live owner or takeover has confirmed + // work instead of replacing the only running signal with ownership copy. + if ( + status === null && + displayWorking && + (controlKind === undefined || controlKind === "observer" || controlKind === "reconciling") + ) { + status = "working"; + } } sessions.push({ id: sessionViewId(hostId, sessionId), diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index b1f0ac59..ff6760ca 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -479,11 +479,18 @@ function HostsRoute() { onBack={() => void navigate({ to: "/settings" })} profilesAvailable={localProfiles !== undefined} {...(shell?.inspectPhoneSetup && shell.configurePhoneSetup ? { - phoneSetup: { + phoneSetup: { inspect: shell.inspectPhoneSetup, configure: shell.configurePhoneSetup, }, } : {})} + {...(shell?.inspectT4OmpLauncher && shell.installT4OmpLauncher && shell.removeT4OmpLauncher ? { + t4OmpLauncher: { + inspect: shell.inspectT4OmpLauncher, + install: shell.installT4OmpLauncher, + remove: shell.removeT4OmpLauncher, + }, + } : {})} serviceAvailable={shell?.serviceInspect !== undefined} snapshot={snapshot} /> diff --git a/apps/web/src/state/shell-data.ts b/apps/web/src/state/shell-data.ts index ebc81b5b..f649c3bd 100644 --- a/apps/web/src/state/shell-data.ts +++ b/apps/web/src/state/shell-data.ts @@ -3,16 +3,71 @@ // sample workspace. Nothing above this file knows which provider fed it, // and the desktop path never reads fixture data. import type { WorkspaceData } from "../lib/workspace-data.ts"; -import { desktopRuntime, useDesktopRuntimeSnapshot } from "../platform/desktop-runtime.ts"; +import { + desktopRuntime, + useDesktopRuntimeSelector, +} from "../platform/desktop-runtime.ts"; import { deriveWorkspaceData } from "../platform/live-workspace.ts"; import { SHELL_FIXTURE } from "../fixture/data.ts"; +type WorkspaceSnapshot = Parameters[0]; + +function warmWorkspaceStateEqual( + left: WorkspaceSnapshot["projection"]["sessions"], + right: WorkspaceSnapshot["projection"]["sessions"], +): boolean { + if (left === right) return true; + if (left.size !== right.size) return false; + for (const [key, next] of right) { + const previous = left.get(key); + if ( + previous === undefined || + previous.freshness !== next.freshness || + previous.confirmations.size !== next.confirmations.size + ) { + return false; + } + } + return true; +} + +/** + * Workspace display truth changes far less often than transcript truth. + * Ignore entry/event churn while retaining every input that can affect rail + * grouping, freshness, ownership, capability, or pending-approval display. + */ +export function workspaceSnapshotEqual( + left: WorkspaceSnapshot, + right: WorkspaceSnapshot, +): boolean { + return ( + left.integration === right.integration && + left.targets === right.targets && + left.connections === right.connections && + left.targetHosts === right.targetHosts && + left.hosts === right.hosts && + left.catalogs === right.catalogs && + left.clusterOperatorEnabled === right.clusterOperatorEnabled && + left.projection.sessionIndex === right.projection.sessionIndex && + left.projection.sessionIndexMetadata === right.projection.sessionIndexMetadata && + left.projection.sessionRefArrivalOrdinals === + right.projection.sessionRefArrivalOrdinals && + left.projection.workspaces === right.projection.workspaces && + warmWorkspaceStateEqual(left.projection.sessions, right.projection.sessions) + ); +} + /** Reactive workspace data for components. */ export function useShellData(): WorkspaceData { - const snapshot = useDesktopRuntimeSnapshot(); + const snapshot = useWorkspaceRuntimeSnapshot(); return snapshot === null ? SHELL_FIXTURE : deriveWorkspaceData(snapshot); } +/** Reactive snapshot containing only changes relevant to workspace chrome. */ +export function useWorkspaceRuntimeSnapshot(): WorkspaceSnapshot | null { + return useDesktopRuntimeSelector((value) => value, workspaceSnapshotEqual); +} + /** Point-in-time workspace data for event handlers outside render. */ export function getShellData(): WorkspaceData { const controller = desktopRuntime(); diff --git a/apps/web/test/cluster-operator.test.tsx b/apps/web/test/cluster-operator.test.tsx index d03f765f..52118045 100644 --- a/apps/web/test/cluster-operator.test.tsx +++ b/apps/web/test/cluster-operator.test.tsx @@ -74,7 +74,7 @@ function snapshot(options: { targetId: TARGET, hostId: HOST, ompVersion: "17.0.5", - ompBuild: "8476f445", + ompBuild: "fc0c3913", appserverVersion: "1", appserverBuild: "test", epoch: "host-epoch", diff --git a/apps/web/test/interaction-motion.test.ts b/apps/web/test/interaction-motion.test.ts index 8a7ace6c..33c7d4d7 100644 --- a/apps/web/test/interaction-motion.test.ts +++ b/apps/web/test/interaction-motion.test.ts @@ -91,15 +91,16 @@ describe("deliberate instant states stay deliberate", () => { }); }); -describe("transcript follow pins before paint", () => { +describe("transcript follow coalesces layout writes", () => { const timeline = readFileSync( join(SRC, "features/transcript/TranscriptTimeline.tsx"), "utf8", ); - it("pins in the layout phase and on layout-phase resizes, not post-paint timers", () => { + it("pins the React commit in layout and coalesces resize feedback onto one frame", () => { expect(timeline).toContain("useLayoutEffect"); - expect(timeline).toContain("new ResizeObserver(pinToEnd)"); + expect(timeline).toContain("new ResizeObserver(schedulePinToEnd)"); + expect(timeline).toContain("requestAnimationFrame(() =>"); // No timeout-driven scrolling anywhere in the follow path. expect(timeline).not.toMatch(/setTimeout\([^)]*scroll/i); }); diff --git a/apps/web/test/live-composer.test.ts b/apps/web/test/live-composer.test.ts index a66a9903..a4024b76 100644 --- a/apps/web/test/live-composer.test.ts +++ b/apps/web/test/live-composer.test.ts @@ -2202,9 +2202,8 @@ describe("session lifecycle", () => { canCancel: true, }); - // The same-epoch welcome deliberately invalidates inventory completeness. - // Retained refs remain useful history, but cannot prove current activity or - // enable commands until the full sessions frame is back. + // The same-epoch welcome invalidates every retained ref. A ref becomes + // writable again only if the current connection actually returns it. shell.emitFrame({ targetId: "local", frame: makeWelcome(HOST, ["sessions.prompt"]), @@ -2220,13 +2219,18 @@ describe("session lifecycle", () => { status: null, }); + const currentBoundedInventory = pendingPromptsSessionsFrame( + [{ entryId: "prompt:before-sleep", text: "keep going" }], + 2, + "active", + ); shell.emitFrame({ targetId: "local", - frame: pendingPromptsSessionsFrame( - [{ entryId: "prompt:before-sleep", text: "keep going" }], - 2, - "active", - ), + frame: { + ...currentBoundedInventory, + totalCount: 7_683, + truncated: true, + }, }); expect(runtime.getSnapshot()).toMatchObject({ link: "live", @@ -2392,7 +2396,7 @@ describe("session lifecycle", () => { ); }); - it("does not settle a turn from a truncated inventory after reconnect", async () => { + it("settles a returned session from a truncated inventory after reconnect", async () => { const { shell, runtime } = await startedRuntime(); shell.emitFrame({ targetId: "local", frame: snapshotFrame(1, []) }); shell.emitFrame({ targetId: "local", frame: turnStart(2) }); @@ -2432,7 +2436,9 @@ describe("session lifecycle", () => { truncated: true, }, }); - expect(runtime.getSnapshot().projection.turnActive).toBe(true); + // Truncation means absence is not authoritative. This session was present + // in the current response, so its idle state is authoritative. + expect(runtime.getSnapshot().projection.turnActive).toBe(false); shell.emitFrame({ targetId: "local", @@ -2460,6 +2466,33 @@ describe("session lifecycle", () => { expect(shell.commandCount("session.attach")).toBe(1); }); + it("replaces a startup fallback runtime when a named target binds the restored host", async () => { + const shell = new FakeShell(); + const controller = createDesktopRuntimeController({ shell }); + await controller.start(); + const cache = new Map(); + const viewId = sessionViewId(HOST, SESSION); + + const fallback = obtainLiveRuntime(controller, viewId, cache); + expect(shell.commandCount("session.attach")).toBe(0); + + shell.emitState({ targetId: "local:candidate", state: "connected" }); + shell.emitFrame({ + targetId: "local:candidate", + frame: makeWelcome(HOST, ["sessions.read", "sessions.prompt"]), + }); + const rebound = obtainLiveRuntime(controller, viewId, cache); + + expect(rebound).not.toBe(fallback); + await Promise.resolve(); + expect(shell.commands.find((request) => request.intent.command === "session.attach")?.targetId) + .toBe("local:candidate"); + + fallback.dispose(); + rebound.dispose(); + await controller.stop(); + }); + it("paints a bounded cold tail before starting the full live attach", async () => { const shell = new FakeShell(); shell.commandResult = (request) => @@ -2805,6 +2838,25 @@ describe("session lifecycle", () => { expect(shell.commandCount("session.attach")).toBe(2); }); + it("reattaches after a new host welcome even when the disconnected notification was missed", async () => { + const { shell, controller } = await startedController(); + const cache = new Map(); + obtainLiveRuntime(controller, sessionViewId(HOST, SESSION), cache); + await settle(); + expect(shell.commandCount("session.attach")).toBe(1); + + shell.emitFrame({ + targetId: "local", + frame: { + ...makeWelcome(HOST, ["sessions.prompt"]), + epoch: "epoch-2", + }, + }); + await settle(); + + expect(shell.commandCount("session.attach")).toBe(2); + }); + it("retries an attach rejected by the host on a later controller notification", async () => { const { shell, controller } = await startedController(); const cache = new Map(); @@ -2861,6 +2913,39 @@ describe("session lifecycle", () => { }); describe("workspace projection safety", () => { + it("retires a stale command confirmation when a newer idle ref settles the session", async () => { + const { shell, controller } = await startedRuntime(); + shell.emitFrame({ + targetId: "local", + frame: { + v: V, + type: "confirmation", + confirmationId: confirmationId("cancel-confirmation"), + commandId: commandId("cancel-command"), + hostId: hostId(HOST), + sessionId: sessionId(SESSION), + commandHash: "sha256:cancel", + revision: revision("rev-1"), + expiresAt: "2999-01-01T00:00:00Z", + summary: "session.cancel", + }, + }); + expect(deriveWorkspaceData(controller.getSnapshot()).sessions[0]).toMatchObject({ + pendingApprovals: 1, + status: "pendingApproval", + }); + + shell.emitFrame({ + targetId: "local", + frame: pendingPromptsSessionsFrame([], 2, "idle"), + }); + expect(deriveWorkspaceData(controller.getSnapshot()).sessions[0]).toMatchObject({ + lifecycle: "idle", + pendingApprovals: 0, + status: null, + }); + }); + it("gives cached and offline freshness precedence over stale working refs", async () => { const { shell, controller } = await startedRuntime(); shell.emitFrame({ diff --git a/apps/web/test/live-create.test.ts b/apps/web/test/live-create.test.ts index ce409612..679b1959 100644 --- a/apps/web/test/live-create.test.ts +++ b/apps/web/test/live-create.test.ts @@ -3,6 +3,7 @@ import { createLiveSession, type LiveCreateController, } from "../src/features/session-runtime/live-create.ts"; +import { hostId, revision, type CatalogFrame } from "@t4-code/protocol"; import type { RendererServerEventEnvelope } from "@t4-code/protocol/desktop-ipc"; import { requiresProfileChoiceForCreate, @@ -11,11 +12,36 @@ import { } from "../src/platform/live-workspace.ts"; const address = { targetId: "target-1", hostId: "host-1", projectId: "project-1" } as const; +function catalog(): CatalogFrame { + return { + v: "omp-app/1", + type: "catalog", + hostId: hostId(address.hostId), + revision: revision("catalog-1"), + items: [ + { + id: "command-session-create" as never, + kind: "command", + name: "session.create", + supported: true, + capabilities: ["sessions.manage"], + }, + { + id: "model-default" as never, + kind: "model", + name: "Default model", + supported: true, + }, + ], + }; +} function controller() { const events: Array<(event: RendererServerEventEnvelope) => void> = []; const snapshot: { connections: Map; targetHosts: Map; + hosts: Map; + catalogs: Map; projection: { sessionIndex: Map< string, @@ -26,6 +52,8 @@ function controller() { } = { connections: new Map([[address.targetId, "connected"]]), targetHosts: new Map([[address.targetId, address.hostId]]), + hosts: new Map([[address.hostId, { grantedCapabilities: ["sessions.manage"] }]]), + catalogs: new Map([[address.hostId, catalog()]]), projection: { sessionIndex: new Map(), sessionIndexMetadata: new Map([[address.hostId, { truncated: false, totalCount: 0 }]]), @@ -35,7 +63,7 @@ function controller() { let unsubscribed = 0; let next = 1; const fake: LiveCreateController = { - getSnapshot: () => snapshot, + getSnapshot: () => snapshot as never, subscribeEvents: (_filter, listener) => { events.push(listener); return () => { @@ -234,8 +262,12 @@ describe("resolveLiveProjectCreateTargets", () => { }); describe("createLiveSession", () => { - it("correlates synchronous response, bounds unrelated frames, then waits for list projection", async () => { + it("creates from a partial inventory, correlates responses, then waits for list projection", async () => { const c = controller(); + c.snapshot.projection.sessionIndexMetadata.set(address.hostId, { + truncated: true, + totalCount: 1001, + }); const result = createLiveSession(c.fake, address, undefined, 1000); for (let i = 0; i < 80; i++) c.emit({ requestId: `noise-${i}`, command: "noise", ok: true }); c.emit(sessionFrame("req-1")); @@ -279,6 +311,21 @@ describe("createLiveSession", () => { await expect(createLiveSession(c.fake, address)).rejects.toThrow("binding"); expect(c.unsubscribed).toBe(0); }, + async () => { + const c = controller(); + const unavailableCatalog = catalog(); + c.snapshot.catalogs.set(address.hostId, { + ...unavailableCatalog, + items: unavailableCatalog.items.map((item) => + item.kind === "model" ? { ...item, supported: false } : item, + ), + }); + await expect(createLiveSession(c.fake, address)).rejects.toThrow( + "Configure a model for this OMP profile", + ); + expect(c.commands).toHaveLength(0); + expect(c.unsubscribed).toBe(0); + }, ]; for (const run of cases) await run(); }); diff --git a/apps/web/test/live-session-observer.test.ts b/apps/web/test/live-session-observer.test.ts index 8a2df884..2af47005 100644 --- a/apps/web/test/live-session-observer.test.ts +++ b/apps/web/test/live-session-observer.test.ts @@ -201,9 +201,9 @@ describe("live runtime observer gating", () => { expect(shell.commandCount("session.thinking.set")).toBe(0); expect(shell.commandCount("session.fast.set")).toBe(0); - // The rail row reads Active elsewhere instead of a status pill. + // Ownership remains separate from confirmed turn activity. const row = deriveWorkspaceData(controller.getSnapshot()).sessions[0]; - expect(row).toMatchObject({ control: "observer", status: null }); + expect(row).toMatchObject({ control: "observer", status: "working" }); runtime.dispose(); await controller.stop(); @@ -236,7 +236,7 @@ describe("live runtime observer gating", () => { expect(snapshot.canPrompt).toBe(false); expect(deriveWorkspaceData(controller.getSnapshot()).sessions[0]).toMatchObject({ control: "reconciling", - status: null, + status: "working", }); runtime.dispose(); await controller.stop(); diff --git a/apps/web/test/live-workspace-runtime.test.ts b/apps/web/test/live-workspace-runtime.test.ts index 8efdb339..bd2cb5ce 100644 --- a/apps/web/test/live-workspace-runtime.test.ts +++ b/apps/web/test/live-workspace-runtime.test.ts @@ -2,10 +2,45 @@ import { OMP_RUNTIME_INTEGRATION, createProjectionSnapshot, type DesktopRuntimeSnapshot, + type SessionProjection, } from "@t4-code/client"; import { describe, expect, it } from "vite-plus/test"; import { deriveWorkspaceData } from "../src/platform/live-workspace.ts"; +import { workspaceSnapshotEqual } from "../src/state/shell-data.ts"; + +function warmProjection( + freshness: SessionProjection["freshness"], + confirmationCount = 0, +): SessionProjection { + return { + hostId: "host-local", + sessionId: "session-1", + entries: [], + events: [], + agents: new Map(), + agentTranscripts: new Map(), + terminals: new Map(), + files: new Map(), + reviews: new Map(), + audit: [], + confirmations: new Map( + Array.from({ length: confirmationCount }, (_, index) => [ + `confirmation-${index}`, + {} as SessionProjection["confirmations"] extends ReadonlyMap + ? Value + : never, + ]), + ), + results: new Map(), + previews: new Map(), + previewEvents: [], + freshness, + transcriptEventArrivalOrdinal: 0, + contextMaintenanceEventArrivalOrdinal: 0, + entryIds: new Set(), + }; +} describe("live workspace runtime identity", () => { it("carries the owning OMP integration into the UI host model", () => { @@ -58,4 +93,44 @@ describe("live workspace runtime identity", () => { expect.objectContaining({ id: "host-local", runtimeKind: "omp" }), ]); }); + + it("ignores transcript-only warm projection churn for shell rendering", () => { + const projection = createProjectionSnapshot(); + const base: DesktopRuntimeSnapshot = { + version: 1, + integration: OMP_RUNTIME_INTEGRATION, + platform: "darwin", + desktopVersion: "test", + startState: "started", + targets: new Map(), + connections: new Map(), + targetHosts: new Map(), + hosts: new Map(), + catalogs: new Map(), + settings: new Map(), + projection: { + ...projection, + sessions: new Map([["host-local\u0000session-1", warmProjection("fresh")]]), + }, + runtimeErrors: [], + }; + const transcriptOnly: DesktopRuntimeSnapshot = { + ...base, + projection: { + ...base.projection, + sessions: new Map([["host-local\u0000session-1", warmProjection("fresh")]]), + arrivalOrdinal: 1, + }, + }; + const confirmationChanged: DesktopRuntimeSnapshot = { + ...transcriptOnly, + projection: { + ...transcriptOnly.projection, + sessions: new Map([["host-local\u0000session-1", warmProjection("fresh", 1)]]), + }, + }; + + expect(workspaceSnapshotEqual(base, transcriptOnly)).toBe(true); + expect(workspaceSnapshotEqual(transcriptOnly, confirmationChanged)).toBe(false); + }); }); diff --git a/apps/web/test/panes-files.test.ts b/apps/web/test/panes-files.test.ts index f17a04af..97ce4fd2 100644 --- a/apps/web/test/panes-files.test.ts +++ b/apps/web/test/panes-files.test.ts @@ -54,10 +54,13 @@ describe("lazy file tree", () => { }); it("a failed listing degrades to an error marker, not a crash", () => { - const { api } = storeWithDirs(); + const { api, dirCalls } = storeWithDirs(); api.getState().setFileExpanded("src", true); resolveDir(api, "src", "error"); expect(api.getState().files.childrenByPath.src).toBe("error"); + api.getState().requestDir("src"); + expect(api.getState().files.childrenByPath.src).toBe("loading"); + expect(dirCalls).toEqual(["src", "src"]); }); it("does not send parent-directory requests to the controller", () => { @@ -148,21 +151,29 @@ describe("file drafts", () => { it("keeps a dirty draft and marks a later host version as a conflict", () => { const { api } = storeWithDirs(); api.getState().selectFile("src/app.ts"); - resolvePreview(api, { - kind: "code", - path: "src/app.ts", - text: "before\n", - truncated: false, - }); + resolvePreview( + api, + { + kind: "code", + path: "src/app.ts", + text: "before\n", + truncated: false, + }, + "sha-file-1", + ); api.getState().startFileEdit("src/app.ts"); api.getState().updateFileDraft("src/app.ts", "mine\n"); - resolvePreview(api, { - kind: "code", - path: "src/app.ts", - text: "theirs\n", - truncated: false, - }); + resolvePreview( + api, + { + kind: "code", + path: "src/app.ts", + text: "theirs\n", + truncated: false, + }, + "sha-file-2", + ); expect(api.getState().files.draftsByPath["src/app.ts"]).toMatchObject({ originalText: "before\n", @@ -175,12 +186,16 @@ describe("file drafts", () => { it("blocks a dirty draft when a reload cannot confirm editable text", () => { const { api } = storeWithDirs(); api.getState().selectFile("src/app.ts"); - resolvePreview(api, { - kind: "code", - path: "src/app.ts", - text: "before\n", - truncated: false, - }); + resolvePreview( + api, + { + kind: "code", + path: "src/app.ts", + text: "before\n", + truncated: false, + }, + "sha-file-1", + ); api.getState().startFileEdit("src/app.ts"); api.getState().updateFileDraft("src/app.ts", "mine\n"); diff --git a/apps/web/test/panes-live.test.ts b/apps/web/test/panes-live.test.ts index e6e431cc..6f203243 100644 --- a/apps/web/test/panes-live.test.ts +++ b/apps/web/test/panes-live.test.ts @@ -263,8 +263,9 @@ function hostMetadata(capabilities: readonly string[], features: readonly string } /** - * Dispatch-time write freshness requires a complete host inventory that - * contains this session's indexed ref; graft both onto fake projections. + * Dispatch-time write freshness requires this process to have received the + * session's indexed ref; graft the ref, its arrival marker, and inventory + * metadata onto fake projections. */ function withInventory(projection: ProjectionSnapshot): ProjectionSnapshot { const key = `${HOST}\u0000${SESSION}`; @@ -282,7 +283,9 @@ function withInventory(projection: ProjectionSnapshot): ProjectionSnapshot { } const sessionIndexMetadata = new Map(projection.sessionIndexMetadata); sessionIndexMetadata.set(HOST, { truncated: false, totalCount: sessionIndex.size } as never); - return { ...projection, sessionIndex, sessionIndexMetadata }; + const sessionRefArrivalOrdinals = new Map(projection.sessionRefArrivalOrdinals); + sessionRefArrivalOrdinals.set(key, 1); + return { ...projection, sessionIndex, sessionIndexMetadata, sessionRefArrivalOrdinals }; } interface VoidDeferred { @@ -630,8 +633,8 @@ describe("live projection populates each family", () => { }); it("file frames build the tree and answer previews", async () => { - // No files.list capability: the pushed frames are the whole tree. - const fake = new FakeRuntime({ capabilities: ["sessions.read", "files.read"], features: [] }); + // No files.list/read capability: pushed frames are the whole read-only tree. + const fake = new FakeRuntime({ capabilities: ["sessions.read"], features: [] }); fake.setProjection( project([ fileFrame("src/main.ts", "export {};\n"), @@ -653,6 +656,9 @@ describe("live projection populates each family", () => { text: "# Hello\n", truncated: false, }); + expect(store.getState().files.previewRevision).toBeNull(); + store.getState().startFileEdit("README.md"); + expect(store.getState().files.draftsByPath["README.md"]).toBeUndefined(); }); it("files.list answers resolve directories and drop unsafe entries", async () => { @@ -689,6 +695,27 @@ describe("live projection populates each family", () => { { path: "notes.md", name: "notes.md", kind: "file" }, ]); }); + + it("retries a failed directory when files.list becomes available after reconnect", async () => { + const fake = new FakeRuntime({ connected: false }); + fake.setProjection(project([snapshotFrame("rev-1")])); + const store = createLiveInspectorStore(fake, VIEW_ID); + store.getState().setFileExpanded("", true); + expect(store.getState().files.childrenByPath[""]).toBe("error"); + expect(fake.commands).toEqual([]); + + fake.setConnection("connected"); + expect(store.getState().files.childrenByPath[""]).toBe("loading"); + await fake.settle(); + expect(fake.commands).toEqual([ + { + hostId: HOST, + sessionId: SESSION, + command: "files.list", + args: {}, + }, + ]); + }); }); // --------------------------------------------------------------------------- @@ -1000,81 +1027,115 @@ describe("live pane actions", () => { expect(store.getState().review.files[0]?.applyState).toBe("applied"); }); - it("file save sends revision-gated text and settles from its final command result", async () => { + it("file save uses the file revision returned by files.read and advances from its result", async () => { const fake = new FakeRuntime({ catalog: AGENT_CATALOG }); - const base = project([ - snapshotFrame("rev-3"), - fileFrame("src/app.ts", "const value = 1;\n"), - ]); + const base = project([snapshotFrame("rev-3")]); fake.setProjection(base); const store = createLiveInspectorStore(fake, VIEW_ID); expect(store.getState().actions.fileWrite.enabled).toBe(true); store.getState().selectFile("src/app.ts"); - store.getState().startFileEdit("src/app.ts"); - store.getState().updateFileDraft("src/app.ts", "const value = 2;\n"); - store.getState().saveFile("src/app.ts"); await fake.settle(); - - expect(fake.commands).toEqual([ - { - hostId: HOST, - sessionId: SESSION, - command: "files.write", - args: { path: "src/app.ts", content: "const value = 2;\n" }, - expectedRevision: "rev-3", - }, - ]); - expect(store.getState().files.draftsByPath["src/app.ts"]).toBeUndefined(); - expect(store.getState().files.preview).toBe("loading"); - fake.setProjection( project( - [snapshotFrame("rev-4", 1), fileFrame("src/app.ts", "const value = 2;\n")], + [ + responseFrame("req-1", true, { + content: "const value = 1;\n", + revision: "sha-file-1", + }), + ], base, ), ); + expect(store.getState().files.previewRevision).toBe("sha-file-1"); + + store.getState().startFileEdit("src/app.ts"); + store.getState().updateFileDraft("src/app.ts", "const value = 2;\n"); + fake.commandResult = { + accepted: true, + result: { path: "src/app.ts", revision: "sha-file-2", size: 17 }, + }; + store.getState().saveFile("src/app.ts"); + await fake.settle(); + + expect(fake.commands[1]).toEqual({ + hostId: HOST, + sessionId: SESSION, + command: "files.write", + args: { path: "src/app.ts", content: "const value = 2;\n" }, + expectedRevision: "sha-file-1", + }); + expect(store.getState().files.draftsByPath["src/app.ts"]).toBeUndefined(); expect(store.getState().files.preview).toMatchObject({ text: "const value = 2;\n" }); + expect(store.getState().files.previewRevision).toBe("sha-file-2"); store.getState().startFileEdit("src/app.ts"); store.getState().updateFileDraft("src/app.ts", "const value = 3;\n"); + fake.commandResult = { + accepted: true, + result: { path: "src/app.ts", revision: "sha-file-3", size: 17 }, + }; store.getState().saveFile("src/app.ts"); await fake.settle(); - expect(fake.commands[1]?.expectedRevision).toBe("rev-4"); + expect(fake.commands[2]?.expectedRevision).toBe("sha-file-2"); }); it("pins a file save to the revision that produced its draft", async () => { const fake = new FakeRuntime({ catalog: AGENT_CATALOG }); - const base = project([ - snapshotFrame("rev-3"), - fileFrame("src/app.ts", "const value = 1;\n"), - ]); + const base = project([snapshotFrame("rev-3")]); fake.setProjection(base); const store = createLiveInspectorStore(fake, VIEW_ID); store.getState().selectFile("src/app.ts"); + await fake.settle(); + fake.setProjection( + project( + [ + responseFrame("req-1", true, { + path: "src/app.ts", + content: "const value = 1;\n", + revision: "sha-file-1", + }), + ], + base, + ), + ); store.getState().startFileEdit("src/app.ts"); store.getState().updateFileDraft("src/app.ts", "const value = 2;\n"); fake.setProjection(project([snapshotFrame("rev-4")], base)); + fake.commandResult = { + accepted: true, + result: { path: "src/app.ts", revision: "sha-file-2", size: 17 }, + }; store.getState().saveFile("src/app.ts"); await fake.settle(); - expect(fake.commands[0]?.expectedRevision).toBe("rev-3"); + expect(fake.commands[1]?.expectedRevision).toBe("sha-file-1"); }); it("a stale file save preserves the draft as a conflict", async () => { const fake = new FakeRuntime({ catalog: AGENT_CATALOG }); - const base = project([ - snapshotFrame("rev-3"), - fileFrame("src/app.ts", "before\n"), - ]); + const base = project([snapshotFrame("rev-3")]); fake.setProjection(base); const store = createLiveInspectorStore(fake, VIEW_ID); + store.getState().selectFile("src/app.ts"); + await fake.settle(); + fake.setProjection( + project( + [ + responseFrame("req-1", true, { + path: "src/app.ts", + content: "before\n", + revision: "sha-file-1", + }), + ], + base, + ), + ); fake.commandResult = { accepted: false, - error: { code: "stale_revision", message: "revision changed" }, + error: { code: "STALE_REVISION", message: "revision changed" }, }; - store.getState().selectFile("src/app.ts"); store.getState().startFileEdit("src/app.ts"); store.getState().updateFileDraft("src/app.ts", "mine\n"); store.getState().saveFile("src/app.ts"); @@ -1084,7 +1145,8 @@ describe("live pane actions", () => { text: "mine\n", status: "conflict", }); - expect(fake.commands).toHaveLength(1); + expect(fake.commands).toHaveLength(2); + expect(fake.commands[1]?.expectedRevision).toBe("sha-file-1"); }); it("a denied review apply keeps the row pending", async () => { diff --git a/apps/web/test/session-management.test.ts b/apps/web/test/session-management.test.ts index f65876ac..67fa74eb 100644 --- a/apps/web/test/session-management.test.ts +++ b/apps/web/test/session-management.test.ts @@ -73,6 +73,7 @@ function catalog(): CatalogFrame { hostId: hostId(ADDRESS.hostId), revision: revision("catalog-1"), items: [ + ...[ "session.create", "project.reveal", "session.rename", @@ -87,6 +88,13 @@ function catalog(): CatalogFrame { supported: true, capabilities: ["sessions.manage"], })), + { + id: "model-default" as never, + kind: "model", + name: "Default model", + supported: true, + }, + ], }; } @@ -192,7 +200,9 @@ class FakeManagementController { { truncated: this.inventoryTruncated, totalCount: this.sessionIndex.size }, ], ]) as DesktopRuntimeSnapshot["projection"]["sessionIndexMetadata"], - sessionRefArrivalOrdinals: new Map(), + sessionRefArrivalOrdinals: new Map( + [...this.sessionIndex.keys()].map((sessionKey) => [sessionKey, 1]), + ), sessionDeltaCursors: new Map(), sessionInventoryCursors: new Map(), workspaces: new Map(), @@ -477,6 +487,52 @@ describe("session management authority helpers", () => { reason: "This host does not offer session creation yet", }); + const withoutUsableModel = catalog(); + const unavailable = { + ...snapshot, + catalogs: new Map([ + [ + ADDRESS.hostId, + { + ...withoutUsableModel, + items: withoutUsableModel.items.map((item) => + item.kind === "model" + ? { + ...item, + id: "availability:models" as never, + name: "availability:models", + supported: false, + reason: "model registry unavailable or no available models", + } + : item, + ), + }, + ], + ]), + } as DesktopRuntimeSnapshot; + expect(sessionCreateSupport(unavailable, address)).toEqual({ + supported: false, + reason: "Configure a model for this OMP profile before creating a session", + }); + + const commandOnlyCatalog = catalog(); + const officialAuthority = { + ...snapshot, + catalogs: new Map([ + [ + ADDRESS.hostId, + { + ...commandOnlyCatalog, + items: commandOnlyCatalog.items.filter((item) => item.kind !== "model"), + }, + ], + ]), + } as DesktopRuntimeSnapshot; + expect(sessionCreateSupport(officialAuthority, address)).toEqual({ + supported: true, + reason: null, + }); + const unbound = { ...snapshot, targetHosts: new Map(), @@ -489,8 +545,8 @@ describe("session management authority helpers", () => { const syncingController = new FakeManagementController(); syncingController.inventoryTruncated = true; expect(sessionCreateSupport(syncingController.getSnapshot(), address)).toEqual({ - supported: false, - reason: "This host's session list is still syncing. Try again in a moment.", + supported: true, + reason: null, }); }); @@ -640,6 +696,34 @@ describe("session management authority helpers", () => { ).toBe(false); }); + it("keeps restore available when an archived row carries stale takeover state", () => { + const archived = { + ...ref({ archived: true }), + liveState: { + phase: "idle", + sessionControl: { mode: "reconciling", transcript: "live" }, + }, + } as SessionRef; + const fake = new FakeManagementController(archived); + expect(managementCommandSupport(fake.getSnapshot(), ADDRESS, "session.restore")).toEqual({ + supported: true, + reason: null, + }); + expect(managementCommandSupport(fake.getSnapshot(), ADDRESS, "session.delete").supported).toBe( + false, + ); + const liveLock = new FakeManagementController({ + ...ref({ archived: true }), + liveState: { + phase: "idle", + sessionControl: { mode: "observer", lockStatus: "live", transcript: "live" }, + }, + } as SessionRef); + expect(managementCommandSupport(liveLock.getSnapshot(), ADDRESS, "session.restore").supported).toBe( + false, + ); + }); + const SYNCING_REASON = "This session is still syncing from the host. Try again in a moment."; const ALL_COMMANDS = [ "session.rename", @@ -649,18 +733,18 @@ describe("session management authority helpers", () => { "session.delete", ] as const; - it("disables management honestly while the host inventory is truncated or incomplete", () => { - // Truncated inventory: the host said the list is cut short. + it("keeps a returned session manageable when the host inventory is bounded", () => { + // Truncation limits absence proof, not the current row the host returned. const truncated = new FakeManagementController(); truncated.inventoryTruncated = true; for (const command of ALL_COMMANDS) { expect(managementCommandSupport(truncated.getSnapshot(), ADDRESS, command)).toEqual({ - supported: false, - reason: SYNCING_REASON, + supported: true, + reason: null, }); } - // Incomplete inventory: the host claims more sessions than we indexed. + // The same applies when the host reports more sessions than this bounded page. const base = new FakeManagementController().getSnapshot(); const incomplete = { ...base, @@ -671,8 +755,8 @@ describe("session management authority helpers", () => { } as DesktopRuntimeSnapshot; for (const command of ALL_COMMANDS) { expect(managementCommandSupport(incomplete, ADDRESS, command)).toEqual({ - supported: false, - reason: SYNCING_REASON, + supported: true, + reason: null, }); } @@ -712,7 +796,7 @@ describe("session management authority helpers", () => { }); }); - it("lets the syncing reason outrank the observer reason, matching the dispatch gate", () => { + it("keeps current ownership truth visible on a bounded inventory", () => { const observed = new FakeManagementController({ ...ref(), liveState: { @@ -721,11 +805,12 @@ describe("session management authority helpers", () => { }, } as SessionRef); observed.inventoryTruncated = true; - // assertSessionWritableNow() rejects cached before it reads ownership; - // the support gate promises the same order. + // This row came from the current response, so ownership—not global list + // truncation—is the reason it remains read-only. expect(managementCommandSupport(observed.getSnapshot(), ADDRESS, "session.close")).toEqual({ supported: false, - reason: SYNCING_REASON, + reason: presentSessionControl({ mode: "observer", lockStatus: "live", transcript: "live" }) + .managementReason, }); }); @@ -918,6 +1003,35 @@ describe("dispatch-time ownership rechecks", () => { expect(fake.confirms).toHaveLength(0); }); + it("restores an archived session without waiting on stale takeover state", async () => { + const archived = { + ...ref({ archived: true }), + liveState: { + phase: "idle", + sessionControl: { mode: "reconciling", transcript: "live" }, + }, + } as SessionRef; + const fake = new FakeManagementController(archived); + await restoreLiveSession(controller(fake), ADDRESS); + expect(fake.commands.map((intent) => intent.command)).toEqual([ + "session.restore", + "session.list", + ]); + expect(sessionIsArchived(fake.getSnapshot().projection.sessionIndex.get(KEY))).toBe(false); + }); + + it("refuses archived restore while another app still holds a live lock", async () => { + const fake = new FakeManagementController({ + ...ref({ archived: true }), + liveState: { + phase: "idle", + sessionControl: { mode: "observer", lockStatus: "live", transcript: "live" }, + }, + } as SessionRef); + await expect(restoreLiveSession(controller(fake), ADDRESS)).rejects.toThrow(OBSERVED_REASON); + expect(fake.commands).toHaveLength(0); + }); + it("refuses malformed/unknown ownership with unclear copy, never another-app copy", async () => { const fake = new FakeManagementController({ ...ref(), diff --git a/apps/web/test/session-state-presentation.test.tsx b/apps/web/test/session-state-presentation.test.tsx index 6cfbe26e..3de49286 100644 --- a/apps/web/test/session-state-presentation.test.tsx +++ b/apps/web/test/session-state-presentation.test.tsx @@ -3,9 +3,12 @@ import { describe, expect, it } from "vite-plus/test"; import { describeSessionState } from "../src/components/Rail.tsx"; import { + resolveSessionActivity, SessionActivityBanner, - SessionLifecycleBadge, + SessionConnectionBadge, + SessionStateBadge, } from "../src/features/transcript/SessionMain.tsx"; +import { presentSessionState } from "../src/features/session-runtime/session-state.ts"; import type { WorkspaceSession } from "../src/lib/workspace-data.ts"; const BASE_SESSION: WorkspaceSession = { @@ -38,18 +41,98 @@ describe("truthful session state presentation", () => { ); }); - it("shows the same explicit lifecycle in the task header", () => { + it("shows the same explicit lifecycle in the task header's stable state slot", () => { const idle = renderToStaticMarkup( - , + , ); const stopped = renderToStaticMarkup( - , + , ); - const unknown = renderToStaticMarkup(); + const unknown = renderToStaticMarkup(); expect(idle).toContain("Idle"); + expect(idle).toContain("bg-muted-foreground"); expect(stopped).toContain("Stopped"); expect(unknown).toContain("Status unknown"); + for (const markup of [idle, stopped, unknown]) { + expect(markup).toContain("sm:w-32"); + } + }); + + it("keeps connection separate while collapsing activity and ownership into one signal", () => { + const connected = renderToStaticMarkup(); + const observedWorking = renderToStaticMarkup( + , + ); + + expect(connected).toContain("Connected"); + expect(observedWorking).toContain("Working elsewhere"); + expect(observedWorking).not.toContain(">Idle<"); + expect( + resolveSessionActivity({ + archived: false, + catchingUp: false, + controlled: false, + contextMaintenance: false, + link: "live", + sessionActive: true, + }), + ).toBe("working"); + expect( + resolveSessionActivity({ + archived: false, + catchingUp: false, + controlled: true, + contextMaintenance: false, + link: "live", + sessionActive: true, + }), + ).toBeNull(); + }); + + it("uses stable connection geometry and names reconnects without leaking them into activity", () => { + const connected = renderToStaticMarkup(); + const reconnecting = renderToStaticMarkup(); + + for (const markup of [connected, reconnecting]) { + expect(markup).toContain("sm:w-28"); + } + expect(reconnecting).toContain("Reconnecting"); + expect(reconnecting).not.toContain(">Connecting<"); + expect(presentSessionState({ ...BASE_SESSION, status: "connecting" }).label).toBe( + "Status unknown", + ); + }); + + it("keeps state priority truthful through takeover and reconnect transitions", () => { + const sequence = [ + presentSessionState({ ...BASE_SESSION, lifecycle: "idle" }).label, + presentSessionState({ + ...BASE_SESSION, + lifecycle: "idle", + control: "reconciling", + }).label, + presentSessionState({ ...BASE_SESSION, lifecycle: "active", status: "working" }).label, + presentSessionState({ ...BASE_SESSION, freshness: "cached", status: "working" }).label, + presentSessionState({ ...BASE_SESSION, freshness: "offline", status: "working" }).label, + presentSessionState({ ...BASE_SESSION, lifecycle: "idle" }).label, + ]; + + expect(sequence).toEqual([ + "Idle", + "Taking over", + "Working", + "Cached", + "Offline", + "Idle", + ]); }); it("renders a moving visual heartbeat only while work is confirmed", () => { diff --git a/apps/web/test/session-tree.test.ts b/apps/web/test/session-tree.test.ts index 492a09ab..ae50646c 100644 --- a/apps/web/test/session-tree.test.ts +++ b/apps/web/test/session-tree.test.ts @@ -55,6 +55,35 @@ describe("fixture invariants", () => { }); describe("buildProjectGroups", () => { + it("collapses large-inventory defaults while keeping the routed project discoverable", () => { + const activeSessionId = "sess-pagination"; + const groups = buildProjectGroups(SHELL_FIXTURE, {}, {}, "current", {}, { + activeSessionId, + defaultExpanded: false, + }); + const active = groups.find((group) => + group.sessions.some((row) => row.session.id === activeSessionId), + ); + expect(active?.expanded).toBe(true); + expect( + groups + .filter((group) => group.project.id !== active?.project.id) + .every((group) => !group.expanded), + ).toBe(true); + + const explicitlyCollapsed = buildProjectGroups( + SHELL_FIXTURE, + { [active!.project.id]: false }, + {}, + "current", + {}, + { activeSessionId, defaultExpanded: false }, + ); + expect( + explicitlyCollapsed.find((group) => group.project.id === active?.project.id)?.expanded, + ).toBe(false); + }); + it("filters by title, project, runtime state, unread state, and errors", () => { const byText = buildProjectGroups( SHELL_FIXTURE, @@ -90,6 +119,20 @@ describe("buildProjectGroups", () => { ]); }); + it("does not treat a host reconnect as per-session work or priority", () => { + const session = SHELL_FIXTURE.sessions[0]!; + const reconnecting = { + ...SHELL_FIXTURE, + sessions: [{ ...session, status: "connecting" as const }], + }; + + const running = buildProjectGroups(reconnecting, {}, {}, "current", {}, { + filter: "running", + }); + expect(running).toEqual([]); + expect(sessionPriority({ session: reconnecting.sessions[0]!, unread: false })).toBe(0); + }); + it("matches uppercase ASCII queries under a Turkish locale", () => { // Simulate a Turkish/Azeri locale, where ASCII "I" lowercases to dotless "ı". String.prototype.toLocaleLowerCase = function (this: string) { diff --git a/apps/web/test/terminal-live-pty.test.ts b/apps/web/test/terminal-live-pty.test.ts index 33a49305..3ec4fe9d 100644 --- a/apps/web/test/terminal-live-pty.test.ts +++ b/apps/web/test/terminal-live-pty.test.ts @@ -6,6 +6,8 @@ // no replay after a dropped connection, cross-session isolation, bounded // pre-ownership buffering, and an untouched fixture bridge. import { + commandId, + confirmationId, hostId, PROTOCOL_VERSION, requestId as brandRequestId, @@ -56,6 +58,7 @@ import { resolveLiveSession, sessionViewId } from "../src/platform/live-workspac import { presentSessionControl } from "../src/features/session-runtime/session-observer.ts"; import { createLivePtySessionFactory, + createResolvingLivePtySessionFactory, resolveLiveTerminalAvailability, type LivePtyBridge, type LivePtyBridgeOptions, @@ -191,6 +194,21 @@ function openResponseFrame( return { ...base, ok: false, error: { code: outcome.code, message: outcome.message } }; } +function openConfirmationFrame(commandIdValue: string): Record { + return { + v: PROTOCOL_VERSION, + type: "confirmation", + confirmationId: confirmationId("confirm-term-open"), + commandId: commandId(commandIdValue), + hostId: hostId(HOST), + sessionId: sessionId(SESSION), + commandHash: "sha256:term-open", + revision: brandRevision("rev-1"), + expiresAt: "2999-01-01T00:00:00.000Z", + summary: "Open a terminal", + }; +} + function outputFrame( seq: number, data: string, @@ -237,11 +255,13 @@ class FakeShell implements DesktopShellPort { readonly inputs: TerminalInputRequest[] = []; readonly resizes: TerminalResizeRequest[] = []; readonly closes: TerminalCloseRequest[] = []; + readonly confirms: ConfirmRequest[] = []; readonly heldInputs: HeldCall[] = []; readonly heldResizes: HeldCall[] = []; holdInput = false; holdResize = false; rejectOpen = false; + challengeOpen = false; leaseRefused = false; /** Emit the term.open response before the command promise resolves. */ respondOpenSynchronously = false; @@ -294,15 +314,22 @@ class FakeShell implements DesktopShellPort { if (intent.command === "term.open") { this.openCount += 1; const requestIdValue = `open-req-${this.openCount}`; + const commandIdValue = `open-cmd-${this.openCount}`; this.lastOpenRequestId = requestIdValue; if (this.rejectOpen) { return { targetId: request.targetId, requestId: requestIdValue, - commandId: `open-cmd-${this.openCount}`, + commandId: commandIdValue, accepted: false, }; } + if (this.challengeOpen) { + this.emitFrame({ + targetId: request.targetId, + frame: openConfirmationFrame(commandIdValue), + }); + } if (this.respondOpenSynchronously) { this.emitFrame({ targetId: request.targetId, @@ -312,7 +339,7 @@ class FakeShell implements DesktopShellPort { return { targetId: request.targetId, requestId: requestIdValue, - commandId: `open-cmd-${this.openCount}`, + commandId: commandIdValue, accepted: true, }; } @@ -324,6 +351,16 @@ class FakeShell implements DesktopShellPort { }; } async confirm(request: ConfirmRequest): Promise { + this.confirms.push(request); + if (this.challengeOpen && String(request.commandId) === `open-cmd-${this.openCount}`) { + this.emitFrame({ + targetId: request.targetId, + frame: openResponseFrame(this.lastOpenRequestId, { + ok: true, + terminalId: SERVER_TERMINAL, + }), + }); + } return { targetId: request.targetId, requestId: "confirm-req", @@ -560,7 +597,67 @@ describe("availability gates", () => { }); describe("term.open through the controller lease", () => { - it("sends the exact session-scoped command with a relative cwd and the injected lease", async () => { + it("recovers a drawer created before its cached session is rebound", async () => { + const h = await harness(); + let address: LiveSessionAddress | null = null; + const bridge = createResolvingLivePtySessionFactory( + h.controller, + () => h.controller.getSnapshot(), + () => address, + ); + + expect(bridge.availability()).toMatchObject({ available: false, kind: "transport" }); + expect(() => bridge.open(openRequest())).toThrow(/live host/); + + address = ADDRESS; + const session = bridge.open(openRequest()); + const events = watch(session); + await settle(); + h.shell.emitFrame({ + targetId: TARGET, + frame: openResponseFrame(h.shell.lastOpenRequestId, { + ok: true, + terminalId: SERVER_TERMINAL, + }), + }); + await settle(); + h.shell.emitFrame({ targetId: TARGET, frame: outputFrame(1, "rebound-ready") }); + expect(events.errors).toEqual([]); + expect(events.data).toEqual(["rebound-ready"]); + bridge.dispose(); + }); + + it("waits for the visible host challenge before opening the shell", async () => { + const h = await harness(); + h.shell.challengeOpen = true; + const session = h.bridge.open(openRequest()); + const events = watch(session); + await settle(); + + expect(h.shell.confirms).toHaveLength(0); + const challenge = [ + ...(h.controller.getSnapshot().projection.sessions.get(`${HOST}\u0000${SESSION}`) + ?.confirmations.values() ?? []), + ][0]; + expect(challenge).toBeDefined(); + if (challenge === undefined) throw new Error("missing term.open confirmation"); + await h.controller.confirm({ + targetId: TARGET, + confirmationId: challenge.confirmationId, + commandId: challenge.commandId, + hostId: challenge.hostId, + ...(challenge.sessionId === undefined ? {} : { sessionId: challenge.sessionId }), + decision: "approve", + }); + await settle(); + expect(h.shell.confirms).toHaveLength(1); + expect(events.errors).toEqual([]); + + h.shell.emitFrame({ targetId: TARGET, frame: outputFrame(1, "challenge-ready") }); + expect(events.data).toEqual(["challenge-ready"]); + }); + + it("omits cwd for the project root and injects the controller lease", async () => { const h = await harness(); await openLive(h); const leaseCommands = h.shell.commands.filter((entry) => @@ -574,13 +671,24 @@ describe("term.open through the controller lease", () => { expect(String(open?.intent.sessionId)).toBe(SESSION); expect(String(open?.intent.expectedRevision)).toBe("rev-1"); expect(open?.intent.args).toEqual({ - cwd: ".", cols: 80, rows: 24, leaseId: "lease-fixture", }); }); + it("passes a safe project-relative cwd through unchanged", async () => { + const h = await harness(); + const session = h.bridge.open(openRequest({ cwd: "packages/web" })); + watch(session); + await settle(); + expect(h.shell.termOpenRequests()[0]?.intent.args).toMatchObject({ + cwd: "packages/web", + cols: 80, + rows: 24, + }); + }); + it("omits the optional shell field unless the catalog advertises it", async () => { const advertised = await harness({ catalogItems: [ @@ -593,7 +701,6 @@ describe("term.open through the controller lease", () => { }); await openLive(advertised); expect(advertised.shell.termOpenRequests()[0]?.intent.args).toEqual({ - cwd: ".", cols: 80, rows: 24, shell: "bash", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 6c71db6c..e05b84d1 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -11,7 +11,8 @@ export default defineConfig(({ mode }) => ({ ? [ { name: "t4-demo-document-root", - transformIndexHtml: (html: string) => html.replaceAll('="./', '="/demo/'), + transformIndexHtml: (html: string) => + html.replace("", '\n ').replaceAll('="./', '="/demo/'), }, ] : []), diff --git a/cluster/images/session-runtime/Dockerfile b/cluster/images/session-runtime/Dockerfile index 6ce1a7e6..8589d636 100644 --- a/cluster/images/session-runtime/Dockerfile +++ b/cluster/images/session-runtime/Dockerfile @@ -12,10 +12,10 @@ RUN rm -f /etc/apt/sources.list.d/* \ COPY --from=docker.io/oven/bun:1.3.14-debian@sha256:9dba1a1b43ce28c9d7931bfc4eb00feb63b0114720a0277a8f939ae4dfc9db6f /usr/local/bin/bun /usr/local/bin/bun WORKDIR /opt/omp RUN git init \ - && git remote add origin https://github.com/lyc-aon/oh-my-pi.git \ - && git fetch --depth=1 origin "refs/tags/t4code-17.0.5-appserver-10" \ - && test "$(git rev-parse FETCH_HEAD)" = "8476f4451ed95c5d5401785d279a93d3c659fac4" \ - && git checkout --detach "8476f4451ed95c5d5401785d279a93d3c659fac4" + && git remote add origin https://github.com/wolfiesch/oh-my-pi.git \ + && git fetch --depth=1 origin "refs/tags/t4code-17.0.5-appserver-13" \ + && test "$(git rev-parse FETCH_HEAD)" = "fc0c391334c08ab260057756aa84bd2b07741ee7" \ + && git checkout --detach "fc0c391334c08ab260057756aa84bd2b07741ee7" RUN bun install --frozen-lockfile --ignore-scripts \ && bun --cwd=packages/natives run build \ && bun --cwd=packages/coding-agent run gen:tool-views \ @@ -63,8 +63,8 @@ ARG SOURCE_REPOSITORY=https://github.com/LycaonLLC/t4-code LABEL org.opencontainers.image.source="${SOURCE_REPOSITORY}" \ org.opencontainers.image.title="T4 OMP session runtime" \ io.t4.omp.version="17.0.5" \ - io.t4.omp.tag="t4code-17.0.5-appserver-10" \ - io.t4.omp.commit="8476f4451ed95c5d5401785d279a93d3c659fac4" \ + io.t4.omp.tag="t4code-17.0.5-appserver-13" \ + io.t4.omp.commit="fc0c391334c08ab260057756aa84bd2b07741ee7" \ io.t4.omp.authority="t4-omp-authority/1" \ io.t4.gui.display="Xvfb" USER 10001:10001 diff --git a/compat/official-omp-gate0.json b/compat/official-omp-gate0.json index 3968ea05..ee1c1cda 100644 --- a/compat/official-omp-gate0.json +++ b/compat/official-omp-gate0.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "gate": "official-omp-gate0", "runtime": { - "version": "17.0.6", - "tag": "v17.0.6", - "commit": "89d6a8f6d14286f32f09ec9c8aa8af7b3451d2d6" + "version": "17.0.9", + "tag": "v17.0.9", + "commit": "639bac596d94b5993349f3f6696176cb2bf9b5d3" }, "requiredPlatforms": ["darwin-arm64", "linux-x64", "linux-arm64"], "requiredScenarios": [ @@ -14,6 +14,7 @@ "follow-up", "approval", "cancellation", + "large-rpc-payload", "crash-after-dispatch-no-replay" ], "officialRpcSupport": { diff --git a/compat/omp-app-matrix.json b/compat/omp-app-matrix.json index d2b56506..398f6462 100644 --- a/compat/omp-app-matrix.json +++ b/compat/omp-app-matrix.json @@ -2,36 +2,36 @@ "appProtocol": "omp-app/1", "officialRuntime": { "package": "omp", - "version": "17.0.6", + "version": "17.0.9", "sourceRepository": "https://github.com/can1357/oh-my-pi", - "sourceCommit": "89d6a8f6d14286f32f09ec9c8aa8af7b3451d2d6", - "sourceUrl": "https://github.com/can1357/oh-my-pi/commit/89d6a8f6d14286f32f09ec9c8aa8af7b3451d2d6", - "sourceTag": "v17.0.6", + "sourceCommit": "639bac596d94b5993349f3f6696176cb2bf9b5d3", + "sourceUrl": "https://github.com/can1357/oh-my-pi/commit/639bac596d94b5993349f3f6696176cb2bf9b5d3", + "sourceTag": "v17.0.9", "artifacts": { "darwin-arm64": { "name": "omp-darwin-arm64", - "size": 119425152, - "sha256": "d747470bcff0412e5bde7860f5dead7de15aad6b973a4b46c34f8527d9d6a4b9" + "size": 121901952, + "sha256": "dd1430ba4809a55f4d6f2f646211cee51535f9619eb6670f1bfcf7484c23b931" }, "darwin-x64": { "name": "omp-darwin-x64", - "size": 124901872, - "sha256": "9e5b4560b61fc4373f604cba9e2cc3bdab04549e830f3d5ecd80b1f7facc86bc" + "size": 127378672, + "sha256": "b3fb6ea340d5eec33adcdae8e1e9923925599c1068c83a51fa5b3a02dd4f2c32" }, "linux-arm64": { "name": "omp-linux-arm64", - "size": 149072016, - "sha256": "4d2f3e9948f81f4c3813b5c89e5eda4bdcd42ef22285d40cf48402ebfa15436d" + "size": 151496848, + "sha256": "c29907233107fc57ae18f81e91877431f69252ba071936b5c6672592d84cc984" }, "linux-x64": { "name": "omp-linux-x64", - "size": 173901952, - "sha256": "27fec143aa646cae5eae9b3a0677c54d43465fc49a79b161bac739dbba244c8c" + "size": 176400512, + "sha256": "485cc301d6fdff26bab5d3ab45ab137634a73b71b2ed7a91c6c8545842ff4c09" }, "win32-x64": { "name": "omp-windows-x64.exe", - "size": 152957440, - "sha256": "b5c7a5b008ae2342664161ed5615337501ae833a7ce825dbbf0918281dafb2d6" + "size": 155415040, + "sha256": "feeb48925791b267dc35912e26e95af4fab66e213ad8dfcfc73ff82ab2c8e84d" } } }, @@ -55,27 +55,27 @@ "publishedRuntime": { "package": "omp", "version": "17.0.5", - "sourceRepository": "https://github.com/lyc-aon/oh-my-pi", - "sourceCommit": "8476f4451ed95c5d5401785d279a93d3c659fac4", - "sourceUrl": "https://github.com/lyc-aon/oh-my-pi/commit/8476f4451ed95c5d5401785d279a93d3c659fac4", - "sourceTag": "t4code-17.0.5-appserver-10", + "sourceRepository": "https://github.com/wolfiesch/oh-my-pi", + "sourceCommit": "fc0c391334c08ab260057756aa84bd2b07741ee7", + "sourceUrl": "https://github.com/wolfiesch/oh-my-pi/commit/fc0c391334c08ab260057756aa84bd2b07741ee7", + "sourceTag": "t4code-17.0.5-appserver-13", "artifacts": { "darwin-arm64": { "name": "omp-darwin-arm64", "size": 120893008, - "sha256": "f7d4438e163aabc4ca624468bf5bd4a243b5e1ab5360e84cef52abe93683ea55", + "sha256": "60ce5193eadc2884a4313289ff1166b6a1daae97f3a3ec66dc260ea5c5713756", "releaseCodeSignature": "adhoc", "distributionSigningBoundary": "t4-product-package" }, "linux-arm64": { "name": "omp-linux-arm64", "size": 150513808, - "sha256": "968a939fc26329590ba53b11c9cb0eccc0e849e8988c8da9fc58c7c2fcfe8ef5" + "sha256": "46d29f223a7481d42b3f7219e71afe389604c2b61f7d309369397a8932837770" }, "linux-x64": { "name": "omp-linux-x64", - "size": 175372416, - "sha256": "deb7dae793a2191746681980305ab8b5ed0480410950ca7d5e555257f4f3fb04" + "size": 175376512, + "sha256": "ef66a95a45185c35a19ed997b68f62f86de5497414f6a9b939c4f228a52471b8" } }, "upstreamRepository": "https://github.com/can1357/oh-my-pi", @@ -149,34 +149,39 @@ "bounded-transcript-tail-paging", "thin-omp-authority-bridge", "bounded-authority-bridge-streams", - "legacy-appserver-launcher-removal" + "legacy-appserver-launcher-removal", + "sparse-authority-session-lists", + "atomic-xdev-mount-publication", + "snapshot-consistent-authority-session-paging", + "explicit-partial-authority-inventory", + "stale-session-lock-lifecycle-recovery" ], "upstreamTagContainsIntegrationPatches": false }, "verifiedRuntime": { "package": "omp", "version": "17.0.5", - "sourceRepository": "https://github.com/lyc-aon/oh-my-pi", - "sourceCommit": "8476f4451ed95c5d5401785d279a93d3c659fac4", - "sourceUrl": "https://github.com/lyc-aon/oh-my-pi/commit/8476f4451ed95c5d5401785d279a93d3c659fac4", - "sourceTag": "t4code-17.0.5-appserver-10", + "sourceRepository": "https://github.com/wolfiesch/oh-my-pi", + "sourceCommit": "fc0c391334c08ab260057756aa84bd2b07741ee7", + "sourceUrl": "https://github.com/wolfiesch/oh-my-pi/commit/fc0c391334c08ab260057756aa84bd2b07741ee7", + "sourceTag": "t4code-17.0.5-appserver-13", "artifacts": { "darwin-arm64": { "name": "omp-darwin-arm64", "size": 120893008, - "sha256": "f7d4438e163aabc4ca624468bf5bd4a243b5e1ab5360e84cef52abe93683ea55", + "sha256": "60ce5193eadc2884a4313289ff1166b6a1daae97f3a3ec66dc260ea5c5713756", "releaseCodeSignature": "adhoc", "distributionSigningBoundary": "t4-product-package" }, "linux-arm64": { "name": "omp-linux-arm64", "size": 150513808, - "sha256": "968a939fc26329590ba53b11c9cb0eccc0e849e8988c8da9fc58c7c2fcfe8ef5" + "sha256": "46d29f223a7481d42b3f7219e71afe389604c2b61f7d309369397a8932837770" }, "linux-x64": { "name": "omp-linux-x64", - "size": 175372416, - "sha256": "deb7dae793a2191746681980305ab8b5ed0480410950ca7d5e555257f4f3fb04" + "size": 175376512, + "sha256": "ef66a95a45185c35a19ed997b68f62f86de5497414f6a9b939c4f228a52471b8" } }, "upstreamRepository": "https://github.com/can1357/oh-my-pi", @@ -250,18 +255,23 @@ "bounded-transcript-tail-paging", "thin-omp-authority-bridge", "bounded-authority-bridge-streams", - "legacy-appserver-launcher-removal" + "legacy-appserver-launcher-removal", + "sparse-authority-session-lists", + "atomic-xdev-mount-publication", + "snapshot-consistent-authority-session-paging", + "explicit-partial-authority-inventory", + "stale-session-lock-lifecycle-recovery" ], "upstreamTagContainsIntegrationPatches": false }, "t4Host": { "wirePackage": "@t4-code/host-wire", - "wirePackageVersion": "0.1.30", + "wirePackageVersion": "0.1.31", "wireSchemaVersion": "0.7.0", "servicePackage": "@t4-code/host-service", - "servicePackageVersion": "0.1.30", + "servicePackageVersion": "0.1.31", "daemonPackage": "@t4-code/host-daemon", - "daemonPackageVersion": "0.1.30", + "daemonPackageVersion": "0.1.31", "protocol": "omp-app/1", "authorityBridgeProtocol": "t4-omp-authority/1", "sourcePaths": [ @@ -281,6 +291,6 @@ }, "desktop": { "package": "@t4-code/protocol", - "version": "0.1.30" + "version": "0.1.31" } } diff --git a/deploy/charts/t4-cluster/Chart.yaml b/deploy/charts/t4-cluster/Chart.yaml index 550297cc..cc1f12e0 100644 --- a/deploy/charts/t4-cluster/Chart.yaml +++ b/deploy/charts/t4-cluster/Chart.yaml @@ -2,6 +2,6 @@ apiVersion: v2 name: t4-cluster description: Default-off portable T4 Kubernetes operator and stateless cluster gateway version: 0.1.0 -appVersion: "0.1.30" +appVersion: "0.1.31" type: application kubeVersion: ">=1.30.0-0" diff --git a/docs/CLUSTER_OPERATOR.md b/docs/CLUSTER_OPERATOR.md index b23aa4c2..91c7c622 100644 --- a/docs/CLUSTER_OPERATOR.md +++ b/docs/CLUSTER_OPERATOR.md @@ -23,13 +23,15 @@ That declaration does not make a non-RWX backend safe. Missing classes, classes ## Install -Installing with defaults creates no controller, gateway, session workload, RBAC, Secret, or network policy: +Installing with defaults creates no controller, gateway, session workload, RBAC, Secret, or network policy. Use the lifecycle runner even for a fresh install so existing live objects and compatibility fixtures are checked against the proposed schemas before the CRDs are server-validated, established, and storage-version-checked and before Helm runs: ```sh -helm install t4-cluster deploy/charts/t4-cluster --namespace t4-system --create-namespace +scripts/cluster-ci/crd-lifecycle.sh install -- \ + helm install t4-cluster deploy/charts/t4-cluster \ + --namespace t4-system --create-namespace --skip-crds ``` -Helm processes files in `crds/` separately. Use `--skip-crds` if CRD lifecycle is administered independently. To enable the control plane, provide a private values file or a deployment controller values source: +Helm processes files in `crds/` separately on a direct install, but does not upgrade or delete them later. The release procedure therefore administers CRDs independently and always passes `--skip-crds` to Helm. To enable the control plane, provide a private values file or a deployment controller values source: ```yaml enabled: true @@ -119,11 +121,7 @@ Reusable credential projection is unsupported because OMP and arbitrary session The optional built-in model gateway is a separate workload and the only chart-managed Pod that mounts the existing provider Secret. It accepts only `GET` and `POST`, pins one credential-free HTTPS upstream origin and an exact path allowlist, preserves allowed queries, strips caller-supplied authorization, cookie, proxy, forwarding, and hop-by-hop headers, injects one allowlisted credential header, refuses upstream redirects, bounds request size and duration, and omits URLs and headers from structured logs. The exact path allowlist is the provider capability boundary: include only inference and model-discovery endpoints OMP actually uses, never account, billing, file-management, fine-tuning, or administrative endpoints. It streams provider responses, including SSE. It validates the credential at startup and readiness and rereads the projected Secret for each request, so a valid Kubernetes Secret rotation does not require placing the value in an environment variable or restarting the gateway. Its Service is ClusterIP-only; its NetworkPolicy admits same-namespace T4 session Pods and allows egress only to administrator-supplied provider CIDRs and ports. This boundary assumes namespace RBAC prevents untrusted workloads from creating Pods with T4 labels. Do not expose the Service to the Internet or a shared untrusted network. The chart remains provider- and model-neutral. -Install or enable with immutable digests: - -```sh -helm upgrade --install t4-cluster deploy/charts/t4-cluster --namespace t4-system --values operator-values.yaml -``` +Install a new release with immutable digests by adding `--values operator-values.yaml` to the lifecycle-runner `install` command above. For an existing release, use the lifecycle-runner `upgrade` procedure below. Do not use `helm upgrade --install`: install and upgrade have different compatibility preflights. The controller always has two replicas and uses a Kubernetes Lease named from `t4-cluster-operator.cluster.t4.dev`; one replica reconciles at a time. The server defaults to three stateless replicas and supports a minimum of two. Its Deployment uses `maxUnavailable: 0`, a `minAvailable: 2` PDB, topology spread, anti-affinity, readiness draining, and an explicit `k3s-worker-02` exclusion. Session pods also exclude that node by default. Additional cluster-specific exclusions belong in deployment values, not this portable chart. @@ -153,32 +151,141 @@ The optional initial prompt is referenced by Secret name and key `prompt`; the S `cluster/images/controller/Dockerfile`, `cluster/images/cluster-server/Dockerfile`, `cluster/images/session-runtime/Dockerfile`, and `cluster/images/model-gateway/Dockerfile` use digest-pinned multi-platform build bases. BuildKit selects the requested target platform; the Dockerfiles do not hardcode or label an architecture that was not built. Debian packages are resolved through a dated snapshot. Published chart values still require per-image immutable digests. -The session runtime verifies and builds the exact OMP tag `t4code-17.0.5-appserver-10` at commit `8476f4451ed95c5d5401785d279a93d3c659fac4`. It preserves `t4-omp-authority/1`, starts the existing T4 session-host entrypoint, and provides Xvfb, a minimal window manager, and Chromium without privileged mode or host display access. The shared claim is mounted at `/workspace`; authority and browser state live in controller-selected per-session subdirectories. OMP configuration is copied from the read-only administrator ConfigMap projection into the private authority child home before launch. `/dev/shm` is an explicit memory-backed volume. Browser Preview remains the existing GUI stream and control surface. +The session runtime verifies and builds the exact OMP tag `t4code-17.0.5-appserver-13` at commit `fc0c391334c08ab260057756aa84bd2b07741ee7`. It preserves `t4-omp-authority/1`, starts the existing T4 session-host entrypoint, and provides Xvfb, a minimal window manager, and Chromium without privileged mode or host display access. The shared claim is mounted at `/workspace`; authority and browser state live in controller-selected per-session subdirectories. OMP configuration is copied from the read-only administrator ConfigMap projection into the private authority child home before launch. `/dev/shm` is an explicit memory-backed volume. Browser Preview remains the existing GUI stream and control surface. Session pods do not receive an automatically mounted ServiceAccount token. The explicit projected reviewer token can only create TokenReviews and is not resource-authorized. The namespace-local ConfigMap projection contains credential-free `auth: none` OMP models and credential-free settings; session, controller, and server Pods receive no provider Secret reference or reusable provider credential. All containers drop capabilities, disallow privilege escalation, use RuntimeDefault seccomp, and use read-only root filesystems. No per-session NodePort, LoadBalancer, host network, host PID, host display, or hostPath is created. -## Upgrade and rollback +## Upgrade, storage migration, and rollback -CRD changes must remain structural and additive. Helm installs files under `crds/` on first install but does not upgrade or delete them. Before every `helm upgrade`, an administrator must review and apply each newer CRD independently, wait for API discovery to serve the updated schemas, and only then upgrade workloads with the new immutable image digests: +The three namespaced CRDs have exactly one version: `cluster.t4.dev/v1alpha1`, with `served: true`, `storage: true`, a structural schema, and a status subresource. Changes in this version must remain additive: existing fields keep their meaning and validation, while new fields are optional or have safe defaults. The compatibility fixtures in `packages/cluster-operator/api/v1alpha1/testdata/compat/` are persisted old-object shapes, including status and finalizers, and must continue to validate and round-trip without losing any declared field. + +Helm does not upgrade CRDs. For every workload upgrade, run the fail-closed lifecycle command: ```sh -kubectl apply --server-side -f deploy/charts/t4-cluster/crds/ -kubectl wait --for=condition=Established crd/t4clusterhosts.cluster.t4.dev crd/t4workspaces.cluster.t4.dev crd/t4sessions.cluster.t4.dev +scripts/cluster-ci/crd-lifecycle.sh upgrade -- \ + helm upgrade t4-cluster deploy/charts/t4-cluster \ + --namespace t4-system --values operator-values.yaml --skip-crds ``` -Do not rely on `helm upgrade` to change CRDs. If a CRD pre-upgrade apply fails validation, stop the upgrade and keep the currently deployed controller/server images; do not force-replace the CRD or remove stored versions. +The command performs this exact order: + +1. Before any cluster access, validate every complete compatibility fixture (including `status`) against the proposed structural OpenAPI schema and CEL programs with the Kubernetes apiextensions validators. The check also rejects declared fields that the structural pruner would remove. +2. With `get` access to the three exact CRD definitions and `list` access to the corresponding namespaced T4 resources, save each installed definition, enumerate every live CR for each existing definition across namespaces, and validate each complete object, including `status`, against the same proposed OpenAPI, CEL create, CEL unchanged-update, and pruning checks. A denied CRD read, denied or malformed object list, or incompatible object fails closed; an absent definition is safely empty on a fresh install. +3. Compare every installed schema with the candidate and enforce the additive-only contract structurally. Existing schema nodes must retain identical validation semantics; properties may only be added when optional. The version-name, served/storage, and conversion contracts must remain exactly unchanged in this lifecycle mode. This rejects removed fields, new required fields, changed defaults, tighter bounds, patterns or enums, new CEL on existing nodes, list/map topology changes, additional versions, and conversion webhooks even if the current object snapshot happens to pass. +4. Generate a merge patch for each installed CRD from the candidate spec plus the validated object's `metadata.resourceVersion` and UID, then server-side dry-run it. For an absent definition, server-side dry-run an atomic create instead. Upgrades stop before any mutation if a local proposed-schema validation, live-object enumeration, structural compatibility check, patch generation, or server dry-run fails. +5. Apply each guarded merge patch with field manager `t4-crd-lifecycle`, or create an absent definition. JSON merge patch uses update ownership semantics, so fields initially created by Helm do not block an additive update; the API server returns a conflict rather than overwriting a CRD that changed or was deleted and recreated after validation. +6. Wait for all three CRDs to report `Established`. +7. Poll the served `cluster.t4.dev/v1alpha1` OpenAPI v3 document until three consecutive observations match the semantics generated from the proposed CRDs, resetting the counter after any stale or unavailable response and failing after a bounded number of attempts. Each request has its own 10-second client timeout, so a connected but unresponsive API endpoint cannot block an attempt forever. Retained `Established=True` is not readiness when discovery still serves an old schema. +8. Server-side dry-run the compatibility fixtures against the converged admission path. +9. Require each CRD's `status.storedVersions` to be exactly `v1alpha1`. +10. Execute the supplied Helm command, which must use `--skip-crds`. + +The corresponding administrative checks are executable independently: ```sh -helm upgrade t4-cluster deploy/charts/t4-cluster --namespace t4-system --values operator-values.yaml +(cd packages/cluster-operator && \ + go run ./cmd/crd-preflight fixtures ../../deploy/charts/t4-cluster/crds api/v1alpha1/testdata/compat) +live_objects=$(mktemp) +installed_crds=$(mktemp -d) +merge_patches=$(mktemp -d) +trap 'rm -f "$live_objects"; rm -rf "$installed_crds" "$merge_patches"' EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM +for resource in t4clusterhosts.cluster.t4.dev t4workspaces.cluster.t4.dev t4sessions.cluster.t4.dev; do + installed_crd="$installed_crds/$resource.yaml" + kubectl get "crd/$resource" --ignore-not-found -o yaml >"$installed_crd" + if [ ! -s "$installed_crd" ]; then + rm -f "$installed_crd" + continue + fi + kubectl get "$resource" --all-namespaces -o json >"$live_objects" + (cd packages/cluster-operator && \ + go run ./cmd/crd-preflight objects ../../deploy/charts/t4-cluster/crds <"$live_objects") +done +(cd packages/cluster-operator && \ + go run ./cmd/crd-preflight compatible ../../deploy/charts/t4-cluster/crds "$installed_crds") +for resource in t4clusterhosts.cluster.t4.dev t4workspaces.cluster.t4.dev t4sessions.cluster.t4.dev; do + if [ -s "$installed_crds/$resource.yaml" ]; then + (cd packages/cluster-operator && \ + go run ./cmd/crd-preflight patch \ + "../../deploy/charts/t4-cluster/crds/$resource.yaml" \ + "$installed_crds/$resource.yaml") >"$merge_patches/$resource.json" + kubectl patch "crd/$resource" --type=merge --dry-run=server \ + --field-manager=t4-crd-lifecycle --patch-file="$merge_patches/$resource.json" >/dev/null + else + kubectl create --dry-run=server --validate=strict \ + --field-manager=t4-crd-lifecycle \ + -f "deploy/charts/t4-cluster/crds/$resource.yaml" >/dev/null + fi +done +for resource in t4clusterhosts.cluster.t4.dev t4workspaces.cluster.t4.dev t4sessions.cluster.t4.dev; do + if [ -s "$merge_patches/$resource.json" ]; then + kubectl patch "crd/$resource" --type=merge \ + --field-manager=t4-crd-lifecycle --patch-file="$merge_patches/$resource.json" + else + kubectl create --validate=strict --field-manager=t4-crd-lifecycle \ + -f "deploy/charts/t4-cluster/crds/$resource.yaml" + fi +done +rm -f "$live_objects" +rm -rf "$installed_crds" "$merge_patches" +trap - EXIT HUP INT TERM +kubectl wait --for=condition=Established --timeout=120s \ + crd/t4clusterhosts.cluster.t4.dev \ + crd/t4workspaces.cluster.t4.dev \ + crd/t4sessions.cluster.t4.dev +openapi_document=$(mktemp) +trap 'rm -f "$openapi_document"' EXIT +observation=0 +attempt=0 +while [ "$observation" -lt 3 ] && [ "$attempt" -lt 30 ]; do + attempt=$((attempt + 1)) + if kubectl get --request-timeout=10s --raw /openapi/v3/apis/cluster.t4.dev/v1alpha1 >"$openapi_document" && \ + (cd packages/cluster-operator && \ + go run ./cmd/crd-preflight served ../../deploy/charts/t4-cluster/crds <"$openapi_document"); then + observation=$((observation + 1)) + else + observation=0 + fi + if [ "$observation" -lt 3 ]; then sleep 2; fi +done +test "$observation" -eq 3 +rm -f "$openapi_document" +trap - EXIT +kubectl apply --dry-run=server --validate=strict --namespace default \ + -f packages/cluster-operator/api/v1alpha1/testdata/compat/ +for crd in t4clusterhosts.cluster.t4.dev t4workspaces.cluster.t4.dev t4sessions.cluster.t4.dev; do + test "$(kubectl get "crd/$crd" -o 'jsonpath={.status.storedVersions[*]}')" = v1alpha1 +done ``` -For an application rollback, retain the additive CRDs and use the previous known-compatible values and image digest set: +Do not rely on `helm upgrade` to change CRDs. Never use `kubectl replace --force`, delete/recreate a CRD, or apply an unguarded force-conflict update. Do not alter `status.storedVersions` outside the verified migration sequence below. A preflight failure leaves the live CRDs, custom resources, controller/server workloads, and session workloads untouched. A resource-version conflict means the validated snapshot is stale: stop, investigate the concurrent CRD change, and rerun the complete lifecycle rather than retrying only the patch. A failure after an additive CRD patch but before Helm leaves the prior workloads running against the still-backward-compatible schema; investigate and rerun the gates rather than attempting CRD rollback. + +### Future `v1beta1` conversion and storage procedure + +There is no `v1beta1` API today. A future proposal is a separate incompatible lifecycle change and may proceed only through these gates: + +1. Back up all three CRDs and every custom resource, record per-kind object counts, and prove a restore in an isolated cluster. +2. Review separate `v1beta1` schemas, defaults, conversion semantics, downgrade semantics, and old/new/old fixture round-trips. Every field represented in either version must survive conversion; conversion may not manufacture product or runtime state in status. +3. Deploy a highly available conversion webhook with a PodDisruptionBudget, strict TLS/service identity, readiness checks, failure policy, metrics, and alerts. Prove conversion availability before adding a served version to any CRD. +4. Add `v1beta1` as served but not storage, retain `v1alpha1` as served and storage, wait for `Established`, and prove reads and writes through both versions plus all compatibility fixtures. Do not advance while any controller, gateway, backup, or recovery tool cannot read both versions. +5. In a later reviewed CRD apply, set `v1beta1` storage to true and `v1alpha1` storage to false while keeping both served. Reconfirm conversion health, then rewrite every object through the Kubernetes API using an approved storage-version migrator; merely changing `spec.versions[*].storage` does not migrate stored objects. +6. Compare pre/post object counts and read every rewritten object through both served versions. Verify identity, declared spec and status fields, finalizers, and conversion round-trips. Stop on any missing object or lossy read. +7. Only after that verification, explicitly retire the old storage record through the CRD status subresource for each CRD, for example `kubectl patch customresourcedefinition t4sessions.cluster.t4.dev --subresource=status --type=json -p='[{"op":"replace","path":"/status/storedVersions","value":["v1beta1"]}]'`. Repeat for hosts and workspaces. This status update records completed storage migration; it does not perform migration. +8. Read each CRD back and require `status.storedVersions` to be exactly `[v1beta1]`. Keep `v1alpha1` served, the webhook available, the verified backup, and dual-read binaries for the entire rollback window. Removing the old served version is a later explicit change after the window expires. + +If conversion or migration fails, stop forward rollout. Do not force-replace or downgrade CRDs. While both versions remain served and conversion is healthy, roll workloads back to the prior dual-read image set. If in-place compatibility cannot be proven, stop mutations and restore the verified backup under the reviewed recovery procedure before resuming service. + +### Workload rollback + +CRDs remain additive and installed. Roll back the known-compatible controller, server, T4 session runtime, and OMP digest set together: ```sh helm rollback t4-cluster REVISION --namespace t4-system ``` -Do not roll OMP independently of the T4 session runtime. Roll back the known-compatible T4/OMP image set together. The pinned authority boundary is not negotiated down. +Do not roll OMP independently of the T4 session runtime. The pinned authority boundary is not negotiated down. ## Uninstall diff --git a/docs/CURRENT_RELEASE_NOTES.md b/docs/CURRENT_RELEASE_NOTES.md index fd248970..02e538cb 100644 --- a/docs/CURRENT_RELEASE_NOTES.md +++ b/docs/CURRENT_RELEASE_NOTES.md @@ -1,59 +1,18 @@ -## Flutter permanent foundations (development) - -The local migration branch now contains the Flutter/Dart Stage 2 foundations for macOS, iOS, -Android, and Web, with generated Windows and Linux targets. The client strictly decodes and -encodes the pinned `omp-app/1` corpus, correlates commands, restores typed transcript and -session-index cursors, negotiates host watching, and handles reconnect, resume, and continuity -gaps without moving protocol logic into widgets. - -Saved Tailnet hosts, active-host selection, device pairing, host switching, and credential removal -now use shared Dart contracts. Host metadata is stored separately from device credentials; Android -uses encrypted storage and migrates the released app's keyed credentials without exposing them to -Dart, while Apple targets use Keychain-backed storage. Compact and wide layouts share the same -immutable state and command surface, including onboarding, pairing, and host management. - -This is still a development migration, not a release cutover. The deterministic fixture suite, -exact 390x844 and 1440x900 browser checks, iOS and Android target smokes, and an authenticated -disposable T4 host connection backed by OMP's authority bridge pass locally. The existing Electron, -React, browser, and Capacitor clients remain the released implementation until the complete feature -matrix, packaging, update, migration, security, and release gates pass. - -Stage 3 host parity is now complete on the local migration branch. The Flutter client presents -negotiated device permissions, deliberate disconnect/reconnect controls, cancellable pre-save host -checks, an exact least-authority pairing command, pairing failures, and confirmed host removal that -deletes only the device-local address and credential. - -Stage 3 project and session parity is also complete locally. The shared session rail consumes the -canonical session index, groups and searches current or archived sessions, creates and selects -sessions, and exposes rename, runtime termination, archive, restore, and confirmed permanent -deletion through revisioned app-wire commands. Compact drawers and wide rails share the same state -and actions. - -Stage 3 transcript and composer parity is complete locally. Durable and live transcript events now -render Markdown, reasoning, tool progress/results, streaming state, and integrity-checked images. -The composer preserves a separate draft and attachment set per session, uploads bounded images -through the chunked app-wire protocol, exposes catalog-backed slash and model choices, applies -thinking and fast-mode controls, and supports steering, queued follow-ups, and confirmed turn -cancellation. Focused protocol, controller, and compact/wide widget coverage passes alongside the -Web release build and fixture-connected macOS, iOS Simulator, and Android emulator smokes. - -Stage 3 decisions and attention parity is complete locally. The shared inbox groups approvals, -questions, plans, security confirmations, failures, completions, and background-agent progress. -Actions remain bound to the host session revision, acquire a negotiated prompt lease before -dispatch, and reject replaced or expired requests. The full Flutter suite, Web release build, -compact widget coverage, and an interactive compact-browser decision smoke pass. - -Stage 3 developer surfaces are complete locally. A shared developer workspace exposes redacted -activity with filters and pause/copy controls, file browsing and source inspection, selected-file -diff review, and protocol-backed PTY tabs with bounded scrollback, resize/input forwarding, exit -state, and guarded paste. Preview navigation stays host-authoritative: Flutter renders only -integrity-checked capture bytes and never executes page HTML or JavaScript. Focused controller and -compact widget coverage, the full Flutter suite, and fixture-connected Web, iOS Simulator, and -Android emulator smokes exercise the new path. +## Electron and React are the product authority + +T4 Code has standardized on the Electron desktop shell and canonical React renderer. The abandoned +Flutter migration, its duplicate platform targets, and its CI and release plumbing have been +removed. macOS is the primary desktop target, Linux remains supported, and React/Capacitor Android +plus the responsive Tailnet browser/PWA remain compatibility clients for paired hosts. + +The standardization preserves T4's standalone host, typed wire, OMP authority boundary, current +session and transcript behavior, native Browser workspace, terminal, files, review, secure +credentials, and signed update path. The public demo now builds from the same React client shipped +inside Electron, eliminating a second product implementation. ## A session rail built for large libraries -T4 Code v0.1.30 makes a large session library easier to navigate. The rail now supports text search, activity filters, newest/oldest sorting, grouped and flat layouts, collapsible project folders, and saved display preferences. Those controls follow the Codex desktop organization model while keeping OMP as the source of truth. +T4 Code v0.1.31 makes a large session library easier to navigate. The rail now supports text search, activity filters, newest/oldest sorting, grouped and flat layouts, collapsible project folders, and saved display preferences. Those controls follow the Codex desktop organization model while keeping OMP as the source of truth. Project menus can create a session in that folder, reveal the folder in the system file manager, collapse the group, or hide it from the rail. Hidden projects are not deleted and can be restored from the filter menu. The reveal action is deliberately narrow: the host accepts only project paths already present in its session catalog. @@ -89,9 +48,9 @@ Session-linked Host Browser Previews continue to open in their dedicated workspa ## Runtime provenance -T4 Code v0.1.30 vendors app-wire 0.7.0 from integration commit [796bb7dc](https://github.com/lyc-aon/oh-my-pi/commit/796bb7dca45027bd4b7b94017cdf41ef214a11f2), source tree `0c195a01ba0bb98fbf4d4863aee59bf23a6e81b7`. The frozen package remains compatibility evidence; T4 owns the active `omp-app/1` wire schema. +T4 Code v0.1.31 vendors app-wire 0.7.0 from integration commit [796bb7dc](https://github.com/lyc-aon/oh-my-pi/commit/796bb7dca45027bd4b7b94017cdf41ef214a11f2), source tree `0c195a01ba0bb98fbf4d4863aee59bf23a6e81b7`. The frozen package remains compatibility evidence; T4 owns the active `omp-app/1` wire schema. -The verified OMP 17.0.5 runtime is built from commit [8476f445](https://github.com/lyc-aon/oh-my-pi/commit/8476f4451ed95c5d5401785d279a93d3c659fac4) and tagged [t4code-17.0.5-appserver-10](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-10). It provides the bounded authority bridge used by T4's standalone host and no longer exposes the old public appserver launchers. It preserves bounded newest-first transcript paging, stale-owner recovery, privacy-safe local project reveal, lazy session indexing, cross-session attention and transcript search, and the negotiated browser-preview command surface. Unsupported optional capabilities remain hidden when the host does not advertise them. +The verified OMP 17.0.5 runtime is built from commit [fc0c3913](https://github.com/wolfiesch/oh-my-pi/commit/fc0c391334c08ab260057756aa84bd2b07741ee7) and tagged [t4code-17.0.5-appserver-13](https://github.com/wolfiesch/oh-my-pi/tree/t4code-17.0.5-appserver-13). It provides the bounded authority bridge used by T4's standalone host and no longer exposes the old public appserver launchers. It pages snapshot-consistent session inventories across bounded frames, marks over-limit inventories partial, and allows lifecycle actions only when a lock is missing or provably stale. It also keeps session-list metadata sparse before bridge encoding, publishes `xd://` mounts atomically with their transport tools, and preserves bounded newest-first transcript paging, stale-owner recovery, privacy-safe local project reveal, lazy session indexing, cross-session attention and transcript search, and the negotiated browser-preview command surface. Unsupported optional capabilities remain hidden when the host does not advertise them. The integration is based on the official upstream [v17.0.5 tag](https://github.com/can1357/oh-my-pi/tree/v17.0.5), commit [9fd6e971](https://github.com/can1357/oh-my-pi/commit/9fd6e97113f5ed3a847e66d346970efdf8afcad9). Official upstream OMP v17.0.5 has no `appserver` command and cannot host T4 Code. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 88f9e4ba..a3455ffe 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -83,36 +83,6 @@ The browser build uses deterministic sample sessions and labels them **Sample da fastest path for layout, interaction, accessibility, and renderer work. It does not prove that a real OMP host can connect or execute a command. -### Flutter migration client - -Start the deterministic fixture host from the repository root: - -```sh -T4_FIXTURE_SCENARIO=stream-v1 node_modules/.bin/jiti e2e/fixture-process.ts -``` - -Copy the `wsUrl` from `T4_FIXTURE_READY`, then launch a target from another terminal: - -```sh -T4_DEVELOPMENT_ENDPOINT=ws://127.0.0.1:PORT/fixture pnpm dev:flutter -- -d macos -``` - -For the Android emulator, run `adb reverse tcp:PORT tcp:PORT`, then keep -`T4_DEVELOPMENT_ENDPOINT=ws://127.0.0.1:PORT/fixture`. Do not substitute `10.0.2.2`: the -deterministic fixture intentionally binds only to host loopback, so that address leaves the app -retrying with no session selected. Do not expose the fixture through Funnel or a public firewall -rule. - -`T4_DEVELOPMENT_ENDPOINT` is only for deterministic fixture work. Without it, the app starts from -its persisted host directory and accepts an exact Tailnet HTTPS address, then performs normal OMP -device pairing. Unsigned macOS `Debug` builds keep the credential in memory only and identify that -behavior in the window; signed Apple configurations and Android use platform secure storage. - - -Use `pnpm check:flutter` for analysis and `pnpm test:flutter` for the Dart protocol and real-fixture -lifecycle checks. This proof is the migration path under active development; the existing released -clients remain authoritative until the feature and release gates pass. - ### Live desktop work Install the exact verified OMP integration shown by the setup check, then run: @@ -126,10 +96,11 @@ locations. The desktop builds and starts `t4-host`, which launches the compatibl bridge. Do not point development at a personal production profile when testing destructive session lifecycle behavior; use a disposable OMP profile and session root. -### Remote browser or Android work +### Remote browser, iPhone/iPad, or Android work -Start with `docs/TAILNET_REMOTE.md`. Tailscale Serve is the access boundary. Never enable Funnel or -open a public firewall port for development. +Start with `docs/TAILNET_REMOTE.md`. Browser and iPhone/iPad access use the responsive React/PWA +client; Android uses the React/Capacitor wrapper. Tailscale Serve is the access boundary. Never +enable Funnel or open a public firewall port for development. ## 4. Verify a change diff --git a/docs/ELECTRON_STABILIZATION_SPRINT.md b/docs/ELECTRON_STABILIZATION_SPRINT.md new file mode 100644 index 00000000..bc534b2e --- /dev/null +++ b/docs/ELECTRON_STABILIZATION_SPRINT.md @@ -0,0 +1,382 @@ +# Electron recovery and stabilization sprint + +- Status: active; source standardization, automated proof, and unsigned macOS candidate proof complete +- Baseline: `origin/main` at `49442848e0e07558de8033d894e647eb68691ddb` +- Primary product: Electron shell plus the React renderer +- Primary platform: macOS +- Supported release platform: Linux desktop +- Compatibility surfaces: Tailnet browser/PWA and React/Capacitor Android +- Deferred: native Swift macOS and iOS clients + +## Outcome + +Restore one unambiguous, releasable T4 product around the existing Electron and +React implementation. Preserve the current T4-owned host, OMP authority boundary, +wire protocol, browser workspace, and recent product work. Port only validated +client behavior that Electron missed, prove the installed application against a +real runtime, remove the abandoned Flutter product path, and prepare a new stable +Electron release. + +This is a recovery sprint, not a visual rewrite or a new-client project. Swift +planning resumes only after the Electron release and rollback are stable. + +## Execution evidence + +- Exact baseline `49442848e0e07558de8033d894e647eb68691ddb` completed all required GitHub CI + jobs successfully. +- PRs #130 and #146 were closed as superseded; independent heartbeat PR #145 was rebased, + reverified, and merged as `0eb07497f4345254134965e666d337dff438058a`. +- Node 24.13.1 and pnpm 11.10.0 pass `pnpm check`, the full 16-workspace test and build graph, + 32 Playwright scenarios, 49 packaging contracts, the bounded 10k-history proof, and 20 + consecutive reconnect cycles. +- Electron/React is now the sole active desktop product graph. Flutter source, duplicate CI lanes, + build commands, and demo deployment were removed; the historical migration material is archived. +- The public demo builds from the same React renderer used by Electron and validates its `/demo/` + containment contract. +- Exact-head CI for `59f1926b3e800eaf692ebaefcd790655a814dc04` passed core, Android debug, + tooling, cluster, official OMP proof on Linux x64, Linux arm64, and macOS arm64, legacy bridge + continuity, and the aggregate verifier. +- The unsigned arm64 v0.1.31 candidate produced a 189 MB DMG and 184 MB ZIP, passed ZIP package + inspection with 956 ASAR entries, and bundled executable arm64 `t4-host` and `omp` binaries. + Its exact app bundle reports v0.1.31, launched with native macOS window chrome, connected to + Local OMP, and loaded the live 1,000-session inventory and host controls. +- The exact-head unsigned package completed a real disposable prompt, relaunched onto seven unique + transcript entries, restarted its packaged host without duplicating them, and exercised native + Terminal, Browser, and Files surfaces. Connection, session activity, and ownership now occupy + stable non-conflicting status slots. +- Remaining evidence is release-bound: protected signed artifacts, Linux package launch, Android + native smoke, physical Tailnet Safari proof, and the remaining native edge cases in the tracker. + No release or tag is authorized by this document. + +## Current state at planning time + +| Evidence | Current state | Sprint consequence | +|---|---|---| +| Public release | v0.1.28 is the latest GitHub release and contains Electron macOS/Linux, React/Capacitor Android, updater metadata, and checksums | Use it as installed-product rollback evidence, not as source to reset onto | +| Current main | Electron, React, Flutter, standalone `t4-host`, and the current OMP wire coexist | Keep current host/protocol work; remove only obsolete client duplication | +| Release workflow | Main still builds Electron macOS/Linux and React/Capacitor Android | Repair and strengthen this existing route rather than adopting PR #146 | +| Draft PR #146 | Deletes Electron and makes Flutter the desktop release | Close as superseded before the sprint PR becomes reviewable | +| PR #130 | Mixes a Flutter macOS launcher with a Flutter endpoint-precedence fix and a small host-contract edit | Do not merge wholesale; salvage only independently valid non-Flutter behavior | +| PR #145 | Focused remote-heartbeat host policy fix | May merge independently; rebase the sprint afterward | +| PR #144 | Site and deployment replacement touching release/site consistency | Coordinate its final site copy and release metadata with this sprint | +| Main CI | `465f0fa` was the last observed complete green main run; `4944284` was still running when this plan was written | Begin implementation only from an exact green main commit | +| Version tags | v0.1.29 and v0.1.30 exist only as local tags in this checkout, not public origin tags/releases | Prefer a fresh unambiguous stabilization version, currently expected to be v0.1.31 | + +The primary checkout contains unrelated host-contract edits and is not a sprint +workspace. Use a clean worktree and preserve those edits. + +## Product authority after the sprint + +```text +macOS / Linux + Electron shell + | + +-- React workspace renderer + +-- native window, browser, terminal, files, updates, secure storage + +-- local Unix WebSocket transport + | + v + t4-host ---> pinned OMP authority + +iPhone / iPad / Android / browser compatibility + React responsive client over a paired Tailnet host +``` + +The client never becomes runtime authority. `t4-host` continues to own the generic +host and wire boundary, while OMP remains authoritative for sessions, locks, +execution, credentials, tools, and durable transcript truth. + +## Sprint guardrails + +- Do not implement Swift during this sprint. +- Do not reset to v0.1.28 or discard host/protocol changes made after it. +- Do not merge PR #146 or delete Electron before installed Electron proof passes. +- Do not continue Flutter feature development except a security fix required for + an already distributed artifact. +- Do not port Flutter code line by line. Recover observable behavior and tests. +- Do not adopt an unmerged Flutter-only feature unless current main's product + contracts or `FEATURE_MATRIX.md` require it. +- Do not weaken local Unix-socket, credential, browser-profile, path-containment, + prompt-lease, or runtime-ownership boundaries to simplify recovery. +- Do not call daemon health a verified user experience without a real session + round trip in an installed app. +- Production release and tag creation remain a separate authorized boundary. + +## Delivery shape + +Use one long-running draft pull request for the coherent Electron recovery. Keep +the branch checkpointed with focused commits, but avoid multiple overlapping +client PRs. Flutter deletion belongs near the end of the same draft only after the +Electron gates pass on that exact branch. + +Focused independent changes such as PR #145 may merge first. Rebase the sprint +after each such merge and require fresh exact-head CI before final review. + +## Work packages + +### E0 - Freeze direction and establish a clean baseline + +1. Wait for exact current-main CI to complete successfully or diagnose it before + branching implementation work. +2. Close PR #146 as superseded by the Electron recovery direction. +3. Split, close, or supersede PR #130. Retain only a host or compatibility fix that + reproduces outside Flutter and passes its own focused test. +4. Record the disposition of PRs #144 and #145 and rebase after any merge. +5. Open the Electron stabilization draft from exact green `origin/main`. +6. Run the unmodified baseline with Node 24.13.1 and pnpm 11.10.0: + +```sh +mise exec node@24.13.1 -- pnpm install --frozen-lockfile +mise exec node@24.13.1 -- pnpm check +mise exec node@24.13.1 -- pnpm test +mise exec node@24.13.1 -- pnpm build:web +mise exec node@24.13.1 -- pnpm build:desktop +mise exec node@24.13.1 -- pnpm test:e2e +mise exec node@24.13.1 -- pnpm test:packaging +``` + +Gate E0: the exact baseline commit, tool versions, raw command results, cache state, +and failures are recorded under `.artifacts/electron-stabilization//`. +Failures become tracker rows; they are not hidden by changing the test first. + +### E1 - Turn the feature map into a parity ledger + +Audit current main, not every historical Flutter branch. For each capability: + +1. Identify the host/wire authority and required capability. +2. Identify current Electron/React implementation and tests. +3. Inspect Flutter only for a user-visible contract or failure case worth keeping. +4. Classify the row as verified, missing, stale claim, intentional compatibility + limitation, or obsolete Flutter-only behavior. +5. Add an exact automated and physical proof requirement. + +Start with `docs/ELECTRON_STABILIZATION_TRACKER.csv`. The highest-risk audit targets +are: + +- host selection, local discovery, pairing, reconnect, and profile precedence; +- tail-first transcript pagination, wheel/scroll anchoring, and encrypted cache; +- capability-derived slash commands, modes, models, reasoning, fast tier, and + prompt/operation gating; +- session create/rename/archive/restore/delete and stale revision handling; +- approvals, user questions, plans, confirmations, attention, and agent control; +- files, review, terminal, browser, working set, settings, usage, broker status, + diagnostics, and update lifecycle; +- wide desktop, narrow touch browser, and Android compatibility behavior. + +Gate E1: every launch-priority `FEATURE_MATRIX.md` row has a stable Electron gap ID, +current source evidence, status, and next proof. A roadmap row alone is not treated +as shipped behavior. + +### E2 - Restore product and documentation authority + +Make the repository describe one active product before deleting code: + +- `PRODUCT_BRIEF.md`, `README.md`, `PRODUCT.md`, `DESIGN.md`, and development docs + identify Electron/React as the primary client. +- Flutter is labeled frozen and pending removal until the final cleanup commit. +- Root `dev`, build, package, performance, and release commands point to Electron. +- The public site and release metadata describe the artifacts that are actually + built from the branch. +- The Android and browser paths are labeled compatibility clients with explicit + reduced/unavailable states rather than desktop parity. +- The Swift proposal is retained outside the active product contract as deferred + follow-up material. + +Gate E2: release-consistency mutation tests fail when any product surface, package +version, download record, command, or client identity drifts back to Flutter. + +### E3 - Stabilize Electron host and connection lifecycle + +Exercise and repair the complete local-first path: + +- discover the bundled `t4-host` and compatible OMP authority; +- install/start/inspect/restart/stop the per-user service safely; +- connect through the local Unix WebSocket without opening a public listener; +- preserve exact profile and root ownership; fail closed on conflicts; +- support paired remote hosts without silently replacing the selected host; +- reconnect after transport loss and host restart without duplicate commands; +- preserve prompt leases, revision checks, command correlation, and outcome-unknown + behavior; +- show actionable redacted diagnostics for missing, incompatible, starting, + unhealthy, and version-skewed runtimes; +- keep the long real-profile cold-start window distinct from a deadlock. + +Gate E3: focused desktop lifecycle tests, host integration tests, the legacy bridge +continuity gate, official OMP packaged proof, and a real local Mac session round +trip all pass from the same commit. + +### E4 - Converge the React workspace on current contracts + +Repair only validated gaps, in dependency order: + +1. Host/profile and project/session inventory. +2. Transcript open, paging, live stream, reconnect, cache, and scroll anchoring. +3. Composer, attachments, command/model/mode controls, dispatch, steering, and + cancellation. +4. Approval/question/plan/confirmation and attention settlement. +5. Agent view, activity, usage, and status truthfulness. +6. Files, turn review, artifacts, transcript search, and export. +7. Universal Working Set and the shared action/Quick Open registry. + +Use existing host-wire fixtures and production commands. Do not reimplement a +Flutter decoder or introduce a second client protocol. + +Gate E4: focused unit/integration tests and Playwright exercise loading, empty, +offline, observer, read-only, stale, streaming, completed, cancelled, failed, and +reconnected states. A 10k-history run remains bounded and does not stall the +composer or session switch. + +### E5 - Prove native desktop surfaces + +Treat the Electron main process and native surfaces as product work, not packaging +plumbing: + +- Browser workspace: trusted profiles, navigation, capture, input, downloads, + uploads, permission dialogs, handoff, crashes, and bounded context capture. +- Terminal: PTY lifecycle, tabs, resize, backpressure, paste guard, reconnect, and + exact session authority. +- Files and review: native pickers, remote-safe paths, save conflicts, diff/comment + actions, confirmations, and artifact reads. +- Application shell: traffic lights, titlebar, menus, shortcuts, multi-window + focus, deep links, notifications, accessibility, and state restoration. +- Security: context isolation, narrow preload/IPC, origin checks, navigation and + popup policy, no wildcard `postMessage`, redaction, and no credential exposure. +- Updates: signed metadata, download/apply/restart, rollback, and failure recovery. + +Gate E5: unit tests plus a real macOS Electron window verify OS-drawn chrome and +native interactions. Browser-only screenshots are insufficient for these checks. + +### E6 - Stabilize compatibility clients + +Keep the shared React client usable without expanding mobile scope: + +- Tailnet-only Safari/PWA on iPhone and iPad; +- React/Capacitor Android APK with secure credentials and signed-update checks; +- paired-host selection takes precedence over unavailable local runtime state; +- touch controls remain reachable at 320, 360, and 390 pixel widths; +- file/image pickers, safe areas, resume/reconnect, model selection, prompt round + trip, and shared transcript convergence work on physical or native clients; +- Funnel remains disabled. + +Gate E6: Android debug CI, signed APK inspection in the protected release lane, +physical/native Android smoke, and Tailnet Safari touch proof pass. Desktop-only +features show an explicit unavailable reason. + +### E7 - Remove Flutter and simplify the repository + +Only after E3 through E6 pass on the branch: + +- remove `apps/flutter` and the Dart/Flutter lockfiles, packages, generated native + runners, tests, coverage tooling, and build scripts; +- remove Flutter CI classifiers and the `flutter`, `flutter-android`, and + `flutter-apple` jobs; +- remove Flutter packaging, demo, site, release, and maintainer paths; remove live + Flutter provenance requirements while preserving historical provenance records; +- retire `docs/FLUTTER_MIGRATION_GOAL.md` and `docs/FLUTTER_STAGE1_PROOF.md` while + preserving useful behavioral contracts in the parity tracker and Git history; +- remove Flutter-specific product copy and compatibility assertions; +- keep the React/Capacitor Android path and public site; +- ensure no active source imports or invokes deleted Flutter artifacts. + +Gate E7: an active-tree scan, release consistency, provenance, CI classification, +documentation commands, and package manager checks contain no live Flutter product +reference. Historical changelogs and provenance may retain clearly historical +references. + +### E8 - Make Electron release proof required in CI + +The final required check set should include: + +- core lint, typecheck, all non-Flutter tests, production builds, Playwright, and + packaging contracts; +- T4/OMP bridge continuity and official packaged OMP gates; +- tooling, provenance, release-consistency, and security mutation tests; +- Android compatibility debug build; +- Linux Electron package build and inspection; +- macOS Electron unsigned package/smoke on pull requests when desktop, packaging, + runtime, or release paths change; +- the existing cluster lanes only where path classification selects them; +- a fail-closed aggregate `verify` check over every selected lane. + +Gate E8: fresh exact-head CI passes after the Flutter deletion commit. No required +check may be green merely because its deleted Flutter job was skipped. + +### E9 - Installed release candidate and stabilization + +Prepare a fresh release version, expected to be v0.1.31 unless the public version +sequence changes before cutover. Do not reuse ambiguous local-only v0.1.29 or +v0.1.30 tags. + +Run the complete release contract: + +1. Build and install the signed/notarized macOS DMG and ZIP. +2. Download the DMG through a browser, copy the app to Applications, and launch it + under Gatekeeper without clearing quarantine. +3. Prove local discovery, session inventory, transcript, prompt, stream, + cancellation, reconnect, restart recovery, browser, terminal, files/review, and + update behavior from the installed app. +4. Prove a large real profile separately from a disposable lifecycle profile. +5. Build and inspect Linux `.deb`, AppImage, and updater metadata. +6. Build and inspect the signed Android compatibility APK. +7. Prove two-client convergence between installed Electron and a Tailnet touch + client. +8. Verify the exact public artifact set, checksums, immutable source commit, + compatibility matrix, site metadata, and download links. +9. Retain the v0.1.28 public artifacts and the immediately previous known-good + runtime pair as rollback. + +Gate E9: Wolfgang explicitly authorizes production release/tag publication after +reviewing the installed-artifact evidence. Merge readiness alone is not release +authorization. + +## Critical path and parallel lanes + +```text +E0 baseline and PR disposition + | + v +E1 parity ledger -----> E2 product authority + | + +-------> E3 host/lifecycle --------+ + +-------> E4 React workspace -------+--> E7 Flutter removal + +-------> E5 native surfaces -------+ | + +-------> E6 compatibility clients -+ v + E8 exact-head CI + | + v + E9 release candidate +``` + +E3 through E6 can proceed independently once E1 fixes their contracts. E7 is the +convergence point; no lane deletes shared Flutter material early. + +## Definition of stable + +The sprint is complete only when all of the following are true: + +- Electron/React is the sole primary product client in source, docs, CI, packages, + release metadata, and the public site. +- Every launch-priority capability is verified or carries an explicit accepted + limitation with a user-visible reason. +- The exact branch passes focused and full tests, builds, packaging checks, OMP + continuity, official packaged-runtime proof, performance/soak, and exact-head CI. +- A signed installed Mac app completes a real user session round trip and native + browser/terminal/file workflows. +- Linux packages and Android compatibility artifacts pass their native inspection + and smoke paths. +- Tailnet-only iPhone/iPad access and multi-client convergence are verified. +- Flutter is absent from the active product tree and required CI. +- No credential, local identity path, private host detail, or secret-like value is + present in diffs or evidence. +- The release has an exact rollback pair and does not depend on local unpushed tags. +- Swift work remains deferred and has not complicated the stabilization branch. + +## Sprint artifacts + +- `docs/ELECTRON_STABILIZATION_TRACKER.csv`: live capability and proof ledger. +- `.artifacts/electron-stabilization//baseline.json`: exact tools and source. +- `.artifacts/electron-stabilization//test-results/`: raw bounded results. +- `.artifacts/electron-stabilization//installed-macos/`: package and UI proof. +- `.artifacts/electron-stabilization//compatibility/`: Tailnet and Android proof. +- Updated PR description: scope, completed gates, exact commands, unresolved risks, + and release boundary. diff --git a/docs/ELECTRON_STABILIZATION_TRACKER.csv b/docs/ELECTRON_STABILIZATION_TRACKER.csv new file mode 100644 index 00000000..edbd4549 --- /dev/null +++ b/docs/ELECTRON_STABILIZATION_TRACKER.csv @@ -0,0 +1,29 @@ +"ID","Area","Acceptance contract","Current evidence","Priority","Status","Next proof" +"EL-AUTH-001","Product authority","Electron and React are the sole primary product clients across source docs CI release metadata and site","Electron React and Capacitor are the only active client graph; release consistency demo contracts and exact-head CI pass","P0","verified","Keep the aggregate CI guard required" +"EL-PR-001","Conflicting work","PR 146 is closed as superseded and cannot delete Electron","PR 146 closed as superseded by the Electron standardization sprint","P0","verified","None" +"EL-PR-002","Mixed Flutter fix","PR 130 is split closed or superseded without losing a proven non-Flutter contract","PR 130 closed after audit found its changes coupled to the abandoned Flutter path","P0","verified","None" +"EL-RUN-001","Local host lifecycle","Installed Electron safely discovers installs starts inspects restarts and stops bundled t4-host","Exact-head unsigned v0.1.31 launched its packaged t4-host and bundled OMP from isolated app data loaded the live 1000-session inventory completed a real disposable prompt and restarted the packaged host","P0","native-verified","Exercise explicit packaged stop start and inspection once more before release" +"EL-RUN-002","OMP authority","Electron works with the exact released T4 host and pinned OMP authority without assuming client truth","Sprint exact-head legacy continuity and official packaged OMP gates pass on Linux x64 Linux arm64 and macOS arm64","P0","verified","Keep the authority gates required" +"EL-CONN-001","Host precedence","An explicitly paired host is not replaced by unavailable local runtime state","Current React and Electron behavior requires audit; PR 130 proves only Flutter changes","P0","audit-needed","Add focused React or desktop reproduction" +"EL-CONN-002","Reconnect","Transport loss and host restart resume once without duplicate commands or transcript entries","Exact packaged host restart recovered the 1000-session inventory and retained seven unique transcript entries with no duplicate IDs; duplicate public connecting notifications are now coalesced at both desktop boundaries","P0","native-verified","Repeat the packaged restart after exact-head CI" +"EL-PROT-001","Wire conformance","The production TypeScript client consumes canonical host-wire fixtures and rejects invalid frames","Host-wire protocol client fixture and mutation suites pass on the standardized graph","P0","verified","Require exact-head CI" +"EL-SESS-001","Session inventory","Create attach rename archive restore terminate and delete converge across two clients with stale revision refusal","React runtime and release gate describe these flows","P0","source-evidence","Playwright plus two-client real host proof" +"EL-TRANS-001","Transcript tail","Cold open paints a bounded newest page then prepends history without moving reading anchor or live cursor","React paging tests and the bounded 10k-history E2E proof pass","P0","automated-verified","Confirm wheel anchor in installed Electron window" +"EL-TRANS-002","Transcript cache","Electron uses a bounded encrypted display-only cache and never authorizes writes from cache","Projection cache and Electron secure storage require joint audit","P0","audit-needed","Corruption stale and offline cache tests" +"EL-COMP-001","Composer","Draft attachments models reasoning slash commands modes prompt leases steering follow-ups and cancellation obey negotiated capabilities","Exact packaged Electron sent a real disposable prompt showed Connected and Working streamed the reply and settled to Connected and Idle after relaunch","P0","native-verified","Cancel a long packaged turn before it completes and verify the final outcome" +"EL-ATTN-001","Attention","Approvals questions plans confirmations and outcomes settle against current indexed authority","A real cancel confirmation that outlived its completed turn now retires on revision change; the native header returned from Pending approval to Idle and the regression suite passes","P0","native-verified","Exercise question and plan settlement in the packaged app" +"EL-AGENT-001","Agent operations","Agent hierarchy progress selection cancellation and attention remain bounded and authoritative","Agent view and host projections exist","P1","source-evidence","10k hierarchy and live cancel proof" +"EL-FILE-001","Files","File search preview and writes enforce remote-safe paths revisions confirmations and conflict handling","Native Electron searched and previewed a disposable file completed one confirmation-gated write and refused a stale-base write while preserving the draft","P0","native-verified","Add packaged traversal refusal and remote-host proof" +"EL-REVIEW-001","Review","Turn diffs artifacts comments and keep or discard actions are lazy bounded and confirmation-gated","Turn review and artifact code exists","P1","source-evidence","Binary missing huge stale and applied proof" +"EL-TERM-001","Terminal","Native PTY tabs handle input resize paste guard backpressure exit restart and reconnect","Native Electron created a PTY accepted typed and pasted input resized rendered output exited cleanly and restarted without renderer errors","P0","native-verified","Exercise backpressure and reconnect with an active packaged PTY" +"EL-BROW-001","Browser workspace","Native browser profiles navigation capture input download upload permission crash and context boundaries remain isolated","Native Electron proved isolated navigation form input a completed tracked download prior-page recovery and geolocation denial; download events are now owner-scoped","P0","native-verified","Exercise upload capture handoff and renderer crash recovery" +"EL-WORK-001","Working set","Files transcript review terminal selection and browser accessibility capture share one bounded reviewed prompt tray","ADR 018 and React actions exist","P1","source-evidence","Cross-source settlement and redaction proof" +"EL-SET-001","Settings and diagnostics","Typed settings usage broker provider and diagnostics surfaces never project secrets or unrecognized metadata","React settings and host contracts exist","P1","audit-needed","Capability schema and redacted export proof" +"EL-UPD-001","Desktop updates","Signed metadata download apply restart failure and rollback work from packaged Electron","Electron updater and release inspection code exist","P0","source-evidence","Installed signed update proof" +"EL-MAC-001","macOS package","DMG and ZIP are Developer ID signed notarized stapled Gatekeeper accepted and bundle exact t4-host","Exact-head unsigned v0.1.31 produced a 189 MB DMG and 184 MB ZIP; both inspectors found 956 ASAR entries and arm64 t4-host plus OMP; the packaged app completed a real prompt and relaunch","P0","unsigned-native-verified","Build inspect notarize staple and launch signed artifacts on the protected runner" +"EL-LINUX-001","Linux package","Deb AppImage and latest-linux metadata are internally consistent and launch installed Electron","Release workflow and inspectors exist","P1","source-evidence","Native Linux package build inspect and launch" +"EL-MOBILE-001","Android compatibility","React Capacitor APK preserves secure credentials paired host touch reachability and signed update checks","Android debug and release lanes exist","P1","audit-needed","Physical or native smoke and signed artifact inspection" +"EL-MOBILE-002","Tailnet touch client","iPhone and iPad browser or PWA reconnect and converge through Tailscale Serve with Funnel off","Release gate and prior dogfood path exist","P1","external-proof","Physical Safari round trip at narrow widths" +"EL-CI-001","Required checks","Exact-head verify fails closed over every selected Electron host tooling packaging Android and platform lane","Exact sprint head passed core Android tooling cluster three-platform official OMP legacy continuity and aggregate verify jobs","P0","verified","Keep exact-head verification required" +"EL-REMOVE-001","Flutter removal","No live Dart Flutter client build CI docs demo release or provenance path remains","Flutter client and dedicated scripts removed; migration documents archived; source scan and exact-head CI pass","P0","verified","None" +"EL-REL-001","Release identity","A fresh unambiguous version has exact source runtime artifacts checksums site metadata and rollback","Source contracts align to v0.1.31 and the unsigned Mac DMG ZIP and app bundle passed local package and native launch proof; no tag or release exists","P0","candidate-built","Complete exact-head CI and request release authorization" diff --git a/docs/GITHUB_OPERATIONS.md b/docs/GITHUB_OPERATIONS.md new file mode 100644 index 00000000..263c64c1 --- /dev/null +++ b/docs/GITHUB_OPERATIONS.md @@ -0,0 +1,98 @@ +# GitHub operations + +This document is the operating contract for `wolfiesch/omperator`. Repository +settings should match it; changes to the contract land through pull requests. + +## Repository model + +- `main` is the only integration and release branch. +- `origin` is `wolfiesch/omperator` and is authoritative for product work. +- `upstream` is the original `LycaonLLC/t4-code` repository and is fetch-only in + the canonical local checkout. Upstream changes are selected deliberately and + imported through ordinary reviewed pull requests. There is no automatic sync. +- `can1357/oh-my-pi` remains the upstream runtime project. OMP runtime updates + follow the pinned compatibility and provenance contracts already in this repo. + +## Access levels + +Use the least privilege that lets someone do their job: + +| Role | Intended use | +| --- | --- | +| Admin | Product owner only; access, security, signing, and repository settings | +| Maintain | Core collaborator who can triage, review, merge, and manage ordinary repository work | +| Write | Regular implementation collaborator who works through pull requests | +| Triage | Issue and pull-request management without code write access | + +New core collaborators should normally start with **Write**. Promote to +**Maintain** after the merge/release workflow has been exercised together. Do +not grant Admin for ordinary development. + +## Protected `main` + +The GitHub protection rule for `main` should enforce: + +- pull requests for non-admin collaborators; +- one approval from someone other than the author, with stale approvals + dismissed after new commits; +- CODEOWNER review requests for protected product, release, security, and + provenance paths, without making CODEOWNER approval a branch-protection gate; +- the aggregate `verify` CI check; +- resolved review conversations; +- no force pushes or branch deletion. + +The product owner retains the admin bypass for releases and emergency recovery. +Routine owner changes still use pull requests and independent maintainer review +so CI and review history remain visible. A product-owner-authored change to a +CODEOWNERS path therefore needs another maintainer's approval, not an admin +bypass or an impossible self-approval. + +## Issues, proposals, and labels + +Bug reports, feature requests, and questions use structured issue forms. Product +or architecture decisions receive the `proposal` label and end with a written +decision before implementation diverges. + +`.github/labels.json` is the label source of truth. The `Sync repository labels` +workflow applies it after changes reach `main`. Labels are intentionally compact: +type, priority, platform, and product area. Do not reproduce OMP's provider-scale +taxonomy unless Omperator actually develops that breadth. + +OMP's vouch gate is intentionally not copied. It is useful for a mature project +with sustained untrusted pull-request volume. Omperator should add such a gate +only after ordinary review and moderation become a repeated burden. + +## Dependencies + +Dependabot checks the pnpm workspace, Go cluster operator, and pinned GitHub +Actions. Scheduled version-update pull requests are temporarily disabled with +`open-pull-requests-limit: 0`; GitHub's separate security-update allowance stays +active. This prevents broad mechanical upgrades from violating toolchain, +release-consistency, or minimum-package-age contracts while the inherited +dependency baseline is repaired deliberately. + +Security updates must pass the same `verify` gate as product changes. Re-enable +scheduled version updates one ecosystem at a time only after its current update +set passes locally, and keep major upgrades in individual pull requests. + +## CI and merges + +The `verify` job is the stable required check. It aggregates core, OMP +continuity, official OMP platform, cluster, tooling, and Android lanes while +allowing path-irrelevant lanes to skip. Do not require every matrix job directly; +that would make path-based CI brittle. + +Enable automatic head-branch updates and delete merged branches. Prefer squash +merges for ordinary feature work; use a merge commit only when preserving a +multi-commit import or provenance boundary materially helps future auditing. + +## Releases + +Release tags are immutable and must point to a commit reachable from `main` with +successful exact-SHA CI. The release workflow owns packaging and publication. +macOS notarization, Android signing, updater metadata, deployment credentials, +and GitHub environments remain owner-controlled. + +Until Omperator's application identifiers and artifacts are rebranded, do not +publish a new stable tag from this repository. Existing T4 release tags and +artifacts remain historical upstream records rather than Omperator releases. diff --git a/docs/MACOS_SIGNING.md b/docs/MACOS_SIGNING.md index 6188e84d..33e3b48a 100644 --- a/docs/MACOS_SIGNING.md +++ b/docs/MACOS_SIGNING.md @@ -42,29 +42,9 @@ Secrets are available only to the protected release job. Pull requests and ordin ## Local commands -### Flutter migration development +### Electron application -Flutter macOS `Debug` builds deliberately omit Keychain Sharing so contributors can build and run -the app without joining the Apple Developer team: - -```bash -pnpm dev:flutter -- -d macos -``` - -These builds use a process-local credential store and show an **Unsigned development** strip. -Saved host metadata remains available, but paired device credentials are never written to disk and -must be paired again after the app exits. `Profile` and `Release` use separate entitlements that -retain Keychain Sharing and therefore require Apple development signing. Never add a plaintext -fallback, remove the warning, or remove Keychain Sharing from the signed configurations. - -Signed Flutter milestone artifacts must be produced by a protected maintainer workflow or signing -machine. Contributors do not need the certificate, provisioning profile, or Apple team membership -to run those artifacts. - -### Released Electron application - - -Contributors can build the released Electron application without Apple credentials: +Contributors can build the Electron application without Apple credentials: ```bash pnpm package:mac:unsigned diff --git a/docs/OMP_BRIDGE.md b/docs/OMP_BRIDGE.md index b59eb481..4d1ab550 100644 --- a/docs/OMP_BRIDGE.md +++ b/docs/OMP_BRIDGE.md @@ -52,10 +52,10 @@ The simplified rollout does not weaken the hard boundaries. We retain strict pro ## Released product state -T4 v0.1.30 is paired with immutable OMP tag `t4code-17.0.5-appserver-10` at commit `8476f4451ed95c5d5401785d279a93d3c659fac4`. Its published Apple Silicon binary is 120,893,008 bytes with SHA-256 `f7d4438e163aabc4ca624468bf5bd4a243b5e1ab5360e84cef52abe93683ea55`. +T4 v0.1.31 is paired with immutable OMP tag `t4code-17.0.5-appserver-13` at commit `fc0c391334c08ab260057756aa84bd2b07741ee7`. Its published Apple Silicon binary is 120,893,008 bytes with SHA-256 `60ce5193eadc2884a4313289ff1166b6a1daae97f3a3ec66dc260ea5c5713756`. The standalone OMP release carries an ad-hoc integrity signature because the fork release workflow does not have a Developer ID identity. The protected T4 product build remains the distribution-signing boundary: it must sign both the bundled OMP executable and `t4-host` with T4's Developer ID identity before shipping the macOS app. -That bridge release moves the running network host into the standalone T4 executable and removes OMP's public legacy launchers. The thin bridge and standalone host pass a compiled-binary end-to-end smoke test. The compatibility matrix records `appserver-10` as both the verified and published pairing for the T4 product build. +That bridge release moves the running network host into the standalone T4 executable and removes OMP's public legacy launchers. The thin bridge and standalone host pass a compiled-binary end-to-end smoke test. The compatibility matrix records `appserver-13` as both the verified and published pairing for the T4 product build. -This reduces the fork to the OMP-specific authority adapter and protocol glue, but does not remove the fork entirely. T4 still pins the exact Lycaon OMP source and binary because the bridge is not part of ordinary upstream OMP. +This reduces the fork to the OMP-specific authority adapter and protocol glue, but does not remove the fork entirely. Active runtime tags, releases, and automation are pinned to `wolfiesch/oh-my-pi`. The earlier `lyc-aon/oh-my-pi` repository remains only as frozen provenance for the app-wire package and host-source migration recorded in `provenance/omp-host-migration.json`. diff --git a/docs/OWNERSHIP.md b/docs/OWNERSHIP.md index c15207f5..b5f1dfa7 100644 --- a/docs/OWNERSHIP.md +++ b/docs/OWNERSHIP.md @@ -8,13 +8,13 @@ integration owner or land the smaller shared contract first. | Path | Primary owner | | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | -| `apps/flutter/**` | Flutter client and provider owner | | `packages/host-wire/**`, network-frame changes in `packages/protocol/**` | Protocol owner | | `packages/host-service/**`, `packages/host-daemon/**` | T4 Local systems owner | | `packages/client/**`, `packages/fixture-server/**` | Client data and fixtures owner | | `packages/remote/**`, `packages/service-manager/**` | Pairing, remote connection, and native service-lifecycle owner | -| `apps/web/**`, `packages/ui/**`, visible copy/assets/screenshots | Compatibility client experience owner | -| `apps/desktop/**` | Compatibility desktop systems owner; coordinate visible UI changes with the client owner | +| `apps/web/**`, `packages/ui/**`, visible copy/assets/screenshots | Canonical React client experience owner | +| `apps/desktop/**` | Primary Electron desktop systems owner; coordinate visible UI changes with the client owner | +| `apps/mobile/**` | React/Capacitor Android compatibility owner | | Root manifests, workspace configuration, and `pnpm-lock.yaml` | Integration owner | | `docs/adr/**`, architecture, licenses, notices, and provenance | Architecture/provenance owner | diff --git a/docs/RELEASE_GATE.md b/docs/RELEASE_GATE.md index 298d961f..ca783ed3 100644 --- a/docs/RELEASE_GATE.md +++ b/docs/RELEASE_GATE.md @@ -24,15 +24,15 @@ Every release must pass the layers below. Destructive lifecycle checks use a dis ## OMP bridge continuity proof -Run the deterministic compatibility gate from the T4 repository root with Node 24.13.1 and the pinned Lycaon OMP source: +Run the deterministic historical compatibility gate from the T4 repository root with Node 24.13.1 and the pinned migration-era OMP source: ```sh T4_OMP_SOURCE_DIR=/path/to/lycaon-oh-my-pi pnpm test:legacy-bridge-continuity ``` -The gate builds and launches T4's standalone `t4-host`, connects it to `omp bridge --stdio` from the pinned authority source, and starts a real OMP TUI plus multiple production T4 clients. Its historical command name still says `legacy-bridge`. The gate proves client compatibility across bounded transcript loading, live ownership refusal, concurrent profile isolation, reconnect after an in-flight transport loss, host restart recovery, transcript search/read-around, stale-revision rejection, recovered control, and cleanup. +The gate builds and launches T4's standalone `t4-host`, connects it to the migration-era authority source, and starts a real OMP TUI plus multiple production T4 clients. Its historical command name still says `legacy-bridge`. The gate proves that the host migration did not regress bounded transcript loading, live ownership refusal, concurrent profile isolation, reconnect after an in-flight transport loss, host restart recovery, transcript search/read-around, stale-revision rejection, recovered control, and cleanup. It does not prove compatibility with the currently published OMP release pair. -CI resolves the exact OMP authority commit from `provenance/omp-host-migration.json`, checks out the exact T4 pull-request head, runs this gate, and attaches the evidence directory to that commit's check run. +CI retains that historical gate by resolving its exact authority commit from `provenance/omp-host-migration.json`. A separate required `current-bridge-continuity` job resolves both repository and commit from `compat/omp-app-matrix.json#verifiedRuntime`, builds the current OMP native addon, runs the focused authority tests, and launches the exact current OMP source through T4's `t4-omp-authority/1` bridge client. The aggregate `verify` check requires both proof boundaries. Each job checks out the exact T4 pull-request head and attaches evidence to that commit's check run. Each successful run writes machine-readable evidence under the historical `artifacts/legacy-bridge-continuity//` path: `report.json`, sanitized `wire-events.ndjson`, `failure-matrix.json`, `cleanup-status.json`, and an executable `rollback.sh`. The report names the host implementation, records both source commits and dirty-state fingerprints, and captures bounded snapshot sizes, failure codes, delivered cursor integrity, profile overlap, restart persistence, search/context results, and cleanup state. These artifacts are local and ignored by Git. diff --git a/docs/adr/014-transcript-tail-pagination.md b/docs/adr/014-transcript-tail-pagination.md index e94e1233..1cf8fec4 100644 --- a/docs/adr/014-transcript-tail-pagination.md +++ b/docs/adr/014-transcript-tail-pagination.md @@ -55,9 +55,10 @@ continue to append normally. - A stale paging generation is an explicit retryable history error; it does not disturb the live transcript. -## Flutter handoff +## Offline compatibility-client follow-up -Flutter should use the same two-lane contract rather than copy the web implementation: +Any future offline cache should use the same two-lane contract rather than copy the React +implementation: ```text open screen diff --git a/docs/adr/019-official-omp-exclusive-profile.md b/docs/adr/019-official-omp-exclusive-profile.md index ea914ef4..abbd6d37 100644 --- a/docs/adr/019-official-omp-exclusive-profile.md +++ b/docs/adr/019-official-omp-exclusive-profile.md @@ -1,7 +1,7 @@ # ADR-019: Official OMP cutover uses a T4-exclusive profile boundary - Status: accepted for opt-in proof; not yet the released default. -- Context: official OMP 17.0.6 exposes the required per-session RPC behavior but not the Lycaon fork's host-wide authority bridge, ready transcript watermark, live durable-entry frames, or cross-process session lock. Making the fork the permanent product center would preserve avoidable coupling; treating arbitrary lockless OMP sessions as writable would be unsafe. +- Context: official OMP 17.0.9 exposes the required per-session RPC behavior but not the Lycaon fork's host-wide authority bridge, ready transcript watermark, live durable-entry frames, or cross-process session lock. Making the fork the permanent product center would preserve avoidable coupling; treating arbitrary lockless OMP sessions as writable would be unsafe. - Decision: T4 may run official OMP through `t4-host` only when the caller supplies an explicit absolute sessions root that is exclusively owned by that T4 profile. T4 owns discovery and lifecycle metadata for that root. Official OMP remains authoritative for each session's JSONL, agent execution, configuration, models, credentials, tools, and events. - Transcript reconciliation: the shared supervisor streams complete JSONL records, synthesizes the current durable watermark, ignores crash-truncated final records, deduplicates optional fork live frames, and correlates a user entry only when it exactly matches the oldest pending prompt-like command. Ambiguous dispatch remains outcome-unknown and is never automatically replayed. - Lock boundary: ordinary missing-lock OMP sessions remain view-only. The official profile authority opts into claiming lockless sessions only because the whole configured root is exclusive to T4; it does not describe a missing lock as stale or weaken the final ownership check. diff --git a/docs/FLUTTER_MIGRATION_GOAL.md b/docs/archive/flutter-migration/FLUTTER_MIGRATION_GOAL.md similarity index 99% rename from docs/FLUTTER_MIGRATION_GOAL.md rename to docs/archive/flutter-migration/FLUTTER_MIGRATION_GOAL.md index a4e56d49..d321aec9 100644 --- a/docs/FLUTTER_MIGRATION_GOAL.md +++ b/docs/archive/flutter-migration/FLUTTER_MIGRATION_GOAL.md @@ -1,4 +1,6 @@ -# T4 Flutter Rewrite Goal Prompt +# T4 Flutter Rewrite Goal Prompt (archived) + +> Historical record only. This migration was abandoned during the Electron standardization sprint. Use this prompt to start or resume the T4 Flutter rewrite in OMP goal mode. diff --git a/docs/FLUTTER_STAGE1_PROOF.md b/docs/archive/flutter-migration/FLUTTER_STAGE1_PROOF.md similarity index 95% rename from docs/FLUTTER_STAGE1_PROOF.md rename to docs/archive/flutter-migration/FLUTTER_STAGE1_PROOF.md index c945c0d3..cc134504 100644 --- a/docs/FLUTTER_STAGE1_PROOF.md +++ b/docs/archive/flutter-migration/FLUTTER_STAGE1_PROOF.md @@ -1,4 +1,6 @@ -# Flutter Stage 1 Proof Contract +# Flutter Stage 1 Proof Contract (archived) + +> Historical record only. This migration was abandoned during the Electron standardization sprint. ## Scope diff --git a/docs/OMP_T4_CAPABILITY_AUDIT.md b/docs/archive/flutter-migration/OMP_T4_CAPABILITY_AUDIT.md similarity index 99% rename from docs/OMP_T4_CAPABILITY_AUDIT.md rename to docs/archive/flutter-migration/OMP_T4_CAPABILITY_AUDIT.md index 7e7b52ae..9d8afdec 100644 --- a/docs/OMP_T4_CAPABILITY_AUDIT.md +++ b/docs/archive/flutter-migration/OMP_T4_CAPABILITY_AUDIT.md @@ -1,4 +1,7 @@ -# OMP to T4 Capability Audit +# OMP to T4 Capability Audit (archived Flutter-era snapshot) + +> Historical record only. Current Electron evidence is tracked in +> `docs/ELECTRON_STABILIZATION_TRACKER.csv`. **Audit date:** 2026-07-20, refreshed 2026-07-21 after T4 PRs #109, #111, #113, and #114 diff --git a/docs/OMP_T4_CAPABILITY_TRACKER.csv b/docs/archive/flutter-migration/OMP_T4_CAPABILITY_TRACKER.csv similarity index 100% rename from docs/OMP_T4_CAPABILITY_TRACKER.csv rename to docs/archive/flutter-migration/OMP_T4_CAPABILITY_TRACKER.csv diff --git a/docs/archive/flutter-migration/README.md b/docs/archive/flutter-migration/README.md new file mode 100644 index 00000000..738d69dd --- /dev/null +++ b/docs/archive/flutter-migration/README.md @@ -0,0 +1,8 @@ +# Archived Flutter migration material + +These files preserve the abandoned 2026 Flutter migration's goals, proof contract, and capability +audit for historical reference. They do not describe the active T4 product or release path. + +T4 is standardized on the Electron desktop shell and canonical React renderer. The responsive +React browser/PWA and React/Capacitor Android builds are compatibility clients. Native Swift work +is deferred until the Electron line is stable. diff --git a/e2e/cluster-operator.spec.ts b/e2e/cluster-operator.spec.ts index 793a5bd3..1d042b02 100644 --- a/e2e/cluster-operator.spec.ts +++ b/e2e/cluster-operator.spec.ts @@ -210,7 +210,7 @@ class OperatorWireFixture { selectedProtocol: "omp-app/1", hostId: HOST, ompVersion: "17.0.5", - ompBuild: "8476f4451ed95c5d5401785d279a93d3c659fac4", + ompBuild: "fc0c391334c08ab260057756aa84bd2b07741ee7", appserverVersion: "cluster-fixture", appserverBuild: "redacted", epoch: "cluster-epoch-1", diff --git a/e2e/remote-app.spec.ts b/e2e/remote-app.spec.ts index b2d3b319..78b560e5 100644 --- a/e2e/remote-app.spec.ts +++ b/e2e/remote-app.spec.ts @@ -633,7 +633,7 @@ test("@soak recovers one live phone session across 20 network drops", async ({ p for (let cycle = 1; cycle <= 20; cycle += 1) { const before = await fixture.state(); await fixture.disconnectClients(); - await expect(page.getByText("Offline", { exact: true }).first()).toBeVisible(); + await expect(page.getByLabel("Reconnecting", { exact: true })).toBeVisible(); await page.clock.fastForward(10_000); await expect .poll(async () => (await fixture.state()).connections, { @@ -646,7 +646,7 @@ test("@soak recovers one live phone session across 20 network drops", async ({ p transcript.getByText("Hello world", { exact: true }).filter({ visible: true }), ).toHaveCount(1); await expect(composer).toBeEnabled(); - await expect(page.getByText("Offline", { exact: true })).toHaveCount(0); + await expect(page.getByLabel("Reconnecting", { exact: true })).toHaveCount(0); } }); @@ -1444,7 +1444,9 @@ test("manages a session from a phone and converges another live client", async ( expect(archivedFilterBox).not.toBeNull(); expect(archivedFilterBox!.height).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET_PX); - await expect(observer.getByText(/Archived · read-only/u).first()).toBeVisible(); + await expect( + observer.getByLabel("Archived · read-only", { exact: true }), + ).toBeVisible(); await expect(observer.getByRole("textbox", { name: "Message the session" })).toHaveCount(0); await expect(observer.getByRole("button", { name: "Run options", exact: true })).toHaveCount(0); await expect( @@ -1472,7 +1474,7 @@ test("manages a session from a phone and converges another live client", async ( }), ).toBeVisible(); await expect(page.getByRole("textbox", { name: "Message the session" })).toBeEnabled(); - await expect(page.getByText(/Archived · read-only/u)).toHaveCount(0); + await expect(page.getByLabel("Archived · read-only", { exact: true })).toHaveCount(0); await page.getByRole("button", { name: "Show session list", exact: true }).click(); await expect(rail).toBeVisible(); @@ -1503,7 +1505,7 @@ test("manages a session from a phone and converges another live client", async ( await rail.getByRole("button", { name: "Archived · 1", exact: true }).click(); await expect(rail).toBeHidden(); - await expect(page.getByText(/Archived · read-only/u).first()).toBeVisible(); + await expect(page.getByLabel("Archived · read-only", { exact: true })).toBeVisible(); await expect(page.getByRole("textbox", { name: "Message the session" })).toHaveCount(0); await page.getByRole("button", { name: "Show session list", exact: true }).click(); @@ -1622,7 +1624,7 @@ test("manages a session from a phone and converges another live client", async ( const route = decodeURIComponent(url.hash.replace(/^#\/sessions\//u, "")); return route === createdViewId; }); - await expect(page.getByText(/Archived · read-only/u).first()).toBeVisible(); + await expect(page.getByLabel("Archived · read-only", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Show session list", exact: true }).click(); await expect(reloadedRail).toBeVisible(); diff --git a/e2e/site-mobile-docs.spec.ts b/e2e/site-mobile-docs.spec.ts index da4544f4..7ad34b78 100644 --- a/e2e/site-mobile-docs.spec.ts +++ b/e2e/site-mobile-docs.spec.ts @@ -130,7 +130,7 @@ test("offers the Android APK without hiding desktop downloads", async ({ page }) await expect(androidDownload).toBeVisible(); await expect(androidDownload).toHaveAttribute( "href", - "https://github.com/LycaonLLC/t4-code/releases/download/v0.1.30/T4-Code-0.1.30-android.apk", + "https://github.com/LycaonLLC/t4-code/releases/download/v0.1.31/T4-Code-0.1.31-android.apk", ); await expect(page.getByRole("link", { name: /Linux/u }).first()).toBeVisible(); await expect(page.getByRole("link", { name: /macOS/u }).first()).toBeVisible(); diff --git a/infra/site/cloudformation.yml b/infra/site/cloudformation.yml index 94f6240d..2c9a8ce6 100644 --- a/infra/site/cloudformation.yml +++ b/infra/site/cloudformation.yml @@ -100,7 +100,7 @@ Resources: Properties: ResponseHeadersPolicyConfig: Name: t4-code-demo-security - Comment: Security headers for the Flutter web demo + Comment: Security headers for the read-only React web demo CustomHeadersConfig: Items: - Header: Cross-Origin-Opener-Policy @@ -111,7 +111,7 @@ Resources: Value: camera=(), geolocation=(), microphone=(), payment=(), usb=() SecurityHeadersConfig: ContentSecurityPolicy: - ContentSecurityPolicy: "default-src 'self'; base-uri 'self'; connect-src 'self' https://fonts.gstatic.com; font-src 'self' https://fonts.gstatic.com; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; upgrade-insecure-requests" + ContentSecurityPolicy: "default-src 'self'; base-uri 'self'; connect-src 'self' https://fonts.gstatic.com; font-src 'self' https://fonts.gstatic.com; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; upgrade-insecure-requests" Override: true ContentTypeOptions: Override: true diff --git a/ops/t4-maintainer/README.md b/ops/t4-maintainer/README.md index 976510cc..af3a6a56 100644 --- a/ops/t4-maintainer/README.md +++ b/ops/t4-maintainer/README.md @@ -2,7 +2,7 @@ This user service watches official Oh My Pi releases and gives GPT-5.6 Sol ownership of each T4 compatibility update. It runs as the normal host account with the standard OMP toolset, existing `gh` authentication, and `yolo` approval mode. There is no sandbox or reduced tool list. -The timer checks every two hours and targets GitHub's latest stable OMP release. Several releases between checks collapse into one update to the newest version. Before any processed or pending no-op, the deterministic wrapper fast-forwards fork `main` to exact official `main` without Sol. It proves ancestry and temporarily disables the canonical fork CI workflow for that mirror push. The wrapper durably snapshots the pre-existing exact-SHA run IDs before it pushes. After GitHub has observed the event, it re-enables and proves the workflow active, cancels only a new first-attempt `ci.yml` push run whose branch and SHA exactly match the mirrored official `main`, and observes the bounded delivery window plus several active-free polls. Older runs and human rerun attempts remain untouched. Durable phase state covers accepted pushes with lost client responses and makes interrupted enablement or run settlement fail closed. Legacy recovery markers without a run snapshot only restore the workflow and never cancel a run. Official `can1357/oh-my-pi` tags are immutable upstream inputs, while `lyc-aon/oh-my-pi:t4code/main` is the durable T4 product branch. Sol merges the exact base into the product line, resolves the integration, and marks releases with immutable `t4code--appserver-` tags. The wrapper-owned publisher sends the exact base tag object, product branch, and annotated integration tag in one atomic three-ref push with durable intent and receipt recovery. `flock` keeps one maintainer active at a time. The dedicated OMP profile is `t4-maintainer`, and its sessions remain available for follow-up. +The timer checks every two hours and targets GitHub's latest stable OMP release. Several releases between checks collapse into one update to the newest version. Before any processed or pending no-op, the deterministic wrapper fast-forwards fork `main` to exact official `main` without Sol. It proves ancestry and temporarily disables the canonical fork CI workflow for that mirror push. The wrapper durably snapshots the pre-existing exact-SHA run IDs before it pushes. After GitHub has observed the event, it re-enables and proves the workflow active, cancels only a new first-attempt `ci.yml` push run whose branch and SHA exactly match the mirrored official `main`, and observes the bounded delivery window plus several active-free polls. Older runs and human rerun attempts remain untouched. Durable phase state covers accepted pushes with lost client responses and makes interrupted enablement or run settlement fail closed. Legacy recovery markers without a run snapshot only restore the workflow and never cancel a run. Official `can1357/oh-my-pi` tags are immutable upstream inputs, while `wolfiesch/oh-my-pi:t4code/main` is the durable T4 product branch. Sol merges the exact base into the product line, resolves the integration, and marks releases with immutable `t4code--appserver-` tags. The wrapper-owned publisher sends the exact base tag object, product branch, and annotated integration tag in one atomic three-ref push with durable intent and receipt recovery. `flock` keeps one maintainer active at a time. The dedicated OMP profile is `t4-maintainer`, and its sessions remain available for follow-up. The profile's `auth-broker.token` is a symlink to the existing mode-0600 broker token file. This keeps one credential source while giving OMP the profile-local path it resolves at launch. diff --git a/ops/t4-maintainer/deploy-local.sh b/ops/t4-maintainer/deploy-local.sh index 9d92c039..e4d4182d 100755 --- a/ops/t4-maintainer/deploy-local.sh +++ b/ops/t4-maintainer/deploy-local.sh @@ -33,9 +33,9 @@ REALPATH=${T4_MAINTAINER_REALPATH:-realpath} UNAME=${T4_MAINTAINER_UNAME:-uname} PROC_ROOT=${T4_MAINTAINER_TEST_PROC_ROOT:-/proc} -OMP_INTEGRATION_REPOSITORY=${T4_LOCAL_OMP_REPOSITORY:-https://github.com/lyc-aon/oh-my-pi.git} +OMP_INTEGRATION_REPOSITORY=${T4_LOCAL_OMP_REPOSITORY:-https://github.com/wolfiesch/oh-my-pi.git} OMP_UPSTREAM_REPOSITORY=${T4_LOCAL_OMP_UPSTREAM_REPOSITORY:-https://github.com/can1357/oh-my-pi.git} -OMP_INTEGRATION_SLUG=${T4_LOCAL_OMP_REPOSITORY_SLUG:-lyc-aon/oh-my-pi} +OMP_INTEGRATION_SLUG=${T4_LOCAL_OMP_REPOSITORY_SLUG:-wolfiesch/oh-my-pi} OMP_UPSTREAM_SLUG=${T4_LOCAL_OMP_UPSTREAM_REPOSITORY_SLUG:-can1357/oh-my-pi} OMP_PRODUCT_BRANCH=t4code/main T4_REPOSITORY=${T4_LOCAL_T4_REPOSITORY:-LycaonLLC/t4-code} diff --git a/ops/t4-maintainer/omp-fork-authority-transfer.json b/ops/t4-maintainer/omp-fork-authority-transfer.json new file mode 100644 index 00000000..44a3a843 --- /dev/null +++ b/ops/t4-maintainer/omp-fork-authority-transfer.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": 1, + "purpose": "one-time-omp-fork-authority-transfer", + "repositories": { + "official": { + "fullName": "can1357/oh-my-pi", + "id": 1125856365, + "nodeId": "R_kgDOQxs0bQ" + }, + "legacy": { + "fullName": "lyc-aon/oh-my-pi", + "id": 1271877000, + "nodeId": "R_kgDOS89NiA", + "parentId": 1125856365, + "parentNodeId": "R_kgDOQxs0bQ" + }, + "current": { + "fullName": "wolfiesch/oh-my-pi", + "id": 1271775475, + "nodeId": "R_kgDOS83A8w", + "parentId": 1125856365, + "parentNodeId": "R_kgDOQxs0bQ" + } + }, + "publication": { + "upstreamTag": "v17.0.5", + "upstreamCommit": "9fd6e97113f5ed3a847e66d346970efdf8afcad9", + "upstreamTagObject": "9fd6e97113f5ed3a847e66d346970efdf8afcad9", + "currentBaseTagObject": null, + "currentBaseCommitAccessible": true, + "productBranch": "t4code/main", + "productCommit": "fc0c391334c08ab260057756aa84bd2b07741ee7", + "integrationTag": "t4code-17.0.5-appserver-13", + "integrationCommit": "fc0c391334c08ab260057756aa84bd2b07741ee7", + "integrationTagObject": "bdaef9e0073bdb5f889a36adef93322aaf82fcec" + }, + "releaseAssets": [ + {"name":"omp-darwin-arm64","size":120893008,"digest":"sha256:60ce5193eadc2884a4313289ff1166b6a1daae97f3a3ec66dc260ea5c5713756"}, + {"name":"omp-darwin-x64","size":126353216,"digest":"sha256:713a4ea2293704fd1958e8cdc91786af292323a9af1169b2013c2498c62d808c"}, + {"name":"omp-linux-arm64","size":150513808,"digest":"sha256:46d29f223a7481d42b3f7219e71afe389604c2b61f7d309369397a8932837770"}, + {"name":"omp-linux-x64","size":175376512,"digest":"sha256:ef66a95a45185c35a19ed997b68f62f86de5497414f6a9b939c4f228a52471b8"}, + {"name":"omp-windows-x64.exe","size":154444800,"digest":"sha256:fc09b7f3f167478095570a8fed367a76f7ad8d9d4c48e0db8bdadbcb1cbe9fea"} + ] +} diff --git a/ops/t4-maintainer/prompt.md b/ops/t4-maintainer/prompt.md index 4e66efd8..9251f2d2 100644 --- a/ops/t4-maintainer/prompt.md +++ b/ops/t4-maintainer/prompt.md @@ -2,7 +2,7 @@ Own this T4 Code compatibility update from the official OMP release through a complete public delivery. Use the host's existing unprivileged tools and compute only inside the wrapper-owned workspace and disposable user-owned roots. Host access is not permission to change live machine state. -Read the run context at `$T4_MAINTENANCE_CONTEXT`, confirm the latest stable official OMP tag and commit, and use clean clones or worktrees in `$T4_MAINTENANCE_WORKSPACE`. The deterministic wrapper has already synchronized the `lyc-aon/oh-my-pi` fork's `main` branch to exact official `main` without spending a Sol call. Before preparing another OMP integration revision, inspect the public product branch, immutable integration tags, exact-commit CI, the wrapper-owned atomic receipt directory, and the public release assets. Reuse the highest existing revision only when all of those facts exactly match the requested official base and the required five assets; never move, rebuild, or republish an immutable integration tag. Create a later annotated revision only when the integration source truly changes. When a new revision is required, merge the exact official `vX.Y.Z` base into the durable `t4code/main` product branch, reconcile the T4 appserver integration and app-wire package there, and carry forward every capability T4 needs. Keep the fork CI gate that proves the integration commit descends from that exact mirrored base and remains reachable from `t4code/main`. +Read the run context at `$T4_MAINTENANCE_CONTEXT`, confirm the latest stable official OMP tag and commit, and use clean clones or worktrees in `$T4_MAINTENANCE_WORKSPACE`. The deterministic wrapper has already synchronized the `wolfiesch/oh-my-pi` fork's `main` branch to exact official `main` without spending a Sol call. Before preparing another OMP integration revision, inspect the public product branch, immutable integration tags, exact-commit CI, the wrapper-owned atomic receipt directory, and the public release assets. Reuse the highest existing revision only when all of those facts exactly match the requested official base and the required five assets; never move, rebuild, or republish an immutable integration tag. Create a later annotated revision only when the integration source truly changes. When a new revision is required, merge the exact official `vX.Y.Z` base into the durable `t4code/main` product branch, reconcile the T4 appserver integration and app-wire package there, and carry forward every capability T4 needs. Keep the fork CI gate that proves the integration commit descends from that exact mirrored base and remains reachable from `t4code/main`. Use `$T4_ATOMIC_PUBLISH_HELPER` as the only OMP publication path. After preparing the local `t4code/main` branch and annotated integration tag, invoke it with `--repo PATH --integration-tag TAG`; its expected official tag, commit, fixed production remotes, and durable state directory are supplied by the wrapper. It publishes the unchanged official base tag object, updated `t4code/main`, and annotated integration tag as one deterministic atomic three-ref transaction and retains the receipt the wrapper requires. Keep the language and workflow positive. Complete every OMP publication through that single helper invocation. Publish the fork release exclusively as the exact five integration binaries required below. diff --git a/ops/t4-maintainer/publish-omp-atomic.sh b/ops/t4-maintainer/publish-omp-atomic.sh index 1a7115ef..0785bc01 100755 --- a/ops/t4-maintainer/publish-omp-atomic.sh +++ b/ops/t4-maintainer/publish-omp-atomic.sh @@ -23,19 +23,19 @@ EXPECTED_UPSTREAM_COMMIT=${T4_ATOMIC_EXPECTED_UPSTREAM_COMMIT:?T4_ATOMIC_EXPECTE TEST_MODE=${T4_ATOMIC_TEST_MODE:-0} readonly OFFICIAL_REPOSITORY=can1357/oh-my-pi -readonly FORK_REPOSITORY=lyc-aon/oh-my-pi +readonly FORK_REPOSITORY=wolfiesch/oh-my-pi readonly PRODUCT_BRANCH=t4code/main readonly OFFICIAL_REPOSITORY_ID=1125856365 -readonly FORK_REPOSITORY_ID=1271877000 +readonly FORK_REPOSITORY_ID=1271775475 readonly OFFICIAL_REPOSITORY_NODE_ID=R_kgDOQxs0bQ -readonly FORK_REPOSITORY_NODE_ID=R_kgDOS89NiA +readonly FORK_REPOSITORY_NODE_ID=R_kgDOS83A8w if [[ $TEST_MODE == 1 ]]; then OFFICIAL_URL=${T4_ATOMIC_OFFICIAL_URL:?T4_ATOMIC_OFFICIAL_URL is required in test mode} FORK_URL=${T4_ATOMIC_FORK_URL:?T4_ATOMIC_FORK_URL is required in test mode} else OFFICIAL_URL=https://github.com/can1357/oh-my-pi.git - FORK_URL=https://github.com/lyc-aon/oh-my-pi.git + FORK_URL=https://github.com/wolfiesch/oh-my-pi.git fi fail() { @@ -138,15 +138,19 @@ assert_production_identity() { local official fork origin official_remote official=$($GH api "repos/$OFFICIAL_REPOSITORY") || return 1 fork=$($GH api "repos/$FORK_REPOSITORY") || return 1 - printf '%s' "$official" | $JQ -e --argjson id "$OFFICIAL_REPOSITORY_ID" ' - .id == $id and .node_id == "R_kgDOQxs0bQ" and .full_name == "can1357/oh-my-pi" + printf '%s' "$official" | $JQ -e \ + --argjson id "$OFFICIAL_REPOSITORY_ID" \ + --arg node_id "$OFFICIAL_REPOSITORY_NODE_ID" ' + .id == $id and .node_id == $node_id and .full_name == "can1357/oh-my-pi" ' >/dev/null || return 1 printf '%s' "$fork" | $JQ -e \ --argjson id "$FORK_REPOSITORY_ID" \ - --argjson parent_id "$OFFICIAL_REPOSITORY_ID" ' - .id == $id and .node_id == "R_kgDOS89NiA" and - .full_name == "lyc-aon/oh-my-pi" and .fork == true and - .parent.id == $parent_id and .parent.node_id == "R_kgDOQxs0bQ" and + --arg node_id "$FORK_REPOSITORY_NODE_ID" \ + --argjson parent_id "$OFFICIAL_REPOSITORY_ID" \ + --arg parent_node_id "$OFFICIAL_REPOSITORY_NODE_ID" ' + .id == $id and .node_id == $node_id and + .full_name == "wolfiesch/oh-my-pi" and .fork == true and + .parent.id == $parent_id and .parent.node_id == $parent_node_id and .parent.full_name == "can1357/oh-my-pi" ' >/dev/null || return 1 origin=$($GIT -C "$repo" remote get-url origin) || return 1 @@ -286,7 +290,7 @@ if [[ -s $receipt_file ]]; then .pushedRefCount == 3 and .productionRemoteIdentity == $production and .officialRepository == "can1357/oh-my-pi" and - .forkRepository == "lyc-aon/oh-my-pi" and + .forkRepository == "wolfiesch/oh-my-pi" and .upstream.tag == $upstream_tag and .upstream.commit == $upstream_commit and .product.branch == "t4code/main" and .integration.tag == $integration_tag and diff --git a/ops/t4-maintainer/run.sh b/ops/t4-maintainer/run.sh index 81b06d3f..54e86a86 100755 --- a/ops/t4-maintainer/run.sh +++ b/ops/t4-maintainer/run.sh @@ -22,6 +22,7 @@ LOCAL_APPLIED_FILE="$STATE_DIR/local-applied.json" BLOCKED_FILE="$STATE_DIR/deployment-blocked.json" FORK_SYNC_FILE="$STATE_DIR/fork-main-sync.json" ATOMIC_PUBLICATION_STATE_DIR=${T4_MAINTAINER_ATOMIC_STATE_DIR:-"$STATE_DIR/atomic-publication"} +OMP_AUTHORITY_TRANSFER_FILE=${T4_MAINTAINER_OMP_AUTHORITY_TRANSFER_FILE:-"$SCRIPT_DIR/omp-fork-authority-transfer.json"} GH=${T4_MAINTAINER_GH:-gh} CURL=${T4_MAINTAINER_CURL:-curl} @@ -71,11 +72,16 @@ FORK_SYNC_RUN_MIN_OBSERVATION_POLLS=${T4_MAINTAINER_FORK_SYNC_RUN_MIN_OBSERVATIO SLEEP=${T4_MAINTAINER_SLEEP:-sleep} readonly OMP_UPSTREAM_REPOSITORY="can1357/oh-my-pi" -readonly OMP_INTEGRATION_REPOSITORY="lyc-aon/oh-my-pi" +readonly OMP_INTEGRATION_REPOSITORY="wolfiesch/oh-my-pi" +readonly OMP_LEGACY_INTEGRATION_REPOSITORY="lyc-aon/oh-my-pi" +readonly OMP_UPSTREAM_REPOSITORY_ID=1125856365 +readonly OMP_UPSTREAM_REPOSITORY_NODE_ID="R_kgDOQxs0bQ" +readonly OMP_INTEGRATION_REPOSITORY_ID=1271775475 +readonly OMP_INTEGRATION_REPOSITORY_NODE_ID="R_kgDOS83A8w" readonly OMP_PRODUCT_BRANCH="t4code/main" readonly OMP_FORK_WORKFLOW="ci.yml" readonly OMP_UPSTREAM_URL="https://github.com/can1357/oh-my-pi.git" -readonly OMP_INTEGRATION_URL="https://github.com/lyc-aon/oh-my-pi.git" +readonly OMP_INTEGRATION_URL="https://github.com/wolfiesch/oh-my-pi.git" readonly T4_REPOSITORY="LycaonLLC/t4-code" readonly T4_SITE="https://t4code.net" T4_MAIN_GATE_SHA= @@ -372,9 +378,40 @@ fork_workflow_state() { $GH api "repos/$OMP_INTEGRATION_REPOSITORY/actions/workflows/$OMP_FORK_WORKFLOW" --jq .state } +omp_fork_identity_is_exact() { + local official fork + official=$($GH api "repos/$OMP_UPSTREAM_REPOSITORY") || return 1 + fork=$($GH api "repos/$OMP_INTEGRATION_REPOSITORY") || return 1 + printf '%s' "$official" | $JQ -e \ + --argjson id "$OMP_UPSTREAM_REPOSITORY_ID" \ + --arg node_id "$OMP_UPSTREAM_REPOSITORY_NODE_ID" ' + .id == $id and .node_id == $node_id and + .full_name == "can1357/oh-my-pi" and + .clone_url == "https://github.com/can1357/oh-my-pi.git" + ' >/dev/null || return 1 + printf '%s' "$fork" | $JQ -e \ + --argjson id "$OMP_INTEGRATION_REPOSITORY_ID" \ + --arg node_id "$OMP_INTEGRATION_REPOSITORY_NODE_ID" \ + --argjson parent_id "$OMP_UPSTREAM_REPOSITORY_ID" \ + --arg parent_node_id "$OMP_UPSTREAM_REPOSITORY_NODE_ID" ' + .id == $id and .node_id == $node_id and + .full_name == "wolfiesch/oh-my-pi" and + .clone_url == "https://github.com/wolfiesch/oh-my-pi.git" and .fork == true and + .parent.id == $parent_id and .parent.node_id == $parent_node_id and + .parent.full_name == "can1357/oh-my-pi" + ' >/dev/null +} + +require_omp_fork_identity() { + omp_fork_identity_is_exact \ + || fail "OMP fork repository identity does not match the pinned numeric fork and parent; refusing mutation" +} + enable_fork_workflow_and_prove() { + require_omp_fork_identity $GH api --method PUT "repos/$OMP_INTEGRATION_REPOSITORY/actions/workflows/$OMP_FORK_WORKFLOW/enable" \ >/dev/null || return 1 + require_omp_fork_identity [[ $(fork_workflow_state) == active ]] } @@ -508,6 +545,7 @@ push_attempted_fork_sync_marker_is_valid() { recover_fork_sync() { [[ -e $FORK_SYNC_FILE ]] || return 0 + require_omp_fork_identity fork_sync_marker_is_valid "$FORK_SYNC_FILE" \ || fail "fork-main recovery state is invalid: $FORK_SYNC_FILE" local official_commit phase preexisting_run_ids @@ -654,6 +692,7 @@ settle_fork_main_push_runs() { quiet_polls=0 while IFS= read -r run_id; do [[ $run_id =~ ^[1-9][0-9]*$ ]] || return 1 + require_omp_fork_identity if $GH api --method POST \ "repos/$OMP_INTEGRATION_REPOSITORY/actions/runs/$run_id/cancel" >/dev/null; then log "Requested cancellation of delayed fork-main CI run $run_id for $official_commit." @@ -730,6 +769,7 @@ sync_fork_main_once() { fi write_fork_sync_marker "$official_commit" "$fork_commit" \ || fail "fork-main recovery state could not be persisted before CI was disabled" + require_omp_fork_identity if ! $GH api --method PUT \ "repos/$OMP_INTEGRATION_REPOSITORY/actions/workflows/$OMP_FORK_WORKFLOW/disable" >/dev/null \ || [[ $(fork_workflow_state) != disabled_manually ]]; then @@ -755,6 +795,7 @@ sync_fork_main_once() { || fail "fork-main recovery state could not advance before the mirror push" preexisting_run_ids=$($JQ -ec '.preexistingRunIds' "$FORK_SYNC_FILE") \ || fail "fork-main recovery state lost its run snapshot before the mirror push" + require_omp_fork_identity $GIT -C "$push_repo" push "$OMP_INTEGRATION_URL" \ "$official_commit:refs/heads/main" || push_status=$? rm -rf -- "$push_repo" @@ -1293,10 +1334,80 @@ site_release_manifest_matches() { return "$manifest_status" } +legacy_omp_authority_transfer_is_valid() { + local result_file=$1 receipt_file=$2 proof=$OMP_AUTHORITY_TRANSFER_FILE + local upstream_tag upstream_commit integration_tag integration_commit + local official_tag_object fork_tag_object integration_tag_object product_commit current_base_commit release + [[ -s $proof && -f $proof && ! -L $proof ]] || return 1 + upstream_tag=$($JQ -er '.upstream.tag' "$result_file") || return 1 + upstream_commit=$($JQ -er '.upstream.commit' "$result_file") || return 1 + integration_tag=$($JQ -er '.integration.tag' "$result_file") || return 1 + integration_commit=$($JQ -er '.integration.commit' "$result_file") || return 1 + $JQ -e \ + --arg upstream_tag "$upstream_tag" \ + --arg upstream_commit "$upstream_commit" \ + --arg integration_tag "$integration_tag" \ + --arg integration_commit "$integration_commit" \ + --slurpfile receipt "$receipt_file" ' + ($receipt[0]) as $r | + .schemaVersion == 1 and + .purpose == "one-time-omp-fork-authority-transfer" and + .repositories.official == { + fullName: "can1357/oh-my-pi", id: 1125856365, nodeId: "R_kgDOQxs0bQ" + } and + .repositories.legacy == { + fullName: "lyc-aon/oh-my-pi", id: 1271877000, nodeId: "R_kgDOS89NiA", + parentId: 1125856365, parentNodeId: "R_kgDOQxs0bQ" + } and + .repositories.current == { + fullName: "wolfiesch/oh-my-pi", id: 1271775475, nodeId: "R_kgDOS83A8w", + parentId: 1125856365, parentNodeId: "R_kgDOQxs0bQ" + } and + .publication.upstreamTag == $upstream_tag and + .publication.upstreamCommit == $upstream_commit and + .publication.upstreamTagObject == $r.upstream.tagObject and + .publication.currentBaseTagObject == null and + .publication.currentBaseCommitAccessible == true and + .publication.productBranch == "t4code/main" and + .publication.productCommit == $integration_commit and + .publication.integrationTag == $integration_tag and + .publication.integrationCommit == $integration_commit and + .publication.integrationTagObject == $r.integration.tagObject and + (.releaseAssets | type == "array" and length == 5) and + all(.releaseAssets[]; + (.name | type == "string") and + (.size | type == "number" and floor == . and . > 0) and + (.digest | type == "string" and test("^sha256:[0-9a-f]{64}$"))) + ' "$proof" >/dev/null || return 1 + omp_fork_identity_is_exact || return 1 + official_tag_object=$(resolve_public_tag_object "$OMP_UPSTREAM_REPOSITORY" "$upstream_tag") \ + || return 1 + fork_tag_object=$(resolve_public_tag_object "$OMP_INTEGRATION_REPOSITORY" "$upstream_tag") \ + || fork_tag_object= + current_base_commit=$(resolve_public_commit "$OMP_INTEGRATION_REPOSITORY" "$upstream_commit") \ + || return 1 + integration_tag_object=$(resolve_public_tag_object "$OMP_INTEGRATION_REPOSITORY" "$integration_tag") \ + || return 1 + product_commit=$(resolve_public_commit "$OMP_INTEGRATION_REPOSITORY" "$OMP_PRODUCT_BRANCH") \ + || return 1 + [[ $official_tag_object == "$($JQ -r '.publication.upstreamTagObject' "$proof")" ]] || return 1 + [[ $current_base_commit == "$upstream_commit" ]] || return 1 + [[ -z $fork_tag_object || $fork_tag_object == "$official_tag_object" ]] || return 1 + [[ $integration_tag_object == "$($JQ -r '.publication.integrationTagObject' "$proof")" ]] \ + || return 1 + [[ $product_commit == "$integration_commit" ]] || return 1 + release=$($GH api "repos/$OMP_INTEGRATION_REPOSITORY/releases/tags/$integration_tag") \ + || return 1 + $JQ -e --argjson release "$release" ' + ([.releaseAssets[] | {name, size, digest}] | sort_by(.name)) == + ([$release.assets[] | select(.state == "uploaded") | {name, size, digest}] | sort_by(.name)) + ' "$proof" >/dev/null +} + atomic_publication_receipt_is_valid() { local result_file=$1 integration_tag receipt_file intent_file intent_object embedded local upstream_tag upstream_commit integration_commit - local official_tag_object fork_tag_object integration_tag_object + local official_tag_object fork_tag_object integration_tag_object legacy_receipt=false integration_tag=$($JQ -er '.integration.tag | strings | select(test("^t4code-[0-9]+\\.[0-9]+\\.[0-9]+-appserver-[1-9][0-9]*$"))' "$result_file") \ || return 1 upstream_tag=$($JQ -er '.upstream.tag' "$result_file") || return 1 @@ -1317,7 +1428,8 @@ atomic_publication_receipt_is_valid() { .schemaVersion == 1 and .helperOwned == true and .atomicPush == true and .pushedRefCount == 3 and .productionRemoteIdentity == true and .officialRepository == "can1357/oh-my-pi" and - .forkRepository == "lyc-aon/oh-my-pi" and + (.forkRepository == "wolfiesch/oh-my-pi" or + .forkRepository == "lyc-aon/oh-my-pi") and .upstream.tag == $upstream_tag and .upstream.commit == $upstream_commit and (.upstream.tagObject | test("^[0-9a-f]{40}$")) and .product.branch == "t4code/main" and .product.commit == $integration_commit and @@ -1350,14 +1462,22 @@ atomic_publication_receipt_is_valid() { "t4code/main", "annotated-integration-tag" ] - ' "$intent_file" >/dev/null || return 1 + ' "$intent_file" >/dev/null || return 1 + if [[ $($JQ -r '.forkRepository' "$receipt_file") == "$OMP_LEGACY_INTEGRATION_REPOSITORY" ]]; then + legacy_omp_authority_transfer_is_valid "$result_file" "$receipt_file" || return 1 + legacy_receipt=true + else + omp_fork_identity_is_exact || return 1 + fi official_tag_object=$(resolve_public_tag_object "$OMP_UPSTREAM_REPOSITORY" "$upstream_tag") \ || return 1 - fork_tag_object=$(resolve_public_tag_object "$OMP_INTEGRATION_REPOSITORY" "$upstream_tag") \ - || return 1 integration_tag_object=$(resolve_public_tag_object "$OMP_INTEGRATION_REPOSITORY" "$integration_tag") \ || return 1 - [[ $official_tag_object == "$fork_tag_object" ]] || return 1 + if [[ $legacy_receipt == false ]]; then + fork_tag_object=$(resolve_public_tag_object "$OMP_INTEGRATION_REPOSITORY" "$upstream_tag") \ + || return 1 + [[ $official_tag_object == "$fork_tag_object" ]] || return 1 + fi [[ $($JQ -r '.upstream.tagObject' "$receipt_file") == "$official_tag_object" ]] || return 1 [[ $($JQ -r '.integration.tagObject' "$receipt_file") == "$integration_tag_object" ]] || return 1 if $JQ -e 'has("atomicPublication")' "$result_file" >/dev/null; then @@ -1606,7 +1726,7 @@ verify_result_once() { local actual_upstream_commit actual_integration_commit actual_t4_commit release_json expected_release_url local omp_release_json local official_main_commit fork_main_commit official_base_tag_object fork_base_tag_object - local fork_base_commit + local fork_base_commit receipt_file legacy_receipt=false atomic_publication_receipt_is_valid "$result_file" || return 1 $JQ -e ' @@ -1632,6 +1752,11 @@ verify_result_once() { release_url=$($JQ -r '.release.url' "$result_file") site_url=$($JQ -r '.site.url' "$result_file") site_tag=$($JQ -r '.site.releaseTag' "$result_file") + receipt_file="$ATOMIC_PUBLICATION_STATE_DIR/$integration_tag/receipt.json" + if [[ $($JQ -r '.forkRepository' "$receipt_file") == "$OMP_LEGACY_INTEGRATION_REPOSITORY" ]]; then + legacy_omp_authority_transfer_is_valid "$result_file" "$receipt_file" || return 1 + legacy_receipt=true + fi [[ $upstream_tag == "$($JQ -r '.tag' <<<"$target")" ]] || return 1 [[ $upstream_commit == "$($JQ -r '.commit' <<<"$target")" ]] || return 1 @@ -1646,10 +1771,17 @@ verify_result_once() { official_base_tag_object=$(resolve_public_tag_object "$OMP_UPSTREAM_REPOSITORY" "$upstream_tag") \ || return 1 fork_base_tag_object=$(resolve_public_tag_object "$OMP_INTEGRATION_REPOSITORY" "$upstream_tag") \ - || return 1 - [[ $official_base_tag_object =~ ^[0-9a-f]{40}$ \ - && $fork_base_tag_object == "$official_base_tag_object" ]] || return 1 - fork_base_commit=$(resolve_public_commit "$OMP_INTEGRATION_REPOSITORY" "$upstream_tag") || return 1 + || fork_base_tag_object= + [[ $official_base_tag_object =~ ^[0-9a-f]{40}$ ]] || return 1 + if [[ $legacy_receipt == true ]]; then + [[ -z $fork_base_tag_object || $fork_base_tag_object == "$official_base_tag_object" ]] || return 1 + fork_base_commit=$(resolve_public_commit "$OMP_INTEGRATION_REPOSITORY" "$upstream_commit") \ + || return 1 + else + [[ $fork_base_tag_object == "$official_base_tag_object" ]] || return 1 + fork_base_commit=$(resolve_public_commit "$OMP_INTEGRATION_REPOSITORY" "$upstream_tag") \ + || return 1 + fi [[ $fork_base_commit == "$upstream_commit" ]] || return 1 official_main_commit=$(resolve_public_commit "$OMP_UPSTREAM_REPOSITORY" main) || return 1 fork_main_commit=$(resolve_public_commit "$OMP_INTEGRATION_REPOSITORY" main) || return 1 diff --git a/package.json b/package.json index b3f80664..5c443500 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/root", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "scripts": { @@ -8,17 +8,12 @@ "dev:web": "vp run --filter @t4-code/web dev", "dev:desktop": "vp run --filter @t4-code/desktop dev", "dev:site": "vp run --filter @t4-code/site dev", - "dev:flutter": "node scripts/run-flutter.mjs", "serve:tailnet": "node scripts/tailnet-gateway.mjs", "build": "vp run -r build", "build:web": "pnpm --filter @t4-code/web build", "build:desktop": "pnpm --filter @t4-code/desktop build", "build:host": "pnpm --filter @t4-code/host-daemon build:binary", "build:site": "pnpm --filter @t4-code/site build", - "build:flutter:web": "vp run --filter @t4-code/flutter build:web", - "build:flutter:macos": "pnpm build:host && vp run --filter @t4-code/flutter build:macos", - "build:flutter:android": "vp run --filter @t4-code/flutter build:android", - "build:flutter:ios": "vp run --filter @t4-code/flutter build:ios", "build:demo": "node scripts/build-demo.mjs", "deploy:site": "node scripts/deploy-site.mjs", "deploy:demo": "node scripts/deploy-demo.mjs", @@ -33,16 +28,13 @@ "inspect:package": "node scripts/inspect-package.mjs", "inspect:dmg": "node scripts/inspect-macos-dmg.mjs", "test:packaging": "node --test scripts/packaging.test.mjs scripts/inspect-linux-update.test.mjs scripts/inspect-macos-dmg.test.mjs scripts/inspect-macos-release.test.mjs", - "test:tooling": "node --test scripts/benchmark-omp-codex-transport.test.mjs scripts/check-adr-numbering.test.mjs scripts/check-flutter-coverage.test.mjs scripts/check-host-ownership.test.mjs scripts/check-release-consistency.test.mjs scripts/check-release-publication.test.mjs scripts/check-provenance.test.mjs scripts/deploy-demo.test.mjs scripts/deploy-site.test.mjs scripts/dispatch-site-deployment.test.mjs scripts/generate-release-manifest.test.mjs scripts/perf/perf.test.mjs scripts/reconcile-release-assets.test.mjs scripts/t4-maintainer-contract.test.mjs scripts/t4-maintainer-integration.test.mjs scripts/t4-maintainer-omp-publish.test.mjs scripts/test-temporary-directory.test.mjs scripts/tailnet-gateway.test.mjs scripts/tailnet-service.test.mjs scripts/wait-for-exact-ci.test.mjs scripts/wait-for-release-assets.test.mjs", + "test:tooling": "node --test scripts/benchmark-omp-codex-transport.test.mjs scripts/check-adr-numbering.test.mjs scripts/check-host-ownership.test.mjs scripts/check-release-consistency.test.mjs scripts/check-release-publication.test.mjs scripts/check-provenance.test.mjs scripts/deploy-demo.test.mjs scripts/deploy-site.test.mjs scripts/dispatch-site-deployment.test.mjs scripts/generate-release-manifest.test.mjs scripts/perf/perf.test.mjs scripts/reconcile-release-assets.test.mjs scripts/t4-maintainer-contract.test.mjs scripts/t4-maintainer-integration.test.mjs scripts/t4-maintainer-omp-publish.test.mjs scripts/test-temporary-directory.test.mjs scripts/tailnet-gateway.test.mjs scripts/tailnet-service.test.mjs scripts/wait-for-exact-ci.test.mjs scripts/wait-for-release-assets.test.mjs", "check:release": "node scripts/check-release-consistency.mjs", "check:provenance": "node scripts/check-provenance.mjs", "lint": "vp lint --deny-warnings", "check": "pnpm check:release && pnpm check:provenance && pnpm lint && pnpm typecheck", "typecheck": "vp run -r typecheck", - "check:flutter": "vp run --filter @t4-code/flutter typecheck", "test": "vp run -r test", - "test:flutter": "vp run --filter @t4-code/flutter test", - "test:flutter:device": "pnpm --filter @t4-code/flutter test:device", "test:e2e": "pnpm build:web && pnpm build:site && playwright test", "test:soak": "pnpm --filter @t4-code/client exec vp test run test/projection.test.ts -t \"retains a 10k snapshot\" && pnpm build:web && playwright test e2e/remote-app.spec.ts --grep \"@soak\"", "test:protocol": "vp run --filter @t4-code/protocol --filter @t4-code/fixture-server test", diff --git a/packages/client/package.json b/packages/client/package.json index c960a2b3..5b207b9a 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/client", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "exports": { @@ -17,6 +17,7 @@ "devDependencies": { "@t4-code/fixture-server": "workspace:*", "@t4-code/host-wire": "workspace:*", + "@t4-code/t4-api-client": "workspace:*", "@types/node": "catalog:", "@types/ws": "8.5.13", "vite-plus": "catalog:", diff --git a/packages/client/src/desktop-runtime-contracts.ts b/packages/client/src/desktop-runtime-contracts.ts index c208d2e4..d5a44924 100644 --- a/packages/client/src/desktop-runtime-contracts.ts +++ b/packages/client/src/desktop-runtime-contracts.ts @@ -27,6 +27,7 @@ import type { RuntimeErrorEvent, ServiceActionResult, ServiceInspection, + T4OmpLauncherState, TargetAddRequest, TargetAddResult, TargetListResult, @@ -70,6 +71,9 @@ export interface DesktopShellPort { readonly serviceStop?: () => Promise; readonly serviceRestart?: () => Promise; readonly serviceUninstall?: () => Promise; + readonly inspectT4OmpLauncher?: () => Promise; + readonly installT4OmpLauncher?: () => Promise; + readonly removeT4OmpLauncher?: () => Promise; readonly listProfiles?: () => Promise; readonly addProfile?: (request: LocalProfileAddRequest) => Promise; readonly updateProfile?: (request: LocalProfileUpdateRequest) => Promise; diff --git a/packages/client/src/desktop-runtime.ts b/packages/client/src/desktop-runtime.ts index b1d2be6d..18482c9e 100644 --- a/packages/client/src/desktop-runtime.ts +++ b/packages/client/src/desktop-runtime.ts @@ -264,6 +264,29 @@ export class DesktopRuntimeController { } async attachSession(targetId: string, hostIdValue: string, sessionIdValue: string, cursor?: Cursor): Promise { const result = await this.command(targetId, { hostId: hostId(hostIdValue), sessionId: sessionId(sessionIdValue), command: "session.attach", args: cursor === undefined ? {} : { cursor } }); + // The IPC command result is itself validated authority. During startup the + // raw response event can race the final target-generation reconciliation; + // apply the completed attach result again after that boundary so an + // exact-head resume cannot remain cached forever when replay is empty. + if ( + result.accepted && + result.targetId === targetId && + this.current.connections.get(targetId) === "connected" && + this.current.targetHosts.get(targetId) === hostIdValue + ) { + this.applyProjectionEvent(targetId, Object.freeze({ + kind: "response" as const, + payload: Object.freeze({ + requestId: result.requestId, + commandId: result.commandId, + hostId: hostId(hostIdValue), + sessionId: sessionId(sessionIdValue), + ok: true as const, + command: "session.attach", + result: result.result, + }), + }) as RendererServerEvent); + } this.activateSession(hostIdValue, sessionIdValue); return result; } @@ -1117,6 +1140,12 @@ export class DesktopRuntimeController { return refreshOnConnect; } private updateConnection(targetId: string, state: DesktopTarget["state"]): void { + if ( + state === "connecting" && + this.current.connections.get(targetId) === state + ) { + return; + } const refreshOnConnect = this.prepareConnectionTransition(targetId, state); const connections = new Map(this.current.connections).set(targetId, state); const existing = this.current.targets.get(targetId); diff --git a/packages/client/src/omp-client-cursor.ts b/packages/client/src/omp-client-cursor.ts index 85a4eed3..68aa020d 100644 --- a/packages/client/src/omp-client-cursor.ts +++ b/packages/client/src/omp-client-cursor.ts @@ -3,6 +3,7 @@ import type { CursorRecord, CursorStore, OmpClientError } from "./omp-client-con import { MAX_SAVED, sessionKey } from "./omp-client-contracts.ts"; interface SaveQueue { + barrier: CursorRecord | undefined; latest: CursorRecord | undefined; running: boolean; } @@ -35,7 +36,7 @@ export class CursorJournal { this.saved.delete(key); } } - remember(record: CursorRecord): void { + remember(record: CursorRecord, authoritative = false): void { let cursor: Cursor; try { cursor = decodeCursor(record.cursor); @@ -60,8 +61,16 @@ export class CursorJournal { } } if (this.store === undefined) return; - const queue = this.queues.get(key) ?? { latest: undefined, running: false }; - queue.latest = normalized; + const queue = this.queues.get(key) ?? { barrier: undefined, latest: undefined, running: false }; + if (authoritative) { + // An authoritative snapshot is a persistence barrier. A later durable + // cursor may coalesce with other later cursors, but it cannot replace + // this reset while an older save is still in flight. + queue.barrier = normalized; + queue.latest = undefined; + } else { + queue.latest = normalized; + } this.queues.set(key, queue); if (!queue.running) this.drain(key, queue); } @@ -101,11 +110,21 @@ export class CursorJournal { let drain!: Promise; drain = (async () => { try { - while (queue.latest !== undefined) { - const record = queue.latest; - queue.latest = undefined; + while (queue.barrier !== undefined || queue.latest !== undefined) { + const authoritative = queue.barrier !== undefined; + const record = queue.barrier ?? queue.latest!; + if (authoritative) { + queue.barrier = undefined; + // Once a reset reaches the head of the serialized queue, an older + // saved sequence must not suppress later cursors even if the reset + // write itself fails. + this.saved.delete(key); + } else { + queue.latest = undefined; + } const saved = this.saved.get(key); if ( + !authoritative && saved !== undefined && saved.epoch === record.cursor.epoch && saved.seq >= record.cursor.seq @@ -121,7 +140,7 @@ export class CursorJournal { } finally { queue.running = false; this.drains.delete(drain); - if (queue.latest === undefined) this.queues.delete(key); + if (queue.barrier === undefined && queue.latest === undefined) this.queues.delete(key); else this.drain(key, queue); } })(); diff --git a/packages/client/src/omp-client-frames.ts b/packages/client/src/omp-client-frames.ts index f056747a..427f0928 100644 --- a/packages/client/src/omp-client-frames.ts +++ b/packages/client/src/omp-client-frames.ts @@ -84,7 +84,7 @@ export function sendClientHello( } catch { fatal(); return; } const encoded = encodeOutgoingMessage(provider, { kind: "hello", - client: options.client ?? { name: "t4-code", version: "0.1.30", build: "client", platform: "electron" }, + client: options.client ?? { name: "t4-code", version: "0.1.31", build: "client", platform: "electron" }, requestedFeatures: [...(options.requestedFeatures ?? ["resume"])], savedCursors, ...(options.capabilities === undefined ? {} : { capabilities: options.capabilities }), diff --git a/packages/client/src/omp-client-runtime.ts b/packages/client/src/omp-client-runtime.ts index c728a26b..34513a28 100644 --- a/packages/client/src/omp-client-runtime.ts +++ b/packages/client/src/omp-client-runtime.ts @@ -173,6 +173,10 @@ export class OmpClient { private readonly inboundDispatcher: OmpClientEventDispatcher; private readonly events = new OmpClientEvents(); private readonly attached = new Map(); + private readonly attachmentRecoveries = new Map< + string, + { readonly generation: number; readonly promise: Promise; readonly awaitsSnapshot: boolean } + >(); private readonly previewCaptures: PreviewCaptureResource; readonly previewLeaseManager: PreviewLeaseManager; private handshakeTimer: ClientTimer | undefined; @@ -426,7 +430,18 @@ export class OmpClient { } async command(intent: CommandIntent, options: CommandOptions = {}): Promise { const generation = this.generation; - const response = await this.sendCommand(intent, options); + const attachmentKey = + intent.command === "session.attach" && intent.hostId !== undefined && intent.sessionId !== undefined + ? sessionKey(intent.hostId, intent.sessionId) + : undefined; + const recovery = attachmentKey === undefined ? undefined : this.attachmentRecoveries.get(attachmentKey); + // The transport owns reattachment across reconnects. A renderer can observe + // the ready state before the new welcome/snapshot reaches its projection and + // otherwise race that recovery with an old-epoch attach of its own. + const response = + recovery !== undefined && recovery.generation === generation + ? await recovery.promise + : await this.sendCommand(intent, options); if ( response.ok && intent.command === "session.attach" && @@ -998,8 +1013,14 @@ export class OmpClient { private acceptSnapshot(event: PublicEvent<"snapshot">): void { const frame = event.payload; const currentKey = sessionKey(String(frame.hostId), String(frame.sessionId)); - const previous = this.cursorJournal.bySession.get(currentKey); - if (previous?.epoch === frame.cursor.epoch && frame.cursor.seq <= previous.seq) return; + const recovery = this.attachmentRecoveries.get(currentKey); + if (recovery?.generation === this.generation && recovery.awaitsSnapshot) { + this.attachmentRecoveries.delete(currentKey); + } + // Inbound dispatch is generation-scoped and ordered, so a snapshot from the + // current transport is authoritative even when its cursor is equal to or + // below the durable journal. The journal can outlive renderer state, and a + // host may rebuild a transient session projection within the same epoch. this.desyncedSessions.delete(currentKey); this.epochValue = frame.cursor.epoch; this.cursorValue = frame.cursor; @@ -1007,7 +1028,7 @@ export class OmpClient { hostId: String(frame.hostId), sessionId: String(frame.sessionId), cursor: frame.cursor, - }); + }, true); this.reconnectHealth.acceptReplayProgress(this.generation, currentKey, frame.cursor, true); this.publish(event); } @@ -1198,10 +1219,9 @@ export class OmpClient { private reattachSessions(): void { const generation = this.generation; for (const record of this.attached.values()) { - const cursor = this.cursorJournal.records.get( - `${String(record.hostId)}\u0000${String(record.sessionId)}`, - )?.cursor; - this.sendCommand( + const key = sessionKey(String(record.hostId), String(record.sessionId)); + const cursor = this.cursorJournal.records.get(key)?.cursor; + const recovery = this.sendCommand( { hostId: String(record.hostId), sessionId: String(record.sessionId), @@ -1209,12 +1229,26 @@ export class OmpClient { args: cursor === undefined ? {} : { cursor }, }, { timeoutMs: this.options.commandTimeoutMs ?? 30_000 }, - ) - .then((response) => { - if (response.ok) this.requestPreviewState(record, generation); - else this.emitRecoveryFailure("session.attach"); - }) - .catch((error: unknown) => this.emitRecoveryError(error)); + ); + const state = { generation, promise: recovery, awaitsSnapshot: cursor === undefined }; + this.attachmentRecoveries.set(key, state); + void recovery.then( + (response) => { + if (response.ok) { + this.requestPreviewState(record, generation); + if (!state.awaitsSnapshot && this.attachmentRecoveries.get(key) === state) { + this.attachmentRecoveries.delete(key); + } + } else { + if (this.attachmentRecoveries.get(key) === state) this.attachmentRecoveries.delete(key); + this.emitRecoveryFailure("session.attach"); + } + }, + (error: unknown) => { + if (this.attachmentRecoveries.get(key) === state) this.attachmentRecoveries.delete(key); + this.emitRecoveryError(error); + }, + ); } } diff --git a/packages/client/src/projection.ts b/packages/client/src/projection.ts index edc4055f..a2b0b463 100644 --- a/packages/client/src/projection.ts +++ b/packages/client/src/projection.ts @@ -966,6 +966,19 @@ function mostRecentSessionKey(sessionIndex: ReadonlyMap): st } return selected?.key; } +function withoutHostRefOrdinals( + ordinals: ReadonlyMap, + hostId: string, +): ReadonlyMap { + const prefix = `${hostId}\u0000`; + let next: Map | undefined; + for (const sessionKey of ordinals.keys()) { + if (!sessionKey.startsWith(prefix)) continue; + next ??= new Map(ordinals); + next.delete(sessionKey); + } + return next === undefined ? ordinals : immutableMap(next); +} function authoritativeSessionHosts( frame: ProjectionInput<"sessions">, refs: readonly SessionRef[], @@ -1145,23 +1158,39 @@ function applyProjectionInput( if (activeSessionKey === warmKey) activeSessionKey = undefined; } } - const lru = freezeArray(snapshot.lru.filter((sessionKey) => sessions.has(sessionKey))); - for (const ref of refs) { - const sessionKey = key(String(ref.hostId), String(ref.sessionId)); - sessionIndex = mapWith(sessionIndex, sessionKey, ref, config.maxIndexedSessions); - sessionRefArrivalOrdinals = mapWith( - sessionRefArrivalOrdinals, - sessionKey, - arrivalOrdinal, - config.maxIndexedSessions, + // A bounded inventory page is authoritative for every row it returned. + // Repeated mapWith calls against a full, differently ordered cached page + // can evict an upcoming returned row, reinsert it later, and cascade that + // eviction through the page. Build the two capped maps atomically so all + // returned refs survive and their current-arrival markers stay aligned. + const retainedCapacity = Math.max(0, config.maxIndexedSessions - refs.length); + const retainedCandidates = [...sessionIndex.entries()] + .filter(([existingKey]) => !incomingKeys.has(existingKey)) + .sort(([leftKey, left], [rightKey, right]) => + String(left.updatedAt).localeCompare(String(right.updatedAt)) || + leftKey.localeCompare(rightKey), ); - } - while (sessionIndex.size > config.maxIndexedSessions) { - const oldest = sessionIndex.keys().next().value; - if (oldest === undefined) break; - sessionIndex = mapWithout(sessionIndex, oldest); - sessionRefArrivalOrdinals = mapWithout(sessionRefArrivalOrdinals, oldest); - } + const retainedEntries = retainedCapacity === 0 + ? [] + : retainedCandidates.slice(-retainedCapacity); + const incomingEntries = refs.map((ref) => [ + key(String(ref.hostId), String(ref.sessionId)), + ref, + ] as const); + sessionIndex = immutableMap([...retainedEntries, ...incomingEntries]); + const retainedKeys = new Set(retainedEntries.map(([existingKey]) => existingKey)); + const retainedOrdinalEntries = [...snapshot.sessionRefArrivalOrdinals.entries()].filter( + ([existingKey]) => { + if (!retainedKeys.has(existingKey)) return false; + const existingRef = snapshot.sessionIndex.get(existingKey); + return existingRef !== undefined && !authoritativeHosts.has(String(existingRef.hostId)); + }, + ); + sessionRefArrivalOrdinals = immutableMap([ + ...retainedOrdinalEntries, + ...incomingEntries.map(([sessionKey]) => [sessionKey, arrivalOrdinal] as const), + ]); + const lru = freezeArray(snapshot.lru.filter((sessionKey) => sessions.has(sessionKey))); let sessionIndexMetadata = snapshot.sessionIndexMetadata; for (const [hostId, metadata] of sessionFrameMetadata( frame, @@ -1753,20 +1782,29 @@ function applyProjectionInput( // gone until the host sends the next authoritative sessions frame. const sessionIndexMetadata = mapWithout(snapshot.sessionIndexMetadata, String(frame.hostId)); const sessionInventoryCursors = mapWithout(snapshot.sessionInventoryCursors, String(frame.hostId)); + const sessionRefArrivalOrdinals = withoutHostRefOrdinals( + snapshot.sessionRefArrivalOrdinals, + String(frame.hostId), + ); + const epochChanged = snapshot.epoch !== undefined && snapshot.epoch !== frame.epoch; const sessions = immutableMap( [...snapshot.sessions.entries()].map( - ([sessionKey, session]) => - [ + ([sessionKey, session]) => { + if (String(session.hostId) !== String(frame.hostId)) return [sessionKey, session] as const; + return [ sessionKey, Object.freeze({ ...session, - ...(snapshot.epoch === undefined || snapshot.epoch === frame.epoch - ? {} - : { + ...(epochChanged + ? { freshness: "catching-up" as const, transcriptEventArrivalOrdinal: 0, contextMaintenanceEventArrivalOrdinal: 0, - }), + } + : {}), + // Preview cursors describe one live browser connection rather + // than durable transcript history. They always need a fresh + // baseline after the host reconnects, even in the same epoch. previews: immutableMap( [...session.previews.entries()].map( ([previewMapKey, preview]) => @@ -1777,11 +1815,18 @@ function applyProjectionInput( ), ), }), - ] as const, + ] as const; + }, ), ); if (snapshot.epoch === undefined || snapshot.epoch === frame.epoch) { - return updateRoot(Object.freeze({ ...snapshot, sessionIndexMetadata, sessionInventoryCursors, sessions }), { + return updateRoot(Object.freeze({ + ...snapshot, + sessionIndexMetadata, + sessionInventoryCursors, + sessionRefArrivalOrdinals, + sessions, + }), { epoch: frame.epoch, freshness: "fresh", }); @@ -1790,6 +1835,7 @@ function applyProjectionInput( ...snapshot, sessionIndexMetadata, sessionInventoryCursors, + sessionRefArrivalOrdinals, sessions, epoch: frame.epoch, freshness: "catching-up", @@ -1977,12 +2023,6 @@ export class ProjectionStore { */ invalidateSessionInventory(hostId?: string): ProjectionSnapshot { if (this.disposed) return this.current; - if ( - hostId !== undefined && - !this.current.sessionIndexMetadata.has(hostId) && - !this.current.sessionInventoryCursors.has(hostId) - ) - return this.current; const sessionIndexMetadata = hostId === undefined ? immutableMap() @@ -1991,12 +2031,22 @@ export class ProjectionStore { hostId === undefined ? immutableMap() : mapWithout(this.current.sessionInventoryCursors, hostId); + const sessionRefArrivalOrdinals = + hostId === undefined + ? immutableMap() + : withoutHostRefOrdinals(this.current.sessionRefArrivalOrdinals, hostId); if ( sessionIndexMetadata === this.current.sessionIndexMetadata && - sessionInventoryCursors === this.current.sessionInventoryCursors + sessionInventoryCursors === this.current.sessionInventoryCursors && + sessionRefArrivalOrdinals === this.current.sessionRefArrivalOrdinals ) return this.current; - const next = Object.freeze({ ...this.current, sessionIndexMetadata, sessionInventoryCursors }); + const next = Object.freeze({ + ...this.current, + sessionIndexMetadata, + sessionInventoryCursors, + sessionRefArrivalOrdinals, + }); this.mutationGeneration += 1; this.current = next; this.queueCacheSave(); diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts index 4d471ca4..792f0c4f 100644 --- a/packages/client/test/client.test.ts +++ b/packages/client/test/client.test.ts @@ -702,6 +702,60 @@ describe("OmpClient protocol state machine", () => { ]); }); + it("publishes an authoritative snapshot behind the persisted cursor so a cold projection can hydrate", async () => { + const store = new FakeStore([{ + hostId: HOST, + sessionId: SESSION, + cursor: { epoch: "epoch-a", seq: 6 }, + }]); + const transport = new FakeTransport({ welcome: welcome() }); + const client = await readyClient(transport, { cursorStore: store }); + const events: PublicOmpServerEvent[] = []; + client.onEvent((event) => events.push(event)); + + transport.emit(snapshot(0)); + + expect(events.map((event) => event.kind)).toEqual(["snapshot"]); + expect(client.snapshot().cursor).toEqual({ epoch: "epoch-a", seq: 0 }); + await client.close(); + }); + + it("persists an authoritative snapshot that resets a cursor in the current transport generation", async () => { + const store = new FakeStore(); + const transport = new FakeTransport({ welcome: welcome() }); + const client = await readyClient(transport, { cursorStore: store }); + + transport.emit(snapshot(6)); + await Promise.resolve(); + await Promise.resolve(); + transport.emit(snapshot(0)); + await client.close(); + + expect(store.saved.map((record) => record.cursor.seq)).toEqual([6, 0]); + }); + + it("persists an authoritative reset before a later durable cursor while an older save is in flight", async () => { + const store = new DeferredStore(); + const transport = new FakeTransport({ welcome: welcome() }); + const client = await readyClient(transport, { cursorStore: store }); + + transport.emit(snapshot(6)); + expect(store.requests.map((record) => record.cursor.seq)).toEqual([6]); + transport.emit(snapshot(0)); + transport.emit(event(1)); + + store.resolves.shift()?.(); + await flushMicrotasks(); + expect(store.requests.map((record) => record.cursor.seq)).toEqual([6, 0]); + + store.resolves.shift()?.(); + await flushMicrotasks(); + expect(store.requests.map((record) => record.cursor.seq)).toEqual([6, 0, 1]); + + store.resolves.shift()?.(); + await client.close(); + }); + it("never invokes privileged pairing sink for unsolicited, replayed, or wrong-kind pair.ok", async () => { let callbacks = 0; const unsolicitedTransport = new FakeTransport({ welcome: welcome() }); @@ -1242,6 +1296,59 @@ describe("OmpClient protocol state machine", () => { expect(client.state).toBe("ready"); await client.close(); }); + + it("coalesces an old-epoch renderer attach with the transport reconnect recovery", async () => { + const clock = new FakeClock(); + const first = new FakeTransport({ + welcome: welcome(), + onSend: (frame, transport) => { + if (frame.type === "command") transport.emit(responseFor(frame)); + }, + }); + let recoveryCommand: CommandFrame | undefined; + const second = new FakeTransport({ + welcome: welcome({ epoch: "epoch-b" }), + onSend: (frame) => { + if (frame.type === "command" && frame.command === "session.attach") recoveryCommand = frame; + }, + }); + const transports = [first, second]; + const client = new OmpClient({ + transport: () => transports.shift() ?? second, + hostId: HOST, + timers: clock, + clock, + random: () => 0, + reconnect: { baseMs: 2, maxMs: 2 }, + }); + + await client.connect(); + await client.attach(HOST, SESSION); + first.drop(); + clock.advanceBy(1); + await flushMicrotasks(); + expect(client.state).toBe("ready"); + expect(recoveryCommand).toBeDefined(); + + const rendererAttach = client.command({ + hostId: HOST, + sessionId: SESSION, + command: "session.attach", + args: { cursor: { epoch: "epoch-a", seq: 0 } }, + }); + expect( + second.sent + .map((raw) => decodeClientFrame(raw)) + .filter((frame) => frame.type === "command" && frame.command === "session.attach"), + ).toHaveLength(1); + + second.emit(responseFor(recoveryCommand!, { cursor: { epoch: "epoch-b", seq: 0 } })); + second.emit(snapshot(0, "epoch-b")); + const result = await rendererAttach; + expect(result.requestId).toBe(recoveryCommand!.requestId); + expect(client.snapshot().cursor).toEqual({ epoch: "epoch-b", seq: 0 }); + await client.close(); + }); }); describe("OmpClient live fixture websocket", () => { diff --git a/packages/client/test/desktop-runtime.test.ts b/packages/client/test/desktop-runtime.test.ts index 6dce78db..eaac5416 100644 --- a/packages/client/test/desktop-runtime.test.ts +++ b/packages/client/test/desktop-runtime.test.ts @@ -169,14 +169,17 @@ class FakeShell implements DesktopShellPort { sessionListResultMissing = false; sessionListGate: Promise | undefined; workspaceListGate: Promise | undefined; + attachGate: Promise | undefined; catalogResult: unknown = { revision: "catalog-1", items: [] }; settingsResult: unknown = { revision: "settings-1", settings: {} }; workspaceListResult: unknown = { cursor: { epoch: "workspace-epoch", seq: 1 }, workspaces: [workspaceInfrastructure()], }; + attachResult: unknown; private sessionListGateResolve: (() => void) | undefined; private workspaceListGateResolve: (() => void) | undefined; + private attachGateResolve: (() => void) | undefined; deferNextSessionList(): void { this.sessionListGate = new Promise((resolve) => { this.sessionListGateResolve = resolve; }); } @@ -193,6 +196,14 @@ class FakeShell implements DesktopShellPort { this.workspaceListGateResolve = undefined; resolve?.(); } + deferNextAttach(): void { + this.attachGate = new Promise((resolve) => { this.attachGateResolve = resolve; }); + } + resolveAttach(): void { + const resolve = this.attachGateResolve; + this.attachGateResolve = undefined; + resolve?.(); + } async bootstrap(): Promise { this.bootstrapCalls += 1; if (this.emitWelcomeOnBootstrap !== undefined) this.emitFrame(this.emitWelcomeOnBootstrap); return { platform: "linux", version: "omp-app/1", connected: false }; } async listTargets(): Promise { return { targets: Object.freeze([...this.listedTargets]) }; } async connectTarget(request: TargetRequest): Promise { this.connectCalls += 1; if (this.rejectConnect) throw new Error("appserver unavailable"); this.emitState({ targetId: request.targetId, state: "connected" }); return { targetId: request.targetId, state: "connected" }; } @@ -244,6 +255,12 @@ class FakeShell implements DesktopShellPort { if (gate !== undefined) await gate; return { ...base, result: this.workspaceListResult }; } + if (request.intent.command === "session.attach") { + const gate = this.attachGate; + this.attachGate = undefined; + if (gate !== undefined) await gate; + return { ...base, ...(this.attachResult === undefined ? {} : { result: this.attachResult }) }; + } return base; } async pair(request: PairRequest): Promise { return { targetId: request.targetId, paired: true }; } @@ -422,6 +439,49 @@ describe("desktop runtime projection", () => { }); expect(runtime.getSnapshot().targetHosts.get("local")).toBe("host-a"); }); + it("applies an exact-head attach result even when no replay response event arrives", async () => { + const shell = new FakeShell(); + const runtime = createDesktopRuntimeController({ shell }); + await runtime.start(); + shell.emitFrame({ targetId: "local", frame: welcome("host-a", ["sessions.read"], []) }); + shell.emitFrame({ + targetId: "local", + frame: { + v: "omp-app/1", + type: "snapshot", + hostId: hostId("host-a"), + sessionId: sessionId("session-a"), + cursor: { epoch: "epoch-1", seq: 2 }, + revision: revision("revision-a"), + entries: [], + }, + }); + shell.emitFrame({ + targetId: "local", + frame: { + v: "omp-app/1", + type: "gap", + hostId: hostId("host-a"), + sessionId: sessionId("session-a"), + from: { epoch: "epoch-1", seq: 3 }, + to: { epoch: "epoch-1", seq: 3 }, + reason: "ring_evicted", + }, + }); + expect(runtime.getSnapshot().projection.sessions.get("host-a\u0000session-a")?.freshness).toBe("catching-up"); + shell.attachResult = { attached: true, cursor: { epoch: "epoch-1", seq: 2 } }; + shell.deferNextAttach(); + + const attached = runtime.attachSession("local", "host-a", "session-a", { epoch: "epoch-1", seq: 2 }); + await Promise.resolve(); + shell.emitState({ targetId: "local", state: "connecting" }); + shell.emitState({ targetId: "local", state: "connected" }); + shell.emitFrame({ targetId: "local", frame: welcome("host-a", ["sessions.read"], []) }); + shell.resolveAttach(); + await attached; + + expect(runtime.getSnapshot().projection.sessions.get("host-a\u0000session-a")?.freshness).toBe("fresh"); + }); it.each([ { authority: "the feature is default-off and unnegotiated", enabled: false, capabilities: [], features: [], granted: false }, { authority: "only the host claim is present", enabled: false, capabilities: ["sessions.read"], features: [CLUSTER_OPERATOR_FEATURE], granted: false }, @@ -1238,6 +1298,22 @@ describe("desktop runtime projection", () => { expect(calls).toBe(beforeStop); expect(runtime.getSnapshot().startState).toBe("stopped"); }); + it("does not publish a new snapshot for a duplicate connection state", async () => { + const shell = new FakeShell(); + const runtime = createDesktopRuntimeController({ shell }); + await runtime.start(); + let calls = 0; + runtime.subscribe(() => { + calls += 1; + }); + + shell.emitState({ targetId: "local", state: "connecting" }); + const afterTransition = calls; + shell.emitState({ targetId: "local", state: "connecting" }); + + expect(afterTransition).toBeGreaterThan(0); + expect(calls).toBe(afterTransition); + }); it("activates sessions without exposing mutable maps", async () => { const shell = new FakeShell(); const runtime = createDesktopRuntimeController({ shell }); diff --git a/packages/client/test/projection.test.ts b/packages/client/test/projection.test.ts index 5a57557c..13cc3a61 100644 --- a/packages/client/test/projection.test.ts +++ b/packages/client/test/projection.test.ts @@ -234,6 +234,82 @@ describe("client projections", () => { }); }); + it("keeps every returned ref current when a full bounded page rolls forward", () => { + const oldPage = Array.from( + { length: MAX_INDEXED_SESSION_REFS }, + (_, index) => ref(String(HOST), `listed-${index + 2}`), + ); + const currentPage = Array.from( + { length: MAX_INDEXED_SESSION_REFS }, + (_, index) => ref(String(HOST), `listed-${index}`), + ); + let state = applyPublicFrame(createProjectionSnapshot(), { + v: V, + type: "sessions", + hostId: HOST, + cursor: { epoch: "rolling", seq: 0 }, + sessions: oldPage, + totalCount: MAX_INDEXED_SESSION_REFS + 2, + truncated: true, + } as ProjectionFrame); + state = applyPublicFrame(state, frame("welcome")); + state = applyPublicFrame(state, { + v: V, + type: "sessions", + hostId: HOST, + cursor: { epoch: "rolling", seq: 1 }, + sessions: currentPage, + totalCount: MAX_INDEXED_SESSION_REFS + 2, + truncated: true, + } as ProjectionFrame); + + expect(state.sessionIndex.size).toBe(MAX_INDEXED_SESSION_REFS); + expect(state.sessionRefArrivalOrdinals.size).toBe(MAX_INDEXED_SESSION_REFS); + for (const listed of currentPage) { + const listedKey = sessionKey(String(listed.sessionId)); + expect(state.sessionIndex.has(listedKey)).toBe(true); + expect(state.sessionRefArrivalOrdinals.has(listedKey)).toBe(true); + } + expect(state.sessionIndex.has(sessionKey("listed-1000"))).toBe(false); + expect(state.sessionRefArrivalOrdinals.has(sessionKey("listed-1000"))).toBe(false); + }); + + it("evicts the oldest retained row when another host shares the global bound", () => { + const otherHost = hostId("projection-host-b"); + const options = { maxIndexedSessions: 4 }; + let state = applyPublicFrame(createProjectionSnapshot(), { + v: V, + type: "sessions", + hostId: HOST, + cursor: { epoch: "host-a", seq: 0 }, + sessions: [ + ref(String(HOST), "newest", { updatedAt: "2026-07-11T00:00:04Z" }), + ref(String(HOST), "newer", { updatedAt: "2026-07-11T00:00:03Z" }), + ref(String(HOST), "older", { updatedAt: "2026-07-11T00:00:02Z" }), + ref(String(HOST), "oldest", { updatedAt: "2026-07-11T00:00:01Z" }), + ], + totalCount: 4, + truncated: false, + } as ProjectionFrame, options); + state = applyPublicFrame(state, { + v: V, + type: "sessions", + hostId: otherHost, + cursor: { epoch: "host-b", seq: 0 }, + sessions: [ref(String(otherHost), "other", { updatedAt: "2026-07-10T00:00:00Z" })], + totalCount: 1, + truncated: false, + } as ProjectionFrame, options); + + expect(state.sessionIndex.has(sessionKey("newest"))).toBe(true); + expect(state.sessionIndex.has(sessionKey("newer"))).toBe(true); + expect(state.sessionIndex.has(sessionKey("older"))).toBe(true); + expect(state.sessionIndex.has(sessionKey("oldest"))).toBe(false); + expect(state.sessionIndex.has(`${String(otherHost)}\u0000other`)).toBe(true); + expect(state.sessionRefArrivalOrdinals.has(sessionKey("newest"))).toBe(true); + expect(state.sessionRefArrivalOrdinals.has(`${String(otherHost)}\u0000other`)).toBe(true); + }); + it("rejects a delayed lower same-epoch session inventory without regressing the index", () => { const currentRef = ref(String(HOST), "ordered", { title: "Current" }); const staleRef = ref(String(HOST), "ordered", { title: "Stale" }); @@ -276,10 +352,12 @@ describe("client projections", () => { truncated: false, } as unknown as ProjectionFrame); expect(state.sessionIndexMetadata.has(String(HOST))).toBe(true); + expect(state.sessionRefArrivalOrdinals.has(sessionKey("cached"))).toBe(true); state = applyPublicFrame(state, frame("welcome")); expect(state.sessionIndex.has(sessionKey("cached"))).toBe(true); expect(state.sessionIndexMetadata.has(String(HOST))).toBe(false); + expect(state.sessionRefArrivalOrdinals.has(sessionKey("cached"))).toBe(false); expect(state.sessionInventoryCursors.has(String(HOST))).toBe(false); state = applyPublicFrame(state, { @@ -295,6 +373,45 @@ describe("client projections", () => { totalCount: 1, truncated: false, }); + expect(state.sessionRefArrivalOrdinals.has(sessionKey("cached"))).toBe(true); + }); + + it("invalidates retained ref arrivals without deleting cached rows", () => { + const store = new ProjectionStore(); + store.applyPublicFrame({ + v: V, + type: "sessions", + hostId: HOST, + cursor: { epoch: "e1", seq: 1 }, + sessions: [ref(String(HOST), "cached")], + totalCount: 1, + truncated: false, + } as unknown as ProjectionFrame); + + const invalidated = store.invalidateSessionInventory(String(HOST)); + expect(invalidated.sessionIndex.has(sessionKey("cached"))).toBe(true); + expect(invalidated.sessionRefArrivalOrdinals.has(sessionKey("cached"))).toBe(false); + }); + + it("scopes welcome invalidation without discarding same-epoch transcript continuity", () => { + const hostB = hostId("projection-host-b"); + let state = applyPublicFrame(createProjectionSnapshot(), frame("snapshot", "session-a")); + state = applyPublicFrame(state, { + ...frame("snapshot", "session-b"), + hostId: hostB, + cursor: { epoch: "host-b-epoch", seq: 0 }, + } as ProjectionFrame); + expect(state.sessions.get(sessionKey("session-a"))?.freshness).toBe("fresh"); + expect(state.sessions.get(`${String(hostB)}\u0000session-b`)?.freshness).toBe("fresh"); + + state = applyPublicFrame(state, { + ...frame("welcome"), + hostId: hostB, + epoch: "host-b-epoch", + } as ProjectionFrame); + + expect(state.sessions.get(sessionKey("session-a"))?.freshness).toBe("fresh"); + expect(state.sessions.get(`${String(hostB)}\u0000session-b`)?.freshness).toBe("fresh"); }); it("reconciles authoritative session lists per host without retaining stale subagent rows", () => { diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts new file mode 100644 index 00000000..ad647394 --- /dev/null +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -0,0 +1,614 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { expect } from "vite-plus/test"; + +const encoder = new TextEncoder(); +const PAGE_CURSOR_SECRET = "t4-api-v1-conformance-page-cursor-secret-2026"; +const COMMAND_BYTES_MAX = 32; +const COMMAND_REQUEST_BYTES_MAX = 256; +const METADATA_VALUE_BYTES_MAX = 32; + +function hasAtMostCodePoints(value: string, maximum: number): boolean { + let count = 0; + for (const _codePoint of value) { + count += 1; + if (count > maximum) return false; + } + return true; +} + + +function validLabels(value: unknown): boolean { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const entries = Object.entries(value as Record); + return entries.length <= 32 && entries.every(([key, item]) => + /^[a-z][a-z0-9.-]{0,62}$/u.test(key) && typeof item === "string" && hasAtMostCodePoints(item, 128)); +} + +function createViolation(body: Record, textField: "name" | "title"): { field: string; rule: string } | undefined { + if (Object.keys(body).some((key) => key !== textField && key !== "labels")) return { field: "body", rule: "schema" }; + const text = body[textField]; + if (typeof text !== "string" || text === "" || !hasAtMostCodePoints(text, 128)) return { field: textField, rule: "length" }; + if (body.labels !== undefined && !validLabels(body.labels)) return { field: "labels", rule: "bounds" }; + return undefined; +} + +function validMutation(body: Record, textField: "name" | "title"): boolean { + const keys = Object.keys(body); + if (keys.length < 1 || keys.some((key) => key !== textField && key !== "labels")) return false; + const text = body[textField]; + return (text === undefined || (typeof text === "string" && text !== "" && hasAtMostCodePoints(text, 128))) && + (body.labels === undefined || validLabels(body.labels)); +} + +function json(status: number, body: unknown, headers: Record = {}): Response { + const selectedVersion = status === 401 ? {} : { "T4-API-Version": "1.0" }; + return Response.json(body, { status, headers: { ...selectedVersion, ...headers } }); +} + +function problem(status: number, code: string, message: string, extra: Record = {}): Response { + return json(status, { error: { code, message, requestId: `req-${code}`, retryable: status >= 500, ...extra } }, + status === 401 ? { "WWW-Authenticate": 'Bearer realm="t4"' } : {}); +} +const SNAPSHOT_BYTES_MAX = 16 * 1024 * 1024; + +function snapshotResponseAtBytes(sessionId: unknown, state: unknown, targetBytes: number): Response { + const entries = Array.from({ length: 4 }, (_, sequence) => ({ sequence, kind: "output", text: "" })); + const snapshot = { sessionId, cursor: "cursor-2", state, entries }; + let remaining = targetBytes - encoder.encode(JSON.stringify(snapshot)).byteLength; + for (const entry of entries) { + const astralCodePoints = Math.min(1_048_576, Math.floor(remaining / 4)); + entry.text = "💚".repeat(astralCodePoints); + remaining -= astralCodePoints * 4; + if (remaining > 0 && remaining < 4) { + entry.text += "x".repeat(remaining); + remaining = 0; + } + } + expect(remaining).toBe(0); + const body = JSON.stringify(snapshot); + expect(encoder.encode(body).byteLength).toBe(targetBytes); + return new Response(body, { status: 200, headers: { "Content-Type": "application/json", "T4-API-Version": "1.0" } }); +} + +function compareUtf16(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function assertWellFormed(value: string): void { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!Number.isInteger(next) || next < 0xdc00 || next > 0xdfff) throw new TypeError("JCS rejects lone UTF-16 surrogates"); + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + throw new TypeError("JCS rejects lone UTF-16 surrogates"); + } + } +} + +export function canonicalJson(value: unknown): string { + if (value === null || typeof value === "boolean") return JSON.stringify(value); + if (typeof value === "string") { + assertWellFormed(value); + return JSON.stringify(value); + } + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new TypeError("JCS only permits finite JSON numbers"); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (typeof value === "object") { + return `{${Object.entries(value as Record) + .filter(([, item]) => item !== undefined) + .sort(([left], [right]) => compareUtf16(left, right)) + .map(([key, item]) => { + assertWellFormed(key); + return `${JSON.stringify(key)}:${canonicalJson(item)}`; + }) + .join(",")}}`; + } + throw new TypeError("JCS identity requires a JSON value"); +} + +function hasDuplicateJsonKeys(source: string): boolean { + let offset = 0; + const whitespace = (): void => { + while (/\s/u.test(source[offset] ?? "")) offset += 1; + }; + const string = (): string => { + const start = offset; + offset += 1; + while (offset < source.length) { + if (source[offset] === "\\") offset += 2; + else if (source[offset++] === '"') break; + } + return JSON.parse(source.slice(start, offset)) as string; + }; + const value = (): boolean => { + whitespace(); + if (source[offset] === "{") { + offset += 1; + whitespace(); + const keys = new Set(); + if (source[offset] === "}") { offset += 1; return false; } + while (offset < source.length) { + const key = string(); + if (keys.has(key)) return true; + keys.add(key); + whitespace(); + offset += 1; + if (value()) return true; + whitespace(); + if (source[offset] === "}") { offset += 1; return false; } + offset += 1; + whitespace(); + } + } else if (source[offset] === "[") { + offset += 1; + whitespace(); + if (source[offset] === "]") { offset += 1; return false; } + while (offset < source.length) { + if (value()) return true; + whitespace(); + if (source[offset] === "]") { offset += 1; return false; } + offset += 1; + } + } else if (source[offset] === '"') { + string(); + } else { + while (offset < source.length && !/[\s,}\]]/u.test(source[offset]!)) offset += 1; + } + return false; + }; + return value(); +} + +type PageCursorResult = { readonly offset: number } | { readonly error: "format" | "issued" }; + +function issuePageCursor(principal: string, collection: string, offset: number): string { + const payload = Buffer.from(canonicalJson({ principal, collection, offset })).toString("base64url"); + const signature = createHmac("sha256", PAGE_CURSOR_SECRET).update(payload).digest("base64url"); + return `page.${payload}.${signature}`; +} + +function decodePageCursor(value: string, principal: string, collection: string): PageCursorResult { + if (value.length > 512) return { error: "format" }; + const legacy = /^page-(?:0|[1-9][0-9]*)$/u.exec(value); + if (legacy !== null) return Number.isSafeInteger(Number(value.slice(5))) ? { error: "issued" } : { error: "format" }; + const match = /^page\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]{43})$/u.exec(value); + if (match === null) return { error: "format" }; + const expected = createHmac("sha256", PAGE_CURSOR_SECRET).update(match[1]!).digest(); + const signature = Buffer.from(match[2]!, "base64url"); + if (signature.toString("base64url") !== match[2]) return { error: "format" }; + if (signature.byteLength !== expected.byteLength || !timingSafeEqual(signature, expected)) return { error: "issued" }; + let decoded: unknown; + try { decoded = JSON.parse(Buffer.from(match[1]!, "base64url").toString("utf8")); } catch { return { error: "format" }; } + if (decoded === null || typeof decoded !== "object" || Array.isArray(decoded)) return { error: "format" }; + const payload = decoded as Record; + if (Object.keys(payload).length !== 3 || !Object.hasOwn(payload, "principal") || !Object.hasOwn(payload, "collection") || !Object.hasOwn(payload, "offset") || + Buffer.from(canonicalJson(payload)).toString("base64url") !== match[1] || + typeof payload.principal !== "string" || typeof payload.collection !== "string" || + !Number.isSafeInteger(payload.offset) || Number(payload.offset) < 1) return { error: "format" }; + if (payload.principal !== principal || payload.collection !== collection) return { error: "issued" }; + return { offset: Number(payload.offset) }; +} + +interface ReplayRecord { + readonly identity: string; + readonly body: unknown; + readonly replayStatus: number; + readonly cursor: string; +} + + +export interface T4ApiV1ConformanceOptions { + readonly invalidPayload?: "discovery" | "workspace" | "session" | "command"; + readonly snapshotBoundary?: "exact" | "over"; + readonly watchTransport?: "normal" | "bytewise" | "oversized" | "many-small"; +} + +export class T4ApiV1ConformanceService { + readonly origin = "https://t4-api.conformance.test"; + readonly calls: Array<{ method: string; path: string; authorization: string | null }> = []; + readonly abortedWatches: string[] = []; + readonly watchCursors: Array<{ query: string | null; header: string | null }> = []; + readonly watchQueries: Array<{ maxEvents: string | null; heartbeatSeconds: string | null }> = []; + + #workspaceSequence = 0; + #sessionSequence = 0; + #commandSequence = 0; + #eventSequence = 0; + readonly #workspaces = new Map>(); + readonly #sessions = new Map>(); + readonly #replays = new Map(); + + constructor(readonly options: T4ApiV1ConformanceOptions = {}) {} + + readonly fetch: typeof globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + const authorization = request.headers.get("authorization"); + this.calls.push({ method: request.method, path: url.pathname, authorization }); + if (url.origin !== this.origin) return problem(400, "invalid_origin", "HTTPS API origin is fixed for this client"); + if (url.protocol !== "https:") return problem(400, "https_required", "HTTPS is required"); + if (authorization !== "Bearer token-a" && authorization !== "Bearer token-b" && authorization !== "Bearer token-denied") { + return problem(401, "unauthenticated", "A valid bearer credential is required"); + } + if (request.headers.get("T4-API-Version") !== "1") { + return problem(406, "incompatible_version", "No compatible T4 API major version", { supportedMajors: [1] }); + } + if (authorization === "Bearer token-denied") return problem(403, "forbidden", "Credential lacks the required operation scope"); + const tenant = authorization === "Bearer token-a" ? "tenant-a" : "tenant-b"; + + if (request.method === "GET" && url.pathname === "/v1") { + return json(200, this.#payload("discovery", { + apiVersion: "1.0", + serverBuild: { version: "0.1.30", revision: "fixture-revision-1" }, + supportedMajors: [1], + capabilities: { + "workspace.lifecycle": { supported: true, enabled: true, authorized: true, available: true }, + "session.lifecycle": { supported: true, enabled: true, authorized: true, available: true }, + "session.commands": { supported: true, enabled: true, authorized: true, available: true }, + "session.watch.sse": { supported: true, enabled: true, authorized: true, available: true }, + "optional.preview": { + supported: true, enabled: false, authorized: false, available: false, + deprecation: { message: "Use workspace.lifecycle", sinceVersion: "1.0", sunsetAt: "2027-01-01T00:00:00Z", replacement: "workspace.lifecycle" }, + }, + }, + limits: { + pageSizeDefault: 2, + pageSizeMax: 3, + commandBytesMax: COMMAND_BYTES_MAX, + commandRequestBytesMax: COMMAND_REQUEST_BYTES_MAX, + commandMetadataValueBytesMax: METADATA_VALUE_BYTES_MAX, + watchEventsDefault: 1, + watchEventsMax: this.options.watchTransport === "many-small" ? 1000 : 4, + heartbeatSeconds: 15, + }, + })); + } + + if (request.method === "POST" && url.pathname === "/v1/workspaces") { + const parsed = await this.#jsonBody(request); + if (parsed instanceof Response) return parsed; + const body = parsed; + const violation = createViolation(body, "name"); + if (violation !== undefined) return this.#invalid(violation.field, violation.rule, "workspace create must match WorkspaceCreate"); + return this.#idempotent(request, tenant, "createWorkspace", [], body, 202, 200, () => { + const id = `ws-${++this.#workspaceSequence}`; + const workspace = { id, name: body.name, ...(body.labels === undefined ? {} : { labels: body.labels }), state: "accepted", revision: 1, tenant }; + this.#workspaces.set(id, workspace); + return workspace; + }); + } + + if (request.method === "GET" && url.pathname === "/v1/workspaces") { + const pageSize = Number(url.searchParams.get("pageSize") ?? "2"); + if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 3) return this.#invalid("pageSize", "range", "pageSize must be between 1 and 3"); + const collection = "workspaces"; + const cursor = url.searchParams.get("cursor"); + let start = 0; + if (cursor !== null) { + const decoded = decodePageCursor(cursor, tenant, collection); + if ("error" in decoded) return this.#invalid("cursor", decoded.error, decoded.error === "format" ? "cursor must be a canonical bounded opaque token" : "cursor was not issued for this principal and collection"); + start = decoded.offset; + } + const visible = [...this.#workspaces.values()].filter((item) => item.tenant === tenant); + if (cursor !== null && start >= visible.length) return this.#invalid("cursor", "issued", "cursor is outside the current collection"); + const items = visible.slice(start, start + pageSize).map(({ tenant: _tenant, ...item }) => item); + const next = start + items.length < visible.length ? issuePageCursor(tenant, collection, start + items.length) : undefined; + return json(200, { items, ...(next === undefined ? {} : { nextCursor: next }) }); + } + + const workspaceMatch = url.pathname.match(/^\/v1\/workspaces\/([^/]+)$/u); + if (workspaceMatch) { + const id = decodeURIComponent(workspaceMatch[1]!); + if (request.method === "PATCH") { + const ifRevision = request.headers.get("T4-If-Revision"); + if (ifRevision === null || !/^[1-9][0-9]{0,18}$/u.test(ifRevision)) return problem(400, "invalid_request", "T4-If-Revision is invalid"); + } + const workspace = this.#workspaces.get(id); + if (request.method === "DELETE") { + return this.#idempotent(request, tenant, "deleteWorkspace", [id], null, 204, 204, () => { + this.#workspaces.delete(id); + return null; + }, () => workspace?.tenant === tenant ? undefined : problem(404, "not_found", "Workspace not found")); + } + if (workspace?.tenant !== tenant) return problem(404, "not_found", "Workspace not found"); + if (request.method === "GET") return json(200, this.#payload("workspace", this.#visible(workspace))); + if (request.method === "PATCH") { + const parsed = await this.#jsonBody(request); + if (parsed instanceof Response) return parsed; + const body = parsed; + if (!validMutation(body, "name")) return this.#invalid("body", "schema", "workspace mutation must match WorkspaceMutation"); + let currentWorkspace: Record | undefined; + return this.#idempotent(request, tenant, "mutateWorkspace", [id], body, 200, 200, () => { + const updated = { ...currentWorkspace!, ...(body.name === undefined ? {} : { name: body.name }), ...(body.labels === undefined ? {} : { labels: body.labels }), revision: Number(currentWorkspace!.revision) + 1 }; + this.#workspaces.set(id, updated); + return updated; + }, () => { + currentWorkspace = this.#workspaces.get(id); + if (currentWorkspace?.tenant !== tenant) return problem(404, "not_found", "Workspace not found"); + return request.headers.get("T4-If-Revision") === String(currentWorkspace.revision) + ? undefined + : problem(409, "revision_conflict", "Workspace revision changed"); + }); + } + } + + const sessionsPath = url.pathname.match(/^\/v1\/workspaces\/([^/]+)\/sessions$/u); + if (sessionsPath) { + const workspaceId = decodeURIComponent(sessionsPath[1]!); + const workspace = this.#workspaces.get(workspaceId); + if (workspace?.tenant !== tenant) return problem(404, "not_found", "Workspace not found"); + if (request.method === "POST") { + const parsed = await this.#jsonBody(request); + if (parsed instanceof Response) return parsed; + const body = parsed; + const violation = createViolation(body, "title"); + if (violation !== undefined) return this.#invalid(violation.field, violation.rule, "session create must match SessionCreate"); + return this.#idempotent(request, tenant, "spawnSession", [workspaceId], body, 202, 200, () => { + const id = `ses-${++this.#sessionSequence}`; + const session = { id, workspaceId, title: body.title, ...(body.labels === undefined ? {} : { labels: body.labels }), state: "accepted", revision: 1, tenant }; + this.#sessions.set(id, session); + return session; + }); + } + if (request.method === "GET") { + const pageSize = Number(url.searchParams.get("pageSize") ?? "2"); + if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 3) return this.#invalid("pageSize", "range", "pageSize must be between 1 and 3"); + const collection = `workspaces/${workspaceId}/sessions`; + const cursor = url.searchParams.get("cursor"); + let start = 0; + if (cursor !== null) { + const decoded = decodePageCursor(cursor, tenant, collection); + if ("error" in decoded) return this.#invalid("cursor", decoded.error, decoded.error === "format" ? "cursor must be a canonical bounded opaque token" : "cursor was not issued for this principal and collection"); + start = decoded.offset; + } + const visible = [...this.#sessions.values()].filter((item) => item.workspaceId === workspaceId && item.tenant === tenant); + if (cursor !== null && start >= visible.length) return this.#invalid("cursor", "issued", "cursor is outside the current collection"); + const items = visible.slice(start, start + pageSize).map((item) => this.#visible(item)); + const next = start + items.length < visible.length ? issuePageCursor(tenant, collection, start + items.length) : undefined; + return json(200, { items, ...(next === undefined ? {} : { nextCursor: next }) }); + } + } + + const sessionMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)$/u); + if (sessionMatch) { + const id = decodeURIComponent(sessionMatch[1]!); + if (request.method === "PATCH") { + const ifRevision = request.headers.get("T4-If-Revision"); + if (ifRevision === null || !/^[1-9][0-9]{0,18}$/u.test(ifRevision)) return problem(400, "invalid_request", "T4-If-Revision is invalid"); + } + const session = this.#sessions.get(id); + if (request.method === "DELETE") { + return this.#idempotent(request, tenant, "deleteSession", [id], null, 204, 204, () => { + this.#sessions.delete(id); + return null; + }, () => session?.tenant === tenant ? undefined : problem(404, "not_found", "Session not found")); + } + if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); + if (request.method === "GET") return json(200, this.#payload("session", this.#visible(session))); + if (request.method === "PATCH") { + const parsed = await this.#jsonBody(request); + if (parsed instanceof Response) return parsed; + const body = parsed; + if (!validMutation(body, "title")) return this.#invalid("body", "schema", "session mutation must match SessionMutation"); + let currentSession: Record | undefined; + return this.#idempotent(request, tenant, "mutateSession", [id], body, 200, 200, () => { + const updated = { ...currentSession!, ...(body.title === undefined ? {} : { title: body.title }), ...(body.labels === undefined ? {} : { labels: body.labels }), revision: Number(currentSession!.revision) + 1 }; + this.#sessions.set(id, updated); + return updated; + }, () => { + currentSession = this.#sessions.get(id); + if (currentSession?.tenant !== tenant) return problem(404, "not_found", "Session not found"); + return request.headers.get("T4-If-Revision") === String(currentSession.revision) + ? undefined + : problem(409, "revision_conflict", "Session revision changed"); + }); + } + } + + const cancelMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/cancel$/u); + if (cancelMatch && request.method === "POST") { + const id = decodeURIComponent(cancelMatch[1]!); + const session = this.#sessions.get(id); + if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); + return this.#idempotent(request, tenant, "cancelSession", [id], null, 202, 200, () => { + const cancelled = { ...session, state: "cancelled", revision: Number(session.revision) + 1 }; + this.#sessions.set(id, cancelled); + return cancelled; + }); + } + + const commandMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/commands$/u); + if (commandMatch && request.method === "POST") { + const id = decodeURIComponent(commandMatch[1]!); + const session = this.#sessions.get(id); + if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); + const mediaError = this.#jsonMediaError(request); + if (mediaError !== undefined) return mediaError; + const text = await request.text(); + if (encoder.encode(text).byteLength > COMMAND_REQUEST_BYTES_MAX) return this.#invalid("body", "maxBytes", `request must not exceed ${COMMAND_REQUEST_BYTES_MAX} UTF-8 bytes`); + let decoded: unknown; + try { + decoded = JSON.parse(text); + if (hasDuplicateJsonKeys(text)) return problem(400, "invalid_request", "JSON request body contains duplicate object members"); + } catch { return problem(400, "invalid_request", "Malformed JSON request"); } + if (decoded === null || typeof decoded !== "object" || Array.isArray(decoded)) return this.#invalid("body", "schema", "command create must match CommandCreate"); + const body = decoded as Record; + if (Object.keys(body).some((key) => key !== "command" && key !== "metadata")) return this.#invalid("body", "schema", "command create must match CommandCreate"); + if (typeof body.command !== "string" || body.command.length < 1 || encoder.encode(body.command).byteLength > COMMAND_BYTES_MAX) return this.#invalid("command", "maxBytes", `command must contain 1 to ${COMMAND_BYTES_MAX} UTF-8 bytes`); + if (!this.#validMetadata(body.metadata)) return this.#invalid("metadata", "bounds", "metadata keys and values exceed the discovered bounds"); + const states: Record = { accepted: true, projected: true, dispatching: true, running: true, succeeded: true, failed: true, cancelling: true, cancelled: true, rejected: true, unavailable: true, indeterminate: true }; + const state = states[body.command] === true ? body.command : "accepted"; + return this.#idempotent(request, tenant, "submitCommand", [id], { metadata: {}, ...body }, 202, 200, () => ({ commandId: `cmd-${++this.#commandSequence}`, state })); + } + + const snapshotMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/snapshot$/u); + if (snapshotMatch && request.method === "GET") { + const session = this.#sessions.get(decodeURIComponent(snapshotMatch[1]!)); + if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); + if (this.options.snapshotBoundary !== undefined) { + return snapshotResponseAtBytes(session.id, session.state, SNAPSHOT_BYTES_MAX + (this.options.snapshotBoundary === "over" ? 1 : 0)); + } + return json(200, { sessionId: session.id, cursor: "cursor-2", state: session.state, entries: [{ sequence: 2, kind: "output", text: "ready" }] }); + } + + const watchMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/events$/u); + if (watchMatch && request.method === "GET") { + if (request.headers.get("Accept") !== "text/event-stream") return problem(406, "incompatible_version", "Watch requests must accept text/event-stream", { supportedMajors: [1] }); + const id = decodeURIComponent(watchMatch[1]!); + const session = this.#sessions.get(id); + if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); + const maxEvents = Number(url.searchParams.get("maxEvents") ?? "100"); + const heartbeat = Number(url.searchParams.get("heartbeatSeconds") ?? "15"); + this.watchQueries.push({ maxEvents: url.searchParams.get("maxEvents"), heartbeatSeconds: url.searchParams.get("heartbeatSeconds") }); + if (!Number.isInteger(maxEvents) || maxEvents < 1 || maxEvents > (this.options.watchTransport === "many-small" ? 1000 : 4)) return this.#invalid("maxEvents", "range", "maxEvents exceeds the discovered bound"); + if (!Number.isInteger(heartbeat) || heartbeat < 5 || heartbeat > 60) return this.#invalid("heartbeatSeconds", "range", "heartbeatSeconds must be between 5 and 60"); + const queryCursor = url.searchParams.get("cursor"); + const headerCursor = request.headers.get("Last-Event-ID"); + this.watchCursors.push({ query: queryCursor, header: headerCursor }); + if (queryCursor !== null && headerCursor !== null && queryCursor !== headerCursor) return problem(400, "invalid_request", "Reconnect cursors disagree"); + const cursor = queryCursor ?? headerCursor; + if (cursor === "expired") return problem(410, "cursor_expired", "Watch cursor is no longer retained", { resync: { snapshotUrl: `v1/sessions/${id}/snapshot`, cursor: "cursor-2" } }); + const allFrames = cursor === "cursor-4" + ? [`id: cursor-4\r\nevent: heartbeat\r\ndata: {"type":"heartbeat","cursor":"cursor-4","observedAt":"2026-07-21T00:00:15Z"}\r\n\r\n`] + : cursor === "cursor-5" + ? [`id: cursor-6\nevent: command\ndata: {"type":"command","cursor":"cursor-6","commandId":"cmd-1","state":"accepted"}\n\n`] + : [ + `id: ${cursor ?? "cursor-3"}\r\nevent: heartbeat\r\ndata: {"type":"heartbeat","cursor":"${cursor ?? "cursor-3"}","observedAt":"2026-07-21T00:00:00Z"}\r\n\r\n`, + `id: cursor-4\r\nevent: session\r\ndata: {"type":"session","cursor":"cursor-4","state":"accepted","revision":2}\r\n\r\n`, + ]; + let payload: Uint8Array; + if (this.options.watchTransport === "oversized") { + payload = encoder.encode(`: ${"x".repeat(1024 * 1024)}\ndata: {"type":"heartbeat","cursor":"large-1","observedAt":"2026-07-21T00:00:00Z"}\n\n`); + } else if (this.options.watchTransport === "many-small") { + payload = encoder.encode(Array.from({ length: maxEvents }, () => `: ${"x".repeat(1010)}\ndata: {"type":"heartbeat","cursor":"bulk-0","observedAt":"2026-07-21T00:00:00Z"}\n\n`).join("")); + } else { + const prefix = this.options.watchTransport === "bytewise" ? ": 💚\n" : ""; + payload = encoder.encode(prefix + allFrames.slice(0, maxEvents).join("")); + } + const stream = new ReadableStream({ + start: (controller) => { + if (this.options.watchTransport === "bytewise") { + for (const byte of payload) controller.enqueue(Uint8Array.of(byte)); + } else { + const split = Math.max(1, payload.byteLength - 3); + controller.enqueue(payload.slice(0, split)); + controller.enqueue(payload.slice(split)); + } + controller.close(); + request.signal.addEventListener("abort", () => this.abortedWatches.push(id), { once: true }); + }, + }); + return new Response(stream, { status: 200, headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-store", "T4-API-Version": "1.0" } }); + } + + return problem(404, "not_found", "Resource not found"); + }; + + expectNoCredentialLeak(): void { + expect(this.calls.every((call) => call.authorization === "Bearer token-a" || call.authorization === "Bearer token-b" || call.authorization === "Bearer token-denied")).toBe(true); + } + + #jsonMediaError(request: Request): Response | undefined { + const mediaType = request.headers.get("Content-Type")?.split(";", 1)[0]?.trim().toLowerCase(); + return mediaType === "application/json" ? undefined : problem(400, "invalid_request", "Content-Type must be application/json"); + } + + async #jsonBody(request: Request): Promise | Response> { + const mediaError = this.#jsonMediaError(request); + if (mediaError !== undefined) return mediaError; + try { + const text = await request.text(); + const value: unknown = JSON.parse(text); + if (hasDuplicateJsonKeys(text)) return problem(400, "invalid_request", "JSON request body contains duplicate object members"); + if (value === null || typeof value !== "object" || Array.isArray(value)) return problem(400, "invalid_request", "JSON request body must be an object"); + return value as Record; + } catch { + return problem(400, "invalid_request", "Malformed JSON request"); + } + } + + #payload(kind: T4ApiV1ConformanceOptions["invalidPayload"], value: Record): Record { + return this.options.invalidPayload === kind ? { ...value, unknown: true } : value; + } + + #invalid(field: string, rule: string, message: string): Response { + return problem(422, "invalid_request", "Request validation failed", { violations: [{ field, rule, message }] }); + } + + #visible(value: Record): Record { + const { tenant: _tenant, ...visible } = value; + return visible; + } + + #validMetadata(value: unknown): boolean { + if (value === undefined) return true; + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const entries = Object.entries(value as Record); + return entries.length <= 32 && entries.every(([key, item]) => + /^[a-z][a-z0-9.-]{0,62}$/u.test(key) && + (item === null || typeof item === "boolean" || + (typeof item === "number" && Number.isSafeInteger(item)) || + (typeof item === "string" && encoder.encode(item).byteLength <= METADATA_VALUE_BYTES_MAX))); + } + + + #idempotent( + request: Request, + principal: string, + operationId: string, + targets: readonly string[], + body: unknown, + firstStatus: number, + replayStatus: number, + create: () => unknown, + validate?: () => Response | undefined, + ): Response { + const key = request.headers.get("Idempotency-Key"); + if (key === null) return problem(400, "idempotency_key_required", "Idempotency-Key is required"); + if (!/^[A-Za-z0-9._~-]{16,128}$/u.test(key)) return problem(400, "invalid_request", "Idempotency-Key is invalid"); + const preconditions = request.headers.has("T4-If-Revision") ? { "t4-if-revision": request.headers.get("T4-If-Revision") } : {}; + let identity: string; + try { + identity = canonicalJson({ operationId, targets, preconditions, body }); + } catch (error) { + if (error instanceof TypeError) return this.#invalid("body", "wellFormedUnicode", "request identity must not contain lone UTF-16 surrogates"); + throw error; + } + const scope = canonicalJson({ principal, operationId, targets, key }); + const prior = this.#replays.get(scope); + if (prior !== undefined) { + if (prior.identity !== identity) return problem(409, "idempotency_conflict", "Idempotency key was reused with a different canonical request"); + const headers = { "T4-API-Version": "1.0", "Idempotency-Replayed": "true", "T4-Event-Cursor": prior.cursor }; + return prior.body === null + ? new Response(null, { status: prior.replayStatus, headers }) + : json(prior.replayStatus, prior.body, headers); + } + const validation = validate?.(); + if (validation !== undefined) return validation; + const created = create(); + const cursor = `event-${++this.#eventSequence}`; + const visibleValue = created !== null && typeof created === "object" && !Array.isArray(created) + ? this.#visible(created as Record) + : created; + const responseKind = operationId === "submitCommand" + ? "command" + : operationId === "spawnSession" || operationId === "mutateSession" || operationId === "cancelSession" + ? "session" + : operationId === "createWorkspace" || operationId === "mutateWorkspace" + ? "workspace" + : undefined; + const visible = visibleValue !== null && typeof visibleValue === "object" && !Array.isArray(visibleValue) && responseKind !== undefined + ? this.#payload(responseKind, visibleValue as Record) + : visibleValue; + this.#replays.set(scope, { identity, body: visible, replayStatus, cursor }); + const headers = { "Idempotency-Replayed": "false", "T4-Event-Cursor": cursor }; + return visible === null + ? new Response(null, { status: firstStatus, headers: { "T4-API-Version": "1.0", ...headers } }) + : json(firstStatus, visible, headers); + } +} diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts new file mode 100644 index 00000000..77953bc8 --- /dev/null +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -0,0 +1,2041 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + T4ApiError, + createT4ApiClient, + type components, +} from "@t4-code/t4-api-client"; +import { T4ApiV1ConformanceService, canonicalJson } from "./t4-api-v1-conformance-service.ts"; + +type WorkspaceCreate = components["schemas"]["WorkspaceCreate"]; +type SessionCreate = components["schemas"]["SessionCreate"]; +type CommandCreate = components["schemas"]["CommandCreate"]; + +type WatchEvent = components["schemas"]["WatchEvent"]; + +function exhaustWatchEvent(event: WatchEvent): string { + switch (event.type) { + case "heartbeat": return event.observedAt; + case "session": return `${event.state}:${event.revision}`; + case "command": return `${event.commandId}:${event.state}`; + default: { + const unreachable: never = event; + return unreachable; + } + } +} + +const VERSION_HEADERS = {} as const; + +const COMMAND_STATES = [ + "accepted", "projected", "dispatching", "running", "succeeded", "failed", + "cancelling", "cancelled", "rejected", "unavailable", "indeterminate", +] as const satisfies readonly components["schemas"]["CommandState"][]; +const DISCOVERY = { + apiVersion: "1.0", + serverBuild: { version: "0.1.30", revision: "fixture-revision-1" }, + supportedMajors: [1], + capabilities: { + "workspace.lifecycle": { supported: true, enabled: true, authorized: true, available: true }, + "optional.preview": { + supported: true, enabled: false, authorized: false, available: false, + deprecation: { message: "Use workspace.lifecycle", sinceVersion: "1.0", sunsetAt: "2027-01-01T00:00:00Z", replacement: "workspace.lifecycle" }, + }, + }, + limits: { pageSizeDefault: 1, pageSizeMax: 1, commandBytesMax: 1, commandRequestBytesMax: 1, commandMetadataValueBytesMax: 1, watchEventsDefault: 1, watchEventsMax: 1, heartbeatSeconds: 5 }, +} as const; + +function withSelectedVersion(init: ResponseInit = {}): ResponseInit { + const headers = new Headers(init.headers); + if (init.status !== 401 && !headers.has("T4-API-Version")) headers.set("T4-API-Version", "1.0"); + if (headers.get("Content-Type")?.split(";", 1)[0]?.trim().toLowerCase() === "text/event-stream" && !headers.has("Cache-Control")) headers.set("Cache-Control", "no-store"); + return { ...init, headers }; +} + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return Response.json(body, withSelectedVersion(init)); +} + +function apiResponse(body?: BodyInit | null, init: ResponseInit = {}): Response { + return new Response(body, withSelectedVersion(init)); +} + +function idempotencyHeaders(key: string): Readonly<{ "Idempotency-Key": string }> { + return { "Idempotency-Key": key }; +} + +function mutationHeaders(revision: number, key: string): Readonly<{ "T4-If-Revision": string; "Idempotency-Key": string }> { + return { "T4-If-Revision": String(revision), "Idempotency-Key": key }; +} + +function requireEventCursor(response: Response): string { + const cursor = response.headers.get("T4-Event-Cursor"); + expect(cursor).toMatch(/^[A-Za-z0-9._~-]+$/u); + return cursor ?? ""; +} + +function requireData(result: { data?: T; error?: unknown }): T { + expect(result.error).toBeUndefined(); + expect(result.data).toBeDefined(); + return result.data!; +} + +async function seededClient(service: T4ApiV1ConformanceService, key: string) { + const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + requireData(await client.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders(`workspace-${key}-0001`) }, + })); + requireData(await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: "agent" }, params: { header: idempotencyHeaders(`session-${key}-00001`), path: { workspaceId: "ws-1" } }, + })); + return client; +} + +describe("generated T4 API v1 client conformance", () => { + it("negotiates truthful discovery metadata and rejects an incompatible major", async () => { + const service = new T4ApiV1ConformanceService(); + const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + const discovery = requireData(await client.http.GET("/v1", { params: { header: VERSION_HEADERS } })); + expect(discovery).toMatchObject({ + apiVersion: "1.0", + serverBuild: { version: "0.1.30", revision: "fixture-revision-1" }, + supportedMajors: [1], + capabilities: { + "workspace.lifecycle": { supported: true, enabled: true, authorized: true, available: true }, + "optional.preview": { supported: true, enabled: false, authorized: false, available: false }, + }, + limits: { pageSizeMax: 3, commandBytesMax: 32, watchEventsMax: 4, heartbeatSeconds: 15 }, + }); + + const incompatible = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 2, fetch: service.fetch }); + const rejected = await incompatible.http.GET("/v1", { params: { header: VERSION_HEADERS } }); + expect(rejected.response.status).toBe(406); + expect(rejected.error).toMatchObject({ error: { code: "incompatible_version", retryable: false, supportedMajors: [1] } }); + + const silentDowngrade = createT4ApiClient({ + baseUrl: "https://silent-downgrade.test", credential: "token-a", majorVersion: 2, + fetch: async () => jsonResponse(DISCOVERY), + }); + await expect(silentDowngrade.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ + code: "indeterminate", status: 502, retryable: false, + }); + + const unauthenticated = await service.fetch(`${service.origin}/v1`, { headers: { "T4-API-Version": "1" } }); + expect(unauthenticated.status).toBe(401); + expect(unauthenticated.headers.has("T4-API-Version")).toBe(false); + expect(await unauthenticated.json()).toMatchObject({ error: { code: "unauthenticated", retryable: false } }); + + const denied = createT4ApiClient({ baseUrl: service.origin, credential: "token-denied", majorVersion: 1, fetch: service.fetch }); + const forbidden = await denied.http.GET("/v1", { params: { header: VERSION_HEADERS } }); + expect(forbidden.response.status).toBe(403); + expect(forbidden.error).toMatchObject({ error: { code: "forbidden" } }); + expect(discovery.limits).toMatchObject({ commandBytesMax: 32, commandRequestBytesMax: 256, commandMetadataValueBytesMax: 32 }); + }); + + it("keeps transport, routing, serializers, and middleware under SDK ownership", async () => { + let transportCalls = 0; + const client = createT4ApiClient({ + baseUrl: "https://sdk-owned.test", + credential: "token-a", + majorVersion: 1, + fetch: async () => { + transportCalls += 1; + return jsonResponse(DISCOVERY); + }, + }); + const get = client.http.GET as unknown as ( + path: string, + init: Record, + ) => Promise; + for (const [key, value] of [ + ["baseUrl", "https://exfiltration.test"], + ["fetch", async () => jsonResponse(DISCOVERY)], + ["headers", { Authorization: "Bearer attacker" }], + ["middleware", [{ onRequest: () => jsonResponse(DISCOVERY) }]], + ["querySerializer", () => ""], + ["bodySerializer", () => "{}"], + ["pathSerializer", () => "/v1"], + ["Request", Request], + ["method", "POST"], + ] as const) { + expect(() => get("/v1", { [key]: value, params: { header: VERSION_HEADERS } })).toThrow( + `T4 API request option ${key} is SDK-owned`, + ); + } + expect(transportCalls).toBe(0); + expect("use" in client.http).toBe(false); + expect("eject" in client.http).toBe(false); + }); + + it("uses a watch default that fits every advertised positive service bound", async () => { + const service = new T4ApiV1ConformanceService(); + const client = createT4ApiClient({ + baseUrl: service.origin, + credential: "token-a", + majorVersion: 1, + fetch: service.fetch, + }); + requireData(await client.http.POST("/v1/workspaces", { + body: { name: "workspace" }, + params: { header: idempotencyHeaders("workspace-watch-default") }, + })); + requireData(await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: "agent" }, + params: { + header: idempotencyHeaders("session-watch-default"), + path: { workspaceId: "ws-1" }, + }, + })); + const stream = client.watchSession("ses-1", { maxReconnectAttempts: 0 }); + await expect(stream.next()).resolves.toMatchObject({ done: false }); + await stream.return(undefined); + expect(service.watchQueries.at(-1)).toEqual({ maxEvents: "1", heartbeatSeconds: "15" }); + }); + + it("binds successful resource identities to the requested route", async () => { + const workspace = { id: "ws-other", name: "workspace", state: "ready", revision: 1 }; + const session = { id: "ses-other", workspaceId: "ws-other", title: "agent", state: "ready", revision: 1 }; + const cases = [ + ["/v1/workspaces/{workspaceId}", { params: { header: VERSION_HEADERS, path: { workspaceId: "ws-1" } } }, workspace], + ["/v1/workspaces/{workspaceId}/sessions", { params: { header: VERSION_HEADERS, path: { workspaceId: "ws-1" } } }, { items: [session] }], + ["/v1/sessions/{sessionId}", { params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } } }, session], + ["/v1/sessions/{sessionId}/snapshot", { params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } } }, { sessionId: "ses-other", cursor: "cursor-1", state: "ready", entries: [] }], + ] as const; + for (const [path, init, body] of cases) { + const client = createT4ApiClient({ + baseUrl: "https://identity-binding.test", + credential: "token-a", + majorVersion: 1, + fetch: async () => jsonResponse(body), + }); + const get = client.http.GET as unknown as (route: string, options: unknown) => Promise; + await expect(get(path, init)).rejects.toMatchObject({ status: 502, code: "indeterminate" }); + } + const sessionClient = createT4ApiClient({ + baseUrl: "https://identity-binding.test", + credential: "token-a", + majorVersion: 1, + fetch: async () => jsonResponse(session, { + status: 202, + headers: { "Idempotency-Replayed": "false", "T4-Event-Cursor": "event-1" }, + }), + }); + await expect(sessionClient.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: "agent" }, + params: { + header: idempotencyHeaders("identity-session-create"), + path: { workspaceId: "ws-1" }, + }, + })).rejects.toMatchObject({ status: 502, code: "indeterminate" }); + await expect(sessionClient.http.POST("/v1/sessions/{sessionId}/cancel", { + params: { + header: idempotencyHeaders("identity-session-cancel"), + path: { sessionId: "ses-1" }, + }, + })).rejects.toMatchObject({ status: 502, code: "indeterminate" }); + }); + + it("creates, canonically replays, conflicts, mutates, paginates, isolates, and deletes workspaces", async () => { + const service = new T4ApiV1ConformanceService(); + const owner = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + const other = createT4ApiClient({ baseUrl: service.origin, credential: "token-b", majorVersion: 1, fetch: service.fetch }); + const body = { name: "primary", labels: { beta: "2", alpha: "1" } } satisfies WorkspaceCreate; + const first = await owner.http.POST("/v1/workspaces", { + body, + params: { header: idempotencyHeaders("workspace-create-0001") }, + }); + expect(first.response.status).toBe(202); + const workspace = requireData(first); + expect(workspace).toMatchObject({ id: "ws-1", state: "accepted", revision: 1 }); + expect(workspace).not.toHaveProperty("tenant"); + + const replay = await owner.http.POST("/v1/workspaces", { + body: { labels: { alpha: "1", beta: "2" }, name: "primary" }, + params: { header: idempotencyHeaders("workspace-create-0001") }, + }); + expect(replay.response.status).toBe(200); + expect(replay.response.headers.get("Idempotency-Replayed")).toBe("true"); + expect(replay.data).toEqual(workspace); + const conflict = await owner.http.POST("/v1/workspaces", { + body: { name: "different" }, + params: { header: idempotencyHeaders("workspace-create-0001") }, + }); + expect(conflict.response.status).toBe(409); + expect(conflict.error).toMatchObject({ error: { code: "idempotency_conflict", retryable: false } }); + + const omitted = await owner.http.POST("/v1/workspaces", { + body: { name: "primary" }, params: { header: idempotencyHeaders("workspace-omitted-0001") }, + }); + expect(omitted.response.status).toBe(202); + const explicitEmpty = await owner.http.POST("/v1/workspaces", { + body: { name: "primary", labels: {} }, params: { header: idempotencyHeaders("workspace-omitted-0001") }, + }); + expect(explicitEmpty.response.status).toBe(409); + expect(canonicalJson({ z: [1, 2], a: { y: 2, x: 1 } })).toBe('{"a":{"x":1,"y":2},"z":[1,2]}'); + expect(canonicalJson({ z: [1, 2] })).not.toBe(canonicalJson({ z: [2, 1] })); + for (const loneSurrogate of [String.fromCharCode(0xd800), String.fromCharCode(0xdc00)]) { + expect(() => canonicalJson(loneSurrogate)).toThrow(TypeError); + expect(() => canonicalJson({ [loneSurrogate]: "value" })).toThrow(TypeError); + } + + for (const name of ["second", "third", "fourth"]) { + requireData(await owner.http.POST("/v1/workspaces", { + body: { name }, + params: { header: idempotencyHeaders(`workspace-${name}-0001`) }, + })); + } + const pageOne = requireData(await owner.http.GET("/v1/workspaces", { + params: { header: VERSION_HEADERS, query: { pageSize: 3 } }, + })); + expect(pageOne.items).toHaveLength(3); + expect(pageOne.nextCursor).toMatch(/^[A-Za-z0-9._~-]+$/u); + const pageTwo = requireData(await owner.http.GET("/v1/workspaces", { + params: { header: VERSION_HEADERS, query: { pageSize: 3, cursor: pageOne.nextCursor! } }, + })); + expect(pageTwo.items).toHaveLength(2); + expect(pageTwo.nextCursor).toBeUndefined(); + + const isolated = await other.http.GET("/v1/workspaces/{workspaceId}", { + params: { header: VERSION_HEADERS, path: { workspaceId: "ws-1" } }, + }); + expect(isolated.response.status).toBe(404); + expect(isolated.error).toMatchObject({ error: { code: "not_found" } }); + const invalid = await owner.http.POST("/v1/workspaces", { + body: { name: "x".repeat(129) }, + params: { header: idempotencyHeaders("workspace-invalid-0001") }, + }); + expect(invalid.response.status).toBe(422); + expect(invalid.error).toMatchObject({ error: { code: "invalid_request", violations: [{ field: "name", rule: "length" }] } }); + + const updated = requireData(await owner.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { header: mutationHeaders(1, "workspace-patch-0001"), path: { workspaceId: "ws-1" } }, + body: { name: "renamed" }, + })); + expect(updated).toMatchObject({ name: "renamed", revision: 2 }); + const updatedReplay = await owner.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { header: mutationHeaders(1, "workspace-patch-0001"), path: { workspaceId: "ws-1" } }, body: { name: "renamed" }, + }); + expect(updatedReplay.response.status).toBe(200); + expect(updatedReplay.response.headers.get("Idempotency-Replayed")).toBe("true"); + expect(updatedReplay.data).toEqual(updated); + const stale = await owner.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { header: mutationHeaders(1, "workspace-patch-0002"), path: { workspaceId: "ws-1" } }, + body: { name: "stale" }, + }); + expect(stale.response.status).toBe(409); + expect(stale.error).toMatchObject({ error: { code: "revision_conflict" } }); + const targetOne = await owner.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { header: mutationHeaders(2, "workspace-shared-0001"), path: { workspaceId: "ws-1" } }, body: { name: "target" }, + }); + const targetTwo = await owner.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { header: mutationHeaders(1, "workspace-shared-0001"), path: { workspaceId: "ws-2" } }, body: { name: "target" }, + }); + expect(targetOne.response.status).toBe(200); + expect(targetTwo.response.status).toBe(200); + const changedPrecondition = await owner.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { header: mutationHeaders(3, "workspace-shared-0001"), path: { workspaceId: "ws-1" } }, body: { name: "target" }, + }); + expect(changedPrecondition.response.status).toBe(409); + expect(changedPrecondition.error).toMatchObject({ error: { code: "idempotency_conflict" } }); + expect((await owner.http.DELETE("/v1/workspaces/{workspaceId}", { + params: { header: idempotencyHeaders("workspace-delete-0001"), path: { workspaceId: "ws-1" } }, + })).response.status).toBe(204); + service.expectNoCredentialLeak(); + }); + + it("replays successful workspace deletion only within the original principal and request scope", async () => { + const service = new T4ApiV1ConformanceService(); + const owner = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + const other = createT4ApiClient({ baseUrl: service.origin, credential: "token-b", majorVersion: 1, fetch: service.fetch }); + requireData(await owner.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("workspace-delete-setup") }, + })); + const first = await owner.http.DELETE("/v1/workspaces/{workspaceId}", { + params: { header: idempotencyHeaders("workspace-delete-replay"), path: { workspaceId: "ws-1" } }, + }); + const replay = await owner.http.DELETE("/v1/workspaces/{workspaceId}", { + params: { header: idempotencyHeaders("workspace-delete-replay"), path: { workspaceId: "ws-1" } }, + }); + expect(first.response.status).toBe(204); + expect(first.response.headers.get("Idempotency-Replayed")).toBe("false"); + expect(replay.response.status).toBe(204); + expect(replay.response.headers.get("Idempotency-Replayed")).toBe("true"); + expect((await owner.http.DELETE("/v1/workspaces/{workspaceId}", { + params: { header: idempotencyHeaders("workspace-delete-fresh"), path: { workspaceId: "ws-1" } }, + })).response.status).toBe(404); + expect((await other.http.DELETE("/v1/workspaces/{workspaceId}", { + params: { header: idempotencyHeaders("workspace-delete-replay"), path: { workspaceId: "ws-1" } }, + })).response.status).toBe(404); + }); + + it("spawns, paginates, and mutates sessions, then submits idempotent stable command states", async () => { + const service = new T4ApiV1ConformanceService(); + const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + requireData(await client.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("workspace-setup-0001") }, + })); + const body = { title: "agent" } satisfies SessionCreate; + const first = await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + params: { header: idempotencyHeaders("session-spawn-0001"), path: { workspaceId: "ws-1" } }, + body, + }); + expect(first.response.status).toBe(202); + const session = requireData(first); + const replay = await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + params: { header: idempotencyHeaders("session-spawn-0001"), path: { workspaceId: "ws-1" } }, + body, + }); + expect(replay.data).toEqual(session); + for (const [key, title] of [["session-spawn-0002", "agent-two"], ["session-spawn-0003", "agent-three"]] as const) { + requireData(await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + params: { header: idempotencyHeaders(key), path: { workspaceId: "ws-1" } }, body: { title }, + })); + } + const sessionPageOne = requireData(await client.http.GET("/v1/workspaces/{workspaceId}/sessions", { + params: { header: VERSION_HEADERS, path: { workspaceId: "ws-1" }, query: { pageSize: 2 } }, + })); + expect(sessionPageOne.items).toHaveLength(2); + expect(sessionPageOne.nextCursor).toMatch(/^[A-Za-z0-9._~-]+$/u); + const sessionPageTwo = requireData(await client.http.GET("/v1/workspaces/{workspaceId}/sessions", { + params: { header: VERSION_HEADERS, path: { workspaceId: "ws-1" }, query: { pageSize: 2, cursor: sessionPageOne.nextCursor! } }, + })); + expect(sessionPageTwo.items).toHaveLength(1); + + const mutated = requireData(await client.http.PATCH("/v1/sessions/{sessionId}", { + params: { header: mutationHeaders(1, "session-patch-0001"), path: { sessionId: "ses-1" } }, body: { title: "renamed-agent" }, + })); + expect(mutated).toMatchObject({ title: "renamed-agent", revision: 2 }); + const mutatedReplay = await client.http.PATCH("/v1/sessions/{sessionId}", { + params: { header: mutationHeaders(1, "session-patch-0001"), path: { sessionId: "ses-1" } }, body: { title: "renamed-agent" }, + }); + expect(mutatedReplay.response.status).toBe(200); + expect(mutatedReplay.response.headers.get("Idempotency-Replayed")).toBe("true"); + expect(mutatedReplay.data).toEqual(mutated); + const stale = await client.http.PATCH("/v1/sessions/{sessionId}", { + params: { header: mutationHeaders(1, "session-patch-0002"), path: { sessionId: "ses-1" } }, body: { title: "stale-agent" }, + }); + expect(stale.response.status).toBe(409); + expect(stale.error).toMatchObject({ error: { code: "revision_conflict" } }); + + for (const state of COMMAND_STATES) { + const command = { command: state, metadata: {} } satisfies CommandCreate; + const result = await client.http.POST("/v1/sessions/{sessionId}/commands", { + params: { header: idempotencyHeaders(`command-${state}-0001`), path: { sessionId: "ses-1" } }, body: command, + }); + expect(requireData(result).state).toBe(state); + const commandReplay = await client.http.POST("/v1/sessions/{sessionId}/commands", { + params: { header: idempotencyHeaders(`command-${state}-0001`), path: { sessionId: "ses-1" } }, body: command, + }); + expect(commandReplay.data).toEqual(result.data); + expect(commandReplay.response.headers.get("Idempotency-Replayed")).toBe("true"); + } + const oversized = await client.http.POST("/v1/sessions/{sessionId}/commands", { + params: { header: idempotencyHeaders("command-oversized-0001"), path: { sessionId: "ses-1" } }, + body: { command: "a".repeat(33), metadata: {} }, + }); + expect(oversized.response.status).toBe(422); + expect(oversized.error).toMatchObject({ error: { violations: [{ field: "command", rule: "maxBytes" }] } }); + const multibyteOversized = await client.http.POST("/v1/sessions/{sessionId}/commands", { + params: { header: idempotencyHeaders("command-multibyte-0001"), path: { sessionId: "ses-1" } }, + body: { command: "é".repeat(17), metadata: {} }, + }); + expect(multibyteOversized.response.status).toBe(422); + const metadataOversized = await client.http.POST("/v1/sessions/{sessionId}/commands", { + params: { header: idempotencyHeaders("command-metadata-0001"), path: { sessionId: "ses-1" } }, + body: { command: "ok", metadata: { source: "é".repeat(17) } }, + }); + expect(metadataOversized.response.status).toBe(422); + const requestOversized = await client.http.POST("/v1/sessions/{sessionId}/commands", { + params: { header: idempotencyHeaders("command-request-0001"), path: { sessionId: "ses-1" } }, + body: { command: "ok", metadata: Object.fromEntries(Array.from({ length: 8 }, (_, index) => [`field-${index}`, "x".repeat(32)])) }, + }); + expect(requestOversized.response.status).toBe(422); + const defaultedMetadata = await client.http.POST("/v1/sessions/{sessionId}/commands", { + params: { header: idempotencyHeaders("command-default-0001"), path: { sessionId: "ses-1" } }, body: { command: "ok" }, + }); + const explicitMetadata = await client.http.POST("/v1/sessions/{sessionId}/commands", { + params: { header: idempotencyHeaders("command-default-0001"), path: { sessionId: "ses-1" } }, body: { command: "ok", metadata: {} }, + }); + expect(explicitMetadata.data).toEqual(defaultedMetadata.data); + const cancelled = await client.http.POST("/v1/sessions/{sessionId}/cancel", { + params: { header: idempotencyHeaders("session-cancel-0001"), path: { sessionId: "ses-1" } }, + }); + expect(cancelled.response.status).toBe(202); + expect(cancelled.data).toMatchObject({ state: "cancelled" }); + }); + + it("returns one durable event cursor for each committed mutation and its replay", async () => { + const service = new T4ApiV1ConformanceService(); + const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + + const workspaceFirst = await client.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("cursor-workspace-create") }, + }); + const workspaceReplay = await client.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("cursor-workspace-create") }, + }); + expect(requireEventCursor(workspaceReplay.response)).toBe(requireEventCursor(workspaceFirst.response)); + + const workspacePatchFirst = await client.http.PATCH("/v1/workspaces/{workspaceId}", { + body: { name: "renamed" }, params: { header: mutationHeaders(1, "cursor-workspace-patch"), path: { workspaceId: "ws-1" } }, + }); + const workspacePatchReplay = await client.http.PATCH("/v1/workspaces/{workspaceId}", { + body: { name: "renamed" }, params: { header: mutationHeaders(1, "cursor-workspace-patch"), path: { workspaceId: "ws-1" } }, + }); + expect(requireEventCursor(workspacePatchReplay.response)).toBe(requireEventCursor(workspacePatchFirst.response)); + + const sessionFirst = await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: "session" }, params: { header: idempotencyHeaders("cursor-session-create"), path: { workspaceId: "ws-1" } }, + }); + const sessionReplay = await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: "session" }, params: { header: idempotencyHeaders("cursor-session-create"), path: { workspaceId: "ws-1" } }, + }); + expect(requireEventCursor(sessionReplay.response)).toBe(requireEventCursor(sessionFirst.response)); + + const sessionPatchFirst = await client.http.PATCH("/v1/sessions/{sessionId}", { + body: { title: "retitled" }, params: { header: mutationHeaders(1, "cursor-session-patch"), path: { sessionId: "ses-1" } }, + }); + const sessionPatchReplay = await client.http.PATCH("/v1/sessions/{sessionId}", { + body: { title: "retitled" }, params: { header: mutationHeaders(1, "cursor-session-patch"), path: { sessionId: "ses-1" } }, + }); + expect(requireEventCursor(sessionPatchReplay.response)).toBe(requireEventCursor(sessionPatchFirst.response)); + + for (const [operation, invoke] of [ + ["cancel", () => client.http.POST("/v1/sessions/{sessionId}/cancel", { params: { header: idempotencyHeaders("cursor-session-cancel"), path: { sessionId: "ses-1" } } })], + ["command", () => client.http.POST("/v1/sessions/{sessionId}/commands", { body: { command: "run" }, params: { header: idempotencyHeaders("cursor-session-command"), path: { sessionId: "ses-1" } } })], + ["delete", () => client.http.DELETE("/v1/sessions/{sessionId}", { params: { header: idempotencyHeaders("cursor-session-delete"), path: { sessionId: "ses-1" } } })], + ] as const) { + const first = await invoke(); + const replay = await invoke(); + expect(requireEventCursor(replay.response), operation).toBe(requireEventCursor(first.response)); + } + }); + + it("replays successful session deletion only within the original principal and request scope", async () => { + const service = new T4ApiV1ConformanceService(); + const owner = await seededClient(service, "delete-replay"); + const other = createT4ApiClient({ baseUrl: service.origin, credential: "token-b", majorVersion: 1, fetch: service.fetch }); + const first = await owner.http.DELETE("/v1/sessions/{sessionId}", { + params: { header: idempotencyHeaders("session-delete-replay"), path: { sessionId: "ses-1" } }, + }); + const replay = await owner.http.DELETE("/v1/sessions/{sessionId}", { + params: { header: idempotencyHeaders("session-delete-replay"), path: { sessionId: "ses-1" } }, + }); + expect(first.response.status).toBe(204); + expect(first.response.headers.get("Idempotency-Replayed")).toBe("false"); + expect(replay.response.status).toBe(204); + expect(replay.response.headers.get("Idempotency-Replayed")).toBe("true"); + expect((await owner.http.DELETE("/v1/sessions/{sessionId}", { + params: { header: idempotencyHeaders("session-delete-fresh"), path: { sessionId: "ses-1" } }, + })).response.status).toBe(404); + expect((await other.http.DELETE("/v1/sessions/{sessionId}", { + params: { header: idempotencyHeaders("session-delete-replay"), path: { sessionId: "ses-1" } }, + })).response.status).toBe(404); + }); + + it("validates mutations before state changes and idempotency recording", async () => { + const workspaceService = new T4ApiV1ConformanceService(); + const workspaceClient = createT4ApiClient({ baseUrl: workspaceService.origin, credential: "token-a", majorVersion: 1, fetch: workspaceService.fetch }); + requireData(await workspaceClient.http.POST("/v1/workspaces", { + body: { name: "original" }, params: { header: idempotencyHeaders("mutation-workspace-setup") }, + })); + const invalidWorkspaceBodies = [ + {}, { unknown: true }, { name: 42 }, { name: "" }, { name: "😀".repeat(129) }, + { labels: { Invalid: "value" } }, { labels: { valid: "😀".repeat(129) } }, + ]; + for (const [index, body] of invalidWorkspaceBodies.entries()) { + const result = await workspaceClient.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { header: mutationHeaders(1, `invalid-workspace-${index}`), path: { workspaceId: "ws-1" } }, body: body as never, + }); + expect(result.response.status).toBe(422); + expect(result.error).toMatchObject({ error: { code: "invalid_request" } }); + } + expect(requireData(await workspaceClient.http.GET("/v1/workspaces/{workspaceId}", { + params: { header: VERSION_HEADERS, path: { workspaceId: "ws-1" } }, + }))).toMatchObject({ name: "original", revision: 1 }); + expect((await workspaceClient.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { header: mutationHeaders(1, "invalid-workspace-0"), path: { workspaceId: "ws-1" } }, body: { name: "valid" }, + })).response.status).toBe(200); + + const sessionService = new T4ApiV1ConformanceService(); + const sessionClient = await seededClient(sessionService, "mutation-validation"); + const invalidSessionBodies = [ + {}, { unknown: true }, { title: 42 }, { title: "" }, { title: "😀".repeat(129) }, + { labels: { Invalid: "value" } }, { labels: { valid: "😀".repeat(129) } }, + ]; + for (const [index, body] of invalidSessionBodies.entries()) { + const result = await sessionClient.http.PATCH("/v1/sessions/{sessionId}", { + params: { header: mutationHeaders(1, `invalid-session-${index}`), path: { sessionId: "ses-1" } }, body: body as never, + }); + expect(result.response.status).toBe(422); + expect(result.error).toMatchObject({ error: { code: "invalid_request" } }); + } + expect(requireData(await sessionClient.http.GET("/v1/sessions/{sessionId}", { + params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } }, + }))).toMatchObject({ title: "agent", revision: 1 }); + expect((await sessionClient.http.PATCH("/v1/sessions/{sessionId}", { + params: { header: mutationHeaders(1, "invalid-session-0"), path: { sessionId: "ses-1" } }, body: { title: "valid" }, + })).response.status).toBe(200); + }); + + it("replays exact PATCH results after an intervening revision", async () => { + const workspaceService = new T4ApiV1ConformanceService(); + const workspaceClient = createT4ApiClient({ baseUrl: workspaceService.origin, credential: "token-a", majorVersion: 1, fetch: workspaceService.fetch }); + requireData(await workspaceClient.http.POST("/v1/workspaces", { + body: { name: "original" }, params: { header: idempotencyHeaders("workspace-replay-setup") }, + })); + const originalWorkspace = requireData(await workspaceClient.http.PATCH("/v1/workspaces/{workspaceId}", { + body: { name: "first" }, params: { header: mutationHeaders(1, "workspace-replay-original"), path: { workspaceId: "ws-1" } }, + })); + requireData(await workspaceClient.http.PATCH("/v1/workspaces/{workspaceId}", { + body: { name: "second" }, params: { header: mutationHeaders(2, "workspace-replay-intervening"), path: { workspaceId: "ws-1" } }, + })); + const workspaceReplay = await workspaceClient.http.PATCH("/v1/workspaces/{workspaceId}", { + body: { name: "first" }, params: { header: mutationHeaders(1, "workspace-replay-original"), path: { workspaceId: "ws-1" } }, + }); + expect(workspaceReplay.response.status).toBe(200); + expect(workspaceReplay.response.headers.get("Idempotency-Replayed")).toBe("true"); + expect(workspaceReplay.data).toEqual(originalWorkspace); + + const sessionService = new T4ApiV1ConformanceService(); + const sessionClient = await seededClient(sessionService, "patch-replay-intervening"); + const originalSession = requireData(await sessionClient.http.PATCH("/v1/sessions/{sessionId}", { + body: { title: "first" }, params: { header: mutationHeaders(1, "session-replay-original"), path: { sessionId: "ses-1" } }, + })); + requireData(await sessionClient.http.PATCH("/v1/sessions/{sessionId}", { + body: { title: "second" }, params: { header: mutationHeaders(2, "session-replay-intervening"), path: { sessionId: "ses-1" } }, + })); + const sessionReplay = await sessionClient.http.PATCH("/v1/sessions/{sessionId}", { + body: { title: "first" }, params: { header: mutationHeaders(1, "session-replay-original"), path: { sessionId: "ses-1" } }, + }); + expect(sessionReplay.response.status).toBe(200); + expect(sessionReplay.response.headers.get("Idempotency-Replayed")).toBe("true"); + expect(sessionReplay.data).toEqual(originalSession); + }); + + it("validates creates before idempotency and round-trips valid labels", async () => { + const invalidLabels = [ + null, [], Object.fromEntries(Array.from({ length: 33 }, (_, index) => [`key-${index}`, "value"])), + { Invalid: "value" }, { valid: 1 }, { valid: "😀".repeat(129) }, + ]; + const workspaceService = new T4ApiV1ConformanceService(); + const workspaceClient = createT4ApiClient({ baseUrl: workspaceService.origin, credential: "token-a", majorVersion: 1, fetch: workspaceService.fetch }); + const workspaceBodies: unknown[] = [{ name: "workspace", unknown: true }, ...invalidLabels.map((labels) => ({ name: "workspace", labels }))]; + for (const [index, body] of workspaceBodies.entries()) { + const key = `invalid-workspace-create-${index}`; + const invalid = await workspaceClient.http.POST("/v1/workspaces", { body: body as never, params: { header: idempotencyHeaders(key) } }); + expect(invalid.response.status).toBe(422); + expect(invalid.error).toMatchObject({ error: { code: "invalid_request" } }); + expect((await workspaceClient.http.POST("/v1/workspaces", { + body: { name: `valid-${index}` }, params: { header: idempotencyHeaders(key) }, + })).response.status).toBe(202); + } + const workspaceLabels = { team: "😀".repeat(128) }; + expect(requireData(await workspaceClient.http.POST("/v1/workspaces", { + body: { name: "labelled", labels: workspaceLabels }, params: { header: idempotencyHeaders("valid-workspace-labels") }, + }))).toMatchObject({ labels: workspaceLabels }); + + const sessionService = new T4ApiV1ConformanceService(); + const sessionClient = createT4ApiClient({ baseUrl: sessionService.origin, credential: "token-a", majorVersion: 1, fetch: sessionService.fetch }); + requireData(await sessionClient.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("session-create-workspace") }, + })); + const sessionBodies: unknown[] = [{ title: "session", unknown: true }, ...invalidLabels.map((labels) => ({ title: "session", labels }))]; + for (const [index, body] of sessionBodies.entries()) { + const key = `invalid-session-create-${index}`; + const invalid = await sessionClient.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: body as never, params: { header: idempotencyHeaders(key), path: { workspaceId: "ws-1" } }, + }); + expect(invalid.response.status).toBe(422); + expect(invalid.error).toMatchObject({ error: { code: "invalid_request" } }); + expect((await sessionClient.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: `valid-${index}` }, params: { header: idempotencyHeaders(key), path: { workspaceId: "ws-1" } }, + })).response.status).toBe(202); + } + const sessionLabels = { team: "😀".repeat(128) }; + expect(requireData(await sessionClient.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: "labelled", labels: sessionLabels }, params: { header: idempotencyHeaders("valid-session-labels"), path: { workspaceId: "ws-1" } }, + }))).toMatchObject({ labels: sessionLabels }); + + const commandClient = await seededClient(new T4ApiV1ConformanceService(), "command-create-validation"); + const invalidCommand = await commandClient.http.POST("/v1/sessions/{sessionId}/commands", { + body: { command: "ok", unknown: true } as never, + params: { header: idempotencyHeaders("invalid-command-create"), path: { sessionId: "ses-1" } }, + }); + expect(invalidCommand.response.status).toBe(422); + expect((await commandClient.http.POST("/v1/sessions/{sessionId}/commands", { + body: { command: "ok" }, params: { header: idempotencyHeaders("invalid-command-create"), path: { sessionId: "ses-1" } }, + })).response.status).toBe(202); + }); + + it("rejects lone UTF-16 surrogates before idempotency lookup or mutation", async () => { + const service = new T4ApiV1ConformanceService(); + const client = await seededClient(service, "unicode-identity"); + const loneHigh = String.fromCharCode(0xd800); + const loneLow = String.fromCharCode(0xdc00); + const invalidRequests = [ + client.http.POST("/v1/workspaces", { + body: { name: loneHigh } as never, + params: { header: idempotencyHeaders("unicode-workspace-name-0001") }, + }), + client.http.POST("/v1/workspaces", { + body: { name: "workspace", labels: { team: loneLow } } as never, + params: { header: idempotencyHeaders("unicode-workspace-label-0001") }, + }), + client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: loneHigh } as never, + params: { header: idempotencyHeaders("unicode-session-title-0001"), path: { workspaceId: "ws-1" } }, + }), + client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: "session", labels: { team: loneLow } } as never, + params: { header: idempotencyHeaders("unicode-session-label-0001"), path: { workspaceId: "ws-1" } }, + }), + client.http.POST("/v1/sessions/{sessionId}/commands", { + body: { command: loneHigh } as never, + params: { header: idempotencyHeaders("unicode-command-text-0001"), path: { sessionId: "ses-1" } }, + }), + client.http.POST("/v1/sessions/{sessionId}/commands", { + body: { command: "ok", metadata: { note: loneLow } } as never, + params: { header: idempotencyHeaders("unicode-command-metadata-0001"), path: { sessionId: "ses-1" } }, + }), + ]; + for (const request of invalidRequests) { + const response = await request; + expect(response.response.status).toBe(422); + expect(response.error).toMatchObject({ error: { + code: "invalid_request", + violations: [{ field: "body", rule: "wellFormedUnicode" }], + } }); + } + }); + + it("rejects undeclared JSON request media before mutation and idempotency", async () => { + for (const contentType of [undefined, "text/plain"]) { + const mediaName = contentType === undefined ? "missing" : "plain"; + const service = new T4ApiV1ConformanceService(); + await seededClient(service, `request-media-${mediaName}`); + const cases = [ + ["POST", "/v1/workspaces", '{"name":"media-workspace"}', {}, 202, { id: "ws-2" }], + ["POST", "/v1/workspaces/ws-1/sessions", '{"title":"media-session"}', {}, 202, { id: "ses-2" }], + ["PATCH", "/v1/workspaces/ws-1", '{"name":"renamed"}', { "T4-If-Revision": "1" }, 200, { revision: 2 }], + ["PATCH", "/v1/sessions/ses-1", '{"title":"retitled"}', { "T4-If-Revision": "1" }, 200, { revision: 2 }], + ["POST", "/v1/sessions/ses-1/commands", '{"command":"ok"}', {}, 202, { commandId: "cmd-1" }], + ] as const; + for (const [index, [method, path, body, routeHeaders, successStatus, successBody]] of cases.entries()) { + const key = `request-media-${mediaName}-${index}`; + const headers = { Authorization: "Bearer token-a", "T4-API-Version": "1", "Idempotency-Key": key, ...routeHeaders }; + const invalid = await service.fetch(`${service.origin}${path}`, { + method, headers: { ...headers, ...(contentType === undefined ? {} : { "Content-Type": contentType }) }, body, + }); + expect(invalid.status).toBe(400); + expect(await invalid.json()).toMatchObject({ error: { code: "invalid_request" } }); + const valid = await service.fetch(`${service.origin}${path}`, { + method, headers: { ...headers, "Content-Type": "Application/JSON; Charset=UTF-8" }, body, + }); + expect(valid.status).toBe(successStatus); + expect(valid.headers.get("Idempotency-Replayed")).toBe("false"); + expect(await valid.json()).toMatchObject(successBody); + } + } + }); + + it("validates T4-If-Revision syntax before PATCH idempotency lookup", async () => { + const service = new T4ApiV1ConformanceService(); + const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + for (const [index, path] of ["/v1/workspaces/missing", "/v1/sessions/missing"].entries()) { + for (const ifMatch of [undefined, "invalid"]) { + const response = await service.fetch(`${service.origin}${path}`, { + method: "PATCH", + headers: { + Authorization: "Bearer token-a", "T4-API-Version": "1", "Idempotency-Key": `missing-if-match-${index}-${ifMatch ?? "absent"}`, "Content-Type": "application/json", + ...(ifMatch === undefined ? {} : { "T4-If-Revision": ifMatch }), + }, + body: path.includes("workspaces") ? '{"name":"updated"}' : '{"title":"updated"}', + }); + expect(response.status).toBe(400); + expect((await response.json()) as unknown).toMatchObject({ error: { code: "invalid_request" } }); + } + } + requireData(await client.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("if-match-workspace-setup") }, + })); + for (const [index, ifMatch] of [undefined, "", "0", "01", "abc", "1.0", "11111111111111111111"].entries()) { + const key = `invalid-if-match-${index}`; + const response = await service.fetch(`${service.origin}/v1/workspaces/ws-1`, { + method: "PATCH", + headers: { + Authorization: "Bearer token-a", "T4-API-Version": "1", "Idempotency-Key": key, "Content-Type": "application/json", + ...(ifMatch === undefined ? {} : { "T4-If-Revision": ifMatch }), + }, + body: '{"name":"updated"}', + }); + expect(response.status).toBe(400); + expect((await response.json()) as unknown).toMatchObject({ error: { code: "invalid_request" } }); + } + const first = await client.http.PATCH("/v1/workspaces/{workspaceId}", { + body: { name: "updated" }, params: { header: mutationHeaders(1, "if-match-replay-order"), path: { workspaceId: "ws-1" } }, + }); + expect(first.response.status).toBe(200); + const missingOnReplay = await service.fetch(`${service.origin}/v1/workspaces/ws-1`, { + method: "PATCH", + headers: { Authorization: "Bearer token-a", "T4-API-Version": "1", "Idempotency-Key": "if-match-replay-order", "Content-Type": "application/json" }, + body: '{"name":"updated"}', + }); + expect(missingOnReplay.status).toBe(400); + }); + + it("rejects duplicate JSON members before idempotency is recorded", async () => { + for (const [index, body] of [ + '{"name":"first","name":"second"}', + '{"name":"workspace","labels":{"team":"one","team":"two"}}', + ].entries()) { + const service = new T4ApiV1ConformanceService(); + const key = `duplicate-json-${index}-0000`; + const headers = { + Authorization: "Bearer token-a", + "T4-API-Version": "1", + "Idempotency-Key": key, + "Content-Type": "application/json", + }; + const rejected = await service.fetch(`${service.origin}/v1/workspaces`, { + method: "POST", headers, body, + }); + expect(rejected.status).toBe(400); + const accepted = await service.fetch(`${service.origin}/v1/workspaces`, { + method: "POST", headers, body: '{"name":"workspace"}', + }); + expect(accepted.status).toBe(202); + expect(accepted.headers.get("Idempotency-Replayed")).toBe("false"); + } + + const commandService = new T4ApiV1ConformanceService(); + await seededClient(commandService, "duplicate-command"); + for (const [index, body] of [ + '{"command":"first","command":"second"}', + '{"command":"run","metadata":{"attempt":1,"attempt":2}}', + ].entries()) { + const response = await commandService.fetch(`${commandService.origin}/v1/sessions/ses-1/commands`, { + method: "POST", + headers: { + Authorization: "Bearer token-a", + "T4-API-Version": "1", + "Idempotency-Key": `duplicate-command-${index}-0000`, + "Content-Type": "application/json", + }, + body, + }); + expect(response.status).toBe(400); + } + }); + + it("commits concurrent revision mutations with one atomic compare-and-swap", async () => { + for (const kind of ["workspace", "session"] as const) { + const service = new T4ApiV1ConformanceService(); + await seededClient(service, `cas-${kind}`); + const path = kind === "workspace" ? "/v1/workspaces/ws-1" : "/v1/sessions/ses-1"; + const field = kind === "workspace" ? "name" : "title"; + const streams = [new TransformStream(), new TransformStream()]; + const requests = streams.map((stream, index) => service.fetch(`${service.origin}${path}`, { + method: "PATCH", + headers: { + Authorization: "Bearer token-a", + "T4-API-Version": "1", + "T4-If-Revision": "1", + "Idempotency-Key": `concurrent-${kind}-${index}-0000`, + "Content-Type": "application/json", + }, + body: stream.readable, + duplex: "half", + } as RequestInit & { duplex: "half" })); + await Promise.all(streams.map(async (stream, index) => { + const writer = stream.writable.getWriter(); + await writer.write(new TextEncoder().encode(JSON.stringify({ [field]: `value-${index}` }))); + await writer.close(); + })); + const responses = await Promise.all(requests); + expect(responses.map((response) => response.status).sort()).toEqual([200, 409]); + const current = await service.fetch(`${service.origin}${path}`, { + headers: { Authorization: "Bearer token-a", "T4-API-Version": "1" }, + }); + expect(await current.json()).toMatchObject({ revision: 2 }); + } + + for (const kind of ["workspace", "session"] as const) { + const service = new T4ApiV1ConformanceService(); + await seededClient(service, `delete-race-${kind}`); + const path = kind === "workspace" ? "/v1/workspaces/ws-1" : "/v1/sessions/ses-1"; + const field = kind === "workspace" ? "name" : "title"; + const stream = new TransformStream(); + const patch = service.fetch(`${service.origin}${path}`, { + method: "PATCH", + headers: { + Authorization: "Bearer token-a", + "T4-API-Version": "1", + "T4-If-Revision": "1", + "Idempotency-Key": `delete-race-patch-${kind}`, + "Content-Type": "application/json", + }, + body: stream.readable, + duplex: "half", + } as RequestInit & { duplex: "half" }); + const deletion = await service.fetch(`${service.origin}${path}`, { + method: "DELETE", + headers: { + Authorization: "Bearer token-a", + "T4-API-Version": "1", + "Idempotency-Key": `delete-race-delete-${kind}`, + }, + }); + expect(deletion.status).toBe(204); + const writer = stream.writable.getWriter(); + await writer.write(new TextEncoder().encode(JSON.stringify({ [field]: "resurrected" }))); + await writer.close(); + expect((await patch).status).toBe(404); + const current = await service.fetch(`${service.origin}${path}`, { + headers: { Authorization: "Bearer token-a", "T4-API-Version": "1" }, + }); + expect(current.status).toBe(404); + } + }); + + it("rejects an invalid declared watch cache header", async () => { + let cacheBodyCancelled = false; + const cacheClient = createT4ApiClient({ + baseUrl: "https://watch-cache.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse(new ReadableStream({ + start(controller) { controller.enqueue(new TextEncoder().encode('data: {"type":"heartbeat","cursor":"cache","observedAt":"2026-07-21T00:00:00Z"}\n\n')); }, + cancel() { cacheBodyCancelled = true; }, + }), { headers: { "Content-Type": "text/event-stream", "Cache-Control": "max-age=3600" } }), + }); + await expect(cacheClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ status: 502, retryable: false }); + expect(cacheBodyCancelled).toBe(true); + }); + + it("reconnects instead of hanging on a half-open watch past the heartbeat deadline", async () => { + vi.useFakeTimers(); + try { + let cancelled = false; + const client = createT4ApiClient({ + baseUrl: "https://half-open-watch.test", + credential: "token-a", + majorVersion: 1, + fetch: async () => apiResponse(new ReadableStream({ + cancel() { cancelled = true; }, + }), { headers: { "Content-Type": "text/event-stream" } }), + }); + const pending = client.watchSession("ses-1", { + maxEvents: 1, + heartbeatSeconds: 5, + maxReconnectAttempts: 0, + }).next(); + const rejected = expect(pending).rejects.toMatchObject({ status: 502, code: "indeterminate" }); + await vi.advanceTimersByTimeAsync(10_001); + await rejected; + expect(cancelled).toBe(true); + + let commentsCancelled = false; + let commentInterval: ReturnType | undefined; + const comments = createT4ApiClient({ + baseUrl: "https://comment-watch.test", + credential: "token-a", + majorVersion: 1, + fetch: async () => apiResponse(new ReadableStream({ + start(stream) { + commentInterval = setInterval( + () => stream.enqueue(new TextEncoder().encode(": keepalive\n\n")), + 4_000, + ); + }, + cancel() { + if (commentInterval !== undefined) clearInterval(commentInterval); + commentsCancelled = true; + }, + }), { headers: { "Content-Type": "text/event-stream" } }), + }); + const commentPending = comments.watchSession("ses-1", { + maxEvents: 1, + heartbeatSeconds: 5, + maxReconnectAttempts: 0, + }).next(); + const commentRejected = expect(commentPending).rejects.toMatchObject({ + status: 502, code: "indeterminate", + }); + await vi.advanceTimersByTimeAsync(10_001); + await commentRejected; + expect(commentsCancelled).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("requires no-store through the exported HTTP watch operation", async () => { + for (const cacheControl of [undefined, "max-age=3600"]) { + const client = createT4ApiClient({ + baseUrl: "https://raw-watch-cache.test", credential: "token-a", majorVersion: 1, + fetch: async () => new Response('data: {"type":"heartbeat","cursor":"raw","observedAt":"2026-07-21T00:00:00Z"}\n\n', { headers: { + "Content-Type": "text/event-stream", "T4-API-Version": "1.0", ...(cacheControl === undefined ? {} : { "Cache-Control": cacheControl }), + } }), + }); + await expect(client.http.GET("/v1/sessions/{sessionId}/events", { + params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" }, query: { maxEvents: 1, heartbeatSeconds: 5 } }, + })).rejects.toMatchObject({ status: 502, retryable: false }); + } + }); + + it("rejects invalid RFC 3339 calendar timestamps", async () => { + + for (const observedAt of [ + "2026-02-31T00:00:00Z", "2026-13-01T00:00:00Z", "2025-02-29T00:00:00Z", + "2026-04-31T00:00:00Z", "2026-01-01T24:00:00Z", "2026-01-01T00:60:00Z", + "2026-01-01T00:00:00+24:00", "2026-01-01T00:00:00+01:60", + "2026-06-30T23:59:60Z", "2026-12-31T23:59:60Z", "2026-06-30T15:59:60-08:00", + ]) { + const calendarClient = createT4ApiClient({ + baseUrl: "https://watch-calendar.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse(`data: {"type":"heartbeat","cursor":"calendar","observedAt":"${observedAt}"}\n\n`, { headers: { "Content-Type": "text/event-stream" } }), + }); + await expect(calendarClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ status: 502, retryable: false }); + } + }); + + it("accepts an RFC 3339 leap second after applying its offset", async () => { + const client = createT4ApiClient({ + baseUrl: "https://watch-leap-second.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse('data: {"type":"heartbeat","cursor":"leap","observedAt":"1990-12-31T15:59:60-08:00"}\n\n', { headers: { "Content-Type": "text/event-stream" } }), + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).resolves.toMatchObject({ value: { cursor: "leap" } }); + }); + + it("accepts lowercase RFC 3339 delimiters", async () => { + const client = createT4ApiClient({ + baseUrl: "https://watch-lowercase-time.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse('data: {"type":"heartbeat","cursor":"lowercase","observedAt":"2026-07-21t00:00:00z"}\n\n', { headers: { "Content-Type": "text/event-stream" } }), + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).resolves.toMatchObject({ value: { cursor: "lowercase" } }); + }); + + it("uses typed low-level stream parsing and the SSE Accept header", async () => { + const service = new T4ApiV1ConformanceService(); + const client = await seededClient(service, "raw-stream"); + const wrongAccept = await service.fetch(`${service.origin}/v1/sessions/ses-1/events?maxEvents=1&heartbeatSeconds=5`, { + headers: { Authorization: "Bearer token-a", "T4-API-Version": "1", Accept: "application/json" }, + }); + expect(wrongAccept.status).toBe(406); + const streamed = await client.http.GET("/v1/sessions/{sessionId}/events", { + params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" }, query: { maxEvents: 1, heartbeatSeconds: 5 } }, + parseAs: "stream", + }); + expect(streamed.response.headers.get("Content-Type")).toBe("text/event-stream"); + expect(streamed.response.headers.get("Cache-Control")).toBe("no-store"); + expect(streamed.data).toBeInstanceOf(ReadableStream); + await streamed.data?.cancel(); + }); + + it("rejects malformed present Retry-After on ordinary and watch 503 responses", async () => { + for (const retryAfter of ["", "not-a-date", "-1", "x".repeat(129)]) { + const body = { error: { code: "unavailable", message: "later", requestId: "r", retryable: true } }; + const ordinary = createT4ApiClient({ + baseUrl: "https://ordinary-retry-after.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse(body, { status: 503, headers: { "Retry-After": retryAfter } }), + }); + await expect(ordinary.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ status: 502, retryable: false }); + + const watch = createT4ApiClient({ + baseUrl: "https://watch-retry-after.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse(body, { status: 503, headers: { "Retry-After": retryAfter } }), + }); + await expect(watch.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0, retryBackoffMs: 0 }).next()).rejects.toMatchObject({ status: 502, retryable: false }); + } + }); + + it("accepts every RFC 9110 HTTP-date form in Retry-After without dropping its delay", async () => { + for (const retryAfter of [ + "Sun, 06 Nov 1994 08:49:37 GMT", + "Sunday, 06-Nov-94 08:49:37 GMT", + "Sun Nov 6 08:49:37 1994", + ]) { + const client = createT4ApiClient({ + baseUrl: "https://http-date-retry-after.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse({ error: { code: "unavailable", message: "later", requestId: "r", retryable: true } }, { status: 503, headers: { "Retry-After": retryAfter } }), + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ + status: 502, cause: { status: 503, retryAfterMs: 0 }, + }); + } + }); + + it("resolves RFC 850 two-digit years relative to the current year", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-07-21T00:00:00Z")); + const client = createT4ApiClient({ + baseUrl: "https://rfc850-year.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse({ error: { code: "unavailable", message: "later", requestId: "r", retryable: true } }, { status: 503, headers: { "Retry-After": "Saturday, 01-Jan-50 00:00:00 GMT" } }), + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ + status: 502, cause: { status: 503, retryAfterMs: 30_000 }, + }); + } finally { + vi.useRealTimers(); + } + }); + + it("applies the RFC 850 pivot to the complete timestamp", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-07-21T00:00:00Z")); + const client = createT4ApiClient({ + baseUrl: "https://rfc850-boundary.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse({ error: { code: "unavailable", message: "later", requestId: "r", retryable: true } }, { status: 503, headers: { "Retry-After": "Friday, 31-Dec-76 00:00:00 GMT" } }), + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ + status: 502, cause: { status: 503, retryAfterMs: 0 }, + }); + } finally { + vi.useRealTimers(); + } + }); + + it("takes a snapshot and watches bounded SSE with heartbeat, reconnect, cancellation, and typed resync", async () => { + const service = new T4ApiV1ConformanceService(); + const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + requireData(await client.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("workspace-watch-0001") }, + })); + requireData(await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + params: { header: idempotencyHeaders("session-watch-0001"), path: { workspaceId: "ws-1" } }, body: { title: "agent" }, + })); + const snapshot = requireData(await client.http.GET("/v1/sessions/{sessionId}/snapshot", { + params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } }, + })); + expect(snapshot).toMatchObject({ sessionId: "ses-1", cursor: "cursor-2", entries: [{ sequence: 2 }] }); + + const received: Array = []; + for await (const event of client.watchSession("ses-1", { cursor: snapshot.cursor, maxEvents: 3, heartbeatSeconds: 20, maxReconnectAttempts: 2, retryBackoffMs: 0 })) { + received.push(event); + } + expect(received).toEqual([ + expect.objectContaining({ type: "heartbeat", cursor: "cursor-2" }), + expect.objectContaining({ type: "session", cursor: "cursor-4", state: "accepted" }), + expect.objectContaining({ type: "heartbeat", cursor: "cursor-4" }), + ]); + expect(received.map(exhaustWatchEvent)).toEqual(["2026-07-21T00:00:00Z", "accepted:2", "2026-07-21T00:00:15Z"]); + expect(service.watchCursors[0]).toEqual({ query: "cursor-2", header: "cursor-2" }); + expect(service.watchCursors[1]).toEqual({ query: "cursor-4", header: "cursor-4" }); + expect(service.watchQueries.slice(0, 2)).toEqual([ + { maxEvents: "3", heartbeatSeconds: "20" }, + { maxEvents: "1", heartbeatSeconds: "20" }, + ]); + + await expect(async () => { + for await (const _event of client.watchSession("ses-1", { cursor: "expired", maxEvents: 1 })) { + throw new Error("expired cursor unexpectedly produced an event"); + } + }).rejects.toMatchObject({ + name: "T4ApiError", + code: "cursor_expired", + status: 410, + resync: { snapshotUrl: "v1/sessions/ses-1/snapshot", cursor: "cursor-2" }, + } satisfies Partial); + }); + + it("enforces the ResourceId bound in cursor-expired resync snapshot URLs", async () => { + const resourceId128 = `s${"a".repeat(127)}`; + const resourceId129 = `s${"a".repeat(128)}`; + const cursorExpired = (sessionId: string) => ({ error: { + code: "cursor_expired", message: "expired", requestId: "r", retryable: false, + resync: { snapshotUrl: `v1/sessions/${sessionId}/snapshot`, cursor: "cursor-1" }, + } }); + + for (const [watchedSessionId, responseSessionId, expected] of [ + [resourceId128, resourceId128, "cursor_expired"], + ["ses-1", resourceId129, "indeterminate"], + ] as const) { + const client = createT4ApiClient({ + baseUrl: "https://resync-bound.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse(cursorExpired(responseSessionId), { status: 410 }), + }); + await expect(client.watchSession(watchedSessionId, { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ + code: expected, status: expected === "cursor_expired" ? 410 : 502, + }); + } + + const mismatched = createT4ApiClient({ + baseUrl: "https://resync-session.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse(cursorExpired("ses-other"), { status: 410 }), + }); + await expect(mismatched.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ + code: "indeterminate", status: 502, + }); + + const contract = JSON.parse(readFileSync(new URL("../../t4-api-contract/openapi.json", import.meta.url), "utf8")) as { + components: { schemas: { Resync: { properties: { snapshotUrl: { pattern: string; maxLength: number } } } } }; + }; + const snapshotUrlSchema = contract.components.schemas.Resync.properties.snapshotUrl; + const pattern = new RegExp(snapshotUrlSchema.pattern, "u"); + expect(pattern.test(`v1/sessions/${resourceId128}/snapshot`)).toBe(true); + expect(pattern.test(`v1/sessions/${resourceId129}/snapshot`)).toBe(false); + expect(`v1/sessions/${resourceId128}/snapshot`.length).toBeLessThanOrEqual(snapshotUrlSchema.maxLength); + }); + + it("accepts every command lifecycle watch state and rejects removed states", async () => { + for (const [index, state] of COMMAND_STATES.entries()) { + const cursor = `command-state-${index}`; + const client = createT4ApiClient({ + baseUrl: "https://command-states.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse(`data: ${JSON.stringify({ type: "command", cursor, commandId: "cmd-1", state })}\n\n`, { headers: { "Content-Type": "text/event-stream" } }), + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).resolves.toMatchObject({ value: { state } }); + } + + const removed = createT4ApiClient({ + baseUrl: "https://removed-command-state.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse('data: {"type":"command","cursor":"removed-1","commandId":"cmd-1","state":"conflict"}\n\n', { headers: { "Content-Type": "text/event-stream" } }), + }); + await expect(removed.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + }); + + it("rejects malformed mutation idempotency and watch query contracts", async () => { + const service = new T4ApiV1ConformanceService(); + const baseHeaders = { Authorization: "Bearer token-a", "T4-API-Version": "1", "Content-Type": "application/json" }; + const created = await service.fetch(`${service.origin}/v1/workspaces`, { method: "POST", headers: { ...baseHeaders, "Idempotency-Key": "workspace-raw-0001" }, body: '{"name":"workspace"}' }); + expect(created.status).toBe(202); + const missingSpawn = await service.fetch(`${service.origin}/v1/workspaces/ws-1/sessions`, { method: "POST", headers: baseHeaders, body: '{"title":"agent"}' }); + expect(missingSpawn.status).toBe(400); + expect(await missingSpawn.json()).toMatchObject({ error: { code: "idempotency_key_required" } }); + const invalidSpawn = await service.fetch(`${service.origin}/v1/workspaces/ws-1/sessions`, { method: "POST", headers: { ...baseHeaders, "Idempotency-Key": "short" }, body: '{"title":"agent"}' }); + expect(invalidSpawn.status).toBe(400); + const spawned = await service.fetch(`${service.origin}/v1/workspaces/ws-1/sessions`, { method: "POST", headers: { ...baseHeaders, "Idempotency-Key": "session-raw-00001" }, body: '{"title":"agent"}' }); + expect(spawned.status).toBe(202); + for (const [method, path, body] of [ + ["PATCH", "/v1/workspaces/ws-1", '{"name":"x"}'], + ["DELETE", "/v1/workspaces/ws-1", undefined], + ["PATCH", "/v1/sessions/ses-1", '{"title":"x"}'], + ["DELETE", "/v1/sessions/ses-1", undefined], + ["POST", "/v1/sessions/ses-1/cancel", undefined], + ["POST", "/v1/sessions/ses-1/commands", '{"command":"ok"}'], + ] as const) { + const response = await service.fetch(`${service.origin}${path}`, { method, headers: { ...baseHeaders, "T4-If-Revision": "1" }, ...(body === undefined ? {} : { body }) }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code: "idempotency_key_required" } }); + } + for (const [method, path, headers] of [ + ["POST", "/v1/workspaces", { ...baseHeaders, "Idempotency-Key": "malformed-workspace-01" }], + ["PATCH", "/v1/workspaces/ws-1", { ...baseHeaders, "T4-If-Revision": "1", "Idempotency-Key": "malformed-workspace-02" }], + ["POST", "/v1/workspaces/ws-1/sessions", { ...baseHeaders, "Idempotency-Key": "malformed-session-0001" }], + ["PATCH", "/v1/sessions/ses-1", { ...baseHeaders, "T4-If-Revision": "1", "Idempotency-Key": "malformed-session-0002" }], + ] as const) { + const response = await service.fetch(`${service.origin}${path}`, { method, headers, body: "{" }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code: "invalid_request" } }); + } + for (const query of ["maxEvents=0", "maxEvents=5", "heartbeatSeconds=4", "heartbeatSeconds=61"]) { + const response = await service.fetch(`${service.origin}/v1/sessions/ses-1/events?${query}`, { headers: { ...baseHeaders, Accept: "text/event-stream" } }); + expect(response.status).toBe(422); + expect(await response.json()).toMatchObject({ error: { code: "invalid_request", violations: expect.any(Array) } }); + } + }); + + it("rejects malformed workspace and session pagination cursors", async () => { + const service = new T4ApiV1ConformanceService(); + const headers = { Authorization: "Bearer token-a", "T4-API-Version": "1", "Content-Type": "application/json" }; + expect((await service.fetch(`${service.origin}/v1/workspaces`, { + method: "POST", headers: { ...headers, "Idempotency-Key": "pagination-workspace" }, body: '{"name":"workspace"}', + })).status).toBe(202); + expect((await service.fetch(`${service.origin}/v1/workspaces/ws-1/sessions`, { + method: "POST", headers: { ...headers, "Idempotency-Key": "pagination-session-1" }, body: '{"title":"session"}', + })).status).toBe(202); + + for (const path of ["/v1/workspaces", "/v1/workspaces/ws-1/sessions"]) { + for (const cursor of ["foo", "page--1", "page-01", "page-9007199254740992"]) { + const response = await service.fetch(`${service.origin}${path}?cursor=${encodeURIComponent(cursor)}`, { headers }); + expect(response.status, `${path} accepted ${cursor}`).toBe(422); + expect(await response.json()).toMatchObject({ error: { code: "invalid_request", violations: [{ field: "cursor", rule: "format" }] } }); + } + } + }); + + it("accepts only pagination cursors issued for the principal and collection", async () => { + const service = new T4ApiV1ConformanceService(); + const headers = { Authorization: "Bearer token-a", "T4-API-Version": "1", "Content-Type": "application/json" }; + for (const [key, name] of [["issued-workspace-1", "one"], ["issued-workspace-2", "two"], ["issued-workspace-3", "three"]]) { + expect((await service.fetch(`${service.origin}/v1/workspaces`, { + method: "POST", headers: { ...headers, "Idempotency-Key": key }, body: JSON.stringify({ name }), + })).status).toBe(202); + } + for (const [key, title] of [["issued-session-01", "one"], ["issued-session-02", "two"]]) { + expect((await service.fetch(`${service.origin}/v1/workspaces/ws-1/sessions`, { + method: "POST", headers: { ...headers, "Idempotency-Key": key }, body: JSON.stringify({ title }), + })).status).toBe(202); + } + + const workspacePage = await service.fetch(`${service.origin}/v1/workspaces?pageSize=1`, { headers }); + const workspaceCursor = (await workspacePage.json() as { nextCursor: string }).nextCursor; + expect((await service.fetch(`${service.origin}/v1/workspaces?pageSize=1&cursor=${workspaceCursor}`, { headers })).status).toBe(200); + const base64UrlAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + const signatureSegment = workspaceCursor.split(".").at(-1)!; + const encodedRemainder = signatureSegment.length % 4; + const unusedBits = encodedRemainder === 2 ? 4 : encodedRemainder === 3 ? 2 : 0; + const terminalIndex = base64UrlAlphabet.indexOf(signatureSegment.at(-1)!); + expect(terminalIndex).toBeGreaterThanOrEqual(0); + const aliasMask = (1 << unusedBits) - 1; + expect(terminalIndex & aliasMask).toBe(0); + const signatureAliases = base64UrlAlphabet.slice( + terminalIndex & ~aliasMask, + (terminalIndex & ~aliasMask) + aliasMask + 1, + ).replace(signatureSegment.at(-1)!, ""); + expect(signatureAliases.length).toBeGreaterThan(0); + expect(signatureAliases).toHaveLength(3); + for (const signatureAlias of signatureAliases) { + const noncanonicalCursor = `${workspaceCursor.slice(0, -1)}${signatureAlias}`; + const noncanonical = await service.fetch(`${service.origin}/v1/workspaces?pageSize=1&cursor=${noncanonicalCursor}`, { headers }); + expect(noncanonical.status).toBe(422); + expect(await noncanonical.json()).toMatchObject({ error: { code: "invalid_request" } }); + } + const sessionPage = await service.fetch(`${service.origin}/v1/workspaces/ws-1/sessions?pageSize=1`, { headers }); + const sessionCursor = (await sessionPage.json() as { nextCursor: string }).nextCursor; + + for (const [path, cursor, authorization] of [ + ["/v1/workspaces", "page-999", "Bearer token-a"], + ["/v1/workspaces/ws-1/sessions", "page-999", "Bearer token-a"], + ["/v1/workspaces/ws-1/sessions", workspaceCursor, "Bearer token-a"], + ["/v1/workspaces", sessionCursor, "Bearer token-a"], + ["/v1/workspaces", workspaceCursor, "Bearer token-b"], + ]) { + const response = await service.fetch(`${service.origin}${path}?pageSize=1&cursor=${cursor}`, { headers: { ...headers, Authorization: authorization } }); + expect(response.status, `${path} accepted unissued cursor ${cursor}`).toBe(422); + expect(await response.json()).toMatchObject({ error: { code: "invalid_request", violations: [{ field: "cursor", rule: "issued" }] } }); + } + + const laterWorkspacePage = await service.fetch(`${service.origin}/v1/workspaces?pageSize=2`, { headers }); + const staleWorkspaceCursor = (await laterWorkspacePage.json() as { nextCursor: string }).nextCursor; + expect((await service.fetch(`${service.origin}/v1/workspaces/ws-3`, { + method: "DELETE", headers: { ...headers, "Idempotency-Key": "shrink-workspace-3" }, + })).status).toBe(204); + expect((await service.fetch(`${service.origin}/v1/sessions/ses-2`, { + method: "DELETE", headers: { ...headers, "Idempotency-Key": "shrink-session-02" }, + })).status).toBe(204); + for (const [path, cursor] of [ + ["/v1/workspaces", staleWorkspaceCursor], + ["/v1/workspaces/ws-1/sessions", sessionCursor], + ]) { + const response = await service.fetch(`${service.origin}${path}?pageSize=1&cursor=${cursor}`, { headers }); + expect(response.status, `${path} accepted a cursor past the shrunken collection`).toBe(422); + expect(await response.json()).toMatchObject({ error: { code: "invalid_request", violations: [{ field: "cursor", rule: "issued" }] } }); + } + }); + + it("parses service frames incrementally across split UTF-8 and per-frame bounds", async () => { + const bytewiseService = new T4ApiV1ConformanceService({ watchTransport: "bytewise" }); + const bytewiseClient = await seededClient(bytewiseService, "bytewise"); + const event = await bytewiseClient.watchSession("ses-1", { maxEvents: 1, heartbeatSeconds: 20, maxReconnectAttempts: 0 }).next(); + expect(event.value).toMatchObject({ type: "heartbeat", cursor: "cursor-3" }); + expect(bytewiseService.watchQueries[0]).toEqual({ maxEvents: "1", heartbeatSeconds: "20" }); + + const manyService = new T4ApiV1ConformanceService({ watchTransport: "many-small" }); + const manyClient = await seededClient(manyService, "many-small"); + let count = 0; + for await (const _event of manyClient.watchSession("ses-1", { maxEvents: 1000, maxReconnectAttempts: 0 })) count += 1; + expect(count).toBe(1000); + + const oversizedService = new T4ApiV1ConformanceService({ watchTransport: "oversized" }); + const oversizedClient = await seededClient(oversizedService, "oversized"); + await expect(oversizedClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + + let ongoingOversizedCancelled = false; + const ongoingOversizedClient = createT4ApiClient({ + baseUrl: "https://ongoing-oversized-event.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse(new ReadableStream({ + pull(controller) { controller.enqueue(new Uint8Array(600 * 1024)); }, + cancel() { ongoingOversizedCancelled = true; }, + }), { status: 200, headers: { "Content-Type": "text/event-stream" } }), + }); + await expect(ongoingOversizedClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ + code: "indeterminate", status: 502, retryable: false, + }); + expect(ongoingOversizedCancelled).toBe(true); + }); + + it("fails closed on unknown watch fields and incomplete typed errors", async () => { + const eventFetch: typeof globalThis.fetch = async () => apiResponse('data: {"type":"heartbeat","cursor":"c1","observedAt":"2026-07-21T00:00:00Z","unknown":true}\n\n', { headers: { "Content-Type": "text/event-stream" } }); + const eventClient = createT4ApiClient({ baseUrl: "https://unknown.test", credential: "token-a", majorVersion: 1, fetch: eventFetch }); + await expect(eventClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + for (const [status, code] of [[410, "cursor_expired"], [406, "incompatible_version"], [422, "invalid_request"]] as const) { + const fetch: typeof globalThis.fetch = async () => jsonResponse({ error: { code, message: "incomplete", requestId: "r", retryable: false } }, { status }); + const client = createT4ApiClient({ baseUrl: "https://errors.test", credential: "token-a", majorVersion: 1, fetch }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); + } + for (const [status, code] of [[401, "not_found"], [403, "unauthenticated"], [404, "forbidden"], [409, "unavailable"], [503, "revision_conflict"]] as const) { + const fetch: typeof globalThis.fetch = async () => jsonResponse({ error: { code, message: "wrong status class", requestId: "r", retryable: false } }, { status }); + const client = createT4ApiClient({ baseUrl: "https://status-errors.test", credential: "token-a", majorVersion: 1, fetch }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); + } + }); + + it("fails closed on malformed, oversized, status-inconsistent, and unknown-field HTTP errors", async () => { + const validErrors = [ + [400, { error: { code: "invalid_request", message: "bad", requestId: "r", retryable: false } }], + [401, { error: { code: "unauthenticated", message: "bad", requestId: "r", retryable: false } }], + [403, { error: { code: "forbidden", message: "bad", requestId: "r", retryable: false } }], + [404, { error: { code: "not_found", message: "bad", requestId: "r", retryable: false } }], + [406, { error: { code: "incompatible_version", message: "bad", requestId: "r", retryable: false, supportedMajors: [1] } }], + [409, { error: { code: "revision_conflict", message: "bad", requestId: "r", retryable: false } }], + [422, { error: { code: "invalid_request", message: "bad", requestId: "r", retryable: false, violations: [{ field: "name", rule: "length", message: "bad" }] } }], + [503, { error: { code: "unavailable", message: "bad", requestId: "r", retryable: true } }], + ] as const; + for (const [status, body] of validErrors) { + const fetch: typeof globalThis.fetch = async () => jsonResponse({ error: { ...body.error, unknown: true } }, { status }); + const client = createT4ApiClient({ baseUrl: "https://ordinary-errors.test", credential: "token-a", majorVersion: 1, fetch }); + await expect(client.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("ordinary-errors-0001") }, + })).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); + } + + const malformedClient = createT4ApiClient({ + baseUrl: "https://malformed-error.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse("{", { status: 404, headers: { "Content-Type": "application/json" } }), + }); + await expect(malformedClient.http.GET("/v1/workspaces/{workspaceId}", { + params: { header: VERSION_HEADERS, path: { workspaceId: "missing" } }, + })).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); + + const oversizedClient = createT4ApiClient({ + baseUrl: "https://oversized-error.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse("x".repeat(1024 * 1024 + 1), { status: 404, headers: { "Content-Type": "application/json" } }), + }); + await expect(oversizedClient.http.GET("/v1/workspaces/{workspaceId}", { + params: { header: VERSION_HEADERS, path: { workspaceId: "missing" } }, + })).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); + + const streamedChunk = new Uint8Array(600 * 1024); + for (const contentType of ["application/json", "text/plain"]) { + let cancelled = false; + const streamedClient = createT4ApiClient({ + baseUrl: "https://streamed-error.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse(new ReadableStream({ + pull(controller) { controller.enqueue(streamedChunk); }, + cancel() { cancelled = true; }, + }), { status: 404, headers: { "Content-Type": contentType } }), + }); + await expect(streamedClient.http.GET("/v1/workspaces/{workspaceId}", { + params: { header: VERSION_HEADERS, path: { workspaceId: "missing" } }, + })).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); + expect(cancelled).toBe(true); + } + + const statusClient = createT4ApiClient({ + baseUrl: "https://undeclared-error.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse(validErrors[3][1], { status: 404 }), + }); + await expect(statusClient.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); + }); + + it("validates successful JSON routes relative to the base path and rejects undeclared media and statuses", async () => { + const discovery = DISCOVERY; + const prefixed = createT4ApiClient({ + baseUrl: "https://prefixed.test/api", credential: "token-a", majorVersion: 1, + fetch: async (input) => { + expect(new Request(input).url).toBe("https://prefixed.test/api/v1"); + return jsonResponse({ ...discovery, unknown: true }); + }, + }); + await expect(prefixed.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + + const invalidSnapshot = createT4ApiClient({ + baseUrl: "https://snapshot.test/api", credential: "token-a", majorVersion: 1, + fetch: async (input) => { + expect(new URL(new Request(input).url).pathname).toBe("/api/v1/sessions/ses-1/snapshot"); + return jsonResponse({ sessionId: "ses-1", cursor: "cursor-1", state: "ready", entries: [], unknown: true }); + }, + }); + await expect(invalidSnapshot.http.GET("/v1/sessions/{sessionId}/snapshot", { + params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } }, + })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + + const astralText = "😀".repeat(524_289); + const unicodeSnapshot = createT4ApiClient({ + baseUrl: "https://unicode-snapshot.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse({ sessionId: "ses-1", cursor: "cursor-1", state: "ready", entries: [{ sequence: 0, kind: "output", text: astralText }] }), + }); + const unicode = requireData(await unicodeSnapshot.http.GET("/v1/sessions/{sessionId}/snapshot", { + params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } }, + })); + expect(unicode.entries[0]?.text).toBe(astralText); + + const astral128 = "😀".repeat(128); + const unicodeWorkspace = createT4ApiClient({ + baseUrl: "https://unicode-workspace.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse({ id: "wsp-1", name: astral128, state: "ready", revision: 1, labels: { team: astral128 } }), + }); + const workspace = requireData(await unicodeWorkspace.http.GET("/v1/workspaces/{workspaceId}", { + params: { header: VERSION_HEADERS, path: { workspaceId: "wsp-1" } }, + })); + expect(workspace).toMatchObject({ name: astral128, labels: { team: astral128 } }); + + const unicodeSession = createT4ApiClient({ + baseUrl: "https://unicode-session.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse({ id: "ses-1", workspaceId: "wsp-1", title: astral128, state: "ready", revision: 1, labels: { team: astral128 } }), + }); + const session = requireData(await unicodeSession.http.GET("/v1/sessions/{sessionId}", { + params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } }, + })); + expect(session).toMatchObject({ title: astral128, labels: { team: astral128 } }); + + const errorMessage = "😀".repeat(1024); + const errorRequestId = "😀".repeat(128); + const violationField = "😀".repeat(256); + const violationMessage = "😀".repeat(512); + const unicodeError = createT4ApiClient({ + baseUrl: "https://unicode-error.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse({ error: { + code: "invalid_request", message: errorMessage, requestId: errorRequestId, retryable: false, + violations: [{ field: violationField, rule: "range", message: violationMessage }], + } }, { status: 422 }), + }); + await expect(unicodeError.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ + code: "invalid_request", status: 422, message: errorMessage, requestId: errorRequestId, + violations: [{ field: violationField, rule: "range", message: violationMessage }], + }); + + for (const response of [ + apiResponse(JSON.stringify(discovery), { status: 200, headers: { "Content-Type": "text/plain" } }), + jsonResponse(discovery, { status: 201 }), + ]) { + const client = createT4ApiClient({ baseUrl: "https://dispatch.test", credential: "token-a", majorVersion: 1, fetch: async () => response.clone() }); + await expect(client.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + } + + const unknownRoute = createT4ApiClient({ + baseUrl: "https://dispatch.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse(discovery), + }); + await expect(unknownRoute.http.GET("/v1/undeclared" as "/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + }); + + it("accepts only the exact watch status matrix under a prefixed base path", async () => { + const prefixed = createT4ApiClient({ + baseUrl: "https://watch-status.test/api", credential: "token-a", majorVersion: 1, + fetch: async (input) => { + expect(new URL(new Request(input).url).pathname).toBe("/api/v1/sessions/ses-1/events"); + return apiResponse('data: {"type":"heartbeat","cursor":"prefixed-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { status: 200, headers: { "Content-Type": "text/event-stream" } }); + }, + }); + await expect(prefixed.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).resolves.toMatchObject({ value: { cursor: "prefixed-1" } }); + + const prefixedExpired = createT4ApiClient({ + baseUrl: "https://watch-status.test/api", credential: "token-a", majorVersion: 1, + fetch: async (input) => { + expect(new URL(new Request(input).url).pathname).toBe("/api/v1/sessions/ses-1/events"); + return jsonResponse({ error: { + code: "cursor_expired", message: "bad", requestId: "r", retryable: false, + resync: { snapshotUrl: "v1/sessions/ses-1/snapshot", cursor: "cursor-1" }, + } }, { status: 410 }); + }, + }); + await expect(prefixedExpired.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ + status: 410, + resync: { snapshotUrl: "v1/sessions/ses-1/snapshot", cursor: "cursor-1" }, + }); + expect(new URL("v1/sessions/ses-1/snapshot", "https://watch-status.test/api/").pathname).toBe("/api/v1/sessions/ses-1/snapshot"); + + const declared = [ + [400, { error: { code: "invalid_request", message: "bad", requestId: "r", retryable: false } }], + [401, { error: { code: "unauthenticated", message: "bad", requestId: "r", retryable: false } }], + [403, { error: { code: "forbidden", message: "bad", requestId: "r", retryable: false } }], + [404, { error: { code: "not_found", message: "bad", requestId: "r", retryable: false } }], + [406, { error: { code: "incompatible_version", message: "bad", requestId: "r", retryable: false, supportedMajors: [1] } }], + [410, { error: { code: "cursor_expired", message: "bad", requestId: "r", retryable: false, resync: { snapshotUrl: "v1/sessions/ses-1/snapshot", cursor: "cursor-1" } } }], + [422, { error: { code: "invalid_request", message: "bad", requestId: "r", retryable: false, violations: [{ field: "maxEvents", rule: "range", message: "bad" }] } }], + [503, { error: { code: "unavailable", message: "bad", requestId: "r", retryable: false } }], + ] as const; + for (const [status, body] of declared) { + const client = createT4ApiClient({ baseUrl: "https://watch-status.test", credential: "token-a", majorVersion: 1, fetch: async () => jsonResponse(body, { + status, + ...(status === 401 ? { headers: { "WWW-Authenticate": 'Bearer realm="t4"' } } : {}), + }) }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ status, code: body.error.code }); + } + + for (const response of [ + apiResponse('data: {"type":"heartbeat","cursor":"wrong-media-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { status: 200, headers: { "Content-Type": "application/json" } }), + apiResponse('data: {"type":"heartbeat","cursor":"wrong-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { status: 201, headers: { "Content-Type": "text/event-stream" } }), + apiResponse(null, { status: 204, headers: { "Content-Type": "text/event-stream" } }), + jsonResponse({ error: { code: "revision_conflict", message: "bad", requestId: "r", retryable: false } }, { status: 409 }), + ]) { + let attempts = 0; + const client = createT4ApiClient({ + baseUrl: "https://watch-status.test", credential: "token-a", majorVersion: 1, + fetch: async () => { attempts += 1; return response.clone(); }, + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 3, retryBackoffMs: 0 }).next()).rejects.toMatchObject({ + status: 502, code: "indeterminate", retryable: false, + }); + expect(attempts).toBe(1); + } + }); + + it("declares truthful discovery and required public response headers while preserving the 401 exception", () => { + const contract = JSON.parse(readFileSync(new URL("../../t4-api-contract/openapi.json", import.meta.url), "utf8")) as { + paths: Record }; get?: { responses: Record }> } }>; + components: { + headers: Record; + responses: Record }>; + schemas: Record }>; + }; + }; + expect(contract.paths["/v1/workspaces/{workspaceId}"]?.patch?.responses["200"]?.$ref).toBe("#/components/responses/WorkspaceReplay"); + expect(contract.paths["/v1/sessions/{sessionId}"]?.patch?.responses["200"]?.$ref).toBe("#/components/responses/SessionReplay"); + expect(contract.components.headers.SelectedVersion?.required).toBe(true); + expect(contract.components.headers.IdempotencyReplayed?.required).toBe(true); + expect(contract.components.headers.EventCursor?.required).toBe(true); + for (const response of ["WorkspaceAccepted", "WorkspaceReplay", "SessionAccepted", "SessionReplay", "CommandAccepted", "CommandReplay", "Deleted"]) { + expect(contract.components.responses[response]?.headers?.["T4-Event-Cursor"]?.$ref).toBe("#/components/headers/EventCursor"); + } + expect(contract.paths["/v1/sessions/{sessionId}/events"]?.get?.responses["200"]?.headers?.["Cache-Control"]?.required).toBe(true); + expect(contract.components.responses.Error401?.headers?.["T4-API-Version"]).toBeUndefined(); + + const discovery = contract.components.schemas.Discovery!; + expect(discovery.required).toContain("serverBuild"); + expect(discovery.properties?.capabilities).toEqual({ $ref: "#/components/schemas/Capabilities" }); + expect(contract.components.schemas.Capabilities).toMatchObject({ + maxProperties: 128, + propertyNames: { pattern: "^[a-z][a-z0-9.-]*$", maxLength: 128 }, + additionalProperties: { $ref: "#/components/schemas/CapabilityStatus" }, + }); + expect(contract.components.schemas.CapabilityStatus).toMatchObject({ + additionalProperties: false, + required: ["supported", "enabled", "authorized", "available"], + }); + }); + + it("treats malformed and oversized watch 503 envelopes as terminal protocol errors", async () => { + for (const body of ["{", "x".repeat(1024 * 1024 + 1)]) { + let attempts = 0; + const client = createT4ApiClient({ + baseUrl: "https://invalid-503.test", credential: "token-a", majorVersion: 1, + fetch: async () => { + attempts += 1; + return apiResponse(body, { status: 503, headers: { "Content-Type": "application/json", "Retry-After": "0" } }); + }, + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 3, retryBackoffMs: 0 }).next()).rejects.toMatchObject({ + code: "indeterminate", status: 502, retryable: false, + }); + expect(attempts).toBe(1); + } + + let oversizedCancelled = false; + const oversizedChunk = new Uint8Array(600 * 1024); + const streamedOversizedClient = createT4ApiClient({ + baseUrl: "https://streamed-invalid-503.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse(new ReadableStream({ + pull(controller) { controller.enqueue(oversizedChunk); }, + cancel() { oversizedCancelled = true; }, + }), { status: 503, headers: { "Content-Type": "application/json" } }), + }); + await expect(streamedOversizedClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 3, retryBackoffMs: 0 }).next()).rejects.toMatchObject({ + code: "indeterminate", status: 502, retryable: false, + }); + expect(oversizedCancelled).toBe(true); + + let nonJsonCancelled = false; + const nonJsonClient = createT4ApiClient({ + baseUrl: "https://invalid-503-media.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse(new ReadableStream({ + pull(controller) { controller.enqueue(new Uint8Array(1024)); }, + cancel() { nonJsonCancelled = true; }, + }), { status: 503, headers: { "Content-Type": "text/plain" } }), + }); + await expect(nonJsonClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 3, retryBackoffMs: 0 }).next()).rejects.toMatchObject({ + code: "indeterminate", status: 502, retryable: false, + }); + expect(nonJsonCancelled).toBe(true); + }); + + it("rejects unknown fields in every public JSON response family", async () => { + const discoveryService = new T4ApiV1ConformanceService({ invalidPayload: "discovery" }); + const discoveryClient = createT4ApiClient({ baseUrl: discoveryService.origin, credential: "token-a", majorVersion: 1, fetch: discoveryService.fetch }); + await expect(discoveryClient.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + + const workspaceService = new T4ApiV1ConformanceService({ invalidPayload: "workspace" }); + const workspaceClient = createT4ApiClient({ baseUrl: workspaceService.origin, credential: "token-a", majorVersion: 1, fetch: workspaceService.fetch }); + await expect(workspaceClient.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("invalid-workspace-0001") }, + })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + + const sessionService = new T4ApiV1ConformanceService({ invalidPayload: "session" }); + const sessionClient = createT4ApiClient({ baseUrl: sessionService.origin, credential: "token-a", majorVersion: 1, fetch: sessionService.fetch }); + requireData(await sessionClient.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("invalid-session-work-01") }, + })); + await expect(sessionClient.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: "agent" }, params: { header: idempotencyHeaders("invalid-session-00001"), path: { workspaceId: "ws-1" } }, + })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + + const commandService = new T4ApiV1ConformanceService({ invalidPayload: "command" }); + const commandClient = await seededClient(commandService, "invalid-command"); + await expect(commandClient.http.POST("/v1/sessions/{sessionId}/commands", { + body: { command: "ok", metadata: {} }, params: { header: idempotencyHeaders("invalid-command-0001"), path: { sessionId: "ses-1" } }, + })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + }); + + it("requires declared selected-version and replay response headers", async () => { + const discovery = DISCOVERY; + for (const selectedVersion of [undefined, "1", "2.0", `1.${"0".repeat(15)}`]) { + const client = createT4ApiClient({ + baseUrl: "https://response-version.test", credential: "token-a", majorVersion: 1, + fetch: async () => Response.json(discovery, selectedVersion === undefined ? {} : { headers: { "T4-API-Version": selectedVersion } }), + }); + await expect(client.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ status: 502, code: "indeterminate", retryable: false }); + } + + const unauthenticated = createT4ApiClient({ + baseUrl: "https://response-version.test", credential: "token-a", majorVersion: 1, + fetch: async () => Response.json({ error: { code: "unauthenticated", message: "bad", requestId: "r", retryable: false } }, { + status: 401, + headers: { "WWW-Authenticate": 'Bearer realm="t4"' }, + }), + }); + expect((await unauthenticated.http.GET("/v1", { params: { header: VERSION_HEADERS } })).response.status).toBe(401); + + for (const challenge of [undefined, 'Basic realm="t4"']) { + const invalidChallenge = createT4ApiClient({ + baseUrl: "https://response-version.test", + credential: "token-a", + majorVersion: 1, + fetch: async () => Response.json( + { error: { code: "unauthenticated", message: "bad", requestId: "r", retryable: false } }, + { status: 401, ...(challenge === undefined ? {} : { headers: { "WWW-Authenticate": challenge } }) }, + ), + }); + await expect(invalidChallenge.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ + status: 502, code: "indeterminate", + }); + } + + const invalidUnauthenticated = createT4ApiClient({ + baseUrl: "https://response-version.test", credential: "token-a", majorVersion: 1, + fetch: async () => Response.json( + { error: { code: "unauthenticated", message: "bad", requestId: "r", retryable: false } }, + { status: 401, headers: { "T4-API-Version": "1.0" } }, + ), + }); + await expect(invalidUnauthenticated.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ + status: 502, code: "indeterminate", + }); + + const workspace = { id: "wsp-1", name: "workspace", state: "ready", revision: 1 }; + const missingReplay = createT4ApiClient({ + baseUrl: "https://response-replay.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse(workspace, { headers: { "T4-API-Version": "1.0" } }), + }); + await expect(missingReplay.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { header: mutationHeaders(1, "response-replay-patch"), path: { workspaceId: "wsp-1" } }, body: { name: "workspace" }, + })).rejects.toMatchObject({ status: 502, retryable: false }); + + const missingEventCursor = createT4ApiClient({ + baseUrl: "https://response-event-cursor.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse(workspace, { headers: { "T4-API-Version": "1.0", "Idempotency-Replayed": "false" } }), + }); + await expect(missingEventCursor.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { header: mutationHeaders(1, "response-event-cursor"), path: { workspaceId: "wsp-1" } }, body: { name: "workspace" }, + })).rejects.toMatchObject({ status: 502, retryable: false }); + + for (const replayed of [undefined, "TRUE", "0"]) { + const client = createT4ApiClient({ + baseUrl: "https://delete-replay-header.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse(null, { status: 204, headers: { + "T4-API-Version": "1.0", ...(replayed === undefined ? {} : { "Idempotency-Replayed": replayed }), + } }), + }); + await expect(client.http.DELETE("/v1/workspaces/{workspaceId}", { + params: { header: idempotencyHeaders("response-delete-key"), path: { workspaceId: "wsp-1" } }, + })).rejects.toMatchObject({ status: 502, retryable: false }); + } + + const missingWatchVersion = createT4ApiClient({ + baseUrl: "https://watch-response-version.test", credential: "token-a", majorVersion: 1, + fetch: async () => new Response('data: {"type":"heartbeat","cursor":"c1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { headers: { "Content-Type": "text/event-stream" } }), + }); + await expect(missingWatchVersion.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ status: 502, retryable: false }); + + const missingWatchErrorVersion = createT4ApiClient({ + baseUrl: "https://watch-error-version.test", credential: "token-a", majorVersion: 1, + fetch: async () => Response.json({ error: { code: "unavailable", message: "later", requestId: "r", retryable: true } }, { status: 503 }), + }); + await expect(missingWatchErrorVersion.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0, retryBackoffMs: 0 }).next()).rejects.toMatchObject({ status: 502, retryable: false }); + }); + + it("aligns the snapshot aggregate schema bound with high-level response validation", async () => { + const contract = JSON.parse(readFileSync(new URL("../../t4-api-contract/openapi.json", import.meta.url), "utf8")) as { + components: { schemas: { SessionSnapshot: { "x-t4-maxUtf8Bytes"?: number } } }; + }; + expect(contract.components.schemas.SessionSnapshot["x-t4-maxUtf8Bytes"]).toBe(16 * 1024 * 1024); + + for (const boundary of ["exact", "over"] as const) { + const service = new T4ApiV1ConformanceService({ snapshotBoundary: boundary }); + const client = await seededClient(service, `snapshot-${boundary}`); + const response = client.http.GET("/v1/sessions/{sessionId}/snapshot", { + params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } }, + }); + if (boundary === "exact") { + const snapshot = requireData(await response); + expect(snapshot.entries).toHaveLength(4); + expect(snapshot.entries[0]?.text).toContain("💚"); + } else { + await expect(response).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); + } + } + }); + + it("rejects duplicate durable cursors within one SSE stream and after reconnect", async () => { + const event = 'data: {"type":"session","cursor":"duplicate-1","state":"ready","revision":1}\n\n'; + for (const delivery of ["same-stream", "reconnect"] as const) { + let attempts = 0; + const client = createT4ApiClient({ + baseUrl: `https://duplicate-${delivery}.test`, credential: "token-a", majorVersion: 1, + fetch: async () => { + attempts += 1; + return apiResponse(delivery === "same-stream" ? `${event}${event}` : event, { headers: { "Content-Type": "text/event-stream" } }); + }, + }); + const stream = client.watchSession("ses-1", { maxEvents: 2, maxReconnectAttempts: 1, retryBackoffMs: 0 }); + await expect(stream.next()).resolves.toMatchObject({ value: { cursor: "duplicate-1" }, done: false }); + await expect(stream.next()).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); + expect(attempts).toBe(delivery === "same-stream" ? 1 : 2); + } + }); + + it("accepts current-cursor heartbeats without allowing them to advance the durable cursor", async () => { + const heartbeat = (cursor: string) => `data: ${JSON.stringify({ type: "heartbeat", cursor, observedAt: "2026-07-21T00:00:00Z" })}\n\n`; + const startingRequests: Array<{ query: string | null; header: string | null }> = []; + const starting = createT4ApiClient({ + baseUrl: "https://starting-heartbeat.test", credential: "token-a", majorVersion: 1, + fetch: async (input, init) => { + const request = new Request(input, init); + startingRequests.push({ query: new URL(request.url).searchParams.get("cursor"), header: request.headers.get("Last-Event-ID") }); + return apiResponse(heartbeat("duplicate-1"), { headers: { "Content-Type": "text/event-stream" } }); + }, + }); + await expect(starting.watchSession("ses-1", { cursor: "duplicate-1", maxEvents: 1, maxReconnectAttempts: 0 }).next()).resolves.toMatchObject({ + value: { type: "heartbeat", cursor: "duplicate-1" }, done: false, + }); + expect(startingRequests).toEqual([{ query: "duplicate-1", header: "duplicate-1" }]); + + const reconnectRequests: Array<{ query: string | null; header: string | null }> = []; + const reconnecting = createT4ApiClient({ + baseUrl: "https://reconnect-heartbeat.test", credential: "token-a", majorVersion: 1, + fetch: async (input, init) => { + const request = new Request(input, init); + reconnectRequests.push({ query: new URL(request.url).searchParams.get("cursor"), header: request.headers.get("Last-Event-ID") }); + return apiResponse(heartbeat("duplicate-1"), { headers: { "Content-Type": "text/event-stream" } }); + }, + }); + const liveness: WatchEvent[] = []; + for await (const event of reconnecting.watchSession("ses-1", { maxEvents: 2, maxReconnectAttempts: 1, retryBackoffMs: 0 })) liveness.push(event); + expect(liveness.map((event) => event.cursor)).toEqual(["duplicate-1", "duplicate-1"]); + expect(reconnectRequests).toEqual([{ query: null, header: null }, { query: "duplicate-1", header: "duplicate-1" }]); + + const advancing = createT4ApiClient({ + baseUrl: "https://advancing-heartbeat.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse(heartbeat("different-2"), { headers: { "Content-Type": "text/event-stream" } }), + }); + await expect(advancing.watchSession("ses-1", { cursor: "duplicate-1", maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ + code: "indeterminate", status: 502, retryable: false, + }); + }); + + it("rejects duplicate version entries and malformed capability maps", async () => { + const status = { supported: true, enabled: true, authorized: true, available: true }; + for (const discovery of [ + { ...DISCOVERY, supportedMajors: [1, 1] }, + { ...DISCOVERY, capabilities: { Invalid: status } }, + { ...DISCOVERY, capabilities: { "session.watch.sse": { ...status, unknown: true } } }, + ]) { + const client = createT4ApiClient({ + baseUrl: "https://invalid-discovery.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse(discovery, { headers: { "T4-API-Version": "1.0" } }), + }); + await expect(client.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ status: 502, retryable: false }); + } + const duplicateError = createT4ApiClient({ + baseUrl: "https://duplicate-error.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse({ error: { + code: "incompatible_version", message: "bad", requestId: "r", retryable: false, supportedMajors: [1, 1], + } }, { status: 406, headers: { "T4-API-Version": "1.0" } }), + }); + await expect(duplicateError.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ status: 502, code: "indeterminate", retryable: false }); + }); + + it("discards unterminated SSE data at EOF before reconnecting", async () => { + for (const unterminated of [ + 'data: {"type":"heartbeat","cursor":"unterminated","observedAt":"2026-07-21T00:00:00Z"}', + "data: {not-json", + ]) { + let attempts = 0; + const client = createT4ApiClient({ + baseUrl: "https://unterminated-sse.test", credential: "token-a", majorVersion: 1, + fetch: async () => { + attempts += 1; + const body = attempts === 1 + ? unterminated + : 'data: {"type":"heartbeat","cursor":"complete","observedAt":"2026-07-21T00:00:00Z"}\n\n'; + return apiResponse(body, { headers: { "Content-Type": "text/event-stream", "T4-API-Version": "1.0" } }); + }, + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 1, retryBackoffMs: 0 }).next()).resolves.toMatchObject({ value: { cursor: "complete" } }); + expect(attempts).toBe(2); + } + }); + + it("accepts exact code-point request lengths and rejects one more", async () => { + const service = new T4ApiV1ConformanceService(); + const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + const exact = "😀".repeat(128); + const workspace = await client.http.POST("/v1/workspaces", { + body: { name: exact }, params: { header: idempotencyHeaders("unicode-workspace-exact") }, + }); + expect(workspace.response.status).toBe(202); + expect((await client.http.POST("/v1/workspaces", { + body: { name: `${exact}😀` }, params: { header: idempotencyHeaders("unicode-workspace-over") }, + })).response.status).toBe(422); + const session = await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: exact }, params: { header: idempotencyHeaders("unicode-session-exact"), path: { workspaceId: requireData(workspace).id } }, + }); + expect(session.response.status).toBe(202); + expect((await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: `${exact}😀` }, params: { header: idempotencyHeaders("unicode-session-over"), path: { workspaceId: requireData(workspace).id } }, + })).response.status).toBe(422); + }); + + it("fails terminally and cancels an invalid UTF-8 SSE body", async () => { + let cancelled = false; + const client = createT4ApiClient({ + baseUrl: "https://invalid-utf8-sse.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse(new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array([100, 97, 116, 97, 58, 32, 195, 40, 10, 10])); }, + cancel() { cancelled = true; }, + }), { headers: { "Content-Type": "text/event-stream", "T4-API-Version": "1.0" } }), + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 3 }).next()).rejects.toMatchObject({ status: 502, code: "indeterminate", retryable: false }); + expect(cancelled).toBe(true); + }); + + it("scopes one DELETE key independently by live target ID", async () => { + const service = new T4ApiV1ConformanceService(); + const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + for (const [key, name] of [["delete-target-workspace-a", "first"], ["delete-target-workspace-b", "second"]] as const) { + requireData(await client.http.POST("/v1/workspaces", { body: { name }, params: { header: idempotencyHeaders(key) } })); + } + for (const workspaceId of ["ws-1", "ws-2"] as const) { + expect((await client.http.DELETE("/v1/workspaces/{workspaceId}", { + params: { header: idempotencyHeaders("delete-shared-workspace"), path: { workspaceId } }, + })).response.status).toBe(204); + } + + const sessionService = new T4ApiV1ConformanceService(); + const sessionClient = createT4ApiClient({ baseUrl: sessionService.origin, credential: "token-a", majorVersion: 1, fetch: sessionService.fetch }); + requireData(await sessionClient.http.POST("/v1/workspaces", { body: { name: "workspace" }, params: { header: idempotencyHeaders("delete-session-workspace") } })); + for (const [key, title] of [["delete-target-session-a", "first"], ["delete-target-session-b", "second"]] as const) { + requireData(await sessionClient.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title }, params: { header: idempotencyHeaders(key), path: { workspaceId: "ws-1" } }, + })); + } + for (const sessionId of ["ses-1", "ses-2"] as const) { + expect((await sessionClient.http.DELETE("/v1/sessions/{sessionId}", { + params: { header: idempotencyHeaders("delete-shared-session"), path: { sessionId } }, + })).response.status).toBe(204); + } + }); + + it("bounds automatic EOF retry and supports explicit abort", async () => { + let attempts = 0; + const eofFetch: typeof globalThis.fetch = async () => { + attempts += 1; + return apiResponse(new ReadableStream({ start(controller) { controller.close(); } }), { headers: { "Content-Type": "text/event-stream" } }); + }; + const eofClient = createT4ApiClient({ baseUrl: "https://eof.test", credential: "token-a", majorVersion: 1, fetch: eofFetch }); + await expect(eofClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 2, retryBackoffMs: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + expect(attempts).toBe(3); + + const reconnectHeaders: Array = []; + let networkAttempt = 0; + const networkFetch: typeof globalThis.fetch = async (_input, init) => { + reconnectHeaders.push(new Headers(init?.headers).get("Last-Event-ID")); + networkAttempt += 1; + if (networkAttempt === 1) { + let sent = false; + return apiResponse(new ReadableStream({ + pull(stream) { + if (sent) stream.error(new TypeError("transient network loss")); + else { + sent = true; + stream.enqueue(new TextEncoder().encode('data: {"type":"heartbeat","cursor":"network-1","observedAt":"2026-07-21T00:00:00Z"}\n\n')); + } + }, + }), { headers: { "Content-Type": "text/event-stream" } }); + } + return apiResponse('data: {"type":"session","cursor":"network-2","state":"ready","revision":2}\n\n', { headers: { "Content-Type": "text/event-stream" } }); + }; + const networkClient = createT4ApiClient({ baseUrl: "https://network.test", credential: "token-a", majorVersion: 1, fetch: networkFetch }); + const networkEvents: WatchEvent[] = []; + for await (const event of networkClient.watchSession("ses-1", { maxEvents: 2, maxReconnectAttempts: 1, retryBackoffMs: 0 })) networkEvents.push(event); + expect(networkEvents.map((event) => event.cursor)).toEqual(["network-1", "network-2"]); + expect(reconnectHeaders).toEqual([null, "network-1"]); + + let retryableAttempts = 0; + const retryableFetch: typeof globalThis.fetch = async () => { + retryableAttempts += 1; + if (retryableAttempts === 1) return jsonResponse({ error: { code: "unavailable", message: "retry", requestId: "r", retryable: true } }, { status: 503, headers: { "Retry-After": "0" } }); + return apiResponse('data: {"type":"heartbeat","cursor":"retry-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { headers: { "Content-Type": "text/event-stream" } }); + }; + const retryableClient = createT4ApiClient({ baseUrl: "https://retryable.test", credential: "token-a", majorVersion: 1, fetch: retryableFetch }); + expect((await retryableClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 1, retryBackoffMs: 0 }).next()).value).toMatchObject({ cursor: "retry-1" }); + expect(retryableAttempts).toBe(2); + + let progressAttempts = 0; + const progressFetch: typeof globalThis.fetch = async () => { + progressAttempts += 1; + if (progressAttempts === 2 || progressAttempts === 4) { + const cursor = progressAttempts === 2 ? "progress-1" : "progress-2"; + return apiResponse(`data: {"type":"session","cursor":"${cursor}","state":"ready","revision":${progressAttempts / 2}}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); + } + return apiResponse(new ReadableStream({ start(controller) { controller.close(); } }), { headers: { "Content-Type": "text/event-stream" } }); + }; + const progressClient = createT4ApiClient({ baseUrl: "https://progress.test", credential: "token-a", majorVersion: 1, fetch: progressFetch }); + const progressEvents: WatchEvent[] = []; + for await (const event of progressClient.watchSession("ses-1", { maxEvents: 2, maxReconnectAttempts: 1, retryBackoffMs: 0 })) progressEvents.push(event); + expect(progressEvents.map((event) => event.cursor)).toEqual(["progress-1", "progress-2"]); + expect(progressAttempts).toBe(4); + + vi.useFakeTimers(); + try { + const retryAfterController = new AbortController(); + let retryAfterAttempts = 0; + const retryAfterFetch: typeof globalThis.fetch = async () => { + retryAfterAttempts += 1; + return jsonResponse({ error: { code: "unavailable", message: "later", requestId: "r", retryable: true } }, { status: 503, headers: { "Retry-After": "30" } }); + }; + const retryAfterClient = createT4ApiClient({ baseUrl: "https://retry-after.test", credential: "token-a", majorVersion: 1, fetch: retryAfterFetch }); + const retryAfterPending = retryAfterClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 2, retryBackoffMs: 0, signal: retryAfterController.signal }).next(); + const retryAfterAssertion = expect(retryAfterPending).resolves.toMatchObject({ done: true }); + await vi.advanceTimersByTimeAsync(0); + expect(retryAfterAttempts).toBe(1); + retryAfterController.abort(); + await retryAfterAssertion; + } finally { + vi.useRealTimers(); + } + + const controller = new AbortController(); + const abortFetch: typeof globalThis.fetch = async (_input, init) => apiResponse(new ReadableStream({ + start(stream) { init?.signal?.addEventListener("abort", () => stream.close(), { once: true }); }, + }), { headers: { "Content-Type": "text/event-stream" } }); + const abortClient = createT4ApiClient({ baseUrl: "https://abort.test", credential: "token-a", majorVersion: 1, fetch: abortFetch }); + const pending = abortClient.watchSession("ses-1", { signal: controller.signal }).next(); + controller.abort("done"); + await expect(pending).resolves.toMatchObject({ done: true }); + }); +}); diff --git a/packages/cluster-operator/api/v1alpha1/testdata/compat/v1alpha1-t4clusterhost.yaml b/packages/cluster-operator/api/v1alpha1/testdata/compat/v1alpha1-t4clusterhost.yaml new file mode 100644 index 00000000..d618128d --- /dev/null +++ b/packages/cluster-operator/api/v1alpha1/testdata/compat/v1alpha1-t4clusterhost.yaml @@ -0,0 +1,25 @@ +apiVersion: cluster.t4.dev/v1alpha1 +kind: T4ClusterHost +metadata: + name: legacy-host +spec: + storageClassName: legacy-rwx + runtimeProfiles: + - default + - gui + ciProvider: + secretRef: + name: legacy-ci-token + configMapRef: + name: legacy-ci-config + allowedOrigins: + - https://t4.example.test +status: + observedGeneration: 3 + conditions: + - type: Available + status: "True" + observedGeneration: 3 + lastTransitionTime: "2026-01-15T12:00:00Z" + reason: Reconciled + message: Legacy host remains available diff --git a/packages/cluster-operator/api/v1alpha1/testdata/compat/v1alpha1-t4session.yaml b/packages/cluster-operator/api/v1alpha1/testdata/compat/v1alpha1-t4session.yaml new file mode 100644 index 00000000..dde06302 --- /dev/null +++ b/packages/cluster-operator/api/v1alpha1/testdata/compat/v1alpha1-t4session.yaml @@ -0,0 +1,29 @@ +apiVersion: cluster.t4.dev/v1alpha1 +kind: T4Session +metadata: + name: legacy-session + finalizers: + - cluster.t4.dev/session-cleanup +spec: + hostRef: legacy-host + workspaceRef: legacy-workspace + title: Legacy session + runtimeProfile: gui + initialPromptSecretRef: + name: legacy-initial-prompt + ci: + repositoryId: legacy/project + ref: refs/heads/main + commit: 0123456789abcdef0123456789abcdef01234567 +status: + observedGeneration: 7 + podName: legacy-session-pod + serviceName: legacy-session + phase: Running + conditions: + - type: Available + status: "True" + observedGeneration: 7 + lastTransitionTime: "2026-01-15T12:00:00Z" + reason: RuntimeReady + message: Legacy session infrastructure is running diff --git a/packages/cluster-operator/api/v1alpha1/testdata/compat/v1alpha1-t4workspace.yaml b/packages/cluster-operator/api/v1alpha1/testdata/compat/v1alpha1-t4workspace.yaml new file mode 100644 index 00000000..dcd9ac1c --- /dev/null +++ b/packages/cluster-operator/api/v1alpha1/testdata/compat/v1alpha1-t4workspace.yaml @@ -0,0 +1,29 @@ +apiVersion: cluster.t4.dev/v1alpha1 +kind: T4Workspace +metadata: + name: legacy-workspace + finalizers: + - cluster.t4.dev/workspace-protection +spec: + hostRef: legacy-host + displayName: Legacy workspace + owner: user:legacy-owner + repository: + repositoryId: legacy/project + ref: refs/heads/main + commit: 0123456789abcdef0123456789abcdef01234567 + size: 20Gi + retentionPolicy: Retain +status: + observedGeneration: 4 + pvcName: legacy-workspace-data + pvcPhase: Bound + capacity: 20Gi + phase: Ready + conditions: + - type: Ready + status: "True" + observedGeneration: 4 + lastTransitionTime: "2026-01-15T12:00:00Z" + reason: StorageBound + message: Legacy workspace storage is ready diff --git a/packages/cluster-operator/api/v1alpha1/types_test.go b/packages/cluster-operator/api/v1alpha1/types_test.go index fde778fb..4bb6390a 100644 --- a/packages/cluster-operator/api/v1alpha1/types_test.go +++ b/packages/cluster-operator/api/v1alpha1/types_test.go @@ -1,8 +1,10 @@ package v1alpha1_test import ( + "encoding/json" "os" "path/filepath" + "reflect" "strings" "testing" @@ -11,6 +13,7 @@ import ( structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" "sigs.k8s.io/yaml" clusterv1alpha1 "github.com/LycaonLLC/t4-code/packages/cluster-operator/api/v1alpha1" @@ -141,6 +144,107 @@ func TestCRDSchemasAreStructuralBoundedAndValidated(t *testing.T) { } } +func TestOldObjectsDefaultAndRoundTripDeclaredFields(t *testing.T) { + tests := []struct { + fixture string + crd string + }{ + {"v1alpha1-t4clusterhost.yaml", "t4clusterhosts.cluster.t4.dev.yaml"}, + {"v1alpha1-t4workspace.yaml", "t4workspaces.cluster.t4.dev.yaml"}, + {"v1alpha1-t4session.yaml", "t4sessions.cluster.t4.dev.yaml"}, + } + + scheme := runtime.NewScheme() + if err := clusterv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + decoder := serializer.NewCodecFactory(scheme).UniversalDeserializer() + + for _, tc := range tests { + t.Run(tc.fixture, func(t *testing.T) { + fixtureRaw, err := os.ReadFile(filepath.Join("testdata", "compat", tc.fixture)) + if err != nil { + t.Fatal(err) + } + var declared map[string]interface{} + if err := yaml.Unmarshal(fixtureRaw, &declared); err != nil { + t.Fatalf("decode fixture: %v", err) + } + + crdRaw, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "deploy", "charts", "t4-cluster", "crds", tc.crd)) + if err != nil { + t.Fatal(err) + } + var crd apiextensionsv1.CustomResourceDefinition + if err := yaml.Unmarshal(crdRaw, &crd); err != nil { + t.Fatalf("decode CRD: %v", err) + } + if len(crd.Spec.Versions) != 1 || crd.Spec.Versions[0].Name != "v1alpha1" || !crd.Spec.Versions[0].Served || !crd.Spec.Versions[0].Storage { + t.Fatalf("storage contract changed: %#v", crd.Spec.Versions) + } + + if tc.fixture == "v1alpha1-t4session.yaml" { + specSchema := crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"] + guiSchema := specSchema.Properties["guiEnabled"] + if guiSchema.Default == nil || string(guiSchema.Default.Raw) != "false" { + t.Fatalf("guiEnabled schema default = %#v, want false", guiSchema.Default) + } + } + + admittedJSON, err := json.Marshal(declared) + if err != nil { + t.Fatal(err) + } + object, gvk, err := decoder.Decode(admittedJSON, nil, nil) + if err != nil { + t.Fatalf("decode through registered v1alpha1 API: %v", err) + } + if gvk.GroupVersion() != clusterv1alpha1.GroupVersion { + t.Fatalf("decoded version = %s", gvk.GroupVersion()) + } + roundTripJSON, err := json.Marshal(object) + if err != nil { + t.Fatal(err) + } + var roundTripped map[string]interface{} + if err := json.Unmarshal(roundTripJSON, &roundTripped); err != nil { + t.Fatal(err) + } + assertDeclaredFieldsPreserved(t, "$", declared, roundTripped) + }) + } +} + +func assertDeclaredFieldsPreserved(t *testing.T, path string, declared, roundTripped interface{}) { + t.Helper() + switch expected := declared.(type) { + case map[string]interface{}: + actual, ok := roundTripped.(map[string]interface{}) + if !ok { + t.Fatalf("%s changed type: got %T", path, roundTripped) + } + for key, expectedValue := range expected { + actualValue, found := actual[key] + if !found { + t.Fatalf("%s.%s was lost", path, key) + } + assertDeclaredFieldsPreserved(t, path+"."+key, expectedValue, actualValue) + } + case []interface{}: + actual, ok := roundTripped.([]interface{}) + if !ok || len(actual) != len(expected) { + t.Fatalf("%s changed array shape: got %#v", path, roundTripped) + } + for index := range expected { + assertDeclaredFieldsPreserved(t, path, expected[index], actual[index]) + } + default: + if !reflect.DeepEqual(declared, roundTripped) { + t.Fatalf("%s changed from %#v to %#v", path, declared, roundTripped) + } + } +} + func TestCRDsHaveCrossFieldCELAndForbidClientRuntimeAuthority(t *testing.T) { for _, name := range []string{"t4clusterhosts.cluster.t4.dev.yaml", "t4workspaces.cluster.t4.dev.yaml", "t4sessions.cluster.t4.dev.yaml"} { raw, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "deploy", "charts", "t4-cluster", "crds", name)) diff --git a/packages/cluster-operator/charttests/chart_contract_test.go b/packages/cluster-operator/charttests/chart_contract_test.go index 9dafdf7f..75197177 100644 --- a/packages/cluster-operator/charttests/chart_contract_test.go +++ b/packages/cluster-operator/charttests/chart_contract_test.go @@ -545,7 +545,25 @@ func TestCRDsRemainExplicitAcrossUpgradeAndUninstall(t *testing.T) { if err != nil { t.Fatal(err) } - for _, required := range []string{"helm upgrade", "helm rollback", "helm uninstall", "kubectl apply --server-side -f deploy/charts/t4-cluster/crds/", "condition=Established", "Do not rely on `helm upgrade` to change CRDs", "Retain", "Delete", "CRDs are not removed"} { + for _, required := range []string{ + "scripts/cluster-ci/crd-lifecycle.sh upgrade", + "helm upgrade", + "--skip-crds", + "helm rollback", + "helm uninstall", + "kubectl patch \"crd/$resource\" --type=merge --dry-run=server", + "metadata.resourceVersion", + "crd-preflight compatible", + "crd-preflight patch", + "--request-timeout=10s", + "condition=Established", + "status.storedVersions", + "Do not rely on `helm upgrade` to change CRDs", + "Future `v1beta1` conversion and storage procedure", + "Retain", + "Delete", + "CRDs are not removed", + } { if !strings.Contains(string(docs), required) { t.Fatalf("operator guide lacks upgrade/uninstall contract %q", required) } @@ -612,8 +630,8 @@ func TestImageContractsArePinnedAndAuthorityCompatible(t *testing.T) { } } assertContains(t, session, - "8476f4451ed95c5d5401785d279a93d3c659fac4", - "t4code-17.0.5-appserver-10", + "fc0c391334c08ab260057756aa84bd2b07741ee7", + "t4code-17.0.5-appserver-13", "t4-omp-authority/1", "session-entrypoint.sh", "chromium", @@ -630,8 +648,8 @@ func TestImageContractsArePinnedAndAuthorityCompatible(t *testing.T) { t.Fatal("session runtime permits overriding a labeled runtime pin") } assertContains(t, session, - "refs/tags/t4code-17.0.5-appserver-10", - "git checkout --detach \"8476f4451ed95c5d5401785d279a93d3c659fac4\"", + "refs/tags/t4code-17.0.5-appserver-13", + "git checkout --detach \"fc0c391334c08ab260057756aa84bd2b07741ee7\"", "snapshot.debian.org/archive/debian/20250721T000000Z", ) assertContains(t, server, "snapshot.debian.org/archive/debian/20250721T000000Z") diff --git a/packages/cluster-operator/cmd/crd-preflight/main.go b/packages/cluster-operator/cmd/crd-preflight/main.go new file mode 100644 index 00000000..5b391857 --- /dev/null +++ b/packages/cluster-operator/cmd/crd-preflight/main.go @@ -0,0 +1,649 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "strings" + + apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel" + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/pruning" + apiservervalidation "k8s.io/apiextensions-apiserver/pkg/apiserver/validation" + "k8s.io/apiextensions-apiserver/pkg/controller/openapi/builder" + "k8s.io/apimachinery/pkg/runtime" + kubejson "k8s.io/apimachinery/pkg/util/json" + "k8s.io/apimachinery/pkg/util/validation/field" + "sigs.k8s.io/yaml" +) + +const ( + perCallCELCostLimit = 1_000_000 + runtimeCELCostBudget = 10_000_000 +) + +type groupVersionKind struct { + Group string + Version string + Kind string +} + +type candidateSchema struct { + crd *apiextensionsv1.CustomResourceDefinition + version *apiextensionsv1.CustomResourceDefinitionVersion + internal *apiextensions.JSONSchemaProps + structural *structuralschema.Structural +} + +func main() { + if len(os.Args) < 2 { + usage() + } + var err error + switch os.Args[1] { + case "fixtures": + if len(os.Args) != 4 { + usage() + } + err = validateFixtures(os.Args[2], os.Args[3]) + case "objects": + if len(os.Args) != 3 { + usage() + } + err = validateObjects(os.Args[2], os.Stdin) + case "compatible": + if len(os.Args) != 4 { + usage() + } + err = validateCompatibility(os.Args[2], os.Args[3]) + case "patch": + if len(os.Args) != 4 { + usage() + } + err = writeMergePatch(os.Args[2], os.Args[3], os.Stdout) + case "served": + if len(os.Args) != 3 { + usage() + } + err = verifyServedSchemas(os.Args[2], os.Stdin) + default: + usage() + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: crd-preflight fixtures CRD_DIRECTORY FIXTURE_DIRECTORY | objects CRD_DIRECTORY | compatible CANDIDATE_CRD_DIRECTORY INSTALLED_CRD_DIRECTORY | patch CANDIDATE_CRD INSTALLED_CRD | served CRD_DIRECTORY") + os.Exit(64) +} + +func validateFixtures(crdDirectory, fixtureDirectory string) error { + candidates, err := loadCandidates(crdDirectory) + if err != nil { + return err + } + paths, err := yamlPaths(fixtureDirectory) + if err != nil { + return err + } + var validationErrors []error + for _, path := range paths { + object, err := decodeYAMLObject(path) + if err != nil { + validationErrors = append(validationErrors, err) + continue + } + validationErrors = append(validationErrors, validateCandidateObject(path, "fixture", object, candidates)...) + } + return errors.Join(validationErrors...) +} + +func validateObjects(crdDirectory string, input io.Reader) error { + candidates, err := loadCandidates(crdDirectory) + if err != nil { + return err + } + var list map[string]interface{} + decoder := json.NewDecoder(input) + decoder.UseNumber() + if err := decoder.Decode(&list); err != nil { + return fmt.Errorf("decode live object list: %w", err) + } + if err := kubejson.ConvertMapNumbers(list, 0); err != nil { + return fmt.Errorf("decode live object list numbers: %w", err) + } + var trailing interface{} + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("decode live object list: multiple JSON documents") + } + return fmt.Errorf("decode live object list trailing data: %w", err) + } + rawItems, found := list["items"] + if !found { + return errors.New("decode live object list: missing items") + } + items, ok := rawItems.([]interface{}) + if !ok { + return errors.New("decode live object list: items is not an array") + } + var validationErrors []error + for index, rawItem := range items { + object, ok := rawItem.(map[string]interface{}) + if !ok { + validationErrors = append(validationErrors, fmt.Errorf("live object item %d is not an object", index)) + continue + } + path, err := liveObjectPath(index, object) + if err != nil { + validationErrors = append(validationErrors, err) + continue + } + validationErrors = append(validationErrors, validateCandidateObject(path, "object", object, candidates)...) + } + return errors.Join(validationErrors...) +} + +func validateCompatibility(candidateDirectory, installedDirectory string) error { + installedPaths, err := yamlPaths(installedDirectory) + if err != nil { + return err + } + if len(installedPaths) == 0 { + return nil + } + candidateCRDs, err := loadCRDs(candidateDirectory) + if err != nil { + return err + } + installedCRDs, err := loadCRDs(installedDirectory) + if err != nil { + return err + } + var compatibilityErrors []error + for name, current := range installedCRDs { + proposed, found := candidateCRDs[name] + if !found { + compatibilityErrors = append(compatibilityErrors, fmt.Errorf("installed CRD %s is removed", name)) + continue + } + if current.Spec.Scope != proposed.Spec.Scope { + compatibilityErrors = append(compatibilityErrors, fmt.Errorf("CRD %s changes scope from %s to %s", name, current.Spec.Scope, proposed.Spec.Scope)) + } + if !reflect.DeepEqual(current.Spec.Names, proposed.Spec.Names) { + compatibilityErrors = append(compatibilityErrors, fmt.Errorf("CRD %s changes resource names", name)) + } + if !reflect.DeepEqual(versionContract(current), versionContract(proposed)) { + compatibilityErrors = append(compatibilityErrors, fmt.Errorf("CRD %s changes its version, served, or storage contract", name)) + } + if !reflect.DeepEqual(conversionContract(current), conversionContract(proposed)) { + compatibilityErrors = append(compatibilityErrors, fmt.Errorf("CRD %s changes conversion configuration", name)) + } + } + candidates, err := loadCandidates(candidateDirectory) + if err != nil { + return err + } + installed, err := loadCandidates(installedDirectory) + if err != nil { + return err + } + for gvk, current := range installed { + proposed, found := candidates[gvk] + if !found { + compatibilityErrors = append(compatibilityErrors, fmt.Errorf("installed schema for %s/%s %s is removed", gvk.Group, gvk.Version, gvk.Kind)) + continue + } + path := fmt.Sprintf("%s/%s %s", gvk.Group, gvk.Version, gvk.Kind) + if current.crd.Spec.Scope != proposed.crd.Spec.Scope { + compatibilityErrors = append(compatibilityErrors, fmt.Errorf("%s changes scope from %s to %s", path, current.crd.Spec.Scope, proposed.crd.Spec.Scope)) + } + if !reflect.DeepEqual(current.crd.Spec.Names, proposed.crd.Spec.Names) { + compatibilityErrors = append(compatibilityErrors, fmt.Errorf("%s changes resource names", path)) + } + if current.version.Storage != proposed.version.Storage { + compatibilityErrors = append(compatibilityErrors, fmt.Errorf("%s changes the storage flag", path)) + } + if !reflect.DeepEqual(current.version.Subresources, proposed.version.Subresources) { + compatibilityErrors = append(compatibilityErrors, fmt.Errorf("%s changes subresources", path)) + } + currentSchema, err := schemaMap(current.version.Schema.OpenAPIV3Schema) + if err != nil { + compatibilityErrors = append(compatibilityErrors, fmt.Errorf("%s: encode installed schema: %w", path, err)) + continue + } + proposedSchema, err := schemaMap(proposed.version.Schema.OpenAPIV3Schema) + if err != nil { + compatibilityErrors = append(compatibilityErrors, fmt.Errorf("%s: encode proposed schema: %w", path, err)) + continue + } + stripDescriptions(currentSchema) + stripDescriptions(proposedSchema) + compatibilityErrors = append(compatibilityErrors, compareAdditiveSchema(path, currentSchema, proposedSchema)...) + } + return errors.Join(compatibilityErrors...) +} + +func conversionContract(crd *apiextensionsv1.CustomResourceDefinition) *apiextensionsv1.CustomResourceConversion { + if crd.Spec.Conversion == nil { + return &apiextensionsv1.CustomResourceConversion{Strategy: apiextensionsv1.NoneConverter} + } + result := crd.Spec.Conversion.DeepCopy() + if result.Strategy == "" { + result.Strategy = apiextensionsv1.NoneConverter + } + return result +} + +type versionContractEntry struct { + Served bool + Storage bool +} + +func versionContract(crd *apiextensionsv1.CustomResourceDefinition) map[string]versionContractEntry { + result := make(map[string]versionContractEntry, len(crd.Spec.Versions)) + for _, version := range crd.Spec.Versions { + result[version.Name] = versionContractEntry{Served: version.Served, Storage: version.Storage} + } + return result +} + +// writeMergePatch emits the narrow, resource-version-guarded update used by +// the lifecycle runner. The API server rejects the patch if the installed CRD +// changed or was deleted and recreated after compatibility validation. +func writeMergePatch(candidatePath, installedPath string, output io.Writer) error { + candidate, err := loadCRD(candidatePath) + if err != nil { + return err + } + installed, err := loadCRD(installedPath) + if err != nil { + return err + } + if candidate.Name != installed.Name { + return fmt.Errorf("candidate CRD %s does not match installed CRD %s", candidate.Name, installed.Name) + } + if installed.ResourceVersion == "" || installed.UID == "" { + return fmt.Errorf("installed CRD %s lacks metadata.resourceVersion or metadata.uid", installed.Name) + } + patch := map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": installed.ResourceVersion, + "uid": installed.UID, + }, + "spec": candidate.Spec, + } + encoder := json.NewEncoder(output) + if err := encoder.Encode(patch); err != nil { + return fmt.Errorf("encode merge patch for %s: %w", candidate.Name, err) + } + return nil +} + +func schemaMap(schema *apiextensionsv1.JSONSchemaProps) (map[string]interface{}, error) { + raw, err := json.Marshal(schema) + if err != nil { + return nil, err + } + var result map[string]interface{} + decoder := json.NewDecoder(strings.NewReader(string(raw))) + decoder.UseNumber() + if err := decoder.Decode(&result); err != nil { + return nil, err + } + return result, nil +} + +// compareAdditiveSchema deliberately accepts a narrow evolution surface: an +// existing schema node must keep exactly the same semantics, while an object +// may add new optional properties. This is stricter than merely proving that a +// snapshot of live objects happens to validate and therefore remains safe when +// another writer creates an object immediately before the CRD apply. +func compareAdditiveSchema(path string, current, proposed map[string]interface{}) []error { + var result []error + for keyword, currentValue := range current { + proposedValue, found := proposed[keyword] + if !found { + result = append(result, fmt.Errorf("%s removes schema keyword %s", path, keyword)) + continue + } + if keyword == "properties" { + currentProperties, currentOK := currentValue.(map[string]interface{}) + proposedProperties, proposedOK := proposedValue.(map[string]interface{}) + if !currentOK || !proposedOK { + result = append(result, fmt.Errorf("%s changes properties representation", path)) + continue + } + for name, currentProperty := range currentProperties { + proposedProperty, exists := proposedProperties[name] + if !exists { + result = append(result, fmt.Errorf("%s.%s removes an existing property", path, name)) + continue + } + currentMap, currentIsMap := currentProperty.(map[string]interface{}) + proposedMap, proposedIsMap := proposedProperty.(map[string]interface{}) + if !currentIsMap || !proposedIsMap { + if !reflect.DeepEqual(currentProperty, proposedProperty) { + result = append(result, fmt.Errorf("%s.%s changes schema semantics", path, name)) + } + continue + } + result = append(result, compareAdditiveSchema(path+"."+name, currentMap, proposedMap)...) + } + continue + } + if keyword == "required" { + if !sameStringSet(currentValue, proposedValue) { + result = append(result, fmt.Errorf("%s changes required properties", path)) + } + continue + } + if !reflect.DeepEqual(currentValue, proposedValue) { + result = append(result, fmt.Errorf("%s changes schema keyword %s", path, keyword)) + } + } + for keyword := range proposed { + if _, found := current[keyword]; found || keyword == "properties" { + continue + } + result = append(result, fmt.Errorf("%s adds schema keyword %s to an existing node", path, keyword)) + } + return result +} + +func sameStringSet(left, right interface{}) bool { + leftValues, leftOK := left.([]interface{}) + rightValues, rightOK := right.([]interface{}) + if !leftOK || !rightOK || len(leftValues) != len(rightValues) { + return false + } + counts := make(map[string]int, len(leftValues)) + for _, value := range leftValues { + text, ok := value.(string) + if !ok { + return false + } + counts[text]++ + } + for _, value := range rightValues { + text, ok := value.(string) + if !ok || counts[text] == 0 { + return false + } + counts[text]-- + } + return true +} + +func liveObjectPath(index int, object map[string]interface{}) (string, error) { + metadata, ok := object["metadata"].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("live object item %d has no metadata", index) + } + name, _ := metadata["name"].(string) + namespace, _ := metadata["namespace"].(string) + if name == "" || namespace == "" { + return "", fmt.Errorf("live object item %d must have namespace and name", index) + } + return fmt.Sprintf("live object %s/%s", namespace, name), nil +} + +func validateCandidateObject(path, fieldRoot string, object map[string]interface{}, candidates map[groupVersionKind]*candidateSchema) []error { + apiVersion, _ := object["apiVersion"].(string) + kind, _ := object["kind"].(string) + group, version, ok := strings.Cut(apiVersion, "/") + if !ok || group == "" || version == "" || kind == "" { + return []error{fmt.Errorf("%s: apiVersion and kind must identify a grouped resource", path)} + } + candidate, found := candidates[groupVersionKind{Group: group, Version: version, Kind: kind}] + if !found { + return []error{fmt.Errorf("%s: no proposed schema for %s %s", path, apiVersion, kind)} + } + return validateObject(path, fieldRoot, object, candidate) +} + +func validateObject(path, fieldRoot string, object map[string]interface{}, candidate *candidateSchema) []error { + var result []error + validator, _, err := apiservervalidation.NewSchemaValidator(candidate.internal) + if err != nil { + return []error{fmt.Errorf("%s: build OpenAPI validator: %w", path, err)} + } + if errs := apiservervalidation.ValidateCustomResource(field.NewPath(fieldRoot), object, validator); len(errs) > 0 { + result = append(result, fmt.Errorf("%s: proposed OpenAPI validation failed: %w", path, errs.ToAggregate())) + } + celValidator := cel.NewValidator(candidate.structural, true, perCallCELCostLimit) + if celValidator != nil { + objectPath := field.NewPath(fieldRoot) + createErrors, _ := celValidator.Validate(context.Background(), objectPath, candidate.structural, object, nil, runtimeCELCostBudget) + if len(createErrors) > 0 { + result = append(result, fmt.Errorf("%s: proposed CEL create validation failed: %w", path, createErrors.ToAggregate())) + } + updateErrors, _ := celValidator.Validate(context.Background(), objectPath, candidate.structural, object, object, runtimeCELCostBudget) + if len(updateErrors) > 0 { + result = append(result, fmt.Errorf("%s: proposed CEL unchanged-update validation failed: %w", path, updateErrors.ToAggregate())) + } + } + unknownFields := pruning.PruneWithOptions( + runtime.DeepCopyJSONValue(object), + candidate.structural, + true, + structuralschema.UnknownFieldPathOptions{TrackUnknownFieldPaths: true}, + ) + if len(unknownFields) > 0 { + result = append(result, fmt.Errorf("%s: proposed structural schema would prune declared fields: %s", path, strings.Join(unknownFields, ", "))) + } + return result +} + +func verifyServedSchemas(crdDirectory string, discovery io.Reader) error { + candidates, err := loadCandidates(crdDirectory) + if err != nil { + return err + } + var document map[string]interface{} + decoder := json.NewDecoder(discovery) + decoder.UseNumber() + if err := decoder.Decode(&document); err != nil { + return fmt.Errorf("decode served OpenAPI v3 document: %w", err) + } + for gvk, candidate := range candidates { + actual, err := schemaForGVK(document, gvk) + if err != nil { + return fmt.Errorf("served schema for %s/%s %s: %w", gvk.Group, gvk.Version, gvk.Kind, err) + } + expected, err := generatedSchema(candidate, gvk) + if err != nil { + return fmt.Errorf("generate proposed schema for %s/%s %s: %w", gvk.Group, gvk.Version, gvk.Kind, err) + } + normalizePublishedSchema(expected) + normalizePublishedSchema(actual) + if !reflect.DeepEqual(expected, actual) { + return fmt.Errorf("served OpenAPI semantics for %s/%s %s do not match the proposed CRD", gvk.Group, gvk.Version, gvk.Kind) + } + } + return nil +} + +func generatedSchema(candidate *candidateSchema, gvk groupVersionKind) (map[string]interface{}, error) { + openapi, err := builder.BuildOpenAPIV3(candidate.crd, candidate.version.Name, builder.Options{}) + if err != nil { + return nil, err + } + raw, err := json.Marshal(openapi) + if err != nil { + return nil, err + } + var document map[string]interface{} + decoder := json.NewDecoder(strings.NewReader(string(raw))) + decoder.UseNumber() + if err := decoder.Decode(&document); err != nil { + return nil, err + } + return schemaForGVK(document, gvk) +} + +func schemaForGVK(document map[string]interface{}, wanted groupVersionKind) (map[string]interface{}, error) { + components, ok := document["components"].(map[string]interface{}) + if !ok { + return nil, errors.New("document has no components") + } + schemas, ok := components["schemas"].(map[string]interface{}) + if !ok { + return nil, errors.New("document has no component schemas") + } + for _, value := range schemas { + schema, ok := value.(map[string]interface{}) + if !ok { + continue + } + gvks, ok := schema["x-kubernetes-group-version-kind"].([]interface{}) + if !ok { + continue + } + for _, value := range gvks { + gvk, ok := value.(map[string]interface{}) + if ok && gvk["group"] == wanted.Group && gvk["version"] == wanted.Version && gvk["kind"] == wanted.Kind { + return runtime.DeepCopyJSONValue(schema).(map[string]interface{}), nil + } + } + } + return nil, errors.New("matching x-kubernetes-group-version-kind was not published") +} + +func normalizePublishedSchema(schema map[string]interface{}) { + delete(schema, "x-kubernetes-group-version-kind") + if properties, ok := schema["properties"].(map[string]interface{}); ok { + delete(properties, "apiVersion") + delete(properties, "kind") + delete(properties, "metadata") + } + stripDescriptions(schema) +} + +func stripDescriptions(value interface{}) { + switch value := value.(type) { + case map[string]interface{}: + delete(value, "description") + for _, child := range value { + stripDescriptions(child) + } + case []interface{}: + for _, child := range value { + stripDescriptions(child) + } + } +} + +func loadCandidates(directory string) (map[groupVersionKind]*candidateSchema, error) { + crds, err := loadCRDs(directory) + if err != nil { + return nil, err + } + result := make(map[groupVersionKind]*candidateSchema) + for name, crd := range crds { + for index := range crd.Spec.Versions { + version := &crd.Spec.Versions[index] + if !version.Served { + continue + } + if version.Schema == nil || version.Schema.OpenAPIV3Schema == nil { + return nil, fmt.Errorf("%s: served version %s has no OpenAPI schema", name, version.Name) + } + internal := &apiextensions.JSONSchemaProps{} + if err := apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(version.Schema.OpenAPIV3Schema, internal, nil); err != nil { + return nil, fmt.Errorf("%s: convert schema for %s: %w", name, version.Name, err) + } + structural, err := structuralschema.NewStructural(internal) + if err != nil { + return nil, fmt.Errorf("%s: schema for %s is not structural: %w", name, version.Name, err) + } + gvk := groupVersionKind{Group: crd.Spec.Group, Version: version.Name, Kind: crd.Spec.Names.Kind} + if _, duplicate := result[gvk]; duplicate { + return nil, fmt.Errorf("%s: duplicate proposed schema for %s/%s %s", name, gvk.Group, gvk.Version, gvk.Kind) + } + result[gvk] = &candidateSchema{crd: crd, version: version, internal: internal, structural: structural} + } + } + if len(result) == 0 { + return nil, fmt.Errorf("%s: no served CRD schemas found", directory) + } + return result, nil +} + +func loadCRDs(directory string) (map[string]*apiextensionsv1.CustomResourceDefinition, error) { + paths, err := yamlPaths(directory) + if err != nil { + return nil, err + } + result := make(map[string]*apiextensionsv1.CustomResourceDefinition, len(paths)) + for _, path := range paths { + crd, err := loadCRD(path) + if err != nil { + return nil, err + } + if crd.Name == "" { + return nil, fmt.Errorf("%s: CRD has no metadata.name", path) + } + if _, duplicate := result[crd.Name]; duplicate { + return nil, fmt.Errorf("%s: duplicate CRD %s", path, crd.Name) + } + result[crd.Name] = crd + } + return result, nil +} + +func loadCRD(path string) (*apiextensionsv1.CustomResourceDefinition, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var crd apiextensionsv1.CustomResourceDefinition + if err := yaml.Unmarshal(raw, &crd); err != nil { + return nil, fmt.Errorf("%s: decode CRD: %w", path, err) + } + return &crd, nil +} + +func decodeYAMLObject(path string) (map[string]interface{}, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + jsonRaw, err := yaml.YAMLToJSON(raw) + if err != nil { + return nil, fmt.Errorf("%s: decode YAML: %w", path, err) + } + var object map[string]interface{} + if err := json.Unmarshal(jsonRaw, &object); err != nil { + return nil, fmt.Errorf("%s: decode object: %w", path, err) + } + return object, nil +} + +func yamlPaths(directory string) ([]string, error) { + entries, err := os.ReadDir(directory) + if err != nil { + return nil, err + } + paths := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + extension := strings.ToLower(filepath.Ext(entry.Name())) + if extension == ".yaml" || extension == ".yml" { + paths = append(paths, filepath.Join(directory, entry.Name())) + } + } + return paths, nil +} diff --git a/packages/cluster-operator/cmd/crd-preflight/main_test.go b/packages/cluster-operator/cmd/crd-preflight/main_test.go new file mode 100644 index 00000000..a72ad90b --- /dev/null +++ b/packages/cluster-operator/cmd/crd-preflight/main_test.go @@ -0,0 +1,482 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +const candidateCRD = `apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: widgets.cluster.t4.dev +spec: + group: cluster.t4.dev + scope: Namespaced + names: + plural: widgets + singular: widget + kind: Widget + listKind: WidgetList + versions: + - name: v1alpha1 + served: true + storage: true + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + required: [spec] + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + type: object + required: [code] + x-kubernetes-validations: + - rule: self.code.startsWith('ok') + message: code must start with ok + properties: + code: + type: string + maxLength: 3 + status: + type: object + properties: + phase: + type: string + enum: [Ready] +` + +func TestValidateFixturesRejectsProposedSpecTighteningAndCEL(t *testing.T) { + for _, test := range []struct { + name string + code string + expectedError string + }{ + {name: "openapi maxLength", code: "okay", expectedError: "proposed OpenAPI validation failed"}, + {name: "CEL rule", code: "bad", expectedError: "proposed CEL create validation failed"}, + } { + t.Run(test.name, func(t *testing.T) { + crds, fixtures := writeCandidate(t, `apiVersion: cluster.t4.dev/v1alpha1 +kind: Widget +metadata: + name: legacy +spec: + code: `+test.code+"\n"+`status: + phase: Ready +`) + err := validateFixtures(crds, fixtures) + if err == nil { + t.Fatal("fixture incompatible with the proposed spec schema was accepted") + } + if !strings.Contains(err.Error(), test.expectedError) { + t.Fatalf("validation error %q does not identify %q", err, test.expectedError) + } + }) + } +} + +func TestValidateFixturesRejectsPersistedStatusAgainstProposedSchema(t *testing.T) { + crds, fixtures := writeCandidate(t, `apiVersion: cluster.t4.dev/v1alpha1 +kind: Widget +metadata: + name: legacy +spec: + code: ok +status: + phase: Legacy +`) + if err := validateFixtures(crds, fixtures); err == nil { + t.Fatal("persisted status incompatible with the proposed status schema was accepted") + } +} + +func TestValidateFixturesRejectsUnchangedLegacyValuesUnderTransitionCEL(t *testing.T) { + tests := []struct { + name string + fixture string + expectedPath string + candidate func(string) string + }{ + { + name: "spec transition rule", + expectedPath: "fixture.spec.code", + fixture: `apiVersion: cluster.t4.dev/v1alpha1 +kind: Widget +metadata: + name: legacy +spec: + code: bad +status: + phase: Ready +`, + candidate: func(crd string) string { + crd = strings.Replace(crd, "rule: self.code.startsWith('ok')", `rule: "true"`, 1) + return strings.Replace(crd, " maxLength: 3", " maxLength: 3\n x-kubernetes-validations:\n - rule: oldSelf.startsWith('ok')", 1) + }, + }, + { + name: "status transition rule", + expectedPath: "fixture.status.phase", + fixture: `apiVersion: cluster.t4.dev/v1alpha1 +kind: Widget +metadata: + name: legacy +spec: + code: ok +status: + phase: Pending +`, + candidate: func(crd string) string { + return strings.Replace(crd, " enum: [Ready]", " enum: [Ready, Pending]\n x-kubernetes-validations:\n - rule: oldSelf == 'Ready'", 1) + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + crds, fixtures := writeCandidate(t, test.fixture) + if err := os.WriteFile(filepath.Join(crds, "widget.yaml"), []byte(test.candidate(candidateCRD)), 0o644); err != nil { + t.Fatal(err) + } + err := validateFixtures(crds, fixtures) + if err == nil { + t.Fatal("unchanged persisted value blocked by transition CEL was accepted") + } + if !strings.Contains(err.Error(), "proposed CEL unchanged-update validation failed") { + t.Fatalf("validation error does not identify unchanged-update semantics: %v", err) + } + if !strings.Contains(err.Error(), test.expectedPath) { + t.Fatalf("validation error %q does not identify field %q", err, test.expectedPath) + } + }) + } +} + +func TestValidateObjectsRejectsLiveDataOmittedFromFixtures(t *testing.T) { + tests := []struct { + name string + candidate func(string) string + fixtureCode string + fixturePhase string + liveCode string + livePhase string + liveExtra string + expectedError string + }{ + { + name: "spec OpenAPI narrowing", + candidate: func(crd string) string { + return strings.Replace(crd, " maxLength: 3", " maxLength: 3\n enum: [ok1]", 1) + }, + fixtureCode: "ok1", fixturePhase: "Ready", liveCode: "ok2", livePhase: "Ready", + expectedError: "proposed OpenAPI validation failed", + }, + { + name: "status OpenAPI narrowing", + candidate: func(crd string) string { return crd }, + fixtureCode: "ok", fixturePhase: "Ready", liveCode: "ok", livePhase: "Legacy", + expectedError: "proposed OpenAPI validation failed", + }, + { + name: "spec CEL create semantics", + candidate: func(crd string) string { return crd }, + fixtureCode: "ok", fixturePhase: "Ready", liveCode: "bad", livePhase: "Ready", + expectedError: "proposed CEL create validation failed", + }, + { + name: "status CEL create semantics", + candidate: func(crd string) string { + return strings.Replace(crd, " phase:\n type: string\n enum: [Ready]", " phase:\n type: string\n enum: [Ready, Legacy]\n x-kubernetes-validations:\n - rule: self == 'Ready'", 1) + }, + fixtureCode: "ok", fixturePhase: "Ready", liveCode: "ok", livePhase: "Legacy", + expectedError: "proposed CEL create validation failed", + }, + { + name: "spec CEL unchanged-update semantics", + candidate: func(crd string) string { + crd = strings.Replace(crd, "rule: self.code.startsWith('ok')", `rule: "true"`, 1) + return strings.Replace(crd, " maxLength: 3", " maxLength: 3\n x-kubernetes-validations:\n - rule: oldSelf.startsWith('ok')", 1) + }, + fixtureCode: "ok", fixturePhase: "Ready", liveCode: "bad", livePhase: "Ready", + expectedError: "proposed CEL unchanged-update validation failed", + }, + { + name: "status CEL unchanged-update semantics", + candidate: func(crd string) string { + return strings.Replace(crd, " enum: [Ready]", " enum: [Ready, Legacy]\n x-kubernetes-validations:\n - rule: oldSelf == 'Ready'", 1) + }, + fixtureCode: "ok", fixturePhase: "Ready", liveCode: "ok", livePhase: "Legacy", + expectedError: "proposed CEL unchanged-update validation failed", + }, + { + name: "spec pruning", + candidate: func(crd string) string { return crd }, + fixtureCode: "ok", fixturePhase: "Ready", liveCode: "ok", livePhase: "Ready", liveExtra: `,"removedSpec":"legacy"`, + expectedError: "proposed structural schema would prune declared fields", + }, + { + name: "status pruning", + candidate: func(crd string) string { return crd }, + fixtureCode: "ok", fixturePhase: "Ready", liveCode: "ok", livePhase: "Ready", liveExtra: `,"removedStatus":"legacy"`, + expectedError: "proposed structural schema would prune declared fields", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := fmt.Sprintf("apiVersion: cluster.t4.dev/v1alpha1\nkind: Widget\nmetadata:\n name: curated\n namespace: tenant-a\nspec:\n code: %s\nstatus:\n phase: %s\n", test.fixtureCode, test.fixturePhase) + crds, fixtures := writeCandidate(t, fixture) + if err := os.WriteFile(filepath.Join(crds, "widget.yaml"), []byte(test.candidate(candidateCRD)), 0o644); err != nil { + t.Fatal(err) + } + if err := validateFixtures(crds, fixtures); err != nil { + t.Fatalf("curated fixture should not expose the live-only incompatibility: %v", err) + } + live := fmt.Sprintf(`{"apiVersion":"v1","kind":"WidgetList","items":[{"apiVersion":"cluster.t4.dev/v1alpha1","kind":"Widget","metadata":{"name":"live","namespace":"tenant-b"},"spec":{"code":%q%s},"status":{"phase":%q%s}}]}`, + test.liveCode, map[bool]string{true: test.liveExtra, false: ""}[test.name == "spec pruning"], test.livePhase, map[bool]string{true: test.liveExtra, false: ""}[test.name == "status pruning"]) + err := validateObjects(crds, strings.NewReader(live)) + if err == nil { + t.Fatal("live object incompatible with the proposed schema was accepted") + } + if !strings.Contains(err.Error(), "tenant-b/live") || !strings.Contains(err.Error(), test.expectedError) { + t.Fatalf("validation error %q does not identify live object and %q", err, test.expectedError) + } + }) + } +} + +func TestValidateObjectsAcceptsKubernetesJSONNumbers(t *testing.T) { + crds, _ := writeCandidate(t, `apiVersion: cluster.t4.dev/v1alpha1 +kind: Widget +metadata: + name: curated +spec: + code: ok +`) + candidate := strings.Replace(candidateCRD, " required: [code]", " required: [code, count]", 1) + candidate = strings.Replace(candidate, " maxLength: 3", " maxLength: 3\n count:\n type: integer\n x-kubernetes-validations:\n - rule: self == 9223372036854775807", 1) + if err := os.WriteFile(filepath.Join(crds, "widget.yaml"), []byte(candidate), 0o644); err != nil { + t.Fatal(err) + } + live := strings.NewReader(`{"apiVersion":"v1","kind":"WidgetList","items":[{"apiVersion":"cluster.t4.dev/v1alpha1","kind":"Widget","metadata":{"name":"live","namespace":"tenant-b"},"spec":{"code":"ok","count":9223372036854775807},"status":{"phase":"Ready"}}]}`) + if err := validateObjects(crds, live); err != nil { + t.Fatalf("ordinary Kubernetes JSON integer was rejected: %v", err) + } +} + +func TestValidateCompatibilityRejectsNonAdditiveSchemaChanges(t *testing.T) { + addOptionalPhase := func(crd string) string { + return strings.Replace(crd, " enum: [Ready]", " enum: [Ready, Pending]", 1) + } + addOptionalProperty := func(crd string) string { + return strings.Replace(crd, " code:\n type: string", " legacy:\n type: string\n code:\n type: string", 1) + } + removeRootCEL := func(crd string) string { + return strings.Replace(crd, " x-kubernetes-validations:\n - rule: self.code.startsWith('ok')\n message: code must start with ok\n", "", 1) + } + for _, test := range []struct { + name string + installed string + proposed string + }{ + {name: "removed property", installed: addOptionalProperty(candidateCRD), proposed: candidateCRD}, + {name: "new required property", installed: candidateCRD, proposed: strings.Replace(candidateCRD, "required: [spec]", "required: [spec, status]", 1)}, + {name: "tighter bound", installed: strings.Replace(candidateCRD, "maxLength: 3", "maxLength: 8", 1), proposed: candidateCRD}, + {name: "narrower enum", installed: addOptionalPhase(candidateCRD), proposed: candidateCRD}, + {name: "changed default", installed: strings.Replace(candidateCRD, "enum: [Ready]", "enum: [Ready, Pending]\n default: Ready", 1), proposed: strings.Replace(candidateCRD, "enum: [Ready]", "enum: [Ready, Pending]\n default: Pending", 1)}, + {name: "new CEL", installed: removeRootCEL(candidateCRD), proposed: candidateCRD}, + {name: "changed map topology", installed: strings.Replace(candidateCRD, " type: object\n required: [code]", " type: object\n x-kubernetes-map-type: granular\n required: [code]", 1), proposed: strings.Replace(candidateCRD, " type: object\n required: [code]", " type: object\n x-kubernetes-map-type: atomic\n required: [code]", 1)}, + } { + t.Run(test.name, func(t *testing.T) { + proposed, installed := writeCompatibilityCRDs(t, test.proposed, test.installed) + if err := validateCompatibility(proposed, installed); err == nil { + t.Fatal("non-additive schema change was accepted") + } + }) + } +} + +func TestValidateCompatibilityRejectsVersionAndConversionChanges(t *testing.T) { + addedVersion := strings.Replace(candidateCRD, " versions:\n", ` versions: + - name: v1beta1 + served: false + storage: false +`, 1) + changedConversion := strings.Replace(candidateCRD, " versions:\n", " conversion:\n strategy: Webhook\n webhook:\n conversionReviewVersions: [v1]\n clientConfig:\n url: https://conversion.example.test\n versions:\n", 1) + for _, test := range []struct { + name string + proposed string + expect string + }{ + {name: "additional version", proposed: addedVersion, expect: "changes its version, served, or storage contract"}, + {name: "conversion configuration", proposed: changedConversion, expect: "changes conversion configuration"}, + } { + t.Run(test.name, func(t *testing.T) { + proposed, installed := writeCompatibilityCRDs(t, test.proposed, candidateCRD) + err := validateCompatibility(proposed, installed) + if err == nil || !strings.Contains(err.Error(), test.expect) { + t.Fatalf("compatibility error %q does not contain %q", err, test.expect) + } + }) + } +} + +func TestValidateCompatibilityTreatsImplicitAndExplicitNoneConversionAsEqual(t *testing.T) { + installedCRD := strings.Replace(candidateCRD, " versions:\n", " conversion:\n strategy: None\n versions:\n", 1) + proposed, installed := writeCompatibilityCRDs(t, candidateCRD, installedCRD) + if err := validateCompatibility(proposed, installed); err != nil { + t.Fatalf("API-server defaulted None conversion was rejected: %v", err) + } +} + +func TestWriteMergePatchGuardsTheValidatedInstalledCRD(t *testing.T) { + root := t.TempDir() + candidatePath := filepath.Join(root, "candidate.yaml") + installedPath := filepath.Join(root, "installed.yaml") + installed := strings.Replace(candidateCRD, " name: widgets.cluster.t4.dev\n", " name: widgets.cluster.t4.dev\n uid: 11111111-2222-3333-4444-555555555555\n resourceVersion: \"42\"\n", 1) + if err := os.WriteFile(candidatePath, []byte(candidateCRD), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(installedPath, []byte(installed), 0o644); err != nil { + t.Fatal(err) + } + var output strings.Builder + if err := writeMergePatch(candidatePath, installedPath, &output); err != nil { + t.Fatal(err) + } + var patch map[string]interface{} + if err := json.Unmarshal([]byte(output.String()), &patch); err != nil { + t.Fatal(err) + } + metadata, ok := patch["metadata"].(map[string]interface{}) + if !ok || metadata["resourceVersion"] != "42" || metadata["uid"] != "11111111-2222-3333-4444-555555555555" { + t.Fatalf("merge patch lacks the installed-object preconditions: %#v", patch["metadata"]) + } + if _, ok := patch["spec"].(map[string]interface{}); !ok { + t.Fatalf("merge patch lacks the candidate spec: %#v", patch) + } +} + +func TestValidateCompatibilityAcceptsNewOptionalProperty(t *testing.T) { + proposedCRD := strings.Replace(candidateCRD, " code:\n type: string", " note:\n type: string\n default: safe\n code:\n type: string", 1) + proposed, installed := writeCompatibilityCRDs(t, proposedCRD, candidateCRD) + if err := validateCompatibility(proposed, installed); err != nil { + t.Fatalf("optional additive field was rejected: %v", err) + } +} + +func TestVerifyServedSchemasRejectsRetainedEstablishedWithStaleSchema(t *testing.T) { + crds, _ := writeCandidate(t, `apiVersion: cluster.t4.dev/v1alpha1 +kind: Widget +metadata: + name: legacy +spec: + code: ok +`) + staleDiscovery := strings.NewReader(`{ + "openapi": "3.0.0", + "components": {"schemas": { + "cluster.t4.dev.v1alpha1.Widget": { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": {"type": "string"}, + "kind": {"type": "string"}, + "metadata": {"type": "object"}, + "spec": { + "type": "object", + "required": ["code"], + "x-kubernetes-validations": [{"rule": "self.code.startsWith('ok')", "message": "code must start with ok"}], + "properties": {"code": {"type": "string", "maxLength": 8}} + }, + "status": {"type": "object", "properties": {"phase": {"type": "string", "enum": ["Ready"]}}} + }, + "x-kubernetes-group-version-kind": [{"group":"cluster.t4.dev","version":"v1alpha1","kind":"Widget"}] + } + }} +}`) + if err := verifyServedSchemas(crds, staleDiscovery); err == nil { + t.Fatal("stale served OpenAPI schema was accepted after Established") + } +} + +func TestVerifyServedSchemasAcceptsExactProposedSemantics(t *testing.T) { + crds, _ := writeCandidate(t, `apiVersion: cluster.t4.dev/v1alpha1 +kind: Widget +metadata: + name: legacy +spec: + code: ok +`) + discovery := strings.NewReader(`{ + "openapi": "3.0.0", + "components": {"schemas": { + "cluster.t4.dev.v1alpha1.Widget": { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": {"type": "string"}, + "kind": {"type": "string"}, + "metadata": {"type": "object"}, + "spec": { + "type": "object", + "required": ["code"], + "x-kubernetes-validations": [{"rule": "self.code.startsWith('ok')", "message": "code must start with ok"}], + "properties": {"code": {"type": "string", "maxLength": 3}} + }, + "status": {"type": "object", "properties": {"phase": {"type": "string", "enum": ["Ready"]}}} + }, + "x-kubernetes-group-version-kind": [{"group":"cluster.t4.dev","version":"v1alpha1","kind":"Widget"}] + } + }} +}`) + if err := verifyServedSchemas(crds, discovery); err != nil { + t.Fatalf("exact proposed schema rejected: %v", err) + } +} + +func writeCandidate(t *testing.T, fixture string) (string, string) { + t.Helper() + root := t.TempDir() + crds := filepath.Join(root, "crds") + fixtures := filepath.Join(root, "fixtures") + for _, directory := range []string{crds, fixtures} { + if err := os.Mkdir(directory, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(crds, "widget.yaml"), []byte(candidateCRD), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fixtures, "widget.yaml"), []byte(fixture), 0o644); err != nil { + t.Fatal(err) + } + return crds, fixtures +} + +func writeCompatibilityCRDs(t *testing.T, proposed, installed string) (string, string) { + t.Helper() + root := t.TempDir() + proposedDirectory := filepath.Join(root, "proposed") + installedDirectory := filepath.Join(root, "installed") + for _, directory := range []string{proposedDirectory, installedDirectory} { + if err := os.Mkdir(directory, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(proposedDirectory, "widget.yaml"), []byte(proposed), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(installedDirectory, "widget.yaml"), []byte(installed), 0o644); err != nil { + t.Fatal(err) + } + return proposedDirectory, installedDirectory +} diff --git a/packages/cluster-operator/cmd/manager/main.go b/packages/cluster-operator/cmd/manager/main.go index 0484a42a..f858b769 100644 --- a/packages/cluster-operator/cmd/manager/main.go +++ b/packages/cluster-operator/cmd/manager/main.go @@ -70,7 +70,7 @@ func main() { ctrl.Log.Error(err, "unable to register T4ClusterHost controller") os.Exit(1) } - if err := (&controllers.WorkspaceReconciler{Client: manager.GetClient(), Scheme: manager.GetScheme()}).SetupWithManager(manager); err != nil { + if err := (&controllers.WorkspaceReconciler{Client: manager.GetClient(), APIReader: manager.GetAPIReader(), Scheme: manager.GetScheme()}).SetupWithManager(manager); err != nil { ctrl.Log.Error(err, "unable to register T4Workspace controller") os.Exit(1) } diff --git a/packages/cluster-operator/controllers/helpers.go b/packages/cluster-operator/controllers/helpers.go index ec5681af..8fef978c 100644 --- a/packages/cluster-operator/controllers/helpers.go +++ b/packages/cluster-operator/controllers/helpers.go @@ -18,6 +18,7 @@ import ( const ( ReasonStorageClassNotFound = "StorageClassNotFound" ReasonStorageClassNotRWX = "StorageClassNotRWX" + ReasonStorageClassMismatch = "StorageClassMismatch" ReasonStorageReady = "StorageClassSupportsRWX" ) @@ -82,6 +83,13 @@ func pvcHasRWX(pvc *corev1.PersistentVolumeClaim) bool { return false } +func pvcStorageClassName(pvc *corev1.PersistentVolumeClaim) string { + if pvc.Spec.StorageClassName == nil { + return "" + } + return *pvc.Spec.StorageClassName +} + func hasString(values []string, wanted string) bool { for _, value := range values { if value == wanted { diff --git a/packages/cluster-operator/controllers/reconciler_test.go b/packages/cluster-operator/controllers/reconciler_test.go index 90ed6600..eccef0a5 100644 --- a/packages/cluster-operator/controllers/reconciler_test.go +++ b/packages/cluster-operator/controllers/reconciler_test.go @@ -2,8 +2,11 @@ package controllers_test import ( "context" + "errors" + "reflect" "strings" "testing" + "time" corev1 "k8s.io/api/core/v1" storagev1 "k8s.io/api/storage/v1" @@ -11,6 +14,7 @@ import ( apiresource "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" utilvalidation "k8s.io/apimachinery/pkg/util/validation" ctrl "sigs.k8s.io/controller-runtime" @@ -69,6 +73,172 @@ func TestWorkspaceReconcileIsIdempotentAcrossDuplicateEvents(t *testing.T) { } } +func TestWorkspaceCreateAlreadyExistsRefetchesForeignPVC(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.UID = "workspace-uid" + base := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&clusterv1alpha1.T4Workspace{}, &corev1.PersistentVolumeClaim{}). + WithObjects(testHost(), rwxStorageClass(), workspace).Build() + c := &createAlreadyExistsClient{Client: base, raceKind: "PVC", hideWinnerFromCache: true} + r := &controllers.WorkspaceReconciler{Client: c, APIReader: base, Scheme: scheme} + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(workspace)}); err != nil { + t.Fatal(err) + } + if c.winner == nil { + t.Fatal("PVC create race was not exercised") + } + var pvc corev1.PersistentVolumeClaim + if err := base.Get(ctx, client.ObjectKeyFromObject(c.winner), &pvc); err != nil { + t.Fatalf("foreign PVC winner was deleted: %v", err) + } + want := c.winner.(*corev1.PersistentVolumeClaim) + if !reflect.DeepEqual(pvc.ObjectMeta, want.ObjectMeta) || !reflect.DeepEqual(pvc.Spec, want.Spec) { + t.Fatalf("foreign PVC winner was mutated: %#v", pvc) + } + var failed clusterv1alpha1.T4Workspace + if err := base.Get(ctx, client.ObjectKeyFromObject(workspace), &failed); err != nil { + t.Fatal(err) + } + storageReady := findCondition(failed.Status.Conditions, "StorageReady") + if failed.Status.PVCName != "" || storageReady == nil || storageReady.Status != metav1.ConditionFalse || storageReady.Reason != "PVCOwnershipConflict" || storageReady.ObservedGeneration != failed.Generation { + t.Fatalf("foreign PVC winner was published as authoritative: status=%#v StorageReady=%#v", failed.Status, storageReady) + } +} + +func TestWorkspaceReadinessRejectsAuthoritativeForeignPVCReplacement(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.UID = "workspace-uid" + cachedPVC := ownedWorkspacePVC(workspace) + cachedPVC.UID = "cached-pvc-uid" + authoritativePVC := cachedPVC.DeepCopy() + authoritativePVC.UID = "replacement-pvc-uid" + authoritativePVC.Annotations = nil + authoritativePVC.OwnerReferences = nil + cacheClient := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&clusterv1alpha1.T4Workspace{}, &corev1.PersistentVolumeClaim{}). + WithObjects(testHost(), rwxStorageClass(), workspace, cachedPVC).Build() + r := &controllers.WorkspaceReconciler{ + Client: cacheClient, + APIReader: &pvcOverrideReader{Reader: cacheClient, pvc: authoritativePVC}, + Scheme: scheme, + } + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(workspace)}); err != nil { + t.Fatal(err) + } + var got clusterv1alpha1.T4Workspace + if err := cacheClient.Get(ctx, client.ObjectKeyFromObject(workspace), &got); err != nil { + t.Fatal(err) + } + storageReady := findCondition(got.Status.Conditions, "StorageReady") + ready := findCondition(got.Status.Conditions, "Ready") + if got.Status.PVCName != "" || got.Status.PVCPhase != "" || !got.Status.Capacity.IsZero() || + storageReady == nil || storageReady.Status != metav1.ConditionFalse || storageReady.Reason != "PVCOwnershipConflict" || storageReady.ObservedGeneration != got.Generation || + ready == nil || ready.Status != metav1.ConditionFalse || ready.ObservedGeneration != got.Generation { + t.Fatalf("stale cached PVC published workspace authority: status=%#v StorageReady=%#v Ready=%#v", got.Status, storageReady, ready) + } +} + + +func TestWorkspacePendingPVCPolicyFailsBeforeAuthority(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*corev1.PersistentVolumeClaim) + reason string + }{ + {name: "wrong class", reason: controllers.ReasonStorageClassMismatch, mutate: func(pvc *corev1.PersistentVolumeClaim) { pvc.Spec.StorageClassName = ptr("other-rwx") }}, + {name: "wrong access", reason: "PVCNotRWX", mutate: func(pvc *corev1.PersistentVolumeClaim) { pvc.Spec.AccessModes = []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce} }}, + } { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.UID = "workspace-uid" + pvc := ownedWorkspacePVC(workspace) + pvc.Status.Phase = corev1.ClaimPending + test.mutate(pvc) + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&clusterv1alpha1.T4Workspace{}, &corev1.PersistentVolumeClaim{}).WithObjects(testHost(), rwxStorageClass(), workspace, pvc).Build() + r := &controllers.WorkspaceReconciler{Client: c, APIReader: c, Scheme: scheme} + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(workspace)}); err != nil { + t.Fatal(err) + } + var failed clusterv1alpha1.T4Workspace + if err := c.Get(ctx, client.ObjectKeyFromObject(workspace), &failed); err != nil { + t.Fatal(err) + } + condition := findCondition(failed.Status.Conditions, "StorageReady") + if failed.Status.PVCName != "" || condition == nil || condition.Status != metav1.ConditionFalse || condition.Reason != test.reason { + t.Fatalf("incompatible Pending PVC published authority: status=%#v condition=%#v", failed.Status, condition) + } + }) + } +} + +func TestWorkspaceDeletionUsesAuthoritativePVCReader(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.UID = "workspace-uid" + workspace.Finalizers = []string{clusterv1alpha1.WorkspaceFinalizer} + pvc := ownedWorkspacePVC(workspace) + pvc.OwnerReferences = nil + base := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&clusterv1alpha1.T4Workspace{}).WithObjects(workspace, pvc).Build() + if err := base.Delete(ctx, workspace); err != nil { + t.Fatal(err) + } + c := &createAlreadyExistsClient{Client: base, raceKind: "PVC", winner: pvc, hideWinnerFromCache: true} + r := &controllers.WorkspaceReconciler{Client: c, APIReader: base, Scheme: scheme} + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(workspace)}); err != nil { + t.Fatal(err) + } + var waiting clusterv1alpha1.T4Workspace + if err := base.Get(ctx, client.ObjectKeyFromObject(workspace), &waiting); err != nil { + t.Fatalf("workspace finalizer ignored authoritative PVC conflict: %v", err) + } + condition := findCondition(waiting.Status.Conditions, "Ready") + if condition == nil || condition.Reason != "CleanupOwnershipConflict" || !contains(waiting.Finalizers, clusterv1alpha1.WorkspaceFinalizer) { + t.Fatalf("authoritative PVC conflict not retained: %#v", waiting) + } +} + +func TestWorkspaceDeletionUsesAuthoritativeSessionReader(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.UID = "workspace-uid" + workspace.Finalizers = []string{clusterv1alpha1.WorkspaceFinalizer} + pvc := ownedWorkspacePVC(workspace) + session := testSession() + session.Spec.WorkspaceRef = workspace.Name + cacheClient := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&clusterv1alpha1.T4Workspace{}).WithObjects(workspace, pvc).Build() + apiReader := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, session).Build() + if err := cacheClient.Delete(ctx, workspace); err != nil { + t.Fatal(err) + } + r := &controllers.WorkspaceReconciler{Client: cacheClient, APIReader: apiReader, Scheme: scheme} + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(workspace)}); err != nil { + t.Fatal(err) + } + var waiting clusterv1alpha1.T4Workspace + if err := cacheClient.Get(ctx, client.ObjectKeyFromObject(workspace), &waiting); err != nil { + t.Fatalf("workspace finalizer ignored authoritative session: %v", err) + } + ready := findCondition(waiting.Status.Conditions, "Ready") + if ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != "SessionsRemain" || ready.ObservedGeneration != waiting.Generation { + t.Fatalf("Ready = %#v, want current-generation False/SessionsRemain", ready) + } + if !contains(waiting.Finalizers, clusterv1alpha1.WorkspaceFinalizer) { + t.Fatal("workspace finalizer was removed while authoritative session remains") + } + var remainingPVC corev1.PersistentVolumeClaim + if err := cacheClient.Get(ctx, client.ObjectKeyFromObject(pvc), &remainingPVC); err != nil { + t.Fatalf("workspace PVC was deleted while authoritative session remains: %v", err) + } +} + func TestRetainWorkspaceCreatesPVCWithoutGarbageCollectableOwner(t *testing.T) { scheme := testScheme(t) workspace := testWorkspace(clusterv1alpha1.RetentionPolicyRetain) @@ -151,7 +321,7 @@ func TestRetainDeletionOrphansPVCBeforeRemovingFinalizer(t *testing.T) { APIVersion: clusterv1alpha1.GroupVersion.String(), Kind: "T4Workspace", Name: workspace.Name, UID: workspace.UID, Controller: ptr(true), }}, }, - Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, } c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&clusterv1alpha1.T4Workspace{}).WithObjects(testHost(), rwxStorageClass(), workspace, pvc).Build() if err := c.Delete(context.Background(), workspace); err != nil { @@ -185,7 +355,7 @@ func TestWorkspaceDeletionWaitsForSessionResources(t *testing.T) { Name: controllers.WorkspacePVCName(workspace), Namespace: workspace.Namespace, OwnerReferences: []metav1.OwnerReference{{APIVersion: clusterv1alpha1.GroupVersion.String(), Kind: "T4Workspace", Name: workspace.Name, UID: workspace.UID, Controller: ptr(true)}}, }, - Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, } session := testSession() session.Spec.WorkspaceRef = workspace.Name @@ -214,6 +384,107 @@ func TestWorkspaceDeletionWaitsForSessionResources(t *testing.T) { } } +func TestSessionPodCreateRevalidatesAuthoritativePVC(t *testing.T) { + tests := []struct { + name string + mutate func(*corev1.PersistentVolumeClaim) + }{ + {name: "replacement UID", mutate: func(pvc *corev1.PersistentVolumeClaim) { pvc.UID = "replacement-pvc-uid" }}, + {name: "foreign owner", mutate: func(pvc *corev1.PersistentVolumeClaim) { + pvc.OwnerReferences = []metav1.OwnerReference{{APIVersion: "v1", Kind: "Secret", Name: "foreign", UID: "foreign-uid", Controller: ptr(true)}} + }}, + {name: "storage class drift", mutate: func(pvc *corev1.PersistentVolumeClaim) { pvc.Spec.StorageClassName = ptr("other-rwx") }}, + {name: "access mode drift", mutate: func(pvc *corev1.PersistentVolumeClaim) { pvc.Spec.AccessModes = []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce} }}, + {name: "unbound replacement", mutate: func(pvc *corev1.PersistentVolumeClaim) { pvc.Status.Phase = corev1.ClaimPending }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.UID = "workspace-uid" + workspace.Status.PVCName = controllers.WorkspacePVCName(workspace) + cachedPVC := ownedWorkspacePVC(workspace) + cachedPVC.UID = "cached-pvc-uid" + authoritativePVC := cachedPVC.DeepCopy() + test.mutate(authoritativePVC) + session := testSession() + session.UID = "session-uid" + cacheClient := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&clusterv1alpha1.T4Session{}, &corev1.PersistentVolumeClaim{}, &corev1.Pod{}). + WithObjects(testHost(), workspace, cachedPVC, session).Build() + r := configuredSessionReconciler(cacheClient, scheme) + r.APIReader = &pvcOverrideReader{Reader: cacheClient, pvc: authoritativePVC} + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}); err != nil { + t.Fatal(err) + } + var pods corev1.PodList + if err := cacheClient.List(ctx, &pods, client.InNamespace(session.Namespace)); err != nil { + t.Fatal(err) + } + if len(pods.Items) != 0 { + t.Fatalf("created %d Pods after authoritative PVC %s", len(pods.Items), test.name) + } + var got clusterv1alpha1.T4Session + if err := cacheClient.Get(ctx, client.ObjectKeyFromObject(session), &got); err != nil { + t.Fatal(err) + } + workspaceReady := findCondition(got.Status.Conditions, "WorkspaceReady") + if got.Status.PodName != "" || workspaceReady == nil || workspaceReady.Status != metav1.ConditionFalse || workspaceReady.Reason != "PVCAuthorityChanged" || workspaceReady.ObservedGeneration != got.Generation { + t.Fatalf("authoritative PVC %s published session authority: status=%#v WorkspaceReady=%#v", test.name, got.Status, workspaceReady) + } + }) + } +} + +func TestSessionExistingPodRepairRejectsAuthoritativeForeignPVCReplacement(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.UID = "workspace-uid" + workspace.Status.PVCName = controllers.WorkspacePVCName(workspace) + cachedPVC := ownedWorkspacePVC(workspace) + cachedPVC.UID = "cached-pvc-uid" + session := testSession() + session.UID = "session-uid" + cacheClient := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&clusterv1alpha1.T4Session{}, &corev1.PersistentVolumeClaim{}, &corev1.Pod{}). + WithObjects(testHost(), workspace, cachedPVC, session).Build() + r := configuredSessionReconciler(cacheClient, scheme) + reconcileMany(t, 2, func() error { + _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}) + return err + }) + var pod corev1.Pod + if err := cacheClient.Get(ctx, types.NamespacedName{Namespace: session.Namespace, Name: controllers.SessionPodName(session)}, &pod); err != nil { + t.Fatal(err) + } + pod.Labels = nil + if err := cacheClient.Update(ctx, &pod); err != nil { + t.Fatal(err) + } + authoritativePVC := cachedPVC.DeepCopy() + authoritativePVC.UID = "replacement-pvc-uid" + authoritativePVC.Annotations = nil + authoritativePVC.OwnerReferences = nil + r.APIReader = &pvcOverrideReader{Reader: cacheClient, pvc: authoritativePVC} + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}); err != nil { + t.Fatal(err) + } + if err := cacheClient.Get(ctx, client.ObjectKeyFromObject(&pod), &pod); !apierrors.IsNotFound(err) { + t.Fatalf("existing Pod survived authoritative PVC replacement: %v", err) + } + var got clusterv1alpha1.T4Session + if err := cacheClient.Get(ctx, client.ObjectKeyFromObject(session), &got); err != nil { + t.Fatal(err) + } + workspaceReady := findCondition(got.Status.Conditions, "WorkspaceReady") + if got.Status.PodName != "" || workspaceReady == nil || workspaceReady.Status != metav1.ConditionFalse || workspaceReady.Reason != "PVCAuthorityChanged" || workspaceReady.ObservedGeneration != got.Generation { + t.Fatalf("existing Pod repair retained stale PVC authority: status=%#v WorkspaceReady=%#v", got.Status, workspaceReady) + } +} + + func TestSessionFailsClosedWhenAnyOMPReferenceIsMissing(t *testing.T) { for _, test := range []struct { name string @@ -230,7 +501,7 @@ func TestSessionFailsClosedWhenAnyOMPReferenceIsMissing(t *testing.T) { workspace.Status.PVCName = "workspace-a-data" pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, - Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, } session := testSession() @@ -307,7 +578,7 @@ func TestSessionRejectsCredentialBearingModelsConfiguration(t *testing.T) { workspace.Status.PVCName = "workspace-a-data" pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, - Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, } session := testSession() @@ -358,7 +629,7 @@ func TestSessionRejectsCredentialBearingSettingsConfiguration(t *testing.T) { workspace.Status.PVCName = "workspace-a-data" pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, - Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, } session := testSession() @@ -407,7 +678,7 @@ func TestSessionRuntimeImageMustBeImmutableDigest(t *testing.T) { workspace.Status.PVCName = "workspace-a-data" pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, - Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, } session := testSession() @@ -478,7 +749,7 @@ func TestSessionAuthorityRevocationDeletesOwnedPodAndService(t *testing.T) { workspace.Status.PVCName = "workspace-a-data" pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, - Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, } session := testSession() @@ -525,7 +796,7 @@ func TestSessionNamesProduceSafeRuntimeIdentities(t *testing.T) { workspace.Status.PVCName = "workspace-a-data" pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, - Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, } session := testSession() @@ -570,7 +841,7 @@ func TestSessionWaitsForBoundRWXThenCreatesExactlyOnePodAndService(t *testing.T) workspace.Status.Phase = clusterv1alpha1.InfrastructurePending pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, - Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimPending}, } session := testSession() @@ -701,7 +972,7 @@ func TestSessionOMPModeOmitsCredentialReferences(t *testing.T) { workspace.Status.PVCName = "workspace-a-data" pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, - Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, } session := testSession() @@ -733,7 +1004,7 @@ func TestSessionRejectsUnownedDeterministicResources(t *testing.T) { workspace.Status.PVCName = "workspace-a-data" pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, - Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, } session := testSession() @@ -768,7 +1039,7 @@ func TestSessionRecreatesPodWhenImmutableDesiredStateChanges(t *testing.T) { workspace.Status.PVCName = "workspace-a-data" pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, - Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, } session := testSession() @@ -823,7 +1094,7 @@ func TestSessionPodHashIncludesEveryOMPReference(t *testing.T) { workspace.Status.PVCName = "workspace-a-data" pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, - Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, } session := testSession() @@ -869,7 +1140,7 @@ func TestSessionRecreatesPodWhenOMPResourceVersionChanges(t *testing.T) { workspace.Status.PVCName = "workspace-a-data" pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, - Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, } session := testSession() @@ -912,7 +1183,7 @@ func TestSessionRuntimeReferenceRevocationStopsAuthority(t *testing.T) { workspace.Status.PVCName = "workspace-a-data" pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, - Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, } session := testSession() @@ -966,7 +1237,7 @@ func TestSessionFailsClosedWhenOMPConfigMapIsMissing(t *testing.T) { scheme := testScheme(t) workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) workspace.Status.PVCName = "workspace-a-data" - pvc := &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}} + pvc := &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}} session := testSession() objects := []client.Object{testHost(), rwxStorageClass(), workspace, pvc, session} if test.configMap != nil { @@ -996,7 +1267,7 @@ func TestSessionRecreatesExternallyExposedOwnedService(t *testing.T) { workspace.Status.PVCName = "workspace-a-data" pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, - Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, } session := testSession() @@ -1044,7 +1315,7 @@ func TestSessionRestoresRequiredPodSelectorLabelsBeforeAvailability(t *testing.T workspace.Status.PVCName = "workspace-a-data" pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, - Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, } session := testSession() @@ -1086,7 +1357,7 @@ func TestSessionRestoresRequiredPodSelectorLabelsBeforeAvailability(t *testing.T func TestWorkspaceDeletionRefusesForeignDeterministicPVC(t *testing.T) { for _, policy := range []clusterv1alpha1.RetentionPolicy{clusterv1alpha1.RetentionPolicyRetain, clusterv1alpha1.RetentionPolicyDelete} { - for _, mismatch := range []string{"uid-annotation", "controller-owner"} { + for _, mismatch := range []string{"uid-annotation", "controller-owner", "foreign-non-controller-owner"} { t.Run(string(policy)+"/"+mismatch, func(t *testing.T) { scheme := testScheme(t) workspace := testWorkspace(policy) @@ -1098,10 +1369,14 @@ func TestWorkspaceDeletionRefusesForeignDeterministicPVC(t *testing.T) { }} if mismatch == "uid-annotation" { pvc.Annotations[clusterv1alpha1.WorkspaceUIDAnnotation] = "foreign-workspace-uid" - } else { + } else if mismatch == "controller-owner" { pvc.OwnerReferences = []metav1.OwnerReference{{ APIVersion: clusterv1alpha1.GroupVersion.String(), Kind: "T4Workspace", Name: "foreign", UID: "foreign-workspace-uid", Controller: ptr(true), }} + } else { + pvc.OwnerReferences = []metav1.OwnerReference{{ + APIVersion: "example.test/v1", Kind: "Foreign", Name: "foreign", UID: "foreign-uid", + }} } expectedOwnerCount := len(pvc.OwnerReferences) c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&clusterv1alpha1.T4Workspace{}).WithObjects(workspace, pvc).Build() @@ -1151,6 +1426,14 @@ func TestSessionDeletionRefusesForeignDeterministicResources(t *testing.T) { service.OwnerReferences = nil } c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&clusterv1alpha1.T4Session{}).WithObjects(session, pod, service).Build() + beforePod := &corev1.Pod{} + if err := c.Get(context.Background(), client.ObjectKeyFromObject(pod), beforePod); err != nil { + t.Fatal(err) + } + beforeService := &corev1.Service{} + if err := c.Get(context.Background(), client.ObjectKeyFromObject(service), beforeService); err != nil { + t.Fatal(err) + } if err := c.Delete(context.Background(), session); err != nil { t.Fatal(err) } @@ -1158,7 +1441,28 @@ func TestSessionDeletionRefusesForeignDeterministicResources(t *testing.T) { if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}); err != nil { t.Fatal(err) } - assertObjectCounts(t, c, 1, 1) + if foreignKind == "Pod" { + assertObjectCounts(t, c, 1, 0) + } else { + assertObjectCounts(t, c, 0, 1) + } + if foreignKind == "Pod" { + var got corev1.Pod + if err := c.Get(context.Background(), client.ObjectKeyFromObject(pod), &got); err != nil { + t.Fatalf("foreign Pod was deleted: %v", err) + } + if !reflect.DeepEqual(got.ObjectMeta, beforePod.ObjectMeta) || !reflect.DeepEqual(got.Spec, beforePod.Spec) { + t.Fatalf("foreign Pod was mutated during finalizer cleanup: %#v", got) + } + } else { + var got corev1.Service + if err := c.Get(context.Background(), client.ObjectKeyFromObject(service), &got); err != nil { + t.Fatalf("foreign Service was deleted: %v", err) + } + if !reflect.DeepEqual(got.ObjectMeta, beforeService.ObjectMeta) || !reflect.DeepEqual(got.Spec, beforeService.Spec) { + t.Fatalf("foreign Service was mutated during finalizer cleanup: %#v", got) + } + } var waiting clusterv1alpha1.T4Session if err := c.Get(context.Background(), client.ObjectKeyFromObject(session), &waiting); err != nil { t.Fatalf("session finalizer was removed on cleanup conflict: %v", err) @@ -1171,6 +1475,100 @@ func TestSessionDeletionRefusesForeignDeterministicResources(t *testing.T) { } } +func TestSessionDeletionUsesAuthoritativeChildReader(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + session := testSession() + session.UID = "session-uid" + session.Finalizers = []string{clusterv1alpha1.SessionFinalizer} + pod, service := ownedSessionResources(session) + pod.OwnerReferences = nil + base := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&clusterv1alpha1.T4Session{}).WithObjects(session, pod, service).Build() + if err := base.Delete(ctx, session); err != nil { + t.Fatal(err) + } + c := &createAlreadyExistsClient{Client: base, raceKind: "Pod", winner: pod, hideWinnerFromCache: true} + r := configuredSessionReconciler(c, scheme) + r.APIReader = base + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}); err != nil { + t.Fatal(err) + } + assertObjectCounts(t, base, 1, 0) + var waiting clusterv1alpha1.T4Session + if err := base.Get(ctx, client.ObjectKeyFromObject(session), &waiting); err != nil { + t.Fatalf("session finalizer ignored authoritative Pod conflict: %v", err) + } + condition := findCondition(waiting.Status.Conditions, "Available") + if condition == nil || condition.Reason != "CleanupOwnershipConflict" || !contains(waiting.Finalizers, clusterv1alpha1.SessionFinalizer) { + t.Fatalf("authoritative child conflict not retained: %#v", waiting) + } +} + +func TestSessionDeletionPreconditionsProtectSameNameReplacement(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + session := testSession() + session.UID = "session-uid" + session.Finalizers = []string{clusterv1alpha1.SessionFinalizer} + pod, service := ownedSessionResources(session) + pod.UID = "pod-uid-a" + pod.ResourceVersion = "7" + service.UID = "service-uid-a" + service.ResourceVersion = "8" + base := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&clusterv1alpha1.T4Session{}). + WithObjects(session, pod, service).Build() + var observedPod corev1.Pod + if err := base.Get(ctx, client.ObjectKeyFromObject(pod), &observedPod); err != nil { + t.Fatal(err) + } + if observedPod.UID == "" || observedPod.ResourceVersion == "" { + t.Fatalf("fake client discarded delete precondition identity: %#v", observedPod.ObjectMeta) + } + if err := base.Delete(ctx, session); err != nil { + t.Fatal(err) + } + racingClient := &replaceBeforeDeleteClient{Client: base, raceKind: "Pod", replacementUID: "pod-uid-b"} + r := configuredSessionReconciler(racingClient, scheme) + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}); !apierrors.IsConflict(err) { + t.Fatalf("same-name replacement did not conflict with the stale delete: %v", err) + } + if !racingClient.raced || racingClient.observedUID != observedPod.UID || racingClient.observedResourceVersion != observedPod.ResourceVersion { + t.Fatalf("delete did not carry both observed preconditions: raced=%t uid=%q resourceVersion=%q", racingClient.raced, racingClient.observedUID, racingClient.observedResourceVersion) + } + var replacement corev1.Pod + if err := base.Get(ctx, client.ObjectKeyFromObject(pod), &replacement); err != nil { + t.Fatalf("same-name replacement Pod did not survive stale delete: %v", err) + } + if replacement.UID != racingClient.replacementUID { + t.Fatalf("surviving Pod UID = %q, want replacement %q", replacement.UID, racingClient.replacementUID) + } + var waiting clusterv1alpha1.T4Session + if err := base.Get(ctx, client.ObjectKeyFromObject(session), &waiting); err != nil { + t.Fatalf("session finalizer advanced after stale child delete: %v", err) + } + available := findCondition(waiting.Status.Conditions, "Available") + if !contains(waiting.Finalizers, clusterv1alpha1.SessionFinalizer) || waiting.Status.Phase != clusterv1alpha1.InfrastructureTerminating || available == nil || available.Reason != "Terminating" { + t.Fatalf("session advanced after stale child delete: %#v", waiting) + } +} + +func TestSessionDependencyCleanupUsesAuthoritativeChildReader(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + session := testSession() + session.UID = "session-uid" + pod, service := ownedSessionResources(session) + base := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&clusterv1alpha1.T4Session{}, &corev1.Pod{}).WithObjects(session, pod, service).Build() + c := &createAlreadyExistsClient{Client: base, raceKind: "Pod", winner: pod, hideWinnerFromCache: true} + r := configuredSessionReconciler(c, scheme) + r.APIReader = base + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}); err != nil { + t.Fatal(err) + } + assertObjectCounts(t, base, 0, 0) +} + func TestSessionDeletionCleansResourcesBeforeFinalizer(t *testing.T) { scheme := testScheme(t) session := testSession() @@ -1198,51 +1596,753 @@ func TestSessionDeletionCleansResourcesBeforeFinalizer(t *testing.T) { } } -func configuredSessionReconciler(c client.Client, scheme *runtime.Scheme) *controllers.SessionReconciler { - for _, object := range []client.Object{ - &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "omp-runtime-config", Namespace: "team"}, Data: map[string]string{ - "provider-models": testOMPModels, "agent-settings": testOMPSettings, "other-models": otherTestOMPModels, "other-settings": otherTestOMPSettings, +func TestSessionDependencyRevocationCleansOwnedResourcesAndConvergesAfterRestart(t *testing.T) { + for _, test := range []struct { + name string + conditionType string + wantReason string + revoke func(context.Context, client.Client) error + }{ + {name: "missing Host", conditionType: "HostReady", wantReason: "HostNotFound", revoke: func(ctx context.Context, c client.Client) error { + return c.Delete(ctx, &clusterv1alpha1.T4ClusterHost{ObjectMeta: metav1.ObjectMeta{Name: "host-a", Namespace: "team"}}) }}, - &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "other-omp-config", Namespace: "team"}, Data: map[string]string{ - "provider-models": testOMPModels, "agent-settings": testOMPSettings, "other-models": otherTestOMPModels, "other-settings": otherTestOMPSettings, + {name: "invalid Host runtime profile", conditionType: "RuntimeConfigured", wantReason: "RuntimeProfileNotAllowed", revoke: func(ctx context.Context, c client.Client) error { + var host clusterv1alpha1.T4ClusterHost + if err := c.Get(ctx, types.NamespacedName{Namespace: "team", Name: "host-a"}, &host); err != nil { + return err + } + host.Spec.RuntimeProfiles = nil + return c.Update(ctx, &host) + }}, + {name: "missing Workspace", conditionType: "WorkspaceReady", wantReason: "WorkspaceNotFound", revoke: func(ctx context.Context, c client.Client) error { + return c.Delete(ctx, &clusterv1alpha1.T4Workspace{ObjectMeta: metav1.ObjectMeta{Name: "workspace-a", Namespace: "team"}}) + }}, + {name: "mismatched Workspace Host", conditionType: "WorkspaceReady", wantReason: "HostMismatch", revoke: func(ctx context.Context, c client.Client) error { + var workspace clusterv1alpha1.T4Workspace + if err := c.Get(ctx, types.NamespacedName{Namespace: "team", Name: "workspace-a"}, &workspace); err != nil { + return err + } + workspace.Spec.HostRef = "host-b" + return c.Update(ctx, &workspace) + }}, + {name: "missing OMP ConfigMap", conditionType: "RuntimeConfigured", wantReason: "OMPConfigMapNotFound", revoke: func(ctx context.Context, c client.Client) error { + return c.Delete(ctx, &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "omp-runtime-config", Namespace: "team"}}) + }}, + {name: "mismatched Host storage class", conditionType: "WorkspaceReady", wantReason: "StorageClassMismatch", revoke: func(ctx context.Context, c client.Client) error { + otherClass := &storagev1.StorageClass{ObjectMeta: metav1.ObjectMeta{Name: "other-rwx", Annotations: map[string]string{clusterv1alpha1.RWXStorageClassAnnotation: string(corev1.ReadWriteMany)}}, Provisioner: "example.invalid/csi"} + if err := c.Create(ctx, otherClass); err != nil { + return err + } + var host clusterv1alpha1.T4ClusterHost + if err := c.Get(ctx, types.NamespacedName{Namespace: "team", Name: "host-a"}, &host); err != nil { + return err + } + host.Spec.StorageClassName = otherClass.Name + return c.Update(ctx, &host) + }}, + {name: "classless Workspace PVC", conditionType: "WorkspaceReady", wantReason: "StorageClassMismatch", revoke: func(ctx context.Context, c client.Client) error { + var pvc corev1.PersistentVolumeClaim + if err := c.Get(ctx, types.NamespacedName{Namespace: "team", Name: "workspace-a-data"}, &pvc); err != nil { + return err + } + pvc.Spec.StorageClassName = nil + return c.Update(ctx, &pvc) }}, - rwxStorageClass(), } { - if err := c.Create(context.Background(), object); err != nil && !apierrors.IsAlreadyExists(err) { - panic(err) - } - } - return &controllers.SessionReconciler{ - Client: c, - APIReader: c, - Scheme: scheme, - RuntimeImage: testRuntimeImage, - OMPConfig: controllers.SessionOMPConfig{ - ConfigMapName: "omp-runtime-config", - ModelsKey: "provider-models", - SettingsKey: "agent-settings", - }, - } -} - -func testScheme(t *testing.T) *runtime.Scheme { - t.Helper() - scheme := runtime.NewScheme() - for _, add := range []func(*runtime.Scheme) error{corev1.AddToScheme, storagev1.AddToScheme, clusterv1alpha1.AddToScheme} { - if err := add(scheme); err != nil { - t.Fatal(err) - } - } - return scheme -} - -func testHost() *clusterv1alpha1.T4ClusterHost { - return &clusterv1alpha1.T4ClusterHost{ - ObjectMeta: metav1.ObjectMeta{Name: "host-a", Namespace: "team", UID: "host-uid"}, - Spec: clusterv1alpha1.T4ClusterHostSpec{StorageClassName: "portable-rwx", RuntimeProfiles: []string{"default"}}, - } -} - + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.Status.PVCName = "workspace-a-data" + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, + } + session := testSession() + session.UID = "session-uid" + foreignPod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "foreign-pod", Namespace: "team"}} + foreignService := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "foreign-service", Namespace: "team"}} + c := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&clusterv1alpha1.T4Session{}, &corev1.PersistentVolumeClaim{}, &corev1.Pod{}). + WithObjects(testHost(), workspace, pvc, session, foreignPod, foreignService).Build() + r := configuredSessionReconciler(c, scheme) + reconcileMany(t, 2, func() error { + _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}) + return err + }) + if err := test.revoke(ctx, c); err != nil { + t.Fatal(err) + } + + result, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}) + if err != nil { + t.Fatal(err) + } + if result.RequeueAfter <= 0 || result.RequeueAfter > 30*time.Second { + t.Fatalf("revoked dependency requeue = %s, want bounded positive retry", result.RequeueAfter) + } + for _, object := range []client.Object{ + &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: controllers.SessionPodName(session), Namespace: session.Namespace}}, + &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: controllers.SessionServiceName(session), Namespace: session.Namespace}}, + } { + if err := c.Get(ctx, client.ObjectKeyFromObject(object), object); !apierrors.IsNotFound(err) { + t.Fatalf("owned stale %T remained after dependency revocation: %v", object, err) + } + } + for _, object := range []client.Object{foreignPod.DeepCopy(), foreignService.DeepCopy()} { + if err := c.Get(ctx, client.ObjectKeyFromObject(object), object); err != nil { + t.Fatalf("unowned %T was removed: %v", object, err) + } + } + + var failed clusterv1alpha1.T4Session + if err := c.Get(ctx, client.ObjectKeyFromObject(session), &failed); err != nil { + t.Fatal(err) + } + condition := findCondition(failed.Status.Conditions, test.conditionType) + available := findCondition(failed.Status.Conditions, "Available") + if failed.Status.ObservedGeneration != failed.Generation || failed.Status.PodName != "" || failed.Status.ServiceName != "" || failed.Status.Phase != clusterv1alpha1.InfrastructureFailed || + condition == nil || condition.Status != metav1.ConditionFalse || condition.Reason != test.wantReason || condition.ObservedGeneration != failed.Generation || + available == nil || available.Status != metav1.ConditionFalse || available.Reason != test.wantReason || available.ObservedGeneration != failed.Generation { + t.Fatalf("revoked session did not converge: status=%#v condition=%#v available=%#v", failed.Status, condition, available) + } + stableStatus := failed.Status + restarted := *r + reconcileMany(t, 2, func() error { + _, err := restarted.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}) + return err + }) + if err := c.Get(ctx, client.ObjectKeyFromObject(session), &failed); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(failed.Status, stableStatus) { + t.Fatalf("duplicate/restart reconciliation changed converged status: got %#v, want %#v", failed.Status, stableStatus) + } + }) + } +} + +func TestWorkspaceHostStorageClassDriftFailsClosedWithoutRecreatingPVC(t *testing.T) { + oldClass := "portable-rwx" + for _, test := range []struct { + name string + claimClass *string + }{ + {name: "different class", claimClass: &oldClass}, + {name: "class omitted"}, + } { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.UID = "workspace-uid" + workspace.Status.ObservedGeneration = workspace.Generation + workspace.Status.PVCName = controllers.WorkspacePVCName(workspace) + workspace.Status.Phase = clusterv1alpha1.InfrastructureReady + workspace.Status.Conditions = []metav1.Condition{ + {Type: "StorageReady", Status: metav1.ConditionTrue, Reason: controllers.ReasonStorageReady, ObservedGeneration: workspace.Generation}, + {Type: "Ready", Status: metav1.ConditionTrue, Reason: "PVCBound", ObservedGeneration: workspace.Generation}, + } + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspace.Status.PVCName, Namespace: workspace.Namespace, + Annotations: map[string]string{clusterv1alpha1.WorkspaceUIDAnnotation: string(workspace.UID)}, + OwnerReferences: []metav1.OwnerReference{{APIVersion: clusterv1alpha1.GroupVersion.String(), Kind: "T4Workspace", Name: workspace.Name, UID: workspace.UID, Controller: ptr(true)}}, + }, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: test.claimClass, AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, + } + host := testHost() + host.Spec.StorageClassName = "other-rwx" + otherClass := &storagev1.StorageClass{ObjectMeta: metav1.ObjectMeta{Name: "other-rwx", Annotations: map[string]string{clusterv1alpha1.RWXStorageClassAnnotation: string(corev1.ReadWriteMany)}}, Provisioner: "example.invalid/csi"} + c := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&clusterv1alpha1.T4Workspace{}, &corev1.PersistentVolumeClaim{}). + WithObjects(host, otherClass, workspace, pvc).Build() + r := &controllers.WorkspaceReconciler{Client: c, Scheme: scheme} + + result, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(workspace)}) + if err != nil { + t.Fatal(err) + } + if result.RequeueAfter <= 0 || result.RequeueAfter > 30*time.Second { + t.Fatalf("storage drift requeue = %s, want bounded positive retry", result.RequeueAfter) + } + var got clusterv1alpha1.T4Workspace + if err := c.Get(ctx, client.ObjectKeyFromObject(workspace), &got); err != nil { + t.Fatal(err) + } + storageReady := findCondition(got.Status.Conditions, "StorageReady") + ready := findCondition(got.Status.Conditions, "Ready") + if got.Status.Phase != clusterv1alpha1.InfrastructureFailed || storageReady == nil || storageReady.Status != metav1.ConditionFalse || storageReady.Reason != "StorageClassMismatch" || ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != "StorageClassMismatch" { + t.Fatalf("storage drift remained Ready: status=%#v StorageReady=%#v Ready=%#v", got.Status, storageReady, ready) + } + var retained corev1.PersistentVolumeClaim + if err := c.Get(ctx, client.ObjectKeyFromObject(pvc), &retained); err != nil { + t.Fatalf("storage drift removed the data PVC: %v", err) + } + if !reflect.DeepEqual(retained.Spec.StorageClassName, test.claimClass) { + t.Fatalf("storage drift recreated or mutated PVC class: got %#v, want %#v", retained.Spec.StorageClassName, test.claimClass) + } + }) + } +} + +func TestHostDependencyRecoveryReplacesFalseConditions(t *testing.T) { + t.Run("Workspace", func(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.UID = "workspace-uid" + c := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&clusterv1alpha1.T4Workspace{}, &corev1.PersistentVolumeClaim{}). + WithObjects(workspace).Build() + r := &controllers.WorkspaceReconciler{Client: c, Scheme: scheme} + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(workspace)}); err != nil { + t.Fatal(err) + } + if err := c.Create(ctx, testHost()); err != nil { + t.Fatal(err) + } + if err := c.Create(ctx, rwxStorageClass()); err != nil { + t.Fatal(err) + } + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(workspace)}); err != nil { + t.Fatal(err) + } + var pvc corev1.PersistentVolumeClaim + if err := c.Get(ctx, types.NamespacedName{Namespace: workspace.Namespace, Name: controllers.WorkspacePVCName(workspace)}, &pvc); err != nil { + t.Fatal(err) + } + pvc.Status.Phase = corev1.ClaimBound + if err := c.Status().Update(ctx, &pvc); err != nil { + t.Fatal(err) + } + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(workspace)}); err != nil { + t.Fatal(err) + } + var recovered clusterv1alpha1.T4Workspace + if err := c.Get(ctx, client.ObjectKeyFromObject(workspace), &recovered); err != nil { + t.Fatal(err) + } + hostReady := findCondition(recovered.Status.Conditions, "HostReady") + if hostReady == nil || hostReady.Status != metav1.ConditionTrue || hostReady.ObservedGeneration != recovered.Generation { + t.Fatalf("recovered Workspace retained stale HostReady: %#v", hostReady) + } + }) + + t.Run("Session", func(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.Status.PVCName = "workspace-a-data" + pvc := &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: workspace.Namespace}, Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}} + session := testSession() + session.UID = "session-uid" + c := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&clusterv1alpha1.T4Session{}, &corev1.PersistentVolumeClaim{}, &corev1.Pod{}). + WithObjects(workspace, pvc, session).Build() + r := configuredSessionReconciler(c, scheme) + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}); err != nil { + t.Fatal(err) + } + if err := c.Create(ctx, testHost()); err != nil { + t.Fatal(err) + } + reconcileMany(t, 2, func() error { + _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}) + return err + }) + var recovered clusterv1alpha1.T4Session + if err := c.Get(ctx, client.ObjectKeyFromObject(session), &recovered); err != nil { + t.Fatal(err) + } + hostReady := findCondition(recovered.Status.Conditions, "HostReady") + if hostReady == nil || hostReady.Status != metav1.ConditionTrue || hostReady.ObservedGeneration != recovered.Generation { + t.Fatalf("recovered Session retained stale HostReady: %#v", hostReady) + } + }) + + t.Run("static runtime failure", func(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + session := testSession() + session.Status.ObservedGeneration = session.Generation + session.Status.Phase = clusterv1alpha1.InfrastructureFailed + session.Status.Conditions = []metav1.Condition{{Type: "HostReady", Status: metav1.ConditionFalse, Reason: "HostNotFound", ObservedGeneration: session.Generation}} + c := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&clusterv1alpha1.T4Session{}). + WithObjects(testHost(), session).Build() + r := configuredSessionReconciler(c, scheme) + r.RuntimeImage = "registry.example/session:latest" + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}); err != nil { + t.Fatal(err) + } + var failed clusterv1alpha1.T4Session + if err := c.Get(ctx, client.ObjectKeyFromObject(session), &failed); err != nil { + t.Fatal(err) + } + hostReady := findCondition(failed.Status.Conditions, "HostReady") + if hostReady == nil || hostReady.Status != metav1.ConditionUnknown || hostReady.Reason != "NotEvaluated" || hostReady.ObservedGeneration != failed.Generation { + t.Fatalf("static runtime failure retained stale HostReady: %#v", hostReady) + } + }) +} + +func TestWorkspaceFailureRevokesPublishedPVCAuthorityAndRefreshesConditions(t *testing.T) { + for _, test := range []struct { + name string + objects func(*clusterv1alpha1.T4Workspace) []client.Object + wantHostStatus metav1.ConditionStatus + wantStorageReason string + }{ + { + name: "missing Host", + objects: func(workspace *clusterv1alpha1.T4Workspace) []client.Object { + return []client.Object{workspace} + }, + wantHostStatus: metav1.ConditionFalse, + wantStorageReason: "NotEvaluated", + }, + { + name: "missing StorageClass", + objects: func(workspace *clusterv1alpha1.T4Workspace) []client.Object { + return []client.Object{testHost(), workspace} + }, + wantHostStatus: metav1.ConditionTrue, + wantStorageReason: controllers.ReasonStorageClassNotFound, + }, + { + name: "non-RWX StorageClass", + objects: func(workspace *clusterv1alpha1.T4Workspace) []client.Object { + class := rwxStorageClass() + class.Annotations = nil + return []client.Object{testHost(), class, workspace} + }, + wantHostStatus: metav1.ConditionTrue, + wantStorageReason: controllers.ReasonStorageClassNotRWX, + }, + { + name: "PVC class drift", + objects: func(workspace *clusterv1alpha1.T4Workspace) []client.Object { + pvc := ownedWorkspacePVC(workspace) + pvc.Spec.StorageClassName = ptr("old-rwx") + return []client.Object{testHost(), rwxStorageClass(), workspace, pvc} + }, + wantHostStatus: metav1.ConditionTrue, + wantStorageReason: controllers.ReasonStorageClassMismatch, + }, + { + name: "PVC ownership conflict", + objects: func(workspace *clusterv1alpha1.T4Workspace) []client.Object { + pvc := ownedWorkspacePVC(workspace) + pvc.OwnerReferences = []metav1.OwnerReference{{APIVersion: "example.test/v1", Kind: "Foreign", Name: "foreign", UID: "foreign-uid", Controller: ptr(true)}} + return []client.Object{testHost(), rwxStorageClass(), workspace, pvc} + }, + wantHostStatus: metav1.ConditionTrue, + wantStorageReason: "PVCOwnershipConflict", + }, + } { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.UID = "workspace-uid" + workspace.Generation = 7 + workspace.Status.ObservedGeneration = 6 + workspace.Status.PVCName = controllers.WorkspacePVCName(workspace) + workspace.Status.PVCPhase = corev1.ClaimBound + workspace.Status.Capacity = apiresource.MustParse("10Gi") + workspace.Status.Phase = clusterv1alpha1.InfrastructureReady + workspace.Status.Conditions = []metav1.Condition{ + {Type: "HostReady", Status: metav1.ConditionTrue, Reason: "Stale", ObservedGeneration: 6}, + {Type: "StorageReady", Status: metav1.ConditionTrue, Reason: "Stale", ObservedGeneration: 6}, + {Type: "Ready", Status: metav1.ConditionTrue, Reason: "Stale", ObservedGeneration: 6}, + } + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&clusterv1alpha1.T4Workspace{}, &corev1.PersistentVolumeClaim{}).WithObjects(test.objects(workspace)...).Build() + r := &controllers.WorkspaceReconciler{Client: c, Scheme: scheme} + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(workspace)}); err != nil { + t.Fatal(err) + } + var failed clusterv1alpha1.T4Workspace + if err := c.Get(ctx, client.ObjectKeyFromObject(workspace), &failed); err != nil { + t.Fatal(err) + } + if failed.Status.PVCName != "" || failed.Status.PVCPhase != "" || !failed.Status.Capacity.IsZero() { + t.Fatalf("failed Workspace retained PVC authority: %#v", failed.Status) + } + hostReady := findCondition(failed.Status.Conditions, "HostReady") + storageReady := findCondition(failed.Status.Conditions, "StorageReady") + ready := findCondition(failed.Status.Conditions, "Ready") + if hostReady == nil || hostReady.Status != test.wantHostStatus || hostReady.ObservedGeneration != failed.Generation || + storageReady == nil || storageReady.Status == metav1.ConditionTrue || storageReady.Reason != test.wantStorageReason || storageReady.ObservedGeneration != failed.Generation || + ready == nil || ready.Status != metav1.ConditionFalse || ready.ObservedGeneration != failed.Generation { + t.Fatalf("failure conditions are stale: HostReady=%#v StorageReady=%#v Ready=%#v", hostReady, storageReady, ready) + } + }) + } +} + +func TestSessionRejectsWorkspacePVCWithoutExactIdentityAndOwnership(t *testing.T) { + for _, test := range []struct { + name string + mutatePVC func(*clusterv1alpha1.T4Workspace, *corev1.PersistentVolumeClaim) + }{ + {name: "foreign deterministic PVC", mutatePVC: func(_ *clusterv1alpha1.T4Workspace, pvc *corev1.PersistentVolumeClaim) { + pvc.OwnerReferences = []metav1.OwnerReference{{APIVersion: "example.test/v1", Kind: "Foreign", Name: "foreign", UID: "foreign-uid", Controller: ptr(true)}} + }}, + {name: "tampered Workspace status name", mutatePVC: func(workspace *clusterv1alpha1.T4Workspace, pvc *corev1.PersistentVolumeClaim) { + workspace.Status.PVCName = "foreign-data" + pvc.Name = workspace.Status.PVCName + }}, + } { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.UID = "workspace-uid" + workspace.Status.PVCName = controllers.WorkspacePVCName(workspace) + pvc := ownedWorkspacePVC(workspace) + test.mutatePVC(workspace, pvc) + session := testSession() + session.UID = "session-uid" + pod, service := ownedSessionResources(session) + c := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&clusterv1alpha1.T4Session{}, &corev1.PersistentVolumeClaim{}, &corev1.Pod{}). + WithObjects(testHost(), workspace, pvc, session, pod, service).Build() + r := configuredSessionReconciler(c, scheme) + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}); err != nil { + t.Fatal(err) + } + assertObjectCounts(t, c, 0, 0) + var failed clusterv1alpha1.T4Session + if err := c.Get(ctx, client.ObjectKeyFromObject(session), &failed); err != nil { + t.Fatal(err) + } + condition := findCondition(failed.Status.Conditions, "WorkspaceReady") + if failed.Status.PodName != "" || failed.Status.ServiceName != "" || condition == nil || condition.Status != metav1.ConditionFalse || condition.ObservedGeneration != failed.Generation { + t.Fatalf("untrusted Workspace PVC retained session authority: status=%#v WorkspaceReady=%#v", failed.Status, condition) + } + }) + } +} + +func TestSessionFailureAndPendingRefreshEveryCondition(t *testing.T) { + t.Run("failure", func(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + session := testSession() + session.Generation = 8 + session.Status.ObservedGeneration = 7 + session.Status.Conditions = staleSessionConditions(7) + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&clusterv1alpha1.T4Session{}).WithObjects(session).Build() + r := configuredSessionReconciler(c, scheme) + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}); err != nil { + t.Fatal(err) + } + var failed clusterv1alpha1.T4Session + if err := c.Get(ctx, client.ObjectKeyFromObject(session), &failed); err != nil { + t.Fatal(err) + } + assertCurrentSessionConditions(t, &failed, map[string]metav1.ConditionStatus{ + "HostReady": metav1.ConditionFalse, "WorkspaceReady": metav1.ConditionUnknown, "RuntimeConfigured": metav1.ConditionUnknown, "Available": metav1.ConditionFalse, + }) + }) + + t.Run("pending", func(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.UID = "workspace-uid" + workspace.Status.PVCName = controllers.WorkspacePVCName(workspace) + pvc := ownedWorkspacePVC(workspace) + session := testSession() + session.UID = "session-uid" + session.Generation = 8 + session.Status.ObservedGeneration = 7 + session.Status.Conditions = staleSessionConditions(7) + _, service := ownedSessionResources(session) + service.Spec.Type = corev1.ServiceTypeNodePort + service.Spec.Ports = []corev1.ServicePort{{Name: "host", Port: 8787, NodePort: 32080}} + c := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&clusterv1alpha1.T4Session{}, &corev1.PersistentVolumeClaim{}, &corev1.Pod{}). + WithObjects(testHost(), workspace, pvc, session, service).Build() + r := configuredSessionReconciler(c, scheme) + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}); err != nil { + t.Fatal(err) + } + var pending clusterv1alpha1.T4Session + if err := c.Get(ctx, client.ObjectKeyFromObject(session), &pending); err != nil { + t.Fatal(err) + } + assertCurrentSessionConditions(t, &pending, map[string]metav1.ConditionStatus{ + "HostReady": metav1.ConditionTrue, "WorkspaceReady": metav1.ConditionTrue, "RuntimeConfigured": metav1.ConditionTrue, "Available": metav1.ConditionFalse, + }) + }) +} + +func TestSessionResourcesWithAnyForeignOwnerFailClosed(t *testing.T) { + for _, path := range []string{"normal", "dependency-cleanup"} { + for _, kind := range []string{"Pod", "Service"} { + t.Run(path+"/"+kind, func(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + session := testSession() + session.UID = "session-uid" + pod, service := ownedSessionResources(session) + foreignOwner := metav1.OwnerReference{APIVersion: "example.test/v1", Kind: "Foreign", Name: "foreign", UID: "foreign-uid"} + if kind == "Pod" { + pod.OwnerReferences = append(pod.OwnerReferences, foreignOwner) + } else { + service.OwnerReferences = append(service.OwnerReferences, foreignOwner) + } + objects := []client.Object{session, pod, service} + if path == "normal" { + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.UID = "workspace-uid" + workspace.Status.PVCName = controllers.WorkspacePVCName(workspace) + objects = append(objects, testHost(), workspace, ownedWorkspacePVC(workspace)) + } + c := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&clusterv1alpha1.T4Session{}, &corev1.PersistentVolumeClaim{}, &corev1.Pod{}). + WithObjects(objects...).Build() + r := configuredSessionReconciler(c, scheme) + beforePod := pod.DeepCopy() + beforeService := service.DeepCopy() + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}); err != nil { + t.Fatal(err) + } + if kind == "Pod" { + var got corev1.Pod + if err := c.Get(ctx, client.ObjectKeyFromObject(pod), &got); err != nil { + t.Fatalf("foreign-owned Pod was deleted: %v", err) + } + if !reflect.DeepEqual(got.ObjectMeta, beforePod.ObjectMeta) || !reflect.DeepEqual(got.Spec, beforePod.Spec) { + t.Fatalf("Pod with foreign OwnerReference was mutated: %#v", got) + } + } else { + var got corev1.Service + if err := c.Get(ctx, client.ObjectKeyFromObject(service), &got); err != nil { + t.Fatalf("foreign-owned Service was deleted: %v", err) + } + if !reflect.DeepEqual(got.ObjectMeta, beforeService.ObjectMeta) || !reflect.DeepEqual(got.Spec, beforeService.Spec) { + t.Fatalf("Service with foreign OwnerReference was mutated: %#v", got) + } + } + var sibling client.Object + if kind == "Pod" { + sibling = &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: controllers.SessionServiceName(session), Namespace: session.Namespace}} + } else { + sibling = &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: controllers.SessionPodName(session), Namespace: session.Namespace}} + } + if err := c.Get(ctx, client.ObjectKeyFromObject(sibling), sibling); !apierrors.IsNotFound(err) { + t.Fatalf("exclusively owned sibling remained after ownership conflict: %v", err) + } + var failed clusterv1alpha1.T4Session + if err := c.Get(ctx, client.ObjectKeyFromObject(session), &failed); err != nil { + t.Fatal(err) + } + available := findCondition(failed.Status.Conditions, "Available") + if available == nil || available.Status != metav1.ConditionFalse || !strings.Contains(available.Reason, "OwnershipConflict") { + t.Fatalf("foreign owner did not produce stable ownership conflict: %#v", available) + } + if path == "normal" { + assertCurrentSessionConditions(t, &failed, map[string]metav1.ConditionStatus{ + "HostReady": metav1.ConditionTrue, "WorkspaceReady": metav1.ConditionTrue, "RuntimeConfigured": metav1.ConditionTrue, "Available": metav1.ConditionFalse, + }) + } + }) + } + } +} + +func TestSessionCreateAlreadyExistsRefetchesForeignWinner(t *testing.T) { + for _, kind := range []string{"Pod", "Service"} { + t.Run(kind, func(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.UID = "workspace-uid" + workspace.Status.PVCName = controllers.WorkspacePVCName(workspace) + session := testSession() + session.UID = "session-uid" + pod, service := ownedSessionResources(session) + objects := []client.Object{testHost(), workspace, ownedWorkspacePVC(workspace), session} + if kind == "Pod" { + objects = append(objects, service) + } else { + objects = append(objects, pod) + } + base := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&clusterv1alpha1.T4Session{}, &corev1.PersistentVolumeClaim{}, &corev1.Pod{}). + WithObjects(objects...).Build() + c := &createAlreadyExistsClient{Client: base, raceKind: kind, hideWinnerFromCache: true} + r := configuredSessionReconciler(c, scheme) + r.APIReader = base + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}); err != nil { + t.Fatal(err) + } + if c.winner == nil { + t.Fatalf("%s create race was not exercised", kind) + } + if kind == "Pod" { + var got corev1.Pod + if err := base.Get(ctx, client.ObjectKeyFromObject(c.winner), &got); err != nil { + t.Fatalf("foreign Pod winner was deleted: %v", err) + } + want := c.winner.(*corev1.Pod) + if !reflect.DeepEqual(got.ObjectMeta, want.ObjectMeta) || !reflect.DeepEqual(got.Spec, want.Spec) { + t.Fatalf("foreign Pod winner was mutated: %#v", got) + } + assertObjectCounts(t, base, 1, 0) + } else { + var got corev1.Service + if err := base.Get(ctx, client.ObjectKeyFromObject(c.winner), &got); err != nil { + t.Fatalf("foreign Service winner was deleted: %v", err) + } + want := c.winner.(*corev1.Service) + if !reflect.DeepEqual(got.ObjectMeta, want.ObjectMeta) || !reflect.DeepEqual(got.Spec, want.Spec) { + t.Fatalf("foreign Service winner was mutated: %#v", got) + } + assertObjectCounts(t, base, 0, 1) + } + var failed clusterv1alpha1.T4Session + if err := base.Get(ctx, client.ObjectKeyFromObject(session), &failed); err != nil { + t.Fatal(err) + } + assertCurrentSessionConditions(t, &failed, map[string]metav1.ConditionStatus{ + "HostReady": metav1.ConditionTrue, "WorkspaceReady": metav1.ConditionTrue, "RuntimeConfigured": metav1.ConditionTrue, "Available": metav1.ConditionFalse, + }) + available := findCondition(failed.Status.Conditions, "Available") + if available.Reason != kind+"OwnershipConflict" { + t.Fatalf("Available reason = %q, want %sOwnershipConflict", available.Reason, kind) + } + }) + } +} + +func TestWorkspaceTerminalPVCFailuresRevokePublishedAuthority(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*corev1.PersistentVolumeClaim) + reason string + }{ + {name: "Bound without RWX", reason: "PVCNotRWX", mutate: func(pvc *corev1.PersistentVolumeClaim) { pvc.Spec.AccessModes = []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce} }}, + {name: "Lost", reason: "PVCLost", mutate: func(pvc *corev1.PersistentVolumeClaim) { pvc.Status.Phase = corev1.ClaimLost }}, + } { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.UID = "workspace-uid" + pvc := ownedWorkspacePVC(workspace) + test.mutate(pvc) + c := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&clusterv1alpha1.T4Workspace{}, &corev1.PersistentVolumeClaim{}). + WithObjects(testHost(), rwxStorageClass(), workspace, pvc).Build() + r := &controllers.WorkspaceReconciler{Client: c, Scheme: scheme} + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(workspace)}); err != nil { + t.Fatal(err) + } + var failed clusterv1alpha1.T4Workspace + if err := c.Get(ctx, client.ObjectKeyFromObject(workspace), &failed); err != nil { + t.Fatal(err) + } + storageReady := findCondition(failed.Status.Conditions, "StorageReady") + ready := findCondition(failed.Status.Conditions, "Ready") + if failed.Status.PVCName != "" || failed.Status.PVCPhase != "" || !failed.Status.Capacity.IsZero() || + storageReady == nil || storageReady.Status != metav1.ConditionFalse || storageReady.Reason != test.reason || storageReady.ObservedGeneration != failed.Generation || + ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != test.reason || ready.ObservedGeneration != failed.Generation { + t.Fatalf("terminal PVC failure retained authority: status=%#v StorageReady=%#v Ready=%#v", failed.Status, storageReady, ready) + } + }) + } +} + + +func ownedWorkspacePVC(workspace *clusterv1alpha1.T4Workspace) *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: controllers.WorkspacePVCName(workspace), Namespace: workspace.Namespace, + Annotations: map[string]string{clusterv1alpha1.WorkspaceUIDAnnotation: string(workspace.UID)}, + OwnerReferences: []metav1.OwnerReference{{APIVersion: clusterv1alpha1.GroupVersion.String(), Kind: "T4Workspace", Name: workspace.Name, UID: workspace.UID, Controller: ptr(true)}}, + }, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr("portable-rwx"), AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, + } +} + +func ownedSessionResources(session *clusterv1alpha1.T4Session) (*corev1.Pod, *corev1.Service) { + owner := metav1.OwnerReference{APIVersion: clusterv1alpha1.GroupVersion.String(), Kind: "T4Session", Name: session.Name, UID: session.UID, Controller: ptr(true)} + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: controllers.SessionPodName(session), Namespace: session.Namespace, OwnerReferences: []metav1.OwnerReference{owner}}} + service := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: controllers.SessionServiceName(session), Namespace: session.Namespace, OwnerReferences: []metav1.OwnerReference{owner}}, Spec: corev1.ServiceSpec{Type: corev1.ServiceTypeClusterIP}} + return pod, service +} + +func staleSessionConditions(generation int64) []metav1.Condition { + return []metav1.Condition{ + {Type: "HostReady", Status: metav1.ConditionTrue, Reason: "Stale", ObservedGeneration: generation}, + {Type: "WorkspaceReady", Status: metav1.ConditionTrue, Reason: "Stale", ObservedGeneration: generation}, + {Type: "RuntimeConfigured", Status: metav1.ConditionTrue, Reason: "Stale", ObservedGeneration: generation}, + {Type: "Available", Status: metav1.ConditionTrue, Reason: "Stale", ObservedGeneration: generation}, + } +} + +func assertCurrentSessionConditions(t *testing.T, session *clusterv1alpha1.T4Session, want map[string]metav1.ConditionStatus) { + t.Helper() + for conditionType, status := range want { + condition := findCondition(session.Status.Conditions, conditionType) + if condition == nil || condition.Status != status || condition.ObservedGeneration != session.Generation { + t.Fatalf("%s = %#v, want %s at generation %d", conditionType, condition, status, session.Generation) + } + } +} + +func configuredSessionReconciler(c client.Client, scheme *runtime.Scheme) *controllers.SessionReconciler { + for _, object := range []client.Object{ + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "omp-runtime-config", Namespace: "team"}, Data: map[string]string{ + "provider-models": testOMPModels, "agent-settings": testOMPSettings, "other-models": otherTestOMPModels, "other-settings": otherTestOMPSettings, + }}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "other-omp-config", Namespace: "team"}, Data: map[string]string{ + "provider-models": testOMPModels, "agent-settings": testOMPSettings, "other-models": otherTestOMPModels, "other-settings": otherTestOMPSettings, + }}, + rwxStorageClass(), + } { + if err := c.Create(context.Background(), object); err != nil && !apierrors.IsAlreadyExists(err) { + panic(err) + } + } + return &controllers.SessionReconciler{ + Client: c, + APIReader: c, + Scheme: scheme, + RuntimeImage: testRuntimeImage, + OMPConfig: controllers.SessionOMPConfig{ + ConfigMapName: "omp-runtime-config", + ModelsKey: "provider-models", + SettingsKey: "agent-settings", + }, + } +} + +func testScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + for _, add := range []func(*runtime.Scheme) error{corev1.AddToScheme, storagev1.AddToScheme, clusterv1alpha1.AddToScheme} { + if err := add(scheme); err != nil { + t.Fatal(err) + } + } + return scheme +} + +func testHost() *clusterv1alpha1.T4ClusterHost { + return &clusterv1alpha1.T4ClusterHost{ + ObjectMeta: metav1.ObjectMeta{Name: "host-a", Namespace: "team", UID: "host-uid"}, + Spec: clusterv1alpha1.T4ClusterHostSpec{StorageClassName: "portable-rwx", RuntimeProfiles: []string{"default"}}, + } +} + func rwxStorageClass() *storagev1.StorageClass { return &storagev1.StorageClass{ ObjectMeta: metav1.ObjectMeta{Name: "portable-rwx", Annotations: map[string]string{clusterv1alpha1.RWXStorageClassAnnotation: string(corev1.ReadWriteMany)}}, @@ -1326,4 +2426,112 @@ func hasReadOnlyMount(mounts []corev1.VolumeMount, name, path string) bool { return false } +type pvcOverrideReader struct { + client.Reader + pvc *corev1.PersistentVolumeClaim +} + +func (r *pvcOverrideReader) Get(ctx context.Context, key client.ObjectKey, object client.Object, options ...client.GetOption) error { + if pvc, ok := object.(*corev1.PersistentVolumeClaim); ok && key == client.ObjectKeyFromObject(r.pvc) { + r.pvc.DeepCopyInto(pvc) + return nil + } + return r.Reader.Get(ctx, key, object, options...) +} + +type createAlreadyExistsClient struct { + client.Client + raceKind string + winner client.Object + hideWinnerFromCache bool +} + +type replaceBeforeDeleteClient struct { + client.Client + raceKind string + replacementUID types.UID + raced bool + observedUID types.UID + observedResourceVersion string +} + +func (c *replaceBeforeDeleteClient) Delete(ctx context.Context, object client.Object, options ...client.DeleteOption) error { + if c.raced { + return c.Client.Delete(ctx, object, options...) + } + if _, isPod := object.(*corev1.Pod); !isPod || c.raceKind != "Pod" { + return c.Client.Delete(ctx, object, options...) + } + deleteOptions := (&client.DeleteOptions{}).ApplyOptions(options) + if deleteOptions.Preconditions != nil { + if deleteOptions.Preconditions.UID != nil { + c.observedUID = *deleteOptions.Preconditions.UID + } + if deleteOptions.Preconditions.ResourceVersion != nil { + c.observedResourceVersion = *deleteOptions.Preconditions.ResourceVersion + } + } + c.raced = true + if err := c.Client.Delete(ctx, object); err != nil { + return err + } + replacement := object.DeepCopyObject().(client.Object) + replacement.SetUID(c.replacementUID) + replacement.SetResourceVersion("") + replacement.SetDeletionTimestamp(nil) + replacement.SetFinalizers(nil) + replacement.SetOwnerReferences(nil) + if err := c.Client.Create(ctx, replacement); err != nil { + return err + } + return apierrors.NewConflict(schema.GroupResource{Resource: "pods"}, object.GetName(), errors.New("delete preconditions no longer match replacement")) +} + +func (c *createAlreadyExistsClient) Get(ctx context.Context, key client.ObjectKey, object client.Object, options ...client.GetOption) error { + if c.hideWinnerFromCache && c.winner != nil && key == client.ObjectKeyFromObject(c.winner) { + switch object.(type) { + case *corev1.Pod: + if c.raceKind == "Pod" { + return apierrors.NewNotFound(schema.GroupResource{Resource: "pods"}, key.Name) + } + case *corev1.Service: + if c.raceKind == "Service" { + return apierrors.NewNotFound(schema.GroupResource{Resource: "services"}, key.Name) + } + case *corev1.PersistentVolumeClaim: + if c.raceKind == "PVC" { + return apierrors.NewNotFound(schema.GroupResource{Resource: "persistentvolumeclaims"}, key.Name) + } + } + } + return c.Client.Get(ctx, key, object, options...) +} + +func (c *createAlreadyExistsClient) Create(ctx context.Context, object client.Object, options ...client.CreateOption) error { + var winner client.Object + switch object := object.(type) { + case *corev1.Pod: + if c.raceKind == "Pod" { + winner = object.DeepCopy() + } + case *corev1.Service: + if c.raceKind == "Service" { + winner = object.DeepCopy() + } + case *corev1.PersistentVolumeClaim: + if c.raceKind == "PVC" { + winner = object.DeepCopy() + } + } + if winner == nil || c.winner != nil { + return c.Client.Create(ctx, object, options...) + } + winner.SetOwnerReferences(nil) + if err := c.Client.Create(ctx, winner, options...); err != nil { + return err + } + c.winner = winner.DeepCopyObject().(client.Object) + return apierrors.NewAlreadyExists(schema.GroupResource{Resource: strings.ToLower(c.raceKind) + "s"}, object.GetName()) +} + func ptr[T any](value T) *T { return &value } diff --git a/packages/cluster-operator/controllers/session_controller.go b/packages/cluster-operator/controllers/session_controller.go index 681f4f0c..49caac41 100644 --- a/packages/cluster-operator/controllers/session_controller.go +++ b/packages/cluster-operator/controllers/session_controller.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/json" + "errors" "fmt" "net/url" "reflect" @@ -25,6 +26,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" clusterv1alpha1 "github.com/LycaonLLC/t4-code/packages/cluster-operator/api/v1alpha1" ) @@ -36,6 +38,13 @@ const ( SessionReviewerTokenExpirationSeconds int64 = 3600 ) +const ( + sessionHostRefIndexField = "t4.session.spec.hostRef" + sessionWorkspaceRefIndexField = "t4.session.spec.workspaceRef" +) + +var errSessionResourceOwnershipConflict = errors.New("session resource ownership conflict") + var ( configMapKeyPattern = regexp.MustCompile(`^[-._A-Za-z0-9]+$`) runtimeImagePattern = regexp.MustCompile(`^(?:(?:[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?|\[[A-Fa-f0-9:]+\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*@sha256:[a-f0-9]{64}$`) @@ -271,6 +280,10 @@ func (r *SessionReconciler) Reconcile(ctx context.Context, request ctrl.Request) var session clusterv1alpha1.T4Session found := false defer func() { + if errors.Is(err, errSessionResourceOwnershipConflict) { + result = ctrl.Result{RequeueAfter: 30 * time.Second} + err = nil + } observeReconcile(metricKindSession, request.NamespacedName, session.Status.Conditions, conditionObjectPresent(&session, found, err), err) }() if err := r.Get(ctx, request.NamespacedName, &session); err != nil { @@ -289,19 +302,22 @@ func (r *SessionReconciler) Reconcile(ctx context.Context, request ctrl.Request) if err := r.deleteOwnedSessionResources(ctx, &session); err != nil { return ctrl.Result{}, err } - return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, "RuntimeConfigured", reason, message) + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, false, false, "RuntimeConfigured", reason, message) } if reason, message := r.OMPConfig.validationFailure(); reason != "" { if err := r.deleteOwnedSessionResources(ctx, &session); err != nil { return ctrl.Result{}, err } - return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, "RuntimeConfigured", reason, message) + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, false, false, "RuntimeConfigured", reason, message) } var host clusterv1alpha1.T4ClusterHost if err := r.Get(ctx, types.NamespacedName{Namespace: session.Namespace, Name: session.Spec.HostRef}, &host); err != nil { if apierrors.IsNotFound(err) { - return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, "HostReady", "HostNotFound", "referenced T4ClusterHost does not exist") + if err := r.deleteOwnedSessionResources(ctx, &session); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, false, false, "HostReady", "HostNotFound", "referenced T4ClusterHost does not exist") } return ctrl.Result{}, err } @@ -309,7 +325,7 @@ func (r *SessionReconciler) Reconcile(ctx context.Context, request ctrl.Request) if err := r.deleteOwnedSessionResources(ctx, &session); err != nil { return ctrl.Result{}, err } - return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, "RuntimeConfigured", "RuntimeProfileNotAllowed", "runtime profile is not allowed by the referenced T4ClusterHost") + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, true, false, "RuntimeConfigured", "RuntimeProfileNotAllowed", "runtime profile is not allowed by the referenced T4ClusterHost") } var storageClass storagev1.StorageClass if err := r.Get(ctx, types.NamespacedName{Name: host.Spec.StorageClassName}, &storageClass); err != nil { @@ -317,7 +333,7 @@ func (r *SessionReconciler) Reconcile(ctx context.Context, request ctrl.Request) if err := r.deleteOwnedSessionResources(ctx, &session); err != nil { return ctrl.Result{}, err } - return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, "WorkspaceReady", ReasonStorageClassNotFound, fmt.Sprintf("StorageClass %q selected by the referenced T4ClusterHost does not exist", host.Spec.StorageClassName)) + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, true, false, "WorkspaceReady", ReasonStorageClassNotFound, fmt.Sprintf("StorageClass %q selected by the referenced T4ClusterHost does not exist", host.Spec.StorageClassName)) } return ctrl.Result{}, err } @@ -325,30 +341,63 @@ func (r *SessionReconciler) Reconcile(ctx context.Context, request ctrl.Request) if err := r.deleteOwnedSessionResources(ctx, &session); err != nil { return ctrl.Result{}, err } - return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, "WorkspaceReady", ReasonStorageClassNotRWX, fmt.Sprintf("StorageClass %q selected by the referenced T4ClusterHost is not administrator-declared ReadWriteMany", host.Spec.StorageClassName)) + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, true, false, "WorkspaceReady", ReasonStorageClassNotRWX, fmt.Sprintf("StorageClass %q selected by the referenced T4ClusterHost is not administrator-declared ReadWriteMany", host.Spec.StorageClassName)) } var workspace clusterv1alpha1.T4Workspace if err := r.Get(ctx, types.NamespacedName{Namespace: session.Namespace, Name: session.Spec.WorkspaceRef}, &workspace); err != nil { if apierrors.IsNotFound(err) { - return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, "WorkspaceReady", "WorkspaceNotFound", "referenced T4Workspace does not exist") + if err := r.deleteOwnedSessionResources(ctx, &session); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, true, false, "WorkspaceReady", "WorkspaceNotFound", "referenced T4Workspace does not exist") } return ctrl.Result{}, err } if workspace.Spec.HostRef != session.Spec.HostRef { - return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, "WorkspaceReady", "HostMismatch", "session and workspace must reference the same T4ClusterHost") + if err := r.deleteOwnedSessionResources(ctx, &session); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, true, false, "WorkspaceReady", "HostMismatch", "session and workspace must reference the same T4ClusterHost") } if workspace.Status.PVCName == "" { - return ctrl.Result{RequeueAfter: 5 * time.Second}, r.updateSessionFailure(ctx, &session, "WorkspaceReady", "PVCNotDeclared", "workspace controller has not declared a PVC") + if err := r.deleteOwnedSessionResources(ctx, &session); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: 5 * time.Second}, r.updateSessionFailure(ctx, &session, true, false, "WorkspaceReady", "PVCNotDeclared", "workspace controller has not declared a PVC") + } + if workspace.UID != "" && workspace.Status.PVCName != WorkspacePVCName(&workspace) { + if err := r.deleteOwnedSessionResources(ctx, &session); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, true, false, "WorkspaceReady", "PVCIdentityMismatch", "workspace status does not reference its deterministic PVC") } var pvc corev1.PersistentVolumeClaim if err := r.Get(ctx, types.NamespacedName{Namespace: session.Namespace, Name: workspace.Status.PVCName}, &pvc); err != nil { if apierrors.IsNotFound(err) { - return ctrl.Result{RequeueAfter: 5 * time.Second}, r.updateSessionFailure(ctx, &session, "WorkspaceReady", "PVCNotFound", "workspace PVC does not exist") + if err := r.deleteOwnedSessionResources(ctx, &session); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: 5 * time.Second}, r.updateSessionFailure(ctx, &session, true, false, "WorkspaceReady", "PVCNotFound", "workspace PVC does not exist") } return ctrl.Result{}, err } + if workspace.UID != "" && !workspaceOwnsPVC(&workspace, &pvc) { + if err := r.deleteOwnedSessionResources(ctx, &session); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, true, false, "WorkspaceReady", "PVCOwnershipConflict", "workspace PVC identity or ownership is not authoritative") + } + if pvcStorageClassName(&pvc) != host.Spec.StorageClassName { + if err := r.deleteOwnedSessionResources(ctx, &session); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, true, false, "WorkspaceReady", ReasonStorageClassMismatch, fmt.Sprintf("workspace PVC uses StorageClass %q instead of host-selected %q", pvcStorageClassName(&pvc), host.Spec.StorageClassName)) + } if pvc.Status.Phase != corev1.ClaimBound || !pvcHasRWX(&pvc) { - return ctrl.Result{RequeueAfter: 5 * time.Second}, r.updateSessionFailure(ctx, &session, "WorkspaceReady", "PVCNotBoundRWX", "workspace PVC must be Bound and ReadWriteMany before a session starts") + if err := r.deleteOwnedSessionResources(ctx, &session); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: 5 * time.Second}, r.updateSessionFailure(ctx, &session, true, false, "WorkspaceReady", "PVCNotBoundRWX", "workspace PVC must be Bound and ReadWriteMany before a session starts") } runtimeVersions, reason, message, err := r.loadOMPResourceVersions(ctx, session.Namespace) if err != nil { @@ -358,9 +407,20 @@ func (r *SessionReconciler) Reconcile(ctx context.Context, request ctrl.Request) if err := r.deleteOwnedSessionResources(ctx, &session); err != nil { return ctrl.Result{}, err } - return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, "RuntimeConfigured", reason, message) + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, true, true, "RuntimeConfigured", reason, message) + } + reason, message, err = r.authoritativePVCValidation(ctx, &workspace, &pvc, host.Spec.StorageClassName) + if err != nil { + return ctrl.Result{}, err + } + if reason != "" { + if err := r.deleteOwnedSessionResources(ctx, &session); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, true, false, "WorkspaceReady", reason, message) } + serviceName := SessionServiceName(&session) podName := SessionPodName(&session) labels := map[string]string{ @@ -380,17 +440,31 @@ func (r *SessionReconciler) Reconcile(ctx context.Context, request ctrl.Request) return ctrl.Result{}, err } var service corev1.Service - if err := r.Get(ctx, types.NamespacedName{Namespace: session.Namespace, Name: serviceName}, &service); apierrors.IsNotFound(err) { + serviceKey := types.NamespacedName{Namespace: session.Namespace, Name: serviceName} + if err := r.Get(ctx, serviceKey, &service); apierrors.IsNotFound(err) { service = desiredService - if err := r.Create(ctx, &service); err != nil && !apierrors.IsAlreadyExists(err) { - return ctrl.Result{}, err + if err := r.Create(ctx, &service); err != nil { + if !apierrors.IsAlreadyExists(err) { + return ctrl.Result{}, err + } + reader := r.APIReader + if reader == nil { + reader = r.Client + } + if err := reader.Get(ctx, serviceKey, &service); err != nil { + return ctrl.Result{}, err + } } } else if err != nil { return ctrl.Result{}, err - } else if !metav1.IsControlledBy(&service, &session) { - return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, "Available", "ServiceOwnershipConflict", "deterministic session Service is not controlled by this session") + } + if !sessionExclusivelyOwnsResource(&service, &session) { + if err := r.deleteOwnedSessionResourcesAfterVerifiedDependencies(ctx, &session, "ServiceOwnershipConflict", "deterministic session Service has an unexpected owner"); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } else if !serviceExposureIsInternal(&service) { - if err := r.Delete(ctx, &service); err != nil && !apierrors.IsNotFound(err) { + if err := deleteWithPreconditions(ctx, r.Client, &service); err != nil && !apierrors.IsNotFound(err) { return ctrl.Result{}, err } if err := r.updateSessionPending(ctx, &session, podName, serviceName, "ServiceExposureChanged", "session Service is being recreated with ClusterIP-only exposure"); err != nil { @@ -414,15 +488,39 @@ func (r *SessionReconciler) Reconcile(ctx context.Context, request ctrl.Request) return ctrl.Result{}, err } var pod corev1.Pod - if err := r.Get(ctx, types.NamespacedName{Namespace: session.Namespace, Name: podName}, &pod); apierrors.IsNotFound(err) { - pod = desiredPod - if err := r.Create(ctx, &pod); err != nil && !apierrors.IsAlreadyExists(err) { + podKey := types.NamespacedName{Namespace: session.Namespace, Name: podName} + if err := r.Get(ctx, podKey, &pod); apierrors.IsNotFound(err) { + reason, message, err := r.authoritativePVCValidation(ctx, &workspace, &pvc, host.Spec.StorageClassName) + if err != nil { return ctrl.Result{}, err } + if reason != "" { + if err := r.deleteOwnedSessionResources(ctx, &session); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, true, false, "WorkspaceReady", reason, message) + } + pod = desiredPod + if err := r.Create(ctx, &pod); err != nil { + if !apierrors.IsAlreadyExists(err) { + return ctrl.Result{}, err + } + reader := r.APIReader + if reader == nil { + reader = r.Client + } + if err := reader.Get(ctx, podKey, &pod); err != nil { + return ctrl.Result{}, err + } + } } else if err != nil { return ctrl.Result{}, err - } else if !metav1.IsControlledBy(&pod, &session) { - return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, "Available", "PodOwnershipConflict", "deterministic session Pod is not controlled by this session") + } + if !sessionExclusivelyOwnsResource(&pod, &session) { + if err := r.deleteOwnedSessionResourcesAfterVerifiedDependencies(ctx, &session, "PodOwnershipConflict", "deterministic session Pod has an unexpected owner"); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } else if !labelsContain(pod.Labels, desiredPod.Labels) { if pod.Labels == nil { pod.Labels = map[string]string{} @@ -438,7 +536,7 @@ func (r *SessionReconciler) Reconcile(ctx context.Context, request ctrl.Request) } return ctrl.Result{RequeueAfter: time.Second}, nil } else if pod.Annotations[clusterv1alpha1.SessionPodSpecHashAnnotation] != desiredPod.Annotations[clusterv1alpha1.SessionPodSpecHashAnnotation] { - if err := r.Delete(ctx, &pod); err != nil && !apierrors.IsNotFound(err) { + if err := deleteWithPreconditions(ctx, r.Client, &pod); err != nil && !apierrors.IsNotFound(err) { return ctrl.Result{}, err } if err := r.updateSessionPending(ctx, &session, podName, serviceName, "PodSpecChanged", "session Pod is being recreated to apply immutable desired state"); err != nil { @@ -447,6 +545,17 @@ func (r *SessionReconciler) Reconcile(ctx context.Context, request ctrl.Request) return ctrl.Result{RequeueAfter: time.Second}, nil } + reason, message, err = r.authoritativePVCValidation(ctx, &workspace, &pvc, host.Spec.StorageClassName) + if err != nil { + return ctrl.Result{}, err + } + if reason != "" { + if err := r.deleteOwnedSessionResources(ctx, &session); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, true, false, "WorkspaceReady", reason, message) + } + original := session.Status if session.Status.Conditions != nil { original.Conditions = append([]metav1.Condition(nil), session.Status.Conditions...) @@ -454,6 +563,7 @@ func (r *SessionReconciler) Reconcile(ctx context.Context, request ctrl.Request) session.Status.ObservedGeneration = session.Generation session.Status.PodName = podName session.Status.ServiceName = serviceName + meta.SetStatusCondition(&session.Status.Conditions, condition("HostReady", metav1.ConditionTrue, "HostResolved", "referenced T4ClusterHost is available", session.Generation)) meta.SetStatusCondition(&session.Status.Conditions, condition("WorkspaceReady", metav1.ConditionTrue, "PVCBoundRWX", "workspace PVC is Bound and ReadWriteMany", session.Generation)) meta.SetStatusCondition(&session.Status.Conditions, condition("RuntimeConfigured", metav1.ConditionTrue, "OMPReferencesReady", "administrator-owned OMP runtime references are configured", session.Generation)) if podReady(&pod) { @@ -477,6 +587,36 @@ func (r *SessionReconciler) Reconcile(ctx context.Context, request ctrl.Request) return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } +func (r *SessionReconciler) authoritativePVCValidation(ctx context.Context, workspace *clusterv1alpha1.T4Workspace, cachedPVC *corev1.PersistentVolumeClaim, storageClassName string) (string, string, error) { + reader := r.APIReader + if reader == nil { + reader = r.Client + } + var authoritativePVC corev1.PersistentVolumeClaim + if err := reader.Get(ctx, client.ObjectKeyFromObject(cachedPVC), &authoritativePVC); err != nil { + if apierrors.IsNotFound(err) { + return "PVCAuthorityChanged", "workspace PVC does not exist in authoritative API state", nil + } + return "", "", err + } + if authoritativePVC.UID != cachedPVC.UID { + return "PVCAuthorityChanged", "authoritative workspace PVC UID differs from the validated cached PVC", nil + } + if workspace.UID != "" && !workspaceOwnsPVC(workspace, &authoritativePVC) { + return "PVCAuthorityChanged", "authoritative workspace PVC owner reference does not belong to the workspace", nil + } + if pvcStorageClassName(&authoritativePVC) != storageClassName { + return "PVCAuthorityChanged", fmt.Sprintf("authoritative workspace PVC uses StorageClass %q instead of host-selected %q", pvcStorageClassName(&authoritativePVC), storageClassName), nil + } + if !pvcHasRWX(&authoritativePVC) { + return "PVCAuthorityChanged", "authoritative workspace PVC does not request ReadWriteMany", nil + } + if authoritativePVC.Status.Phase != corev1.ClaimBound { + return "PVCAuthorityChanged", "authoritative workspace PVC is not Bound", nil + } + return "", "", nil +} + func (r *SessionReconciler) desiredPod(session *clusterv1alpha1.T4Session, pvcName, podName string, labels map[string]string, runtimeVersions ompResourceVersions) (corev1.Pod, error) { falseValue := false trueValue := true @@ -609,6 +749,9 @@ func (r *SessionReconciler) updateSessionPending(ctx context.Context, session *c original.Conditions = append([]metav1.Condition(nil), session.Status.Conditions...) } session.Status.ObservedGeneration = session.Generation + meta.SetStatusCondition(&session.Status.Conditions, condition("HostReady", metav1.ConditionTrue, "HostResolved", "referenced T4ClusterHost is available", session.Generation)) + meta.SetStatusCondition(&session.Status.Conditions, condition("WorkspaceReady", metav1.ConditionTrue, "PVCBoundRWX", "workspace PVC is Bound and ReadWriteMany", session.Generation)) + meta.SetStatusCondition(&session.Status.Conditions, condition("RuntimeConfigured", metav1.ConditionTrue, "OMPReferencesReady", "administrator-owned OMP runtime references are configured", session.Generation)) session.Status.PodName = podName session.Status.ServiceName = serviceName session.Status.Phase = clusterv1alpha1.InfrastructurePending @@ -640,35 +783,46 @@ func (r *SessionReconciler) reconcileDelete(ctx context.Context, session *cluste &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: SessionServiceName(session), Namespace: session.Namespace}}, } existing := make([]client.Object, 0, len(objects)) + var ownershipConflict client.Object + reader := r.APIReader + if reader == nil { + reader = r.Client + } for _, object := range objects { - err := r.Get(ctx, client.ObjectKeyFromObject(object), object) + err := reader.Get(ctx, client.ObjectKeyFromObject(object), object) if apierrors.IsNotFound(err) { continue } if err != nil { return ctrl.Result{}, err } - if !metav1.IsControlledBy(object, session) { - before := session.Status - if session.Status.Conditions != nil { - before.Conditions = append([]metav1.Condition(nil), session.Status.Conditions...) - } - meta.SetStatusCondition(&session.Status.Conditions, condition("Available", metav1.ConditionFalse, "CleanupOwnershipConflict", fmt.Sprintf("deterministic %T is not controlled by this session", object), session.Generation)) - if !reflect.DeepEqual(before, session.Status) { - if err := r.Status().Update(ctx, session); err != nil { - return ctrl.Result{}, err - } + if !sessionExclusivelyOwnsResource(object, session) { + if ownershipConflict == nil { + ownershipConflict = object } - return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + continue } existing = append(existing, object) } for _, object := range existing { if object.GetDeletionTimestamp().IsZero() { - if err := r.Delete(ctx, object); err != nil && !apierrors.IsNotFound(err) { + if err := deleteWithPreconditions(ctx, r.Client, object); err != nil && !apierrors.IsNotFound(err) { + return ctrl.Result{}, err + } + } + } + if ownershipConflict != nil { + before := session.Status + if session.Status.Conditions != nil { + before.Conditions = append([]metav1.Condition(nil), session.Status.Conditions...) + } + meta.SetStatusCondition(&session.Status.Conditions, condition("Available", metav1.ConditionFalse, "CleanupOwnershipConflict", fmt.Sprintf("deterministic %T is not controlled by this session", ownershipConflict), session.Generation)) + if !reflect.DeepEqual(before, session.Status) { + if err := r.Status().Update(ctx, session); err != nil { return ctrl.Result{}, err } } + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } if len(existing) > 0 { return ctrl.Result{RequeueAfter: time.Second}, nil @@ -678,46 +832,122 @@ func (r *SessionReconciler) reconcileDelete(ctx context.Context, session *cluste } func (r *SessionReconciler) deleteOwnedSessionResources(ctx context.Context, session *clusterv1alpha1.T4Session) error { + reader := r.APIReader + if reader == nil { + reader = r.Client + } + return r.deleteOwnedSessionResourcesWithFailure(ctx, reader, session, true, false, false, "ResourceOwnershipConflict", "one or more deterministic session resources have an unexpected owner") +} + +func (r *SessionReconciler) deleteOwnedSessionResourcesAfterVerifiedDependencies(ctx context.Context, session *clusterv1alpha1.T4Session, reason, message string) error { + reader := r.APIReader + if reader == nil { + reader = r.Client + } + return r.deleteOwnedSessionResourcesWithFailure(ctx, reader, session, false, true, true, reason, message) +} + +func (r *SessionReconciler) deleteOwnedSessionResourcesWithFailure(ctx context.Context, reader client.Reader, session *clusterv1alpha1.T4Session, deleteWithoutConflict, hostReady, workspaceReady bool, reason, message string) error { objects := []client.Object{ &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: SessionPodName(session), Namespace: session.Namespace}}, &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: SessionServiceName(session), Namespace: session.Namespace}}, } + owned := make([]client.Object, 0, len(objects)) + ownershipConflict := false for _, object := range objects { - if err := r.Get(ctx, client.ObjectKeyFromObject(object), object); err != nil { + if err := reader.Get(ctx, client.ObjectKeyFromObject(object), object); err != nil { if err := client.IgnoreNotFound(err); err != nil { return err } continue } - if !metav1.IsControlledBy(object, session) { + if !sessionExclusivelyOwnsResource(object, session) { + ownershipConflict = true continue } - if err := r.Delete(ctx, object); err != nil && !apierrors.IsNotFound(err) { + owned = append(owned, object) + } + if ownershipConflict || deleteWithoutConflict { + for _, object := range owned { + if err := deleteWithPreconditions(ctx, r.Client, object); err != nil && !apierrors.IsNotFound(err) { + return err + } + } + } + if ownershipConflict { + if err := r.updateSessionFailure(ctx, session, hostReady, workspaceReady, "Available", reason, message); err != nil { return err } + return errSessionResourceOwnershipConflict } return nil } -func (r *SessionReconciler) updateSessionFailure(ctx context.Context, session *clusterv1alpha1.T4Session, conditionType, reason, message string) error { +func deleteWithPreconditions(ctx context.Context, writer client.Client, object client.Object) error { + preconditions := metav1.Preconditions{} + if uid := object.GetUID(); uid != "" { + preconditions.UID = &uid + } + if resourceVersion := object.GetResourceVersion(); resourceVersion != "" { + preconditions.ResourceVersion = &resourceVersion + } + options := &client.DeleteOptions{} + if preconditions.UID != nil || preconditions.ResourceVersion != nil { + options.Preconditions = &preconditions + } + return writer.Delete(ctx, object, options) +} + +func (r *SessionReconciler) updateSessionFailure(ctx context.Context, session *clusterv1alpha1.T4Session, hostReady, workspaceReady bool, conditionType, reason, message string) error { original := session.Status if session.Status.Conditions != nil { original.Conditions = append([]metav1.Condition(nil), session.Status.Conditions...) } + if conditionType == "HostReady" { + meta.SetStatusCondition(&session.Status.Conditions, condition("HostReady", metav1.ConditionFalse, reason, message, session.Generation)) + } else if hostReady { + meta.SetStatusCondition(&session.Status.Conditions, condition("HostReady", metav1.ConditionTrue, "HostResolved", "referenced T4ClusterHost is available", session.Generation)) + } else { + meta.SetStatusCondition(&session.Status.Conditions, condition("HostReady", metav1.ConditionUnknown, "NotEvaluated", "host dependency was not evaluated", session.Generation)) + } + if conditionType == "WorkspaceReady" { + meta.SetStatusCondition(&session.Status.Conditions, condition("WorkspaceReady", metav1.ConditionFalse, reason, message, session.Generation)) + } else if workspaceReady { + meta.SetStatusCondition(&session.Status.Conditions, condition("WorkspaceReady", metav1.ConditionTrue, "PVCBoundRWX", "workspace PVC is Bound and ReadWriteMany", session.Generation)) + } else { + meta.SetStatusCondition(&session.Status.Conditions, condition("WorkspaceReady", metav1.ConditionUnknown, "NotEvaluated", "workspace dependency was not evaluated", session.Generation)) + } + if conditionType == "RuntimeConfigured" { + meta.SetStatusCondition(&session.Status.Conditions, condition("RuntimeConfigured", metav1.ConditionFalse, reason, message, session.Generation)) + } else if workspaceReady { + meta.SetStatusCondition(&session.Status.Conditions, condition("RuntimeConfigured", metav1.ConditionTrue, "OMPReferencesReady", "administrator-owned OMP runtime references are configured", session.Generation)) + } else { + meta.SetStatusCondition(&session.Status.Conditions, condition("RuntimeConfigured", metav1.ConditionUnknown, "NotEvaluated", "runtime configuration was not evaluated", session.Generation)) + } session.Status.ObservedGeneration = session.Generation session.Status.PodName = "" session.Status.ServiceName = "" session.Status.Phase = clusterv1alpha1.InfrastructureFailed - meta.SetStatusCondition(&session.Status.Conditions, condition(conditionType, metav1.ConditionFalse, reason, message, session.Generation)) - if conditionType != "Available" { - meta.SetStatusCondition(&session.Status.Conditions, condition("Available", metav1.ConditionFalse, reason, message, session.Generation)) - } + meta.SetStatusCondition(&session.Status.Conditions, condition("Available", metav1.ConditionFalse, reason, message, session.Generation)) if reflect.DeepEqual(original, session.Status) { return nil } return r.Status().Update(ctx, session) } +func sessionExclusivelyOwnsResource(object metav1.Object, session *clusterv1alpha1.T4Session) bool { + controller := metav1.GetControllerOf(object) + if controller == nil || controller.APIVersion != clusterv1alpha1.GroupVersion.String() || controller.Kind != "T4Session" || controller.Name != session.Name || controller.UID != session.UID { + return false + } + for _, reference := range object.GetOwnerReferences() { + if reference.APIVersion != clusterv1alpha1.GroupVersion.String() || reference.Kind != "T4Session" || reference.Name != session.Name || reference.UID != session.UID { + return false + } + } + return true +} + func serviceExposureIsInternal(service *corev1.Service) bool { if service.Spec.Type != corev1.ServiceTypeClusterIP || service.Spec.ClusterIP == corev1.ClusterIPNone || service.Spec.ExternalName != "" || len(service.Spec.ExternalIPs) != 0 || service.Spec.LoadBalancerIP != "" || len(service.Spec.LoadBalancerSourceRanges) != 0 || @@ -741,9 +971,62 @@ func labelsContain(actual, required map[string]string) bool { return true } +func indexSessionByHostRef(object client.Object) []string { + session, ok := object.(*clusterv1alpha1.T4Session) + if !ok || session.Spec.HostRef == "" { + return nil + } + return []string{session.Spec.HostRef} +} + +func indexSessionByWorkspaceRef(object client.Object) []string { + session, ok := object.(*clusterv1alpha1.T4Session) + if !ok || session.Spec.WorkspaceRef == "" { + return nil + } + return []string{session.Spec.WorkspaceRef} +} + +func (r *SessionReconciler) sessionRequestsForHost(ctx context.Context, object client.Object) []ctrl.Request { + host, ok := object.(*clusterv1alpha1.T4ClusterHost) + if !ok || host.Name == "" || host.Namespace == "" { + return nil + } + return r.sessionRequestsForReference(ctx, host.Namespace, sessionHostRefIndexField, host.Name, "clusterHost", client.ObjectKeyFromObject(host)) +} + +func (r *SessionReconciler) sessionRequestsForWorkspace(ctx context.Context, object client.Object) []ctrl.Request { + workspace, ok := object.(*clusterv1alpha1.T4Workspace) + if !ok || workspace.Name == "" || workspace.Namespace == "" { + return nil + } + return r.sessionRequestsForReference(ctx, workspace.Namespace, sessionWorkspaceRefIndexField, workspace.Name, "workspace", client.ObjectKeyFromObject(workspace)) +} + +func (r *SessionReconciler) sessionRequestsForReference(ctx context.Context, namespace, field, value, dependencyKind string, dependencyKey types.NamespacedName) []ctrl.Request { + var sessions clusterv1alpha1.T4SessionList + if err := r.List(ctx, &sessions, client.InNamespace(namespace), client.MatchingFields{field: value}); err != nil { + ctrl.LoggerFrom(ctx).Error(err, "unable to map dependency to sessions", dependencyKind, dependencyKey) + return nil + } + requests := make([]ctrl.Request, 0, len(sessions.Items)) + for i := range sessions.Items { + requests = append(requests, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&sessions.Items[i])}) + } + return requests +} + func (r *SessionReconciler) SetupWithManager(manager ctrl.Manager) error { + if err := manager.GetFieldIndexer().IndexField(context.Background(), &clusterv1alpha1.T4Session{}, sessionHostRefIndexField, indexSessionByHostRef); err != nil { + return fmt.Errorf("index T4Session by host reference: %w", err) + } + if err := manager.GetFieldIndexer().IndexField(context.Background(), &clusterv1alpha1.T4Session{}, sessionWorkspaceRefIndexField, indexSessionByWorkspaceRef); err != nil { + return fmt.Errorf("index T4Session by workspace reference: %w", err) + } return ctrl.NewControllerManagedBy(manager). For(&clusterv1alpha1.T4Session{}). + Watches(&clusterv1alpha1.T4ClusterHost{}, handler.EnqueueRequestsFromMapFunc(r.sessionRequestsForHost)). + Watches(&clusterv1alpha1.T4Workspace{}, handler.EnqueueRequestsFromMapFunc(r.sessionRequestsForWorkspace)). Owns(&corev1.Pod{}). Owns(&corev1.Service{}). Complete(r) diff --git a/packages/cluster-operator/controllers/session_controller_test.go b/packages/cluster-operator/controllers/session_controller_test.go new file mode 100644 index 00000000..cba15fef --- /dev/null +++ b/packages/cluster-operator/controllers/session_controller_test.go @@ -0,0 +1,62 @@ +package controllers + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + clusterv1alpha1 "github.com/LycaonLLC/t4-code/packages/cluster-operator/api/v1alpha1" +) + +func TestSessionRequestsForHostOnlyEnqueuesAffectedSessions(t *testing.T) { + scheme := runtime.NewScheme() + if err := clusterv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + objects := []client.Object{ + &clusterv1alpha1.T4Session{ObjectMeta: metav1.ObjectMeta{Name: "session-a", Namespace: "team"}, Spec: clusterv1alpha1.T4SessionSpec{HostRef: "host-a", WorkspaceRef: "workspace-a"}}, + &clusterv1alpha1.T4Session{ObjectMeta: metav1.ObjectMeta{Name: "session-b", Namespace: "team"}, Spec: clusterv1alpha1.T4SessionSpec{HostRef: "host-a", WorkspaceRef: "workspace-b"}}, + &clusterv1alpha1.T4Session{ObjectMeta: metav1.ObjectMeta{Name: "other-host", Namespace: "team"}, Spec: clusterv1alpha1.T4SessionSpec{HostRef: "host-b", WorkspaceRef: "workspace-a"}}, + &clusterv1alpha1.T4Session{ObjectMeta: metav1.ObjectMeta{Name: "other-namespace", Namespace: "other"}, Spec: clusterv1alpha1.T4SessionSpec{HostRef: "host-a", WorkspaceRef: "workspace-a"}}, + } + c := fake.NewClientBuilder().WithScheme(scheme). + WithIndex(&clusterv1alpha1.T4Session{}, sessionHostRefIndexField, indexSessionByHostRef). + WithIndex(&clusterv1alpha1.T4Session{}, sessionWorkspaceRefIndexField, indexSessionByWorkspaceRef). + WithObjects(objects...).Build() + r := &SessionReconciler{Client: c, Scheme: scheme} + + requests := r.sessionRequestsForHost(context.Background(), &clusterv1alpha1.T4ClusterHost{ObjectMeta: metav1.ObjectMeta{Name: "host-a", Namespace: "team"}}) + assertRequestSet(t, requests, []types.NamespacedName{ + {Namespace: "team", Name: "session-a"}, + {Namespace: "team", Name: "session-b"}, + }) +} + +func TestSessionRequestsForWorkspaceOnlyEnqueuesAffectedSessions(t *testing.T) { + scheme := runtime.NewScheme() + if err := clusterv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + objects := []client.Object{ + &clusterv1alpha1.T4Session{ObjectMeta: metav1.ObjectMeta{Name: "session-a", Namespace: "team"}, Spec: clusterv1alpha1.T4SessionSpec{HostRef: "host-a", WorkspaceRef: "workspace-a"}}, + &clusterv1alpha1.T4Session{ObjectMeta: metav1.ObjectMeta{Name: "session-b", Namespace: "team"}, Spec: clusterv1alpha1.T4SessionSpec{HostRef: "host-b", WorkspaceRef: "workspace-a"}}, + &clusterv1alpha1.T4Session{ObjectMeta: metav1.ObjectMeta{Name: "other-workspace", Namespace: "team"}, Spec: clusterv1alpha1.T4SessionSpec{HostRef: "host-a", WorkspaceRef: "workspace-b"}}, + &clusterv1alpha1.T4Session{ObjectMeta: metav1.ObjectMeta{Name: "other-namespace", Namespace: "other"}, Spec: clusterv1alpha1.T4SessionSpec{HostRef: "host-a", WorkspaceRef: "workspace-a"}}, + } + c := fake.NewClientBuilder().WithScheme(scheme). + WithIndex(&clusterv1alpha1.T4Session{}, sessionHostRefIndexField, indexSessionByHostRef). + WithIndex(&clusterv1alpha1.T4Session{}, sessionWorkspaceRefIndexField, indexSessionByWorkspaceRef). + WithObjects(objects...).Build() + r := &SessionReconciler{Client: c, Scheme: scheme} + + requests := r.sessionRequestsForWorkspace(context.Background(), &clusterv1alpha1.T4Workspace{ObjectMeta: metav1.ObjectMeta{Name: "workspace-a", Namespace: "team"}}) + assertRequestSet(t, requests, []types.NamespacedName{ + {Namespace: "team", Name: "session-a"}, + {Namespace: "team", Name: "session-b"}, + }) +} diff --git a/packages/cluster-operator/controllers/workspace_controller.go b/packages/cluster-operator/controllers/workspace_controller.go index fbc60f45..e1b616dc 100644 --- a/packages/cluster-operator/controllers/workspace_controller.go +++ b/packages/cluster-operator/controllers/workspace_controller.go @@ -10,6 +10,7 @@ import ( storagev1 "k8s.io/api/storage/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" meta "k8s.io/apimachinery/pkg/api/meta" + apiresource "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -23,7 +24,8 @@ import ( type WorkspaceReconciler struct { client.Client - Scheme *runtime.Scheme + APIReader client.Reader + Scheme *runtime.Scheme } const ( @@ -73,8 +75,9 @@ func (r *WorkspaceReconciler) Reconcile(ctx context.Context, request ctrl.Reques } pvcName := WorkspacePVCName(&workspace) + pvcKey := types.NamespacedName{Namespace: workspace.Namespace, Name: pvcName} var pvc corev1.PersistentVolumeClaim - err = r.Get(ctx, types.NamespacedName{Namespace: workspace.Namespace, Name: pvcName}, &pvc) + err = r.Get(ctx, pvcKey, &pvc) if apierrors.IsNotFound(err) { volumeMode := corev1.PersistentVolumeFilesystem pvc = corev1.PersistentVolumeClaim{ @@ -98,13 +101,27 @@ func (r *WorkspaceReconciler) Reconcile(ctx context.Context, request ctrl.Reques return ctrl.Result{}, err } } - if err := r.Create(ctx, &pvc); err != nil && !apierrors.IsAlreadyExists(err) { - return ctrl.Result{}, err + if err := r.Create(ctx, &pvc); err != nil { + if !apierrors.IsAlreadyExists(err) { + return ctrl.Result{}, err + } + reader := r.APIReader + if reader == nil { + reader = r.Client + } + if err := reader.Get(ctx, pvcKey, &pvc); err != nil { + return ctrl.Result{}, err + } } } else if err != nil { return ctrl.Result{}, err - } else if !workspaceOwnsPVC(&workspace, &pvc) { + } + if !workspaceOwnsPVC(&workspace, &pvc) { return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateWorkspaceFailure(ctx, &workspace, "StorageReady", "PVCOwnershipConflict", "deterministic workspace PVC does not belong to this workspace") + } else if !pvcHasRWX(&pvc) { + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateWorkspaceFailure(ctx, &workspace, "StorageReady", "PVCNotRWX", "workspace PVC does not request ReadWriteMany") + } else if pvcStorageClassName(&pvc) != storageClassName { + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateWorkspaceFailure(ctx, &workspace, "StorageReady", ReasonStorageClassMismatch, fmt.Sprintf("workspace PVC uses StorageClass %q instead of host-selected %q; data-bearing PVCs are never recreated automatically", pvcStorageClassName(&pvc), storageClassName)) } else if workspace.Spec.RetentionPolicy == clusterv1alpha1.RetentionPolicyRetain && metav1.IsControlledBy(&pvc, &workspace) { before := pvc.DeepCopy() pvc.OwnerReferences = removeWorkspaceOwnerReference(pvc.OwnerReferences, workspace.UID) @@ -115,6 +132,31 @@ func (r *WorkspaceReconciler) Reconcile(ctx context.Context, request ctrl.Reques return ctrl.Result{Requeue: true}, nil } } + reader := r.APIReader + if reader == nil { + reader = r.Client + } + var authoritativePVC corev1.PersistentVolumeClaim + if err := reader.Get(ctx, pvcKey, &authoritativePVC); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{RequeueAfter: 5 * time.Second}, r.updateWorkspaceFailure(ctx, &workspace, "StorageReady", "PVCNotFound", "workspace PVC does not exist in authoritative API state") + } + return ctrl.Result{}, err + } + if authoritativePVC.UID != pvc.UID || !workspaceOwnsPVC(&workspace, &authoritativePVC) { + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateWorkspaceFailure(ctx, &workspace, "StorageReady", "PVCOwnershipConflict", "authoritative workspace PVC identity or ownership does not belong to this workspace") + } + if !pvcHasRWX(&authoritativePVC) { + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateWorkspaceFailure(ctx, &workspace, "StorageReady", "PVCNotRWX", "authoritative workspace PVC does not request ReadWriteMany") + } + if pvcStorageClassName(&authoritativePVC) != storageClassName { + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateWorkspaceFailure(ctx, &workspace, "StorageReady", ReasonStorageClassMismatch, fmt.Sprintf("authoritative workspace PVC uses StorageClass %q instead of host-selected %q; data-bearing PVCs are never recreated automatically", pvcStorageClassName(&authoritativePVC), storageClassName)) + } + pvc = authoritativePVC + + if pvc.Status.Phase == corev1.ClaimLost { + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateWorkspaceFailure(ctx, &workspace, "StorageReady", "PVCLost", "workspace PVC lost its volume") + } original := workspace.Status original.Capacity = workspace.Status.Capacity.DeepCopy() @@ -126,19 +168,12 @@ func (r *WorkspaceReconciler) Reconcile(ctx context.Context, request ctrl.Reques workspace.Status.PVCPhase = pvc.Status.Phase capacity := pvc.Status.Capacity[corev1.ResourceStorage] workspace.Status.Capacity = capacity.DeepCopy() + meta.SetStatusCondition(&workspace.Status.Conditions, condition("HostReady", metav1.ConditionTrue, "HostResolved", "referenced T4ClusterHost is available", workspace.Generation)) meta.SetStatusCondition(&workspace.Status.Conditions, condition("StorageReady", metav1.ConditionTrue, ReasonStorageReady, "RWX StorageClass and workspace PVC are accepted", workspace.Generation)) switch pvc.Status.Phase { case corev1.ClaimBound: - if !pvcHasRWX(&pvc) { - workspace.Status.Phase = clusterv1alpha1.InfrastructureFailed - meta.SetStatusCondition(&workspace.Status.Conditions, condition("Ready", metav1.ConditionFalse, "PVCNotRWX", "bound workspace PVC does not request ReadWriteMany", workspace.Generation)) - } else { - workspace.Status.Phase = clusterv1alpha1.InfrastructureReady - meta.SetStatusCondition(&workspace.Status.Conditions, condition("Ready", metav1.ConditionTrue, "PVCBound", "workspace PVC is bound with ReadWriteMany access", workspace.Generation)) - } - case corev1.ClaimLost: - workspace.Status.Phase = clusterv1alpha1.InfrastructureFailed - meta.SetStatusCondition(&workspace.Status.Conditions, condition("Ready", metav1.ConditionFalse, "PVCLost", "workspace PVC lost its volume", workspace.Generation)) + workspace.Status.Phase = clusterv1alpha1.InfrastructureReady + meta.SetStatusCondition(&workspace.Status.Conditions, condition("Ready", metav1.ConditionTrue, "PVCBound", "workspace PVC is bound with ReadWriteMany access", workspace.Generation)) default: workspace.Status.Phase = clusterv1alpha1.InfrastructurePending meta.SetStatusCondition(&workspace.Status.Conditions, condition("Ready", metav1.ConditionFalse, "PVCBinding", "workspace PVC is waiting to bind", workspace.Generation)) @@ -158,6 +193,11 @@ func workspaceOwnsPVC(workspace *clusterv1alpha1.T4Workspace, pvc *corev1.Persis if pvc.Annotations[clusterv1alpha1.WorkspaceUIDAnnotation] != string(workspace.UID) { return false } + for _, reference := range pvc.OwnerReferences { + if reference.APIVersion != clusterv1alpha1.GroupVersion.String() || reference.Kind != "T4Workspace" || reference.Name != workspace.Name || reference.UID != workspace.UID { + return false + } + } controller := metav1.GetControllerOf(pvc) if workspace.Spec.RetentionPolicy == clusterv1alpha1.RetentionPolicyDelete { return controller != nil && controller.UID == workspace.UID @@ -192,7 +232,11 @@ func (r *WorkspaceReconciler) reconcileDelete(ctx context.Context, workspace *cl } } var sessions clusterv1alpha1.T4SessionList - if err := r.List(ctx, &sessions, client.InNamespace(workspace.Namespace)); err != nil { + sessionReader := r.APIReader + if sessionReader == nil { + sessionReader = r.Client + } + if err := sessionReader.List(ctx, &sessions, client.InNamespace(workspace.Namespace)); err != nil { return ctrl.Result{}, err } remainingSessions := 0 @@ -216,7 +260,11 @@ func (r *WorkspaceReconciler) reconcileDelete(ctx context.Context, workspace *cl } pvcKey := types.NamespacedName{Namespace: workspace.Namespace, Name: WorkspacePVCName(workspace)} var pvc corev1.PersistentVolumeClaim - err := r.Get(ctx, pvcKey, &pvc) + reader := r.APIReader + if reader == nil { + reader = r.Client + } + err := reader.Get(ctx, pvcKey, &pvc) if err == nil && !workspaceOwnsPVC(workspace, &pvc) { before := workspace.Status if workspace.Status.Conditions != nil { @@ -248,7 +296,7 @@ func (r *WorkspaceReconciler) reconcileDelete(ctx context.Context, workspace *cl } } else { if err == nil { - if err := r.Delete(ctx, &pvc); err != nil && !apierrors.IsNotFound(err) { + if err := deleteWithPreconditions(ctx, r.Client, &pvc); err != nil && !apierrors.IsNotFound(err) { return ctrl.Result{}, err } return ctrl.Result{RequeueAfter: time.Second}, nil @@ -268,11 +316,18 @@ func (r *WorkspaceReconciler) updateWorkspaceFailure(ctx context.Context, worksp original.Conditions = append([]metav1.Condition(nil), workspace.Status.Conditions...) } workspace.Status.ObservedGeneration = workspace.Generation + workspace.Status.PVCName = "" + workspace.Status.PVCPhase = "" + workspace.Status.Capacity = apiresource.Quantity{} workspace.Status.Phase = clusterv1alpha1.InfrastructureFailed - meta.SetStatusCondition(&workspace.Status.Conditions, condition(conditionType, metav1.ConditionFalse, reason, message, workspace.Generation)) - if conditionType != "Ready" { - meta.SetStatusCondition(&workspace.Status.Conditions, condition("Ready", metav1.ConditionFalse, reason, message, workspace.Generation)) + if conditionType == "HostReady" { + meta.SetStatusCondition(&workspace.Status.Conditions, condition("HostReady", metav1.ConditionFalse, reason, message, workspace.Generation)) + meta.SetStatusCondition(&workspace.Status.Conditions, condition("StorageReady", metav1.ConditionUnknown, "NotEvaluated", "storage dependency was not evaluated because the referenced host is unavailable", workspace.Generation)) + } else { + meta.SetStatusCondition(&workspace.Status.Conditions, condition("HostReady", metav1.ConditionTrue, "HostResolved", "referenced T4ClusterHost is available", workspace.Generation)) + meta.SetStatusCondition(&workspace.Status.Conditions, condition("StorageReady", metav1.ConditionFalse, reason, message, workspace.Generation)) } + meta.SetStatusCondition(&workspace.Status.Conditions, condition("Ready", metav1.ConditionFalse, reason, message, workspace.Generation)) if reflect.DeepEqual(original, workspace.Status) { return nil } @@ -337,6 +392,23 @@ func (r *WorkspaceReconciler) workspaceRequestsForStorageClass(ctx context.Conte return requests } +func (r *WorkspaceReconciler) workspaceRequestsForHost(ctx context.Context, object client.Object) []ctrl.Request { + host, ok := object.(*clusterv1alpha1.T4ClusterHost) + if !ok || host.Name == "" || host.Namespace == "" { + return nil + } + var workspaces clusterv1alpha1.T4WorkspaceList + if err := r.List(ctx, &workspaces, client.InNamespace(host.Namespace), client.MatchingFields{workspaceHostRefIndexField: host.Name}); err != nil { + ctrl.LoggerFrom(ctx).Error(err, "unable to map cluster host to workspaces", "clusterHost", client.ObjectKeyFromObject(host)) + return nil + } + requests := make([]ctrl.Request, 0, len(workspaces.Items)) + for i := range workspaces.Items { + requests = append(requests, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&workspaces.Items[i])}) + } + return requests +} + func (r *WorkspaceReconciler) SetupWithManager(manager ctrl.Manager) error { if err := manager.GetFieldIndexer().IndexField(context.Background(), &clusterv1alpha1.T4ClusterHost{}, hostStorageClassIndexField, indexHostByStorageClass); err != nil { return fmt.Errorf("index T4ClusterHost by StorageClass: %w", err) @@ -346,6 +418,7 @@ func (r *WorkspaceReconciler) SetupWithManager(manager ctrl.Manager) error { } return ctrl.NewControllerManagedBy(manager). For(&clusterv1alpha1.T4Workspace{}). + Watches(&clusterv1alpha1.T4ClusterHost{}, handler.EnqueueRequestsFromMapFunc(r.workspaceRequestsForHost)). Watches(&corev1.PersistentVolumeClaim{}, handler.EnqueueRequestsFromMapFunc(workspaceRequestsForPVC)). Watches(&storagev1.StorageClass{}, handler.EnqueueRequestsFromMapFunc(r.workspaceRequestsForStorageClass)). Complete(r) diff --git a/packages/cluster-operator/controllers/workspace_controller_test.go b/packages/cluster-operator/controllers/workspace_controller_test.go index 412705d0..27d07759 100644 --- a/packages/cluster-operator/controllers/workspace_controller_test.go +++ b/packages/cluster-operator/controllers/workspace_controller_test.go @@ -9,6 +9,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -107,3 +108,42 @@ func TestWorkspaceRequestsForStorageClassOnlyEnqueuesAffectedWorkspaces(t *testi } } } + +func TestWorkspaceRequestsForHostOnlyEnqueuesAffectedWorkspaces(t *testing.T) { + scheme := runtime.NewScheme() + if err := clusterv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + objects := []client.Object{ + &clusterv1alpha1.T4Workspace{ObjectMeta: metav1.ObjectMeta{Name: "workspace-a", Namespace: "team"}, Spec: clusterv1alpha1.T4WorkspaceSpec{HostRef: "host-a"}}, + &clusterv1alpha1.T4Workspace{ObjectMeta: metav1.ObjectMeta{Name: "workspace-b", Namespace: "team"}, Spec: clusterv1alpha1.T4WorkspaceSpec{HostRef: "host-a"}}, + &clusterv1alpha1.T4Workspace{ObjectMeta: metav1.ObjectMeta{Name: "other-host", Namespace: "team"}, Spec: clusterv1alpha1.T4WorkspaceSpec{HostRef: "host-b"}}, + &clusterv1alpha1.T4Workspace{ObjectMeta: metav1.ObjectMeta{Name: "other-namespace", Namespace: "other"}, Spec: clusterv1alpha1.T4WorkspaceSpec{HostRef: "host-a"}}, + } + c := fake.NewClientBuilder().WithScheme(scheme). + WithIndex(&clusterv1alpha1.T4Workspace{}, workspaceHostRefIndexField, indexWorkspaceByHostRef). + WithObjects(objects...).Build() + r := &WorkspaceReconciler{Client: c, Scheme: scheme} + + requests := r.workspaceRequestsForHost(context.Background(), &clusterv1alpha1.T4ClusterHost{ObjectMeta: metav1.ObjectMeta{Name: "host-a", Namespace: "team"}}) + assertRequestSet(t, requests, []types.NamespacedName{ + {Namespace: "team", Name: "workspace-a"}, + {Namespace: "team", Name: "workspace-b"}, + }) +} + +func assertRequestSet(t *testing.T, requests []ctrl.Request, want []types.NamespacedName) { + t.Helper() + got := make(map[types.NamespacedName]int, len(requests)) + for _, request := range requests { + got[request.NamespacedName]++ + } + if len(got) != len(want) { + t.Fatalf("requests = %#v, want exactly %v", requests, want) + } + for _, key := range want { + if got[key] != 1 { + t.Fatalf("requests = %#v, want %v exactly once", requests, key) + } + } +} diff --git a/packages/cluster-operator/go.mod b/packages/cluster-operator/go.mod index c85639c9..f4504858 100644 --- a/packages/cluster-operator/go.mod +++ b/packages/cluster-operator/go.mod @@ -14,23 +14,32 @@ require ( ) require ( + cel.dev/expr v0.19.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/btree v1.1.3 // indirect + github.com/google/cel-go v0.23.2 // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect @@ -41,10 +50,22 @@ require ( github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect + github.com/spf13/cobra v1.8.1 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect + go.opentelemetry.io/otel v1.33.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 // indirect + go.opentelemetry.io/otel/metric v1.33.0 // indirect + go.opentelemetry.io/otel/sdk v1.33.0 // indirect + go.opentelemetry.io/otel/trace v1.33.0 // indirect + go.opentelemetry.io/proto/otlp v1.4.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect golang.org/x/sync v0.12.0 // indirect @@ -53,12 +74,19 @@ require ( golang.org/x/text v0.23.0 // indirect golang.org/x/time v0.9.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect + google.golang.org/grpc v1.68.1 // indirect google.golang.org/protobuf v1.36.5 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiserver v0.33.2 // indirect + k8s.io/component-base v0.33.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect diff --git a/packages/cluster-operator/go.sum b/packages/cluster-operator/go.sum index e291ac10..6883ecd7 100644 --- a/packages/cluster-operator/go.sum +++ b/packages/cluster-operator/go.sum @@ -1,9 +1,16 @@ +cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4= +cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -14,12 +21,17 @@ github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8 github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= @@ -36,6 +48,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/cel-go v0.23.2 h1:UdEe3CvQh3Nv+E/j9r1Y//WO0K0cSyD7/y0bzyLIMI4= +github.com/google/cel-go v0.23.2/go.mod h1:52Pb6QsDbC5kvgxvZhiL9QX1oZEkcUF/ZqaPx1J5Wwo= github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -48,6 +62,11 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -92,8 +111,13 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -109,6 +133,24 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= +go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= +go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= +go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= +go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= +go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= +go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= +go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= +go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= +go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= +go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -118,6 +160,8 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -158,6 +202,13 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 h1:KAeGQVN3M9nD0/bQXnr/ClcEMJ968gUXJQ9pwfSynuQ= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= +google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= +google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -176,14 +227,20 @@ k8s.io/apiextensions-apiserver v0.33.2 h1:6gnkIbngnaUflR3XwE1mCefN3YS8yTD631JXQh k8s.io/apiextensions-apiserver v0.33.2/go.mod h1:IvVanieYsEHJImTKXGP6XCOjTwv2LUMos0YWc9O+QP8= k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/apiserver v0.33.2 h1:KGTRbxn2wJagJowo29kKBp4TchpO1DRO3g+dB/KOJN4= +k8s.io/apiserver v0.33.2/go.mod h1:9qday04wEAMLPWWo9AwqCZSiIn3OYSZacDyu/AcoM/M= k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E= k8s.io/client-go v0.33.2/go.mod h1:9mCgT4wROvL948w6f6ArJNb7yQd7QsvqavDeZHvNmHo= +k8s.io/component-base v0.33.2 h1:sCCsn9s/dG3ZrQTX/Us0/Sx2R0G5kwa0wbZFYoVp/+0= +k8s.io/component-base v0.33.2/go.mod h1:/41uw9wKzuelhN+u+/C59ixxf4tYQKW7p32ddkYNe2k= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= diff --git a/packages/cluster-server/package.json b/packages/cluster-server/package.json index 7b7b3a24..bb7e6951 100644 --- a/packages/cluster-server/package.json +++ b/packages/cluster-server/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/cluster-server", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "description": "Stateless Kubernetes-backed omp-app/1 cluster gateway", diff --git a/packages/cluster-server/src/gateway.ts b/packages/cluster-server/src/gateway.ts index 76b3f5e0..149ec632 100644 --- a/packages/cluster-server/src/gateway.ts +++ b/packages/cluster-server/src/gateway.ts @@ -102,7 +102,7 @@ export class ClusterGateway { this.#connector = options.connector; this.#mutations = options.mutations; this.#ci = options.ciProvider; - this.#version = options.appserverVersion ?? "0.1.30"; + this.#version = options.appserverVersion ?? "0.1.31"; this.#build = options.appserverBuild ?? "cluster"; } get connectionCount(): number { return this.#connections.size; } @@ -265,7 +265,7 @@ export class ClusterGateway { grantedFeatures = new Set(GATEWAY_FEATURES.filter(feature => requestedFeatures.has(feature) && (feature !== CLUSTER_OPERATOR_FEATURE || operatorEnabled))); client.send({ v: "omp-app/1", type: "welcome", selectedProtocol: "omp-app/1", hostId: this.#projection.hostId, - ompVersion: "17.0.5", ompBuild: "8476f4451ed95c5d5401785d279a93d3c659fac4", + ompVersion: "17.0.5", ompBuild: "fc0c391334c08ab260057756aa84bd2b07741ee7", appserverVersion: this.#version, appserverBuild: this.#build, epoch: this.#projection.epoch, grantedCapabilities: [...grantedCapabilities], grantedFeatures: [...grantedFeatures], negotiatedLimits: { maxPayloadLength: 1_048_576, maxWorkspaces: 256, maxSessions: 1_000, workspaceReplayFrames: 512 }, diff --git a/packages/cluster-server/src/main.ts b/packages/cluster-server/src/main.ts index 0ac12d7f..01dee33d 100755 --- a/packages/cluster-server/src/main.ts +++ b/packages/cluster-server/src/main.ts @@ -13,9 +13,9 @@ import { WoodpeckerProvider } from "./woodpecker.ts"; export async function runClusterServer(env: Readonly> = process.env): Promise { const config = clusterServerConfigFromEnv(env); - const logger = new JsonLogger(undefined, { component: "cluster-server", version: "0.1.30", namespace: config.namespace }); + const logger = new JsonLogger(undefined, { component: "cluster-server", version: "0.1.31", namespace: config.namespace }); const health = new ClusterServerHealth(); - const metrics = new ClusterMetrics({ component: "cluster-server", version: "0.1.30", namespace: config.namespace }); + const metrics = new ClusterMetrics({ component: "cluster-server", version: "0.1.31", namespace: config.namespace }); const ca = await loadKubernetesCa(config); const kubernetes = new KubernetesApiClient({ baseUrl: config.kubernetesBaseUrl, @@ -109,7 +109,7 @@ export async function runClusterServer(env: Readonly { try { await runClusterServer(); } catch (error) { - const logger = new JsonLogger(undefined, { component: "cluster-server", version: "0.1.30" }); + const logger = new JsonLogger(undefined, { component: "cluster-server", version: "0.1.31" }); logger.error("cluster server failed", { condition: error instanceof Error ? error.name : "unknown", result: "failure" }); process.exitCode = 1; } diff --git a/packages/cluster-server/src/pod-host-router.ts b/packages/cluster-server/src/pod-host-router.ts index db26affc..b38b43ea 100644 --- a/packages/cluster-server/src/pod-host-router.ts +++ b/packages/cluster-server/src/pod-host-router.ts @@ -61,7 +61,7 @@ export class WebSocketPodHostConnector implements PodHostConnector { socket.send(JSON.stringify({ v: "omp-app/1", type: "hello", protocol: { min: "omp-app/1", max: "omp-app/1" }, - client: { name: "cluster-server", version: "0.1.30", build: "cluster", platform: "linux" }, + client: { name: "cluster-server", version: "0.1.31", build: "cluster", platform: "linux" }, requestedFeatures: PROTOCOL_FEATURES.filter(feature => feature !== "cluster.operator"), savedCursors: [], capabilities: { client: DEVICE_CAPABILITIES.filter(capability => capability !== "ci.trigger") }, diff --git a/packages/cluster-server/src/session-host-main.ts b/packages/cluster-server/src/session-host-main.ts index 72181224..ab9ec755 100755 --- a/packages/cluster-server/src/session-host-main.ts +++ b/packages/cluster-server/src/session-host-main.ts @@ -14,7 +14,7 @@ import { ClusterInternalRemotePolicy, sessionHostConfigFromEnv, type SessionHost import { KubernetesTokenReviewer } from "./kubernetes-client.ts"; const OMP_VERSION = "17.0.5"; -const OMP_COMMIT = "8476f4451ed95c5d5401785d279a93d3c659fac4"; +const OMP_COMMIT = "fc0c391334c08ab260057756aa84bd2b07741ee7"; export async function runSessionHost( config: SessionHostConfig, @@ -55,7 +55,7 @@ export async function runSessionHost( attentionOutcomePath: join(config.stateRoot, "attention-outcomes.json"), ompVersion: ready.ompVersion, ompBuild: ready.ompBuild, - appserverVersion: "0.1.30", + appserverVersion: "0.1.31", appserverBuild: "cluster-session", sessionAuthority: authorities.sessionAuthority, discovery: authorities.discovery, diff --git a/packages/cluster-server/test/pod-host-router.test.ts b/packages/cluster-server/test/pod-host-router.test.ts index 2c6b05f3..8b620271 100644 --- a/packages/cluster-server/test/pod-host-router.test.ts +++ b/packages/cluster-server/test/pod-host-router.test.ts @@ -11,7 +11,7 @@ const welcome = { hostId: "host-a", ompVersion: "17.0.5", ompBuild: "test", - appserverVersion: "0.1.30", + appserverVersion: "0.1.31", appserverBuild: "cluster-session", epoch: "pod-epoch", grantedCapabilities: [], diff --git a/packages/fixture-server/package.json b/packages/fixture-server/package.json index a410979a..2d157d93 100644 --- a/packages/fixture-server/package.json +++ b/packages/fixture-server/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/fixture-server", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "exports": { diff --git a/packages/host-daemon/package.json b/packages/host-daemon/package.json index 7e668438..544ff7d7 100644 --- a/packages/host-daemon/package.json +++ b/packages/host-daemon/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/host-daemon", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "description": "Standalone T4-owned host daemon with a thin OMP authority bridge", diff --git a/packages/host-daemon/src/cli.ts b/packages/host-daemon/src/cli.ts index b6700ebd..320de0fb 100644 --- a/packages/host-daemon/src/cli.ts +++ b/packages/host-daemon/src/cli.ts @@ -19,9 +19,9 @@ import { } from "@t4-code/host-service"; import { COMMAND_DESCRIPTORS, type ProjectId, type SessionId } from "@t4-code/protocol"; -export const T4_HOST_VERSION = "0.1.30"; -export const OFFICIAL_OMP_VERSION = "17.0.6"; -export const OFFICIAL_OMP_BUILD = "89d6a8f6d14286f32f09ec9c8aa8af7b3451d2d6"; +export const T4_HOST_VERSION = "0.1.31"; +export const OFFICIAL_OMP_VERSION = "17.0.9"; +export const OFFICIAL_OMP_BUILD = "639bac596d94b5993349f3f6696176cb2bf9b5d3"; const PROFILE = /^[a-z0-9][a-z0-9._-]{0,63}$/u; const ORIGIN_LIMIT = 32; const VERSION_OUTPUT_BYTES = 4 * 1024; @@ -70,6 +70,7 @@ export interface HostDaemonPaths { readonly profileStateRoot: string; readonly hostIdPath: string; readonly attentionOutcomePath: string; + readonly sessionOwnershipPath: string; readonly transcriptSearchPath: string; readonly officialMetadataPath: string; readonly remoteStateRoot: string; @@ -186,6 +187,7 @@ export function hostDaemonPaths( profileStateRoot, hostIdPath: join(profileStateRoot, "host-id"), attentionOutcomePath: join(profileStateRoot, "attention-outcomes.json"), + sessionOwnershipPath: join(profileStateRoot, "owned-sessions.json"), transcriptSearchPath: join(profileStateRoot, "transcript-search.sqlite"), officialMetadataPath: join(profileStateRoot, "official-omp-sessions.json"), remoteStateRoot: join(profileStateRoot, "remote"), @@ -337,6 +339,7 @@ export async function runHostDaemon( socketPath: paths.socketPath, hostIdPath: paths.hostIdPath, attentionOutcomePath: paths.attentionOutcomePath, + sessionOwnershipPath: paths.sessionOwnershipPath, sessionAuthority, discovery, operationsAuthority: { @@ -352,7 +355,7 @@ export async function runHostDaemon( ...(transcriptImageRoot ? { transcriptImageRoot } : {}), rpcChildInvocation: { executable: config.ompExecutable, prefixArgv: [] }, rpcChildEnvironment: { OMP_PROFILE: config.profileId }, - ...(config.authorityMode === "official" ? { rpcDialect: "official-17.0.6" as const } : {}), + ...(config.authorityMode === "official" ? { rpcDialect: "official-17.0.9" as const } : {}), ...(process.platform === "darwin" ? { projectRevealer: async (root: string): Promise => { diff --git a/packages/host-daemon/test/cli.test.ts b/packages/host-daemon/test/cli.test.ts index 2597ba81..9f9b2fe9 100644 --- a/packages/host-daemon/test/cli.test.ts +++ b/packages/host-daemon/test/cli.test.ts @@ -26,6 +26,7 @@ describe("T4 host daemon CLI", () => { expect(hostDaemonPaths(config)).toMatchObject({ profileStateRoot: expect.stringContaining("/home/test/.t4-code/host/profiles/"), hostIdPath: expect.stringContaining("/host-id"), + sessionOwnershipPath: expect.stringContaining("/owned-sessions.json"), transcriptSearchPath: expect.stringContaining("/transcript-search.sqlite"), }); }); @@ -173,8 +174,9 @@ describe("T4 host daemon CLI", () => { expect(captured).toMatchObject({ ompVersion: OFFICIAL_OMP_VERSION, ompBuild: OFFICIAL_OMP_BUILD, - rpcDialect: "official-17.0.6", + rpcDialect: "official-17.0.9", claimLocklessSessions: true, + sessionOwnershipPath: expect.stringContaining("/owned-sessions.json"), }); const operations = captured?.operationsAuthority as { catalogGet?: () => Promise>; @@ -184,7 +186,9 @@ describe("T4 host daemon CLI", () => { }); const catalog = await operations.catalogGet?.(); if (!catalog) throw new Error("official catalog missing"); - const commandNames = (catalog.items as Array<{ name: string }>).map(item => item.name); + const officialItems = catalog.items as Array<{ kind: string; name: string }>; + const commandNames = officialItems.map(item => item.name); + expect(officialItems.every(item => item.kind === "command")).toBe(true); expect(commandNames).toContain("session.model.set"); expect(commandNames).not.toContain("session.fast.set"); expect(commandNames).not.toContain("session.retry"); diff --git a/packages/host-service/bin/current-omp-bridge-proof.ts b/packages/host-service/bin/current-omp-bridge-proof.ts new file mode 100644 index 00000000..3d6c3f6c --- /dev/null +++ b/packages/host-service/bin/current-omp-bridge-proof.ts @@ -0,0 +1,116 @@ +#!/usr/bin/env bun + +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { OmpAuthorityBridgeClient } from "../src/omp-authority-bridge-client.ts"; + +interface VerifiedRuntime { + readonly sourceCommit: string; + readonly sourceRepository: string; + readonly sourceTag: string; + readonly version: string; +} + +function runtime(value: unknown): VerifiedRuntime { + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("compatibility matrix verifiedRuntime is invalid"); + const record = value as Record; + for (const key of ["sourceCommit", "sourceRepository", "sourceTag", "version"] as const) { + if (typeof record[key] !== "string" || record[key].length === 0) + throw new Error(`compatibility matrix verifiedRuntime.${key} is invalid`); + } + return record as unknown as VerifiedRuntime; +} + +async function gitHead(sourceRoot: string): Promise { + const child = Bun.spawn(["git", "rev-parse", "HEAD"], { + cwd: sourceRoot, + stdout: "pipe", + stderr: "pipe", + }); + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + if (code !== 0) throw new Error(`cannot resolve current OMP source: ${stderr.trim().slice(-1_024)}`); + return stdout.trim(); +} + +async function main(): Promise { + const repoRoot = resolve(import.meta.dirname, "../../.."); + const sourceRoot = process.env.T4_CURRENT_OMP_SOURCE_DIR; + const exactSourceRoot = sourceRoot ? resolve(repoRoot, sourceRoot) : ""; + if (!exactSourceRoot.startsWith(`${repoRoot}/.current-continuity/`)) + throw new Error("T4_CURRENT_OMP_SOURCE_DIR must be the checked-out current continuity source"); + const matrix = JSON.parse(await readFile(join(repoRoot, "compat", "omp-app-matrix.json"), "utf8")) as { + verifiedRuntime?: unknown; + }; + const expected = runtime(matrix.verifiedRuntime); + if (expected.sourceRepository !== "https://github.com/wolfiesch/oh-my-pi") + throw new Error("verified runtime repository is not the owned OMP fork"); + if ((await gitHead(exactSourceRoot)) !== expected.sourceCommit) + throw new Error("checked-out current OMP source does not match verifiedRuntime.sourceCommit"); + const cli = join(exactSourceRoot, "packages", "coding-agent", "src", "cli.ts"); + if (!(await stat(cli)).isFile()) throw new Error("current OMP CLI source is missing"); + + const root = await mkdtemp(join(tmpdir(), "t4-current-omp-bridge-")); + const home = join(root, "home"); + const workspace = join(root, "workspace"); + const profile = `current-proof-${Bun.randomUUIDv7().slice(-12)}`; + const client = new OmpAuthorityBridgeClient({ + executable: process.execPath, + argv: [cli, "bridge", "--stdio"], + cwd: exactSourceRoot, + environment: { + HOME: home, + XDG_CONFIG_HOME: join(home, ".config"), + XDG_DATA_HOME: join(home, ".local", "share"), + XDG_STATE_HOME: join(home, ".local", "state"), + XDG_CACHE_HOME: join(home, ".cache"), + PI_NOTIFICATIONS: "off", + OMP_PROFILE: profile, + }, + }); + try { + await Promise.all([mkdir(home, { recursive: true, mode: 0o700 }), mkdir(workspace)]); + const ready = await client.start(); + if (ready.ompVersion !== expected.version || ready.ompBuild !== "source") + throw new Error("current OMP bridge identity does not match the verified runtime"); + const authorities = client.createAuthorities(); + const host = await authorities.hostInfo(); + const sessions = await authorities.sessionAuthority.list(); + if (!host.transcriptImageRoot.startsWith(home)) + throw new Error("current OMP bridge escaped the disposable profile"); + if (sessions.length !== 0 || !authorities.discovery.inventoryComplete?.()) + throw new Error("current OMP bridge did not return one complete disposable inventory"); + + const evidence = { + schemaVersion: 1, + runtime: { + repository: expected.sourceRepository, + tag: expected.sourceTag, + commit: expected.sourceCommit, + version: ready.ompVersion, + build: ready.ompBuild, + }, + bridge: { + protocol: "t4-omp-authority/1", + methods: [...ready.methods].sort(), + completeInventory: true, + sessionCount: sessions.length, + }, + passed: true, + }; + const evidenceRoot = join(repoRoot, "artifacts", "current-omp-bridge"); + await mkdir(evidenceRoot, { recursive: true }); + await writeFile(join(evidenceRoot, `${process.platform}-${process.arch}.json`), `${JSON.stringify(evidence, null, 2)}\n`); + console.log(JSON.stringify(evidence, null, 2)); + } finally { + await client.stop().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } +} + +await main(); diff --git a/packages/host-service/bin/official-omp-gate0.ts b/packages/host-service/bin/official-omp-gate0.ts index 1c5643d2..4d944a32 100755 --- a/packages/host-service/bin/official-omp-gate0.ts +++ b/packages/host-service/bin/official-omp-gate0.ts @@ -5,12 +5,18 @@ import { createReadStream } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join, resolve } from "node:path"; +import { projectId, sessionId } from "@t4-code/host-wire"; +import { BunRpcChildFactory, RpcChildSupervisor } from "../src/rpc-child.ts"; +import type { SessionRecord } from "../src/types.ts"; const FRAME_TIMEOUT_MS = 10_000; const STALE_LOCK_RECOVERY_MS = 20_500; const MAX_FRAME_BYTES = 4 * 1024 * 1024; const MAX_FRAMES_PER_TURN = 1_000; const MAX_SESSION_ENTRIES = 10_000; +const LARGE_RPC_PROBE = "T4 large RPC payload probe"; +const LARGE_RPC_RESPONSE_BYTES = 2 * 1024 * 1024; +const LARGE_RPC_RESPONSE_SHA256 = "6932fd31e5daf4739b9fa78ff777b2831b0995cc1d0b0093cac80601902013bc"; type JsonMap = Record; @@ -248,6 +254,9 @@ export function startDeterministicModel(): DeterministicModel { const ordinal = requests.length; const model = typeof body.model === "string" ? body.model : "deterministic"; const id = `chatcmpl-t4-gate0-${ordinal}`; + const responseText = messages.some((message) => message.includes(LARGE_RPC_PROBE)) + ? "x".repeat(LARGE_RPC_RESPONSE_BYTES) + : `Gate 0 response ${ordinal}`; const events: unknown[] = [ { id, @@ -257,7 +266,7 @@ export function startDeterministicModel(): DeterministicModel { choices: [ { index: 0, - delta: { role: "assistant", content: `Gate 0 response ${ordinal}` }, + delta: { role: "assistant", content: responseText }, finish_reason: null, }, ], @@ -555,6 +564,116 @@ async function runApprovalScenario(input: { } } +async function runLargeRpcPayloadScenario(input: { + readonly runtime: VerifiedRuntime; + readonly root: string; + readonly workspace: string; + readonly profile: string; +}): Promise<{ responseBytes: number; responseSha256: string }> { + const path = join(input.root, "large-rpc-payload.jsonl"); + const record: SessionRecord = { + sessionId: sessionId("official-large-rpc-payload"), + path, + cwd: input.workspace, + projectId: projectId("official-large-rpc-project"), + title: "Large RPC payload", + updatedAt: new Date().toISOString(), + status: "idle", + entries: [], + }; + const factory = new BunRpcChildFactory( + { executable: input.runtime.path, prefixArgv: [] }, + undefined, + { PI_CODING_AGENT_DIR: input.profile, PI_NOTIFICATIONS: "off" }, + ); + const completion = Promise.withResolvers< + { readonly kind: "agent_end" } | { readonly kind: "crashed"; readonly error: Error } + >(); + const supervisor = new RpcChildSupervisor( + factory, + record, + { + entry: () => {}, + event: (frame) => { + if (frame.type === "agent_end") completion.resolve({ kind: "agent_end" }); + }, + crashed: (error) => completion.resolve({ kind: "crashed", error }), + }, + [ + input.runtime.path, + "--mode", + "rpc", + "--session", + path, + "--cwd", + input.workspace, + "--model", + "gate0/deterministic", + "--no-tools", + "--no-extensions", + "--no-skills", + "--no-rules", + "--no-title", + ], + undefined, + input.runtime.matrix.officialRuntime.version, + ); + try { + await supervisor.start(); + const prompt = await supervisor.call({ type: "prompt", message: LARGE_RPC_PROBE }, "large-rpc-prompt"); + if (!prompt.success) throw new Error(`large RPC probe prompt failed: ${prompt.error}`); + const outcome = await Promise.race([ + completion.promise, + Bun.sleep(FRAME_TIMEOUT_MS).then(() => { + throw new Error("large RPC probe timed out"); + }), + ]); + if (outcome.kind === "crashed") throw outcome.error; + const messages: JsonMap[] = []; + const seenCursors = new Set(); + let cursor: string | undefined; + let totalMessages: number | undefined; + do { + const page = await supervisor.call( + { type: "get_messages_page", limit: 10, ...(cursor ? { cursor } : {}) }, + `large-rpc-page-${messages.length}`, + ); + if (!page.success) throw new Error(`large RPC page failed: ${page.error}`); + const data = map(page.data, "large RPC page data"); + if (!Array.isArray(data.messages)) throw new Error("large RPC page messages are missing"); + if (!Number.isSafeInteger(data.totalMessages) || (data.totalMessages as number) < 0) + throw new Error("large RPC page total is invalid"); + if (totalMessages !== undefined && data.totalMessages !== totalMessages) + throw new Error("large RPC page total changed"); + totalMessages = data.totalMessages as number; + messages.push( + ...data.messages.map((message, index) => + map(message, `large RPC page message ${messages.length + index}`), + ), + ); + cursor = data.nextCursor === undefined ? undefined : text(data.nextCursor, "large RPC page cursor"); + if (cursor && seenCursors.has(cursor)) throw new Error("large RPC page repeated a cursor"); + if (cursor) seenCursors.add(cursor); + } while (cursor); + if (messages.length !== totalMessages) throw new Error("large RPC pagination ended early"); + const response = messages.find( + (message) => message.role === "assistant" && messageText(message).length === LARGE_RPC_RESPONSE_BYTES, + ); + if (!response) throw new Error("large RPC response was not reassembled losslessly"); + const responseBytes = Buffer.byteLength(messageText(response), "utf8"); + if (responseBytes !== LARGE_RPC_RESPONSE_BYTES) + throw new Error(`large RPC response has ${responseBytes} bytes`); + const responseSha256 = createHash("sha256").update(messageText(response), "utf8").digest("hex"); + if (responseSha256 !== LARGE_RPC_RESPONSE_SHA256) + throw new Error(`large RPC response digest is ${responseSha256}`); + return { responseBytes, responseSha256 }; + } finally { + const child = supervisor.child(); + supervisor.stop(); + await child?.exited.catch(() => undefined); + } +} + export async function verifyRuntime(repoRoot: string): Promise { const matrix = decodeRuntimeMatrix(await readJson(join(repoRoot, "compat", "omp-app-matrix.json"))); const manifestPath = join(repoRoot, ".artifacts", "omp-runtime-official", "manifest.json"); @@ -680,6 +799,7 @@ async function main(): Promise { profile, model, }); + const largeRpcPayload = await runLargeRpcPayloadScenario({ runtime, root, workspace, profile }); const result = { schemaVersion: 1, runtime: { @@ -700,6 +820,7 @@ async function main(): Promise { followUp, approval, cancellation, + largeRpcPayload, }, observedStockSeams: { readyTranscriptWatermark: initialWatermark !== null && restartWatermark !== null, diff --git a/packages/host-service/package.json b/packages/host-service/package.json index f607c163..b722bdd3 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/host-service", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "description": "T4-owned host service with runtime adapters over Unix WebSocket", @@ -12,6 +12,7 @@ "build": "bun run typecheck", "check": "bun run typecheck", "test": "bun test test", + "verify:current-omp-bridge": "bun run bin/current-omp-bridge-proof.ts", "verify:official-omp-lifecycle": "node ../../scripts/stage-omp-runtime.mjs --runtime official && bun run bin/official-omp-gate0.ts", "typecheck": "tsgo -p tsconfig.json --noEmit" }, diff --git a/packages/host-service/src/index.ts b/packages/host-service/src/index.ts index 53307279..4e6a5dc2 100644 --- a/packages/host-service/src/index.ts +++ b/packages/host-service/src/index.ts @@ -20,6 +20,7 @@ export * from "./remote/types.ts"; export * from "./rpc-child.ts"; export * from "./runtime-adapter.ts"; export * from "./runtime-adapter-presets.ts"; +export * from "./session-ownership-store.ts"; export type { AuthenticatedPrincipal, Capability, diff --git a/packages/host-service/src/official-omp-capabilities.ts b/packages/host-service/src/official-omp-capabilities.ts index e4b740da..e46a7f65 100644 --- a/packages/host-service/src/official-omp-capabilities.ts +++ b/packages/host-service/src/official-omp-capabilities.ts @@ -16,8 +16,8 @@ const MAX_COMMAND_INPUT_HINT_BYTES = 512; const MAX_COMMAND_SOURCE_BYTES = 64; export const OFFICIAL_OMP_TERMINAL_ONLY_EVIDENCE = Object.freeze({ - packageVersion: "17.0.6", - sourceCommit: "89d6a8f6d14286f32f09ec9c8aa8af7b3451d2d6", + packageVersion: "17.0.9", + sourceCommit: "639bac596d94b5993349f3f6696176cb2bf9b5d3", }); interface TerminalOnlyCommand { @@ -106,33 +106,55 @@ function slashName(value: unknown, path: string): string { return name; } +function inlineMetadata(value: unknown, path: string, maxBytes: number): string { + return controlFree( + typeof value === "string" ? value.replace(/[\t\n\r]+/gu, " ") : value, + path, + maxBytes, + ); +} + +function decodeHeadlessCommand(value: unknown, index: number): HeadlessCommand { + const path = `available_commands_update.commands[${index}]`; + const item = boundedMap(value, path, 16); + const name = slashName(item.name, `${path}.name`); + const aliases = boundedArray(item.aliases ?? [], `${path}.aliases`, MAX_COMMAND_ALIASES).map( + (alias, aliasIndex) => slashName(alias, `${path}.aliases[${aliasIndex}]`), + ); + const description = + item.description === undefined + ? undefined + : inlineMetadata(item.description, `${path}.description`, MAX_COMMAND_DESCRIPTION_BYTES); + const input = item.input === undefined ? undefined : boundedMap(item.input, `${path}.input`, 4); + const inputHint = + input?.hint === undefined + ? undefined + : inlineMetadata(input.hint, `${path}.input.hint`, MAX_COMMAND_INPUT_HINT_BYTES); + const source = controlFree(item.source, `${path}.source`, MAX_COMMAND_SOURCE_BYTES); + return Object.freeze({ name, aliases: Object.freeze(aliases), description, inputHint, source }); +} + function decodeHeadlessCommands(value: unknown): readonly HeadlessCommand[] { const items = boundedArray(value, "available_commands_update.commands", MAX_AVAILABLE_COMMANDS); const commands: HeadlessCommand[] = []; const names = new Set(); + let rejected = 0; for (let index = 0; index < items.length; index++) { - const path = `available_commands_update.commands[${index}]`; - const item = boundedMap(items[index], path, 16); - const name = slashName(item.name, `${path}.name`); - if (names.has(name)) throw new Error(`duplicate available command: ${name}`); - names.add(name); - const aliases = boundedArray(item.aliases ?? [], `${path}.aliases`, MAX_COMMAND_ALIASES).map( - (alias, aliasIndex) => slashName(alias, `${path}.aliases[${aliasIndex}]`), - ); - const description = - item.description === undefined - ? undefined - : controlFree(item.description, `${path}.description`, MAX_COMMAND_DESCRIPTION_BYTES); - const input = item.input === undefined ? undefined : boundedMap(item.input, `${path}.input`, 4); - const inputHint = - input?.hint === undefined - ? undefined - : controlFree(input.hint, `${path}.input.hint`, MAX_COMMAND_INPUT_HINT_BYTES); - const source = controlFree(item.source, `${path}.source`, MAX_COMMAND_SOURCE_BYTES); - commands.push( - Object.freeze({ name, aliases: Object.freeze(aliases), description, inputHint, source }), - ); + let command: HeadlessCommand; + try { + command = decodeHeadlessCommand(items[index], index); + } catch { + // One non-addressable extension or skill must not suppress the bounded + // catalog for every valid command in the same authoritative update. + rejected += 1; + continue; + } + if (names.has(command.name)) throw new Error(`duplicate available command: ${command.name}`); + names.add(command.name); + commands.push(command); } + if (items.length > 0 && commands.length === 0 && rejected > 0) + throw new Error("available commands contain no valid entries"); return Object.freeze(commands); } @@ -158,7 +180,12 @@ export class OfficialOmpCapabilityAdapter { consume(frame: Readonly>): boolean { if (frame.type !== "available_commands_update") return false; - this.update(frame.commands); + try { + this.update(frame.commands); + } catch { + // Live capability metadata is advisory. Preserve the last bounded catalog + // when one update is malformed instead of terminating prompt transport. + } return true; } diff --git a/packages/host-service/src/omp-authority-bridge-client.ts b/packages/host-service/src/omp-authority-bridge-client.ts index 8c92d2d5..8978f5e6 100644 --- a/packages/host-service/src/omp-authority-bridge-client.ts +++ b/packages/host-service/src/omp-authority-bridge-client.ts @@ -1,4 +1,4 @@ -import type { ProjectId, SessionId, UsageReadResult } from "@t4-code/host-wire"; +import { MAX_ARRAY_ITEMS, type ProjectId, type SessionId, type UsageReadResult } from "@t4-code/host-wire"; import { isAbsolute } from "node:path"; import { decodeOmpAuthorityBridgeServerFrame, @@ -81,6 +81,71 @@ function contextPayload(context: OperationContext): Record { }; } +function sessionReference(session: SessionRecord): SessionRecord { + return { ...session, entriesLoaded: false, entries: [] }; +} + +function sparseSessionListResponse(value: unknown): unknown { + if (!value || typeof value !== "object" || Array.isArray(value)) return value; + const frame = value as Record; + if (frame.type !== "response" || frame.ok !== true) return value; + const result = + Array.isArray(frame.result) + ? frame.result + : frame.result && typeof frame.result === "object" && !Array.isArray(frame.result) + ? (frame.result as Record).sessions + : undefined; + if (!Array.isArray(result)) return value; + const sparse = result.map(session => + session && typeof session === "object" && !Array.isArray(session) + ? { ...(session as Record), entriesLoaded: false, entries: [] } + : session, + ); + if (Array.isArray(frame.result)) return { ...frame, result: sparse }; + return { + ...frame, + result: { ...(frame.result as Record), sessions: sparse }, + }; +} + +function sessionListPage(value: unknown): { + readonly sessions: readonly SessionRecord[]; + readonly nextCursor?: string; + readonly complete: boolean; + readonly totalCount: number; +} { + if (Array.isArray(value)) + return { sessions: value as SessionRecord[], complete: true, totalCount: value.length }; + const page = asRecord(value, "session inventory page"); + const keys = Object.keys(page).sort(); + const expected = [ + "sessions", + "complete", + "totalCount", + ...(page.nextCursor === undefined ? [] : ["nextCursor"]), + ].sort(); + if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) + throw new Error("session inventory page is invalid"); + if (!Array.isArray(page.sessions) || page.sessions.length > MAX_ARRAY_ITEMS) + throw new Error("session inventory page is invalid"); + if ( + typeof page.complete !== "boolean" || + !Number.isSafeInteger(page.totalCount) || + (page.totalCount as number) < page.sessions.length + ) + throw new Error("session inventory page is invalid"); + const nextCursor = + page.nextCursor === undefined ? undefined : asString(page.nextCursor, "session inventory cursor"); + if (nextCursor !== undefined && page.sessions.length === 0) + throw new Error("session inventory page made no progress"); + return { + sessions: page.sessions as SessionRecord[], + ...(nextCursor === undefined ? {} : { nextCursor }), + complete: page.complete, + totalCount: page.totalCount as number, + }; +} + async function* lines(stream: AsyncIterable): AsyncGenerator { const decoder = new TextDecoder("utf-8", { fatal: true }); let pending = ""; @@ -134,6 +199,8 @@ export class OmpAuthorityBridgeClient { #counter = 0; #closed = false; #stderr = ""; + #sessionInventoryComplete = true; + #sessionInventoryTotalCount = 0; constructor( private readonly invocation: OmpAuthorityBridgeInvocation, @@ -179,19 +246,54 @@ export class OmpAuthorityBridgeClient { emitTerminalOutput?: (frame: unknown) => void) => this.#request(method, params, signal, emitTerminalOutput); const sessionAuthority: SessionAuthority = { create: async (cwd, title) => call("session.create", { cwd, ...(title === undefined ? {} : { title }) }) as never, - list: async () => call("session.list", {}) as never, - archive: async (session, archivedAt) => { await call("session.archive", { session, archivedAt }); }, - restore: async session => { await call("session.restore", { session }); }, - delete: async session => { await call("session.delete", { session }); }, + list: async () => { + const sessions: SessionRecord[] = []; + const seenCursors = new Set(); + let complete: boolean | undefined; + let totalCount: number | undefined; + let cursor: string | undefined; + for (;;) { + const page = sessionListPage( + await call("session.list", cursor === undefined ? {} : { cursor }), + ); + if (sessions.length + page.sessions.length > MAX_ARRAY_ITEMS) + throw new Error("session inventory exceeds the bridge item limit"); + if ( + (complete !== undefined && page.complete !== complete) || + (totalCount !== undefined && page.totalCount !== totalCount) + ) + throw new Error("session inventory snapshot metadata changed between pages"); + complete = page.complete; + totalCount = page.totalCount; + sessions.push(...page.sessions); + if (page.nextCursor === undefined) { + if (page.complete && page.totalCount !== sessions.length) + throw new Error("complete session inventory count is inconsistent"); + if (!page.complete && page.totalCount <= sessions.length) + throw new Error("partial session inventory count is inconsistent"); + this.#sessionInventoryComplete = page.complete; + this.#sessionInventoryTotalCount = page.totalCount; + return sessions; + } + if (seenCursors.has(page.nextCursor)) throw new Error("session inventory cursor repeated"); + seenCursors.add(page.nextCursor); + cursor = page.nextCursor; + } + }, + archive: async (session, archivedAt) => { await call("session.archive", { session: sessionReference(session), archivedAt }); }, + restore: async session => { await call("session.restore", { session: sessionReference(session) }); }, + delete: async session => { await call("session.delete", { session: sessionReference(session) }); }, }; const discovery: SessionDiscovery = { list: () => sessionAuthority.list(), + inventoryComplete: () => this.#sessionInventoryComplete, + inventoryTotalCount: () => this.#sessionInventoryTotalCount, ...(this.#methods.has("discovery.load") - ? { load: async (session: SessionRecord) => call("discovery.load", { session }) as Promise } + ? { load: async (session: SessionRecord) => call("discovery.load", { session: sessionReference(session) }) as Promise } : {}), ...(this.#methods.has("discovery.page") ? { page: async (session: SessionRecord, args: Record) => - call("discovery.page", { session, args }) as never } + call("discovery.page", { session: sessionReference(session), args }) as never } : {}), }; const operationsAuthority: DesktopOperationsAuthority = {}; @@ -228,8 +330,9 @@ export class OmpAuthorityBridgeClient { await call("project.rootForProject", { projectId }), "project root"), projectRootForSession: async sessionId => asString( await call("project.rootForSession", { sessionId }), "session root"), - lockCheck: async session => { await call("lock.check", { session }); }, - lockStatus: async session => asString(await call("lock.status", { session }), "lock status") as never, + lockCheck: async session => { await call("lock.check", { session: sessionReference(session) }); }, + lockStatus: async session => asString( + await call("lock.status", { session: sessionReference(session) }), "lock status") as never, }; } @@ -269,7 +372,14 @@ export class OmpAuthorityBridgeClient { try { for await (const line of lines(child.stdout)) { if (!line) continue; - const frame = decodeOmpAuthorityBridgeServerFrame(JSON.parse(line)); + const value = JSON.parse(line); + const requestId = value && typeof value === "object" && !Array.isArray(value) && typeof value.id === "string" + ? value.id + : undefined; + const expected = requestId === undefined ? undefined : this.#pending.get(requestId); + const frame = decodeOmpAuthorityBridgeServerFrame( + expected?.method === "session.list" ? sparseSessionListResponse(value) : value, + ); if (frame.type === "ready") { if (this.#ready) throw new Error("OMP authority bridge sent duplicate ready frame"); this.#ready = frame; diff --git a/packages/host-service/src/omp-authority-bridge-contract.ts b/packages/host-service/src/omp-authority-bridge-contract.ts index 2b37a88a..4d0aeafd 100644 --- a/packages/host-service/src/omp-authority-bridge-contract.ts +++ b/packages/host-service/src/omp-authority-bridge-contract.ts @@ -93,6 +93,7 @@ const METHOD_SET = new Set(OMP_AUTHORITY_BRIDGE_METHODS); const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; const ERROR_CODE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/u; const MAX_TEXT_BYTES = 256 * 1024; +const MAX_RESULT_TEXT_BYTES = 512 * 1024; const MAX_VALUE_DEPTH = 32; const MAX_VALUE_NODES = 50_000; @@ -124,7 +125,7 @@ function method(value: unknown): OmpAuthorityBridgeMethod { return value as OmpAuthorityBridgeMethod; } -function boundedJson(value: unknown, label: string): unknown { +function boundedJson(value: unknown, label: string, maxTextBytes = MAX_TEXT_BYTES): unknown { let nodes = 0; let textBytes = 0; const visit = (item: unknown, depth: number): void => { @@ -137,7 +138,7 @@ function boundedJson(value: unknown, label: string): unknown { } if (typeof item === "string") { textBytes += Buffer.byteLength(item, "utf8"); - if (textBytes > MAX_TEXT_BYTES) throw new Error(`${label} exceeds bridge text bounds`); + if (textBytes > maxTextBytes) throw new Error(`${label} exceeds bridge text bounds`); return; } if (Array.isArray(item)) { @@ -148,7 +149,7 @@ function boundedJson(value: unknown, label: string): unknown { throw new Error(`${label} contains a non-JSON value`); for (const [key, child] of Object.entries(item)) { textBytes += Buffer.byteLength(key, "utf8"); - if (textBytes > MAX_TEXT_BYTES) throw new Error(`${label} exceeds bridge text bounds`); + if (textBytes > maxTextBytes) throw new Error(`${label} exceeds bridge text bounds`); visit(child, depth + 1); } }; @@ -213,7 +214,7 @@ export function decodeOmpAuthorityBridgeServerFrame(value: unknown): OmpAuthorit type: "response", id, ok: true, - result: boundedJson(frame.result, "bridge result"), + result: boundedJson(frame.result, "bridge result", MAX_RESULT_TEXT_BYTES), }; } if (frame.ok !== false) throw new Error("bridge response status is invalid"); diff --git a/packages/host-service/src/projection.ts b/packages/host-service/src/projection.ts index 49840af0..25f489d2 100644 --- a/packages/host-service/src/projection.ts +++ b/packages/host-service/src/projection.ts @@ -253,16 +253,21 @@ export class SessionProjection { return this.updateRef(next, `session-control:${control?.mode ?? "clear"}`); } rebaseEntries(entries: readonly DurableEntry[]): ServerFrame[] { + const reboundEntries = entries.map(entry => + entry.hostId === this.value.hostId && entry.sessionId === this.value.sessionId + ? entry + : { ...entry, hostId: this.value.hostId, sessionId: this.value.sessionId }, + ); const current = this.value.entries; const prefix = - entries.length >= current.length && - current.every((entry, index) => JSON.stringify(entry) === JSON.stringify(entries[index])); + reboundEntries.length >= current.length && + current.every((entry, index) => JSON.stringify(entry) === JSON.stringify(reboundEntries[index])); if (prefix) { const previous = { ...this.value.cursor }; const frames: ServerFrame[] = []; let frameBytes = 0; let budgetExceeded = false; - for (const entry of entries.slice(current.length)) { + for (const entry of reboundEntries.slice(current.length)) { const frame = this.appendEntry(entry); if (!frame || budgetExceeded) continue; const bytes = encoder.encode(JSON.stringify(frame)).byteLength; @@ -293,13 +298,13 @@ export class SessionProjection { this.#byId.clear(); this.#revisionHash = createHash("sha256"); this.value.entries = []; - for (const entry of entries) { + for (const entry of reboundEntries) { this.#byId.set(entry.id, entry); this.value.entries.push(entry); this.#revisionHash.update(`${JSON.stringify(entry)}\n`); } this.value.revision = revision(`r-${this.#revisionHash.copy().digest("hex").slice(0, 24)}`); - const last = entries.at(-1); + const last = reboundEntries.at(-1); this.value.ref = { ...this.value.ref, revision: this.value.revision, diff --git a/packages/host-service/src/rpc-child.ts b/packages/host-service/src/rpc-child.ts index 0a50a11c..2ca95c51 100644 --- a/packages/host-service/src/rpc-child.ts +++ b/packages/host-service/src/rpc-child.ts @@ -7,8 +7,11 @@ import { OfficialOmpCapabilityAdapter } from "./official-omp-capabilities.ts"; import type { ChildHandle, RpcChildFactory, SessionRecord } from "./types.ts"; const MAX_LINE_BYTES = 1024 * 1024; +const MAX_RPC_REASSEMBLED_BYTES = 64 * 1024 * 1024; +const RPC_CHUNK_PAYLOAD_BYTES = 256 * 1024; const STDERR_BYTES = 64 * 1024; const FAILURE_STOP_GRACE_MS = 2_000; +const PROTOCOL_NEGOTIATION_TIMEOUT_MS = 10_000; const TRANSCRIPT_READ_BYTES = 64 * 1024; const MAX_PENDING_DURABLE_CORRELATIONS = 64; @@ -17,6 +20,89 @@ interface PendingDurableCorrelation { readonly message: string; } +interface PendingRpcChunks { + readonly chunkId: string; + readonly count: number; + readonly byteLength: number; + readonly chunks: Buffer[]; + nextIndex: number; + receivedBytes: number; +} + +class RpcChunkDecoder { + #pending?: PendingRpcChunks; + + push(frame: Record): Record | undefined { + if (frame.type !== "rpc_chunk") { + if (this.#pending) throw new Error("rpc chunk sequence interrupted"); + return frame; + } + const { chunkId, index, count, byteLength, data } = frame; + if ( + typeof chunkId !== "string" || + chunkId.length === 0 || + chunkId.length > 128 || + typeof index !== "number" || + typeof count !== "number" || + typeof byteLength !== "number" || + typeof data !== "string" + ) + throw new Error("malformed rpc chunk"); + if ( + !Number.isSafeInteger(index) || + !Number.isSafeInteger(count) || + !Number.isSafeInteger(byteLength) || + index < 0 || + count < 2 || + count > Math.ceil(MAX_RPC_REASSEMBLED_BYTES / RPC_CHUNK_PAYLOAD_BYTES) || + index >= count || + byteLength < MAX_LINE_BYTES || + byteLength > MAX_RPC_REASSEMBLED_BYTES || + data.length === 0 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data) + ) + throw new Error("malformed rpc chunk"); + const bytes = Buffer.from(data, "base64"); + if (bytes.toString("base64") !== data || bytes.byteLength > RPC_CHUNK_PAYLOAD_BYTES) + throw new Error("malformed rpc chunk"); + + if (!this.#pending) { + if (index !== 0) throw new Error("rpc chunk sequence must start at index 0"); + this.#pending = { chunkId, count, byteLength, chunks: [], nextIndex: 0, receivedBytes: 0 }; + } + const pending = this.#pending; + if ( + pending.chunkId !== chunkId || + pending.count !== count || + pending.byteLength !== byteLength || + pending.nextIndex !== index + ) + throw new Error("rpc chunk sequence mismatch"); + pending.chunks.push(bytes); + pending.receivedBytes += bytes.byteLength; + pending.nextIndex++; + if (pending.receivedBytes > pending.byteLength) throw new Error("rpc chunk sequence exceeds declared length"); + if (pending.nextIndex < pending.count) return undefined; + if (pending.receivedBytes !== pending.byteLength) throw new Error("rpc chunk sequence length mismatch"); + + this.#pending = undefined; + let decoded: string; + try { + decoded = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(pending.chunks)); + } catch { + throw new Error("invalid UTF-8 rpc frame"); + } + let value: unknown; + try { + value = JSON.parse(decoded); + } catch { + throw new Error("malformed reassembled rpc frame"); + } + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("rpc frame must be an object"); + return value as Record; + } +} + function rawEntryId(value: unknown): string | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const id = (value as Record).id; @@ -63,6 +149,8 @@ class DurableJsonlReconciler { #offset = 0; #entryCount = 0; #lastEntryId: string | null = null; + #skippingOversizedLine = false; + #oversizedLineStart = 0; readonly #projectedEntryIds = new Set(); readonly #pendingCorrelations: PendingDurableCorrelation[] = []; #reconcileTail: Promise = Promise.resolve(); @@ -70,6 +158,7 @@ class DurableJsonlReconciler { constructor( private readonly path: string, private readonly emit: (frame: RpcSessionEntryFrame) => void, + private readonly transcriptRecordOmitted?: (offset: number) => void, ) {} watermark(): RpcLoadedTranscriptWatermark { @@ -133,6 +222,8 @@ class DurableJsonlReconciler { this.#offset = 0; this.#entryCount = 0; this.#lastEntryId = null; + this.#skippingOversizedLine = false; + this.#oversizedLineStart = 0; this.#projectedEntryIds.clear(); await this.initialize(); return; @@ -145,33 +236,60 @@ class DurableJsonlReconciler { const handle = await open(this.path, "r"); let position = this.#offset; let pending = Buffer.alloc(0); + let pendingStart = position; try { while (position < size) { const length = Math.min(TRANSCRIPT_READ_BYTES, size - position); const bytes = Buffer.allocUnsafe(length); - const result = await handle.read(bytes, 0, length, position); + const chunkStart = position; + const result = await handle.read(bytes, 0, length, chunkStart); if (result.bytesRead === 0) throw new Error("session transcript changed during reconciliation"); position += result.bytesRead; - const buffered = pending.byteLength === 0 - ? bytes.subarray(0, result.bytesRead) - : Buffer.concat([pending, bytes.subarray(0, result.bytesRead)]); - let start = 0; - let newline = buffered.indexOf(0x0a, start); - while (newline >= 0) { - const line = buffered.subarray(start, newline); - if (line.byteLength > MAX_LINE_BYTES) throw new Error("session transcript line exceeds 1 MiB"); - if (line.byteLength > 0) - this.#observeLine(new TextDecoder("utf-8", { fatal: true }).decode(line), publish); - start = newline + 1; - newline = buffered.indexOf(0x0a, start); + const chunk = bytes.subarray(0, result.bytesRead); + let cursor = 0; + while (cursor < chunk.byteLength) { + const newline = chunk.indexOf(0x0a, cursor); + const end = newline < 0 ? chunk.byteLength : newline; + const segment = chunk.subarray(cursor, end); + if (this.#skippingOversizedLine) { + if (newline < 0) break; + this.#skippingOversizedLine = false; + this.#omitOversizedRecord(publish); + pendingStart = chunkStart + newline + 1; + } else if (pending.byteLength + segment.byteLength > MAX_LINE_BYTES) { + this.#oversizedLineStart = pendingStart; + pending = Buffer.alloc(0); + if (newline < 0) { + this.#skippingOversizedLine = true; + break; + } + this.#omitOversizedRecord(publish); + pendingStart = chunkStart + newline + 1; + } else if (newline < 0) { + pending = pending.byteLength === 0 ? Buffer.from(segment) : Buffer.concat([pending, segment]); + break; + } else { + const line = pending.byteLength === 0 ? segment : Buffer.concat([pending, segment]); + if (line.byteLength > 0) + this.#observeLine(new TextDecoder("utf-8", { fatal: true }).decode(line), publish); + pending = Buffer.alloc(0); + pendingStart = chunkStart + newline + 1; + } + cursor = end + 1; } - pending = Buffer.from(buffered.subarray(start)); - if (pending.byteLength > MAX_LINE_BYTES) throw new Error("session transcript line exceeds 1 MiB"); } } finally { await handle.close(); } - this.#offset = position - pending.byteLength; + this.#offset = this.#skippingOversizedLine ? position : position - pending.byteLength; + } + + #omitOversizedRecord(publish: boolean): void { + // The skipped record could be the durable user entry at the head of the + // correlation queue. Its identity is unknowable without parsing it, so + // retain no correlation across this gap. + this.#pendingCorrelations.length = 0; + if (publish) this.transcriptRecordOmitted?.(this.#oversizedLineStart); } #observeLine(line: string, publish: boolean): void { @@ -214,6 +332,7 @@ export interface ChildCallbacks { entry(frame: RpcSessionEntryFrame): void; event(frame: Record): void; capabilities?(operations: readonly OperationCapability[]): void; + transcriptRecordOmitted?(offset: number): void; crashed(error: Error): void; } @@ -328,14 +447,16 @@ async function* lines(stream: AsyncIterable): AsyncGenerato throw new Error("invalid UTF-8 stdout"); } pending += text; - if (stringBytes(pending) > MAX_LINE_BYTES) throw new Error("rpc line exceeds 1MiB"); let index = pending.indexOf("\n"); while (index >= 0) { + if (stringBytes(pending.slice(0, index + 1)) > MAX_LINE_BYTES) + throw new Error("rpc line exceeds 1MiB"); const line = pending.slice(0, index).replace(/\r$/, ""); pending = pending.slice(index + 1); yield line; index = pending.indexOf("\n"); } + if (stringBytes(pending) > MAX_LINE_BYTES) throw new Error("rpc line exceeds 1MiB"); } try { pending += decoder.decode(); @@ -358,6 +479,9 @@ export class RpcChildSupervisor { #counter = 0; #stderr = ""; #ready = false; + #supportsProtocolV2 = false; + #protocolV2 = false; + readonly #frameDecoder = new RpcChunkDecoder(); #termination?: Promise; #operationCapabilities: OfficialOmpCapabilityAdapter; #transcript: DurableJsonlReconciler; @@ -368,11 +492,22 @@ export class RpcChildSupervisor { private readonly argv = ["omp", "--mode", "rpc"], private readonly failureStopGraceMs = FAILURE_STOP_GRACE_MS, private readonly runtimeVersion?: string, + private readonly protocolNegotiationTimeoutMs = PROTOCOL_NEGOTIATION_TIMEOUT_MS, ) { this.#operationCapabilities = new OfficialOmpCapabilityAdapter(runtimeVersion); - this.#transcript = new DurableJsonlReconciler(session.path, frame => this.callbacks.entry(frame)); + this.#transcript = new DurableJsonlReconciler( + session.path, + frame => this.callbacks.entry(frame), + offset => this.callbacks.transcriptRecordOmitted?.(offset), + ); if (!Number.isSafeInteger(failureStopGraceMs) || failureStopGraceMs <= 0 || failureStopGraceMs > 60_000) throw new Error("failureStopGraceMs must be between 1 and 60000"); + if ( + !Number.isSafeInteger(protocolNegotiationTimeoutMs) || + protocolNegotiationTimeoutMs <= 0 || + protocolNegotiationTimeoutMs > 60_000 + ) + throw new Error("protocolNegotiationTimeoutMs must be between 1 and 60000"); } hasPendingCalls(): boolean { return this.#pending.size > 0; @@ -390,6 +525,35 @@ export class RpcChildSupervisor { const timer = setTimeout(() => ready.reject(new Error("rpc child ready timeout")), 10_000); try { await ready.promise; + if (this.#supportsProtocolV2) { + this.#protocolV2 = true; + const abort = new AbortController(); + const negotiationTimer = setTimeout(() => abort.abort(), this.protocolNegotiationTimeoutMs); + let negotiated: RpcResponse; + try { + negotiated = await this.call( + { type: "negotiate_protocol", protocolVersion: 2 }, + "rpc-protocol", + abort.signal, + undefined, + false, + ); + } catch (error) { + if (abort.signal.aborted) + throw new Error("rpc protocol v2 negotiation timed out", { cause: error }); + throw error; + } finally { + clearTimeout(negotiationTimer); + } + if ( + !negotiated.success || + !negotiated.data || + typeof negotiated.data !== "object" || + Array.isArray(negotiated.data) || + (negotiated.data as Record).protocolVersion !== 2 + ) + throw new Error("rpc protocol v2 negotiation failed"); + } } catch (error) { this.stop(); throw error; @@ -527,8 +691,20 @@ export class RpcChildSupervisor { } if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("rpc frame must be an object"); - const frame = value as Record; + const parsed = value as Record; + if (parsed.type === "rpc_chunk" && !this.#protocolV2) throw new Error("rpc chunk received before negotiation"); + const decoded = this.#frameDecoder.push(parsed); + if (!decoded) continue; + const frame = decoded; if (frame.type === "ready") { + const versions = frame.supportedProtocolVersions; + const maxFrameBytes = frame.maxFrameBytes; + const maxReassembledFrameBytes = frame.maxReassembledFrameBytes; + this.#supportsProtocolV2 = + Array.isArray(versions) && + versions.includes(2) && + maxFrameBytes === MAX_LINE_BYTES && + maxReassembledFrameBytes === MAX_RPC_REASSEMBLED_BYTES; const watermark = frame.transcriptWatermark; if (watermark && typeof watermark === "object" && !Array.isArray(watermark)) { const candidate = watermark as Record; diff --git a/packages/host-service/src/server.ts b/packages/host-service/src/server.ts index 680d3363..55ce11a7 100644 --- a/packages/host-service/src/server.ts +++ b/packages/host-service/src/server.ts @@ -5,6 +5,8 @@ import { chmod, stat as fsStat, lstat, open, readlink, realpath, rename, symlink import { dirname, isAbsolute, join } from "node:path"; import { type AttentionOutcome, + type CatalogItem, + catalogId, COMMAND_DESCRIPTORS, type CommandFrame, type ConfirmationChallenge, @@ -30,6 +32,7 @@ import { parseBounded, type OperationCapability, projectId, + type ProjectId, type ResultFrame, requiredCapability, revision as wireRevision, @@ -41,6 +44,7 @@ import { sessionId, type TranscriptContextArguments, type TranscriptPageArguments, + type TranscriptPageResult, type TranscriptSearchArguments, type UsageReadResult, utf8ByteLength, @@ -75,6 +79,7 @@ import { unixSocketActive, } from "./identity.ts"; import { ImageUploadError, ImageUploadStore } from "./image-upload-store.ts"; +import { SessionOwnershipStore } from "./session-ownership-store.ts"; import { commandFeature, DesktopOperationDispatcher, @@ -185,6 +190,7 @@ const OBSERVER_READ_COMMANDS = new Set([ ]); const SUBAGENT_TRANSCRIPT_RPC_BYTES = 384 * 1024; const REMOTE_OUTBOUND_TRANSFORM_TIMEOUT_MS = 10_000; +const CATALOG_OPERATION_REFRESH_TIMEOUT_MS = 750; const DEFAULT_USAGE_READ_TIMEOUT_MS = 15_000; const PENDING_PROMPT_TEXT_BYTES = 8 * 1024; // cleanText removes most controls, but retained newlines, quotes, and @@ -305,6 +311,30 @@ async function boundedRemoteTransform(operation: Promise | T): Promise } } +async function boundedOperationCapabilityRefresh( + supervisor: RpcChildSupervisor, + requestId: string, + signal: AbortSignal, +): Promise { + const controller = new AbortController(); + const onAbort = (): void => controller.abort(); + signal.addEventListener("abort", onAbort, { once: true }); + const timer = setTimeout(() => { + controller.abort(); + }, CATALOG_OPERATION_REFRESH_TIMEOUT_MS); + try { + return await supervisor.refreshOperationCapabilities(requestId, controller.signal); + } catch (error) { + if (signal.aborted) throw error; + // Runtime command discovery enriches the pinned catalog, but must never + // prevent the base command list (including terminal access) from loading. + return undefined; + } finally { + clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + } +} + async function raceAbortSignal(operation: Promise, signal: AbortSignal): Promise { if (signal.aborted) throw new Error("operation aborted"); const gate = Promise.withResolvers(); @@ -471,6 +501,44 @@ function safeProviderTransport(value: unknown): ProviderTransportState | undefin return undefined; } } +const SESSION_UNVERIFIED_FEATURE = "session.unverified"; +function downgradeUnverifiedSessionControl(ref: SessionRef): SessionRef { + const control = ref.liveState?.sessionControl; + if (control?.mode !== "unverified") return ref; + return { + ...ref, + liveState: { + ...ref.liveState, + sessionControl: { mode: "reconciling", transcript: control.transcript }, + }, + }; +} +function downgradeUnverifiedSessionFrame(frame: ServerFrame): ServerFrame { + if (frame.type === "sessions") + return { ...frame, sessions: frame.sessions.map(downgradeUnverifiedSessionControl) }; + if (frame.type === "session.delta" && frame.upsert) + return { ...frame, upsert: downgradeUnverifiedSessionControl(frame.upsert) }; + if ( + frame.type === "response" && + frame.ok && + (frame.command === "host.list" || frame.command === "session.list") && + frame.result && + typeof frame.result === "object" && + !Array.isArray(frame.result) + ) { + const result = frame.result as Record; + if (!Array.isArray(result.sessions)) return frame; + return { + ...frame, + result: { + ...result, + sessions: result.sessions.map(session => + downgradeUnverifiedSessionControl(session as SessionRef)), + }, + } as ServerFrame; + } + return frame; +} function childBoolean(value: unknown, key: string): boolean { const record = value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; @@ -694,6 +762,7 @@ export function appserverSupportedFeatures( "prompt.images", "agent.transcript", "session.observer", + SESSION_UNVERIFIED_FEATURE, "artifacts.read", ]); if (includeRemotePolicy) { @@ -784,6 +853,8 @@ export class LocalAppserver implements AppserverHandle { #sessionLoads = new Map>(); #inventoryGeneration = 0; #inventoryLoaded = false; + #inventoryComplete = false; + #inventoryTotalCount = 0; #discoveryMisses = new Map(); #agentTranscripts = new Map(); #startPromises = new Map>(); @@ -820,6 +891,7 @@ export class LocalAppserver implements AppserverHandle { #hostProvided: boolean; #hostIdPath?: string; #attentionOutcomes?: AttentionOutcomeStore; + #sessionOwnership?: SessionOwnershipStore; #ownerLock = false; #ownerPaths?: OwnerPaths; #ownerHandle?: FileHandle; @@ -863,6 +935,7 @@ export class LocalAppserver implements AppserverHandle { options.attentionOutcomePath ?? join(dirname(hostIdPath), "attention-outcomes.json"), ); } + if (options.sessionOwnershipPath) this.#sessionOwnership = new SessionOwnershipStore(options.sessionOwnershipPath); this.epoch = createEpoch(options.epoch); this.socketPath = options.socketPath ?? defaultSocketPath(); this.#remotePolicy = options.remotePolicy; @@ -999,7 +1072,7 @@ export class LocalAppserver implements AppserverHandle { if (command.command === "session.compact") return { type, customInstructions: command.args.instructions }; if (command.command === "session.rename") return { type, name: command.args.name }; if (command.command === "session.model.set") { - if (this.#rpcDialect === "official-17.0.6") { + if (this.#rpcDialect === "official-17.0.9") { if ( command.args.persistence !== "session" || typeof command.args.selector !== "string" || @@ -1029,11 +1102,14 @@ export class LocalAppserver implements AppserverHandle { if (this.#started) return; this.#inventoryGeneration += 1; this.#inventoryLoaded = false; + this.#inventoryComplete = false; + this.#inventoryTotalCount = 0; this.#stopping = false; this.#draining = false; this.#closedSessions.clear(); if (!this.#hostProvided) this.hostId = await loadPersistentHostId(this.#hostIdPath); await this.#attentionOutcomes?.load(); + await this.#sessionOwnership?.load(); try { await this.#transcriptSearch?.initialize(); this.#records.clear(); @@ -1241,6 +1317,7 @@ export class LocalAppserver implements AppserverHandle { this.#transcriptSearchReconcile = undefined; this.#transcriptSearchRerun = false; await this.#attentionOutcomes?.flush().catch(() => undefined); + await this.#sessionOwnership?.flush().catch(() => undefined); this.#started = false; const identity = this.#runIdentity; if (identity) await this.cleanupOwned(identity); @@ -1500,7 +1577,7 @@ export class LocalAppserver implements AppserverHandle { } const projection = command.sessionId ? this.#projections.get(command.sessionId) : undefined; // Attach output is connection-scoped and rebuilt on every delivery. A - if (this.observerBarrierBlocks(command)) return this.observerBarrierOutcome(command); + if (await this.observerBarrierBlocks(command)) return this.observerBarrierOutcome(command); // cached success cannot attach a session that has since been deleted. if (command.command === "session.attach" && !projection) return { @@ -1674,7 +1751,15 @@ export class LocalAppserver implements AppserverHandle { ) as unknown as TranscriptPageArguments; const record = this.#records.get(command.sessionId!); if (!record) throw new TranscriptPageError("transcript_page_unavailable"); - const result = await this.#discovery.page(record, args); + const authorityResult = await this.#discovery.page(record, args); + const result: TranscriptPageResult = { + ...authorityResult, + entries: authorityResult.entries.map(entry => ({ + ...entry, + hostId: this.hostId, + sessionId: command.sessionId!, + })), + }; outcome = { frame: response(this.hostId, command, true, result) }; } } else if (command.command === "transcript.search") { @@ -2129,11 +2214,14 @@ export class LocalAppserver implements AppserverHandle { if (attachedSessions?.size === 1) { const attachedSessionId = attachedSessions.values().next().value; const supervisor = attachedSessionId ? this.#supervisors.get(attachedSessionId) : undefined; - if (supervisor) - runtimeOperations = await supervisor.refreshOperationCapabilities( + if (supervisor) { + const refreshed = await boundedOperationCapabilityRefresh( + supervisor, `catalog:${command.requestId}`, controller.signal, ); + if (refreshed) runtimeOperations = refreshed; + } } const operationsById = new Map(); for (const capability of catalog.operations ?? []) @@ -2142,14 +2230,34 @@ export class LocalAppserver implements AppserverHandle { const operations = [...operationsById.values()]; if (operations.length > MAX_ARRAY_ITEMS) throw Object.assign(new Error("catalog operation limit exceeded"), { code: "BOUNDS" }); + const terminalAvailable = + this.#supportedFeatures.has("terminal.io") && + this.#supportedCapabilities.has("term.open") && + this.#supportedCapabilities.has("term.input") && + this.#supportedCapabilities.has("term.resize"); + const authorityItems = catalog.items as readonly CatalogItem[]; + const items = terminalAvailable + ? [ + { + id: catalogId("cmd-term-open"), + kind: "command" as const, + name: "term.open", + capabilities: ["term.open"], + supported: true, + }, + ...authorityItems.filter(item => item.id !== "cmd-term-open" && item.name !== "term.open"), + ].slice(0, MAX_ARRAY_ITEMS) + : authorityItems; const revisionHash = createHash("sha256") .update(catalog.revision) .update(JSON.stringify(operations)) + .update(JSON.stringify(items)) .digest("hex"); outcome = { frame: response(this.hostId, command, true, { ...catalog, revision: wireRevision(`capabilities-${revisionHash}`), + items, operations, }), }; @@ -2267,6 +2375,12 @@ export class LocalAppserver implements AppserverHandle { status: "idle", entries: created.entries, }; + try { + await this.#sessionOwnership?.add(record.sessionId, record.path); + } catch (error) { + await this.#authority.delete(record).catch(() => undefined); + throw error; + } this.#records.set(record.sessionId, record); this.#projections.set(record.sessionId, new SessionProjection(this.hostId, record, this.epoch, this.#ringSize)); this.#createdPending.set(record.sessionId, { record, refreshesRemaining: 1 }); @@ -2626,20 +2740,41 @@ export class LocalAppserver implements AppserverHandle { if (!this.#projectRootForProject) throw new Error("project resolver is unavailable"); if (typeof value !== "string") throw new Error("projectId is invalid"); const requestedProject = projectId(value); + const indexed = [...this.#records.values()].filter( + record => record.runtime === undefined && record.projectId === requestedProject, + ); + for (const record of indexed) { + const canonical = await this.canonicalProjectRoot(record.cwd, requestedProject); + if (canonical !== undefined) return canonical; + } const requestedCwd = await this.#projectRootForProject(requestedProject); if (typeof requestedCwd !== "string" || !requestedCwd.startsWith("/")) throw new Error("project resolver returned an invalid local root"); - let canonical: string; - try { - canonical = await realpath(requestedCwd); - if (!(await fsStat(canonical)).isDirectory()) throw new Error("not a directory"); - } catch { + const canonical = await this.canonicalProjectRoot(requestedCwd, requestedProject); + if (canonical === undefined) { + let available = false; + try { + available = (await fsStat(await realpath(requestedCwd))).isDirectory(); + } catch {} + if (available) + throw new Error("project resolver returned a mismatched local root"); throw new Error("project resolver returned an unavailable local root"); } - if (stableProjectId(canonical) !== requestedProject) - throw new Error("project resolver returned a mismatched local root"); return canonical; } + private async canonicalProjectRoot( + candidate: string, + requestedProject: ProjectId, + ): Promise { + if (!candidate.startsWith("/")) return undefined; + try { + const canonical = await realpath(candidate); + if (!(await fsStat(canonical)).isDirectory()) return undefined; + return stableProjectId(canonical) === requestedProject ? canonical : undefined; + } catch { + return undefined; + } + } private workspaceProjection(record: WorkspaceRecord): Record { return { repositoryId: record.repositoryId, @@ -2751,7 +2886,9 @@ export class LocalAppserver implements AppserverHandle { } private async handleCreate(command: CommandFrame): Promise { const created = await this.createSession(command.args); - const projection = this.#projections.get(created.sessionId as SessionId)!; + const createdSessionId = created.sessionId as SessionId; + const projection = this.#projections.get(createdSessionId)!; + if (!this.#externalRuntimes.has(createdSessionId)) await this.ensureSupervisor(createdSessionId); await this.broadcastIndex(projection.indexUpsert()); return { frame: response(this.hostId, command, true, { @@ -2830,9 +2967,23 @@ export class LocalAppserver implements AppserverHandle { this.#records.get(sessionId)?.archivedAt || this.#projections.get(sessionId)?.value.ref.archivedAt, ); } - private observerBarrierBlocks(command: CommandFrame): boolean { + private async observerBarrierBlocks(command: CommandFrame): Promise { if (!command.sessionId) return false; if (this.#externalRuntimes.has(command.sessionId)) return false; + if (command.command === "session.restore" && this.sessionArchived(command.sessionId)) { + const record = this.#records.get(command.sessionId); + if (!record) return true; + try { + const status = await this.#lockStatus(record); + // A stale lock is the same safe takeover boundary used by + // observer promotion. A live, suspect, or malformed lock keeps + // restore read-only until authority can be established. + if (status === "missing" || status === "stale") return false; + } catch { + // Lock inspection failures fail closed. + } + return true; + } if (command.command !== "session.state.get" && OBSERVER_READ_COMMANDS.has(command.command)) return false; return ( this.#observers.has(command.sessionId) || @@ -3135,6 +3286,7 @@ export class LocalAppserver implements AppserverHandle { this.#records.delete(sessionId); this.#projections.delete(sessionId); await this.#attentionOutcomes?.delete(sessionId); + await this.#sessionOwnership?.delete(sessionId); this.#closedSessions.delete(sessionId); this.#releaseAllMessageLifecycles(sessionId, "completed-without-entry"); this.#stateRefreshGenerations.delete(sessionId); @@ -3554,6 +3706,22 @@ export class LocalAppserver implements AppserverHandle { if (frame.type === "thinking_level_changed") this.scheduleStateRefresh(sessionId, supervisor, "thinking-level-changed"); }, + transcriptRecordOmitted: offset => { + const omission: DurableEntry = { + id: entryId(`oversized-transcript-record-${offset}`), + parentId: projection.value.entries.at(-1)?.id ?? null, + hostId: this.hostId, + sessionId, + kind: "compaction", + timestamp: this.#clock.now().toISOString(), + data: { + summary: "One transcript record was omitted because it exceeded the 1 MiB safety limit.", + oversizedRecordOmission: true, + }, + }; + const output = projection.appendEntry(omission); + if (output) this.broadcast(sessionId, output); + }, crashed: () => { this.markSupervisorCrashed(sessionId, supervisor); }, @@ -3754,7 +3922,7 @@ export class LocalAppserver implements AppserverHandle { return; } const descriptor = COMMAND_DESCRIPTORS[frame.command]; - if (this.observerBarrierBlocks(frame)) { + if (await this.observerBarrierBlocks(frame)) { await this.#sendFrame(ws, this.observerBarrierOutcome(frame).frame); return; } @@ -4057,14 +4225,17 @@ export class LocalAppserver implements AppserverHandle { } } async #sendFrameNow(transport: AppWs, frame: ServerFrame): Promise { + const compatibleFrame = this.#clientFeatures.get(transport)?.has(SESSION_UNVERIFIED_FEATURE) + ? frame + : downgradeUnverifiedSessionFrame(frame); if (transport.remote) { const connection = this.#remoteConnections.get(transport); if (!connection || !this.#remotePolicy) return false; let transformed: ServerFrame | string | undefined; try { transformed = this.#remotePolicy.transformOutbound - ? await boundedRemoteTransform(this.#remotePolicy.transformOutbound(connection, frame)) - : frame; + ? await boundedRemoteTransform(this.#remotePolicy.transformOutbound(connection, compatibleFrame)) + : compatibleFrame; } catch { connection.socket.close(1011, "remote policy failed"); return false; @@ -4072,7 +4243,7 @@ export class LocalAppserver implements AppserverHandle { if (transformed === undefined) return false; return transport.send(typeof transformed === "string" ? transformed : JSON.stringify(transformed)); } - return transport.send(JSON.stringify(frame)); + return transport.send(JSON.stringify(compatibleFrame)); } private refreshSessions(): Promise { if (this.#sessionRefresh) return this.#sessionRefresh; @@ -4087,6 +4258,16 @@ export class LocalAppserver implements AppserverHandle { private async refreshSessionsOnce(generation: number): Promise { const discovered = await this.#discovery.list(); if (generation !== this.#inventoryGeneration || this.#stopping || !this.#started) return; + const inventoryComplete = this.#discovery.inventoryComplete?.() ?? true; + const reportedTotal = this.#discovery.inventoryTotalCount?.() ?? discovered.length; + if ( + !Number.isSafeInteger(reportedTotal) || + reportedTotal < discovered.length || + (inventoryComplete && reportedTotal !== discovered.length) + ) + throw new Error("session inventory completeness metadata is invalid"); + this.#inventoryComplete = inventoryComplete; + this.#inventoryTotalCount = reportedTotal; const publishChanges = this.#inventoryLoaded; const discoveredIds = new Set(); for (const record of discovered) { @@ -4124,6 +4305,15 @@ export class LocalAppserver implements AppserverHandle { if (output && publishChanges) await this.broadcastIndex(output); } } + if (!inventoryComplete) { + // Absence from a partial inventory is not deletion evidence. Reset + // miss counters so eviction still requires two later complete + // inventories that both omit the same session. + this.#discoveryMisses.clear(); + this.#inventoryLoaded = true; + this.scheduleTranscriptSearchReconcile(); + return; + } for (const [sessionId, pending] of this.#createdPending) { if (discoveredIds.has(sessionId)) { this.#createdPending.delete(sessionId); @@ -4131,16 +4321,28 @@ export class LocalAppserver implements AppserverHandle { } if (pending.refreshesRemaining > 0) pending.refreshesRemaining -= 1; else { - this.#createdPending.delete(sessionId); - this.#discoveryMisses.delete(sessionId); - const projection = this.#projections.get(sessionId); - if (projection) await this.broadcastIndex(projection.remove()); - this.cleanupObserverState(sessionId); - await this.#imageUploads.cleanupSession(sessionId); - this.#records.delete(sessionId); - this.#projections.delete(sessionId); - await this.#attentionOutcomes?.delete(sessionId); - this.#stateRefreshGenerations.delete(sessionId); + // A create can start its writer before discovery catches up. + // Never discard its ownership proof while that writer may still + // be live; retain the pending record and retry on the next refresh. + if (this.#lifecycleMutations.has(sessionId)) continue; + this.#lifecycleMutations.add(sessionId); + try { + if (this.sessionLifecycleBusy(sessionId, true)) continue; + if (!(await this.quiesceSessionRuntime(sessionId))) continue; + this.#createdPending.delete(sessionId); + this.#discoveryMisses.delete(sessionId); + const projection = this.#projections.get(sessionId); + if (projection) await this.broadcastIndex(projection.remove()); + this.cleanupObserverState(sessionId); + await this.#imageUploads.cleanupSession(sessionId); + this.#records.delete(sessionId); + this.#projections.delete(sessionId); + await this.#attentionOutcomes?.delete(sessionId); + await this.#sessionOwnership?.delete(sessionId); + this.#stateRefreshGenerations.delete(sessionId); + } finally { + this.#lifecycleMutations.delete(sessionId); + } } } for (const sessionId of this.#records.keys()) { @@ -4166,6 +4368,7 @@ export class LocalAppserver implements AppserverHandle { await this.#transcriptSearch?.deleteSession(sessionId); this.#projections.delete(sessionId); await this.#attentionOutcomes?.delete(sessionId); + await this.#sessionOwnership?.delete(sessionId); this.#closedSessions.delete(sessionId); this.#releaseAllMessageLifecycles(sessionId, "completed-without-entry"); this.#stateRefreshGenerations.delete(sessionId); @@ -4204,10 +4407,26 @@ export class LocalAppserver implements AppserverHandle { if (loaded.sessionId !== record.sessionId || loaded.path !== record.path) throw new Error("session identity changed while loading transcript"); this.#records.set(loaded.sessionId, loaded); - const projection = new SessionProjection(this.hostId, loaded, this.epoch, this.#ringSize); - const outcome = this.#attentionOutcomes?.get(loaded.sessionId); - if (outcome) projection.setLatestOutcome(outcome); - this.#projections.set(loaded.sessionId, projection); + const projection = this.#projections.get(loaded.sessionId); + if (!projection) { + const inserted = new SessionProjection(this.hostId, loaded, this.epoch, this.#ringSize); + const outcome = this.#attentionOutcomes?.get(loaded.sessionId); + if (outcome) inserted.setLatestOutcome(outcome); + this.#projections.set(loaded.sessionId, inserted); + return; + } + // A running RPC supervisor closes over this projection. Replacing it after + // sparse discovery hydration would split cursor authority: streamed frames + // would advance the old object while later attaches read a new seq-0 object. + // Rebase in place so the writer, replay ring, and attach baseline stay one + // monotonic timeline. + const transcriptFrames = projection.rebaseEntries(loaded.entries); + for (const frame of transcriptFrames) this.broadcast(loaded.sessionId, frame); + const metadata = projection.value.ref.liveState?.sessionControl + ? projection.reconcileObserverRecord(loaded) + : projection.reconcileRecord(loaded); + if (metadata) await this.broadcastIndex(metadata); + else if (transcriptFrames.length > 0) await this.broadcastIndex(projection.indexUpsert()); } private async refreshTranscriptSearch(): Promise { if (!this.#transcriptSearch) return; @@ -4233,7 +4452,13 @@ export class LocalAppserver implements AppserverHandle { } const authority = this.#transcriptSearch; const records = [...this.#records.values()]; - const reconcile = authority.reconcile(records); + const pruneMissing = this.#inventoryComplete; + // Search maintenance is deliberately a later task. Reconciliation may do + // synchronous SQLite work before its first await; starting it inline would + // delay the authoritative sessions frame after Welcome. + const reconcile = new Promise(resolve => setTimeout(resolve, 0)).then(() => + authority.reconcile(records, { pruneMissing }), + ); this.#transcriptSearchReconcile = reconcile; void reconcile .catch(() => undefined) @@ -4365,8 +4590,9 @@ export class LocalAppserver implements AppserverHandle { } let observer = this.#observers.get(sessionId); if (!observer) { + const owned = this.#sessionOwnership?.owns(sessionId, record.path) === true; const lockless = - !this.#claimLocklessSessions && status === "missing" && !projection.value.ref.liveState?.sessionControl; + !this.#claimLocklessSessions && !owned && status === "missing" && !projection.value.ref.liveState?.sessionControl; observer = new SessionTranscriptObserver(record.path, this.hostId); this.#observers.set(sessionId, observer); if (lockless) this.#locklessObservers.add(observer); @@ -4384,11 +4610,18 @@ export class LocalAppserver implements AppserverHandle { if (pollRecordMatches && (!lockless || establishingLocklessBaseline || poll.changed)) await this.applyObserverPoll(sessionId, projection, poll); if (!this.observerIsCurrent(sessionId, observer, record, projection)) return; - const reconciling = projection.setSessionControl({ - mode: "reconciling", - transcript: pollRecordMatches ? poll.transcript : "snapshot", - }); - if (reconciling) await this.broadcastIndex(reconciling); + const sessionControl = projection.setSessionControl( + lockless + ? { + mode: "unverified", + transcript: pollRecordMatches ? poll.transcript : "snapshot", + } + : { + mode: "reconciling", + transcript: pollRecordMatches ? poll.transcript : "snapshot", + }, + ); + if (sessionControl) await this.broadcastIndex(sessionControl); if (!this.observerIsCurrent(sessionId, observer, record, projection)) return; if (lockless) return; if (!this.hasAttachedClient(sessionId)) return; @@ -4464,6 +4697,21 @@ export class LocalAppserver implements AppserverHandle { return; } if (this.#supervisors.get(sessionId) !== supervisor) return; + try { + await this.#sessionOwnership?.add(sessionId, record.path); + } catch { + await this.discardPromotionSupervisor(sessionId, supervisor); + this.#promotionFailures.set(sessionId, this.promotionFingerprint(record, projection, final)); + return; + } + if (!this.observerIsCurrent(sessionId, observer, record, projection)) { + if (this.#supervisors.get(sessionId) === supervisor) { + await this.#sessionOwnership?.delete(sessionId).catch(() => undefined); + await this.discardPromotionSupervisor(sessionId, supervisor); + } + return; + } + if (this.#supervisors.get(sessionId) !== supervisor) return; const control = projection.setSessionControl(); if (control) await this.broadcastIndex(control); this.cleanupObserverState(sessionId); @@ -4485,7 +4733,7 @@ export class LocalAppserver implements AppserverHandle { if (a.sessionId > b.sessionId) return 1; return 0; }); - const totalCount = allSessions.length; + const totalCount = Math.max(allSessions.length, this.#inventoryTotalCount); const pendingBudget: SessionListBudget = { pendingBytes: 0, pendingEntries: 0, diff --git a/packages/host-service/src/session-ownership-store.ts b/packages/host-service/src/session-ownership-store.ts new file mode 100644 index 00000000..11790bde --- /dev/null +++ b/packages/host-service/src/session-ownership-store.ts @@ -0,0 +1,150 @@ +import { randomUUID } from "node:crypto"; +import { Buffer } from "node:buffer"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { type SessionId, sessionId } from "@t4-code/host-wire"; + +const LEDGER_VERSION = 1; +const MAX_SESSIONS = 10_000; +const MAX_LEDGER_BYTES = 3 * 1024 * 1024; +const MAX_PATH_BYTES = 16 * 1024; + +interface OwnedSessionRecord { + readonly sessionId: SessionId; + readonly path: string; +} + +interface SessionOwnershipLedger { + readonly version: typeof LEDGER_VERSION; + readonly sessions: readonly OwnedSessionRecord[]; +} + +function exactKeys(value: Record, expected: readonly string[]): boolean { + const keys = Object.keys(value).sort(); + return keys.length === expected.length && [...expected].sort().every((key, index) => keys[index] === key); +} + +function decodeRecord(value: unknown): OwnedSessionRecord | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const record = value as Record; + if (!exactKeys(record, ["sessionId", "path"]) || typeof record.path !== "string") return undefined; + if (!path.isAbsolute(record.path) || Buffer.byteLength(record.path, "utf8") > MAX_PATH_BYTES) return undefined; + try { + return { sessionId: sessionId(record.sessionId, "sessions[].sessionId"), path: record.path }; + } catch { + return undefined; + } +} + +function decodeLedger(value: unknown): SessionOwnershipLedger | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const ledger = value as Record; + if (!exactKeys(ledger, ["version", "sessions"]) || ledger.version !== LEDGER_VERSION) return undefined; + if (!Array.isArray(ledger.sessions) || ledger.sessions.length > MAX_SESSIONS) return undefined; + const sessions: OwnedSessionRecord[] = []; + const seen = new Set(); + for (const value of ledger.sessions) { + const record = decodeRecord(value); + if (!record || seen.has(record.sessionId)) return undefined; + seen.add(record.sessionId); + sessions.push(record); + } + return { version: LEDGER_VERSION, sessions }; +} + +/** Private profile-local proof that a session was created through this T4 host profile. */ +export class SessionOwnershipStore { + readonly path: string; + #sessions = new Map(); + #tail = Promise.resolve(); + constructor(filePath: string) { + this.path = filePath; + } + async load(): Promise { + this.#sessions.clear(); + let metadata: Awaited>; + try { + metadata = await fs.lstat(this.path); + } catch { + return; + } + if (!metadata.isFile() || (metadata.mode & 0o777) !== 0o600 || metadata.size > MAX_LEDGER_BYTES) return; + let parsed: unknown; + try { + parsed = JSON.parse(await Bun.file(this.path).text()) as unknown; + } catch { + return; + } + const ledger = decodeLedger(parsed); + if (!ledger) return; + this.#sessions = new Map(ledger.sessions.map(record => [record.sessionId, record.path])); + } + owns(id: SessionId, transcriptPath: string): boolean { + return this.#sessions.get(id) === transcriptPath; + } + add(id: SessionId, transcriptPath: string): Promise { + if (!path.isAbsolute(transcriptPath) || Buffer.byteLength(transcriptPath, "utf8") > MAX_PATH_BYTES) + return Promise.reject(new Error("owned session path must be a bounded absolute path")); + const operation = this.#tail.catch(() => undefined).then(async () => { + if (!this.#sessions.has(id) && this.#sessions.size >= MAX_SESSIONS) + throw new Error("owned session ledger is full"); + const previous = this.#sessions.get(id); + this.#sessions.set(id, transcriptPath); + try { + await this.#write(this.#ledger()); + } catch (error) { + if (previous === undefined) this.#sessions.delete(id); + else this.#sessions.set(id, previous); + throw error; + } + }); + this.#tail = operation; + return operation; + } + delete(id: SessionId): Promise { + const operation = this.#tail.catch(() => undefined).then(async () => { + const previous = this.#sessions.get(id); + if (previous === undefined) return; + this.#sessions.delete(id); + try { + await this.#write(this.#ledger()); + } catch (error) { + this.#sessions.set(id, previous); + throw error; + } + }); + this.#tail = operation; + return operation; + } + async flush(): Promise { + await this.#tail; + } + #ledger(): SessionOwnershipLedger { + return { + version: LEDGER_VERSION, + sessions: [...this.#sessions] + .map(([id, transcriptPath]) => ({ sessionId: id, path: transcriptPath })) + .sort((left, right) => left.sessionId.localeCompare(right.sessionId)), + }; + } + async #write(ledger: SessionOwnershipLedger): Promise { + const directory = path.dirname(this.path); + await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + await fs.chmod(directory, 0o700); + const temporary = `${this.path}.${randomUUID()}.tmp`; + try { + const handle = await fs.open(temporary, "wx", 0o600); + try { + await handle.writeFile(`${JSON.stringify(ledger)}\n`); + await handle.sync(); + } finally { + await handle.close(); + } + await fs.rename(temporary, this.path); + await fs.chmod(this.path, 0o600); + } catch (error) { + await fs.unlink(temporary).catch(() => undefined); + throw error; + } + } +} diff --git a/packages/host-service/src/transcript-search-index.ts b/packages/host-service/src/transcript-search-index.ts index e910c9d9..e8313900 100644 --- a/packages/host-service/src/transcript-search-index.ts +++ b/packages/host-service/src/transcript-search-index.ts @@ -411,16 +411,25 @@ export class TranscriptSearchIndex { this.#commitMutation(() => db.run("UPDATE sessions SET state='stale' WHERE session_id=?", [session]).changes > 0); } - async reconcile(records: readonly SessionRecord[]): Promise { + async reconcile( + records: readonly SessionRecord[], + options: { readonly pruneMissing?: boolean } = {}, + ): Promise { const db = this.#database(); this.#state = "building"; this.#knownSessions = records.length; let failed = false; const wanted = new Set(records.map(record => String(record.sessionId))); const existingIds = db.query("SELECT session_id FROM sessions").all() as Array<{ session_id: string }>; - for (const { session_id } of existingIds) { - if (!wanted.has(session_id)) - this.#commitMutation(() => db.run("DELETE FROM sessions WHERE session_id=?", [session_id]).changes > 0); + if (options.pruneMissing !== false) { + for (const { session_id } of existingIds) { + if (!wanted.has(session_id)) + this.#commitMutation(() => db.run("DELETE FROM sessions WHERE session_id=?", [session_id]).changes > 0); + } + } else { + const known = new Set(existingIds.map(({ session_id }) => session_id)); + for (const session_id of wanted) known.add(session_id); + this.#knownSessions = known.size; } for (const record of records) { try { @@ -430,7 +439,7 @@ export class TranscriptSearchIndex { this.#markSessionStale(String(record.sessionId)); } } - this.#state = failed ? "stale" : "ready"; + this.#state = failed || options.pruneMissing === false ? "stale" : "ready"; this.#refreshStatus(); return this.status(); } diff --git a/packages/host-service/src/types.ts b/packages/host-service/src/types.ts index 58c59564..952cf93f 100644 --- a/packages/host-service/src/types.ts +++ b/packages/host-service/src/types.ts @@ -132,6 +132,10 @@ export interface SessionAuthority { } export interface SessionDiscovery { list(): Promise; + /** Whether the most recent successful list proved the inventory complete. */ + inventoryComplete?(): boolean; + /** Authoritative total for the most recent successful list, including omitted rows. */ + inventoryTotalCount?(): number; /** Load the bounded transcript snapshot for one lazily discovered session. */ load?(session: SessionRecord): Promise; /** Read one bounded chronological page backward from the authoritative JSONL file. */ @@ -174,7 +178,10 @@ export interface AppserverUsageAuthority { } export interface AppserverTranscriptSearchAuthority { initialize(): Promise; - reconcile(records: readonly SessionRecord[]): Promise; + reconcile( + records: readonly SessionRecord[], + options?: { readonly pruneMissing?: boolean }, + ): Promise; search( args: TranscriptSearchArguments, signal: AbortSignal, @@ -227,6 +234,8 @@ export interface AppserverOptions { hostIdPath?: string; /** Private latest-outcome ledger path. Defaults beside the persistent host identity unless hostId is explicit. */ attentionOutcomePath?: string; + /** Private profile-local ledger of exact sessions created through T4. */ + sessionOwnershipPath?: string; epoch?: string; clock?: Clock; discovery?: SessionDiscovery; @@ -259,7 +268,7 @@ export interface AppserverOptions { /** Bounded profile environment applied only to per-session OMP children. */ rpcChildEnvironment?: Readonly>; /** Exact child RPC command dialect; official OMP intentionally exposes a narrower command set. */ - rpcDialect?: "fork" | "official-17.0.6"; + rpcDialect?: "fork" | "official-17.0.9"; appserverVersion?: string; appserverBuild?: string; supportedFeatures?: readonly string[]; diff --git a/packages/host-service/test/appserver.test.ts b/packages/host-service/test/appserver.test.ts index a0be4b0e..63732af1 100644 --- a/packages/host-service/test/appserver.test.ts +++ b/packages/host-service/test/appserver.test.ts @@ -1,13 +1,14 @@ import { describe, expect, test } from "bun:test"; -import { appendFile, mkdir, mkdtemp, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { appendFile, mkdir, mkdtemp, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { DESKTOP_CATALOG_COMMANDS, type DurableEntry, hostId, projectId, sessionId } from "@t4-code/host-wire"; import { completeAttachOutput, prepareAttachOutput } from "../src/attach-output.ts"; import { IdempotencyStore } from "../src/idempotency.ts"; import { ensureSecureSocketDirectory } from "../src/ownership.ts"; -import { FileSessionDiscovery, realFs } from "../src/discovery.ts"; +import { FileSessionDiscovery, realFs, stableProjectId } from "../src/discovery.ts"; import { SessionProjection } from "../src/projection.ts"; +import { SessionOwnershipStore } from "../src/session-ownership-store.ts"; import { appserverSupportedCapabilities, appserverSupportedFeatures, createAppserver } from "../src/server.ts"; import { SubagentProjection } from "../src/subagent-projection.ts"; import type { ChildHandle, RpcChildFactory, SessionDiscovery, SessionRecord } from "../src/types.ts"; @@ -60,6 +61,73 @@ class FakeFactory implements RpcChildFactory { return ["omp", "--mode", "rpc", "--session", path]; } } +class DeferredPromptChild implements ChildHandle { + #prompt = Promise.withResolvers>(); + #state = Promise.withResolvers>(); + #reply = Promise.withResolvers(); + #finish = Promise.withResolvers(); + #exited = Promise.withResolvers(); + killed = false; + promptReceived = this.#prompt.promise; + stdin = { + write: (data: string) => { + const frame = JSON.parse(data) as Record; + if (frame.type === "prompt") this.#prompt.resolve(frame); + else if (frame.type === "get_state") this.#state.resolve(frame); + }, + }; + stdout: AsyncIterable = this.stream(); + exited = this.#exited.promise; + async *stream() { + yield `${JSON.stringify({ type: "ready" })}\n`; + const prompt = await this.#prompt.promise; + await this.#reply.promise; + yield `${JSON.stringify({ + type: "response", + id: prompt.id, + command: "prompt", + success: false, + })}\n`; + const state = await this.#state.promise; + yield `${JSON.stringify({ + type: "response", + id: state.id, + command: "get_state", + success: true, + data: { + isStreaming: false, + isCompacting: false, + isPaused: false, + messageCount: 0, + queuedMessageCount: 0, + steeringMode: "all", + followUpMode: "all", + interruptMode: "immediate", + }, + })}\n`; + await this.#finish.promise; + } + replyToPrompt() { + this.#reply.resolve(); + } + kill() { + this.killed = true; + this.#reply.resolve(); + this.#finish.resolve(); + this.#exited.resolve(0); + } +} +class DeferredPromptFactory implements RpcChildFactory { + children: DeferredPromptChild[] = []; + spawn() { + const child = new DeferredPromptChild(); + this.children.push(child); + return child; + } + argv(path: string) { + return ["omp", "--mode", "rpc", "--session", path]; + } +} class StaticDiscovery implements SessionDiscovery { constructor(private readonly records: SessionRecord[]) {} async list() { @@ -243,6 +311,7 @@ describe("appserver lifecycle", () => { "prompt.images", "agent.transcript", "session.observer", + "session.unverified", "artifacts.read", ]); }); @@ -337,6 +406,948 @@ describe("appserver lifecycle", () => { await expect(stat(socketPath)).rejects.toThrow(); for (const child of factory.children) expect(child.killed).toBe(true); }); + test("starts a writer from an indexed project before returning a session created through T4", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-created-session-")); + const socketPath = join(root, "run", "appserver.sock"); + const sessionOwnershipPath = join(root, "profile", "owned-sessions.json"); + const existing = { + ...record("existing-session"), + path: join(root, "existing-session.jsonl"), + cwd: root, + projectId: stableProjectId(root), + }; + const created = { + ...record("created-session"), + path: join(root, "created-session.jsonl"), + cwd: root, + projectId: stableProjectId(root), + }; + const factory = new FakeFactory(); + let visible = false; + let createdCwd: string | undefined; + const sessionAuthority = { + create: async (cwd: string) => { + createdCwd = cwd; + visible = true; + return created; + }, + list: async () => visible ? [created, existing] : [existing], + archive: async () => {}, + restore: async () => {}, + delete: async () => {}, + }; + const appserver = createAppserver({ + hostId: host, + epoch: "created-session-test", + socketPath, + discovery: sessionAuthority, + sessionAuthority, + sessionOwnershipPath, + projectRootForProject: () => { + throw new Error("partial authority inventory cannot resolve project roots"); + }, + childFactory: factory, + }); + await appserver.start(); + const client = await RawUdsWebSocket.connect(socketPath); + try { + client.sendJson({ + v: "omp-app/1", + type: "hello", + protocol: { min: "omp-app/1", max: "omp-app/1" }, + client: { name: "create-test", version: "1", build: "test", platform: "linux" }, + requestedFeatures: [], + capabilities: { client: ["sessions.manage", "sessions.read"] }, + savedCursors: [], + }); + expect(await client.nextServer()).toMatchObject({ type: "welcome" }); + expect((await client.nextServer()).type).toBe("sessions"); + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "create-session", + commandId: "create-session-command", + hostId: host, + command: "session.create", + args: { projectId: created.projectId }, + }); + for (;;) { + const frame = await client.nextServer(); + if (frame.type !== "response" || frame.requestId !== "create-session") continue; + expect(frame).toMatchObject({ ok: true }); + break; + } + expect(factory.children).toHaveLength(1); + expect(factory.children[0]?.killed).toBe(false); + expect(createdCwd).toBe(await realpath(root)); + const ownership = new SessionOwnershipStore(sessionOwnershipPath); + await ownership.load(); + expect(ownership.owns(created.sessionId, created.path)).toBe(true); + + const list = async (suffix: string): Promise => { + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: `list-${suffix}`, + commandId: `list-${suffix}-command`, + hostId: host, + command: "session.list", + args: {}, + }); + for (;;) { + const frame = await client.nextServer(); + if (frame.type !== "response" || frame.requestId !== `list-${suffix}`) continue; + expect(frame.ok).toBe(true); + return; + } + }; + await list("visible"); + visible = false; + await list("missing-once"); + await list("missing-twice"); + const pruned = new SessionOwnershipStore(sessionOwnershipPath); + await pruned.load(); + expect(pruned.owns(created.sessionId, created.path)).toBe(false); + } finally { + client.destroy(); + await client.closed(); + await appserver.stop(); + await rm(root, { recursive: true, force: true }); + } + }); + test("ignores external runtime records when resolving a native session project", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-created-session-external-record-")); + const worktree = join(root, "external-worktree"); + await mkdir(worktree); + const socketPath = join(root, "run", "appserver.sock"); + const requestedProject = stableProjectId(root); + const external = { + ...record("external-session"), + path: worktree, + cwd: worktree, + projectId: requestedProject, + runtime: { id: "external-runtime", workspaceInstanceId: "external-worktree" }, + }; + const created = { + ...record("native-created-session"), + path: join(root, "native-created-session.jsonl"), + cwd: root, + projectId: requestedProject, + }; + let createdCwd: string | undefined; + let resolverCalls = 0; + const sessionAuthority = { + create: async (cwd: string) => { + createdCwd = cwd; + return created; + }, + list: async () => [external], + archive: async () => {}, + restore: async () => {}, + delete: async () => {}, + }; + const appserver = createAppserver({ + hostId: host, + epoch: "external-record-project-test", + socketPath, + discovery: sessionAuthority, + sessionAuthority, + projectRootForProject: () => { + resolverCalls += 1; + return root; + }, + childFactory: new FakeFactory(), + }); + await appserver.start(); + const client = await RawUdsWebSocket.connect(socketPath); + try { + client.sendJson({ + v: "omp-app/1", + type: "hello", + protocol: { min: "omp-app/1", max: "omp-app/1" }, + client: { name: "external-record-test", version: "1", build: "test", platform: "linux" }, + requestedFeatures: [], + capabilities: { client: ["sessions.manage", "sessions.read"] }, + savedCursors: [], + }); + expect(await client.nextServer()).toMatchObject({ type: "welcome" }); + expect((await client.nextServer()).type).toBe("sessions"); + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "create-native-session", + commandId: "create-native-session-command", + hostId: host, + command: "session.create", + args: { projectId: requestedProject }, + }); + for (;;) { + const frame = await client.nextServer(); + if (frame.type !== "response" || frame.requestId !== "create-native-session") continue; + expect(frame).toMatchObject({ ok: true }); + break; + } + expect(resolverCalls).toBe(1); + expect(createdCwd).toBe(await realpath(root)); + } finally { + client.destroy(); + await client.closed(); + await appserver.stop(); + await rm(root, { recursive: true, force: true }); + } + }); + test("prunes ownership when a created session never enters discovery", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-created-session-missing-")); + const socketPath = join(root, "run", "appserver.sock"); + const sessionOwnershipPath = join(root, "profile", "owned-sessions.json"); + const created = { + ...record("created-session-missing"), + path: join(root, "created-session-missing.jsonl"), + cwd: root, + projectId: stableProjectId(root), + }; + const sessionAuthority = { + create: async () => created, + list: async () => [], + archive: async () => {}, + restore: async () => {}, + delete: async () => {}, + }; + const appserver = createAppserver({ + hostId: host, + epoch: "created-session-missing-test", + socketPath, + discovery: sessionAuthority, + sessionAuthority, + sessionOwnershipPath, + projectRootForProject: () => root, + childFactory: new FakeFactory(), + }); + await appserver.start(); + const client = await RawUdsWebSocket.connect(socketPath); + try { + client.sendJson({ + v: "omp-app/1", + type: "hello", + protocol: { min: "omp-app/1", max: "omp-app/1" }, + client: { name: "missing-create-test", version: "1", build: "test", platform: "linux" }, + requestedFeatures: [], + capabilities: { client: ["sessions.manage", "sessions.read"] }, + savedCursors: [], + }); + expect(await client.nextServer()).toMatchObject({ type: "welcome" }); + expect((await client.nextServer()).type).toBe("sessions"); + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "create-missing", + commandId: "create-missing-command", + hostId: host, + command: "session.create", + args: { projectId: created.projectId }, + }); + for (;;) { + const frame = await client.nextServer(); + if (frame.type === "response" && frame.requestId === "create-missing") { + expect(frame.ok).toBe(true); + break; + } + } + const owned = new SessionOwnershipStore(sessionOwnershipPath); + await owned.load(); + expect(owned.owns(created.sessionId, created.path)).toBe(true); + + for (const suffix of ["once", "twice"]) { + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: `list-missing-${suffix}`, + commandId: `list-missing-${suffix}-command`, + hostId: host, + command: "session.list", + args: {}, + }); + for (;;) { + const frame = await client.nextServer(); + if (frame.type !== "response" || frame.requestId !== `list-missing-${suffix}`) continue; + expect(frame.ok).toBe(true); + break; + } + } + const pruned = new SessionOwnershipStore(sessionOwnershipPath); + await pruned.load(); + expect(pruned.owns(created.sessionId, created.path)).toBe(false); + } finally { + client.destroy(); + await client.closed(); + await appserver.stop(); + await rm(root, { recursive: true, force: true }); + } + }); + test("defers missing-created-session cleanup while its first prompt is in flight", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-created-session-busy-")); + const socketPath = join(root, "run", "appserver.sock"); + const sessionOwnershipPath = join(root, "profile", "owned-sessions.json"); + const created = { + ...record("created-session-busy"), + path: join(root, "created-session-busy.jsonl"), + cwd: root, + projectId: stableProjectId(root), + }; + const sessionAuthority = { + create: async () => created, + list: async () => [], + archive: async () => {}, + restore: async () => {}, + delete: async () => {}, + }; + const factory = new DeferredPromptFactory(); + const appserver = createAppserver({ + hostId: host, + epoch: "created-session-busy-test", + socketPath, + discovery: sessionAuthority, + sessionAuthority, + sessionOwnershipPath, + projectRootForProject: () => root, + childFactory: factory, + }); + await appserver.start(); + const client = await RawUdsWebSocket.connect(socketPath); + const nextResponse = async (requestId: string) => { + for (;;) { + const frame = await client.nextServer(); + if (frame.type === "response" && frame.requestId === requestId) return frame; + } + }; + try { + client.sendJson({ + v: "omp-app/1", + type: "hello", + protocol: { min: "omp-app/1", max: "omp-app/1" }, + client: { name: "busy-create-test", version: "1", build: "test", platform: "linux" }, + requestedFeatures: [], + capabilities: { client: ["sessions.manage", "sessions.read", "sessions.prompt"] }, + savedCursors: [], + }); + expect(await client.nextServer()).toMatchObject({ type: "welcome" }); + expect((await client.nextServer()).type).toBe("sessions"); + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "create-busy", + commandId: "create-busy-command", + hostId: host, + command: "session.create", + args: { projectId: created.projectId }, + }); + expect(await nextResponse("create-busy")).toMatchObject({ ok: true }); + const child = factory.children[0]; + if (!child) throw new Error("created session did not start its writer"); + + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "prompt-busy", + commandId: "prompt-busy-command", + hostId: host, + sessionId: created.sessionId, + command: "session.prompt", + args: { message: "keep the first prompt active" }, + }); + await child.promptReceived; + + for (const suffix of ["once", "twice"]) { + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: `list-busy-${suffix}`, + commandId: `list-busy-${suffix}-command`, + hostId: host, + command: "session.list", + args: {}, + }); + expect(await nextResponse(`list-busy-${suffix}`)).toMatchObject({ ok: true }); + } + expect(child.killed).toBe(false); + expect(appserver.snapshot(created.sessionId)).toBeDefined(); + const retained = new SessionOwnershipStore(sessionOwnershipPath); + await retained.load(); + expect(retained.owns(created.sessionId, created.path)).toBe(true); + + child.replyToPrompt(); + expect(await nextResponse("prompt-busy")).toMatchObject({ ok: false }); + await Promise.race([ + (async () => { + while (appserver.snapshot(created.sessionId)?.ref.status === "active") await Bun.sleep(5); + })(), + Bun.sleep(1_000).then(() => { + throw new Error("prompt state did not settle"); + }), + ]); + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "list-busy-settled", + commandId: "list-busy-settled-command", + hostId: host, + command: "session.list", + args: {}, + }); + expect(await nextResponse("list-busy-settled")).toMatchObject({ ok: true }); + expect(child.killed).toBe(true); + expect(appserver.snapshot(created.sessionId)).toBeUndefined(); + const pruned = new SessionOwnershipStore(sessionOwnershipPath); + await pruned.load(); + expect(pruned.owns(created.sessionId, created.path)).toBe(false); + } finally { + client.destroy(); + await client.closed(); + await appserver.stop(); + await rm(root, { recursive: true, force: true }); + } + }); + test("reclaims only an exact T4-owned lockless session after host restart", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-owned-session-restart-")); + const socketPath = join(root, "run", "appserver.sock"); + const sessionOwnershipPath = join(root, "profile", "owned-sessions.json"); + const transcriptPath = join(root, "owned-session.jsonl"); + const sid = sessionId("owned-session-restart"); + const timestamp = "2026-07-22T00:00:00.000Z"; + await writeFile( + transcriptPath, + `${JSON.stringify({ type: "session", version: 3, id: sid, cwd: root, timestamp, title: "Owned session" })}\n`, + ); + const ownership = new SessionOwnershipStore(sessionOwnershipPath); + await ownership.add(sid, transcriptPath); + const factory = new FakeFactory(); + const appserver = createAppserver({ + hostId: host, + epoch: "owned-session-restart-test", + socketPath, + sessionOwnershipPath, + discovery: new FileSessionDiscovery(root, realFs, host, true), + childFactory: factory, + lockStatus: () => "missing", + lockCheck: async () => {}, + }); + await appserver.start(); + const client = await RawUdsWebSocket.connect(socketPath); + try { + client.sendJson({ + v: "omp-app/1", + type: "hello", + protocol: { min: "omp-app/1", max: "omp-app/1" }, + client: { name: "owned-restart-test", version: "1", build: "test", platform: "linux" }, + requestedFeatures: ["session.observer"], + capabilities: { client: ["sessions.read"] }, + savedCursors: [], + }); + expect(await client.nextServer()).toMatchObject({ type: "welcome" }); + expect((await client.nextServer()).type).toBe("sessions"); + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "attach-owned", + commandId: "attach-owned-command", + hostId: host, + sessionId: sid, + command: "session.attach", + args: {}, + }); + for (;;) { + const frame = await client.nextServer(); + if (frame.type === "response" && frame.requestId === "attach-owned") { + expect(frame.ok).toBe(true); + break; + } + } + await Promise.race([ + (async () => { + while (factory.children.length === 0) await Bun.sleep(20); + })(), + Bun.sleep(1_000).then(() => { + throw new Error("owned session was not reclaimed"); + }), + ]); + expect(factory.children).toHaveLength(1); + } finally { + client.destroy(); + await client.closed(); + await appserver.stop(); + await rm(root, { recursive: true, force: true }); + } + }); + test("persists ownership after safely promoting an external session", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-promoted-session-restart-")); + const socketPath = join(root, "run", "appserver.sock"); + const sessionOwnershipPath = join(root, "profile", "owned-sessions.json"); + const transcriptPath = join(root, "promoted-session.jsonl"); + const sid = sessionId("promoted-session-restart"); + const timestamp = "2026-07-23T00:00:00.000Z"; + await writeFile( + transcriptPath, + `${JSON.stringify({ type: "session", version: 3, id: sid, cwd: root, timestamp, title: "Promoted session" })}\n`, + ); + let lockStatus: "live" | "missing" = "live"; + const factory = new DeferredPromptFactory(); + const appserver = createAppserver({ + hostId: host, + epoch: "promoted-session-restart-test", + socketPath, + sessionOwnershipPath, + discovery: new FileSessionDiscovery(root, realFs, host, true), + childFactory: factory, + lockStatus: () => lockStatus, + lockCheck: async () => { + if (lockStatus !== "missing") throw new Error("session lock is still live"); + }, + }); + await appserver.start(); + const client = await RawUdsWebSocket.connect(socketPath); + try { + client.sendJson({ + v: "omp-app/1", + type: "hello", + protocol: { min: "omp-app/1", max: "omp-app/1" }, + client: { name: "promoted-restart-test", version: "1", build: "test", platform: "linux" }, + requestedFeatures: ["session.observer"], + capabilities: { client: ["sessions.read"] }, + savedCursors: [], + }); + expect(await client.nextServer()).toMatchObject({ type: "welcome" }); + expect((await client.nextServer()).type).toBe("sessions"); + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "attach-promoted", + commandId: "attach-promoted-command", + hostId: host, + sessionId: sid, + command: "session.attach", + args: {}, + }); + for (;;) { + const frame = await client.nextServer(); + if (frame.type === "response" && frame.requestId === "attach-promoted") { + expect(frame.ok).toBe(true); + break; + } + } + await Promise.race([ + (async () => { + while (appserver.snapshot(sid)?.ref.liveState?.sessionControl?.mode !== "observer") { + await Bun.sleep(10); + } + })(), + Bun.sleep(1_000).then(() => { + throw new Error("external session did not enter observer mode"); + }), + ]); + + lockStatus = "missing"; + await Promise.race([ + (async () => { + while (factory.children.length === 0) await Bun.sleep(10); + while (appserver.snapshot(sid)?.ref.liveState?.sessionControl !== undefined) await Bun.sleep(10); + })(), + Bun.sleep(1_000).then(() => { + throw new Error( + `external session was not promoted: ${JSON.stringify({ + children: factory.children.length, + killed: factory.children.map(child => child.killed), + control: appserver.snapshot(sid)?.ref.liveState?.sessionControl, + })}`, + ); + }), + ]); + + const ownership = new SessionOwnershipStore(sessionOwnershipPath); + await ownership.load(); + expect(ownership.owns(sid, transcriptPath)).toBe(true); + } finally { + client.destroy(); + await client.closed(); + await appserver.stop(); + await rm(root, { recursive: true, force: true }); + } + }); + test("hydrates a T4-created session without replacing its writer projection", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-created-session-hydration-")); + const socketPath = join(root, "run", "appserver.sock"); + const sid = sessionId("created-session-hydration"); + const created = { + ...record(sid), + path: join(root, "created-session-hydration.jsonl"), + cwd: root, + projectId: stableProjectId(root), + }; + const hydratedEntry = { + ...entry("hydrated-entry"), + hostId: hostId("upstream-host"), + sessionId: sessionId("upstream-session"), + }; + let visible = false; + const sessionAuthority = { + create: async () => { + visible = true; + return created; + }, + list: async () => + visible + ? [{ ...created, updatedAt: new Date(1).toISOString(), entries: [], entriesLoaded: false }] + : [], + load: async () => ({ + ...created, + updatedAt: new Date(1).toISOString(), + entries: [hydratedEntry], + }), + archive: async () => {}, + restore: async () => {}, + delete: async () => {}, + }; + const appserver = createAppserver({ + hostId: host, + epoch: "created-session-hydration-test", + socketPath, + discovery: sessionAuthority, + sessionAuthority, + projectRootForProject: () => root, + childFactory: new FakeFactory(), + }); + await appserver.start(); + const client = await RawUdsWebSocket.connect(socketPath); + try { + client.sendJson({ + v: "omp-app/1", + type: "hello", + protocol: { min: "omp-app/1", max: "omp-app/1" }, + client: { name: "hydration-test", version: "1", build: "test", platform: "linux" }, + requestedFeatures: [], + capabilities: { client: ["sessions.manage", "sessions.read"] }, + savedCursors: [], + }); + expect(await client.nextServer()).toMatchObject({ type: "welcome" }); + expect((await client.nextServer()).type).toBe("sessions"); + + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "create-hydration", + commandId: "create-hydration-command", + hostId: host, + command: "session.create", + args: { projectId: created.projectId }, + }); + for (;;) { + const frame = await client.nextServer(); + if (frame.type === "response" && frame.requestId === "create-hydration") break; + } + const writerProjection = appserver.snapshot(sid); + expect(writerProjection).toBeDefined(); + + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "list-hydration", + commandId: "list-hydration-command", + hostId: host, + command: "session.list", + args: {}, + }); + for (;;) { + const frame = await client.nextServer(); + if (frame.type === "response" && frame.requestId === "list-hydration") break; + } + + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "attach-hydration", + commandId: "attach-hydration-command", + hostId: host, + sessionId: sid, + command: "session.attach", + args: {}, + }); + let attachResponse; + for (;;) { + const frame = await client.nextServer(); + if (frame.type === "response" && frame.requestId === "attach-hydration") { + attachResponse = frame; + break; + } + } + + expect(appserver.snapshot(sid)).toBe(writerProjection); + expect(appserver.snapshot(sid)?.entries).toEqual([{ ...hydratedEntry, hostId: host, sessionId: sid }]); + expect(attachResponse).toMatchObject({ + ok: true, + result: { attached: true, cursor: { epoch: "created-session-hydration-test", seq: 1 } }, + }); + } finally { + client.destroy(); + await client.closed(); + await appserver.stop(); + await rm(root, { recursive: true, force: true }); + } + }); + test("restores an archived observed session after a fresh missing-lock check", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-archived-restore-")); + const socketPath = join(root, "run", "appserver.sock"); + const sid = sessionId("archived-observer-session"); + let current: SessionRecord = { + ...record(sid), + path: join(root, "archived-observer-session.jsonl"), + cwd: root, + projectId: stableProjectId(root), + archivedAt: "2026-07-23T00:00:00.000Z", + }; + const authority = { + create: async () => { + throw new Error("not used"); + }, + list: async () => [current], + archive: async () => {}, + restore: async () => { + const next = { ...current }; + delete next.archivedAt; + current = next; + }, + delete: async () => {}, + }; + let lockStatus: "live" | "missing" = "live"; + const appserver = createAppserver({ + hostId: host, + epoch: "archived-restore-test", + socketPath, + discovery: authority, + sessionAuthority: authority, + childFactory: new FakeFactory(), + lockStatus: () => lockStatus, + }); + await appserver.start(); + const client = await RawUdsWebSocket.connect(socketPath); + try { + client.sendJson({ + v: "omp-app/1", + type: "hello", + protocol: { min: "omp-app/1", max: "omp-app/1" }, + client: { name: "archived-restore-test", version: "1", build: "test", platform: "linux" }, + requestedFeatures: ["session.observer"], + capabilities: { client: ["sessions.manage", "sessions.read"] }, + savedCursors: [], + }); + expect(await client.nextServer()).toMatchObject({ type: "welcome" }); + expect((await client.nextServer()).type).toBe("sessions"); + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "attach-archived-observer", + commandId: "attach-archived-observer-command", + hostId: host, + sessionId: sid, + command: "session.attach", + args: {}, + }); + for (;;) { + const frame = await client.nextServer(); + if (frame.type === "response" && frame.requestId === "attach-archived-observer") { + expect(frame.ok).toBe(true); + break; + } + } + expect(appserver.snapshot(sid)?.ref.liveState?.sessionControl).toBeDefined(); + lockStatus = "missing"; + const expectedRevision = appserver.snapshot(sid)?.revision; + if (expectedRevision === undefined) throw new Error("missing archived session revision"); + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "restore-archived-observer", + commandId: "restore-archived-observer-command", + hostId: host, + sessionId: sid, + command: "session.restore", + expectedRevision, + args: {}, + }); + for (;;) { + const frame = await client.nextServer(); + if (frame.type === "response" && frame.requestId === "restore-archived-observer") { + expect(frame).toMatchObject({ ok: true, result: { restored: true } }); + break; + } + } + expect(current.archivedAt).toBeUndefined(); + } finally { + client.destroy(); + await client.closed(); + await appserver.stop(); + await rm(root, { recursive: true, force: true }); + } + }); + test("keeps an archived observed session read-only while its authority lock is live", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-archived-restore-live-lock-")); + const socketPath = join(root, "run", "appserver.sock"); + const sid = sessionId("archived-live-lock-session"); + let current: SessionRecord = { + ...record(sid), + path: join(root, "archived-live-lock-session.jsonl"), + cwd: root, + projectId: stableProjectId(root), + archivedAt: "2026-07-23T00:00:00.000Z", + }; + const authority = { + create: async () => { + throw new Error("not used"); + }, + list: async () => [current], + archive: async () => {}, + restore: async () => { + const next = { ...current }; + delete next.archivedAt; + current = next; + }, + delete: async () => {}, + }; + const appserver = createAppserver({ + hostId: host, + epoch: "archived-live-lock-test", + socketPath, + discovery: authority, + sessionAuthority: authority, + childFactory: new FakeFactory(), + lockStatus: () => "live", + }); + await appserver.start(); + const client = await RawUdsWebSocket.connect(socketPath); + try { + client.sendJson({ + v: "omp-app/1", + type: "hello", + protocol: { min: "omp-app/1", max: "omp-app/1" }, + client: { name: "archived-live-lock-test", version: "1", build: "test", platform: "linux" }, + requestedFeatures: ["session.observer"], + capabilities: { client: ["sessions.manage", "sessions.read"] }, + savedCursors: [], + }); + expect(await client.nextServer()).toMatchObject({ type: "welcome" }); + expect((await client.nextServer()).type).toBe("sessions"); + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "attach-archived-live-lock", + commandId: "attach-archived-live-lock-command", + hostId: host, + sessionId: sid, + command: "session.attach", + args: {}, + }); + for (;;) { + const frame = await client.nextServer(); + if (frame.type === "response" && frame.requestId === "attach-archived-live-lock") { + expect(frame.ok).toBe(true); + break; + } + } + const expectedRevision = appserver.snapshot(sid)?.revision; + if (expectedRevision === undefined) throw new Error("missing archived session revision"); + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "restore-archived-live-lock", + commandId: "restore-archived-live-lock-command", + hostId: host, + sessionId: sid, + command: "session.restore", + expectedRevision, + args: {}, + }); + for (;;) { + const frame = await client.nextServer(); + if (frame.type === "response" && frame.requestId === "restore-archived-live-lock") { + expect(frame).toMatchObject({ + ok: false, + error: { code: "session_locked" }, + }); + break; + } + } + expect(current.archivedAt).toBe("2026-07-23T00:00:00.000Z"); + } finally { + client.destroy(); + await client.closed(); + await appserver.stop(); + await rm(root, { recursive: true, force: true }); + } + }); + test("never evicts omitted sessions from a partial authority inventory", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-partial-inventory-")); + const socketPath = join(root, "run", "appserver.sock"); + const retained = record("partial-retained"); + const omitted = record("partial-omitted"); + let records = [retained, omitted]; + let complete = true; + let totalCount = 2; + const discovery: SessionDiscovery = { + list: async () => records, + inventoryComplete: () => complete, + inventoryTotalCount: () => totalCount, + }; + const appserver = createAppserver({ + hostId: host, + epoch: "partial-inventory-test", + socketPath, + discovery, + }); + await appserver.start(); + const client = await RawUdsWebSocket.connect(socketPath); + try { + client.sendJson({ + v: "omp-app/1", + type: "hello", + protocol: { min: "omp-app/1", max: "omp-app/1" }, + client: { name: "partial-inventory-test", version: "1", build: "test", platform: "linux" }, + requestedFeatures: [], + capabilities: { client: ["sessions.read"] }, + savedCursors: [], + }); + expect(await client.nextServer()).toMatchObject({ type: "welcome" }); + expect(await client.nextServer()).toMatchObject({ type: "sessions", totalCount: 2, truncated: false }); + records = [retained]; + complete = false; + totalCount = 3; + for (let attempt = 0; attempt < 2; attempt += 1) { + const requestId = `partial-list-${attempt}`; + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId, + commandId: `${requestId}-command`, + hostId: host, + command: "session.list", + args: {}, + }); + for (;;) { + const frame = await client.nextServer(); + if (frame.type === "response" && frame.requestId === requestId) { + expect(frame).toMatchObject({ + ok: true, + result: { totalCount: 3, truncated: true }, + }); + break; + } + } + } + expect(appserver.snapshot(omitted.sessionId)).toBeDefined(); + } finally { + client.destroy(); + await client.closed(); + await appserver.stop(); + await rm(root, { recursive: true, force: true }); + } + }); test("rejects shared system socket roots before changing their modes", async () => { if (process.platform === "win32") return; for (const directory of ["/", "/tmp", "/var", "/private/tmp", "/private/var"]) { @@ -400,7 +1411,7 @@ describe("appserver lifecycle", () => { type: "hello", protocol: { min: "omp-app/1", max: "omp-app/1" }, client: { name: "lockless-test", version: "1", build: "test", platform: "linux" }, - requestedFeatures: ["session.observer", "transcript.page"], + requestedFeatures: ["session.observer", "session.unverified", "transcript.page"], capabilities: { client: ["sessions.read"] }, savedCursors: [], }); @@ -493,10 +1504,63 @@ describe("appserver lifecycle", () => { } expect(appserver.snapshot(sid)?.entries.at(-1)?.data.text).toBe("second"); expect(appserver.snapshot(sid)?.ref.liveState?.sessionControl).toEqual({ - mode: "reconciling", + mode: "unverified", transcript: "live", }); expect(factory.children).toHaveLength(0); + + const legacyClient = await RawUdsWebSocket.connect(socketPath); + try { + legacyClient.sendJson({ + v: "omp-app/1", + type: "hello", + protocol: { min: "omp-app/1", max: "omp-app/1" }, + client: { name: "legacy-lockless-test", version: "0.5.8", build: "test", platform: "linux" }, + requestedFeatures: ["session.observer"], + capabilities: { client: ["sessions.read"] }, + savedCursors: [], + }); + expect(await legacyClient.nextServer()).toMatchObject({ + type: "welcome", + grantedFeatures: ["session.observer"], + }); + const sessions = await legacyClient.nextServer(); + expect(sessions).toMatchObject({ + type: "sessions", + sessions: [{ + liveState: { + sessionControl: { mode: "reconciling", transcript: "live" }, + }, + }], + }); + legacyClient.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "legacy-list-lockless", + commandId: "legacy-list-lockless-command", + hostId: host, + command: "session.list", + args: {}, + }); + for (;;) { + const frame = await legacyClient.nextServer(); + if (frame.type !== "response" || frame.requestId !== "legacy-list-lockless") continue; + expect(frame).toMatchObject({ + ok: true, + result: { + sessions: [{ + liveState: { + sessionControl: { mode: "reconciling", transcript: "live" }, + }, + }], + }, + }); + break; + } + } finally { + legacyClient.destroy(); + await legacyClient.closed(); + } } finally { client.destroy(); await client.closed(); diff --git a/packages/host-service/test/hardening.test.ts b/packages/host-service/test/hardening.test.ts index 51944f8e..170e30ce 100644 --- a/packages/host-service/test/hardening.test.ts +++ b/packages/host-service/test/hardening.test.ts @@ -895,6 +895,432 @@ describe("child supervision", () => { supervisor.stop(); }); + test("skips an oversized live JSONL record and resumes reconciliation", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-oversized-live-jsonl-")); + const path = join(root, "session.jsonl"); + await writeFile( + path, + `${JSON.stringify({ type: "session", version: 3, id: "session", timestamp: stamp, cwd: root })}\n`, + ); + const append = Promise.withResolvers(); + const release = Promise.withResolvers(); + const exited = Promise.withResolvers(); + const child: ChildHandle = { + stdin: { write: () => {} }, + stdout: (async function* () { + yield `${JSON.stringify({ type: "ready" })}\n`; + await append.promise; + yield `${JSON.stringify({ type: "message_end", message: { role: "assistant" } })}\n`; + await release.promise; + })(), + stderr: (async function* () {})(), + exited: exited.promise, + kill: () => { + release.resolve(); + exited.resolve(0); + }, + }; + const entry = Promise.withResolvers(); + const omittedOffsets: number[] = []; + const crashed: Error[] = []; + const supervisor = new RpcChildSupervisor( + { + spawn: () => child, + argv: sessionPath => ["omp", "--mode", "rpc", "--session", sessionPath], + }, + { ...record("oversized-live-jsonl"), path, cwd: root }, + { + entry: entry.resolve, + event: () => {}, + transcriptRecordOmitted: offset => omittedOffsets.push(offset), + crashed: error => crashed.push(error), + }, + ); + + await supervisor.start(); + const oversizedOffset = (await stat(path)).size; + await appendFile(path, `${"x".repeat(1024 * 1024 + 1)}\n`); + await appendFile( + path, + `${JSON.stringify({ + type: "message", + id: "after-oversized", + message: { role: "assistant", content: "still live" }, + })}\n`, + ); + append.resolve(); + + expect((await entry.promise).entry).toMatchObject({ + id: "after-oversized", + message: { role: "assistant", content: "still live" }, + }); + expect(omittedOffsets).toEqual([oversizedOffset]); + expect(crashed).toEqual([]); + expect(supervisor.loadedWatermark()).toEqual({ lastEntryId: "after-oversized", entryCount: 1 }); + supervisor.stop(); + }); + + test("invalidates stale prompt correlations after an oversized JSONL record", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-oversized-correlation-jsonl-")); + const path = join(root, "session.jsonl"); + await writeFile( + path, + `${JSON.stringify({ type: "session", version: 3, id: "session", timestamp: stamp, cwd: root })}\n`, + ); + const firstCommand = Promise.withResolvers>(); + const secondCommand = Promise.withResolvers>(); + const firstReconcile = Promise.withResolvers(); + const secondReconcile = Promise.withResolvers(); + const release = Promise.withResolvers(); + const exited = Promise.withResolvers(); + let writes = 0; + const child: ChildHandle = { + stdin: { + write: line => { + const command = JSON.parse(String(line)) as Record; + const target = writes === 0 ? firstCommand : secondCommand; + writes += 1; + target.resolve(command); + }, + }, + stdout: (async function* () { + yield `${JSON.stringify({ type: "ready" })}\n`; + const first = await firstCommand.promise; + yield `${JSON.stringify({ + type: "response", + id: first.id, + command: first.type, + success: true, + })}\n`; + await firstReconcile.promise; + yield `${JSON.stringify({ type: "notice", message: "first reconciliation" })}\n`; + const second = await secondCommand.promise; + yield `${JSON.stringify({ + type: "response", + id: second.id, + command: second.type, + success: true, + })}\n`; + await secondReconcile.promise; + yield `${JSON.stringify({ type: "notice", message: "second reconciliation" })}\n`; + await release.promise; + })(), + stderr: (async function* () {})(), + exited: exited.promise, + kill: () => { + release.resolve(); + exited.resolve(0); + }, + }; + const omitted = Promise.withResolvers(); + const entry = Promise.withResolvers(); + const supervisor = new RpcChildSupervisor( + { spawn: () => child, argv: sessionPath => ["omp", "--mode", "rpc", "--session", sessionPath] }, + { ...record("oversized-correlation-jsonl"), path, cwd: root }, + { + entry: entry.resolve, + event: () => {}, + transcriptRecordOmitted: omitted.resolve, + crashed: error => expect.unreachable(error.message), + }, + ); + + await supervisor.start(); + await supervisor.call({ type: "prompt", message: "omitted prompt" }, "omitted-prompt"); + await appendFile( + path, + `${JSON.stringify({ + type: "message", + id: "oversized-user", + message: { role: "user", content: "x".repeat(1024 * 1024) }, + })}\n`, + ); + firstReconcile.resolve(); + await omitted.promise; + + const second = supervisor.call({ type: "prompt", message: "visible prompt" }, "visible-prompt"); + const secondInternalId = String((await secondCommand.promise).id); + await second; + await appendFile( + path, + `${JSON.stringify({ + type: "message", + id: "visible-user", + message: { role: "user", content: "visible prompt" }, + })}\n`, + ); + secondReconcile.resolve(); + expect((await entry.promise).entry).toMatchObject({ + id: "visible-user", + message: { role: "user", content: "visible prompt", clientCorrelationId: secondInternalId }, + }); + supervisor.stop(); + await child.exited; + }); + + test("negotiates RPC v2 and reassembles exact-boundary and oversized responses losslessly", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-rpc-v2-")); + const path = join(root, "session.jsonl"); + await writeFile( + path, + `${JSON.stringify({ type: "session", version: 3, id: "session", timestamp: stamp, cwd: root })}\n`, + ); + const negotiate = Promise.withResolvers>(); + const exactBoundaryRequest = Promise.withResolvers>(); + const oversizedRequest = Promise.withResolvers>(); + const release = Promise.withResolvers(); + const exited = Promise.withResolvers(); + let requestCount = 0; + const oversizedPayload = "😀".repeat(400_000); + const child: ChildHandle = { + stdin: { + write: line => { + const command = JSON.parse(String(line)) as Record; + if (command.type === "negotiate_protocol") negotiate.resolve(command); + else if (requestCount++ === 0) exactBoundaryRequest.resolve(command); + else oversizedRequest.resolve(command); + }, + }, + stdout: (async function* () { + yield `${JSON.stringify({ + type: "ready", + protocolVersion: 1, + supportedProtocolVersions: [1, 2], + maxFrameBytes: 1024 * 1024, + maxReassembledFrameBytes: 64 * 1024 * 1024, + })}\n`; + const negotiation = await negotiate.promise; + yield `${JSON.stringify({ + type: "response", + id: negotiation.id, + command: "negotiate_protocol", + success: true, + data: { protocolVersion: 2 }, + })}\n`; + for (let responseIndex = 0; responseIndex < 2; responseIndex++) { + const command = await (responseIndex === 0 + ? exactBoundaryRequest.promise + : oversizedRequest.promise); + const payload = responseIndex === 0 ? undefined : oversizedPayload; + const chunkId = responseIndex === 0 ? "response-boundary" : "response-oversized"; + const response = { + type: "response", + id: command.id, + command: command.type, + success: true, + data: { payload: payload ?? "" }, + }; + if (payload === undefined) { + const emptyBytes = Buffer.byteLength(JSON.stringify(response), "utf8"); + response.data.payload = "x".repeat(1024 * 1024 - emptyBytes); + } + const logical = Buffer.from(JSON.stringify(response), "utf8"); + if (payload === undefined) expect(logical.byteLength).toBe(1024 * 1024); + const chunkBytes = 256 * 1024; + const count = Math.ceil(logical.byteLength / chunkBytes); + const frames: string[] = []; + for (let index = 0; index < count; index++) { + frames.push(`${JSON.stringify({ + type: "rpc_chunk", + chunkId, + index, + count, + byteLength: logical.byteLength, + data: logical.subarray(index * chunkBytes, (index + 1) * chunkBytes).toString("base64"), + })}\n`); + } + yield frames.join(""); + } + await release.promise; + })(), + stderr: (async function* () {})(), + exited: exited.promise, + kill: () => { + release.resolve(); + exited.resolve(0); + }, + }; + const supervisor = new RpcChildSupervisor( + { + spawn: () => child, + argv: sessionPath => ["omp", "--mode", "rpc", "--session", sessionPath], + }, + { ...record("rpc-v2"), path, cwd: root }, + { entry: () => {}, event: () => {}, crashed: error => expect.unreachable(error.message) }, + ); + + await supervisor.start(); + expect(await negotiate.promise).toMatchObject({ type: "negotiate_protocol", protocolVersion: 2 }); + const exactBoundary = await supervisor.call({ type: "get_state" }, "boundary-response"); + expect(exactBoundary).toMatchObject({ success: true }); + expect(Buffer.byteLength(JSON.stringify(exactBoundary), "utf8")).toBe(1024 * 1024); + const oversized = await supervisor.call({ type: "get_messages" }, "large-response"); + expect(oversized).toMatchObject({ success: true, data: { payload: oversizedPayload } }); + supervisor.stop(); + }); + + test("keeps the legacy v1 path when ready does not advertise protocol v2", async () => { + const request = Promise.withResolvers>(); + const release = Promise.withResolvers(); + const exited = Promise.withResolvers(); + const commands: Record[] = []; + const child: ChildHandle = { + stdin: { + write: line => { + const command = JSON.parse(String(line)) as Record; + commands.push(command); + request.resolve(command); + }, + }, + stdout: (async function* () { + yield `${JSON.stringify({ type: "ready", protocolVersion: 1 })}\n`; + const command = await request.promise; + yield `${JSON.stringify({ + type: "response", + id: command.id, + command: command.type, + success: true, + data: { legacy: true }, + })}\n`; + await release.promise; + })(), + stderr: (async function* () {})(), + exited: exited.promise, + kill: () => { + release.resolve(); + exited.resolve(0); + }, + }; + const supervisor = new RpcChildSupervisor( + { spawn: () => child, argv: sessionPath => ["omp", "--mode", "rpc", "--session", sessionPath] }, + record("rpc-v1"), + { entry: () => {}, event: () => {}, crashed: error => expect.unreachable(error.message) }, + ); + + await supervisor.start(); + expect(await supervisor.call({ type: "get_state" }, "legacy-response")).toMatchObject({ + success: true, + data: { legacy: true }, + }); + expect(commands.map(command => command.type)).toEqual(["get_state"]); + supervisor.stop(); + }); + + test("bounds protocol v2 negotiation during startup", async () => { + const negotiation = Promise.withResolvers>(); + const release = Promise.withResolvers(); + const exited = Promise.withResolvers(); + let killed = false; + const child: ChildHandle = { + stdin: { + write: line => negotiation.resolve(JSON.parse(String(line)) as Record), + }, + stdout: (async function* () { + yield `${JSON.stringify({ + type: "ready", + protocolVersion: 1, + supportedProtocolVersions: [1, 2], + maxFrameBytes: 1024 * 1024, + maxReassembledFrameBytes: 64 * 1024 * 1024, + })}\n`; + await release.promise; + })(), + stderr: (async function* () {})(), + exited: exited.promise, + kill: () => { + killed = true; + release.resolve(); + exited.resolve(0); + }, + }; + const supervisor = new RpcChildSupervisor( + { spawn: () => child, argv: sessionPath => ["omp", "--mode", "rpc", "--session", sessionPath] }, + record("rpc-v2-negotiation-timeout"), + { entry: () => {}, event: () => {}, crashed: () => {} }, + undefined, + undefined, + undefined, + 10, + ); + + const startup = supervisor.start(); + expect(await negotiation.promise).toMatchObject({ type: "negotiate_protocol", protocolVersion: 2 }); + const outcome = await Promise.race([ + startup.then( + () => ({ kind: "resolved" as const }), + error => ({ kind: "rejected" as const, error }), + ), + Bun.sleep(100).then(() => ({ kind: "pending" as const })), + ]); + expect(outcome.kind).toBe("rejected"); + if (outcome.kind === "rejected") + expect(outcome.error).toHaveProperty("message", "rpc protocol v2 negotiation timed out"); + expect(killed).toBe(true); + await child.exited; + }); + + test("fails closed when a protocol v2 chunk sequence is interrupted", async () => { + const negotiate = Promise.withResolvers>(); + const request = Promise.withResolvers>(); + const release = Promise.withResolvers(); + const exited = Promise.withResolvers(); + const crashed = Promise.withResolvers(); + const child: ChildHandle = { + stdin: { + write: line => { + const command = JSON.parse(String(line)) as Record; + if (command.type === "negotiate_protocol") negotiate.resolve(command); + else request.resolve(command); + }, + }, + stdout: (async function* () { + yield `${JSON.stringify({ + type: "ready", + protocolVersion: 1, + supportedProtocolVersions: [1, 2], + maxFrameBytes: 1024 * 1024, + maxReassembledFrameBytes: 64 * 1024 * 1024, + })}\n`; + const negotiation = await negotiate.promise; + yield `${JSON.stringify({ + type: "response", + id: negotiation.id, + command: "negotiate_protocol", + success: true, + data: { protocolVersion: 2 }, + })}\n`; + await request.promise; + yield `${JSON.stringify({ + type: "rpc_chunk", + chunkId: "interrupted-response", + index: 0, + count: 2, + byteLength: 1024 * 1024, + data: Buffer.from("{").toString("base64"), + })}\n`; + yield `${JSON.stringify({ type: "notice", message: "interleaved" })}\n`; + await release.promise; + })(), + stderr: (async function* () {})(), + exited: exited.promise, + kill: () => { + release.resolve(); + exited.resolve(1); + }, + }; + const supervisor = new RpcChildSupervisor( + { spawn: () => child, argv: sessionPath => ["omp", "--mode", "rpc", "--session", sessionPath] }, + record("rpc-v2-interrupted"), + { entry: () => {}, event: () => {}, crashed: crashed.resolve }, + ); + + await supervisor.start(); + await expect(supervisor.call({ type: "get_state" }, "interrupted-response")).rejects.toThrow( + "rpc chunk sequence interrupted", + ); + expect((await crashed.promise).message).toBe("rpc chunk sequence interrupted"); + }); + test("daemon entrypoints resolve RPC children in source and installed layouts", () => { const cases = [ ["/checkout/packages/coding-agent/src/cli/ompd.ts", "/checkout/packages/coding-agent/src/cli.ts"], diff --git a/packages/host-service/test/official-omp-capabilities.test.ts b/packages/host-service/test/official-omp-capabilities.test.ts index 27b7fd98..f37e3202 100644 --- a/packages/host-service/test/official-omp-capabilities.test.ts +++ b/packages/host-service/test/official-omp-capabilities.test.ts @@ -118,20 +118,53 @@ describe("official OMP capability adapter", () => { test("rejects ambiguous and malformed capability updates", () => { const adapter = new OfficialOmpCapabilityAdapter(); expect(() => - adapter.consume({ - type: "available_commands_update", - commands: [ - { name: "compact", source: "builtin" }, - { name: "compact", source: "extension" }, - ], - }), + adapter.update([ + { name: "compact", source: "builtin" }, + { name: "compact", source: "extension" }, + ]), ).toThrow("duplicate available command"); expect(() => + adapter.update([{ name: "bad/name", source: "builtin" }]), + ).toThrow("no valid entries"); + adapter.update([ + { name: "skill:Video Processor", source: "skill" }, + { name: "compact", source: "builtin" }, + ]); + expect(adapter.operations().some((item) => item.operationId === "slash.skill:Video Processor")) + .toBe(false); + expect(adapter.assertOperationSupported("slash.compact")).toMatchObject({ supported: true }); + }); + + test("ignores malformed live capability metadata without terminating prompt support", () => { + const adapter = new OfficialOmpCapabilityAdapter(); + adapter.update([{ name: "compact", description: "Compact context", source: "builtin" }]); + expect( adapter.consume({ type: "available_commands_update", - commands: [{ name: "bad/name", source: "builtin" }], + commands: [{ name: "skill:test", description: "Line one\0Line two", source: "skill" }], }), - ).toThrow("must be a slash command name"); + ).toBe(true); + expect(adapter.assertPromptSupported("/compact now")).toMatchObject({ + operationId: "slash.compact", + supported: true, + }); + }); + + test("normalizes multiline command metadata into bounded inline catalog text", () => { + const adapter = new OfficialOmpCapabilityAdapter(); + adapter.update([ + { + name: "skill:test", + description: "Line one\nLine two\tDetail", + input: { hint: "first\r\nsecond" }, + source: "skill", + }, + ]); + expect(adapter.assertOperationSupported("slash.skill:test")).toMatchObject({ + operationId: "slash.skill:test", + description: "Line one Line two Detail", + metadata: { inlineHint: "first second" }, + }); }); test("supervisor queries discovery and blocks terminal-only text before stdin dispatch", async () => { @@ -149,6 +182,10 @@ describe("official OMP capability adapter", () => { }, stdout: (async function* () { yield `${JSON.stringify({ type: "ready" })}\n`; + yield `${JSON.stringify({ + type: "available_commands_update", + commands: [{ name: "skill:test", description: "Line one\nLine two", source: "skill" }], + })}\n`; const request = await capabilityRequest.promise; yield `${JSON.stringify({ type: "response", diff --git a/packages/host-service/test/official-omp-catalog-server.test.ts b/packages/host-service/test/official-omp-catalog-server.test.ts index f5d21cc2..095fccf5 100644 --- a/packages/host-service/test/official-omp-catalog-server.test.ts +++ b/packages/host-service/test/official-omp-catalog-server.test.ts @@ -24,6 +24,7 @@ class CapabilityRpcChild implements ChildHandle { #stderrController?: ReadableStreamDefaultController; #closed = false; readonly exited = this.#exit.promise; + constructor(private readonly respondToCapabilityRefresh = true) {} readonly stdout = new ReadableStream({ start: (controller) => { this.#stdoutController = controller; @@ -57,7 +58,7 @@ class CapabilityRpcChild implements ChildHandle { interruptMode: "immediate", }, }); - else if (command.type === "get_available_commands") + else if (command.type === "get_available_commands" && this.respondToCapabilityRefresh) this.push({ type: "response", id, @@ -99,9 +100,10 @@ class CapabilityRpcChild implements ChildHandle { class CapabilityRpcFactory implements RpcChildFactory { readonly children: CapabilityRpcChild[] = []; + constructor(private readonly respondToCapabilityRefresh = true) {} spawn(): ChildHandle { - const child = new CapabilityRpcChild(); + const child = new CapabilityRpcChild(this.respondToCapabilityRefresh); this.children.push(child); return child; } @@ -118,7 +120,7 @@ test("catalog.get merges normalized official OMP operation capabilities", async const appserver = createAppserver({ hostId: host, socketPath, - ompVersion: "17.0.6", + ompVersion: "17.0.9", discovery: { list: async () => [] }, operationsAuthority: { catalogGet: async () => ({ @@ -143,6 +145,10 @@ test("catalog.get merges normalized official OMP operation capabilities", async }, ], }), + termOpen: async () => ({ terminalId: "terminal-1" }), + terminalInput: async () => {}, + terminalResize: async () => {}, + terminalClose: async () => {}, }, }); await appserver.start(); @@ -177,9 +183,14 @@ test("catalog.get merges normalized official OMP operation capabilities", async if (!response.ok) throw new Error("catalog request failed"); const result = response.result as { revision: string; + items: Array<{ name: string; capabilities?: string[]; metadata?: Record }>; operations: Array<{ operationId: string; execution: string; supported: boolean }>; }; expect(result.revision).toMatch(/^capabilities-[0-9a-f]{64}$/u); + expect(result.items.find((item) => item.name === "term.open")).toMatchObject({ + capabilities: ["term.open"], + }); + expect(result.items.find((item) => item.name === "term.open")?.metadata).toBeUndefined(); expect(result.operations.find((item) => item.operationId === "session.prompt")).toMatchObject({ execution: "typed", supported: true, @@ -218,10 +229,10 @@ test("attached catalog refresh and terminal-only rejection stay on the runtime b const appserver = createAppserver({ hostId: host, socketPath, - ompVersion: "17.0.6", + ompVersion: "17.0.9", discovery: { list: async () => [session] }, childFactory: factory, - rpcDialect: "official-17.0.6", + rpcDialect: "official-17.0.9", lockCheck: () => {}, lockStatus: () => "missing", operationsAuthority: { @@ -358,3 +369,106 @@ test("attached catalog refresh and terminal-only rejection stay on the runtime b await rm(root, { recursive: true, force: true }); } }); + +test("a stalled attached capability refresh falls back to the base catalog", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-official-omp-stalled-catalog-")); + const socketPath = join(root, "run", "app.sock"); + const host = hostId("official-omp-stalled-catalog-host"); + const session: SessionRecord = { + sessionId: sessionId("official-omp-stalled-catalog-session"), + path: join(root, "session.jsonl"), + cwd: root, + projectId: projectId("official-omp-stalled-catalog-project"), + title: "Stalled capability session", + updatedAt: "2026-07-23T00:00:00.000Z", + status: "idle", + entries: [], + }; + const factory = new CapabilityRpcFactory(false); + const appserver = createAppserver({ + hostId: host, + socketPath, + ompVersion: "17.0.9", + discovery: { list: async () => [session] }, + childFactory: factory, + rpcDialect: "official-17.0.9", + lockCheck: () => {}, + lockStatus: () => "missing", + operationsAuthority: { + catalogGet: async () => ({ revision: "authority-revision", items: [] }), + termOpen: async () => ({ terminalId: "terminal-1" }), + terminalInput: async () => {}, + terminalResize: async () => {}, + terminalClose: async () => {}, + }, + }); + await appserver.start(); + const client = await RawUdsWebSocket.connect(socketPath); + try { + client.sendJson({ + v: "omp-app/1", + type: "hello", + protocol: { min: "omp-app/1", max: "omp-app/1" }, + client: { name: "stalled-catalog-test", version: "1", build: "test", platform: "linux" }, + requestedFeatures: [], + capabilities: { + client: ["sessions.read", "sessions.control", "catalog.read"], + }, + savedCursors: [], + }); + expect(await client.nextServer()).toMatchObject({ type: "welcome" }); + expect((await client.nextServer()).type).toBe("sessions"); + + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "state", + commandId: "state-command", + hostId: host, + sessionId: session.sessionId, + command: "session.state.get", + args: {}, + }); + expect(await responseFor(client, "state")).toMatchObject({ ok: true }); + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "attach", + commandId: "attach-command", + hostId: host, + sessionId: session.sessionId, + command: "session.attach", + args: {}, + }); + expect(await responseFor(client, "attach")).toMatchObject({ ok: true }); + + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "catalog", + commandId: "catalog-command", + hostId: host, + command: "catalog.get", + args: {}, + }); + const response = await responseFor(client, "catalog"); + expect(response.ok).toBe(true); + if (!response.ok) throw new Error("base catalog fallback failed"); + const result = response.result as { + items: Array<{ name: string }>; + operations: Array<{ operationId: string; supported: boolean }>; + }; + expect(result.items.find((item) => item.name === "term.open")).toBeDefined(); + expect(result.operations.find((item) => item.operationId === "session.prompt")).toMatchObject({ + supported: true, + }); + expect(factory.children[0]?.writes.map((command) => command.type)).toContain( + "get_available_commands", + ); + } finally { + client.destroy(); + await client.closed(); + await appserver.stop(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/host-service/test/omp-authority-bridge-client.test.ts b/packages/host-service/test/omp-authority-bridge-client.test.ts index 168562a9..8852fec4 100644 --- a/packages/host-service/test/omp-authority-bridge-client.test.ts +++ b/packages/host-service/test/omp-authority-bridge-client.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { hostId, sessionId } from "@t4-code/host-wire"; +import { entryId, hostId, projectId, sessionId } from "@t4-code/host-wire"; import { OmpAuthorityBridgeClient, type OmpAuthorityBridgeChild } from "../src/omp-authority-bridge-client.ts"; import { decodeOmpAuthorityBridgeClientFrame, @@ -7,6 +7,7 @@ import { OMP_AUTHORITY_BRIDGE_MAX_LINE_BYTES, OMP_AUTHORITY_BRIDGE_PROTOCOL, } from "../src/omp-authority-bridge-contract.ts"; +import type { SessionRecord } from "../src/types.ts"; class AsyncQueue implements AsyncIterable { readonly #values: string[] = []; @@ -63,6 +64,19 @@ const ready = { ompBuild: "bridge-test", }; +function listedSession(id: string): SessionRecord { + return { + sessionId: sessionId(id), + path: `/tmp/${id}.jsonl`, + cwd: "/tmp", + projectId: projectId("listed-project"), + title: id, + updatedAt: "2026-07-23T00:00:00.000Z", + status: "idle", + entries: [], + }; +} + describe("OMP authority bridge client", () => { test("waits for ready, exposes only advertised authorities, and routes responses", async () => { const child = new FakeBridgeChild(); @@ -83,6 +97,133 @@ describe("OMP authority bridge client", () => { await client.stop(); }); + test("assembles every bounded page from one bridge inventory snapshot", async () => { + const child = new FakeBridgeChild(); + const client = new OmpAuthorityBridgeClient({ executable: "/opt/omp" }, () => child); + const started = client.start(); + child.server(ready); + await started; + const authorities = client.createAuthorities(); + const listed = authorities.sessionAuthority.list(); + await Bun.sleep(0); + const first = child.request(0); + expect(first).toMatchObject({ type: "request", method: "session.list", params: {} }); + child.server({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "response", + id: first.id, + ok: true, + result: { + sessions: [listedSession("first")], + nextCursor: "snapshot-cursor-1", + complete: true, + totalCount: 3, + }, + }); + await Bun.sleep(0); + const second = child.request(1); + expect(second).toMatchObject({ + type: "request", + method: "session.list", + params: { cursor: "snapshot-cursor-1" }, + }); + child.server({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "response", + id: second.id, + ok: true, + result: { + sessions: [listedSession("second")], + nextCursor: "snapshot-cursor-2", + complete: true, + totalCount: 3, + }, + }); + await Bun.sleep(0); + const third = child.request(2); + expect(third).toMatchObject({ + type: "request", + method: "session.list", + params: { cursor: "snapshot-cursor-2" }, + }); + child.server({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "response", + id: third.id, + ok: true, + result: { sessions: [listedSession("third")], complete: true, totalCount: 3 }, + }); + expect((await listed).map(record => String(record.sessionId))).toEqual(["first", "second", "third"]); + expect(authorities.discovery.inventoryComplete?.()).toBe(true); + expect(authorities.discovery.inventoryTotalCount?.()).toBe(3); + await client.stop(); + }); + + test("preserves partial-inventory metadata for safe host reconciliation", async () => { + const child = new FakeBridgeChild(); + const client = new OmpAuthorityBridgeClient({ executable: "/opt/omp" }, () => child); + const started = client.start(); + child.server(ready); + await started; + const authorities = client.createAuthorities(); + const listed = authorities.sessionAuthority.list(); + await Bun.sleep(0); + const request = child.request(); + child.server({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "response", + id: request.id, + ok: true, + result: { + sessions: [listedSession("visible")], + complete: false, + totalCount: 2, + }, + }); + expect((await listed).map(record => String(record.sessionId))).toEqual(["visible"]); + expect(authorities.discovery.inventoryComplete?.()).toBe(false); + expect(authorities.discovery.inventoryTotalCount?.()).toBe(2); + await client.stop(); + }); + + test("keeps session inventory sparse when the bridge cache contains loaded transcripts", async () => { + const child = new FakeBridgeChild(); + const client = new OmpAuthorityBridgeClient({ executable: "/opt/omp" }, () => child); + const started = client.start(); + child.server(ready); + await started; + const listed = client.createAuthorities().sessionAuthority.list(); + await Bun.sleep(0); + const request = child.request(); + const session: SessionRecord = { + sessionId: sessionId("loaded-session"), + path: "/tmp/loaded-session.jsonl", + cwd: "/tmp", + projectId: projectId("loaded-project"), + title: "Loaded session", + updatedAt: "2026-07-22T00:00:00.000Z", + status: "idle", + entries: [{ + id: entryId("loaded-entry"), + parentId: null, + hostId: hostId("host-test"), + sessionId: sessionId("loaded-session"), + kind: "message", + timestamp: "2026-07-22T00:00:00.000Z", + data: { text: "x".repeat(600_000) }, + }], + }; + child.server({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "response", + id: request.id, + ok: true, + result: [session], + }); + expect(await listed).toEqual([{ ...session, entriesLoaded: false, entries: [] }]); + await client.stop(); + }); + test("keeps terminal events attached before and after term.open settles", async () => { const child = new FakeBridgeChild(); const client = new OmpAuthorityBridgeClient({ executable: "/opt/omp" }, () => child); @@ -136,6 +277,71 @@ describe("OMP authority bridge client", () => { await client.stop(); }); + test("sends sparse session references to authority methods after a transcript is loaded", async () => { + const child = new FakeBridgeChild(); + const client = new OmpAuthorityBridgeClient({ executable: "/opt/omp" }, () => child); + const started = client.start(); + child.server({ + ...ready, + methods: [ + ...ready.methods, + "session.archive", + "session.restore", + "session.delete", + "discovery.load", + "discovery.page", + "lock.check", + ], + }); + await started; + const authorities = client.createAuthorities(); + const session: SessionRecord = { + sessionId: sessionId("large-session"), + path: "/tmp/large-session.jsonl", + cwd: "/tmp", + projectId: projectId("large-project"), + title: "Large session", + updatedAt: "2026-07-22T00:00:00.000Z", + status: "idle", + entries: [{ + id: entryId("large-entry"), + parentId: null, + hostId: hostId("host-test"), + sessionId: sessionId("large-session"), + kind: "message", + timestamp: "2026-07-22T00:00:00.000Z", + data: { text: "x".repeat(300_000) }, + }], + }; + const calls = [ + { method: "lock.status", pending: authorities.lockStatus(session), result: "missing" }, + { method: "lock.check", pending: Promise.resolve(authorities.lockCheck(session)), result: null }, + { method: "session.archive", pending: authorities.sessionAuthority.archive(session, "2026-07-22T00:00:00.000Z"), result: null }, + { method: "session.restore", pending: authorities.sessionAuthority.restore(session), result: null }, + { method: "session.delete", pending: authorities.sessionAuthority.delete(session), result: null }, + { method: "discovery.load", pending: authorities.discovery.load!(session), result: session }, + { method: "discovery.page", pending: authorities.discovery.page!(session, { limit: 10 }), result: { entries: [], hasMore: false } }, + ] as const; + for (let index = 0; index < calls.length; index += 1) { + await Bun.sleep(0); + const request = child.request(index); + expect(request).toMatchObject({ + type: "request", + method: calls[index]!.method, + params: { session: { entries: [], entriesLoaded: false } }, + }); + child.server({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "response", + id: request.id, + ok: true, + result: calls[index]!.result, + }); + await calls[index]!.pending; + } + await client.stop(); + }); + test("fails closed on an oversized unfinished bridge frame", async () => { const child = new FakeBridgeChild(); const client = new OmpAuthorityBridgeClient({ executable: "/opt/omp" }, () => child); diff --git a/packages/host-service/test/omp-authority-bridge-contract.test.ts b/packages/host-service/test/omp-authority-bridge-contract.test.ts index 64b9bde9..1a1d8d98 100644 --- a/packages/host-service/test/omp-authority-bridge-contract.test.ts +++ b/packages/host-service/test/omp-authority-bridge-contract.test.ts @@ -91,4 +91,27 @@ describe("OMP authority bridge contract", () => { params: { invalid: undefined }, })).toThrow("non-JSON"); }); + + test("accepts a bounded catalog response larger than the client request text budget", () => { + const item = { kind: "model", description: "x".repeat(420) }; + const catalog = Array.from({ length: 791 }, (_, index) => ({ ...item, id: `model-${index}` })); + expect( + decodeOmpAuthorityBridgeServerFrame({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "response", + id: "catalog-request", + ok: true, + result: { items: catalog }, + }), + ).toMatchObject({ type: "response", ok: true }); + expect(() => + decodeOmpAuthorityBridgeServerFrame({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "response", + id: "oversized-catalog-request", + ok: true, + result: { items: [{ description: "x".repeat(600_000) }] }, + }), + ).toThrow("text bounds"); + }); }); diff --git a/packages/host-service/test/runtime-workspace-server.test.ts b/packages/host-service/test/runtime-workspace-server.test.ts index bb1f15df..65395086 100644 --- a/packages/host-service/test/runtime-workspace-server.test.ts +++ b/packages/host-service/test/runtime-workspace-server.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { expect, setDefaultTimeout, test } from "bun:test"; import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -10,6 +10,8 @@ import { RawUdsWebSocket } from "./raw-uds-client.ts"; const host = hostId("runtime-workspace-test-host"); +setDefaultTimeout(30_000); + async function git(cwd: string, ...arguments_: string[]): Promise { const child = Bun.spawn(["git", "-C", cwd, ...arguments_], { stdout: "pipe", stderr: "pipe" }); const [exitCode, stdout, stderr] = await Promise.all([ diff --git a/packages/host-service/test/session-ownership-store.test.ts b/packages/host-service/test/session-ownership-store.test.ts new file mode 100644 index 00000000..ebbda76f --- /dev/null +++ b/packages/host-service/test/session-ownership-store.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { sessionId } from "@t4-code/host-wire"; +import { SessionOwnershipStore } from "../src/session-ownership-store.ts"; + +describe("session ownership store", () => { + test("persists exact private session and transcript identities", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-owned-sessions-")); + const ledgerPath = join(root, "profile", "owned-sessions.json"); + const transcriptPath = join(root, "session.jsonl"); + const sid = sessionId("owned-session"); + try { + const writer = new SessionOwnershipStore(ledgerPath); + await writer.add(sid, transcriptPath); + expect((await stat(ledgerPath)).mode & 0o777).toBe(0o600); + + const reader = new SessionOwnershipStore(ledgerPath); + await reader.load(); + expect(reader.owns(sid, transcriptPath)).toBe(true); + expect(reader.owns(sid, join(root, "replacement.jsonl"))).toBe(false); + + await reader.delete(sid); + const reloaded = new SessionOwnershipStore(ledgerPath); + await reloaded.load(); + expect(reloaded.owns(sid, transcriptPath)).toBe(false); + expect(JSON.parse(await readFile(ledgerPath, "utf8"))).toEqual({ version: 1, sessions: [] }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("fails closed for malformed or non-private ledgers", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-owned-sessions-invalid-")); + const ledgerPath = join(root, "owned-sessions.json"); + const sid = sessionId("owned-session"); + const transcriptPath = join(root, "session.jsonl"); + try { + await writeFile( + ledgerPath, + `${JSON.stringify({ version: 1, sessions: [{ sessionId: sid, path: transcriptPath }] })}\n`, + { mode: 0o644 }, + ); + const publicLedger = new SessionOwnershipStore(ledgerPath); + await publicLedger.load(); + expect(publicLedger.owns(sid, transcriptPath)).toBe(false); + + await chmod(ledgerPath, 0o600); + await writeFile(ledgerPath, "{not-json\n"); + const malformedLedger = new SessionOwnershipStore(ledgerPath); + await malformedLedger.load(); + expect(malformedLedger.owns(sid, transcriptPath)).toBe(false); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/host-service/test/transcript-page-server.test.ts b/packages/host-service/test/transcript-page-server.test.ts index efc36043..a1a0e0a9 100644 --- a/packages/host-service/test/transcript-page-server.test.ts +++ b/packages/host-service/test/transcript-page-server.test.ts @@ -16,6 +16,7 @@ import type { SessionDiscovery, SessionRecord } from "../src/types.ts"; import { RawUdsWebSocket } from "./raw-uds-client.ts"; const host = hostId("transcript-page-server-test"); +const authorityHost = hostId("transcript-page-authority"); const session = sessionId("cold-session"); const stamp = "2026-07-20T00:00:00.000Z"; @@ -47,7 +48,7 @@ class PagedDiscovery implements SessionDiscovery { { id: entryId("tail-entry"), parentId: null, - hostId: host, + hostId: authorityHost, sessionId: session, kind: "message", timestamp: stamp, @@ -101,6 +102,7 @@ test("advertises and routes cold transcript pages before whole-file loading", as }); const response = (await responseFor(client, "page-1")) as Extract; expect(response).toMatchObject({ ok: true, result: { hasMore: false, generation: "generation-1" } }); + expect((response.result as TranscriptPageResult).entries[0]).toMatchObject({ hostId: host, sessionId: session }); expect(discovery.pageCalls).toEqual([{ limit: 17, maxBytes: 4096 }]); expect(discovery.loadCalls).toBe(0); } finally { diff --git a/packages/host-service/test/transcript-search-index.test.ts b/packages/host-service/test/transcript-search-index.test.ts index ae361df9..8a575f60 100644 --- a/packages/host-service/test/transcript-search-index.test.ts +++ b/packages/host-service/test/transcript-search-index.test.ts @@ -443,4 +443,56 @@ describe("TranscriptSearchIndex", () => { await fs.rm(root, { recursive: true, force: true }); } }); + + test("retains sessions omitted from a partial inventory until a complete inventory confirms removal", async () => { + const { root, transcript, index } = await fixture(); + const omitted = join(root, "omitted.jsonl"); + try { + await fs.writeFile(transcript, message("kept", "user", "retained koala decision")); + await fs.writeFile(omitted, message("omitted", "assistant", "protected wombat decision")); + const keptRecord = record("session-one", transcript); + const omittedRecord = record("session-two", omitted); + await index.reconcile([keptRecord, omittedRecord]); + + const partial = await index.reconcile([keptRecord], { pruneMissing: false }); + expect(partial).toMatchObject({ state: "stale", indexedSessions: 2, knownSessions: 2 }); + const partialSearch = index.search({ query: "wombat" }); + expect(partialSearch.items).toHaveLength(1); + expect(partialSearch.incomplete).toBe(true); + + await index.reconcile([keptRecord]); + expect(index.search({ query: "wombat" }).items).toHaveLength(0); + } finally { + index.close(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + test("counts the union of retained and newly discovered sessions in a partial inventory", async () => { + const { root, transcript, index } = await fixture(); + const retained = join(root, "retained.jsonl"); + const discovered = join(root, "discovered.jsonl"); + try { + await fs.writeFile(transcript, message("old-one", "user", "first retained session")); + await fs.writeFile(retained, message("old-two", "assistant", "second retained session")); + await fs.writeFile(discovered, message("new-one", "assistant", "newly discovered session")); + await index.reconcile([ + record("session-old-one", transcript), + record("session-old-two", retained), + ]); + + const partial = await index.reconcile( + [record("session-new-one", discovered)], + { pruneMissing: false }, + ); + expect(partial).toMatchObject({ state: "stale", indexedSessions: 3, knownSessions: 3 }); + expect(index.search({ query: "session" })).toMatchObject({ + incomplete: true, + index: { indexedSessions: 3, knownSessions: 3 }, + }); + } finally { + index.close(); + await fs.rm(root, { recursive: true, force: true }); + } + }); }); diff --git a/packages/host-service/test/transcript-search-server.test.ts b/packages/host-service/test/transcript-search-server.test.ts index e015917c..2187e076 100644 --- a/packages/host-service/test/transcript-search-server.test.ts +++ b/packages/host-service/test/transcript-search-server.test.ts @@ -59,6 +59,15 @@ class StaticDiscovery implements SessionDiscovery { } } +class PartialDiscovery extends StaticDiscovery { + inventoryComplete(): boolean { + return false; + } + inventoryTotalCount(): number { + return 2; + } +} + class FakeSessionAuthority implements SessionAuthority { constructor( private readonly records: SessionRecord[], @@ -116,6 +125,7 @@ class FakeTranscriptSearchAuthority implements AppserverTranscriptSearchAuthorit readonly contexts: Array<{ sessionId: string; args: TranscriptContextArguments }> = []; initializeCalls = 0; reconcileCalls = 0; + lastReconcileOptions: { readonly pruneMissing?: boolean } | undefined; closeCalls = 0; constructor(lifecycle: string[] = []) { this.lifecycle = lifecycle; @@ -125,8 +135,12 @@ class FakeTranscriptSearchAuthority implements AppserverTranscriptSearchAuthorit this.initializeCalls += 1; this.lifecycle.push("search.initialize"); } - async reconcile(): Promise { + async reconcile( + _records: readonly SessionRecord[], + options?: { readonly pruneMissing?: boolean }, + ): Promise { this.reconcileCalls += 1; + this.lastReconcileOptions = options; this.lifecycle.push("search.reconcile"); return searchResult.index; } @@ -158,6 +172,12 @@ class FailingTranscriptSearchAuthority extends FakeTranscriptSearchAuthority { } } +class SynchronouslyFailingReconcileAuthority extends FakeTranscriptSearchAuthority { + override reconcile(): Promise { + throw new Error("synchronous search maintenance failure"); + } +} + function hello(): Record { return { v: "omp-app/1", @@ -278,6 +298,34 @@ describe("transcript search appserver boundary", () => { ); }); + test("keeps initial session inventory independent from deferred search maintenance", async () => { + const search = new SynchronouslyFailingReconcileAuthority(); + const { appserver, client, root } = await startServer([record("visible")], search); + await cleanup(appserver, client, root); + }); + + test("marks partial inventory reconciliation as non-pruning", async () => { + const root = await mkdtemp(join(tmpdir(), "omp-transcript-search-partial-")); + const socketPath = join(root, "run", "app.sock"); + const search = new FakeTranscriptSearchAuthority(); + const appserver = createAppserver({ + hostId: host, + epoch: "transcript-search-partial-test", + socketPath, + discovery: new PartialDiscovery([record("visible")]), + transcriptSearchAuthority: search, + }); + await appserver.start(); + const { client } = await readyClient(socketPath); + try { + for (let attempt = 0; attempt < 20 && search.reconcileCalls === 0; attempt += 1) + await Bun.sleep(5); + expect(search.lastReconcileOptions).toEqual({ pruneMissing: false }); + } finally { + await cleanup(appserver, client, root); + } + }); + test("routes search and archived context reads and never replays them from idempotency", async () => { const search = new FakeTranscriptSearchAuthority(); const archived = record("archived", "2026-01-02T00:00:00.000Z"); diff --git a/packages/host-wire/CHANGELOG.md b/packages/host-wire/CHANGELOG.md index aa325a01..2d10febd 100644 --- a/packages/host-wire/CHANGELOG.md +++ b/packages/host-wire/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Changed + +- Distinguish lockless sessions with no compatible handoff signal as `sessionControl.mode: "unverified"` instead of reporting a takeover that cannot complete. +- Negotiate `session.unverified` separately and down-convert that mode to the + existing read-only `reconciling` state for older clients. + ### Added - Add negotiated, bounded `transcript.page` backward pagination for attached session history. diff --git a/packages/host-wire/README.md b/packages/host-wire/README.md index 7b963930..114f4007 100644 --- a/packages/host-wire/README.md +++ b/packages/host-wire/README.md @@ -21,7 +21,7 @@ Appserver command idempotency is a bounded retry contract. Reusing a pending `co `session.attach` prepares its snapshot or requested replay before acknowledging success. It then delivers the acknowledgement, prepared frames, catch-up replay from the acknowledged baseline, and current subagent state in order. Cached attach delivery rebuilds this connection-scoped output and revalidates session existence, so an old success cannot resurrect a deleted session. Preparation failure does not mark the connection attached. -`session.observer` is additive. When another OMP process owns a session, `SessionRef.liveState.sessionControl` reports categorical observer state without exposing lock or process details. `mode: "observer"` carries `lockStatus: "live" | "suspect" | "malformed"` and `transcript: "live" | "snapshot"`; `mode: "reconciling"` carries only `transcript`. A missing `sessionControl` means ordinary appserver control. Present but malformed or unknown control data must be treated as read-only. +`session.observer` is additive. When another OMP process owns a session, `SessionRef.liveState.sessionControl` reports categorical observer state without exposing lock or process details. `mode: "observer"` carries `lockStatus: "live" | "suspect" | "malformed"` and `transcript: "live" | "snapshot"`; `mode: "reconciling"` carries only `transcript`. Clients that also negotiate `session.unverified` may receive that mode for a lockless session whose writer cannot be safely identified or handed off; older clients receive the existing read-only `reconciling` shape. A missing `sessionControl` means ordinary appserver control. Present but malformed or unknown control data must be treated as read-only. Appserver accepts one unresolved `session.prompt` per session. A second normal prompt receives `session_busy` before another prompt is written to the RPC child; use `session.steer` or `session.followUp` to add work to an active run. The session remains `active` across intermediate `turn.end` events, including tool-driven multi-turn runs, and returns to `idle` on the final `agent.end`. diff --git a/packages/host-wire/package.json b/packages/host-wire/package.json index 444a62a4..a286026a 100644 --- a/packages/host-wire/package.json +++ b/packages/host-wire/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/host-wire", - "version": "0.1.30", + "version": "0.1.31", "type": "module", "description": "Dependency-free T4 host protocol frames and bounded decoders", "license": "MIT", diff --git a/packages/host-wire/src/additive.ts b/packages/host-wire/src/additive.ts index 823d7ad6..b8b890d9 100644 --- a/packages/host-wire/src/additive.ts +++ b/packages/host-wire/src/additive.ts @@ -57,6 +57,7 @@ export const ADDITIVE_FEATURES = [ "session.state", "session.delta", "session.observer", + "session.unverified", "controller.lease", "prompt.lease", "prompt.images", diff --git a/packages/host-wire/src/capabilities.ts b/packages/host-wire/src/capabilities.ts index 2b7aabc6..7398dc0b 100644 --- a/packages/host-wire/src/capabilities.ts +++ b/packages/host-wire/src/capabilities.ts @@ -33,6 +33,7 @@ export const PROTOCOL_FEATURES = [ "session.state", "session.delta", "session.observer", + "session.unverified", "controller.lease", "prompt.lease", "prompt.images", diff --git a/packages/host-wire/src/session-index.ts b/packages/host-wire/src/session-index.ts index da7fa369..9f5afdd4 100644 --- a/packages/host-wire/src/session-index.ts +++ b/packages/host-wire/src/session-index.ts @@ -119,6 +119,10 @@ export type SessionControlState = | { mode: "reconciling"; transcript: SessionObserverTranscript; + } + | { + mode: "unverified"; + transcript: SessionObserverTranscript; }; export interface SessionLiveState { sessionControl?: SessionControlState; @@ -179,6 +183,13 @@ function decodeSessionControl(value: unknown, path: string): SessionControlState fail("INVALID_FRAME", "unknown reconciling session control field", path); return control as unknown as SessionControlState; } + if (control.mode === "unverified") { + if (control.transcript !== "live" && control.transcript !== "snapshot") + fail("INVALID_FRAME", "invalid unverified transcript state", `${path}.transcript`); + if (Object.keys(control).some(key => !["mode", "transcript"].includes(key))) + fail("INVALID_FRAME", "unknown unverified session control field", path); + return control as unknown as SessionControlState; + } fail("INVALID_FRAME", "invalid session control mode", `${path}.mode`); } const PROVIDER_TRANSPORT_KEYS = new Set([ diff --git a/packages/host-wire/test/app-wire.test.ts b/packages/host-wire/test/app-wire.test.ts index 18354ed1..4fad9a96 100644 --- a/packages/host-wire/test/app-wire.test.ts +++ b/packages/host-wire/test/app-wire.test.ts @@ -723,7 +723,7 @@ describe("app-wire authority", () => { test("exports the bridge schema version independently from the T4 package release", async () => { const metadata = (await Bun.file(new URL("../package.json", import.meta.url)).json()) as { version: string }; expect(APP_WIRE_VERSION).toBe("0.7.0"); - expect(metadata.version).toBe("0.1.30"); + expect(metadata.version).toBe("0.1.31"); }); test("session project wire data is opaque and live state is secret-free", () => { const providerTransport = { @@ -790,6 +790,8 @@ describe("app-wire authority", () => { test("session observer control is additive, categorical, and exact", () => { expect(ADDITIVE_FEATURES).toContain("session.observer"); expect(PROTOCOL_FEATURES).toContain("session.observer"); + expect(ADDITIVE_FEATURES).toContain("session.unverified"); + expect(PROTOCOL_FEATURES).toContain("session.unverified"); const session = { hostId: "h", sessionId: "s", @@ -808,6 +810,7 @@ describe("app-wire authority", () => { for (const lockStatus of ["live", "suspect", "malformed"]) expect(() => decodeServerFrame(frame({ mode: "observer", lockStatus, transcript: "live" }))).not.toThrow(); expect(() => decodeServerFrame(frame({ mode: "reconciling", transcript: "snapshot" }))).not.toThrow(); + expect(() => decodeServerFrame(frame({ mode: "unverified", transcript: "live" }))).not.toThrow(); for (const invalid of [ null, { mode: "observer", transcript: "live" }, @@ -815,6 +818,8 @@ describe("app-wire authority", () => { { mode: "future", transcript: "live" }, { mode: "reconciling", transcript: "future" }, { mode: "reconciling", transcript: "live", path: "/secret" }, + { mode: "unverified", transcript: "future" }, + { mode: "unverified", transcript: "live", owner: "secret" }, ]) expect(() => decodeServerFrame(frame(invalid))).toThrow(AppWireError); }); diff --git a/packages/host-wire/test/cluster-operator.test.ts b/packages/host-wire/test/cluster-operator.test.ts index 7c8d2c7d..e0f5d439 100644 --- a/packages/host-wire/test/cluster-operator.test.ts +++ b/packages/host-wire/test/cluster-operator.test.ts @@ -49,8 +49,8 @@ describe("cluster operator wire contract", () => { selectedProtocol: "omp-app/1", hostId: "cluster-host-uid-1", ompVersion: "17.0.5", - ompBuild: "8476f4451ed95c5d5401785d279a93d3c659fac4", - appserverVersion: "0.1.30", + ompBuild: "fc0c391334c08ab260057756aa84bd2b07741ee7", + appserverVersion: "0.1.31", appserverBuild: "cluster", epoch: "replica-pod-uid-1", grantedCapabilities: ["sessions.read", "ci.trigger"], diff --git a/packages/model-gateway/package.json b/packages/model-gateway/package.json index 5949e8ab..1500518b 100644 --- a/packages/model-gateway/package.json +++ b/packages/model-gateway/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/model-gateway", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "description": "Credential-isolating provider gateway for T4 cluster sessions", diff --git a/packages/protocol/package.json b/packages/protocol/package.json index a2475381..5f8691be 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/protocol", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "exports": { diff --git a/packages/protocol/src/browser-ipc.ts b/packages/protocol/src/browser-ipc.ts index 0888b198..ea2b8eb0 100644 --- a/packages/protocol/src/browser-ipc.ts +++ b/packages/protocol/src/browser-ipc.ts @@ -598,7 +598,8 @@ const MAX_ELEMENTS = 512; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; const SURFACE_PATTERN = /^surface:([1-9][0-9]{0,8})$/u; const ELEMENT_PATTERN = /^@e([1-9][0-9]{0,8})$/u; -const OWNER_SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const MAX_OWNER_SESSION_ID_BYTES = 512; +const OWNER_SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~!%*'()/:+-]{0,511}$/u; export class BrowserProtocolError extends Error { readonly code: BrowserErrorCode = "invalid_params"; @@ -705,10 +706,15 @@ function surfaceId(value: unknown, path = "surfaceId"): SurfaceId { return text as SurfaceId; } function ownerSessionId(value: unknown, path = "ownerSessionId"): OwnerSessionId { - const text = boundedString(value, path, 128); - if (!OWNER_SESSION_ID_PATTERN.test(text)) fail(`${path} must be a bounded session identifier`); + const text = boundedString(value, path, MAX_OWNER_SESSION_ID_BYTES); + if (!isBrowserOwnerSessionId(text)) fail(`${path} must be a bounded session identifier`); return text as OwnerSessionId; } +export function isBrowserOwnerSessionId(value: unknown): value is OwnerSessionId { + return typeof value === "string" && + utf8ByteLength(value) <= MAX_OWNER_SESSION_ID_BYTES && + OWNER_SESSION_ID_PATTERN.test(value); +} function handle(value: unknown, path = "handle"): SurfaceHandle { const text = boundedString(value, path, 32); if (!SURFACE_PATTERN.test(text)) fail(`${path} must be a surface handle`); diff --git a/packages/protocol/src/desktop-ipc.ts b/packages/protocol/src/desktop-ipc.ts index c665fcdc..96bee1db 100644 --- a/packages/protocol/src/desktop-ipc.ts +++ b/packages/protocol/src/desktop-ipc.ts @@ -66,6 +66,9 @@ export const DESKTOP_IPC_CHANNELS = [ "omp:service:stop", "omp:service:restart", "omp:service:uninstall", + "app:t4-omp:inspect", + "app:t4-omp:install", + "app:t4-omp:remove", "app:update:get-state", "app:update:check", "app:update:download", @@ -113,6 +116,37 @@ export interface ServiceActionRequest {} export interface ServiceActionResult { completed: true; } +export type T4OmpLauncherPhase = + | "unsupported" + | "not-installed" + | "installed" + | "update-available" + | "conflict"; +export interface T4OmpLauncherState { + readonly phase: T4OmpLauncherPhase; + readonly command: "t4-omp"; + readonly location: "~/.local/bin/t4-omp"; + readonly message: string; +} +export interface T4OmpLauncherRequest {} +export function decodeT4OmpLauncherState(value: unknown): T4OmpLauncherState { + const item = object(value, "t4-omp launcher state"); + exact(item, ["phase", "command", "location", "message"]); + if ( + !["unsupported", "not-installed", "installed", "update-available", "conflict"].includes( + item.phase as string, + ) + ) throw new Error("invalid t4-omp launcher phase"); + if (item.command !== "t4-omp" || item.location !== "~/.local/bin/t4-omp") { + throw new Error("invalid t4-omp launcher identity"); + } + return Object.freeze({ + phase: item.phase as T4OmpLauncherPhase, + command: "t4-omp", + location: "~/.local/bin/t4-omp", + message: controlFree(item.message, "t4-omp launcher message", 512), + }); +} export type PhoneSetupPhase = "unsupported" | "tailscale-required" | "not-configured" | "ready" | "error"; export interface PhoneSetupState { readonly phase: PhoneSetupPhase; @@ -499,6 +533,9 @@ export interface DesktopInvokeRequestMap { "omp:service:stop": ServiceActionRequest; "omp:service:restart": ServiceActionRequest; "omp:service:uninstall": ServiceActionRequest; + "app:t4-omp:inspect": T4OmpLauncherRequest; + "app:t4-omp:install": T4OmpLauncherRequest; + "app:t4-omp:remove": T4OmpLauncherRequest; "app:update:get-state": DesktopUpdateRequest; "app:update:check": DesktopUpdateRequest; "app:update:download": DesktopUpdateRequest; @@ -537,6 +574,9 @@ export interface DesktopInvokeResponseMap { "omp:service:stop": ServiceActionResult; "omp:service:restart": ServiceActionResult; "omp:service:uninstall": ServiceActionResult; + "app:t4-omp:inspect": T4OmpLauncherState; + "app:t4-omp:install": T4OmpLauncherState; + "app:t4-omp:remove": T4OmpLauncherState; "omp:speech:speak": SpeechResult; "omp:speech:stop": SpeechResult; "app:update:get-state": DesktopUpdateState; @@ -849,7 +889,29 @@ export function decodeProjectionCacheSaveResult(value: unknown): ProjectionCache export function decodeProjectionCacheSaveRequestValue(value: unknown): string { return projectionCacheSerialized(value); } + +/** + * Projection caches intentionally have a larger budget than ordinary app-wire + * input. Electron IPC delivers this request as an already-cloned object, so + * recognize the one large channel before the generic 1 MiB object guard and + * apply its stricter cache-specific schema and 2 MiB bound instead. + */ +function decodeLargeProjectionCacheSaveRequest(input: unknown): DesktopInvokeRequest | null { + if (input === null || typeof input !== "object" || Array.isArray(input)) return null; + const frame = object(input, "frame"); + if (frame.channel !== "app:projection-cache:save") return null; + exact(frame, ["channel", "payload"]); + const payload = object(frame.payload, "payload"); + exact(payload, ["value"]); + return { + channel: "app:projection-cache:save", + payload: { value: projectionCacheSerialized(payload.value) }, + }; +} + export function decodeDesktopInvokeRequest(input: unknown): DesktopInvokeRequest { + const largeProjectionCache = decodeLargeProjectionCacheSaveRequest(input); + if (largeProjectionCache !== null) return largeProjectionCache; const frame = inputObject(input); exact(frame, ["channel", "payload"]); if (!(DESKTOP_IPC_CHANNELS as readonly string[]).includes(frame.channel as string)) @@ -909,6 +971,9 @@ export function decodeDesktopInvokeRequest(input: unknown): DesktopInvokeRequest case "omp:service:stop": case "omp:service:restart": case "omp:service:uninstall": + case "app:t4-omp:inspect": + case "app:t4-omp:install": + case "app:t4-omp:remove": case "app:update:get-state": case "app:update:check": case "app:update:download": diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index f1db54af..7b37e97d 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -38,7 +38,7 @@ function isKnownSessionControl(value: unknown): boolean { ); } return ( - value.mode === "reconciling" && + (value.mode === "reconciling" || value.mode === "unverified") && hasExactKeys(value, ["mode", "transcript"]) && (value.transcript === "live" || value.transcript === "snapshot") ); diff --git a/packages/protocol/test/browser-ipc.test.ts b/packages/protocol/test/browser-ipc.test.ts index 56dae0b1..d670637b 100644 --- a/packages/protocol/test/browser-ipc.test.ts +++ b/packages/protocol/test/browser-ipc.test.ts @@ -33,6 +33,25 @@ function surface(lifecycle: "creating" | "loading" | "ready" | "closed" | "crash } describe("browser IPC boundary", () => { + it("accepts encoded composite workspace-session owners", () => { + expect( + decodeBrowserCall({ + version: BROWSER_IPC_VERSION, + method: "surface.list", + ownerSessionId: "host-a/session%20with%20spaces", + request: {}, + }), + ).toMatchObject({ ownerSessionId: "host-a/session%20with%20spaces" }); + expect(() => + decodeBrowserCall({ + version: BROWSER_IPC_VERSION, + method: "surface.list", + ownerSessionId: "host-a/session with spaces", + request: {}, + }), + ).toThrow(BrowserProtocolError); + }); + it("keeps the design brief out of page-bound design-mode requests", () => { const call = decodeBrowserCall({ version: BROWSER_IPC_VERSION, diff --git a/packages/protocol/test/desktop-ipc.test.ts b/packages/protocol/test/desktop-ipc.test.ts index b2a91945..07f147b5 100644 --- a/packages/protocol/test/desktop-ipc.test.ts +++ b/packages/protocol/test/desktop-ipc.test.ts @@ -9,6 +9,7 @@ import { decodeProjectionCacheSaveRequestValue, decodeProjectionCacheSaveResult, decodePhoneSetupState, + decodeT4OmpLauncherState, decodeSpeechText, MAX_PROJECTION_CACHE_BYTES, MAX_SPEECH_TEXT_BYTES, @@ -16,6 +17,29 @@ import { } from "../src/desktop-ipc.ts"; describe("desktop IPC boundary", () => { + it("strictly bounds t4-omp launcher state and actions", () => { + expect(decodeT4OmpLauncherState({ + phase: "installed", + command: "t4-omp", + location: "~/.local/bin/t4-omp", + message: "Ready", + })).toEqual({ + phase: "installed", + command: "t4-omp", + location: "~/.local/bin/t4-omp", + message: "Ready", + }); + for (const channel of ["app:t4-omp:inspect", "app:t4-omp:install", "app:t4-omp:remove"] as const) { + expect(decodeDesktopInvokeRequest({ channel, payload: {} })).toEqual({ channel, payload: {} }); + expect(() => decodeDesktopInvokeRequest({ channel, payload: { path: "/tmp/omp" } })).toThrow(); + } + expect(() => decodeT4OmpLauncherState({ + phase: "installed", + command: "omp", + location: "/tmp/omp", + message: "Ready", + })).toThrow(); + }); it("accepts only private root Tailnet URLs for phone setup", () => { expect(decodePhoneSetupState({ phase: "ready", @@ -495,6 +519,19 @@ describe("desktop IPC boundary", () => { value: currentValue, }); + const largeCurrentValue = JSON.stringify({ + kind: "t4-code-projection", + version: 2, + data: { padding: "x".repeat(1_100_000) }, + }); + expect(decodeDesktopInvokeRequest({ + channel: "app:projection-cache:save", + payload: { value: largeCurrentValue }, + })).toEqual({ + channel: "app:projection-cache:save", + payload: { value: largeCurrentValue }, + }); + for (const invalid of [ { channel: "app:projection-cache:load", payload: { storageKey: "renderer-choice" } }, { channel: "app:projection-cache:save", payload: { value: JSON.stringify({ kind: "t4-code-projection", version: 3, data: {} }) } }, diff --git a/packages/protocol/test/distribution.test.ts b/packages/protocol/test/distribution.test.ts index 637f2655..d8e1edfa 100644 --- a/packages/protocol/test/distribution.test.ts +++ b/packages/protocol/test/distribution.test.ts @@ -166,7 +166,7 @@ describe("T4 host-wire distribution", () => { readFileSync(join(installedRoot, "package.json"), "utf8"), ) as Record; expect(installedPackage.name).toBe("@t4-code/host-wire"); - expect(installedPackage.version).toBe("0.1.30"); + expect(installedPackage.version).toBe("0.1.31"); expect(installedPackage.version).not.toBe(manifest.version); expect(installedPackage.dependencies ?? {}).toEqual({}); }); diff --git a/packages/protocol/test/fixtures/platform-boundaries.ts b/packages/protocol/test/fixtures/platform-boundaries.ts index abbe59e0..d2762430 100644 --- a/packages/protocol/test/fixtures/platform-boundaries.ts +++ b/packages/protocol/test/fixtures/platform-boundaries.ts @@ -25,7 +25,7 @@ export const androidUpdateFixtures = Object.freeze({ }, { currentVersion: "0.1.22", - latestVersion: "0.1.30", + latestVersion: "0.1.31", checkedAt: 1_721_234_567_890, phase: "available", revision: 7, @@ -33,7 +33,7 @@ export const androidUpdateFixtures = Object.freeze({ }, { currentVersion: "0.1.22", - latestVersion: "0.1.30", + latestVersion: "0.1.31", phase: "installer", revision: 8, message: "Installer opened.\nReview Android's prompt.", diff --git a/packages/remote/package.json b/packages/remote/package.json index 40d2b642..4628db2e 100644 --- a/packages/remote/package.json +++ b/packages/remote/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/remote", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "exports": { diff --git a/packages/service-manager/package.json b/packages/service-manager/package.json index f1102cbe..ad1436ac 100644 --- a/packages/service-manager/package.json +++ b/packages/service-manager/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/service-manager", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "exports": { diff --git a/packages/t4-api-client/package.json b/packages/t4-api-client/package.json new file mode 100644 index 00000000..789eff43 --- /dev/null +++ b/packages/t4-api-client/package.json @@ -0,0 +1,23 @@ +{ + "name": "@t4-code/t4-api-client", + "version": "0.1.31", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./generated": "./src/generated/schema.ts" + }, + "scripts": { + "build": "tsgo --noEmit", + "typecheck": "tsgo --noEmit", + "test": "vp test run --passWithNoTests" + }, + "dependencies": { + "openapi-fetch": "0.17.0" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/t4-api-client/src/generated/schema.ts b/packages/t4-api-client/src/generated/schema.ts new file mode 100644 index 00000000..c20c817d --- /dev/null +++ b/packages/t4-api-client/src/generated/schema.ts @@ -0,0 +1,1076 @@ +export interface paths { + "/v1": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Negotiate T4 API v1 and discover capabilities and bounds */ + get: operations["discoverV1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/sessions/{sessionId}": { + parameters: { + query?: never; + header?: never; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + get: operations["getSession"]; + put?: never; + post?: never; + delete: operations["deleteSession"]; + options?: never; + head?: never; + patch: operations["mutateSession"]; + trace?: never; + }; + "/v1/sessions/{sessionId}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["cancelSession"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/sessions/{sessionId}/commands": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["submitCommand"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/sessions/{sessionId}/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Watch a bounded Server-Sent Events stream over HTTPS + * @description The response is standard Server-Sent Events over HTTPS. Clients reconnect with the last event cursor in the cursor query parameter or Last-Event-ID header. Heartbeat events bound idle detection. Clients explicitly cancel by aborting the HTTPS request. A retained cursor expiry returns a typed 410 response with a snapshot resync target. + */ + get: operations["watchSessionEvents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/sessions/{sessionId}/snapshot": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getSessionSnapshot"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/workspaces": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listWorkspaces"]; + put?: never; + post: operations["createWorkspace"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/workspaces/{workspaceId}": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: components["parameters"]["WorkspaceId"]; + }; + cookie?: never; + }; + get: operations["getWorkspace"]; + put?: never; + post?: never; + delete: operations["deleteWorkspace"]; + options?: never; + head?: never; + patch: operations["mutateWorkspace"]; + trace?: never; + }; + "/v1/workspaces/{workspaceId}/sessions": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: components["parameters"]["WorkspaceId"]; + }; + cookie?: never; + }; + get: operations["listSessions"]; + put?: never; + post: operations["spawnSession"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + ApiError: { + /** @enum {string} */ + code: "invalid_request" | "unauthenticated" | "forbidden" | "not_found" | "idempotency_key_required" | "idempotency_conflict" | "revision_conflict" | "incompatible_version" | "cursor_expired" | "unavailable" | "indeterminate" | "invalid_origin" | "https_required"; + message: string; + requestId: string; + resync?: components["schemas"]["Resync"]; + retryable: boolean; + supportedMajors?: number[]; + violations?: components["schemas"]["FieldViolation"][]; + }; + BadRequestApiError: components["schemas"]["ApiError"] & { + /** @enum {unknown} */ + code?: "invalid_request" | "idempotency_key_required" | "invalid_origin" | "https_required"; + }; + BadRequestErrorEnvelope: { + error: components["schemas"]["BadRequestApiError"]; + }; + Capabilities: { + [key: string]: components["schemas"]["CapabilityStatus"]; + }; + CapabilityDeprecation: { + message: string; + replacement?: string; + sinceVersion?: string; + /** Format: date-time */ + sunsetAt?: string; + }; + CapabilityStatus: { + authorized: boolean; + available: boolean; + deprecation?: components["schemas"]["CapabilityDeprecation"]; + enabled: boolean; + supported: boolean; + }; + CommandCreate: { + command: string; + /** @default {} */ + metadata?: { + [key: string]: string | number | boolean | null; + }; + }; + CommandResult: { + commandId: components["schemas"]["ResourceId"]; + state: components["schemas"]["CommandState"]; + }; + /** @enum {string} */ + CommandState: "accepted" | "projected" | "dispatching" | "running" | "succeeded" | "failed" | "cancelling" | "cancelled" | "rejected" | "unavailable" | "indeterminate"; + CommandWatchEvent: { + commandId: components["schemas"]["ResourceId"]; + cursor: components["schemas"]["Cursor"]; + state: components["schemas"]["CommandState"]; + /** @constant */ + type: "command"; + }; + ConflictApiError: components["schemas"]["ApiError"] & { + /** @enum {unknown} */ + code?: "idempotency_conflict" | "revision_conflict"; + }; + ConflictErrorEnvelope: { + error: components["schemas"]["ConflictApiError"]; + }; + /** @description Opaque server-issued header-safe SSE cursor. */ + Cursor: string; + CursorExpiredApiError: components["schemas"]["ApiError"] & { + /** @constant */ + code?: "cursor_expired"; + resync: components["schemas"]["Resync"]; + }; + CursorExpiredErrorEnvelope: { + error: components["schemas"]["CursorExpiredApiError"]; + }; + Discovery: { + apiVersion: string; + capabilities: components["schemas"]["Capabilities"]; + limits: { + commandBytesMax: number; + commandMetadataValueBytesMax: number; + commandRequestBytesMax: number; + heartbeatSeconds: number; + pageSizeDefault: number; + pageSizeMax: number; + watchEventsDefault: number; + watchEventsMax: number; + }; + serverBuild: components["schemas"]["ServerBuild"]; + supportedMajors: number[]; + }; + ErrorEnvelope: { + error: components["schemas"]["ApiError"]; + }; + FieldViolation: { + field: string; + message: string; + rule: string; + }; + ForbiddenApiError: components["schemas"]["ApiError"] & { + /** @constant */ + code?: "forbidden"; + }; + ForbiddenErrorEnvelope: { + error: components["schemas"]["ForbiddenApiError"]; + }; + HeartbeatWatchEvent: { + cursor: components["schemas"]["Cursor"]; + /** Format: date-time */ + observedAt: string; + /** @constant */ + type: "heartbeat"; + }; + IncompatibleVersionApiError: components["schemas"]["ApiError"] & { + /** @constant */ + code?: "incompatible_version"; + supportedMajors: number[]; + }; + IncompatibleVersionErrorEnvelope: { + error: components["schemas"]["IncompatibleVersionApiError"]; + }; + InvalidRequestApiError: components["schemas"]["ApiError"] & { + /** @constant */ + code?: "invalid_request"; + violations: components["schemas"]["FieldViolation"][]; + }; + InvalidRequestErrorEnvelope: { + error: components["schemas"]["InvalidRequestApiError"]; + }; + Labels: { + [key: string]: string; + }; + NotFoundApiError: components["schemas"]["ApiError"] & { + /** @constant */ + code?: "not_found"; + }; + NotFoundErrorEnvelope: { + error: components["schemas"]["NotFoundApiError"]; + }; + ResourceId: string; + Resync: { + cursor: components["schemas"]["Cursor"]; + /** @description API-base-relative snapshot path for this watched session. */ + snapshotUrl: string; + }; + Revision: number; + ServerBuild: { + revision: string; + version: string; + }; + Session: { + id: components["schemas"]["ResourceId"]; + labels?: components["schemas"]["Labels"]; + revision: components["schemas"]["Revision"]; + state: components["schemas"]["SessionState"]; + title: string; + workspaceId: components["schemas"]["ResourceId"]; + }; + SessionCreate: { + labels?: components["schemas"]["Labels"]; + title: string; + }; + SessionMutation: { + labels?: components["schemas"]["Labels"]; + title?: string; + }; + SessionPage: { + items: components["schemas"]["Session"][]; + nextCursor?: components["schemas"]["Cursor"]; + }; + SessionSnapshot: { + cursor: components["schemas"]["Cursor"]; + entries: components["schemas"]["SnapshotEntry"][]; + sessionId: components["schemas"]["ResourceId"]; + state: components["schemas"]["SessionState"]; + }; + /** @enum {string} */ + SessionState: "accepted" | "provisioning" | "ready" | "cancelling" | "cancelled" | "failed" | "unavailable" | "indeterminate"; + SessionWatchEvent: { + cursor: components["schemas"]["Cursor"]; + revision: components["schemas"]["Revision"]; + state: components["schemas"]["SessionState"]; + /** @constant */ + type: "session"; + }; + SnapshotEntry: { + /** @enum {string} */ + kind: "input" | "output" | "status"; + sequence: number; + text: string; + }; + UnauthenticatedApiError: components["schemas"]["ApiError"] & { + /** @constant */ + code?: "unauthenticated"; + }; + UnauthenticatedErrorEnvelope: { + error: components["schemas"]["UnauthenticatedApiError"]; + }; + UnavailableApiError: components["schemas"]["ApiError"] & { + /** @enum {unknown} */ + code?: "unavailable" | "indeterminate"; + }; + UnavailableErrorEnvelope: { + error: components["schemas"]["UnavailableApiError"]; + }; + WatchEvent: components["schemas"]["HeartbeatWatchEvent"] | components["schemas"]["SessionWatchEvent"] | components["schemas"]["CommandWatchEvent"]; + Workspace: { + id: components["schemas"]["ResourceId"]; + labels?: components["schemas"]["Labels"]; + name: string; + revision: components["schemas"]["Revision"]; + state: components["schemas"]["WorkspaceState"]; + }; + WorkspaceCreate: { + labels?: components["schemas"]["Labels"]; + name: string; + }; + WorkspaceMutation: { + labels?: components["schemas"]["Labels"]; + name?: string; + }; + WorkspacePage: { + items: components["schemas"]["Workspace"][]; + nextCursor?: components["schemas"]["Cursor"]; + }; + /** @enum {string} */ + WorkspaceState: "accepted" | "provisioning" | "ready" | "deleting" | "deleted" | "failed" | "unavailable" | "indeterminate"; + }; + responses: { + /** @description Command intent accepted with a stable outcome state */ + CommandAccepted: { + headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; + "T4-API-Version": components["headers"]["SelectedVersion"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResult"]; + }; + }; + /** @description Identical command request replay */ + CommandReplay: { + headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; + "T4-API-Version": components["headers"]["SelectedVersion"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResult"]; + }; + }; + /** @description Idempotent deletion accepted or resource already absent */ + Deleted: { + headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; + "T4-API-Version": components["headers"]["SelectedVersion"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; + [name: string]: unknown; + }; + content?: never; + }; + /** @description Negotiated v1 discovery */ + Discovery: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Discovery"]; + }; + }; + /** @description Malformed request or missing idempotency key */ + Error400: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BadRequestErrorEnvelope"]; + }; + }; + /** @description Missing or invalid opaque bearer credential */ + Error401: { + headers: { + "WWW-Authenticate": "Bearer realm=\"t4\""; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnauthenticatedErrorEnvelope"]; + }; + }; + /** @description Credential lacks the deny-by-default operation or resource scope */ + Error403: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ForbiddenErrorEnvelope"]; + }; + }; + /** @description Resource absent or outside caller scope (scope existence is not disclosed) */ + Error404: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NotFoundErrorEnvelope"]; + }; + }; + /** @description Requested major is incompatible; no silent downgrade */ + Error406: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IncompatibleVersionErrorEnvelope"]; + }; + }; + /** @description Idempotency or resource revision conflict */ + Error409: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConflictErrorEnvelope"]; + }; + }; + /** @description Watch cursor expired; resync from the typed snapshot target */ + Error410: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CursorExpiredErrorEnvelope"]; + }; + }; + /** @description Bounded semantic validation failure with stable field violations */ + Error422: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InvalidRequestErrorEnvelope"]; + }; + }; + /** @description Service temporarily unavailable or outcome indeterminate. Retryable watch failures may advertise Retry-After; clients honor it with a 30 second ceiling. */ + Error503: { + headers: { + /** @description RFC 9110 delay in seconds or HTTP date; clients bound the applied delay. */ + "Retry-After"?: string; + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnavailableErrorEnvelope"]; + }; + }; + /** @description Session */ + Session: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Session"]; + }; + }; + /** @description Session intent accepted */ + SessionAccepted: { + headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; + "T4-API-Version": components["headers"]["SelectedVersion"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Session"]; + }; + }; + /** @description Bounded session page */ + SessionPage: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionPage"]; + }; + }; + /** @description Identical session mutation replay */ + SessionReplay: { + headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; + "T4-API-Version": components["headers"]["SelectedVersion"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Session"]; + }; + }; + /** @description Bounded session snapshot and reconnect cursor */ + Snapshot: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionSnapshot"]; + }; + }; + /** @description Workspace */ + Workspace: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Workspace"]; + }; + }; + /** @description Workspace intent accepted */ + WorkspaceAccepted: { + headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; + "T4-API-Version": components["headers"]["SelectedVersion"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Workspace"]; + }; + }; + /** @description Bounded workspace page */ + WorkspacePage: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkspacePage"]; + }; + }; + /** @description Identical workspace request replay */ + WorkspaceReplay: { + headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; + "T4-API-Version": components["headers"]["SelectedVersion"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Workspace"]; + }; + }; + }; + parameters: { + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + ApiVersion: string; + HeartbeatSeconds: number; + /** @description Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict. */ + IdempotencyKey: string; + /** @description Decimal resource revision for optimistic mutation. */ + IfRevision: string; + /** @description Standard SSE reconnect header. Must agree with cursor when both are present. */ + LastEventId: components["schemas"]["Cursor"]; + /** @description Omit to use the server-specific limits.watchEventsDefault value from discovery. */ + MaxEvents: number; + /** @description Opaque page cursor, valid only for the same identity and list operation. */ + PageCursor: components["schemas"]["Cursor"]; + /** @description Omit to use the server-specific limits.pageSizeDefault value from discovery. */ + PageSize: number; + SessionId: components["schemas"]["ResourceId"]; + /** @description Opaque last-consumed watch event cursor. */ + WatchCursor: components["schemas"]["Cursor"]; + WorkspaceId: components["schemas"]["ResourceId"]; + }; + requestBodies: never; + headers: { + /** @description Durable event cursor committed with the accepted mutation and preserved by replay. */ + EventCursor: components["schemas"]["Cursor"]; + /** @description True when this response replays a prior identical request. */ + IdempotencyReplayed: "true" | "false"; + /** @description Selected T4 API profile. This strict v1.0 contract requires exactly 1.0. */ + SelectedVersion: "1.0"; + }; + pathItems: never; +} +export type $defs = Record; +export interface operations { + discoverV1: { + parameters: { + query?: never; + header: { + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["Discovery"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 406: components["responses"]["Error406"]; + 503: components["responses"]["Error503"]; + }; + }; + getSession: { + parameters: { + query?: never; + header: { + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["Session"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 503: components["responses"]["Error503"]; + }; + }; + deleteSession: { + parameters: { + query?: never; + header: { + /** @description Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict. */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: components["responses"]["Deleted"]; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 409: components["responses"]["Error409"]; + 503: components["responses"]["Error503"]; + }; + }; + mutateSession: { + parameters: { + query?: never; + header: { + /** @description Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict. */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + /** @description Decimal resource revision for optimistic mutation. */ + "T4-If-Revision": components["parameters"]["IfRevision"]; + }; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SessionMutation"]; + }; + }; + responses: { + 200: components["responses"]["SessionReplay"]; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 409: components["responses"]["Error409"]; + 422: components["responses"]["Error422"]; + 503: components["responses"]["Error503"]; + }; + }; + cancelSession: { + parameters: { + query?: never; + header: { + /** @description Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict. */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["SessionReplay"]; + 202: components["responses"]["SessionAccepted"]; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 409: components["responses"]["Error409"]; + 503: components["responses"]["Error503"]; + }; + }; + submitCommand: { + parameters: { + query?: never; + header: { + /** @description Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict. */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CommandCreate"]; + }; + }; + responses: { + 200: components["responses"]["CommandReplay"]; + 202: components["responses"]["CommandAccepted"]; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 409: components["responses"]["Error409"]; + 422: components["responses"]["Error422"]; + 503: components["responses"]["Error503"]; + }; + }; + watchSessionEvents: { + parameters: { + query?: { + /** @description Opaque last-consumed watch event cursor. */ + cursor?: components["parameters"]["WatchCursor"]; + heartbeatSeconds?: components["parameters"]["HeartbeatSeconds"]; + /** @description Omit to use the server-specific limits.watchEventsDefault value from discovery. */ + maxEvents?: components["parameters"]["MaxEvents"]; + }; + header: { + /** @description Standard SSE reconnect header. Must agree with cursor when both are present. */ + "Last-Event-ID"?: components["parameters"]["LastEventId"]; + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Bounded SSE event stream. Every non-empty SSE data field is one JSON value conforming to WatchEvent; clients MUST reject unknown fields and schema-invalid event payloads before delivery. Transport chunk boundaries do not delimit frames. */ + 200: { + headers: { + "Cache-Control": "no-store"; + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + /** @example id: cur_01J...\nevent: heartbeat\ndata: {"type":"heartbeat","cursor":"cur_01J...","observedAt":"2026-07-21T00:00:00Z"}\n\n */ + "text/event-stream": string; + }; + }; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 410: components["responses"]["Error410"]; + 422: components["responses"]["Error422"]; + 503: components["responses"]["Error503"]; + }; + }; + getSessionSnapshot: { + parameters: { + query?: never; + header: { + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["Snapshot"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 503: components["responses"]["Error503"]; + }; + }; + listWorkspaces: { + parameters: { + query?: { + /** @description Opaque page cursor, valid only for the same identity and list operation. */ + cursor?: components["parameters"]["PageCursor"]; + /** @description Omit to use the server-specific limits.pageSizeDefault value from discovery. */ + pageSize?: components["parameters"]["PageSize"]; + }; + header: { + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["WorkspacePage"]; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 406: components["responses"]["Error406"]; + 422: components["responses"]["Error422"]; + 503: components["responses"]["Error503"]; + }; + }; + createWorkspace: { + parameters: { + query?: never; + header: { + /** @description Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict. */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkspaceCreate"]; + }; + }; + responses: { + 200: components["responses"]["WorkspaceReplay"]; + 202: components["responses"]["WorkspaceAccepted"]; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 406: components["responses"]["Error406"]; + 409: components["responses"]["Error409"]; + 422: components["responses"]["Error422"]; + 503: components["responses"]["Error503"]; + }; + }; + getWorkspace: { + parameters: { + query?: never; + header: { + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + workspaceId: components["parameters"]["WorkspaceId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["Workspace"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 503: components["responses"]["Error503"]; + }; + }; + deleteWorkspace: { + parameters: { + query?: never; + header: { + /** @description Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict. */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + workspaceId: components["parameters"]["WorkspaceId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: components["responses"]["Deleted"]; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 409: components["responses"]["Error409"]; + 503: components["responses"]["Error503"]; + }; + }; + mutateWorkspace: { + parameters: { + query?: never; + header: { + /** @description Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict. */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + /** @description Decimal resource revision for optimistic mutation. */ + "T4-If-Revision": components["parameters"]["IfRevision"]; + }; + path: { + workspaceId: components["parameters"]["WorkspaceId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkspaceMutation"]; + }; + }; + responses: { + 200: components["responses"]["WorkspaceReplay"]; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 409: components["responses"]["Error409"]; + 422: components["responses"]["Error422"]; + 503: components["responses"]["Error503"]; + }; + }; + listSessions: { + parameters: { + query?: { + /** @description Opaque page cursor, valid only for the same identity and list operation. */ + cursor?: components["parameters"]["PageCursor"]; + /** @description Omit to use the server-specific limits.pageSizeDefault value from discovery. */ + pageSize?: components["parameters"]["PageSize"]; + }; + header: { + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + workspaceId: components["parameters"]["WorkspaceId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["SessionPage"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 422: components["responses"]["Error422"]; + 503: components["responses"]["Error503"]; + }; + }; + spawnSession: { + parameters: { + query?: never; + header: { + /** @description Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict. */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + workspaceId: components["parameters"]["WorkspaceId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SessionCreate"]; + }; + }; + responses: { + 200: components["responses"]["SessionReplay"]; + 202: components["responses"]["SessionAccepted"]; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 409: components["responses"]["Error409"]; + 422: components["responses"]["Error422"]; + 503: components["responses"]["Error503"]; + }; + }; +} diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts new file mode 100644 index 00000000..84de6e1b --- /dev/null +++ b/packages/t4-api-client/src/index.ts @@ -0,0 +1,1049 @@ +import createClient, { type Client } from "openapi-fetch"; + +import type { components, paths } from "./generated/schema.ts"; + +export type { components, operations, paths } from "./generated/schema.ts"; + +type HttpMethod = "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace"; +type EmptyHeaders = Readonly>; +type WithoutInjectedVersion = Omit; +type ClientParameters = Parameters extends { header: infer Headers } + ? Omit & (keyof WithoutInjectedVersion extends never + ? { readonly header?: EmptyHeaders } + : { readonly header: WithoutInjectedVersion }) + : Parameters; +type ClientOperation = Operation extends { parameters: infer Parameters } + ? Omit & { readonly parameters: ClientParameters } + : Operation; +type ClientPath = { + readonly [Key in keyof Path]: Key extends HttpMethod ? ClientOperation : Path[Key]; +}; + +/** Generated API paths with the SDK-owned version header removed from caller input. */ +export type T4ClientPaths = { readonly [Path in keyof paths]: ClientPath }; + +const MAX_CREDENTIAL_LENGTH = 4096; +const MAX_ERROR_BYTES = 1024 * 1024; +const MAX_EVENT_BYTES = 1024 * 1024; +const MAX_JSON_RESPONSE_BYTES = 16 * 1024 * 1024; +const CURSOR_PATTERN = /^[A-Za-z0-9._~-]+$/u; +const SELECTED_VERSION_MAX_LENGTH = 16; +const INVALID_RETRY_AFTER = Symbol("invalid Retry-After"); +const HTTP_MONTHS: Readonly> = { + Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, + Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11, +}; +const HTTP_WEEKDAYS: Readonly> = { + Sun: 0, Sunday: 0, Mon: 1, Monday: 1, Tue: 2, Tuesday: 2, + Wed: 3, Wednesday: 3, Thu: 4, Thursday: 4, Fri: 5, Friday: 5, + Sat: 6, Saturday: 6, +}; +const SELECTED_VERSION_PATTERN = /^1\.0$/u; +const ERROR_CODES = { + invalid_request: true, unauthenticated: true, forbidden: true, not_found: true, + idempotency_key_required: true, idempotency_conflict: true, revision_conflict: true, + incompatible_version: true, cursor_expired: true, unavailable: true, indeterminate: true, + invalid_origin: true, https_required: true, +} as const satisfies Record; +const ERROR_FIELDS: Record = { + code: true, message: true, requestId: true, retryable: true, + violations: true, supportedMajors: true, resync: true, +}; +const ERROR_CODES_BY_STATUS: Readonly>>> = { + 400: { invalid_request: true, idempotency_key_required: true, invalid_origin: true, https_required: true }, + 401: { unauthenticated: true }, + 403: { forbidden: true }, + 404: { not_found: true }, + 406: { incompatible_version: true }, + 409: { idempotency_conflict: true, revision_conflict: true }, + 410: { cursor_expired: true }, + 422: { invalid_request: true }, + 503: { unavailable: true, indeterminate: true }, +}; +const WORKSPACE_STATES = { + accepted: true, provisioning: true, ready: true, deleting: true, + deleted: true, failed: true, unavailable: true, indeterminate: true, +} as const satisfies Record; +const SESSION_STATES = { + accepted: true, provisioning: true, ready: true, cancelling: true, + cancelled: true, failed: true, unavailable: true, indeterminate: true, +} as const satisfies Record; +const COMMAND_STATES = { + accepted: true, projected: true, dispatching: true, running: true, succeeded: true, failed: true, + cancelling: true, cancelled: true, rejected: true, unavailable: true, indeterminate: true, +} as const satisfies Record; + +type ApiError = components["schemas"]["ApiError"]; +type Resync = components["schemas"]["Resync"]; +type WatchEvent = components["schemas"]["WatchEvent"]; + +export interface T4ApiClientOptions { + readonly baseUrl: string; + readonly credential: string; + readonly majorVersion: number; + readonly fetch?: typeof globalThis.fetch; +} + +export interface WatchSessionOptions { + readonly cursor?: components["schemas"]["Cursor"]; + readonly maxEvents?: number; + readonly heartbeatSeconds?: number; + readonly signal?: AbortSignal; + readonly maxReconnectAttempts?: number; + readonly retryBackoffMs?: number; +} + +export interface T4ApiClient { + /** + * Low-level generated client. Event-stream calls must pass `parseAs: "stream"`; + * use `watchSession` for bounded, validated event decoding and reconnects. + */ + readonly http: Readonly, "use" | "eject">>; + watchSession(sessionId: string, options?: WatchSessionOptions): AsyncGenerator; +} + +export class T4ApiError extends Error { + readonly code: ApiError["code"]; + readonly status: number; + readonly requestId: string; + readonly retryable: boolean; + readonly violations?: ApiError["violations"]; + readonly supportedMajors?: ApiError["supportedMajors"]; + readonly resync?: Resync; + readonly retryAfterMs?: number; + + constructor(status: number, error: ApiError, options?: ErrorOptions & { readonly retryAfterMs?: number }) { + super(error.message, options); + this.name = "T4ApiError"; + this.code = error.code; + this.status = status; + this.requestId = error.requestId; + this.retryable = error.retryable; + if (error.violations !== undefined) this.violations = error.violations; + if (error.supportedMajors !== undefined) this.supportedMajors = error.supportedMajors; + if (error.resync !== undefined) this.resync = error.resync; + if (options?.retryAfterMs !== undefined) this.retryAfterMs = options.retryAfterMs; + } +} + +function normalizedBaseUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch (error) { + throw new TypeError("T4 API baseUrl must be an absolute HTTPS URL", { cause: error }); + } + if ( + url.protocol !== "https:" || + url.username !== "" || + url.password !== "" || + url.search !== "" || + url.hash !== "" + ) { + throw new TypeError("T4 API baseUrl must be a credential-free HTTPS URL without query or fragment"); + } + url.pathname = url.pathname.replace(/\/+$/u, ""); + return url.toString().replace(/\/$/u, ""); +} + +function requiredCredential(value: string): string { + if ( + value.length === 0 || + value.length > MAX_CREDENTIAL_LENGTH || + !/^[A-Za-z0-9._~+/-]+=*$/u.test(value) + ) { + throw new TypeError("credential must be an opaque bearer token of at most 4096 characters"); + } + return value; +} + +function requiredMajor(value: number): string { + if (!Number.isSafeInteger(value) || value < 1 || value > 9999) { + throw new TypeError("majorVersion must be an integer between 1 and 9999"); + } + return String(value); +} + +function boundedInteger(value: number | undefined, fallback: number, minimum: number, maximum: number, label: string): number { + const selected = value ?? fallback; + if (!Number.isSafeInteger(selected) || selected < minimum || selected > maximum) { + throw new RangeError(`${label} must be an integer between ${minimum} and ${maximum}`); + } + return selected; +} + +function requiredSessionId(value: string): string { + if (value.length < 1 || value.length > 128 || !/^[A-Za-z0-9][A-Za-z0-9._~-]*$/u.test(value)) { + throw new TypeError("sessionId is invalid"); + } + return value; +} + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function validViolation(value: unknown): boolean { + const violation = record(value); + return violation !== undefined && + Object.keys(violation).every((key) => key === "field" || key === "rule" || key === "message") && + typeof violation.field === "string" && violation.field !== "" && hasAtMostCodePoints(violation.field, 256) && + typeof violation.rule === "string" && hasAtMostCodePoints(violation.rule, 64) && /^[A-Za-z][A-Za-z0-9._-]{0,63}$/u.test(violation.rule) && + typeof violation.message === "string" && violation.message !== "" && hasAtMostCodePoints(violation.message, 512); +} + +function validResync(value: unknown, expectedSessionId?: string): value is Resync { + const resync = record(value); + if (resync === undefined || Object.keys(resync).some((key) => key !== "snapshotUrl" && key !== "cursor") || typeof resync.snapshotUrl !== "string") return false; + const snapshot = /^v1\/sessions\/([^/]+)\/snapshot$/u.exec(resync.snapshotUrl); + return hasAtMostCodePoints(resync.snapshotUrl, 149) && snapshot !== null && validResourceId(snapshot[1]) && + (expectedSessionId === undefined || snapshot[1] === expectedSessionId) && + typeof resync.cursor === "string" && hasAtMostCodePoints(resync.cursor, 512) && CURSOR_PATTERN.test(resync.cursor); +} + +function apiError(value: unknown, status: number, expectedSessionId?: string): ApiError | undefined { + const envelope = record(value); + const error = record(envelope?.error); + if ( + envelope === undefined || Object.keys(envelope).some((key) => key !== "error") || + error === undefined || Object.keys(error).some((key) => ERROR_FIELDS[key] !== true) || + typeof error.code !== "string" || ERROR_CODES[error.code as ApiError["code"]] !== true || + ERROR_CODES_BY_STATUS[status]?.[error.code] !== true || + typeof error.message !== "string" || error.message === "" || !hasAtMostCodePoints(error.message, 1024) || + typeof error.requestId !== "string" || error.requestId === "" || !hasAtMostCodePoints(error.requestId, 128) || + typeof error.retryable !== "boolean" || + (error.violations !== undefined && (!Array.isArray(error.violations) || error.violations.length > 64 || !error.violations.every(validViolation))) || + (error.supportedMajors !== undefined && (!Array.isArray(error.supportedMajors) || error.supportedMajors.length > 8 || !hasUniqueItems(error.supportedMajors) || !error.supportedMajors.every((major) => Number.isSafeInteger(major) && Number(major) >= 1))) || + (error.resync !== undefined && !validResync(error.resync)) || + (status === 406 && (error.code !== "incompatible_version" || !Array.isArray(error.supportedMajors) || error.supportedMajors.length < 1)) || + (status === 410 && (error.code !== "cursor_expired" || !validResync(error.resync, expectedSessionId))) || + (status === 422 && (error.code !== "invalid_request" || !Array.isArray(error.violations) || error.violations.length < 1)) + ) return undefined; + return error as ApiError; +} + +async function boundedResponseText(response: Response, maximumBytes = MAX_ERROR_BYTES): Promise { + if (response.body === null) return ""; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + length += chunk.value.byteLength; + if (length > maximumBytes) { + void reader.cancel().catch(() => {}); + return undefined; + } + chunks.push(chunk.value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { return undefined; } +} + +function matchingHttpDate( + timestamp: number, + parts: RegExpExecArray, + indexes: { readonly weekday: number; readonly day: number; readonly alternateDay?: number; readonly month: number; readonly year: number; readonly hour: number; readonly minute: number; readonly second: number }, + expectedYear = Number(parts[indexes.year]), +): boolean { + const date = new Date(timestamp); + return date.getUTCDay() === HTTP_WEEKDAYS[parts[indexes.weekday]!] && + date.getUTCDate() === Number(parts[indexes.day] ?? parts[indexes.alternateDay ?? indexes.day]) && + date.getUTCMonth() === HTTP_MONTHS[parts[indexes.month]!] && + date.getUTCFullYear() === expectedYear && + date.getUTCHours() === Number(parts[indexes.hour]) && + date.getUTCMinutes() === Number(parts[indexes.minute]) && + date.getUTCSeconds() === Number(parts[indexes.second]); +} + +function httpDateTimestamp(value: string): number | undefined { + const imf = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat), ([0-9]{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([0-9]{4}) ([0-9]{2}):([0-9]{2}):([0-9]{2}) GMT$/u.exec(value); + if (imf !== null) { + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) && matchingHttpDate(timestamp, imf, { weekday: 1, day: 2, month: 3, year: 4, hour: 5, minute: 6, second: 7 }) ? timestamp : undefined; + } + const rfc850 = /^(Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday), ([0-9]{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-([0-9]{2}) ([0-9]{2}):([0-9]{2}):([0-9]{2}) GMT$/u.exec(value); + if (rfc850 !== null) { + const now = new Date(Date.now()); + let year = Math.floor(now.getUTCFullYear() / 100) * 100 + Number(rfc850[4]); + const date = new Date(0); + date.setUTCFullYear(year, HTTP_MONTHS[rfc850[3]!]!, Number(rfc850[2])); + date.setUTCHours(Number(rfc850[5]), Number(rfc850[6]), Number(rfc850[7]), 0); + const fiftyYearsFromNow = new Date(now.getTime()); + fiftyYearsFromNow.setUTCFullYear(now.getUTCFullYear() + 50); + if (date.getTime() > fiftyYearsFromNow.getTime()) { + year -= 100; + date.setUTCFullYear(year); + } + const timestamp = date.getTime(); + return matchingHttpDate(timestamp, rfc850, { weekday: 1, day: 2, month: 3, year: 4, hour: 5, minute: 6, second: 7 }, year) ? timestamp : undefined; + } + const asctime = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (?:([0-9]{2})| ([0-9])) ([0-9]{2}):([0-9]{2}):([0-9]{2}) ([0-9]{4})$/u.exec(value); + if (asctime === null) return undefined; + const date = new Date(0); + date.setUTCFullYear(Number(asctime[8]), HTTP_MONTHS[asctime[2]!]!, Number(asctime[3] ?? asctime[4])); + date.setUTCHours(Number(asctime[5]), Number(asctime[6]), Number(asctime[7]), 0); + const timestamp = date.getTime(); + return Number.isFinite(timestamp) && matchingHttpDate(timestamp, asctime, { weekday: 1, day: 3, alternateDay: 4, month: 2, year: 8, hour: 5, minute: 6, second: 7 }) ? timestamp : undefined; +} + +function retryAfterMilliseconds(response: Response): number | undefined | typeof INVALID_RETRY_AFTER { + const header = response.headers.get("Retry-After"); + if (header === null) return undefined; + const value = header.trim(); + if (value === "" || !hasAtMostCodePoints(value, 128)) return INVALID_RETRY_AFTER; + if (/^[0-9]+$/u.test(value)) return Math.min(30_000, Number(value) * 1000); + const timestamp = httpDateTimestamp(value); + if (timestamp === undefined) return INVALID_RETRY_AFTER; + return Math.min(30_000, Math.max(0, timestamp - Date.now())); +} + + +function protocolError(status: number, message: string): T4ApiError { + return new T4ApiError(status === 503 ? 502 : status, { + code: "indeterminate", + message, + requestId: "unavailable", + retryable: false, + }); +} + +async function parsedError(response: Response, expectedSessionId?: string): Promise { + if (response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") { + void response.body?.cancel().catch(() => {}); + return undefined; + } + const text = await boundedResponseText(response); + if (text === undefined) return undefined; + try { + const decoded = apiError(JSON.parse(text), response.status, expectedSessionId); + if (decoded === undefined) return undefined; + const retryAfterMs = retryAfterMilliseconds(response); + if (retryAfterMs === INVALID_RETRY_AFTER) return undefined; + return retryAfterMs === undefined + ? new T4ApiError(response.status, decoded) + : new T4ApiError(response.status, decoded, { retryAfterMs }); + } catch { + return undefined; + } +} + + +function hasOnlyKeys(value: Record, keys: Readonly>): boolean { + return Object.keys(value).every((key) => keys[key] === true); +} + +function hasUniqueItems(value: readonly unknown[]): boolean { + for (let index = 0; index < value.length; index += 1) { + for (let prior = 0; prior < index; prior += 1) { + if (value[index] === value[prior]) return false; + } + } + return true; +} + +function validResourceId(value: unknown): value is string { + return typeof value === "string" && value !== "" && hasAtMostCodePoints(value, 128) && /^[A-Za-z0-9][A-Za-z0-9._~-]*$/u.test(value); +} + +function hasAtMostCodePoints(value: string, maximum: number): boolean { + let count = 0; + for (const _codePoint of value) { + count += 1; + if (count > maximum) return false; + } + return true; +} + +function validCursor(value: unknown): value is string { + return typeof value === "string" && value !== "" && hasAtMostCodePoints(value, 512) && CURSOR_PATTERN.test(value); +} + +function validLabels(value: unknown): boolean { + if (value === undefined) return true; + const labels = record(value); + return labels !== undefined && Object.keys(labels).length <= 32 && Object.entries(labels).every(([key, item]) => + /^[a-z][a-z0-9.-]{0,62}$/u.test(key) && typeof item === "string" && hasAtMostCodePoints(item, 128)); +} + +function validWorkspace(value: unknown): boolean { + const item = record(value); + return item !== undefined && hasOnlyKeys(item, { id: true, name: true, state: true, revision: true, labels: true }) && + validResourceId(item.id) && typeof item.name === "string" && item.name !== "" && hasAtMostCodePoints(item.name, 128) && + typeof item.state === "string" && WORKSPACE_STATES[item.state as components["schemas"]["WorkspaceState"]] === true && + Number.isSafeInteger(item.revision) && Number(item.revision) >= 1 && validLabels(item.labels); +} + +function validSession(value: unknown): boolean { + const item = record(value); + return item !== undefined && hasOnlyKeys(item, { id: true, workspaceId: true, title: true, state: true, revision: true, labels: true }) && + validResourceId(item.id) && validResourceId(item.workspaceId) && typeof item.title === "string" && item.title !== "" && hasAtMostCodePoints(item.title, 128) && + typeof item.state === "string" && SESSION_STATES[item.state as components["schemas"]["SessionState"]] === true && + Number.isSafeInteger(item.revision) && Number(item.revision) >= 1 && validLabels(item.labels); +} + +function validCommandResult(value: unknown): boolean { + const item = record(value); + return item !== undefined && hasOnlyKeys(item, { commandId: true, state: true }) && validResourceId(item.commandId) && + typeof item.state === "string" && COMMAND_STATES[item.state as components["schemas"]["CommandState"]] === true; +} + +function validCapabilityDeprecation(value: unknown): boolean { + if (value === undefined) return true; + const deprecation = record(value); + return deprecation !== undefined && hasOnlyKeys(deprecation, { message: true, sinceVersion: true, sunsetAt: true, replacement: true }) && + typeof deprecation.message === "string" && deprecation.message !== "" && hasAtMostCodePoints(deprecation.message, 1024) && + (deprecation.sinceVersion === undefined || (typeof deprecation.sinceVersion === "string" && deprecation.sinceVersion !== "" && hasAtMostCodePoints(deprecation.sinceVersion, 128))) && + (deprecation.sunsetAt === undefined || (typeof deprecation.sunsetAt === "string" && validRfc3339DateTime(deprecation.sunsetAt))) && + (deprecation.replacement === undefined || (typeof deprecation.replacement === "string" && /^[a-z][a-z0-9.-]{0,127}$/u.test(deprecation.replacement))); +} + +function validCapabilityStatus(value: unknown): boolean { + const status = record(value); + return status !== undefined && hasOnlyKeys(status, { supported: true, enabled: true, authorized: true, available: true, deprecation: true }) && + typeof status.supported === "boolean" && typeof status.enabled === "boolean" && + typeof status.authorized === "boolean" && typeof status.available === "boolean" && validCapabilityDeprecation(status.deprecation); +} + +function validDiscovery(value: unknown): boolean { + const discovery = record(value); + const serverBuild = record(discovery?.serverBuild); + const capabilities = record(discovery?.capabilities); + const limits = record(discovery?.limits); + return discovery !== undefined && hasOnlyKeys(discovery, { apiVersion: true, serverBuild: true, supportedMajors: true, capabilities: true, limits: true }) && + typeof discovery.apiVersion === "string" && /^1\.[0-9]+$/u.test(discovery.apiVersion) && + serverBuild !== undefined && hasOnlyKeys(serverBuild, { version: true, revision: true }) && + typeof serverBuild.version === "string" && serverBuild.version !== "" && hasAtMostCodePoints(serverBuild.version, 128) && + typeof serverBuild.revision === "string" && serverBuild.revision !== "" && hasAtMostCodePoints(serverBuild.revision, 128) && + Array.isArray(discovery.supportedMajors) && discovery.supportedMajors.length >= 1 && discovery.supportedMajors.length <= 8 && hasUniqueItems(discovery.supportedMajors) && discovery.supportedMajors.every((major) => Number.isSafeInteger(major) && Number(major) >= 1) && + capabilities !== undefined && Object.keys(capabilities).length <= 128 && Object.entries(capabilities).every(([id, status]) => /^[a-z][a-z0-9.-]{0,127}$/u.test(id) && validCapabilityStatus(status)) && + limits !== undefined && hasOnlyKeys(limits, { pageSizeDefault: true, pageSizeMax: true, commandBytesMax: true, commandRequestBytesMax: true, commandMetadataValueBytesMax: true, watchEventsDefault: true, watchEventsMax: true, heartbeatSeconds: true }) && + [limits.pageSizeDefault, limits.pageSizeMax, limits.commandBytesMax, limits.commandRequestBytesMax, limits.commandMetadataValueBytesMax, limits.watchEventsDefault, limits.watchEventsMax, limits.heartbeatSeconds].every((limit) => Number.isSafeInteger(limit) && Number(limit) >= 1) && + Number(limits.pageSizeDefault) <= Number(limits.pageSizeMax) && Number(limits.watchEventsDefault) <= Number(limits.watchEventsMax) && Number(limits.commandBytesMax) <= Number(limits.commandRequestBytesMax) && Number(limits.commandMetadataValueBytesMax) <= Number(limits.commandRequestBytesMax) && + Number(limits.pageSizeMax) <= 100 && Number(limits.commandBytesMax) <= 262144 && Number(limits.commandRequestBytesMax) <= 1048576 && Number(limits.commandMetadataValueBytesMax) <= 262144 && Number(limits.watchEventsMax) <= 1000 && Number(limits.heartbeatSeconds) >= 5 && Number(limits.heartbeatSeconds) <= 60; +} + +function validSnapshot(value: unknown): boolean { + const snapshot = record(value); + return snapshot !== undefined && hasOnlyKeys(snapshot, { sessionId: true, cursor: true, state: true, entries: true }) && + validResourceId(snapshot.sessionId) && validCursor(snapshot.cursor) && + typeof snapshot.state === "string" && SESSION_STATES[snapshot.state as components["schemas"]["SessionState"]] === true && + Array.isArray(snapshot.entries) && snapshot.entries.length <= 1000 && snapshot.entries.every((value) => { + const entry = record(value); + return entry !== undefined && hasOnlyKeys(entry, { sequence: true, kind: true, text: true }) && + Number.isSafeInteger(entry.sequence) && Number(entry.sequence) >= 0 && + (entry.kind === "input" || entry.kind === "output" || entry.kind === "status") && + typeof entry.text === "string" && hasAtMostCodePoints(entry.text, 1_048_576); + }); +} + +type SuccessValidator = ((value: unknown) => boolean) | "empty" | "event-stream"; + +interface ResponseContract { + readonly success: Readonly>; + readonly errors: readonly number[]; + readonly replaySuccess?: readonly number[]; +} + +const DISCOVERY_RESPONSE: ResponseContract = { success: { 200: validDiscovery }, errors: [401, 403, 406, 503] }; +const WORKSPACE_LIST_RESPONSE: ResponseContract = { success: { 200: validWorkspacePage }, errors: [400, 401, 403, 406, 422, 503] }; +const WORKSPACE_CREATE_RESPONSE: ResponseContract = { success: { 200: validWorkspace, 202: validWorkspace }, errors: [400, 401, 403, 406, 409, 422, 503], replaySuccess: [200, 202] }; +const WORKSPACE_GET_RESPONSE: ResponseContract = { success: { 200: validWorkspace }, errors: [401, 403, 404, 406, 503] }; +const WORKSPACE_MUTATE_RESPONSE: ResponseContract = { success: { 200: validWorkspace }, errors: [400, 401, 403, 404, 406, 409, 422, 503], replaySuccess: [200] }; +const DELETE_RESPONSE: ResponseContract = { success: { 204: "empty" }, errors: [400, 401, 403, 404, 406, 409, 503], replaySuccess: [204] }; +const SESSION_LIST_RESPONSE: ResponseContract = { success: { 200: validSessionPage }, errors: [401, 403, 404, 406, 422, 503] }; +const SESSION_CREATE_RESPONSE: ResponseContract = { success: { 200: validSession, 202: validSession }, errors: [400, 401, 403, 404, 406, 409, 422, 503], replaySuccess: [200, 202] }; +const SESSION_GET_RESPONSE: ResponseContract = { success: { 200: validSession }, errors: [401, 403, 404, 406, 503] }; +const SESSION_MUTATE_RESPONSE: ResponseContract = { success: { 200: validSession }, errors: [400, 401, 403, 404, 406, 409, 422, 503], replaySuccess: [200] }; +const SESSION_CANCEL_RESPONSE: ResponseContract = { success: { 200: validSession, 202: validSession }, errors: [400, 401, 403, 404, 406, 409, 503], replaySuccess: [200, 202] }; +const COMMAND_RESPONSE: ResponseContract = { success: { 200: validCommandResult, 202: validCommandResult }, errors: [400, 401, 403, 404, 406, 409, 422, 503], replaySuccess: [200, 202] }; +const SNAPSHOT_RESPONSE: ResponseContract = { success: { 200: validSnapshot }, errors: [401, 403, 404, 406, 503] }; +const EVENTS_RESPONSE: ResponseContract = { success: { 200: "event-stream" }, errors: [400, 401, 403, 404, 406, 410, 422, 503] }; + +function validWorkspacePage(value: unknown): boolean { + const page = record(value); + return page !== undefined && hasOnlyKeys(page, { items: true, nextCursor: true }) && + Array.isArray(page.items) && page.items.length <= 100 && page.items.every(validWorkspace) && + (page.nextCursor === undefined || validCursor(page.nextCursor)); +} + +function validSessionPage(value: unknown): boolean { + const page = record(value); + return page !== undefined && hasOnlyKeys(page, { items: true, nextCursor: true }) && + Array.isArray(page.items) && page.items.length <= 100 && page.items.every(validSession) && + (page.nextCursor === undefined || validCursor(page.nextCursor)); +} + +function relativeApiPath(request: Request, baseUrl: string): string | undefined { + const requestUrl = new URL(request.url); + const base = new URL(baseUrl); + if (requestUrl.origin !== base.origin) return undefined; + const prefix = base.pathname === "/" ? "" : base.pathname; + if (prefix === "") return requestUrl.pathname; + if (!requestUrl.pathname.startsWith(`${prefix}/`)) return undefined; + return requestUrl.pathname.slice(prefix.length); +} + +function responseContract(method: string, path: string): ResponseContract | undefined { + if (path === "/v1" && method === "GET") return DISCOVERY_RESPONSE; + if (path === "/v1/workspaces") { + if (method === "GET") return WORKSPACE_LIST_RESPONSE; + if (method === "POST") return WORKSPACE_CREATE_RESPONSE; + return undefined; + } + if (/^\/v1\/workspaces\/[A-Za-z0-9._~-]+$/u.test(path)) { + if (method === "GET") return WORKSPACE_GET_RESPONSE; + if (method === "PATCH") return WORKSPACE_MUTATE_RESPONSE; + if (method === "DELETE") return DELETE_RESPONSE; + return undefined; + } + if (/^\/v1\/workspaces\/[A-Za-z0-9._~-]+\/sessions$/u.test(path)) { + if (method === "GET") return SESSION_LIST_RESPONSE; + if (method === "POST") return SESSION_CREATE_RESPONSE; + return undefined; + } + if (/^\/v1\/sessions\/[A-Za-z0-9._~-]+$/u.test(path)) { + if (method === "GET") return SESSION_GET_RESPONSE; + if (method === "PATCH") return SESSION_MUTATE_RESPONSE; + if (method === "DELETE") return DELETE_RESPONSE; + return undefined; + } + if (/^\/v1\/sessions\/[A-Za-z0-9._~-]+\/cancel$/u.test(path) && method === "POST") return SESSION_CANCEL_RESPONSE; + if (/^\/v1\/sessions\/[A-Za-z0-9._~-]+\/commands$/u.test(path) && method === "POST") return COMMAND_RESPONSE; + if (/^\/v1\/sessions\/[A-Za-z0-9._~-]+\/snapshot$/u.test(path) && method === "GET") return SNAPSHOT_RESPONSE; + if (/^\/v1\/sessions\/[A-Za-z0-9._~-]+\/events$/u.test(path) && method === "GET") return EVENTS_RESPONSE; + return undefined; +} + +function validPathBoundResponse(method: string, path: string, value: unknown): boolean { + const workspace = /^\/v1\/workspaces\/([^/]+)$/u.exec(path); + if (workspace !== null && (method === "GET" || method === "PATCH")) { + return record(value)?.id === workspace[1]; + } + const sessions = /^\/v1\/workspaces\/([^/]+)\/sessions$/u.exec(path); + if (sessions !== null) { + if (method === "POST") return record(value)?.workspaceId === sessions[1]; + if (method === "GET") { + const items = record(value)?.items; + return Array.isArray(items) && items.every((item) => record(item)?.workspaceId === sessions[1]); + } + } + const session = /^\/v1\/sessions\/([^/]+)$/u.exec(path); + if (session !== null && (method === "GET" || method === "PATCH")) { + return record(value)?.id === session[1]; + } + const cancellation = /^\/v1\/sessions\/([^/]+)\/cancel$/u.exec(path); + if (cancellation !== null && method === "POST") return record(value)?.id === cancellation[1]; + const snapshot = /^\/v1\/sessions\/([^/]+)\/snapshot$/u.exec(path); + if (snapshot !== null && method === "GET") return record(value)?.sessionId === snapshot[1]; + return true; +} + +function validSelectedVersion(response: Response, expectedMajor?: string): boolean { + const selected = response.headers.get("T4-API-Version"); + return selected !== null && selected.length <= SELECTED_VERSION_MAX_LENGTH && SELECTED_VERSION_PATTERN.test(selected) && + (expectedMajor === undefined || selected.startsWith(`${expectedMajor}.`)); +} + +function validBearerChallenge(response: Response): boolean { + return response.headers.get("WWW-Authenticate") === 'Bearer realm="t4"'; +} + +function validReplayHeader(response: Response): boolean { + const replayed = response.headers.get("Idempotency-Replayed"); + return replayed === "true" || replayed === "false"; +} + + +async function validateResponse(request: Request, response: Response, baseUrl: string): Promise { + const path = relativeApiPath(request, baseUrl); + const contract = path === undefined ? undefined : responseContract(request.method, path); + if (contract === undefined) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned a response for an undeclared route"); + } + if (!response.ok) { + if (!contract.errors.includes(response.status)) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned an undeclared error status"); + } + if ((response.status === 401 && (response.headers.has("T4-API-Version") || !validBearerChallenge(response))) || + (response.status !== 401 && !validSelectedVersion(response))) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned an invalid selected-version header"); + } + const watchedSession = path === undefined ? undefined : /^\/v1\/sessions\/([^/]+)\/events$/u.exec(path)?.[1]; + if (await parsedError(response.clone(), watchedSession) === undefined) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned an invalid or oversized error envelope"); + } + return; + } + const validator = contract.success[response.status]; + if (validator === undefined) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned an undeclared success status"); + } + if (!validSelectedVersion(response, request.headers.get("T4-API-Version") ?? "")) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned a missing or invalid selected version"); + } + if (contract.replaySuccess?.includes(response.status) === true && !validReplayHeader(response)) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned a missing or invalid replay header"); + } + if (contract.replaySuccess?.includes(response.status) === true && !validCursor(response.headers.get("T4-Event-Cursor"))) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned a missing or invalid durable event cursor"); + } + if (validator === "empty") { + if (response.body !== null || response.headers.has("content-type")) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned content for a bodyless response"); + } + return; + } + if (validator === "event-stream") { + if (response.headers.get("Cache-Control") !== "no-store") { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API watch did not return Cache-Control: no-store"); + } + if (response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "text/event-stream") { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned an undeclared success media type"); + } + return; + } + if (response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned an undeclared success media type"); + } + const text = await boundedResponseText(response.clone(), MAX_JSON_RESPONSE_BYTES); + let value: unknown; + try { value = text === undefined ? undefined : JSON.parse(text); } catch { value = undefined; } + if (text === undefined || !validator(value) || !validPathBoundResponse(request.method, path!, value)) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned an invalid or oversized JSON response"); + } +} + +function isKnownUtcLeapSecondDate(year: number, month: number, day: number): boolean { + // Update this IERS insertion-date table when a new UTC leap second is announced. + switch (year * 10_000 + month * 100 + day) { + case 19720630: case 19721231: case 19731231: case 19741231: case 19751231: + case 19761231: case 19771231: case 19781231: case 19791231: case 19810630: + case 19820630: case 19830630: case 19850630: case 19871231: case 19891231: + case 19901231: case 19920630: case 19930630: case 19940630: case 19951231: + case 19970630: case 19981231: case 20051231: case 20081231: case 20120630: + case 20150630: case 20161231: + return true; + default: + return false; + } +} + +function validRfc3339DateTime(value: string): boolean { + if (!hasAtMostCodePoints(value, 64)) return false; + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2}))$/iu.exec(value); + if (match === null) return false; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 60) return false; + let maximumDay = 31; + if (month === 2) maximumDay = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28; + else if (month === 4 || month === 6 || month === 9 || month === 11) maximumDay = 30; + if (day < 1 || day > maximumDay) return false; + let offsetMinutes = 0; + if (match[7] !== undefined) { + const offsetHour = Number(match[8]); + const offsetMinute = Number(match[9]); + if (offsetHour > 23 || offsetMinute > 59) return false; + offsetMinutes = (match[7] === "+" ? 1 : -1) * (offsetHour * 60 + offsetMinute); + } + if (second < 60) return true; + const utc = new Date(0); + utc.setUTCFullYear(year, month - 1, day); + utc.setUTCHours(hour, minute - offsetMinutes, 0, 0); + return utc.getUTCHours() === 23 && utc.getUTCMinutes() === 59 && + isKnownUtcLeapSecondDate(utc.getUTCFullYear(), utc.getUTCMonth() + 1, utc.getUTCDate()); +} + +function watchEvent(value: unknown, eventId: string | undefined): WatchEvent { + const event = record(value); + if ( + event === undefined || + typeof event.type !== "string" || + typeof event.cursor !== "string" || + event.cursor === "" || + !hasAtMostCodePoints(event.cursor, 512) || + !CURSOR_PATTERN.test(event.cursor) || + (eventId !== undefined && eventId !== event.cursor) + ) throw protocolError(502, "T4 API returned an invalid watch event"); + if ( + event.type === "heartbeat" && + hasOnlyKeys(event, { type: true, cursor: true, observedAt: true }) && + typeof event.observedAt === "string" && validRfc3339DateTime(event.observedAt) + ) return event as WatchEvent; + if ( + event.type === "session" && + hasOnlyKeys(event, { type: true, cursor: true, state: true, revision: true }) && + typeof event.state === "string" && + SESSION_STATES[event.state as components["schemas"]["SessionState"]] === true && + Number.isSafeInteger(event.revision) && Number(event.revision) >= 1 + ) return event as WatchEvent; + if ( + event.type === "command" && + hasOnlyKeys(event, { type: true, cursor: true, commandId: true, state: true }) && + typeof event.commandId === "string" && + event.commandId !== "" && hasAtMostCodePoints(event.commandId, 128) && + /^[A-Za-z0-9][A-Za-z0-9._~-]*$/u.test(event.commandId) && + typeof event.state === "string" && + COMMAND_STATES[event.state as components["schemas"]["CommandState"]] === true + ) return event as WatchEvent; + throw protocolError(502, "T4 API returned an invalid watch event"); +} + +function decodeSseFrame(frame: string): WatchEvent | undefined { + let eventId: string | undefined; + const data: string[] = []; + for (const rawLine of frame.split(/\r\n|\r|\n/u)) { + const line = rawLine; + if (line === "" || line.startsWith(":")) continue; + const separator = line.indexOf(":"); + const field = separator < 0 ? line : line.slice(0, separator); + const rawValue = separator < 0 ? "" : line.slice(separator + 1); + const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue; + if (field === "id") eventId = value; + else if (field === "data") data.push(value); + } + if (data.length === 0) return undefined; + try { + return watchEvent(JSON.parse(data.join("\n")), eventId); + } catch (error) { + if (error instanceof T4ApiError) throw error; + throw protocolError(502, "T4 API returned malformed SSE data"); + } +} + +class SseFrameParser { + readonly #buffer = new Uint8Array(MAX_EVENT_BYTES + 4); + #length = 0; + #lineStart = 0; + #pendingCarriageReturn = -1; + + push(chunk: Uint8Array): Uint8Array[] { + const frames: Uint8Array[] = []; + for (const byte of chunk) this.#pushByte(byte, frames); + return frames; + } + + finish(): Uint8Array[] { + const frames: Uint8Array[] = []; + if (this.#pendingCarriageReturn >= 0) { + this.#finishLine(this.#pendingCarriageReturn, frames); + this.#pendingCarriageReturn = -1; + } + if (this.#length > MAX_EVENT_BYTES) this.#oversized(); + this.#reset(); + return frames; + } + + #pushByte(byte: number, frames: Uint8Array[]): void { + if (this.#pendingCarriageReturn >= 0) { + if (byte === 10) { + this.#append(byte); + this.#finishLine(this.#pendingCarriageReturn, frames); + this.#pendingCarriageReturn = -1; + return; + } + this.#finishLine(this.#pendingCarriageReturn, frames); + this.#pendingCarriageReturn = -1; + } + this.#append(byte); + if (byte === 13) this.#pendingCarriageReturn = this.#length - 1; + else if (byte === 10) this.#finishLine(this.#length - 1, frames); + } + + #append(byte: number): void { + if (this.#length >= this.#buffer.byteLength) this.#oversized(); + this.#buffer[this.#length++] = byte; + } + + #finishLine(newlineStart: number, frames: Uint8Array[]): void { + if (newlineStart === this.#lineStart) { + if (this.#lineStart > MAX_EVENT_BYTES) this.#oversized(); + frames.push(this.#buffer.slice(0, this.#lineStart)); + this.#reset(); + return; + } + this.#lineStart = this.#length; + if (this.#lineStart > MAX_EVENT_BYTES) this.#oversized(); + } + + #reset(): void { + this.#length = 0; + this.#lineStart = 0; + } + + #oversized(): never { + throw protocolError(502, "T4 API watch event exceeds the client bound"); + } +} + +function decodedFrames(parser: SseFrameParser, chunk: Uint8Array | undefined): WatchEvent[] { + const frames = chunk === undefined ? parser.finish() : parser.push(chunk); + return frames.flatMap((bytes) => { + let text: string; + try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { + throw protocolError(502, "T4 API returned malformed SSE data"); + } + const event = decodeSseFrame(text); + return event === undefined ? [] : [event]; + }); +} + +async function retryDelay(milliseconds: number, signal: AbortSignal): Promise { + if (signal.aborted) return false; + if (milliseconds === 0) return true; + return await new Promise((resolve) => { + const timeout = setTimeout(() => { + signal.removeEventListener("abort", abort); + resolve(true); + }, milliseconds); + const abort = (): void => { + clearTimeout(timeout); + resolve(false); + }; + signal.addEventListener("abort", abort, { once: true }); + }); +} + +async function readBeforeDeadline( + reader: ReadableStreamDefaultReader, + deadline: number, + signal: AbortSignal, +): Promise> { + const remaining = Math.max(0, deadline - Date.now()); + return await new Promise((resolve, reject) => { + let settled = false; + const finish = (callback: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + signal.removeEventListener("abort", aborted); + callback(); + }; + const aborted = (): void => finish(() => reject(signal.reason)); + const timeout = setTimeout( + () => finish(() => reject(new TypeError("T4 API watch exceeded its heartbeat inactivity deadline"))), + remaining, + ); + signal.addEventListener("abort", aborted, { once: true }); + reader.read().then( + (result) => finish(() => resolve(result)), + (error: unknown) => finish(() => reject(error)), + ); + }); +} + +async function* watch( + baseUrl: string, + credential: string, + majorVersion: string, + fetchImpl: typeof globalThis.fetch, + sessionIdValue: string, + options: WatchSessionOptions, +): AsyncGenerator { + const sessionId = requiredSessionId(sessionIdValue); + const maxEvents = boundedInteger(options.maxEvents, 1, 1, 1000, "maxEvents"); + const heartbeatSeconds = boundedInteger(options.heartbeatSeconds, 15, 5, 60, "heartbeatSeconds"); + const maxReconnectAttempts = boundedInteger(options.maxReconnectAttempts, 3, 0, 10, "maxReconnectAttempts"); + const retryBackoffMs = boundedInteger(options.retryBackoffMs, 250, 0, 30_000, "retryBackoffMs"); + if (options.cursor !== undefined && (options.cursor.length < 1 || options.cursor.length > 512 || !CURSOR_PATTERN.test(options.cursor))) { + throw new TypeError("cursor must be a header-safe token containing between 1 and 512 characters"); + } + const controller = new AbortController(); + const abort = (): void => controller.abort(options.signal?.reason); + if (options.signal?.aborted === true) abort(); + else options.signal?.addEventListener("abort", abort, { once: true }); + let delivered = 0; + let reconnectAttempts = 0; + let cursor = options.cursor; + const deliveredCursors = new Set(); + if (cursor !== undefined) deliveredCursors.add(cursor); + try { + while (delivered < maxEvents && !controller.signal.aborted) { + const deliveredAtAttemptStart = delivered; + let reader: ReadableStreamDefaultReader | undefined; + let transientFailure: unknown; + let retryAfterMs = 0; + try { + const url = new URL(`${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/events`); + url.searchParams.set("maxEvents", String(maxEvents - delivered)); + url.searchParams.set("heartbeatSeconds", String(heartbeatSeconds)); + if (cursor !== undefined) url.searchParams.set("cursor", cursor); + const headers = new Headers({ + Accept: "text/event-stream", + Authorization: `Bearer ${credential}`, + "Cache-Control": "no-store", + "T4-API-Version": majorVersion, + }); + if (cursor !== undefined) headers.set("Last-Event-ID", cursor); + const response = await fetchImpl(url, { method: "GET", headers, signal: controller.signal }); + if (!response.ok) { + if (!EVENTS_RESPONSE.errors.includes(response.status)) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned an undeclared watch error status"); + } + if ((response.status === 401 && (response.headers.has("T4-API-Version") || !validBearerChallenge(response))) || + (response.status !== 401 && !validSelectedVersion(response))) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned an invalid selected-version header"); + } + const error = await parsedError(response, sessionId); + throw error ?? protocolError(502, "T4 API returned an invalid or oversized error envelope"); + } + if (response.status !== 200) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned an undeclared watch success status"); + } + if (!validSelectedVersion(response, majorVersion)) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned a missing or invalid selected version"); + } + if (response.headers.get("Cache-Control") !== "no-store") { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API watch did not return Cache-Control: no-store"); + } + if (response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "text/event-stream") { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API watch did not return text/event-stream"); + } + if (response.body === null) throw protocolError(502, "T4 API watch response body is unavailable"); + reader = response.body.getReader(); + const parser = new SseFrameParser(); + let inactivityDeadline = Date.now() + heartbeatSeconds * 2_000; + while (delivered < maxEvents && !controller.signal.aborted) { + const chunk = await readBeforeDeadline(reader, inactivityDeadline, controller.signal); + const events = chunk.done ? decodedFrames(parser, undefined) : decodedFrames(parser, chunk.value); + if (events.length > 0) inactivityDeadline = Date.now() + heartbeatSeconds * 2_000; + for (const event of events) { + if (event.type === "heartbeat") { + if (cursor === undefined) { + cursor = event.cursor; + deliveredCursors.add(event.cursor); + } else if (event.cursor !== cursor) { + throw protocolError(502, "T4 API watch heartbeat advanced the durable event cursor"); + } + } else { + if (deliveredCursors.has(event.cursor)) throw protocolError(502, "T4 API watch repeated a durable event cursor"); + deliveredCursors.add(event.cursor); + cursor = event.cursor; + } + delivered += 1; + reconnectAttempts = 0; + yield event; + if (delivered >= maxEvents || controller.signal.aborted) break; + } + if (chunk.done) break; + } + if (delivered >= maxEvents || controller.signal.aborted) return; + transientFailure = new TypeError("T4 API watch ended before the requested event bound"); + } catch (error) { + if (controller.signal.aborted) return; + if (error instanceof T4ApiError) { + const reconnectable = error.retryable && error.status === 503 && (error.code === "unavailable" || error.code === "indeterminate"); + if (!reconnectable) throw error; + retryAfterMs = error.retryAfterMs ?? 0; + } + transientFailure = error; + } finally { + if (reader !== undefined) { + try { await reader.cancel(); } catch { /* cancellation is best effort */ } + reader.releaseLock(); + } + } + const madeProgress = delivered > deliveredAtAttemptStart; + if (!madeProgress && reconnectAttempts >= maxReconnectAttempts) { + throw new T4ApiError(502, { code: "indeterminate", message: "T4 API watch reconnect attempts exhausted", requestId: "unavailable", retryable: true }, { cause: transientFailure }); + } + const delay = Math.min(30_000, Math.max(retryAfterMs, retryBackoffMs * (2 ** reconnectAttempts))); + if (!madeProgress) reconnectAttempts += 1; + if (!await retryDelay(delay, controller.signal)) return; + } + } finally { + options.signal?.removeEventListener("abort", abort); + controller.abort(); + } +} + +export function createT4ApiClient(options: T4ApiClientOptions): T4ApiClient { + const baseUrl = normalizedBaseUrl(options.baseUrl); + const credential = requiredCredential(options.credential); + const majorVersion = requiredMajor(options.majorVersion); + const fetchImpl = options.fetch ?? globalThis.fetch; + const authenticatedFetch: typeof globalThis.fetch = async (input, init) => { + const candidate = new Request(input, init); + const path = relativeApiPath(candidate, baseUrl); + const contract = path === undefined ? undefined : responseContract(candidate.method, path); + if (contract === undefined) throw protocolError(502, "T4 API client refused an undeclared or cross-origin request"); + const headers = new Headers(candidate.headers); + headers.set("Authorization", `Bearer ${credential}`); + headers.set("T4-API-Version", majorVersion); + headers.set("Accept", contract.success[200] === "event-stream" ? "text/event-stream" : "application/json"); + const request = new Request(candidate, { headers, redirect: "error" }); + const response = await fetchImpl(request); + await validateResponse(request, response, baseUrl); + return response; + }; + const generated = createClient({ baseUrl, fetch: authenticatedFetch }); + const forbiddenOverrides = new Set([ + "baseUrl", "fetch", "headers", "middleware", "querySerializer", "bodySerializer", + "pathSerializer", "Request", "method", + ]); + const safeInit = (init: unknown): unknown => { + if (init === undefined) return undefined; + if (init === null || typeof init !== "object" || Array.isArray(init)) { + throw new TypeError("T4 API request options must be an object"); + } + for (const key of Object.keys(init)) { + if (forbiddenOverrides.has(key)) { + throw new TypeError(`T4 API request option ${key} is SDK-owned`); + } + } + return init; + }; + const http = Object.freeze({ + request: (method: Parameters[0], path: Parameters[1], init?: unknown) => + generated.request(method, path, safeInit(init) as never), + GET: (path: Parameters[0], init?: unknown) => generated.GET(path, safeInit(init) as never), + PUT: (path: Parameters[0], init?: unknown) => generated.PUT(path, safeInit(init) as never), + POST: (path: Parameters[0], init?: unknown) => generated.POST(path, safeInit(init) as never), + DELETE: (path: Parameters[0], init?: unknown) => generated.DELETE(path, safeInit(init) as never), + OPTIONS: (path: Parameters[0], init?: unknown) => generated.OPTIONS(path, safeInit(init) as never), + HEAD: (path: Parameters[0], init?: unknown) => generated.HEAD(path, safeInit(init) as never), + PATCH: (path: Parameters[0], init?: unknown) => generated.PATCH(path, safeInit(init) as never), + TRACE: (path: Parameters[0], init?: unknown) => generated.TRACE(path, safeInit(init) as never), + }) as unknown as Readonly, "use" | "eject">>; + return Object.freeze({ + http, + watchSession: (sessionId: string, watchOptions: WatchSessionOptions = {}) => + watch(baseUrl, credential, majorVersion, fetchImpl, sessionId, watchOptions), + }); +} diff --git a/packages/t4-api-client/tsconfig.json b/packages/t4-api-client/tsconfig.json new file mode 100644 index 00000000..824d57c3 --- /dev/null +++ b/packages/t4-api-client/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/t4-api-contract/openapi.json b/packages/t4-api-contract/openapi.json new file mode 100644 index 00000000..99fe6bbe --- /dev/null +++ b/packages/t4-api-contract/openapi.json @@ -0,0 +1,864 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "T4 API", + "version": "1.0.0", + "summary": "Authenticated public API for bounded T4 workspace and session lifecycle", + "description": "The public boundary for T4 clients. The service is not assumed to be publicly exposed. Every operation requires an opaque bearer credential and deny-by-default server-side scope authorization. Kubernetes, PostgreSQL, controller, runtime, and private OMP representations are never exposed." + }, + "servers": [{ "url": "https://t4.example.invalid", "description": "Operator-configured private HTTPS endpoint" }], + "security": [{ "BearerAuth": [] }], + "tags": [ + { "name": "Discovery" }, + { "name": "Workspaces" }, + { "name": "Sessions" }, + { "name": "Watch" } + ], + "paths": { + "/v1": { + "get": { + "operationId": "discoverV1", + "tags": ["Discovery"], + "summary": "Negotiate T4 API v1 and discover capabilities and bounds", + "parameters": [{ "$ref": "#/components/parameters/ApiVersion" }], + "responses": { + "200": { "$ref": "#/components/responses/Discovery" }, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "406": {"$ref": "#/components/responses/Error406"}, + "503": {"$ref": "#/components/responses/Error503"} + } + } + }, + "/v1/workspaces": { + "get": { + "operationId": "listWorkspaces", + "tags": ["Workspaces"], + "parameters": [ + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/PageSize" }, + { "$ref": "#/components/parameters/PageCursor" } + ], + "responses": { + "200": { "$ref": "#/components/responses/WorkspacePage" }, + "400": {"$ref": "#/components/responses/Error400"}, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "406": {"$ref": "#/components/responses/Error406"}, + "422": {"$ref": "#/components/responses/Error422"}, + "503": {"$ref": "#/components/responses/Error503"} + } + }, + "post": { + "operationId": "createWorkspace", + "tags": ["Workspaces"], + "parameters": [ + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/IdempotencyKey" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceCreate" } } } + }, + "responses": { + "200": { "$ref": "#/components/responses/WorkspaceReplay" }, + "202": { "$ref": "#/components/responses/WorkspaceAccepted" }, + "400": {"$ref": "#/components/responses/Error400"}, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "406": {"$ref": "#/components/responses/Error406"}, + "409": {"$ref": "#/components/responses/Error409"}, + "422": {"$ref": "#/components/responses/Error422"}, + "503": {"$ref": "#/components/responses/Error503"} + } + } + }, + "/v1/workspaces/{workspaceId}": { + "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }], + "get": { + "operationId": "getWorkspace", + "tags": ["Workspaces"], + "parameters": [{ "$ref": "#/components/parameters/ApiVersion" }], + "responses": { + "200": { "$ref": "#/components/responses/Workspace" }, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "503": {"$ref": "#/components/responses/Error503"} + } + }, + "patch": { + "operationId": "mutateWorkspace", + "tags": ["Workspaces"], + "parameters": [ + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/IfRevision" }, + { "$ref": "#/components/parameters/IdempotencyKey" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceMutation" } } } + }, + "responses": { + "400": { "$ref": "#/components/responses/Error400" }, + "200": { "$ref": "#/components/responses/WorkspaceReplay" }, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "409": {"$ref": "#/components/responses/Error409"}, + "422": {"$ref": "#/components/responses/Error422"}, + "503": {"$ref": "#/components/responses/Error503"} + } + }, + "delete": { + "operationId": "deleteWorkspace", + "tags": ["Workspaces"], + "parameters": [ + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/IdempotencyKey" } + ], + "responses": { + "204": { "$ref": "#/components/responses/Deleted" }, + "400": { "$ref": "#/components/responses/Error400" }, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "409": {"$ref": "#/components/responses/Error409"}, + "503": {"$ref": "#/components/responses/Error503"} + } + } + }, + "/v1/workspaces/{workspaceId}/sessions": { + "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }], + "get": { + "operationId": "listSessions", + "tags": ["Sessions"], + "parameters": [ + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/PageSize" }, + { "$ref": "#/components/parameters/PageCursor" } + ], + "responses": { + "200": { "$ref": "#/components/responses/SessionPage" }, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "422": {"$ref": "#/components/responses/Error422"}, + "503": {"$ref": "#/components/responses/Error503"} + } + }, + "post": { + "operationId": "spawnSession", + "tags": ["Sessions"], + "parameters": [ + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/IdempotencyKey" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionCreate" } } } + }, + "responses": { + "200": { "$ref": "#/components/responses/SessionReplay" }, + "202": { "$ref": "#/components/responses/SessionAccepted" }, + "400": { "$ref": "#/components/responses/Error400" }, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "409": {"$ref": "#/components/responses/Error409"}, + "422": {"$ref": "#/components/responses/Error422"}, + "503": {"$ref": "#/components/responses/Error503"} + } + } + }, + "/v1/sessions/{sessionId}": { + "parameters": [{ "$ref": "#/components/parameters/SessionId" }], + "get": { + "operationId": "getSession", + "tags": ["Sessions"], + "parameters": [{ "$ref": "#/components/parameters/ApiVersion" }], + "responses": { + "200": { "$ref": "#/components/responses/Session" }, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "503": {"$ref": "#/components/responses/Error503"} + } + }, + "patch": { + "operationId": "mutateSession", + "tags": ["Sessions"], + "parameters": [ + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/IfRevision" }, + { "$ref": "#/components/parameters/IdempotencyKey" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionMutation" } } } + }, + "responses": { + "400": { "$ref": "#/components/responses/Error400" }, + "200": { "$ref": "#/components/responses/SessionReplay" }, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "409": {"$ref": "#/components/responses/Error409"}, + "422": {"$ref": "#/components/responses/Error422"}, + "503": {"$ref": "#/components/responses/Error503"} + } + }, + "delete": { + "operationId": "deleteSession", + "tags": ["Sessions"], + "parameters": [ + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/IdempotencyKey" } + ], + "responses": { + "204": { "$ref": "#/components/responses/Deleted" }, + "400": { "$ref": "#/components/responses/Error400" }, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "409": {"$ref": "#/components/responses/Error409"}, + "503": {"$ref": "#/components/responses/Error503"} + } + } + }, + "/v1/sessions/{sessionId}/cancel": { + "post": { + "operationId": "cancelSession", + "tags": ["Sessions"], + "parameters": [ + { "$ref": "#/components/parameters/SessionId" }, + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/IdempotencyKey" } + ], + "responses": { + "200": { "$ref": "#/components/responses/SessionReplay" }, + "400": { "$ref": "#/components/responses/Error400" }, + "202": { "$ref": "#/components/responses/SessionAccepted" }, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "409": {"$ref": "#/components/responses/Error409"}, + "503": {"$ref": "#/components/responses/Error503"} + } + } + }, + "/v1/sessions/{sessionId}/commands": { + "post": { + "operationId": "submitCommand", + "tags": ["Sessions"], + "parameters": [ + { "$ref": "#/components/parameters/SessionId" }, + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/IdempotencyKey" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CommandCreate" } } } + }, + "responses": { + "200": { "$ref": "#/components/responses/CommandReplay" }, + "202": { "$ref": "#/components/responses/CommandAccepted" }, + "400": { "$ref": "#/components/responses/Error400" }, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "409": {"$ref": "#/components/responses/Error409"}, + "422": {"$ref": "#/components/responses/Error422"}, + "503": {"$ref": "#/components/responses/Error503"} + } + } + }, + "/v1/sessions/{sessionId}/snapshot": { + "get": { + "operationId": "getSessionSnapshot", + "tags": ["Watch"], + "parameters": [ + { "$ref": "#/components/parameters/SessionId" }, + { "$ref": "#/components/parameters/ApiVersion" } + ], + "responses": { + "200": { "$ref": "#/components/responses/Snapshot" }, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "503": {"$ref": "#/components/responses/Error503"} + } + } + }, + "/v1/sessions/{sessionId}/events": { + "get": { + "operationId": "watchSessionEvents", + "tags": ["Watch"], + "summary": "Watch a bounded Server-Sent Events stream over HTTPS", + "description": "The response is standard Server-Sent Events over HTTPS. Clients reconnect with the last event cursor in the cursor query parameter or Last-Event-ID header. Heartbeat events bound idle detection. Clients explicitly cancel by aborting the HTTPS request. A retained cursor expiry returns a typed 410 response with a snapshot resync target.", + "parameters": [ + { "$ref": "#/components/parameters/SessionId" }, + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/WatchCursor" }, + { "$ref": "#/components/parameters/LastEventId" }, + { "$ref": "#/components/parameters/MaxEvents" }, + { "$ref": "#/components/parameters/HeartbeatSeconds" } + ], + "responses": { + "200": { + "description": "Bounded SSE event stream. Every non-empty SSE data field is one JSON value conforming to WatchEvent; clients MUST reject unknown fields and schema-invalid event payloads before delivery. Transport chunk boundaries do not delimit frames.", + "headers": { + "Cache-Control": { "required": true, "schema": { "type": "string", "const": "no-store" } }, + "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } + }, + "content": { + "text/event-stream": { + "schema": { "type": "string", "description": "Server-Sent Events framing whose data values conform to #/components/schemas/WatchEvent", "x-t4-sse-data-schema": "#/components/schemas/WatchEvent" }, + "example": "id: cur_01J...\\nevent: heartbeat\\ndata: {\"type\":\"heartbeat\",\"cursor\":\"cur_01J...\",\"observedAt\":\"2026-07-21T00:00:00Z\"}\\n\\n" + } + } + }, + "400": {"$ref": "#/components/responses/Error400"}, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "410": {"$ref": "#/components/responses/Error410"}, + "422": {"$ref": "#/components/responses/Error422"}, + "503": {"$ref": "#/components/responses/Error503"} + } + } + } + }, + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "Opaque bearer credential. The server maps it to deny-by-default tenant and resource scopes; clients must not inspect or persist credential contents in API resources." + } + }, + "parameters": { + "ApiVersion": { + "name": "T4-API-Version", + "in": "header", + "required": true, + "description": "Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading.", + "schema": { "type": "string", "pattern": "^[1-9][0-9]{0,3}$", "maxLength": 4 } + }, + "IdempotencyKey": { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "description": "Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict.", + "schema": { "type": "string", "minLength": 16, "maxLength": 128, "pattern": "^[A-Za-z0-9._~-]+$" } + }, + "IfRevision": { + "name": "T4-If-Revision", + "in": "header", + "required": true, + "description": "Decimal resource revision for optimistic mutation.", + "schema": { "type": "string", "pattern": "^[1-9][0-9]{0,18}$" } + }, + "WorkspaceId": { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { "$ref": "#/components/schemas/ResourceId" } + }, + "SessionId": { + "name": "sessionId", + "in": "path", + "required": true, + "schema": { "$ref": "#/components/schemas/ResourceId" } + }, + "PageSize": { + "name": "pageSize", + "in": "query", + "description": "Omit to use the server-specific limits.pageSizeDefault value from discovery.", + "schema": { "type": "integer", "minimum": 1, "maximum": 100 } + }, + "PageCursor": { + "name": "cursor", + "in": "query", + "description": "Opaque page cursor, valid only for the same identity and list operation.", + "schema": { "$ref": "#/components/schemas/Cursor" } + }, + "WatchCursor": { + "name": "cursor", + "in": "query", + "description": "Opaque last-consumed watch event cursor.", + "schema": { "$ref": "#/components/schemas/Cursor" } + }, + "LastEventId": { + "name": "Last-Event-ID", + "in": "header", + "description": "Standard SSE reconnect header. Must agree with cursor when both are present.", + "schema": { "$ref": "#/components/schemas/Cursor" } + }, + "MaxEvents": { + "name": "maxEvents", + "in": "query", + "description": "Omit to use the server-specific limits.watchEventsDefault value from discovery.", + "schema": { "type": "integer", "minimum": 1, "maximum": 1000 } + }, + "HeartbeatSeconds": { + "name": "heartbeatSeconds", + "in": "query", + "schema": { "type": "integer", "minimum": 5, "maximum": 60, "default": 15 } + } + }, + "headers": { + "SelectedVersion": { + "description": "Selected T4 API profile. This strict v1.0 contract requires exactly 1.0.", + "required": true, + "schema": { "type": "string", "const": "1.0" } + }, + "IdempotencyReplayed": { + "description": "True when this response replays a prior identical request.", + "required": true, + "schema": { "type": "string", "enum": ["true", "false"] } + }, + "EventCursor": { + "description": "Durable event cursor committed with the accepted mutation and preserved by replay.", + "required": true, + "schema": { "$ref": "#/components/schemas/Cursor" } + } + }, + "schemas": { + "ResourceId": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$" }, + "Cursor": { "type": "string", "minLength": 1, "maxLength": 512, "pattern": "^[A-Za-z0-9._~-]+$", "description": "Opaque server-issued header-safe SSE cursor." }, + "Revision": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, + "CommandState": { "type": "string", "enum": ["accepted", "projected", "dispatching", "running", "succeeded", "failed", "cancelling", "cancelled", "rejected", "unavailable", "indeterminate"] }, + "WorkspaceState": { "type": "string", "enum": ["accepted", "provisioning", "ready", "deleting", "deleted", "failed", "unavailable", "indeterminate"] }, + "SessionState": { "type": "string", "enum": ["accepted", "provisioning", "ready", "cancelling", "cancelled", "failed", "unavailable", "indeterminate"] }, + "ServerBuild": { + "type": "object", + "additionalProperties": false, + "required": ["version", "revision"], + "properties": { + "version": { "type": "string", "minLength": 1, "maxLength": 128 }, + "revision": { "type": "string", "minLength": 1, "maxLength": 128 } + } + }, + "CapabilityDeprecation": { + "type": "object", + "additionalProperties": false, + "required": ["message"], + "properties": { + "message": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "sinceVersion": { "type": "string", "minLength": 1, "maxLength": 128 }, + "sunsetAt": { "type": "string", "format": "date-time", "maxLength": 64 }, + "replacement": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[a-z][a-z0-9.-]*$" } + } + }, + "CapabilityStatus": { + "type": "object", + "additionalProperties": false, + "required": ["supported", "enabled", "authorized", "available"], + "properties": { + "supported": { "type": "boolean" }, + "enabled": { "type": "boolean" }, + "authorized": { "type": "boolean" }, + "available": { "type": "boolean" }, + "deprecation": { "$ref": "#/components/schemas/CapabilityDeprecation" } + } + }, + "Capabilities": { + "type": "object", + "maxProperties": 128, + "propertyNames": { "minLength": 1, "maxLength": 128, "pattern": "^[a-z][a-z0-9.-]*$" }, + "additionalProperties": { "$ref": "#/components/schemas/CapabilityStatus" } + }, + "Discovery": { + "type": "object", + "additionalProperties": false, + "required": ["apiVersion", "serverBuild", "supportedMajors", "capabilities", "limits"], + "properties": { + "apiVersion": { "type": "string", "pattern": "^1\\.[0-9]+$" }, + "serverBuild": { "$ref": "#/components/schemas/ServerBuild" }, + "supportedMajors": { "type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, "items": { "type": "integer", "minimum": 1 } }, + "capabilities": { "$ref": "#/components/schemas/Capabilities" }, + "limits": { + "type": "object", + "additionalProperties": false, + "required": ["pageSizeDefault", "pageSizeMax", "commandBytesMax", "commandRequestBytesMax", "commandMetadataValueBytesMax", "watchEventsDefault", "watchEventsMax", "heartbeatSeconds"], + "properties": { + "pageSizeDefault": { "type": "integer", "minimum": 1, "maximum": 100 }, + "pageSizeMax": { "type": "integer", "minimum": 1, "maximum": 100 }, + "commandBytesMax": { "type": "integer", "minimum": 1, "maximum": 262144 }, + "commandRequestBytesMax": { "type": "integer", "minimum": 1, "maximum": 1048576 }, + "commandMetadataValueBytesMax": { "type": "integer", "minimum": 1, "maximum": 262144 }, + "watchEventsDefault": { "type": "integer", "minimum": 1, "maximum": 1000 }, + "watchEventsMax": { "type": "integer", "minimum": 1, "maximum": 1000 }, + "heartbeatSeconds": { "type": "integer", "minimum": 5, "maximum": 60 } + } + } + } + }, + "WorkspaceCreate": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 128 }, + "labels": { "$ref": "#/components/schemas/Labels" } + } + }, + "WorkspaceMutation": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 128 }, + "labels": { "$ref": "#/components/schemas/Labels" } + } + }, + "Workspace": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "state", "revision"], + "properties": { + "id": { "$ref": "#/components/schemas/ResourceId" }, + "name": { "type": "string", "minLength": 1, "maxLength": 128 }, + "state": { "$ref": "#/components/schemas/WorkspaceState" }, + "revision": { "$ref": "#/components/schemas/Revision" }, + "labels": { "$ref": "#/components/schemas/Labels" } + } + }, + "WorkspacePage": { + "type": "object", + "additionalProperties": false, + "required": ["items"], + "properties": { + "items": { "type": "array", "maxItems": 100, "items": { "$ref": "#/components/schemas/Workspace" } }, + "nextCursor": { "$ref": "#/components/schemas/Cursor" } + } + }, + "SessionCreate": { + "type": "object", + "additionalProperties": false, + "required": ["title"], + "properties": { + "title": { "type": "string", "minLength": 1, "maxLength": 128 }, + "labels": { "$ref": "#/components/schemas/Labels" } + } + }, + "SessionMutation": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "title": { "type": "string", "minLength": 1, "maxLength": 128 }, + "labels": { "$ref": "#/components/schemas/Labels" } + } + }, + "Session": { + "type": "object", + "additionalProperties": false, + "required": ["id", "workspaceId", "title", "state", "revision"], + "properties": { + "id": { "$ref": "#/components/schemas/ResourceId" }, + "workspaceId": { "$ref": "#/components/schemas/ResourceId" }, + "title": { "type": "string", "minLength": 1, "maxLength": 128 }, + "state": { "$ref": "#/components/schemas/SessionState" }, + "revision": { "$ref": "#/components/schemas/Revision" }, + "labels": { "$ref": "#/components/schemas/Labels" } + } + }, + "SessionPage": { + "type": "object", + "additionalProperties": false, + "required": ["items"], + "properties": { + "items": { "type": "array", "maxItems": 100, "items": { "$ref": "#/components/schemas/Session" } }, + "nextCursor": { "$ref": "#/components/schemas/Cursor" } + } + }, + "Labels": { + "type": "object", + "maxProperties": 32, + "propertyNames": { "pattern": "^[a-z][a-z0-9.-]{0,62}$" }, + "additionalProperties": { "type": "string", "maxLength": 128 } + }, + "CommandCreate": { + "type": "object", + "additionalProperties": false, + "required": ["command"], + "x-t4-maxUtf8Bytes": 1048576, + "properties": { + "command": { "type": "string", "minLength": 1, "maxLength": 262144, "x-t4-maxUtf8Bytes": 262144 }, + "metadata": { + "type": "object", + "default": {}, + "maxProperties": 32, + "propertyNames": { "pattern": "^[a-z][a-z0-9.-]{0,62}$" }, + "additionalProperties": { + "oneOf": [ + { "type": "string", "maxLength": 262144, "x-t4-maxUtf8Bytes": 262144 }, + { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, + { "type": "boolean" }, + { "type": "null" } + ] + } + } + } + }, + "CommandResult": { + "type": "object", + "additionalProperties": false, + "required": ["commandId", "state"], + "properties": { + "commandId": { "$ref": "#/components/schemas/ResourceId" }, + "state": { "$ref": "#/components/schemas/CommandState" } + } + }, + "SnapshotEntry": { + "type": "object", + "additionalProperties": false, + "required": ["sequence", "kind", "text"], + "properties": { + "sequence": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, + "kind": { "type": "string", "enum": ["input", "output", "status"] }, + "text": { "type": "string", "maxLength": 1048576 } + } + }, + "SessionSnapshot": { + "type": "object", + "additionalProperties": false, + "x-t4-maxUtf8Bytes": 16777216, + "required": ["sessionId", "cursor", "state", "entries"], + "properties": { + "sessionId": { "$ref": "#/components/schemas/ResourceId" }, + "cursor": { "$ref": "#/components/schemas/Cursor" }, + "state": { "$ref": "#/components/schemas/SessionState" }, + "entries": { "type": "array", "maxItems": 1000, "items": { "$ref": "#/components/schemas/SnapshotEntry" } } + } + }, + "HeartbeatWatchEvent": { + "type": "object", + "additionalProperties": false, + "required": ["type", "cursor", "observedAt"], + "properties": { + "type": { "type": "string", "const": "heartbeat" }, + "cursor": { "$ref": "#/components/schemas/Cursor" }, + "observedAt": { "type": "string", "format": "date-time", "maxLength": 64 } + } + }, + "SessionWatchEvent": { + "type": "object", + "additionalProperties": false, + "required": ["type", "cursor", "state", "revision"], + "properties": { + "type": { "type": "string", "const": "session" }, + "cursor": { "$ref": "#/components/schemas/Cursor" }, + "state": { "$ref": "#/components/schemas/SessionState" }, + "revision": { "$ref": "#/components/schemas/Revision" } + } + }, + "CommandWatchEvent": { + "type": "object", + "additionalProperties": false, + "required": ["type", "cursor", "commandId", "state"], + "properties": { + "type": { "type": "string", "const": "command" }, + "cursor": { "$ref": "#/components/schemas/Cursor" }, + "commandId": { "$ref": "#/components/schemas/ResourceId" }, + "state": { "$ref": "#/components/schemas/CommandState" } + } + }, + "WatchEvent": { + "oneOf": [ + { "$ref": "#/components/schemas/HeartbeatWatchEvent" }, + { "$ref": "#/components/schemas/SessionWatchEvent" }, + { "$ref": "#/components/schemas/CommandWatchEvent" } + ] + }, + "FieldViolation": { + "type": "object", + "additionalProperties": false, + "required": ["field", "rule", "message"], + "properties": { + "field": { "type": "string", "minLength": 1, "maxLength": 256 }, + "rule": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[A-Za-z][A-Za-z0-9._-]*$" }, + "message": { "type": "string", "minLength": 1, "maxLength": 512 } + } + }, + "Resync": { + "type": "object", + "additionalProperties": false, + "required": ["snapshotUrl", "cursor"], + "properties": { + "snapshotUrl": { "type": "string", "description": "API-base-relative snapshot path for this watched session.", "pattern": "^v1/sessions/[A-Za-z0-9][A-Za-z0-9._~-]{0,127}/snapshot$", "maxLength": 149 }, + "cursor": { "$ref": "#/components/schemas/Cursor" } + } + }, + "ApiError": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message", "requestId", "retryable"], + "properties": { + "code": { + "type": "string", + "enum": ["invalid_request", "unauthenticated", "forbidden", "not_found", "idempotency_key_required", "idempotency_conflict", "revision_conflict", "incompatible_version", "cursor_expired", "unavailable", "indeterminate", "invalid_origin", "https_required"] + }, + "message": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "requestId": { "type": "string", "minLength": 1, "maxLength": 128 }, + "retryable": { "type": "boolean" }, + "violations": { "type": "array", "maxItems": 64, "items": { "$ref": "#/components/schemas/FieldViolation" } }, + "supportedMajors": { "type": "array", "maxItems": 8, "uniqueItems": true, "items": { "type": "integer", "minimum": 1 } }, + "resync": { "$ref": "#/components/schemas/Resync" } + } + }, + "BadRequestApiError": { + "allOf": [ + { "$ref": "#/components/schemas/ApiError" }, + { "type": "object", "properties": { "code": { "enum": ["invalid_request", "idempotency_key_required", "invalid_origin", "https_required"] } } } + ] + }, + "UnauthenticatedApiError": { + "allOf": [ + { "$ref": "#/components/schemas/ApiError" }, + { "type": "object", "properties": { "code": { "const": "unauthenticated" } } } + ] + }, + "ForbiddenApiError": { + "allOf": [ + { "$ref": "#/components/schemas/ApiError" }, + { "type": "object", "properties": { "code": { "const": "forbidden" } } } + ] + }, + "NotFoundApiError": { + "allOf": [ + { "$ref": "#/components/schemas/ApiError" }, + { "type": "object", "properties": { "code": { "const": "not_found" } } } + ] + }, + "ConflictApiError": { + "allOf": [ + { "$ref": "#/components/schemas/ApiError" }, + { "type": "object", "properties": { "code": { "enum": ["idempotency_conflict", "revision_conflict"] } } } + ] + }, + "UnavailableApiError": { + "allOf": [ + { "$ref": "#/components/schemas/ApiError" }, + { "type": "object", "properties": { "code": { "enum": ["unavailable", "indeterminate"] } } } + ] + }, + "IncompatibleVersionApiError": { + "allOf": [ + { "$ref": "#/components/schemas/ApiError" }, + { "type": "object", "required": ["supportedMajors"], "properties": { "code": { "const": "incompatible_version" }, "supportedMajors": { "type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, "items": { "type": "integer", "minimum": 1 } } } } + ] + }, + "CursorExpiredApiError": { + "allOf": [ + { "$ref": "#/components/schemas/ApiError" }, + { "type": "object", "required": ["resync"], "properties": { "code": { "const": "cursor_expired" }, "resync": { "$ref": "#/components/schemas/Resync" } } } + ] + }, + "InvalidRequestApiError": { + "allOf": [ + { "$ref": "#/components/schemas/ApiError" }, + { "type": "object", "required": ["violations"], "properties": { "code": { "const": "invalid_request" }, "violations": { "type": "array", "minItems": 1, "maxItems": 64, "items": { "$ref": "#/components/schemas/FieldViolation" } } } } + ] + }, + "ErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/ApiError" } } + }, + "BadRequestErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/BadRequestApiError" } } + }, + "UnauthenticatedErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/UnauthenticatedApiError" } } + }, + "ForbiddenErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/ForbiddenApiError" } } + }, + "NotFoundErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/NotFoundApiError" } } + }, + "ConflictErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/ConflictApiError" } } + }, + "UnavailableErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/UnavailableApiError" } } + }, + "IncompatibleVersionErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/IncompatibleVersionApiError" } } + }, + "CursorExpiredErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/CursorExpiredApiError" } } + }, + "InvalidRequestErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/InvalidRequestApiError" } } + } + }, + "responses": { + "Discovery": { "description": "Negotiated v1 discovery", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Discovery" } } } }, + "Workspace": { "description": "Workspace", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Workspace" } } } }, + "WorkspaceAccepted": { "description": "Workspace intent accepted", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" }, "T4-Event-Cursor": { "$ref": "#/components/headers/EventCursor" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Workspace" } } } }, + "WorkspaceReplay": { "description": "Identical workspace request replay", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" }, "T4-Event-Cursor": { "$ref": "#/components/headers/EventCursor" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Workspace" } } } }, + "WorkspacePage": { "description": "Bounded workspace page", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspacePage" } } } }, + "Session": { "description": "Session", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Session" } } } }, + "SessionAccepted": { "description": "Session intent accepted", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" }, "T4-Event-Cursor": { "$ref": "#/components/headers/EventCursor" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Session" } } } }, + "SessionReplay": { "description": "Identical session mutation replay", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" }, "T4-Event-Cursor": { "$ref": "#/components/headers/EventCursor" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Session" } } } }, + "SessionPage": { "description": "Bounded session page", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionPage" } } } }, + "CommandAccepted": { "description": "Command intent accepted with a stable outcome state", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" }, "T4-Event-Cursor": { "$ref": "#/components/headers/EventCursor" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CommandResult" } } } }, + "CommandReplay": { "description": "Identical command request replay", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" }, "T4-Event-Cursor": { "$ref": "#/components/headers/EventCursor" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CommandResult" } } } }, + "Snapshot": { "description": "Bounded session snapshot and reconnect cursor", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionSnapshot" } } } }, + "Deleted": { "description": "Idempotent deletion accepted or resource already absent", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" }, "T4-Event-Cursor": { "$ref": "#/components/headers/EventCursor" } } }, + "Error400": { "description": "Malformed request or missing idempotency key", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BadRequestErrorEnvelope" } } } }, + "Error401": { "description": "Missing or invalid opaque bearer credential", "headers": { "WWW-Authenticate": { "required": true, "schema": { "type": "string", "const": "Bearer realm=\"t4\"" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UnauthenticatedErrorEnvelope" } } } }, + "Error403": { "description": "Credential lacks the deny-by-default operation or resource scope", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ForbiddenErrorEnvelope" } } } }, + "Error404": { "description": "Resource absent or outside caller scope (scope existence is not disclosed)", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotFoundErrorEnvelope" } } } }, + "Error406": { "description": "Requested major is incompatible; no silent downgrade", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IncompatibleVersionErrorEnvelope" } } } }, + "Error409": { "description": "Idempotency or resource revision conflict", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConflictErrorEnvelope" } } } }, + "Error410": { "description": "Watch cursor expired; resync from the typed snapshot target", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CursorExpiredErrorEnvelope" } } } }, + "Error422": { "description": "Bounded semantic validation failure with stable field violations", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/InvalidRequestErrorEnvelope" } } } }, + "Error503": { "description": "Service temporarily unavailable or outcome indeterminate. Retryable watch failures may advertise Retry-After; clients honor it with a 30 second ceiling.", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Retry-After": { "description": "RFC 9110 delay in seconds or HTTP date; clients bound the applied delay.", "schema": { "type": "string", "minLength": 1, "maxLength": 128 } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UnavailableErrorEnvelope" } } } } + } + } +} diff --git a/packages/t4-api-contract/package.json b/packages/t4-api-contract/package.json new file mode 100644 index 00000000..a69bfb23 --- /dev/null +++ b/packages/t4-api-contract/package.json @@ -0,0 +1,22 @@ +{ + "name": "@t4-code/t4-api-contract", + "version": "0.1.31", + "private": true, + "type": "module", + "exports": { + "./openapi.json": "./openapi.json" + }, + "scripts": { + "build": "pnpm validate && pnpm check:generated", + "typecheck": "pnpm validate && pnpm check:generated", + "test": "pnpm validate && pnpm check:generated", + "validate": "node scripts/validate.mjs", + "generate": "node scripts/generate.mjs --write", + "generate:ci": "node scripts/generate.mjs --ci-artifact", + "check:generated": "node scripts/generate.mjs --check" + }, + "devDependencies": { + "@readme/openapi-parser": "6.3.0", + "openapi-typescript": "7.13.0" + } +} diff --git a/packages/t4-api-contract/scripts/generate.mjs b/packages/t4-api-contract/scripts/generate.mjs new file mode 100644 index 00000000..a2b095d1 --- /dev/null +++ b/packages/t4-api-contract/scripts/generate.mjs @@ -0,0 +1,49 @@ +import { createHash } from "node:crypto"; +import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import openapiTS, { astToString } from "openapi-typescript"; + +const contractRoot = fileURLToPath(new URL("..", import.meta.url)); +const schemaUrl = new URL("../openapi.json", import.meta.url); +const target = resolve(contractRoot, "../t4-api-client/src/generated/schema.ts"); +const mode = process.argv[2]; +if (!new Set(["--write", "--check", "--ci-artifact"]).has(mode)) { + throw new Error("usage: node scripts/generate.mjs --write|--check|--ci-artifact"); +} + +const generated = `${astToString(await openapiTS(schemaUrl, { alphabetize: true, defaultNonNullable: false })).trimEnd()}\n`; +const digest = createHash("sha256").update(generated).digest("hex"); + +if (mode === "--write") { + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, generated); + await chmod(target, 0o644); + console.log(`generated ${target} sha256:${digest}`); + process.exit(0); +} + +if (mode === "--ci-artifact") { + const artifact = resolve(contractRoot, "../../artifacts/t4-api/generated/schema.ts"); + await mkdir(dirname(artifact), { recursive: true }); + await writeFile(artifact, generated); + await chmod(artifact, 0o644); + console.log(`T4_API_GENERATED_SHA256=${digest}`); + console.log(`T4_API_GENERATED_BASE64_BEGIN\n${Buffer.from(generated).toString("base64")}\nT4_API_GENERATED_BASE64_END`); +} + +let checkedIn; +try { + checkedIn = await readFile(target, "utf8"); +} catch (error) { + if (mode === "--ci-artifact") throw new Error(`generated client is absent; recover the CI artifact at artifacts/t4-api/generated/schema.ts (sha256:${digest})`, { cause: error }); + throw error; +} +if (checkedIn !== generated) { + throw new Error(`generated client drift: expected sha256:${digest}; regenerate only with the authorized CI artifact`); +} +if (((await stat(target)).mode & 0o111) !== 0) { + throw new Error("generated client mode drift: schema.ts must not be executable"); +} +console.log(`generated client is deterministic (sha256:${digest})`); diff --git a/packages/t4-api-contract/scripts/validate.mjs b/packages/t4-api-contract/scripts/validate.mjs new file mode 100644 index 00000000..eaa3f290 --- /dev/null +++ b/packages/t4-api-contract/scripts/validate.mjs @@ -0,0 +1,83 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { compileErrors, validate } from "@readme/openapi-parser"; + +const source = new URL("../openapi.json", import.meta.url); +const document = JSON.parse(await readFile(source, "utf8")); +const validation = await validate(fileURLToPath(source)); +if (!validation.valid || validation.warnings.length > 0) throw new Error(compileErrors(validation)); + +if (document.openapi !== "3.1.0") throw new Error("T4 API contract must remain OpenAPI 3.1.0"); +if (!Array.isArray(document.servers) || document.servers.length === 0) throw new Error("T4 API contract requires an HTTPS server"); +for (const server of document.servers) { + if (new URL(server.url).protocol !== "https:") throw new Error("T4 API contract permits only HTTPS servers"); +} +if (document.security?.[0]?.BearerAuth === undefined) throw new Error("T4 API contract must default to bearer authentication"); +const sseSchema = document.paths?.["/v1/sessions/{sessionId}/events"]?.get?.responses?.["200"]?.content?.["text/event-stream"]?.schema; +if (sseSchema?.["x-t4-sse-data-schema"] !== "#/components/schemas/WatchEvent") throw new Error("watch SSE data must remain linked to WatchEvent"); +const schemas = document.components?.schemas ?? {}; +const apiVersionSchema = document.components?.parameters?.ApiVersion?.schema; +if (apiVersionSchema?.pattern !== "^[1-9][0-9]{0,3}$" || apiVersionSchema?.maxLength !== 4) throw new Error("ApiVersion must remain a bounded major-only header"); +if (document.components?.parameters?.IfRevision?.name !== "T4-If-Revision") throw new Error("optimistic revisions must use the T4-If-Revision application header"); +const resyncSnapshot = schemas.Resync?.properties?.snapshotUrl; +if (resyncSnapshot?.pattern !== "^v1/sessions/[A-Za-z0-9][A-Za-z0-9._~-]{0,127}/snapshot$" || resyncSnapshot?.maxLength !== 149) throw new Error("Resync.snapshotUrl must remain API-base-relative and ResourceId-bounded"); +const commandCreate = schemas.CommandCreate; +if (commandCreate?.["x-t4-maxUtf8Bytes"] !== 1048576) throw new Error("CommandCreate must retain its UTF-8 request-byte bound"); +if (commandCreate?.properties?.command?.["x-t4-maxUtf8Bytes"] !== 262144) throw new Error("command must retain its UTF-8 byte bound"); +const metadataString = commandCreate?.properties?.metadata?.additionalProperties?.oneOf?.find((item) => item?.type === "string"); +if (metadataString?.["x-t4-maxUtf8Bytes"] !== 262144) throw new Error("command metadata strings must retain their UTF-8 byte bound"); +if (schemas.SessionSnapshot?.["x-t4-maxUtf8Bytes"] !== 16777216) throw new Error("SessionSnapshot must retain its aggregate UTF-8 response-byte bound"); + +for (const [schema, property] of [["Discovery", "supportedMajors"], ["ApiError", "supportedMajors"]]) { + if (schemas[schema]?.properties?.[property]?.uniqueItems !== true) throw new Error(`${schema}.${property} must retain uniqueItems`); +} +if (schemas.Discovery?.required?.includes("serverBuild") !== true || schemas.Discovery?.properties?.capabilities?.$ref !== "#/components/schemas/Capabilities") throw new Error("Discovery must require structured server build and capabilities"); +if (schemas.Capabilities?.maxProperties !== 128 || schemas.Capabilities?.additionalProperties?.$ref !== "#/components/schemas/CapabilityStatus") throw new Error("Capabilities must remain a bounded status map"); + +const selectedVersionHeader = document.components?.headers?.SelectedVersion; +const replayHeaderObject = document.components?.headers?.IdempotencyReplayed; +const eventCursorHeader = document.components?.headers?.EventCursor; +if (selectedVersionHeader?.required !== true) throw new Error("SelectedVersion must remain required wherever referenced"); +if (replayHeaderObject?.required !== true) throw new Error("IdempotencyReplayed must remain required wherever referenced"); +if (eventCursorHeader?.required !== true || eventCursorHeader?.schema?.$ref !== "#/components/schemas/Cursor") throw new Error("EventCursor must remain a required header-safe cursor"); +const selectedVersion = document.components?.headers?.SelectedVersion?.schema; +if (selectedVersion?.const !== "1.0") throw new Error("SelectedVersion must remain the exact strict v1.0 profile"); +const replayHeader = document.components?.headers?.IdempotencyReplayed?.schema; +if (replayHeader?.type !== "string" || JSON.stringify(replayHeader.enum) !== '["true","false"]') throw new Error("IdempotencyReplayed must remain the exact true|false string enum"); +if (schemas.HeartbeatWatchEvent?.properties?.observedAt?.maxLength !== 64) throw new Error("HeartbeatWatchEvent.observedAt must remain bounded to 64 characters"); +const selectedVersionRef = "#/components/headers/SelectedVersion"; +const replayRef = "#/components/headers/IdempotencyReplayed"; +const selectedVersionResponses = ["Discovery", "Workspace", "WorkspaceAccepted", "WorkspaceReplay", "WorkspacePage", "Session", "SessionAccepted", "SessionReplay", "SessionPage", "CommandAccepted", "CommandReplay", "Snapshot", "Deleted", "Error400", "Error403", "Error404", "Error406", "Error409", "Error410", "Error422", "Error503"]; +for (const responseName of selectedVersionResponses) { + if (document.components?.responses?.[responseName]?.headers?.["T4-API-Version"]?.$ref !== selectedVersionRef) throw new Error(`${responseName} must declare SelectedVersion`); +} +const mutationResponses = ["WorkspaceAccepted", "WorkspaceReplay", "SessionAccepted", "SessionReplay", "CommandAccepted", "CommandReplay", "Deleted"]; +for (const responseName of mutationResponses) { + if (document.components?.responses?.[responseName]?.headers?.["Idempotency-Replayed"]?.$ref !== replayRef) throw new Error(`${responseName} must declare IdempotencyReplayed`); + if (document.components?.responses?.[responseName]?.headers?.["T4-Event-Cursor"]?.$ref !== "#/components/headers/EventCursor") throw new Error(`${responseName} must declare EventCursor`); +} +if (document.paths?.["/v1/sessions/{sessionId}/events"]?.get?.responses?.["200"]?.headers?.["T4-API-Version"]?.$ref !== selectedVersionRef) throw new Error("watch success must declare SelectedVersion"); +const watchCacheControl = document.paths?.["/v1/sessions/{sessionId}/events"]?.get?.responses?.["200"]?.headers?.["Cache-Control"]; +if (watchCacheControl?.required !== true || watchCacheControl?.schema?.const !== "no-store") throw new Error("watch success must require Cache-Control: no-store"); +if (document.components?.responses?.Error401?.headers?.["T4-API-Version"] !== undefined) throw new Error("Error401 must omit SelectedVersion"); +if (document.components?.responses?.Error401?.headers?.["WWW-Authenticate"]?.schema?.const !== 'Bearer realm="t4"') throw new Error("Error401 must require the exact bearer challenge"); +const limits = schemas.Discovery?.properties?.limits?.properties; +if (limits?.watchEventsDefault === undefined || limits?.watchEventsMax === undefined) throw new Error("Discovery must expose watch default and maximum bounds"); +if (document.paths?.["/v1/workspaces/{workspaceId}"]?.patch?.responses?.["200"]?.$ref !== "#/components/responses/WorkspaceReplay") throw new Error("workspace PATCH success must declare replay headers"); +if (document.paths?.["/v1/sessions/{sessionId}"]?.patch?.responses?.["200"]?.$ref !== "#/components/responses/SessionReplay") throw new Error("session PATCH success must declare replay headers"); +const errorResponses = { 400: "Error400", 401: "Error401", 403: "Error403", 404: "Error404", 406: "Error406", 409: "Error409", 410: "Error410", 422: "Error422", 503: "Error503" }; +for (const path of Object.values(document.paths ?? {})) { + for (const operation of Object.values(path)) { + if (operation === null || typeof operation !== "object" || operation.responses === undefined) continue; + for (const [status, component] of Object.entries(errorResponses)) { + const response = operation.responses[status]; + if (response !== undefined && response.$ref !== `#/components/responses/${component}`) throw new Error(`${status} responses must use ${component}`); + } + } +} +const serializedSchemas = JSON.stringify(document.components?.schemas ?? {}).toLowerCase(); +for (const privateType of ["kubernetes.io", "v1alpha1", "postgresql", "ompserver", "podspec"]) { + if (serializedSchemas.includes(privateType)) throw new Error(`public schema leaks private type ${privateType}`); +} +console.log("T4 API OpenAPI 3.1 contract is valid"); diff --git a/packages/t4-api-contract/tsconfig.json b/packages/t4-api-contract/tsconfig.json new file mode 100644 index 00000000..9e7ef5aa --- /dev/null +++ b/packages/t4-api-contract/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": [] +} diff --git a/packages/ui/package.json b/packages/ui/package.json index d95d188f..9b4b6e87 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/ui", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "exports": { diff --git a/packages/ui/src/motion/AnimatedHeight.tsx b/packages/ui/src/motion/AnimatedHeight.tsx index 5f3b8ffb..7bbe4a2c 100644 --- a/packages/ui/src/motion/AnimatedHeight.tsx +++ b/packages/ui/src/motion/AnimatedHeight.tsx @@ -31,11 +31,13 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) { // (scrollHeight reads), which profiled as the single largest app cost // while scrolling a long transcript (every freshly mounted row runs // this effect). - const resizeObserver = new ResizeObserver((entries) => { - const entry = entries[entries.length - 1]; - if (entry === undefined) return; - const measured = entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; - const nextHeight = Math.ceil(measured); + let frame = 0; + let pendingHeight: number | null = null; + const commitHeight = () => { + frame = 0; + const nextHeight = pendingHeight; + pendingHeight = null; + if (nextHeight === null) return; setHeightState((currentState) => { if (currentState.height === nextHeight) return currentState; return { @@ -43,9 +45,17 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) { isClipping: currentState.height !== null, }; }); + }; + const resizeObserver = new ResizeObserver((entries) => { + const entry = entries[entries.length - 1]; + if (entry === undefined) return; + const measured = entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; + pendingHeight = Math.ceil(measured); + if (frame === 0) frame = requestAnimationFrame(commitHeight); }); resizeObserver.observe(element); return () => { + if (frame !== 0) cancelAnimationFrame(frame); resizeObserver.disconnect(); }; }, []); diff --git a/patches/@legendapp__list@3.2.0.patch b/patches/@legendapp__list@3.2.0.patch new file mode 100644 index 00000000..685de57d --- /dev/null +++ b/patches/@legendapp__list@3.2.0.patch @@ -0,0 +1,70 @@ +diff --git a/react.js b/react.js +--- a/react.js ++++ b/react.js +@@ -4830,15 +4830,25 @@ + // src/hooks/createResizeObserver.ts + var globalResizeObserver = null; + function getGlobalResizeObserver() { + if (!globalResizeObserver) { ++ let frame = 0; ++ const pendingEntries = /* @__PURE__ */ new Map(); + globalResizeObserver = new ResizeObserver((entries) => { +- for (const entry of entries) { +- const callbacks = callbackMap.get(entry.target); +- if (callbacks) { +- for (const callback of callbacks) { +- callback(entry); ++ for (const entry of entries) pendingEntries.set(entry.target, entry); ++ if (frame === 0) { ++ frame = requestAnimationFrame(() => { ++ frame = 0; ++ const currentEntries = Array.from(pendingEntries.values()); ++ pendingEntries.clear(); ++ for (const entry of currentEntries) { ++ const callbacks = callbackMap.get(entry.target); ++ if (callbacks) { ++ for (const callback of callbacks) { ++ callback(entry); ++ } ++ } + } +- } ++ }); + } + }); + } +diff --git a/react.mjs b/react.mjs +--- a/react.mjs ++++ b/react.mjs +@@ -4809,15 +4809,25 @@ + // src/hooks/createResizeObserver.ts + var globalResizeObserver = null; + function getGlobalResizeObserver() { + if (!globalResizeObserver) { ++ let frame = 0; ++ const pendingEntries = /* @__PURE__ */ new Map(); + globalResizeObserver = new ResizeObserver((entries) => { +- for (const entry of entries) { +- const callbacks = callbackMap.get(entry.target); +- if (callbacks) { +- for (const callback of callbacks) { +- callback(entry); ++ for (const entry of entries) pendingEntries.set(entry.target, entry); ++ if (frame === 0) { ++ frame = requestAnimationFrame(() => { ++ frame = 0; ++ const currentEntries = Array.from(pendingEntries.values()); ++ pendingEntries.clear(); ++ for (const entry of currentEntries) { ++ const callbacks = callbackMap.get(entry.target); ++ if (callbacks) { ++ for (const callback of callbacks) { ++ callback(entry); ++ } ++ } + } +- } ++ }); + } + }); + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a71ecc1d..23e313fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ overrides: packageExtensionsChecksum: sha256-q5WdBF7BCWKzly/tgcivWtVAQ00t1e/WaEGl0+mhjaI= +patchedDependencies: + '@legendapp/list@3.2.0': 6049699ee3ebabe70c73c0a64950edbdf985bfb6c98d66f7722cd592b58db63d + importers: .: @@ -55,7 +58,7 @@ importers: version: 3.4.1 '@electron/osx-sign': specifier: 1.3.3 - version: 1.3.3 + version: 1.3.3(supports-color@10.2.2) '@playwright/test': specifier: 1.61.1 version: 1.61.1 @@ -110,7 +113,7 @@ importers: version: 8.5.13 electron: specifier: 'catalog:' - version: 41.5.0 + version: 41.5.0(supports-color@10.2.2) electron-builder: specifier: 'catalog:' version: 26.15.6(electron-builder-squirrel-windows@26.15.6) @@ -118,8 +121,6 @@ importers: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) - apps/flutter: {} - apps/mobile: dependencies: '@capacitor/core': @@ -131,7 +132,7 @@ importers: version: 8.4.1(@capacitor/core@8.4.1) '@capacitor/cli': specifier: 8.4.1 - version: 8.4.1 + version: 8.4.1(supports-color@10.2.2) apps/site: dependencies: @@ -177,7 +178,7 @@ importers: version: 5.2.8 '@legendapp/list': specifier: 3.2.0 - version: 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.2.0(patch_hash=6049699ee3ebabe70c73c0a64950edbdf985bfb6c98d66f7722cd592b58db63d)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) @@ -273,6 +274,9 @@ importers: '@t4-code/host-wire': specifier: workspace:* version: link:../host-wire + '@t4-code/t4-api-client': + specifier: workspace:* + version: link:../t4-api-client '@types/node': specifier: 'catalog:' version: 24.12.4 @@ -436,6 +440,31 @@ importers: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + packages/t4-api-client: + dependencies: + openapi-fetch: + specifier: 0.17.0 + version: 0.17.0 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.12.4 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vite-plus: + specifier: 'catalog:' + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + + packages/t4-api-contract: + devDependencies: + '@readme/openapi-parser': + specifier: 6.3.0 + version: 6.3.0(openapi-types@12.1.3) + openapi-typescript: + specifier: 7.13.0 + version: 7.13.0(typescript@6.0.3) + packages/ui: dependencies: '@base-ui/react': @@ -480,6 +509,12 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@apidevtools/json-schema-ref-parser@14.2.1': + resolution: {integrity: sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==} + engines: {node: '>= 20'} + peerDependencies: + '@types/json-schema': ^7.0.15 + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -602,6 +637,10 @@ packages: '@fontsource/jetbrains-mono@5.2.8': resolution: {integrity: sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==} + '@humanwhocodes/momoa@2.0.4': + resolution: {integrity: sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==} + engines: {node: '>=10.10.0'} + '@ionic/cli-framework-output@2.2.8': resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==} engines: {node: '>=16.0.0'} @@ -1066,6 +1105,32 @@ packages: '@preact/signals-core@1.14.4': resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} + '@readme/better-ajv-errors@2.4.0': + resolution: {integrity: sha512-9WODaOAKSl/mU+MYNZ2aHCrkoRSvmQ+1YkLj589OEqqjOAhbn8j7Z+ilYoiTu/he6X63/clsxxAB4qny9/dDzg==} + engines: {node: '>=18'} + peerDependencies: + ajv: 4.11.8 - 8 + + '@readme/openapi-parser@6.3.0': + resolution: {integrity: sha512-pLsspfvN/m6C4BuZ91ZppK2uo86IT4EDhXu6B+giNLehLkuZbtg47OJvOktwDHBmheULPmXg5pzM7065AAHeyQ==} + engines: {node: '>=20'} + peerDependencies: + openapi-types: '>=7' + + '@readme/openapi-schemas@4.0.0': + resolution: {integrity: sha512-hrG//9/+RpOZTrUDirU4OzfMqaxCdY5BMpr3YuLf3Rje9rfNsydQJsH9iCPTie9ZRu9xe+0OUD4fHe0JmGwa2w==} + engines: {node: '>=20'} + + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.17': + resolution: {integrity: sha512-wsV2keCt6B806XpSdezbWZ9aFJYf14YVh+XQf0ESt7M90yqVuxH9//PxvtC70sgj9OCkRM3nRaLfu4MsGQZRig==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} @@ -1244,6 +1309,9 @@ packages: '@types/http-cache-semantics@4.2.0': resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} @@ -1514,6 +1582,14 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -1525,6 +1601,10 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1665,6 +1745,9 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -1713,6 +1796,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -2135,6 +2221,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -2220,9 +2310,17 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + js-yaml@4.3.0: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true @@ -2250,6 +2348,10 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2264,6 +2366,10 @@ packages: lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + lexical@0.41.0: resolution: {integrity: sha512-pNIm5+n+hVnJHB9gYPDYsIO5Y59dNaDU9rJmPPsfqQhP2ojKFnUoPbcRnrI9FJLXB14sSumcY8LUw7Sq70TZqA==} @@ -2649,6 +2755,21 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + openapi-fetch@0.17.0: + resolution: {integrity: sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==} + + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + + openapi-typescript-helpers@0.1.0: + resolution: {integrity: sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==} + + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + oxfmt@0.57.0: resolution: {integrity: sha512-ZB7Bi+rGDSqmVIo9jwcLyFgjxXvQhDdU+jx+ZrVy6VRiVXK2+CHc4hO3J4dUQjHe7V0ymHB+MDuv5z+NhK07HA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2705,6 +2826,10 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -2763,6 +2888,10 @@ packages: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -3057,6 +3186,10 @@ packages: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -3148,6 +3281,10 @@ packages: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -3196,6 +3333,9 @@ packages: unzipper@0.12.5: resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -3350,6 +3490,9 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -3399,6 +3542,11 @@ snapshots: dependencies: zod: 4.4.3 + '@apidevtools/json-schema-ref-parser@14.2.1(@types/json-schema@7.0.15)': + dependencies: + '@types/json-schema': 7.0.15 + js-yaml: 4.3.0 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -3438,17 +3586,17 @@ snapshots: dependencies: '@capacitor/core': 8.4.1 - '@capacitor/cli@8.4.1': + '@capacitor/cli@8.4.1(supports-color@10.2.2)': dependencies: - '@ionic/cli-framework-output': 2.2.8 - '@ionic/utils-subprocess': 3.0.1 + '@ionic/cli-framework-output': 2.2.8(supports-color@10.2.2) + '@ionic/utils-subprocess': 3.0.1(supports-color@10.2.2) '@ionic/utils-terminal': 2.3.5 commander: 12.1.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) env-paths: 2.2.1 fs-extra: 11.3.6 kleur: 4.1.5 - native-run: 2.0.3 + native-run: 2.0.3(supports-color@10.2.2) open: 8.4.2 plist: 3.1.0 prompts: 2.4.2 @@ -3476,9 +3624,9 @@ snapshots: fs-extra: 9.1.0 minimist: 1.2.8 - '@electron/get@2.0.3': + '@electron/get@2.0.3(supports-color@10.2.2)': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) env-paths: 2.2.1 fs-extra: 8.1.0 got: 11.8.6 @@ -3492,7 +3640,7 @@ snapshots: '@electron/get@3.1.0': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) env-paths: 2.2.1 fs-extra: 8.1.0 got: 11.8.6 @@ -3506,16 +3654,16 @@ snapshots: '@electron/notarize@2.5.0': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 9.1.0 promise-retry: 2.0.1 transitivePeerDependencies: - supports-color - '@electron/osx-sign@1.3.3': + '@electron/osx-sign@1.3.3(supports-color@10.2.2)': dependencies: compare-version: 0.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 10.1.0 isbinaryfile: 4.0.10 minimist: 1.2.8 @@ -3526,7 +3674,7 @@ snapshots: '@electron/rebuild@4.2.0': dependencies: '@malept/cross-spawn-promise': 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) node-abi: 4.33.0 node-api-version: 0.2.1 node-gyp: 12.4.0 @@ -3538,7 +3686,7 @@ snapshots: dependencies: '@electron/asar': 3.4.1 '@malept/cross-spawn-promise': 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) dir-compare: 4.2.0 fs-extra: 11.3.6 minimatch: 9.0.9 @@ -3549,7 +3697,7 @@ snapshots: '@electron/windows-sign@1.2.2': dependencies: cross-dirname: 0.1.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 11.3.6 minimist: 1.2.8 postject: 1.0.0-alpha.6 @@ -3586,17 +3734,19 @@ snapshots: '@fontsource/jetbrains-mono@5.2.8': {} - '@ionic/cli-framework-output@2.2.8': + '@humanwhocodes/momoa@2.0.4': {} + + '@ionic/cli-framework-output@2.2.8(supports-color@10.2.2)': dependencies: '@ionic/utils-terminal': 2.3.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-array@2.1.6': + '@ionic/utils-array@2.1.6(supports-color@10.2.2)': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -3604,46 +3754,46 @@ snapshots: '@ionic/utils-fs@3.1.7': dependencies: '@types/fs-extra': 8.1.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 9.1.0 tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-object@2.1.6': + '@ionic/utils-object@2.1.6(supports-color@10.2.2)': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-process@2.1.12': + '@ionic/utils-process@2.1.12(supports-color@10.2.2)': dependencies: - '@ionic/utils-object': 2.1.6 + '@ionic/utils-object': 2.1.6(supports-color@10.2.2) '@ionic/utils-terminal': 2.3.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) signal-exit: 3.0.7 tree-kill: 1.2.2 tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-stream@3.1.7': + '@ionic/utils-stream@3.1.7(supports-color@10.2.2)': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-subprocess@3.0.1': + '@ionic/utils-subprocess@3.0.1(supports-color@10.2.2)': dependencies: - '@ionic/utils-array': 2.1.6 + '@ionic/utils-array': 2.1.6(supports-color@10.2.2) '@ionic/utils-fs': 3.1.7 - '@ionic/utils-process': 2.1.12 - '@ionic/utils-stream': 3.1.7 + '@ionic/utils-process': 2.1.12(supports-color@10.2.2) + '@ionic/utils-stream': 3.1.7(supports-color@10.2.2) '@ionic/utils-terminal': 2.3.5 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -3651,7 +3801,7 @@ snapshots: '@ionic/utils-terminal@2.3.5': dependencies: '@types/slice-ansi': 4.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) signal-exit: 3.0.7 slice-ansi: 4.0.0 string-width: 4.2.3 @@ -3685,7 +3835,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.2.0(patch_hash=6049699ee3ebabe70c73c0a64950edbdf985bfb6c98d66f7722cd592b58db63d)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) @@ -3857,7 +4007,7 @@ snapshots: '@malept/flatpak-bundler@0.4.0': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 9.1.0 lodash: 4.18.1 tmp-promise: 3.0.3 @@ -4036,6 +4186,53 @@ snapshots: '@preact/signals-core@1.14.4': {} + '@readme/better-ajv-errors@2.4.0(ajv@8.20.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@humanwhocodes/momoa': 2.0.4 + ajv: 8.20.0 + jsonpointer: 5.0.1 + leven: 3.1.0 + picocolors: 1.1.1 + + '@readme/openapi-parser@6.3.0(openapi-types@12.1.3)': + dependencies: + '@apidevtools/json-schema-ref-parser': 14.2.1(@types/json-schema@7.0.15) + '@readme/better-ajv-errors': 2.4.0(ajv@8.20.0) + '@readme/openapi-schemas': 4.0.0 + '@types/json-schema': 7.0.15 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + openapi-types: 12.1.3 + + '@readme/openapi-schemas@4.0.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.17(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.2.0 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + '@rolldown/pluginutils@1.0.1': {} '@sindresorhus/is@4.6.0': {} @@ -4200,6 +4397,8 @@ snapshots: '@types/http-cache-semantics@4.2.0': {} + '@types/json-schema@7.0.15': {} + '@types/keyv@3.1.4': dependencies: '@types/node': 24.12.4 @@ -4448,6 +4647,10 @@ snapshots: agent-base@7.1.4: {} + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv-formats@2.1.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -4459,6 +4662,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-colors@4.1.3: {} + ansi-regex@5.0.1: {} ansi-styles@4.3.0: @@ -4473,7 +4678,7 @@ snapshots: '@electron/fuses': 1.8.0 '@electron/get': 3.1.0 '@electron/notarize': 2.5.0 - '@electron/osx-sign': 1.3.3 + '@electron/osx-sign': 1.3.3(supports-color@10.2.2) '@electron/rebuild': 4.2.0 '@electron/universal': 2.0.3 '@malept/flatpak-bundler': 0.4.0 @@ -4487,7 +4692,7 @@ snapshots: builder-util-runtime: 9.7.0 chromium-pickle-js: 0.2.0 ci-info: 4.3.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) dmg-builder: 26.15.6(electron-builder-squirrel-windows@26.15.6) dotenv: 16.6.1 dotenv-expand: 11.0.7 @@ -4581,7 +4786,7 @@ snapshots: builder-util-runtime@9.7.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) sax: 1.6.0 transitivePeerDependencies: - supports-color @@ -4592,10 +4797,10 @@ snapshots: builder-util-runtime: 9.7.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 10.1.0 http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.2.2) js-yaml: 4.3.0 sanitize-filename: 1.6.4 source-map-support: 0.5.21 @@ -4637,6 +4842,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + change-case@5.4.4: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -4675,6 +4882,8 @@ snapshots: color-name@1.1.4: {} + colorette@1.4.0: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -4726,9 +4935,11 @@ snapshots: dependencies: mimic-fn: 3.1.0 - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 decode-named-character-reference@1.3.0: dependencies: @@ -4870,7 +5081,7 @@ snapshots: electron-winstaller@5.4.0: dependencies: '@electron/asar': 3.4.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 7.0.1 lodash: 4.18.1 temp: 0.9.4 @@ -4879,11 +5090,11 @@ snapshots: transitivePeerDependencies: - supports-color - electron@41.5.0: + electron@41.5.0(supports-color@10.2.2): dependencies: - '@electron/get': 2.0.3 + '@electron/get': 2.0.3(supports-color@10.2.2) '@types/node': 24.12.4 - extract-zip: 2.0.1 + extract-zip: 2.0.1(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -4947,9 +5158,9 @@ snapshots: extend@3.0.2: {} - extract-zip@2.0.1: + extract-zip@2.0.1(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -5214,7 +5425,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -5223,13 +5434,15 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color + index-to-position@1.2.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -5288,8 +5501,14 @@ snapshots: jiti@2.7.0: {} + js-levenshtein@1.1.6: {} + js-tokens@4.0.0: {} + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -5315,6 +5534,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonpointer@5.0.1: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -5325,6 +5546,8 @@ snapshots: lazy-val@1.0.5: {} + leven@3.1.0: {} + lexical@0.41.0: {} lib0@0.2.117: @@ -5750,7 +5973,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.13 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -5819,12 +6042,12 @@ snapshots: nanoid@3.3.15: {} - native-run@2.0.3: + native-run@2.0.3(supports-color@10.2.2): dependencies: '@ionic/utils-fs': 3.1.7 '@ionic/utils-terminal': 2.3.5 bplist-parser: 0.3.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) elementtree: 0.1.7 ini: 4.1.3 plist: 3.1.0 @@ -5883,6 +6106,24 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openapi-fetch@0.17.0: + dependencies: + openapi-typescript-helpers: 0.1.0 + + openapi-types@12.1.3: {} + + openapi-typescript-helpers@0.1.0: {} + + openapi-typescript@7.13.0(typescript@6.0.3): + dependencies: + '@redocly/openapi-core': 1.34.17(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 6.0.3 + yargs-parser: 21.1.1 + oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)): dependencies: tinypool: 2.1.0 @@ -6018,6 +6259,12 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.7 + index-to-position: 1.2.0 + type-fest: 4.41.0 + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -6070,6 +6317,8 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + pluralize@8.0.0: {} + pngjs@7.0.0: {} postcss@8.5.16: @@ -6165,7 +6414,7 @@ snapshots: read-binary-file-arch@1.0.6: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -6386,10 +6635,12 @@ snapshots: sumchecker@3.0.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color + supports-color@10.2.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -6468,6 +6719,8 @@ snapshots: type-fest@2.19.0: {} + type-fest@4.41.0: {} + typescript@6.0.3: {} undici-types@7.16.0: {} @@ -6523,6 +6776,8 @@ snapshots: graceful-fs: 4.2.11 node-int64: 0.4.0 + uri-js-replace@1.0.1: {} + use-sync-external-store@1.6.0(react@19.2.6): dependencies: react: 19.2.6 @@ -6772,6 +7027,8 @@ snapshots: yallist@5.0.0: {} + yaml-ast-parser@0.0.43: {} + yargs-parser@21.1.1: {} yargs@17.7.3: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b77f9296..17e5aab4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,6 +24,9 @@ catalog: overrides: vite: "catalog:" +patchedDependencies: + "@legendapp/list@3.2.0": patches/@legendapp__list@3.2.0.patch + packageExtensions: vite-plus@*: dependencies: diff --git a/provenance/t3code/imports/f0-20260711.json b/provenance/t3code/imports/f0-20260711.json index a8946995..4447440d 100644 --- a/provenance/t3code/imports/f0-20260711.json +++ b/provenance/t3code/imports/f0-20260711.json @@ -73,7 +73,7 @@ "sourceBlobSha": "d0e21b3907eebffbb328e72927d35a2e9fdc9dfe", "targetPath": "packages/ui/src/motion/AnimatedHeight.tsx", "classification": "adapted", - "checksum": "sha256:ddc7624084d802d9d2dbdb1c325148a65dd6597da53eb1d5054f11d98f2a2707" + "checksum": "sha256:4d9fd5c20a5eeed59c3720110d40defa56034f1763fad5aa898d938f9b23f34f" }, { "sourcePath": "apps/web/src/hooks/useResizableWidth.ts", diff --git a/provenance/t3code/imports/f1-shell-20260711.json b/provenance/t3code/imports/f1-shell-20260711.json index 66994d29..a8b926e9 100644 --- a/provenance/t3code/imports/f1-shell-20260711.json +++ b/provenance/t3code/imports/f1-shell-20260711.json @@ -51,7 +51,7 @@ "sourceBlobSha": "4e7614ed55161f50f756184251c80cf9dfb16ea2", "targetPath": "apps/web/src/lib/session-tree.ts", "classification": "reference-only", - "checksum": "sha256:1fedb20c636999cf99e5b3332f1c2c6dd62fb1be096bf0864b8d83052f64997c" + "checksum": "sha256:e1ebcd3fd1d2ac17f7c4172941d30cf0bcd80a15b248890cc08e50599ebdbafd" } ] } diff --git a/provenance/t3code/imports/f2-transcript-20260711.json b/provenance/t3code/imports/f2-transcript-20260711.json index 3e7a1900..22190ce1 100644 --- a/provenance/t3code/imports/f2-transcript-20260711.json +++ b/provenance/t3code/imports/f2-transcript-20260711.json @@ -9,7 +9,7 @@ "sourceBlobSha": "1a4dc6b689558a9f8d834c12d7ecb2bb64e0d7c8", "targetPath": "apps/web/src/features/transcript/TranscriptTimeline.tsx", "classification": "adapted", - "checksum": "sha256:37f420901e069080143ea4203ad1b9e32fab091c947ceea1fd6cb4beabf2789f" + "checksum": "sha256:cfa923c1aa4b8e2ef920f4ef9adf00c0c19e806174078a2d9c35255a5aa67c85" }, { "sourcePath": "apps/web/src/components/chat/MessagesTimeline.tsx", diff --git a/provenance/t3code/imports/f3-panes-20260711.json b/provenance/t3code/imports/f3-panes-20260711.json index 7009dabf..a0bc8a8c 100644 --- a/provenance/t3code/imports/f3-panes-20260711.json +++ b/provenance/t3code/imports/f3-panes-20260711.json @@ -9,7 +9,7 @@ "sourceBlobSha": "1641bb6b109b44f2c7b0f2576fcf0750a96da88f", "targetPath": "apps/web/src/features/terminal/TerminalViewport.tsx", "classification": "adapted", - "checksum": "sha256:40eeb5fc91c99b1f3f0c221037f6056f703837181f3cf5cd95b3e9110fac61a7" + "checksum": "sha256:5dd04d78d1d58b546f2c8ca67d269cee27a03b2ea3182f249b7692ae2b4229ac" }, { "sourcePath": "apps/web/src/components/ThreadTerminalDrawer.tsx", diff --git a/scripts/build-demo.mjs b/scripts/build-demo.mjs index 76b188a1..c8833cff 100644 --- a/scripts/build-demo.mjs +++ b/scripts/build-demo.mjs @@ -1,5 +1,4 @@ import { spawnSync } from "node:child_process"; -import { rmSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -20,25 +19,9 @@ export function buildDemo( const output = resolve(repoRoot, "apps/site/dist/demo"); runCommand( "pnpm", - [ - "--filter", - "@t4-code/flutter", - "exec", - "flutter", - "build", - "web", - "--base-href", - DEMO_BASE_HREF, - "--csp", - "--no-web-resources-cdn", - "--dart-define", - "T4_DEMO_MODE=true", - "--output", - output, - ], + ["--filter", "@t4-code/web", "exec", "vp", "build", "--mode", "demo", "--outDir", output], repoRoot, ); - rmSync(resolve(output, "flutter_service_worker.js"), { force: true }); } const isMain = diff --git a/scripts/check-flutter-coverage.mjs b/scripts/check-flutter-coverage.mjs deleted file mode 100644 index 67a7b905..00000000 --- a/scripts/check-flutter-coverage.mjs +++ /dev/null @@ -1,69 +0,0 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; - -const DEFAULT_REPORT = resolve(import.meta.dirname, "../apps/flutter/coverage/lcov.info"); -const DEFAULT_MINIMUM_PERCENT = 65; - -function boundedPercentage(value, label) { - const parsed = Number(value); - if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) { - throw new Error(`${label} must be a finite percentage between 0 and 100`); - } - return parsed; -} - -export function flutterLineCoverage(lcov) { - if (typeof lcov !== "string" || lcov.length === 0) throw new Error("LCOV report is empty"); - const files = new Map(); - let source; - for (const rawLine of lcov.split(/\r?\n/u)) { - if (rawLine.startsWith("SF:")) { - source = rawLine.slice(3); - if (source.length === 0) throw new Error("LCOV source path is empty"); - if (!files.has(source)) files.set(source, new Map()); - continue; - } - if (!rawLine.startsWith("DA:")) continue; - if (source === undefined) throw new Error("LCOV line data appeared before a source file"); - const match = /^DA:(\d+),(\d+)(?:,.*)?$/u.exec(rawLine); - if (match === null) throw new Error(`Malformed LCOV line data: ${rawLine}`); - const line = Number(match[1]); - const hits = Number(match[2]); - if (!Number.isSafeInteger(line) || line <= 0 || !Number.isSafeInteger(hits) || hits < 0) { - throw new Error(`Invalid LCOV line data: ${rawLine}`); - } - const lines = files.get(source); - lines.set(line, Math.max(lines.get(line) ?? 0, hits)); - } - let found = 0; - let covered = 0; - for (const lines of files.values()) { - found += lines.size; - for (const hits of lines.values()) if (hits > 0) covered += 1; - } - if (found === 0) throw new Error("LCOV report contains no executable lines"); - return { covered, found, percent: (covered / found) * 100 }; -} - -export function requireFlutterLineCoverage(lcov, minimumPercent = DEFAULT_MINIMUM_PERCENT) { - const minimum = boundedPercentage(minimumPercent, "minimum coverage"); - const result = flutterLineCoverage(lcov); - if (result.percent + Number.EPSILON < minimum) { - throw new Error( - `Flutter line coverage ${result.percent.toFixed(2)}% (${result.covered}/${result.found}) is below ${minimum.toFixed(2)}%`, - ); - } - return result; -} - -if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { - const report = process.argv[2] ?? DEFAULT_REPORT; - const minimum = process.argv[3] ?? DEFAULT_MINIMUM_PERCENT; - try { - const result = requireFlutterLineCoverage(readFileSync(report, "utf8"), minimum); - console.log(`Flutter line coverage ${result.percent.toFixed(2)}% (${result.covered}/${result.found})`); - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; - } -} diff --git a/scripts/check-flutter-coverage.test.mjs b/scripts/check-flutter-coverage.test.mjs deleted file mode 100644 index 08569f3f..00000000 --- a/scripts/check-flutter-coverage.test.mjs +++ /dev/null @@ -1,36 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { flutterLineCoverage, requireFlutterLineCoverage } from "./check-flutter-coverage.mjs"; - -const REPORT = `TN: -SF:lib/a.dart -DA:1,1 -DA:2,0 -end_of_record -SF:lib/b.dart -DA:4,3 -DA:5,0 -end_of_record -`; - -test("computes line coverage across source records", () => { - assert.deepEqual(flutterLineCoverage(REPORT), { covered: 2, found: 4, percent: 50 }); -}); - -test("deduplicates repeated source lines using the highest hit count", () => { - const repeated = `${REPORT}SF:lib/a.dart\nDA:1,0\nDA:2,2\nend_of_record\n`; - assert.deepEqual(flutterLineCoverage(repeated), { covered: 3, found: 4, percent: 75 }); -}); - -test("enforces the configured minimum", () => { - assert.equal(requireFlutterLineCoverage(REPORT, 50).percent, 50); - assert.throws(() => requireFlutterLineCoverage(REPORT, 50.01), /below 50\.01%/u); -}); - -test("rejects empty and malformed reports", () => { - assert.throws(() => flutterLineCoverage(""), /empty/u); - assert.throws(() => flutterLineCoverage("DA:1,1\n"), /before a source/u); - assert.throws(() => flutterLineCoverage("SF:lib/a.dart\nDA:nope\n"), /Malformed/u); - assert.throws(() => flutterLineCoverage("SF:lib/a.dart\n"), /no executable lines/u); -}); diff --git a/scripts/check-host-ownership.test.mjs b/scripts/check-host-ownership.test.mjs index b1d11555..5a6452a6 100644 --- a/scripts/check-host-ownership.test.mjs +++ b/scripts/check-host-ownership.test.mjs @@ -37,7 +37,8 @@ test("product and ownership documents link the canonical architecture", () => { const ownership = readFileSync(join(root, "docs", "OWNERSHIP.md"), "utf8"); const architecture = readFileSync(join(root, "docs", "T4_ARCHITECTURE.html"), "utf8"); - assert.match(brief, /Flutter desktop, mobile, and web workspace/u); + assert.match(brief, /Electron desktop workspace/u); + assert.match(brief, /React compatibility client/u); assert.match(brief, /docs\/T4_ARCHITECTURE\.html/u); assert.doesNotMatch(brief, /packages\/host-service/u); assert.match(ownership, /packages\/host-wire/u); @@ -67,6 +68,14 @@ test("compatibility metadata records the artifact-backed OMP bridge", () => { const matrix = json("compat/omp-app-matrix.json"); const provenance = json("provenance/omp-host-migration.json"); + assert.equal(provenance.sourceRepository, "https://github.com/lyc-aon/oh-my-pi"); + assert.deepEqual(provenance.inputs, { + t4codeBase: "09835b929cd028e7e3f800b3e4203e3d1f37931c", + operationsContinuity: "08504b1281f01d8fb81e27306f7d3f6e6c29c4a6", + artifactAndTurnReview: "796bb7dca4f9c0ebba98bafc37dc67359bb6ea39", + runtimeAndWorkspaceAdapters: "6ce1d41b35db9a5feaa4743f4a3200d9a8f9ae61", + }); + assert.deepEqual(matrix.t4Host.sourcePaths, [ "packages/host-wire", "packages/host-service", @@ -76,7 +85,7 @@ test("compatibility metadata records the artifact-backed OMP bridge", () => { assert.equal(matrix.t4Host.deploymentState, "standalone-t4-host-thin-omp-bridge"); assert.equal(matrix.t4Host.wireSchemaVersion, "0.7.0"); assert.equal(matrix.t4Host.daemonPackage, "@t4-code/host-daemon"); - assert.equal(matrix.t4Host.daemonPackageVersion, "0.1.30"); + assert.equal(matrix.t4Host.daemonPackageVersion, "0.1.31"); assert.equal(matrix.t4Host.authorityBridgeProtocol, "t4-omp-authority/1"); assert.equal(matrix.verifiedRuntime.artifacts["darwin-arm64"].releaseCodeSignature, "adhoc"); assert.equal( diff --git a/scripts/check-release-consistency.mjs b/scripts/check-release-consistency.mjs index 7694fc50..74f61f0e 100644 --- a/scripts/check-release-consistency.mjs +++ b/scripts/check-release-consistency.mjs @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { load as parseYaml } from "js-yaml"; export const RELEASE_CONTRACT_PATHS = [ + ".woodpecker.yml", ".github/android-release-identity.json", ".github/macos-release-identity.json", ".github/ISSUE_TEMPLATE/bug_report.yml", @@ -26,6 +27,7 @@ export const RELEASE_CONTRACT_PATHS = [ "docs/RELEASE_GATE.md", "ops/t4-maintainer/README.md", "packages/client/src/omp-client-frames.ts", + "provenance/omp-host-migration.json", "scripts/check-release-publication.mjs", "scripts/deploy-site.mjs", "scripts/dispatch-site-deployment.mjs", @@ -39,22 +41,21 @@ export const RELEASE_CONTRACT_PATHS = [ ]; const REPOSITORY_URL = "https://github.com/LycaonLLC/t4-code"; -const OMP_RUNTIME_REPOSITORY = "https://github.com/lyc-aon/oh-my-pi"; +const OMP_RUNTIME_REPOSITORY = "https://github.com/wolfiesch/oh-my-pi"; +const OMP_APP_WIRE_SOURCE_REPOSITORY = "https://github.com/lyc-aon/oh-my-pi"; const OMP_UPSTREAM_REPOSITORY = "https://github.com/can1357/oh-my-pi"; +const OMP_HOST_MIGRATION_SOURCE_REPOSITORY = "https://github.com/lyc-aon/oh-my-pi"; +const OMP_HOST_MIGRATION_INPUTS = { + t4codeBase: "09835b929cd028e7e3f800b3e4203e3d1f37931c", + operationsContinuity: "08504b1281f01d8fb81e27306f7d3f6e6c29c4a6", + artifactAndTurnReview: "796bb7dca4f9c0ebba98bafc37dc67359bb6ea39", + runtimeAndWorkspaceAdapters: "6ce1d41b35db9a5feaa4743f4a3200d9a8f9ae61", +}; const VERSION_PATTERN = /^\d+\.\d+\.\d+$/u; const SHA_PATTERN = /^[0-9a-f]{40}$/u; const SHA256_PATTERN = /^[0-9a-f]{64}$/u; const PATCH_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; -function compareStableVersions(left, right) { - const leftParts = left.split(".").map(Number); - const rightParts = right.split(".").map(Number); - for (let index = 0; index < 3; index += 1) { - if (leftParts[index] !== rightParts[index]) return leftParts[index] - rightParts[index]; - } - return 0; -} - export function expectedReleaseAssetNames(version) { return [ `T4-Code-${version}-android.apk`, @@ -333,6 +334,7 @@ function validateOfficialGate0Snapshot(snapshot, officialRuntime, path, errors) "follow-up", "approval", "cancellation", + "large-rpc-payload", "crash-after-dispatch-no-replay", ]; if (!isDeepStrictEqual(snapshot?.requiredScenarios, requiredScenarios)) { @@ -483,6 +485,16 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { const matrixPath = "compat/omp-app-matrix.json"; const matrix = parseJson(files, matrixPath, errors); + const hostMigrationPath = "provenance/omp-host-migration.json"; + const hostMigration = parseJson(files, hostMigrationPath, errors); + if (hostMigration?.sourceRepository !== OMP_HOST_MIGRATION_SOURCE_REPOSITORY) { + errors.push( + `${hostMigrationPath} source repository must remain ${OMP_HOST_MIGRATION_SOURCE_REPOSITORY}`, + ); + } + if (!isDeepStrictEqual(hostMigration?.inputs, OMP_HOST_MIGRATION_INPUTS)) { + errors.push(`${hostMigrationPath} migration inputs must remain the frozen reviewed commits`); + } validateOfficialRuntimeMetadata(matrix?.officialRuntime, matrixPath, errors); const officialGatePath = "compat/official-omp-gate0.json"; const officialGate = parseJson(files, officialGatePath, errors); @@ -510,8 +522,8 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { if (typeof appWireVersion !== "string" || !VERSION_PATTERN.test(appWireVersion)) { errors.push(`${matrixPath} app-wire version must be a stable x.y.z version`); } - if (appWire?.sourceRepository !== OMP_RUNTIME_REPOSITORY) { - errors.push(`${matrixPath} app-wire repository must be ${OMP_RUNTIME_REPOSITORY}`); + if (appWire?.sourceRepository !== OMP_APP_WIRE_SOURCE_REPOSITORY) { + errors.push(`${matrixPath} app-wire repository must be ${OMP_APP_WIRE_SOURCE_REPOSITORY}`); } if (!SHA_PATTERN.test(appWireSourceCommit)) { errors.push(`${matrixPath} app-wire commit must be a lowercase 40-character Git SHA`); @@ -528,8 +540,8 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { ) { errors.push(`${matrixPath} published app-wire version must be a stable x.y.z version`); } - if (publishedAppWire?.sourceRepository !== OMP_RUNTIME_REPOSITORY) { - errors.push(`${matrixPath} published app-wire repository must be ${OMP_RUNTIME_REPOSITORY}`); + if (publishedAppWire?.sourceRepository !== OMP_APP_WIRE_SOURCE_REPOSITORY) { + errors.push(`${matrixPath} published app-wire repository must be ${OMP_APP_WIRE_SOURCE_REPOSITORY}`); } if (!SHA_PATTERN.test(publishedAppWireSourceCommit)) { errors.push(`${matrixPath} published app-wire commit must be a lowercase 40-character Git SHA`); @@ -730,7 +742,7 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { ); requireText( site, - "export const OMP_RUNTIME_URL = `https://github.com/lyc-aon/oh-my-pi/tree/${OMP_RUNTIME_TAG}`;", + "export const OMP_RUNTIME_URL = `https://github.com/wolfiesch/oh-my-pi/tree/${OMP_RUNTIME_TAG}`;", "apps/site/src/release.ts", errors, ); @@ -783,7 +795,7 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { ); requireText( readme, - `T4 Code vendors \`@oh-my-pi/app-wire\` ${publishedAppWireVersion} from integration commit [\`${publishedAppWireSourceCommit.slice(0, 8)}\`](${OMP_RUNTIME_REPOSITORY}/commit/${publishedAppWireSourceCommit}), source tree \`${publishedAppWireSourceTree}\`.`, + `T4 Code vendors \`@oh-my-pi/app-wire\` ${publishedAppWireVersion} from integration commit [\`${publishedAppWireSourceCommit.slice(0, 8)}\`](${OMP_APP_WIRE_SOURCE_REPOSITORY}/commit/${publishedAppWireSourceCommit}), source tree \`${publishedAppWireSourceTree}\`.`, "README.md", errors, ); @@ -812,7 +824,7 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { const releaseNotes = files.get("docs/CURRENT_RELEASE_NOTES.md") ?? ""; for (const expected of [ `app-wire ${publishedAppWireVersion}`, - `[${publishedAppWireSourceCommit.slice(0, 8)}](${OMP_RUNTIME_REPOSITORY}/commit/${publishedAppWireSourceCommit})`, + `[${publishedAppWireSourceCommit.slice(0, 8)}](${OMP_APP_WIRE_SOURCE_REPOSITORY}/commit/${publishedAppWireSourceCommit})`, `OMP ${ompRuntimeVersion}`, `[${String(ompRuntimeCommit).slice(0, 8)}](${ompRuntimeCommitUrl})`, `[${ompRuntimeSourceTag}](${ompRuntimeSourceUrl})`, @@ -823,15 +835,9 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { } const securityPolicy = files.get("SECURITY.md") ?? ""; - const firstSignedVersion = String(macosIdentity?.firstSignedReleaseTag ?? "").replace(/^v/u, ""); - const signedRelease = VERSION_PATTERN.test(firstSignedVersion) - ? compareStableVersions(version, firstSignedVersion) >= 0 - : false; requireText( securityPolicy, - signedRelease - ? `The macOS ${expectedTag} build is signed with Apple Developer ID and notarized by Apple` - : `The macOS ${expectedTag} build is unsigned and unnotarized`, + "Published macOS builds are signed with Apple Developer ID and notarized by Apple", "SECURITY.md", errors, ); @@ -878,6 +884,24 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { const releaseWorkflow = files.get(".github/workflows/release.yml") ?? ""; const ciWorkflow = files.get(".github/workflows/ci.yml") ?? ""; + const woodpeckerWorkflow = files.get(".woodpecker.yml") ?? ""; + requireText( + woodpeckerWorkflow, + "https://github.com/wolfiesch/oh-my-pi.git", + ".woodpecker.yml", + errors, + ); + for (const expected of [ + `git -C .current-continuity/omp fetch --depth=1 origin ${ompRuntimeCommit}`, + `test "$(git -C .current-continuity/omp rev-parse HEAD)" = ${ompRuntimeCommit}`, + "T4_CURRENT_OMP_SOURCE_DIR: .current-continuity/omp", + "pnpm --filter @t4-code/host-service verify:current-omp-bridge", + ]) { + requireText(woodpeckerWorkflow, expected, ".woodpecker.yml", errors); + } + if (woodpeckerWorkflow.includes("https://github.com/lyc-aon/oh-my-pi.git")) { + errors.push(".woodpecker.yml must not use the retired Lycaon OMP integration fork"); + } try { const workflow = parseYaml(ciWorkflow); const continuityJob = workflow?.jobs?.["legacy-bridge-continuity"]; @@ -897,11 +921,11 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { const continuityStep = namedStep("Run legacy bridge continuity gate"); const uploadStep = namedStep("Upload continuity evidence"); const authorityCommands = [ - `source_repository="$(jq -er '.sourceRepository' provenance/omp-host-migration.json)"`, - `test "$source_repository" = "https://github.com/lyc-aon/oh-my-pi"`, + `source_repository="$(jq -er '.verifiedRuntime.sourceRepository' compat/omp-app-matrix.json)"`, + `test "$source_repository" = "https://github.com/wolfiesch/oh-my-pi"`, `sha="$(jq -er '.inputs.operationsContinuity' provenance/omp-host-migration.json)"`, '[[ "$sha" =~ ^[0-9a-f]{40}$ ]]', - `echo "repository=lyc-aon/oh-my-pi" >> "$GITHUB_OUTPUT"`, + `echo "repository=wolfiesch/oh-my-pi" >> "$GITHUB_OUTPUT"`, `echo "sha=$sha" >> "$GITHUB_OUTPUT"`, ]; for (const command of authorityCommands) { @@ -925,6 +949,59 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { ) errors.push(".github/workflows/ci.yml continuity evidence upload is not fail-closed"); } + const currentJob = workflow?.jobs?.["current-bridge-continuity"]; + if (!currentJob || !Array.isArray(currentJob.steps)) { + errors.push(".github/workflows/ci.yml is missing the current-bridge-continuity job"); + } else { + const namedCurrentStep = (name) => { + const matches = currentJob.steps.filter((step) => step?.name === name); + if (matches.length !== 1) { + errors.push(`.github/workflows/ci.yml must contain exactly one ${JSON.stringify(name)} step`); + return undefined; + } + return matches[0]; + }; + const authorityStep = namedCurrentStep("Resolve current OMP authority source"); + const checkoutStep = namedCurrentStep("Check out current OMP authority source"); + const sourceTestStep = namedCurrentStep("Run current OMP authority source tests"); + const proofStep = namedCurrentStep("Run current bridge compatibility proof"); + const uploadStep = namedCurrentStep("Upload current bridge evidence"); + for (const command of [ + `source_repository="$(jq -er '.verifiedRuntime.sourceRepository' compat/omp-app-matrix.json)"`, + `test "$source_repository" = "https://github.com/wolfiesch/oh-my-pi"`, + `sha="$(jq -er '.verifiedRuntime.sourceCommit' compat/omp-app-matrix.json)"`, + '[[ "$sha" =~ ^[0-9a-f]{40}$ ]]', + `echo "repository=wolfiesch/oh-my-pi" >> "$GITHUB_OUTPUT"`, + `echo "sha=$sha" >> "$GITHUB_OUTPUT"`, + ]) { + if (!authorityStep?.run?.includes(command)) + errors.push(`.github/workflows/ci.yml current authority step is missing ${JSON.stringify(command)}`); + } + if (checkoutStep?.with?.repository !== "${{ steps.current-authority.outputs.repository }}") + errors.push(".github/workflows/ci.yml current checkout must use the validated repository output"); + if (checkoutStep?.with?.ref !== "${{ steps.current-authority.outputs.sha }}") + errors.push(".github/workflows/ci.yml current checkout must use the validated SHA output"); + if (checkoutStep?.with?.path !== ".current-continuity/omp") + errors.push(".github/workflows/ci.yml current checkout must use .current-continuity/omp"); + if ( + sourceTestStep?.["working-directory"] !== ".current-continuity/omp" || + sourceTestStep?.run !== + "bun test packages/coding-agent/test/appserver-bridge.test.ts packages/coding-agent/test/appserver-session-lifecycle.test.ts" + ) + errors.push(".github/workflows/ci.yml current authority source tests are incomplete"); + if ( + proofStep?.env?.T4_CURRENT_OMP_SOURCE_DIR !== + "${{ github.workspace }}/.current-continuity/omp" || + proofStep?.run !== "pnpm --filter @t4-code/host-service verify:current-omp-bridge" + ) + errors.push(".github/workflows/ci.yml current bridge proof must target the checked-out current source"); + if ( + uploadStep?.if !== "${{ success() }}" || + uploadStep?.with?.path !== "artifacts/current-omp-bridge/" || + uploadStep?.with?.["if-no-files-found"] !== "error" + ) + errors.push(".github/workflows/ci.yml current bridge evidence upload is not fail-closed"); + } } catch (error) { errors.push(`.github/workflows/ci.yml is invalid YAML: ${error instanceof Error ? error.message : error}`); } @@ -933,17 +1010,24 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { "core:", "legacy-bridge-continuity:", 'ref: ${{ github.event.pull_request.head.sha || github.sha }}', - `source_repository="$(jq -er '.sourceRepository' provenance/omp-host-migration.json)"`, - `test "$source_repository" = "https://github.com/lyc-aon/oh-my-pi"`, + `source_repository="$(jq -er '.verifiedRuntime.sourceRepository' compat/omp-app-matrix.json)"`, + `test "$source_repository" = "https://github.com/wolfiesch/oh-my-pi"`, `sha="$(jq -er '.inputs.operationsContinuity' provenance/omp-host-migration.json)"`, '[[ "$sha" =~ ^[0-9a-f]{40}$ ]]', - `echo "repository=lyc-aon/oh-my-pi" >> "$GITHUB_OUTPUT"`, + `echo "repository=wolfiesch/oh-my-pi" >> "$GITHUB_OUTPUT"`, "repository: ${{ steps.authority.outputs.repository }}", "ref: ${{ steps.authority.outputs.sha }}", "T4_OMP_SOURCE_DIR: ${{ github.workspace }}/.continuity/omp", "run: pnpm test:legacy-bridge-continuity", "path: artifacts/legacy-bridge-continuity/", "if-no-files-found: error", + "current-bridge-continuity:", + `sha="$(jq -er '.verifiedRuntime.sourceCommit' compat/omp-app-matrix.json)"`, + "repository: ${{ steps.current-authority.outputs.repository }}", + "ref: ${{ steps.current-authority.outputs.sha }}", + "T4_CURRENT_OMP_SOURCE_DIR: ${{ github.workspace }}/.current-continuity/omp", + "run: pnpm --filter @t4-code/host-service verify:current-omp-bridge", + "path: artifacts/current-omp-bridge/", "official-omp-gate0:", "runner: ubuntu-24.04-arm", "run: pnpm --filter @t4-code/host-service verify:official-omp-lifecycle", @@ -957,20 +1041,14 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { "run: go test ./...", "run: helm lint deploy/charts/t4-cluster", "android-debug:", - "flutter:", - "flutter-android:", - "flutter-apple:", - "Run Flutter iOS launch smoke test", - 'xcrun simctl install "$DEVICE_ID" build/ios/iphonesimulator/Runner.app', - 'kill -0 "$app_pid"', - "Build standalone T4 host for Flutter macOS", - "Verify bundled Flutter macOS host", - "test -x apps/flutter/build/macos/Build/Products/Debug/t4code.app/Contents/Resources/runtime/t4-host", "name: verify", "if: ${{ always() }}", - "needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple]", + "needs: [changes, t4-api-generation, core, legacy-bridge-continuity, current-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug]", 'test "$CHANGES_RESULT" = success', + 'test "$T4_API_GENERATION_RESULT" = success', 'test "$CORE_RESULT" = success', + "CURRENT_CONTINUITY_RESULT: ${{ needs.current-bridge-continuity.result }}", + '"$CURRENT_CONTINUITY_RESULT" \\', "for result in \\", "success|skipped) ;;", "github.event_name == 'pull_request' && github.ref || github.sha", diff --git a/scripts/check-release-consistency.test.mjs b/scripts/check-release-consistency.test.mjs index b049aba9..a55bf3d5 100644 --- a/scripts/check-release-consistency.test.mjs +++ b/scripts/check-release-consistency.test.mjs @@ -87,11 +87,58 @@ test("rejects duplicate keys in JSON release contracts", () => { test("promotes the verified runtime into the product release", () => { const matrix = JSON.parse(files.get("compat/omp-app-matrix.json")); - assert.equal(matrix.verifiedRuntime.sourceTag, "t4code-17.0.5-appserver-10"); - assert.equal(matrix.publishedRuntime.sourceTag, "t4code-17.0.5-appserver-10"); + assert.equal(matrix.verifiedRuntime.sourceTag, "t4code-17.0.5-appserver-13"); + assert.equal(matrix.publishedRuntime.sourceTag, "t4code-17.0.5-appserver-13"); assert.deepEqual(matrix.publishedRuntime, matrix.verifiedRuntime); }); +test("freezes the legacy OMP host-migration provenance literals", () => { + const sourceDrift = changed("provenance/omp-host-migration.json", (text) => { + const provenance = JSON.parse(text); + provenance.sourceRepository = "https://github.com/wolfiesch/oh-my-pi"; + return JSON.stringify(provenance); + }); + assert.ok( + collectReleaseConsistencyErrors(sourceDrift).some((error) => + error.includes("source repository must remain"), + ), + ); + + for (const field of [ + "t4codeBase", + "operationsContinuity", + "artifactAndTurnReview", + "runtimeAndWorkspaceAdapters", + ]) { + const inputDrift = changed("provenance/omp-host-migration.json", (text) => { + const provenance = JSON.parse(text); + provenance.inputs[field] = "0".repeat(40); + return JSON.stringify(provenance); + }); + assert.ok( + collectReleaseConsistencyErrors(inputDrift).some((error) => + error.includes("migration inputs must remain"), + ), + field, + ); + } +}); + +test("rejects the retired OMP fork in the active Woodpecker workflow", () => { + const staleWorkflow = changed(".woodpecker.yml", (text) => + replaceRequired( + text, + "https://github.com/wolfiesch/oh-my-pi.git", + "https://github.com/lyc-aon/oh-my-pi.git", + ), + ); + assert.ok( + collectReleaseConsistencyErrors(staleWorkflow).some((error) => + error.includes("retired Lycaon OMP integration fork"), + ), + ); +}); + test("pins official OMP artifacts and the Gate 0 proof contract", () => { const officialDrift = changedRuntime("officialRuntime", (runtime) => { runtime.artifacts["linux-arm64"].sha256 = "invalid"; @@ -120,7 +167,7 @@ test("pins official OMP artifacts and the Gate 0 proof contract", () => { test("rejects a tag that differs from the package version", () => { assert.ok( collectReleaseConsistencyErrors(files, "v9.9.9").some((error) => - error.includes("release tag v9.9.9 does not match v0.1.30"), + error.includes("release tag v9.9.9 does not match v0.1.31"), ), ); }); @@ -149,7 +196,7 @@ test("tagged releases reject published provenance drift", () => { for (const [field, mutate] of appWireCases) { const drifted = changedRuntime("publishedAppWire", mutate); assert.ok( - collectReleaseConsistencyErrors(drifted, "v0.1.30").some((error) => + collectReleaseConsistencyErrors(drifted, "v0.1.31").some((error) => error.includes( `published app-wire ${field} must match current app-wire for tagged releases`, ), @@ -192,7 +239,7 @@ test("tagged releases reject published provenance drift", () => { for (const [field, mutate] of runtimeCases) { const drifted = changedRuntime("publishedRuntime", mutate); assert.ok( - collectReleaseConsistencyErrors(drifted, "v0.1.30").some((error) => + collectReleaseConsistencyErrors(drifted, "v0.1.31").some((error) => error.includes( `published runtime ${field} must match current verified runtime for tagged releases`, ), @@ -204,7 +251,7 @@ test("tagged releases reject published provenance drift", () => { runtime.artifactSha256 = "0".repeat(64); }); assert.ok( - collectReleaseConsistencyErrors(extended, "v0.1.30").some((error) => + collectReleaseConsistencyErrors(extended, "v0.1.31").some((error) => error.includes( "published runtime must exactly match current verified runtime for tagged releases", ), @@ -214,15 +261,15 @@ test("tagged releases reject published provenance drift", () => { test("rejects workspace, site, README, and runtime version drift", () => { const cases = [ - ["apps/web/package.json", (text) => text.replace('"version": "0.1.30"', '"version": "0.1.3"')], + ["apps/web/package.json", (text) => text.replace('"version": "0.1.31"', '"version": "0.1.3"')], [ "apps/site/src/release.ts", - (text) => text.replace('RELEASE_TAG = "v0.1.30"', 'RELEASE_TAG = "v0.1.3"'), + (text) => text.replace('RELEASE_TAG = "v0.1.31"', 'RELEASE_TAG = "v0.1.3"'), ], - ["README.md", (text) => text.replace("Download v0.1.30", "Download v0.1.3")], + ["README.md", (text) => text.replace("Download v0.1.31", "Download v0.1.3")], [ "apps/desktop/src/target-manager.ts", - (text) => text.replace('version: "0.1.30"', 'version: "0.1.3"'), + (text) => text.replace('version: "0.1.31"', 'version: "0.1.3"'), ], [ "apps/site/src/docs/content.ts", @@ -275,7 +322,7 @@ test("rejects updater channel, stable manifest, and publication-contract drift", ".github/workflows/ci.yml", (text) => text.replace( - "needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple]", + "needs: [changes, t4-api-generation, core, legacy-bridge-continuity, current-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug]", "needs: [changes, core, tooling, android-debug]", ), ], @@ -292,7 +339,7 @@ test("rejects updater channel, stable manifest, and publication-contract drift", (text) => replaceRequired( text, - 'test "$source_repository" = "https://github.com/lyc-aon/oh-my-pi"', + 'test "$source_repository" = "https://github.com/wolfiesch/oh-my-pi"', 'test "$source_repository" = "https://github.com/example/other"', ), ], @@ -519,7 +566,7 @@ test("rejects stale README release URLs while allowing historical prose", () => const staleLink = changed("README.md", (text) => `${text}\n[Old release](${oldReleaseUrl})\n`); assert.ok( collectReleaseConsistencyErrors(staleLink).some((error) => - error.includes("release URL for v0.1.3; expected v0.1.30"), + error.includes("release URL for v0.1.3; expected v0.1.31"), ), ); assert.deepEqual(collectReleaseConsistencyErrors(files), []); @@ -533,25 +580,36 @@ test("deploys release site source only after artifact publication", () => { assert.ok(ciWorkflow.includes("android-debug:")); assert.ok(ciWorkflow.includes("core:")); assert.ok(ciWorkflow.includes("legacy-bridge-continuity:")); + assert.ok(ciWorkflow.includes("current-bridge-continuity:")); assert.ok(ciWorkflow.includes("ref: ${{ github.event.pull_request.head.sha || github.sha }}")); assert.ok( ciWorkflow.includes( - `source_repository="$(jq -er '.sourceRepository' provenance/omp-host-migration.json)"`, + `source_repository="$(jq -er '.verifiedRuntime.sourceRepository' compat/omp-app-matrix.json)"`, ), ); assert.ok( - ciWorkflow.includes('test "$source_repository" = "https://github.com/lyc-aon/oh-my-pi"'), + ciWorkflow.includes('test "$source_repository" = "https://github.com/wolfiesch/oh-my-pi"'), ); assert.ok( ciWorkflow.includes("sha=\"$(jq -er '.inputs.operationsContinuity' provenance/omp-host-migration.json)\""), ); assert.ok(ciWorkflow.includes('[[ "$sha" =~ ^[0-9a-f]{40}$ ]]')); - assert.ok(ciWorkflow.includes('echo "repository=lyc-aon/oh-my-pi" >> "$GITHUB_OUTPUT"')); + assert.ok(ciWorkflow.includes('echo "repository=wolfiesch/oh-my-pi" >> "$GITHUB_OUTPUT"')); assert.ok(ciWorkflow.includes("repository: ${{ steps.authority.outputs.repository }}")); assert.ok(ciWorkflow.includes("ref: ${{ steps.authority.outputs.sha }}")); assert.ok(ciWorkflow.includes("T4_OMP_SOURCE_DIR: ${{ github.workspace }}/.continuity/omp")); assert.ok(ciWorkflow.includes("run: pnpm test:legacy-bridge-continuity")); assert.ok(ciWorkflow.includes("path: artifacts/legacy-bridge-continuity/")); + assert.ok( + ciWorkflow.includes("sha=\"$(jq -er '.verifiedRuntime.sourceCommit' compat/omp-app-matrix.json)\""), + ); + assert.ok( + ciWorkflow.includes("T4_CURRENT_OMP_SOURCE_DIR: ${{ github.workspace }}/.current-continuity/omp"), + ); + assert.ok( + ciWorkflow.includes("run: pnpm --filter @t4-code/host-service verify:current-omp-bridge"), + ); + assert.ok(ciWorkflow.includes("path: artifacts/current-omp-bridge/")); assert.ok(ciWorkflow.includes("if-no-files-found: error")); assert.ok(ciWorkflow.includes("tooling:")); assert.ok(ciWorkflow.includes("cluster:")); @@ -559,32 +617,20 @@ test("deploys release site source only after artifact publication", () => { assert.ok(ciWorkflow.includes("run: pnpm test:cluster:ci")); assert.ok(ciWorkflow.includes("run: go test ./...")); assert.ok(ciWorkflow.includes("run: helm lint deploy/charts/t4-cluster")); - assert.ok(ciWorkflow.includes("flutter:")); - assert.ok(ciWorkflow.includes("flutter-android:")); - assert.ok(ciWorkflow.includes("flutter-apple:")); - assert.ok(ciWorkflow.includes("Run Flutter iOS launch smoke test")); - assert.ok( - ciWorkflow.includes( - 'xcrun simctl install "$DEVICE_ID" build/ios/iphonesimulator/Runner.app', - ), - ); - assert.ok(ciWorkflow.includes('kill -0 "$app_pid"')); - assert.ok(ciWorkflow.includes("Build standalone T4 host for Flutter macOS")); - assert.ok(ciWorkflow.includes("Verify bundled Flutter macOS host")); - assert.ok( - ciWorkflow.includes( - "test -x apps/flutter/build/macos/Build/Products/Debug/t4code.app/Contents/Resources/runtime/t4-host", - ), - ); assert.ok(ciWorkflow.includes("name: verify")); assert.ok(ciWorkflow.includes("if: ${{ always() }}")); assert.ok( ciWorkflow.includes( - "needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple]", + "needs: [changes, t4-api-generation, core, legacy-bridge-continuity, current-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug]", ), ); assert.ok(ciWorkflow.includes('test "$CHANGES_RESULT" = success')); + assert.ok(ciWorkflow.includes('test "$T4_API_GENERATION_RESULT" = success')); assert.ok(ciWorkflow.includes('test "$CORE_RESULT" = success')); + assert.ok( + ciWorkflow.includes("CURRENT_CONTINUITY_RESULT: ${{ needs.current-bridge-continuity.result }}"), + ); + assert.ok(ciWorkflow.includes('"$CURRENT_CONTINUITY_RESULT" \\')); assert.ok(ciWorkflow.includes("for result in \\")); assert.ok(ciWorkflow.includes("success|skipped) ;;")); assert.ok(ciWorkflow.includes("github.event_name == 'pull_request' && github.ref || github.sha")); diff --git a/scripts/ci-paths.mjs b/scripts/ci-paths.mjs index 5f6daa67..5c06ac39 100755 --- a/scripts/ci-paths.mjs +++ b/scripts/ci-paths.mjs @@ -11,12 +11,17 @@ const FORCE_ALL = [ const GROUP_PATTERNS = Object.freeze({ continuity: [ + /^\.github\/workflows\/ci\.yml$/u, + /^\.woodpecker\.yml$/u, + /^compat\/omp-app-matrix\.json$/u, + /^packages\/host-service\/bin\/current-omp-bridge-proof\.ts$/u, /^packages\/host-service\/src\//u, /^packages\/host-service\/package\.json$/u, /^packages\/host-wire\/src\//u, /^packages\/host-wire\/package\.json$/u, /^provenance\/omp-host-migration\.json$/u, /^scripts\/legacy-bridge-continuity(?:\.test)?\.mjs$/u, + /^scripts\/ci-paths(?:\.test)?\.mjs$/u, ], cluster: [ /^\.github\/workflows\/ci\.yml$/u, @@ -31,7 +36,7 @@ const GROUP_PATTERNS = Object.freeze({ official_omp_gate0: [ /^\.github\/workflows\/ci\.yml$/u, /^compat\/(?:official-omp-gate0|omp-app-matrix)\.json$/u, - /^docs\/(?:OMP_T4_CAPABILITY_AUDIT\.md|OMP_T4_CAPABILITY_TRACKER\.csv|T4_ARCHITECTURE\.html)$/u, + /^docs\/(?:archive\/flutter-migration\/(?:OMP_T4_CAPABILITY_AUDIT\.md|OMP_T4_CAPABILITY_TRACKER\.csv)|T4_ARCHITECTURE\.html)$/u, /^packages\/host-service\/(?:bin\/official-omp-gate0\.ts|package\.json)$/u, /^packages\/host-service\/src\/(?:official-omp-profile-authority|rpc-child|server|types)\.ts$/u, /^packages\/host-daemon\/(?:bin\/official-omp-packaged-proof\.ts|package\.json|src\/cli\.ts)$/u, @@ -50,14 +55,6 @@ const GROUP_PATTERNS = Object.freeze({ /^packages\/(?:client|ui)\//u, /^packages\/host-wire\//u, ], - flutter: [/^apps\/flutter\//u, /^packages\/host-wire\//u], - flutter_android: [/^apps\/flutter\//u, /^packages\/host-wire\//u], - flutter_apple: [ - /^apps\/flutter\//u, - /^packages\/host-(?:daemon|wire)\//u, - /^apps\/desktop\/build\/entitlements\.omp-runtime\.plist$/u, - /^scripts\/(?:package-mac|stage-omp-runtime)/u, - ], }); function normalizePath(path) { diff --git a/scripts/ci-paths.test.mjs b/scripts/ci-paths.test.mjs index 302558ae..81a2856f 100644 --- a/scripts/ci-paths.test.mjs +++ b/scripts/ci-paths.test.mjs @@ -8,9 +8,6 @@ const none = { official_omp_gate0: false, tooling: false, android_debug: false, - flutter: false, - flutter_android: false, - flutter_apple: false, }; test("host runtime source runs host gates without unrelated platform builds", () => { @@ -30,9 +27,9 @@ test("official lifecycle inputs run their native proof and tooling", () => { "docs/T4_ARCHITECTURE.html", "compat/omp-app-matrix.json", ]), - { ...none, official_omp_gate0: true, tooling: true }, + { ...none, continuity: true, official_omp_gate0: true, tooling: true }, ); - assert.deepEqual(classifyCiPaths(["docs/OMP_T4_CAPABILITY_TRACKER.csv"]), { + assert.deepEqual(classifyCiPaths(["docs/archive/flutter-migration/OMP_T4_CAPABILITY_TRACKER.csv"]), { ...none, official_omp_gate0: true, tooling: true, @@ -46,15 +43,6 @@ test("cluster implementation changes run the cluster gate", () => { }); }); -test("Flutter changes run all Flutter legs", () => { - assert.deepEqual(classifyCiPaths(["apps/flutter/lib/src/client/t4_client_controller.dart"]), { - ...none, - flutter: true, - flutter_android: true, - flutter_apple: true, - }); -}); - test("host wire changes run every dependent client and continuity gate", () => { assert.deepEqual(classifyCiPaths(["packages/host-wire/src/command.ts"]), { continuity: true, @@ -62,23 +50,18 @@ test("host wire changes run every dependent client and continuity gate", () => { official_omp_gate0: false, tooling: true, android_debug: true, - flutter: true, - flutter_android: true, - flutter_apple: true, }); }); -test("host daemon changes run the Apple packaging leg", () => { +test("host daemon changes run its host gates", () => { assert.deepEqual(classifyCiPaths(["packages/host-daemon/src/main.ts"]), { ...none, tooling: true, - flutter_apple: true, }); assert.deepEqual(classifyCiPaths(["packages/host-daemon/src/cli.ts"]), { ...none, official_omp_gate0: true, tooling: true, - flutter_apple: true, }); }); @@ -97,9 +80,6 @@ test("dependency graph changes conservatively run every leg", () => { official_omp_gate0: true, tooling: true, android_debug: true, - flutter: true, - flutter_android: true, - flutter_apple: true, }); } }); @@ -107,16 +87,25 @@ test("dependency graph changes conservatively run every leg", () => { test("workflow changes run tooling on the PR and the full matrix after merge", () => { assert.deepEqual(classifyCiPaths([".github/workflows/ci.yml"]), { ...none, + continuity: true, cluster: true, official_omp_gate0: true, tooling: true, }); }); +test("Woodpecker continuity changes run the equivalent GitHub continuity gate", () => { + assert.deepEqual(classifyCiPaths([".woodpecker.yml"]), { + ...none, + continuity: true, + cluster: true, + }); +}); + test("paths are normalized and GitHub outputs are stable", () => { - const result = classifyCiPaths(["./apps\\flutter\\pubspec.yaml", "./apps/flutter/pubspec.yaml"]); + const result = classifyCiPaths(["./apps\\web\\package.json", "./apps/web/package.json"]); assert.equal( formatGitHubOutputs(result), - "continuity=false\ncluster=false\nofficial_omp_gate0=false\ntooling=false\nandroid_debug=false\nflutter=true\nflutter_android=true\nflutter_apple=true\n", + "continuity=false\ncluster=false\nofficial_omp_gate0=false\ntooling=false\nandroid_debug=true\n", ); }); diff --git a/scripts/cluster-ci/capture-redacted-frames.mjs b/scripts/cluster-ci/capture-redacted-frames.mjs index bce7b872..ce9999cb 100644 --- a/scripts/cluster-ci/capture-redacted-frames.mjs +++ b/scripts/cluster-ci/capture-redacted-frames.mjs @@ -13,7 +13,7 @@ const MAX_FRAMES = 32; const SAFE_AGENT_ID_KEYS = ["agentId", "rootAgentId", "activeAgentId", "parentAgentId"]; const CLUSTER_HOST = "t4-dev.tailb18de3.ts.net"; const EXPECTED_OMP_VERSION = "17.0.5"; -const EXPECTED_OMP_BUILD = "8476f4451ed95c5d5401785d279a93d3c659fac4"; +const EXPECTED_OMP_BUILD = "fc0c391334c08ab260057756aa84bd2b07741ee7"; const REQUIRED_CAPABILITIES = Object.freeze([ "sessions.read", "ci.trigger", diff --git a/scripts/cluster-ci/cluster-ci-contract.test.mjs b/scripts/cluster-ci/cluster-ci-contract.test.mjs index 8e43d77b..78fd2595 100644 --- a/scripts/cluster-ci/cluster-ci-contract.test.mjs +++ b/scripts/cluster-ci/cluster-ci-contract.test.mjs @@ -253,21 +253,13 @@ test("Woodpecker keeps upstream gates and serializes bounded cluster publication assert.deepEqual(coreCommands, [ 'export PATH="$PWD/.ci:$PATH"', "corepack enable", - "pnpm check:release && pnpm check:provenance && pnpm lint && pnpm --filter '!@t4-code/flutter' -r typecheck", - "VP_RUN_CONCURRENCY_LIMIT=1 pnpm --filter '!@t4-code/flutter' -r test", - "pnpm --filter '!@t4-code/flutter' -r build", + "pnpm check", + "VP_RUN_CONCURRENCY_LIMIT=1 pnpm test", + "pnpm build", "pnpm exec playwright install --with-deps chromium", "pnpm test:e2e", "pnpm test:packaging", ]); - const unfilteredSdkCommand = /(?:^|\s)pnpm(?:\s+-r)?\s+(?:check|typecheck|test|build)(?:\s|$)/u; - for (const command of coreCommands) { - assert.doesNotMatch( - command, - unfilteredSdkCommand, - `pipeline 38:64 reproduced unfiltered core workspace traversal as "Failed to find executable flutter": ${command}`, - ); - } assert.deepEqual(steps["legacy-authority-build"].depends_on, [ "legacy-authority-source", "bun-runtime", @@ -305,14 +297,44 @@ test("Woodpecker keeps upstream gates and serializes bounded cluster publication assert.deepEqual(steps["legacy-bridge-continuity"].depends_on, ["legacy-authority-build"]); assert.ok(steps["legacy-bridge-continuity"].commands.includes("pnpm test:legacy-bridge-continuity")); assert.equal(steps["legacy-bridge-continuity"].environment.T4_OMP_SOURCE_DIR, ".continuity/omp"); + assert.deepEqual(steps["current-authority-build"].depends_on, [ + "current-authority-source", + "bun-runtime", + ]); + assert.equal( + steps["current-authority-build"].environment.CARGO_TARGET_DIR, + "/tmp/t4-current-authority-target", + ); + assert.ok( + steps["current-authority-build"].commands.includes( + "(cd .current-continuity/omp && bun test packages/coding-agent/test/appserver-bridge.test.ts packages/coding-agent/test/appserver-session-lifecycle.test.ts)", + ), + ); + assert.deepEqual(steps["current-bridge-continuity"].depends_on, [ + "current-authority-build", + ]); + assert.equal( + steps["current-bridge-continuity"].environment.T4_CURRENT_OMP_SOURCE_DIR, + ".current-continuity/omp", + ); + assert.ok( + steps["current-bridge-continuity"].commands.includes( + "pnpm --filter @t4-code/host-service verify:current-omp-bridge", + ), + ); + assert.equal(steps["current-bridge-continuity"].commands[0], 'export PATH="$PWD/.ci:$PATH"'); + assert.deepEqual(steps["android-debug"].depends_on, [ + "legacy-bridge-continuity", + "current-bridge-continuity", + ]); assert.ok( steps["android-debug"].commands.includes("pnpm --filter @t4-code/mobile check:android:debug"), ); assert.ok(steps["cluster-ci-contracts"].commands.includes("pnpm test:cluster:ci")); assert.deepEqual(steps["cluster-operator-tests"].commands, [ - "GOMAXPROCS=1 GOFLAGS=-p=1 go test ./api/... ./controllers/... ./cmd/...", + "GOMAXPROCS=1 GOFLAGS='-mod=readonly -p=1' go test ./api/... ./controllers/... ./cmd/...", "mkdir -p ../../artifacts/cluster-proof", - "CGO_ENABLED=0 GOMAXPROCS=1 GOFLAGS=-p=1 go test -c ./charttests -o ../../artifacts/cluster-proof/chart-contract.test", + "CGO_ENABLED=0 GOMAXPROCS=1 GOFLAGS='-mod=readonly -p=1' go test -c ./charttests -o ../../artifacts/cluster-proof/chart-contract.test", ]); assert.equal( steps["cluster-operator-tests"].backend_options.kubernetes.resources.limits.memory, diff --git a/scripts/cluster-ci/crd-lifecycle.sh b/scripts/cluster-ci/crd-lifecycle.sh new file mode 100755 index 00000000..b61d8e1e --- /dev/null +++ b/scripts/cluster-ci/crd-lifecycle.sh @@ -0,0 +1,215 @@ +#!/bin/sh +set -eu + +usage() { + cat >&2 <<'EOF' +usage: crd-lifecycle.sh install|upgrade -- helm install|upgrade ... --skip-crds + +Environment: + KUBECTL kubectl executable (default: kubectl) + T4_CRD_DIRECTORY reviewed CRD directory + T4_COMPAT_DIRECTORY old-object compatibility fixture directory + T4_VALIDATION_NAMESPACE existing namespace used for server dry-runs (default: default) + T4_CRD_VALIDATOR proposed-schema validator executable (default: Go validator) + T4_DISCOVERY_OBSERVATIONS consecutive matching OpenAPI observations required (default: 3) + T4_DISCOVERY_ATTEMPTS maximum OpenAPI fetch attempts (default: 30) + T4_DISCOVERY_INTERVAL_SECONDS delay between OpenAPI attempts (default: 2) + T4_DISCOVERY_REQUEST_TIMEOUT timeout for each OpenAPI request (default: 10s) +EOF + exit 64 +} + +[ "$#" -ge 4 ] || usage +mode=$1 +shift +case "$mode" in + install|upgrade) ;; + *) usage ;; +esac +[ "${1:-}" = "--" ] || usage +shift +[ "${1:-}" = "helm" ] || usage +[ "${2:-}" = "$mode" ] || usage + +has_skip_crds=false +for argument in "$@"; do + case "$argument" in + --skip-crds) has_skip_crds=true ;; + --force|--force=*|--force-conflicts|replace) + echo "force replacement is prohibited by the T4 CRD lifecycle" >&2 + exit 64 + ;; + esac +done +[ "$has_skip_crds" = true ] || { + echo "the workload command must include --skip-crds" >&2 + exit 64 +} + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +kubectl=${KUBECTL:-kubectl} +crd_directory=${T4_CRD_DIRECTORY:-$repo_root/deploy/charts/t4-cluster/crds} +compat_directory=${T4_COMPAT_DIRECTORY:-$repo_root/packages/cluster-operator/api/v1alpha1/testdata/compat} +validation_namespace=${T4_VALIDATION_NAMESPACE:-default} +field_manager=t4-crd-lifecycle +crds="crd/t4clusterhosts.cluster.t4.dev crd/t4workspaces.cluster.t4.dev crd/t4sessions.cluster.t4.dev" +live_resources="t4clusterhosts.cluster.t4.dev t4workspaces.cluster.t4.dev t4sessions.cluster.t4.dev" +discovery_observations=${T4_DISCOVERY_OBSERVATIONS:-3} +discovery_attempts=${T4_DISCOVERY_ATTEMPTS:-30} +discovery_interval_seconds=${T4_DISCOVERY_INTERVAL_SECONDS:-2} +discovery_request_timeout=${T4_DISCOVERY_REQUEST_TIMEOUT:-10s} + +case "$discovery_observations:$discovery_attempts:$discovery_interval_seconds" in + *[!0-9:]*|0:*|*:0:*) + echo "T4 discovery observations and attempts must be positive integers; interval must be a non-negative integer" >&2 + exit 64 + ;; +esac +if [ "$discovery_attempts" -lt "$discovery_observations" ]; then + echo "T4_DISCOVERY_ATTEMPTS must be at least T4_DISCOVERY_OBSERVATIONS" >&2 + exit 64 +fi +case "$discovery_request_timeout" in + *s) discovery_request_timeout_seconds=${discovery_request_timeout%s} ;; + *) discovery_request_timeout_seconds= ;; +esac +case "$discovery_request_timeout_seconds" in + ''|*[!0-9]*|0) + echo "T4_DISCOVERY_REQUEST_TIMEOUT must be a positive whole number of seconds such as 10s" >&2 + exit 64 + ;; +esac +case "$discovery_observations" in + ''|*[!0-9]*|0) + echo "T4_DISCOVERY_OBSERVATIONS must be a positive integer" >&2 + exit 64 + ;; +esac + +run_validator() { + if [ -n "${T4_CRD_VALIDATOR:-}" ]; then + "$T4_CRD_VALIDATOR" "$@" + else + (cd "$repo_root/packages/cluster-operator" && go run ./cmd/crd-preflight "$@") + fi +} + +# Validate the persisted compatibility instances, including status and CEL, +# directly against the proposed structural schemas. This is intentionally the +# first operation: current-cluster admission cannot prove candidate compatibility. +run_validator fixtures "$crd_directory" "$compat_directory" + +# Curated fixtures cannot prove that every value currently persisted in the +# cluster remains valid. Enumerate only the three namespaced T4 resources and +# run each complete live object, including status, through the same proposed- +# schema engine before a CRD or workload can be mutated. A denied or malformed +# list fails closed under set -e. +live_objects=$(mktemp) +installed_crds=$(mktemp -d) +merge_patches=$(mktemp -d) +cleanup_preflight() { + rm -f "$live_objects" + rm -rf "$installed_crds" + rm -rf "$merge_patches" +} +trap 'cleanup_preflight' EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM +for resource in $live_resources; do + installed_crd="$installed_crds/$resource.yaml" + "$kubectl" get "crd/$resource" --ignore-not-found -o yaml >"$installed_crd" + if [ ! -s "$installed_crd" ]; then + rm -f "$installed_crd" + continue + fi + "$kubectl" get "$resource" --all-namespaces -o json >"$live_objects" + run_validator objects "$crd_directory" <"$live_objects" +done +run_validator compatible "$crd_directory" "$installed_crds" + +# Build resource-version and UID guarded merge patches for existing CRDs. A +# missing CRD instead uses create, which atomically fails if another writer +# creates it after the absence check. +for resource in $live_resources; do + installed_crd="$installed_crds/$resource.yaml" + if [ -s "$installed_crd" ]; then + run_validator patch "$crd_directory/$resource.yaml" "$installed_crd" >"$merge_patches/$resource.json" + fi +done + +# Every kubectl operation before the first non-dry-run request is read-only. +for resource in $live_resources; do + merge_patch="$merge_patches/$resource.json" + if [ -s "$merge_patch" ]; then + "$kubectl" patch "crd/$resource" --type=merge --dry-run=server \ + --field-manager="$field_manager" --patch-file="$merge_patch" >/dev/null + else + "$kubectl" create --dry-run=server --validate=strict \ + --field-manager="$field_manager" -f "$crd_directory/$resource.yaml" >/dev/null + fi +done + +for resource in $live_resources; do + merge_patch="$merge_patches/$resource.json" + if [ -s "$merge_patch" ]; then + "$kubectl" patch "crd/$resource" --type=merge \ + --field-manager="$field_manager" --patch-file="$merge_patch" + else + "$kubectl" create --validate=strict --field-manager="$field_manager" \ + -f "$crd_directory/$resource.yaml" + fi +done +cleanup_preflight +trap - EXIT HUP INT TERM +# Discovery and admission must converge before compatibility validation or any +# workload rollout uses the new schema. +# shellcheck disable=SC2086 # The fixed CRD words are intentional argv entries. +"$kubectl" wait --for=condition=Established --timeout=120s $crds + +# Established can remain True across an update. Require several independently +# fetched discovery documents to expose exactly the candidate OpenAPI semantics +# before trusting admission or starting a workload rollout. +openapi_document=$(mktemp) +trap 'rm -f "$openapi_document"' EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM +observation=0 +attempt=0 +while [ "$observation" -lt "$discovery_observations" ]; do + attempt=$((attempt + 1)) + if "$kubectl" get --request-timeout="$discovery_request_timeout" \ + --raw /openapi/v3/apis/cluster.t4.dev/v1alpha1 >"$openapi_document" && \ + run_validator served "$crd_directory" <"$openapi_document"; then + observation=$((observation + 1)) + else + observation=0 + fi + if [ "$observation" -ge "$discovery_observations" ]; then + break + fi + if [ "$attempt" -ge "$discovery_attempts" ]; then + echo "served OpenAPI did not converge after $discovery_attempts attempts" >&2 + exit 65 + fi + sleep "$discovery_interval_seconds" +done + +"$kubectl" apply --dry-run=server --validate=strict \ + --namespace "$validation_namespace" -f "$compat_directory" >/dev/null + +for crd in $crds; do + stored_versions=$("$kubectl" get "$crd" -o 'jsonpath={.status.storedVersions[*]}') + if [ "$stored_versions" != v1alpha1 ]; then + echo "$crd status.storedVersions is '$stored_versions'; expected exactly 'v1alpha1'" >&2 + exit 65 + fi +done + +# Helm is deliberately last and must not manage CRDs. If any earlier gate fails, +# the existing controller, server, session workloads, and custom resources are +# untouched by this runner. +rm -f "$openapi_document" +trap - EXIT HUP INT TERM +exec "$@" diff --git a/scripts/cluster-ci/crd-lifecycle.test.mjs b/scripts/cluster-ci/crd-lifecycle.test.mjs new file mode 100644 index 00000000..384151a2 --- /dev/null +++ b/scripts/cluster-ci/crd-lifecycle.test.mjs @@ -0,0 +1,422 @@ +import assert from "node:assert/strict"; +import { chmod, mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawn } from "node:child_process"; +import test from "node:test"; + +const repoRoot = resolve(import.meta.dirname, "../.."); +const lifecycle = resolve(import.meta.dirname, "crd-lifecycle.sh"); + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "t4-crd-lifecycle-")); + const bin = join(root, "bin"); + const log = join(root, "commands.log"); + await mkdir(bin); + await writeFile( + join(bin, "kubectl"), + `#!/bin/sh +set -eu +printf 'kubectl' >>"$COMMAND_LOG" +for argument in "$@"; do printf '\\t%s' "$argument" >>"$COMMAND_LOG"; done +printf '\\n' >>"$COMMAND_LOG" +for argument in "$@"; do + if [ "$argument" = "--dry-run=server" ] && [ "\${FAIL_DRY_RUN:-0}" = 1 ]; then + exit 42 + fi +done +if [ "\${1:-}" = patch ] && [ "\${FAIL_REAL_PATCH:-}" = "\${2:-}" ]; then + is_dry=false + for argument in "$@"; do if [ "$argument" = "--dry-run=server" ]; then is_dry=true; fi; done + if [ "$is_dry" = false ]; then exit 47; fi +fi +if [ "\${1:-}" = get ]; then + for argument in "$@"; do + case "$argument" in + --raw|--raw=*) + if [ -n "\${RAW_RESULTS:-}" ]; then + raw_counter_file="\${RAW_COUNTER_FILE:-$COMMAND_LOG.raw-count}" + raw_count=0 + if [ -f "$raw_counter_file" ]; then raw_count=$(cat "$raw_counter_file"); fi + raw_count=$((raw_count + 1)) + printf '%s' "$raw_count" >"$raw_counter_file" + raw_result=$(printf '%s' "$RAW_RESULTS" | awk -F, -v field_index="$raw_count" '{print $field_index}') + if [ "$raw_result" = timeout ]; then exit 46; fi + fi + printf '%s' '{}' + exit 0 + ;; + esac + done + case "\${2:-}" in + crd/*) + for argument in "$@"; do + if [ "$argument" = "--ignore-not-found" ]; then + if [ "\${FAIL_CRD_READ:-}" = "\${2:-}" ]; then exit 45; fi + if [ "\${MISSING_LIVE_CRDS:-0}" = 1 ]; then exit 0; fi + resource_name=\${2#crd/} + printf 'apiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\nmetadata:\n name: %s\n uid: 11111111-2222-3333-4444-555555555555\n resourceVersion: "42"\n' "$resource_name" + exit 0 + fi + done + ;; + esac + case "\${2:-}" in + t4clusterhosts.cluster.t4.dev|t4workspaces.cluster.t4.dev|t4sessions.cluster.t4.dev) + if [ "\${FAIL_LIVE_LIST:-}" = "\${2:-}" ]; then exit 44; fi + if [ "\${TERMINATE_DURING_LIVE_READ:-}" = "\${2:-}" ]; then + kill -TERM "$PPID" + sleep 1 + fi + printf '%s' '{"apiVersion":"v1","kind":"List","items":[]}' + exit 0 + ;; + esac + printf '%s' "\${STORED_VERSIONS:-v1alpha1}" +fi +`, + ); + await writeFile( + join(bin, "crd-preflight"), + `#!/bin/sh +set -eu +printf 'validator' >>"$COMMAND_LOG" +for argument in "$@"; do printf '\\t%s' "$argument" >>"$COMMAND_LOG"; done +printf '\\n' >>"$COMMAND_LOG" +case "\${1:-}:\${FAIL_PROPOSED_VALIDATION:-}" in + fixtures:spec|fixtures:status|objects:live|served:stale) exit 43 ;; +esac +if [ "\${1:-}" = patch ]; then + printf '%s' '{"metadata":{"resourceVersion":"42","uid":"11111111-2222-3333-4444-555555555555"},"spec":{}}' + exit 0 +fi +if [ "\${1:-}" = served ] && [ -n "\${SERVED_RESULTS:-}" ]; then + served_counter_file="\${SERVED_COUNTER_FILE:-$COMMAND_LOG.served-count}" + served_count=0 + if [ -f "$served_counter_file" ]; then served_count=$(cat "$served_counter_file"); fi + served_count=$((served_count + 1)) + printf '%s' "$served_count" >"$served_counter_file" + served_result=$(printf '%s' "$SERVED_RESULTS" | awk -F, -v field_index="$served_count" '{print $field_index}') + if [ "$served_result" = stale ]; then exit 43; fi +fi +cat >/dev/null +`, + ); + await writeFile( + join(bin, "helm"), + `#!/bin/sh +set -eu +printf 'helm' >>"$COMMAND_LOG" +for argument in "$@"; do printf '\\t%s' "$argument" >>"$COMMAND_LOG"; done +printf '\\n' >>"$COMMAND_LOG" +`, + ); + await chmod(join(bin, "kubectl"), 0o755); + await chmod(join(bin, "helm"), 0o755); + await chmod(join(bin, "crd-preflight"), 0o755); + return { + root, + log, + env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, COMMAND_LOG: log, T4_CRD_VALIDATOR: join(bin, "crd-preflight"), T4_DISCOVERY_INTERVAL_SECONDS: "0" }, + }; +} + +async function runLifecycle(args, env = {}) { + const result = await new Promise((resolveResult, reject) => { + const child = spawn(lifecycle, args, { + cwd: repoRoot, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code, signal) => resolveResult({ code, signal, stdout, stderr })); + }); + return result; +} + +async function commands(log) { + return (await readFile(log, "utf8")).trim().split("\n").filter(Boolean); +} + +function findCommand(log, predicate, description) { + const index = log.findIndex(predicate); + assert.notEqual(index, -1, `missing ${description}:\n${log.join("\n")}`); + return index; +} + +function isClusterMutation(line) { + return /^kubectl\t(?:patch|create|apply)\t/u.test(line) && !line.includes("--dry-run=server"); +} + +test("upgrade validates proposed schemas, proves served convergence, verifies storage, then upgrades workloads", async () => { + const value = await fixture(); + const result = await runLifecycle( + ["upgrade", "--", "helm", "upgrade", "t4-cluster", "deploy/charts/t4-cluster", "--namespace", "t4-system", "--skip-crds"], + value.env, + ); + assert.equal(result.code, 0, `${result.stdout}\n${result.stderr}`); + const log = await commands(value.log); + const proposedPreflight = findCommand(log, (line) => line.startsWith("validator\tfixtures\t"), "local proposed-schema fixture preflight"); + const crdReads = log.map((line, index) => ({ line, index })).filter(({ line }) => /^kubectl\tget\tcrd\/t4(?:clusterhosts|workspaces|sessions)\.cluster\.t4\.dev\t--ignore-not-found\t-o\tyaml$/u.test(line)); + const liveLists = log.map((line, index) => ({ line, index })).filter(({ line }) => /^kubectl\tget\tt4(?:clusterhosts|workspaces|sessions)\.cluster\.t4\.dev\t--all-namespaces\t-o\tjson$/u.test(line)); + const liveValidations = log.map((line, index) => ({ line, index })).filter(({ line }) => line.startsWith("validator\tobjects\t")); + const structuralCompatibility = findCommand(log, (line) => line.startsWith("validator\tcompatible\t"), "installed-schema additive compatibility gate"); + const patchGeneration = log.map((line, index) => ({ line, index })).filter(({ line }) => line.startsWith("validator\tpatch\t")); + const crdPreflight = findCommand(log, (line) => line.startsWith("kubectl\tpatch\tcrd/") && line.includes("--dry-run=server"), "resource-version-guarded CRD preflight"); + const crdApply = findCommand(log, (line) => line.startsWith("kubectl\tpatch\tcrd/") && !line.includes("--dry-run=server"), "resource-version-guarded CRD patch"); + const established = findCommand(log, (line) => line.includes("wait") && line.includes("condition=Established") && line.includes("t4clusterhosts.cluster.t4.dev") && line.includes("t4workspaces.cluster.t4.dev") && line.includes("t4sessions.cluster.t4.dev"), "Established wait"); + const servedChecks = log.map((line, index) => ({ line, index })).filter(({ line }) => line.startsWith("validator\tserved\t")); + const admissionPreflight = findCommand(log, (line) => line.includes("apply") && line.includes("--dry-run=server") && line.includes("testdata/compat"), "converged admission preflight"); + const storageChecks = log.map((line, index) => ({ line, index })).filter(({ line }) => line.startsWith("kubectl\tget\tcrd/") && line.includes("status.storedVersions")); + assert.deepEqual(storageChecks.length, 3); + assert.equal(servedChecks.length, 3); + assert.equal(crdReads.length, 3); + assert.equal(liveLists.length, 3); + assert.equal(liveValidations.length, 3); + assert.equal(patchGeneration.length, 3); + const workload = findCommand(log, (line) => line.startsWith("helm\tupgrade\t"), "Helm workload upgrade"); + assert.ok(proposedPreflight < crdPreflight); + assert.ok(proposedPreflight < crdReads[0].index); + assert.ok(crdReads[0].index < liveLists[0].index); + assert.ok(liveLists[0].index < liveValidations[0].index); + assert.ok(liveValidations[0].index < crdReads[1].index); + assert.ok(crdReads[1].index < liveLists[1].index); + assert.ok(liveLists[1].index < liveValidations[1].index); + assert.ok(liveValidations[1].index < crdReads[2].index); + assert.ok(crdReads[2].index < liveLists[2].index); + assert.ok(liveLists[2].index < liveValidations[2].index); + assert.ok(liveValidations[2].index < structuralCompatibility); + assert.ok(structuralCompatibility < patchGeneration[0].index); + assert.ok(patchGeneration.at(-1).index < crdPreflight); + assert.ok(crdPreflight < crdApply); + assert.ok(crdApply < established); + assert.ok(established < servedChecks[0].index); + assert.ok(servedChecks.at(-1).index < admissionPreflight); + assert.ok(admissionPreflight < storageChecks[0].index); + assert.ok(storageChecks.every(({ index }) => index < workload)); + const crdPatches = log.filter((line) => line.startsWith("kubectl\tpatch\tcrd/")); + assert.equal(crdPatches.length, 6, log.join("\n")); + assert.ok(crdPatches.every((line) => line.includes("--type=merge") && line.includes("--field-manager=t4-crd-lifecycle") && line.includes("--patch-file=")), log.join("\n")); + assert.ok(log.every((line) => !line.includes("--force-conflicts") && !line.includes("replace") && !line.includes("delete\tcrd")), log.join("\n")); +}); + +test("fresh install establishes and validates CRDs before Helm installs with CRD handling disabled", async () => { + const value = await fixture(); + const result = await runLifecycle( + ["install", "--", "helm", "install", "t4-cluster", "deploy/charts/t4-cluster", "--namespace", "t4-system", "--skip-crds"], + { ...value.env, MISSING_LIVE_CRDS: "1" }, + ); + assert.equal(result.code, 0, `${result.stdout}\n${result.stderr}`); + const log = await commands(value.log); + assert.ok(log.every((line) => !line.startsWith("validator\tobjects\t") && !/kubectl\tget\tt4(?:clusterhosts|workspaces|sessions)\.cluster\.t4\.dev\t--all-namespaces/u.test(line)), log.join("\n")); + const established = findCommand(log, (line) => line.includes("wait") && line.includes("condition=Established"), "Established wait"); + const fixtureValidation = findCommand(log, (line) => line.includes("--dry-run=server") && line.includes("testdata/compat"), "fixture validation"); + const storage = findCommand(log, (line) => line.includes("status.storedVersions"), "stored-version check"); + const workload = findCommand(log, (line) => line.startsWith("helm\tinstall\t"), "Helm install"); + const crdCreates = log.filter((line) => line.startsWith("kubectl\tcreate\t")); + assert.equal(crdCreates.length, 6, log.join("\n")); + assert.ok(crdCreates.every((line) => line.includes("--field-manager=t4-crd-lifecycle")), log.join("\n")); + assert.ok(established < fixtureValidation && fixtureValidation < storage && storage < workload); +}); + +test("candidate schema tightening fails locally before any cluster or workload mutation", async () => { + const value = await fixture(); + const result = await runLifecycle( + ["upgrade", "--", "helm", "upgrade", "t4-cluster", "chart", "--skip-crds"], + { ...value.env, FAIL_PROPOSED_VALIDATION: "spec" }, + ); + assert.notEqual(result.code, 0); + assert.deepEqual(await commands(value.log), [ + `validator\tfixtures\t${join(repoRoot, "deploy/charts/t4-cluster/crds")}\t${join(repoRoot, "packages/cluster-operator/api/v1alpha1/testdata/compat")}`, + ]); +}); + +test("persisted status is validated against the proposed status schema before mutation", async () => { + const value = await fixture(); + const result = await runLifecycle( + ["upgrade", "--", "helm", "upgrade", "t4-cluster", "chart", "--skip-crds"], + { ...value.env, FAIL_PROPOSED_VALIDATION: "status" }, + ); + assert.notEqual(result.code, 0); + assert.deepEqual(await commands(value.log), [ + `validator\tfixtures\t${join(repoRoot, "deploy/charts/t4-cluster/crds")}\t${join(repoRoot, "packages/cluster-operator/api/v1alpha1/testdata/compat")}`, + ]); +}); + +test("live object incompatibility fails before non-dry-run CRD, object, or Helm mutation", async () => { + const value = await fixture(); + const result = await runLifecycle( + ["upgrade", "--", "helm", "upgrade", "t4-cluster", "chart", "--skip-crds"], + { ...value.env, FAIL_PROPOSED_VALIDATION: "live" }, + ); + assert.notEqual(result.code, 0); + const log = await commands(value.log); + assert.match(log.at(-1), /^validator\tobjects\t/u); + assert.ok(log.some((line) => /^kubectl\tget\tt4clusterhosts\.cluster\.t4\.dev\t--all-namespaces\t-o\tjson$/u.test(line)), log.join("\n")); + assert.ok(log.every((line) => !line.startsWith("helm\t") && !isClusterMutation(line)), log.join("\n")); +}); + +test("live object definition read or list denial fails closed before non-dry-run mutation", async () => { + const deniedResource = "t4workspaces.cluster.t4.dev"; + for (const scenario of [ + { env: { FAIL_CRD_READ: `crd/${deniedResource}` }, expected: `kubectl\tget\tcrd/${deniedResource}\t--ignore-not-found\t-o\tyaml` }, + { env: { FAIL_LIVE_LIST: deniedResource }, expected: `kubectl\tget\t${deniedResource}\t--all-namespaces\t-o\tjson` }, + ]) { + const value = await fixture(); + const result = await runLifecycle( + ["upgrade", "--", "helm", "upgrade", "t4-cluster", "chart", "--skip-crds"], + { ...value.env, ...scenario.env }, + ); + assert.notEqual(result.code, 0); + const log = await commands(value.log); + assert.ok(log.some((line) => line.startsWith(scenario.expected)), log.join("\n")); + assert.ok(log.every((line) => !line.startsWith("helm\t") && !isClusterMutation(line)), log.join("\n")); + } +}); + +test("cancellation during live enumeration cannot continue to mutation", async () => { + const value = await fixture(); + const result = await runLifecycle( + ["upgrade", "--", "helm", "upgrade", "t4-cluster", "chart", "--skip-crds"], + { ...value.env, TERMINATE_DURING_LIVE_READ: "t4clusterhosts.cluster.t4.dev" }, + ); + assert.notEqual(result.code, 0); + const log = await commands(value.log); + assert.match(log.at(-1), /^kubectl\tget\tt4clusterhosts\.cluster\.t4\.dev\t--all-namespaces\t-o\tjson$/u); + assert.ok(log.every((line) => !line.startsWith("helm\t") && !isClusterMutation(line)), log.join("\n")); +}); + +test("retained Established cannot pass readiness while served OpenAPI is stale", async () => { + const value = await fixture(); + const result = await runLifecycle( + ["upgrade", "--", "helm", "upgrade", "t4-cluster", "chart", "--skip-crds"], + { ...value.env, FAIL_PROPOSED_VALIDATION: "stale", T4_DISCOVERY_ATTEMPTS: "3" }, + ); + assert.notEqual(result.code, 0); + const log = await commands(value.log); + const apply = findCommand(log, (line) => line.startsWith("kubectl\tpatch\tcrd/") && !line.includes("--dry-run=server"), "non-dry-run CRD patch"); + const established = findCommand(log, (line) => line.includes("condition=Established"), "Established wait"); + const served = findCommand(log, (line) => line.startsWith("validator\tserved\t"), "served-schema semantic verification"); + assert.ok(apply < established && established < served, log.join("\n")); + assert.ok(log.every((line) => !line.startsWith("helm\t")), log.join("\n")); +}); + +test("served OpenAPI waits for three consecutive matching observations", async () => { + for (const sequence of ["stale,fresh,fresh,fresh", "fresh,stale,fresh,fresh,fresh"]) { + const value = await fixture(); + const result = await runLifecycle( + ["upgrade", "--", "helm", "upgrade", "t4-cluster", "chart", "--skip-crds"], + { ...value.env, SERVED_RESULTS: sequence, T4_DISCOVERY_ATTEMPTS: "6" }, + ); + assert.equal(result.code, 0, `${sequence}\n${result.stdout}\n${result.stderr}`); + const log = await commands(value.log); + assert.equal( + log.filter((line) => line.startsWith("validator\tserved\t")).length, + sequence.split(",").length, + log.join("\n"), + ); + assert.ok(log.some((line) => line.startsWith("helm\tupgrade\t")), log.join("\n")); + } +}); + +test("served OpenAPI requests are individually bounded and failed requests consume attempts", async () => { + const value = await fixture(); + const result = await runLifecycle( + ["upgrade", "--", "helm", "upgrade", "t4-cluster", "chart", "--skip-crds"], + { + ...value.env, + RAW_RESULTS: "timeout,timeout,fresh,fresh,fresh", + T4_DISCOVERY_ATTEMPTS: "5", + T4_DISCOVERY_REQUEST_TIMEOUT: "1s", + }, + ); + assert.equal(result.code, 0, `${result.stdout}\n${result.stderr}`); + const log = await commands(value.log); + const rawRequests = log.filter((line) => line.includes("\tget\t") && line.includes("\t--raw\t/openapi/v3/")); + assert.equal(rawRequests.length, 5, log.join("\n")); + assert.ok(rawRequests.every((line) => line.includes("\t--request-timeout=1s\t")), log.join("\n")); + assert.equal(log.filter((line) => line.startsWith("validator\tserved\t")).length, 3, log.join("\n")); +}); + +test("invalid discovery request timeout is rejected before cluster access", async () => { + const value = await fixture(); + const result = await runLifecycle( + ["upgrade", "--", "helm", "upgrade", "t4-cluster", "chart", "--skip-crds"], + { ...value.env, T4_DISCOVERY_REQUEST_TIMEOUT: "0s" }, + ); + assert.equal(result.code, 64, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /positive whole number of seconds/u); + await assert.rejects(readFile(value.log, "utf8"), { code: "ENOENT" }); +}); + +test("failed server preflight leaves CRDs and workloads untouched", async () => { + const value = await fixture(); + const result = await runLifecycle( + ["upgrade", "--", "helm", "upgrade", "t4-cluster", "deploy/charts/t4-cluster", "--namespace", "t4-system", "--skip-crds"], + { ...value.env, FAIL_DRY_RUN: "1" }, + ); + assert.notEqual(result.code, 0); + const log = await commands(value.log); + assert.match(log[0], /^validator\tfixtures\t/u); + assert.match(log.at(-1), /^kubectl\tpatch\tcrd\/.*--dry-run=server/u); + assert.ok(log.every((line) => !line.startsWith("helm\t") && !isClusterMutation(line)), log.join("\n")); +}); + +test("a CRD changed after validation conflicts instead of being overwritten", async () => { + const value = await fixture(); + const result = await runLifecycle( + ["upgrade", "--", "helm", "upgrade", "t4-cluster", "chart", "--skip-crds"], + { ...value.env, FAIL_REAL_PATCH: "crd/t4clusterhosts.cluster.t4.dev" }, + ); + assert.notEqual(result.code, 0); + const log = await commands(value.log); + const guardedPatch = log.find((line) => line.startsWith("kubectl\tpatch\tcrd/t4clusterhosts.cluster.t4.dev") && !line.includes("--dry-run=server")); + assert.ok(guardedPatch?.includes("--patch-file="), log.join("\n")); + assert.ok(log.every((line) => !line.startsWith("helm\t")), log.join("\n")); +}); + +test("an unexpected stored version stops workload rollout", async () => { + const value = await fixture(); + const result = await runLifecycle( + ["upgrade", "--", "helm", "upgrade", "t4-cluster", "deploy/charts/t4-cluster", "--namespace", "t4-system", "--skip-crds"], + { ...value.env, STORED_VERSIONS: "v1alpha1,v1beta1" }, + ); + assert.notEqual(result.code, 0); + const log = await commands(value.log); + assert.ok(log.some((line) => line.includes("status.storedVersions"))); + assert.ok(log.every((line) => !line.startsWith("helm\t")), log.join("\n")); +}); + +test("force replacement and implicit Helm CRD handling are rejected before cluster access", async () => { + const value = await fixture(); + for (const args of [ + ["upgrade", "--", "helm", "upgrade", "t4-cluster", "chart", "--skip-crds", "--force"], + ["upgrade", "--", "helm", "upgrade", "t4-cluster", "chart"], + ]) { + const result = await runLifecycle(args, value.env); + assert.equal(result.code, 64, `${result.stdout}\n${result.stderr}`); + } +}); + +test("future storage migration explicitly retires the old stored version only after rewrite and dual-version reads", async () => { + const docs = await readFile(join(repoRoot, "docs/CLUSTER_OPERATOR.md"), "utf8"); + const migration = docs.slice(docs.indexOf("### Future `v1beta1`"), docs.indexOf("### Workload rollback")); + const storageFlip = migration.indexOf("`v1beta1` storage to true"); + const rewrite = migration.indexOf("rewrite every object"); + const verifyReads = migration.indexOf("read every rewritten object through both served versions"); + const statusUpdate = migration.indexOf("/status"); + const exactAssertion = migration.indexOf("exactly `[v1beta1]`"); + const oldStillServed = migration.indexOf("Keep `v1alpha1` served"); + assert.ok(storageFlip >= 0 && storageFlip < rewrite, migration); + assert.ok(rewrite < verifyReads && verifyReads < statusUpdate, migration); + assert.ok(statusUpdate < exactAssertion && exactAssertion < oldStillServed, migration); + assert.match(migration, /patch customresourcedefinition[^\n]*--subresource=status/u); +}); diff --git a/scripts/deploy-demo.mjs b/scripts/deploy-demo.mjs index 3f9c844b..7bd0e3a6 100644 --- a/scripts/deploy-demo.mjs +++ b/scripts/deploy-demo.mjs @@ -30,8 +30,8 @@ export function assertDemoDocumentPaths(document) { !url.startsWith("https:") && !url.startsWith("#"), ); - if (!localUrls.includes("flutter_bootstrap.js")) { - throw new Error("demo index is not a Flutter web build"); + if (!localUrls.some((url) => url?.startsWith("/demo/assets/"))) { + throw new Error("demo index is not a React production build"); } const escaped = localUrls.find((url) => { const resolved = new URL(url, "https://t4code.net/demo/"); @@ -43,18 +43,8 @@ export function assertDemoDocumentPaths(document) { export function validateDemoBuild(repoRoot) { const document = readFileSync(resolve(repoRoot, "apps/site/dist/demo/index.html"), "utf8"); assertDemoDocumentPaths(document); - const bootstrap = readFileSync( - resolve(repoRoot, "apps/site/dist/demo/flutter_bootstrap.js"), - "utf8", - ); - if (!bootstrap.includes("registration.unregister()")) { - throw new Error("demo bootstrap must retire stale service workers"); - } - if (!bootstrap.includes('"useLocalCanvasKit":true')) { - throw new Error("demo bootstrap must use local Flutter renderer assets"); - } - if (existsSync(resolve(repoRoot, "apps/site/dist/demo/flutter_service_worker.js"))) { - throw new Error("demo build must not publish a Flutter service worker"); + if (existsSync(resolve(repoRoot, "apps/site/dist/demo/service-worker.js"))) { + throw new Error("demo build must not publish a service worker"); } } diff --git a/scripts/deploy-demo.test.mjs b/scripts/deploy-demo.test.mjs index 93ffc737..860fca23 100644 --- a/scripts/deploy-demo.test.mjs +++ b/scripts/deploy-demo.test.mjs @@ -4,7 +4,7 @@ import test from "node:test"; import { buildDemo } from "./build-demo.mjs"; import { assertDemoDocumentPaths, deployDemo } from "./deploy-demo.mjs"; -test("demo build compiles the Flutter client for the /demo/ path", () => { +test("demo build compiles the React client for the /demo/ path", () => { const calls = []; buildDemo("/repo", (command, args, cwd) => calls.push({ command, args, cwd })); @@ -13,18 +13,13 @@ test("demo build compiles the Flutter client for the /demo/ path", () => { command: "pnpm", args: [ "--filter", - "@t4-code/flutter", + "@t4-code/web", "exec", - "flutter", + "vp", "build", - "web", - "--base-href", - "/demo/", - "--csp", - "--no-web-resources-cdn", - "--dart-define", - "T4_DEMO_MODE=true", - "--output", + "--mode", + "demo", + "--outDir", "/repo/apps/site/dist/demo", ], cwd: "/repo", @@ -32,23 +27,29 @@ test("demo build compiles the Flutter client for the /demo/ path", () => { ]); }); -test("site workflow deploys the Flutter demo independently from release publication", async () => { +test("site workflow deploys the React demo independently from release publication", async () => { const { readFile } = await import("node:fs/promises"); const workflow = await readFile(".github/workflows/deploy-site.yml", "utf8"); const infrastructure = await readFile("infra/site/cloudformation.yml", "utf8"); - assert.match(workflow, /- "apps\/flutter\/\*\*"/u); - assert.doesNotMatch(workflow, /- "apps\/web\/\*\*"/u); + assert.match(workflow, /- "apps\/web\/\*\*"/u); assert.match(workflow, /demo:\n if: \$\{\{ github\.event_name == 'push' \}\}/u); - assert.match(workflow, /id: demo_csp/u); - assert.match(workflow, /grep -Fq "'wasm-unsafe-eval'"/u); - assert.match(workflow, /if: \$\{\{ steps\.demo_csp\.outputs\.ready == 'true' \}\}/u); - assert.match(workflow, /Defer Flutter demo until its response policy is active/u); + assert.doesNotMatch(workflow, /flutter-action/u); assert.match(workflow, /run: pnpm deploy:demo/u); assert.match(workflow, /run: pnpm deploy:site/u); assert.doesNotMatch(workflow, /deploy:site-bundle/u); + assert.equal( + workflow.match(/name: Authenticate to AWS with deployment credentials/gu)?.length, + 2, + ); + assert.equal(workflow.match(/aws-access-key-id: \$\{\{ secrets\.AWS_ACCESS_KEY_ID \}\}/gu)?.length, 2); + assert.equal( + workflow.match(/aws-secret-access-key: \$\{\{ secrets\.AWS_SECRET_ACCESS_KEY \}\}/gu)?.length, + 2, + ); + assert.equal(workflow.match(/vars\.AWS_ROLE_ARN == ''/gu)?.length, 2); + assert.equal(workflow.match(/vars\.AWS_ROLE_ARN != ''/gu)?.length, 2); assert.match(infrastructure, /PathPattern: demo\*/u); - assert.match(infrastructure, /script-src 'self' 'wasm-unsafe-eval'/u); assert.match(infrastructure, /connect-src 'self' https:\/\/fonts\.gstatic\.com/u); }); @@ -79,29 +80,29 @@ test("demo deploy replaces only the demo prefix after immutable assets", () => { test("demo build keeps every local document URL under /demo", () => { assert.doesNotThrow(() => assertDemoDocumentPaths( - '', + '', ), ); assert.throws( () => assertDemoDocumentPaths( - '', + '', ), /demo asset escapes/u, ); assert.throws( () => assertDemoDocumentPaths( - '', + '', ), /demo asset escapes/u, ); assert.throws( - () => assertDemoDocumentPaths(''), + () => assertDemoDocumentPaths(''), /base href/u, ); assert.throws( () => assertDemoDocumentPaths(''), - /not a Flutter web build/u, + /not a React production build/u, ); }); diff --git a/scripts/run-flutter.mjs b/scripts/run-flutter.mjs deleted file mode 100644 index 4d1ecf80..00000000 --- a/scripts/run-flutter.mjs +++ /dev/null @@ -1,53 +0,0 @@ -import { spawn, spawnSync } from "node:child_process"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const args = process.argv.slice(2); -if (args[0] === "--") args.shift(); - -const developmentEndpoint = process.env.T4_DEVELOPMENT_ENDPOINT?.trim(); -if ( - developmentEndpoint && - !args.some((argument) => - argument.startsWith("--dart-define=T4_DEVELOPMENT_ENDPOINT="), - ) -) { - args.push(`--dart-define=T4_DEVELOPMENT_ENDPOINT=${developmentEndpoint}`); -} - -const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const hostBuild = spawnSync("pnpm", ["build:host"], { - cwd: repositoryRoot, - env: process.env, - stdio: "inherit", -}); -if (hostBuild.status !== 0) { - process.exitCode = hostBuild.status ?? 1; - process.exit(); -} -const hostExecutable = resolve(repositoryRoot, "packages/host-daemon/dist/t4-host"); -const child = spawn("flutter", ["run", ...args], { - cwd: resolve(repositoryRoot, "apps/flutter"), - env: { - ...process.env, - T4_HOST_EXECUTABLE: process.env.T4_HOST_EXECUTABLE ?? hostExecutable, - }, - stdio: "inherit", -}); - -for (const signal of ["SIGINT", "SIGTERM"]) { - process.on(signal, () => child.kill(signal)); -} - -child.once("error", (error) => { - console.error(`Unable to launch Flutter: ${error.message}`); - process.exitCode = 1; -}); - -child.once("exit", (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; - } - process.exitCode = code ?? 1; -}); diff --git a/scripts/t4-maintainer-contract.test.mjs b/scripts/t4-maintainer-contract.test.mjs index e52aa7fc..f991c5e4 100644 --- a/scripts/t4-maintainer-contract.test.mjs +++ b/scripts/t4-maintainer-contract.test.mjs @@ -314,7 +314,7 @@ test("runner gives Sol the requested model, tools, and release ownership", async assert.match(runner, /"\$SETPRIV" --no-new-privs -- "\$OMP"/u); assert.doesNotMatch(runner, /--no-tools|--tools=|--no-pty|\bbwrap\b/u); for (const responsibility of [ - "`lyc-aon/oh-my-pi` fork", + "`wolfiesch/oh-my-pi` fork", "wrapper has already synchronized", "merge the exact official `vX.Y.Z` base into the durable `t4code/main` product branch", "reachable from `t4code/main`", @@ -523,6 +523,22 @@ test("public verification requires exact GitHub provenance despite admin bypass ]); }); +test("atomic publisher pins the owned OMP fork identity", async () => { + const publisher = await source("publish-omp-atomic.sh"); + assertIncludesAll( + publisher, + [ + "readonly FORK_REPOSITORY=wolfiesch/oh-my-pi", + "readonly FORK_REPOSITORY_ID=1271775475", + "readonly FORK_REPOSITORY_NODE_ID=R_kgDOS83A8w", + ".id == $id and .node_id == $node_id", + '.full_name == "wolfiesch/oh-my-pi" and .fork == true', + '.parent.full_name == "can1357/oh-my-pi"', + ], + "owned OMP fork identity", + ); +}); + test("pending publication is atomic and gates local deployment before processed state", async () => { const runner = await source("run.sh"); const recordPending = shellFunction(runner, "record_pending"); diff --git a/scripts/t4-maintainer-integration.test.mjs b/scripts/t4-maintainer-integration.test.mjs index 23e74cdc..d9adbc74 100644 --- a/scripts/t4-maintainer-integration.test.mjs +++ b/scripts/t4-maintainer-integration.test.mjs @@ -137,6 +137,28 @@ case $tool in [[ $argument == repos/* ]] && endpoint=$argument done case $endpoint in + repos/can1357/oh-my-pi) + official_id=1125856365 + official_node=R_kgDOQxs0bQ + official_clone=https://github.com/can1357/oh-my-pi.git + [[ \${MOCK_OMP_OFFICIAL_ID_MISMATCH:-0} != 1 ]] || official_id=1 + [[ \${MOCK_OMP_OFFICIAL_CLONE_MISMATCH:-0} != 1 ]] || official_clone=https://example.invalid/oh-my-pi.git + printf '{"id":%s,"node_id":"%s","full_name":"can1357/oh-my-pi","clone_url":"%s"}\n' \ + "$official_id" "$official_node" "$official_clone" + ;; + repos/wolfiesch/oh-my-pi) + fork_id=1271775475 + fork_node=R_kgDOS83A8w + parent_id=1125856365 + parent_node=R_kgDOQxs0bQ + fork_clone=https://github.com/wolfiesch/oh-my-pi.git + [[ \${MOCK_OMP_FORK_ID_MISMATCH:-0} != 1 ]] || fork_id=1 + [[ \${MOCK_OMP_FORK_NODE_MISMATCH:-0} != 1 ]] || fork_node=wrong + [[ \${MOCK_OMP_FORK_PARENT_MISMATCH:-0} != 1 ]] || parent_id=1 + [[ \${MOCK_OMP_FORK_CLONE_MISMATCH:-0} != 1 ]] || fork_clone=https://example.invalid/oh-my-pi.git + printf '{"id":%s,"node_id":"%s","full_name":"wolfiesch/oh-my-pi","clone_url":"%s","fork":true,"parent":{"id":%s,"node_id":"%s","full_name":"can1357/oh-my-pi"}}\n' \ + "$fork_id" "$fork_node" "$fork_clone" "$parent_id" "$parent_node" + ;; 'repos/LycaonLLC/t4-code/pulls?state=open&base=main&per_page=100') count=$(read_state t4-pr-queries 0) count=$((count + 1)) @@ -159,13 +181,16 @@ case $tool in repos/can1357/oh-my-pi/commits/v1.2.3) printf '%s\n' "$MOCK_UPSTREAM_COMMIT" ;; - repos/lyc-aon/oh-my-pi/commits/v1.2.3) + repos/wolfiesch/oh-my-pi/commits/v1.2.3) + printf '%s\n' "$MOCK_UPSTREAM_COMMIT" + ;; + repos/wolfiesch/oh-my-pi/commits/$MOCK_UPSTREAM_COMMIT) printf '%s\n' "$MOCK_UPSTREAM_COMMIT" ;; repos/can1357/oh-my-pi/git/ref/tags/v1.2.3) printf '%s\n' "$MOCK_UPSTREAM_TAG_OBJECT" ;; - repos/lyc-aon/oh-my-pi/git/ref/tags/v1.2.3) + repos/wolfiesch/oh-my-pi/git/ref/tags/v1.2.3) [[ \${MOCK_FORK_BASE_TAG_MISSING:-0} != 1 ]] || exit 1 if [[ \${MOCK_FORK_BASE_TAG_MISMATCH:-0} == 1 ]]; then printf '%040d\n' 8 @@ -173,13 +198,13 @@ case $tool in printf '%s\n' "$MOCK_UPSTREAM_TAG_OBJECT" fi ;; - repos/lyc-aon/oh-my-pi/git/ref/tags/t4code-1.2.3-appserver-1) + repos/wolfiesch/oh-my-pi/git/ref/tags/t4code-1.2.3-appserver-1) printf '%s\n' "$MOCK_INTEGRATION_TAG_OBJECT" ;; repos/can1357/oh-my-pi/commits/main) printf '%s\n' "$MOCK_MAIN_COMMIT" ;; - repos/lyc-aon/oh-my-pi/commits/main) + repos/wolfiesch/oh-my-pi/commits/main) if [[ -f $state/fork-main-synced ]]; then printf '%s\n' "$MOCK_MAIN_COMMIT" elif [[ \${MOCK_FORK_MAIN_DIVERGED:-0} == 1 ]]; then @@ -190,15 +215,15 @@ case $tool in printf '%s\n' "$MOCK_MAIN_COMMIT" fi ;; - repos/lyc-aon/oh-my-pi/actions/workflows/ci.yml) + repos/wolfiesch/oh-my-pi/actions/workflows/ci.yml) printf '%s\n' "$(read_state fork-workflow active)" ;; - repos/lyc-aon/oh-my-pi/actions/workflows/ci.yml/disable) + repos/wolfiesch/oh-my-pi/actions/workflows/ci.yml/disable) [[ \${MOCK_FORK_WORKFLOW_DISABLE_FAIL:-0} != 1 ]] || exit 1 write_state fork-workflow disabled_manually printf '{}\n' ;; - repos/lyc-aon/oh-my-pi/actions/workflows/ci.yml/enable) + repos/wolfiesch/oh-my-pi/actions/workflows/ci.yml/enable) [[ \${MOCK_FORK_WORKFLOW_ENABLE_FAIL:-0} != 1 ]] || exit 1 write_state fork-workflow active printf '{}\n' @@ -219,7 +244,10 @@ case $tool in printf '{"desktop":{"version":"1.2.3"},"verifiedRuntime":{"upstreamTag":"%s","upstreamCommit":"%s","sourceTag":"t4code-1.2.3-appserver-1","sourceCommit":"%s"}}\n' \ "$upstream_tag" "$MOCK_UPSTREAM_COMMIT" "$MOCK_INTEGRATION_COMMIT" ;; - repos/lyc-aon/oh-my-pi/commits/t4code-1.2.3-appserver-1) + repos/wolfiesch/oh-my-pi/commits/t4code-1.2.3-appserver-1) + printf '%s\n' "$MOCK_INTEGRATION_COMMIT" + ;; + repos/wolfiesch/oh-my-pi/commits/t4code/main) printf '%s\n' "$MOCK_INTEGRATION_COMMIT" ;; repos/LycaonLLC/t4-code/commits/main) @@ -235,7 +263,7 @@ case $tool in repos/LycaonLLC/t4-code/commits/v1.2.3) printf '%s\n' "$MOCK_T4_COMMIT" ;; - repos/lyc-aon/oh-my-pi/compare/*) + repos/wolfiesch/oh-my-pi/compare/*) if [[ $endpoint == *"$MOCK_INTEGRATION_COMMIT...t4code/main" && \${MOCK_PRODUCT_BRANCH_MISSING:-0} != 1 ]]; then printf '{"status":"ahead","ahead_by":1,"base_commit":{"sha":"%s"},"merge_base_commit":{"sha":"%s"},"commits":[]}\n' \ @@ -284,7 +312,7 @@ JSON JSON fi ;; - repos/lyc-aon/oh-my-pi/actions/workflows/ci.yml/runs*) + repos/wolfiesch/oh-my-pi/actions/workflows/ci.yml/runs*) if [[ $endpoint == *'branch=main'* && $endpoint == *"head_sha=$MOCK_MAIN_COMMIT"* ]]; then [[ \${MOCK_FORK_MAIN_RUN_LIST_FAIL:-0} != 1 ]] || exit 1 if [[ \${MOCK_FORK_MAIN_RUN_MALFORMED:-0} == 1 ]]; then @@ -331,7 +359,7 @@ JSON fi fi ;; - repos/lyc-aon/oh-my-pi/actions/runs/4242/cancel) + repos/wolfiesch/oh-my-pi/actions/runs/4242/cancel) if [[ \${MOCK_FORK_MAIN_RUN_CANCEL_STUCK:-0} != 1 ]]; then write_state fork-main-run-cancelled 1 fi @@ -343,7 +371,7 @@ JSON printf '{}\n' fi ;; - repos/lyc-aon/oh-my-pi/releases/tags/t4code-1.2.3-appserver-1) + repos/wolfiesch/oh-my-pi/releases/tags/t4code-1.2.3-appserver-1) omp_digest=$(printf 'mock-asset\n' | sha256sum | awk '{print $1}') omp_asset_prefix='mock://' [[ \${MOCK_OMP_ASSET_WRONG_ORIGIN:-0} != 1 ]] || omp_asset_prefix='https://example.invalid/' @@ -357,7 +385,7 @@ JSON [[ \${MOCK_OMP_ASSET_DIGESTLESS:-0} != 1 ]] || digest='null' [[ $digest == null ]] || digest='"'"$digest"'"' cat < ({ name, size: 11, digest: `sha256:${mockDigest}` })), + })}\n`, + ); const runnerEnv = { ...fixture.env, MOCK_RUNTIME_ROOT: runtimeRoot, @@ -1705,6 +1794,7 @@ async function createRunnerFixture(options = {}) { T4_MAINTAINER_VERIFY_ATTEMPTS: "1", T4_MAINTAINER_VERIFY_INTERVAL_SECONDS: "1", T4_MAINTAINER_ATOMIC_STATE_DIR: atomicState, + T4_MAINTAINER_OMP_AUTHORITY_TRANSFER_FILE: transferProof, ...(options.localDeployFail ? { MOCK_LOCAL_DEPLOY_FAIL: "1" } : {}), ...(options.publicIncompatible ? { MOCK_PUBLIC_INCOMPATIBLE: "1" } : {}), ...(options.mainIncompatible ? { MOCK_MAIN_INCOMPATIBLE: "1" } : {}), @@ -2476,7 +2566,7 @@ test("a divergent fork main is retried and rejected before source staging", asyn assert.notEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); const calls = await fixture.callsText(); assert.equal( - calls.split("\n").filter((line) => line.includes("repos/lyc-aon/oh-my-pi/commits/main")) + calls.split("\n").filter((line) => line.includes("repos/wolfiesch/oh-my-pi/commits/main")) .length, 2, calls, @@ -2500,7 +2590,7 @@ test("a missing or recreated fork base tag is rejected before source staging", a assert.equal( calls .split("\n") - .filter((line) => line.includes("repos/lyc-aon/oh-my-pi/git/ref/tags/v1.2.3")) + .filter((line) => line.includes("repos/wolfiesch/oh-my-pi/git/ref/tags/v1.2.3")) .length, 2, calls, @@ -2519,11 +2609,70 @@ test("public verification rejects a changed fork base-tag object", async (t) => assert.notEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); const calls = await fixture.callsText(); assert.match(calls, /repos\/can1357\/oh-my-pi\/git\/ref\/tags\/v1\.2\.3/mu); - assert.match(calls, /repos\/lyc-aon\/oh-my-pi\/git\/ref\/tags\/v1\.2\.3/mu); + assert.match(calls, /repos\/wolfiesch\/oh-my-pi\/git\/ref\/tags\/v1\.2\.3/mu); assert.equal(calls.split("\n").filter((line) => line.startsWith("local-deploy\t")).length, 0); assert.equal(calls.split("\n").filter((line) => line.startsWith("omp\t")).length, 0); }); +test("legacy atomic receipts remain valid only through the exact transfer proof", async (t) => { + const fixture = await createRunnerFixture({ legacyAtomicReceipt: true }); + t.after(() => fixture.cleanup()); + const result = fixture.runRunner(); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + const processed = JSON.parse(await readFile(fixture.processed, "utf8")); + assert.equal(processed.atomicPublication.forkRepository, "lyc-aon/oh-my-pi"); + + const receiptPath = join( + fixture.maintainerRoot, + "state", + "atomic-publication", + "t4code-1.2.3-appserver-1", + "receipt.json", + ); + const receipt = JSON.parse(await readFile(receiptPath, "utf8")); + assert.equal(receipt.forkRepository, "lyc-aon/oh-my-pi"); +}); + +test("legacy transfer proof accepts the exact base commit without recreating the fork tag", async (t) => { + const fixture = await createRunnerFixture({ + legacyAtomicReceipt: true, + forkBaseTagMissing: true, + }); + t.after(() => fixture.cleanup()); + const result = fixture.runRunner(); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + const calls = await fixture.callsText(); + assert.match(calls, /repos\/wolfiesch\/oh-my-pi\/commits\/[0-9a-f]{40}/mu); +}); + +test("fork repository identity drift blocks every fork-main mutation", async (t) => { + for (const option of [ + "ompOfficialIdMismatch", + "ompOfficialCloneMismatch", + "ompForkIdMismatch", + "ompForkNodeMismatch", + "ompForkParentMismatch", + "ompForkCloneMismatch", + ]) { + await t.test(option, async (subtest) => { + const fixture = await createRunnerFixture({ + forkMainBehind: true, + localDeployFail: true, + [option]: true, + }); + subtest.after(() => fixture.cleanup()); + const result = fixture.runRunner(); + assert.notEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /repository identity does not match/u); + const calls = await fixture.callsText(); + assert.doesNotMatch(calls, /actions\/workflows\/ci\.yml\/(?:disable|enable)/u); + assert.doesNotMatch(calls, /actions\/runs\/4242\/cancel/u); + assert.equal(await pathExists(join(fixture.state, "fork-main-synced")), false); + assert.equal(calls.split("\n").filter((line) => line.startsWith("omp\t")).length, 0); + }); + } +}); + test("site release-manifest drift blocks public verification and local work", async (t) => { const cases = [ ["schema", "schema"], @@ -2630,7 +2779,7 @@ test("integration tags must remain reachable from the durable fork product branc const calls = await fixture.callsText(); assert.match( calls, - /repos\/lyc-aon\/oh-my-pi\/compare\/b{40}\.\.\.t4code\/main/mu, + /repos\/wolfiesch\/oh-my-pi\/compare\/b{40}\.\.\.t4code\/main/mu, ); assert.equal(calls.split("\n").filter((line) => line.startsWith("local-deploy\t")).length, 0); assert.equal(calls.split("\n").filter((line) => line.startsWith("omp\t")).length, 0);