From 82cf51659d2f858933d4523b1d2b2a9d27fb49f4 Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Sat, 5 Sep 2026 00:25:30 -0500 Subject: [PATCH 1/9] feat: prepare updater recovery and opt-in continuity integration --- .github/workflows/prerelease.yml | 91 +++ .github/workflows/release.yml | 103 ++- docs/release-channels-and-continuity.md | 52 ++ src/mendcode/install | 350 ++++----- src/mendcode/install.ps1 | 425 ++++++++--- .../migration.sql | 13 + .../packages/opencode/src/cli/cmd/run.ts | 695 ++++++++++-------- .../packages/opencode/src/cli/cmd/stats.ts | 25 +- .../packages/opencode/src/cli/cmd/tui/app.tsx | 74 +- .../cmd/tui/component/agent-command-panel.tsx | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 175 ++++- .../opencode/src/cli/cmd/tui/shared-server.ts | 54 +- .../opencode/src/cli/cmd/tui/thread.ts | 51 +- .../opencode/src/cli/cmd/tui/worker.ts | 3 + .../packages/opencode/src/cli/cmd/upgrade.ts | 69 +- .../opencode/src/cli/shared-client.ts | 17 + .../packages/opencode/src/config/config.ts | 23 +- src/mendcode/packages/opencode/src/index.ts | 55 +- .../opencode/src/installation/index.ts | 114 ++- .../opencode/src/installation/progress.ts | 41 ++ .../src/installation/release-channel.ts | 51 ++ .../src/installation/release-index.ts | 129 ++++ .../opencode/src/installation/rollback.ts | 117 +++ .../opencode/src/installation/startup.ts | 123 ++++ .../opencode/src/mend/prompt/model-family.ts | 37 + .../src/mend/prompt/reasoning-auto.ts | 38 + .../src/mend/prompt/reasoning-state.ts | 38 + .../src/mend/prompt/runtime-capabilities.ts | 40 + .../opencode/src/mend/prompt/sources.ts | 17 +- .../opencode/src/mend/tui/widgets-runtime.tsx | 139 ++++ .../opencode/src/mend/tui/widgets-tray.tsx | 43 ++ .../packages/opencode/src/plugin/codex.ts | 7 +- .../packages/opencode/src/question/index.ts | 121 ++- .../opencode/src/server/routes/global.ts | 8 +- .../src/server/routes/instance/continuity.ts | 70 ++ .../src/server/routes/instance/httpapi/api.ts | 2 + .../instance/httpapi/groups/continuity.ts | 41 ++ .../routes/instance/httpapi/groups/global.ts | 4 + .../instance/httpapi/handlers/continuity.ts | 82 +++ .../instance/httpapi/handlers/global.ts | 7 +- .../server/routes/instance/httpapi/server.ts | 2 + .../src/server/routes/instance/index.ts | 4 + .../src/server/routes/instance/usage.ts | 26 + .../opencode/src/server/upgrade-progress.ts | 29 + .../src/session/continuity-control.ts | 23 + .../opencode/src/session/continuity.sql.ts | 20 + .../packages/opencode/src/session/llm.ts | 48 +- .../packages/opencode/src/session/prompt.ts | 93 ++- .../opencode/src/session/runtime-mailbox.ts | 192 +++++ .../packages/opencode/src/session/system.ts | 2 +- .../opencode/src/session/tool-jobs.ts | 189 +++++ .../opencode/src/storage/compatibility.ts | 139 ++++ .../packages/opencode/src/storage/db.bun.ts | 7 +- .../packages/opencode/src/storage/db.node.ts | 6 +- .../packages/opencode/src/storage/db.ts | 121 ++- .../opencode/src/storage/migration-journal.ts | 47 ++ .../storage/resolve-default-sqlite-path.ts | 2 +- .../opencode/src/storage/startup-migration.ts | 41 ++ .../opencode/src/storage/writer-lease.ts | 63 ++ .../packages/opencode/src/tool/continuity.ts | 221 ++++++ .../opencode/src/tool/reasoning_auto.ts | 26 + .../packages/opencode/src/tool/registry.ts | 44 +- .../opencode/src/tool/session-notes.ts | 43 ++ .../opencode/test/cli/run-attach.test.ts | 130 ++++ .../test/cli/tui/agent-command-panel.test.tsx | 40 +- .../test/cli/tui/shared-server.test.ts | 54 ++ .../opencode/test/cli/tui/thread.test.ts | 42 ++ .../test/cli/tui/widgets-runtime.test.tsx | 100 +++ .../test/cli/tui/widgets-tray.test.tsx | 72 ++ .../opencode/test/cli/upgrade-channel.test.ts | 20 + .../test/cli/upgrade-readonly.test.ts | 38 + .../test/installation/install-layout.test.ts | 107 ++- .../test/installation/installation.test.ts | 82 ++- .../test/installation/progress.test.ts | 29 + .../test/installation/release-channel.test.ts | 23 + .../test/installation/release-index.test.ts | 92 +++ .../test/installation/rollback.test.ts | 98 +++ .../test/installation/startup.test.ts | 78 ++ .../test/mend/concurrent-model-policy.test.ts | 71 ++ .../test/server/release-channel.test.ts | 30 + .../test/server/upgrade-progress.test.ts | 26 + .../opencode/test/server/usage.test.ts | 83 +++ .../opencode/test/session/continuity.test.ts | 263 +++++++ .../opencode/test/session/llm.test.ts | 30 +- .../opencode/test/session/prompt.test.ts | 61 +- .../test/session/session-notes.test.ts | 28 + .../test/storage/compatibility.test.ts | 81 ++ .../test/storage/writer-lease.test.ts | 50 ++ src/mendcode/script/release-index.mjs | 76 ++ src/mendcode/script/release-index.test.mjs | 45 ++ .../script/windows-installer-smoke.ts | 73 ++ 91 files changed, 6026 insertions(+), 830 deletions(-) create mode 100644 .github/workflows/prerelease.yml create mode 100644 docs/release-channels-and-continuity.md create mode 100644 src/mendcode/packages/opencode/migration/20260904120000_continuity_runtime/migration.sql create mode 100644 src/mendcode/packages/opencode/src/cli/shared-client.ts create mode 100644 src/mendcode/packages/opencode/src/installation/progress.ts create mode 100644 src/mendcode/packages/opencode/src/installation/release-channel.ts create mode 100644 src/mendcode/packages/opencode/src/installation/release-index.ts create mode 100644 src/mendcode/packages/opencode/src/installation/rollback.ts create mode 100644 src/mendcode/packages/opencode/src/installation/startup.ts create mode 100644 src/mendcode/packages/opencode/src/mend/prompt/model-family.ts create mode 100644 src/mendcode/packages/opencode/src/mend/prompt/reasoning-auto.ts create mode 100644 src/mendcode/packages/opencode/src/mend/prompt/reasoning-state.ts create mode 100644 src/mendcode/packages/opencode/src/mend/prompt/runtime-capabilities.ts create mode 100644 src/mendcode/packages/opencode/src/mend/tui/widgets-runtime.tsx create mode 100644 src/mendcode/packages/opencode/src/mend/tui/widgets-tray.tsx create mode 100644 src/mendcode/packages/opencode/src/server/routes/instance/continuity.ts create mode 100644 src/mendcode/packages/opencode/src/server/routes/instance/httpapi/groups/continuity.ts create mode 100644 src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/continuity.ts create mode 100644 src/mendcode/packages/opencode/src/server/routes/instance/usage.ts create mode 100644 src/mendcode/packages/opencode/src/server/upgrade-progress.ts create mode 100644 src/mendcode/packages/opencode/src/session/continuity-control.ts create mode 100644 src/mendcode/packages/opencode/src/session/continuity.sql.ts create mode 100644 src/mendcode/packages/opencode/src/session/runtime-mailbox.ts create mode 100644 src/mendcode/packages/opencode/src/session/tool-jobs.ts create mode 100644 src/mendcode/packages/opencode/src/storage/compatibility.ts create mode 100644 src/mendcode/packages/opencode/src/storage/migration-journal.ts create mode 100644 src/mendcode/packages/opencode/src/storage/startup-migration.ts create mode 100644 src/mendcode/packages/opencode/src/storage/writer-lease.ts create mode 100644 src/mendcode/packages/opencode/src/tool/continuity.ts create mode 100644 src/mendcode/packages/opencode/src/tool/reasoning_auto.ts create mode 100644 src/mendcode/packages/opencode/src/tool/session-notes.ts create mode 100644 src/mendcode/packages/opencode/test/cli/run-attach.test.ts create mode 100644 src/mendcode/packages/opencode/test/cli/tui/widgets-runtime.test.tsx create mode 100644 src/mendcode/packages/opencode/test/cli/tui/widgets-tray.test.tsx create mode 100644 src/mendcode/packages/opencode/test/cli/upgrade-channel.test.ts create mode 100644 src/mendcode/packages/opencode/test/cli/upgrade-readonly.test.ts create mode 100644 src/mendcode/packages/opencode/test/installation/progress.test.ts create mode 100644 src/mendcode/packages/opencode/test/installation/release-channel.test.ts create mode 100644 src/mendcode/packages/opencode/test/installation/release-index.test.ts create mode 100644 src/mendcode/packages/opencode/test/installation/rollback.test.ts create mode 100644 src/mendcode/packages/opencode/test/installation/startup.test.ts create mode 100644 src/mendcode/packages/opencode/test/mend/concurrent-model-policy.test.ts create mode 100644 src/mendcode/packages/opencode/test/server/release-channel.test.ts create mode 100644 src/mendcode/packages/opencode/test/server/upgrade-progress.test.ts create mode 100644 src/mendcode/packages/opencode/test/server/usage.test.ts create mode 100644 src/mendcode/packages/opencode/test/session/continuity.test.ts create mode 100644 src/mendcode/packages/opencode/test/session/session-notes.test.ts create mode 100644 src/mendcode/packages/opencode/test/storage/compatibility.test.ts create mode 100644 src/mendcode/packages/opencode/test/storage/writer-lease.test.ts create mode 100644 src/mendcode/script/release-index.mjs create mode 100644 src/mendcode/script/release-index.test.mjs create mode 100644 src/mendcode/script/windows-installer-smoke.ts diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml new file mode 100644 index 00000000..5b4ca41e --- /dev/null +++ b/.github/workflows/prerelease.yml @@ -0,0 +1,91 @@ +name: Prerelease candidate + +on: + schedule: + - cron: "23 6 * * *" + workflow_dispatch: + inputs: + channel: + description: "Candidate channel" + type: choice + options: [beta, nightly] + default: beta + required: true + beta_number: + description: "Positive beta sequence, required for beta" + type: string + required: false + dry_run: + description: "Keep artifacts in Actions without creating a release draft" + type: boolean + default: true + required: true + +permissions: + contents: read + +concurrency: + group: prerelease-candidate + cancel-in-progress: false + +jobs: + select: + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.candidate.outputs.changed }} + version: ${{ steps.candidate.outputs.version }} + sha: ${{ steps.candidate.outputs.sha }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + with: + ref: dev + fetch-depth: 0 + persist-credentials: false + - name: Select immutable candidate + id: candidate + shell: bash + env: + CHANNEL: ${{ inputs.channel || 'nightly' }} + BETA_NUMBER: ${{ inputs.beta_number }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + sha="$(git rev-parse HEAD)" + base="$(node -p 'JSON.parse(require("fs").readFileSync("src/mendcode/packages/opencode/package.json", "utf8")).version')" + [[ "$base" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Package version must be stable semver" >&2; exit 1; } + if [ "$CHANNEL" = nightly ]; then + # Tags are never moved. Any successful draft on this source already + # represents the daily candidate, including reruns and manual runs. + previous="$(gh api "repos/${GH_REPO}/releases?per_page=100" --jq '.[] | select(.tag_name | test("^v[0-9]+\\.[0-9]+\\.[0-9]+-nightly\\.")) | .target_commitish')" + if git tag --points-at "$sha" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+-nightly\.' || printf '%s\n' "$previous" | grep -Fxq "$sha"; then + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + version="${base}-nightly.$(date -u +%Y%m%d).${GITHUB_RUN_ID}" + elif [ "$CHANNEL" = beta ]; then + [[ "$BETA_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "beta_number must be positive" >&2; exit 1; } + version="${base}-beta.${BETA_NUMBER}" + else + echo "Unsupported prerelease channel" >&2 + exit 1 + fi + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + build: + needs: select + if: needs.select.outputs.changed == 'true' + permissions: + contents: write + attestations: write + id-token: write + uses: ./.github/workflows/release.yml + with: + version: ${{ needs.select.outputs.version }} + source_sha: ${{ needs.select.outputs.sha }} + prerelease: true + # Scheduled runs create reviewable drafts. Publication still requires the + # platform acceptance gates and an explicit promotion; never age-based. + dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8c1aa8b..ab9dcb2c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,24 @@ on: required: true default: false type: boolean + source_sha: + description: "Exact candidate commit already tested; required when publishing" + required: true + type: string + workflow_call: + inputs: + version: + required: true + type: string + dry_run: + required: true + type: boolean + prerelease: + required: true + type: boolean + source_sha: + required: true + type: string permissions: contents: read @@ -50,12 +68,19 @@ jobs: echo "version must be semver without leading v: ${VERSION}" >&2 exit 1 fi + if [[ ! "${SOURCE_SHA}" =~ ^[a-f0-9]{40}$ ]]; then + echo "source_sha must be the exact candidate commit" >&2 + exit 1 + fi + env: + SOURCE_SHA: ${{ inputs.source_sha }} - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with: fetch-depth: 0 persist-credentials: false + ref: ${{ inputs.source_sha }} - name: Ensure release runs from main if: ${{ inputs.dry_run == false }} @@ -63,10 +88,29 @@ jobs: run: | set -euo pipefail branch="${GITHUB_REF_NAME:-}" - if [ "$branch" != "main" ]; then + if [ "$PRERELEASE" = "false" ] && [ "$branch" != "main" ]; then echo "publishing releases is only allowed from main; current ref is ${branch}" >&2 exit 1 fi + if [ "$PRERELEASE" = "false" ]; then + git merge-base --is-ancestor HEAD origin/main + else + git merge-base --is-ancestor HEAD origin/dev + fi + env: + PRERELEASE: ${{ inputs.prerelease }} + + - name: Validate channel and immutable version + shell: bash + env: + PRERELEASE: ${{ inputs.prerelease }} + run: | + set -euo pipefail + node --input-type=module -e 'import {releaseChannel} from "./src/mendcode/script/release-index.mjs"; if ((releaseChannel(process.env.VERSION) !== "stable") !== (process.env.PRERELEASE === "true")) throw Error("Version and prerelease flag disagree")' + if git rev-parse --verify "refs/tags/v${VERSION}" >/dev/null 2>&1; then + echo "Release tag already exists; releases are immutable" >&2 + exit 1 + fi - name: Supply-chain preflight shell: bash @@ -106,6 +150,17 @@ jobs: set -euo pipefail bun run --cwd packages/opencode script/build.ts --release-assets --skip-embed-web-ui --skip-install --exclude-os=darwin + - name: Verify release contracts and types + working-directory: src/mendcode + shell: bash + run: | + set -euo pipefail + node --test script/release-index.test.mjs + bun run --cwd packages/opencode typecheck + export MENDCODE_DB="${TMPDIR:-/tmp}/opencode-test-data-$$/share/release.db" + cd packages/opencode + exec bun test test/installation test/cli/upgrade-channel.test.ts test/cli/tui/shared-server.test.ts test/cli/tui/thread.test.ts + - name: Upload release artifacts to workflow uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: @@ -146,6 +201,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false + ref: ${{ inputs.source_sha }} - name: Setup pnpm uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 @@ -194,6 +250,17 @@ jobs: Compress-Archive -Path "$($_.FullName)/bin/*" -DestinationPath "packages/opencode/dist/$($_.Name).zip" -Force } + - name: Verify native Windows installer recovery and failure preservation + working-directory: src/mendcode + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $candidate = Get-ChildItem packages/opencode/dist/mendcode-windows-${{ matrix.arch }}*/bin/mendcode.exe | Select-Object -First 1 + if (-not $candidate) { throw "Native installer test executable is missing" } + $env:MENDCODE_INSTALLER_TEST_BINARY = $candidate.FullName + bun run script/windows-installer-smoke.ts + if ($LASTEXITCODE -ne 0) { throw "Native installer acceptance failed" } + - name: Upload Windows release artifacts to workflow uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: @@ -228,6 +295,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false + ref: ${{ inputs.source_sha }} - name: Setup pnpm uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 @@ -295,6 +363,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false + ref: ${{ inputs.source_sha }} - name: Download platform release assets uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -322,12 +391,22 @@ jobs: { echo "version=${VERSION}" echo "repo=${GITHUB_REPOSITORY}" - echo "sha=${GITHUB_SHA}" + echo "sha=${RELEASE_COMMIT}" echo "run=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" echo cat SHA256SUMS } > RELEASE-MANIFEST.txt + env: + RELEASE_COMMIT: ${{ inputs.source_sha }} + + - name: Write versioned release index + env: + RELEASE_COMMIT: ${{ inputs.source_sha }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT" + node src/mendcode/script/release-index.mjs dist - name: Generate SBOM uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 @@ -345,6 +424,7 @@ jobs: dist/*.tar.gz dist/SHA256SUMS dist/RELEASE-MANIFEST.txt + dist/release-index.json dist/mendcode.spdx.json - name: Upload assembled release artifacts to workflow @@ -372,6 +452,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with: persist-credentials: false + ref: ${{ inputs.source_sha }} - name: Download release artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -402,21 +483,24 @@ jobs: fi done < "${GITHUB_WORKSPACE}/CHANGELOG.md" } > "$notes" - if [[ "$section_found" != true ]]; then + if [[ "$section_found" != true && "$PRERELEASE" != "true" ]]; then echo "Missing CHANGELOG.md section for ${VERSION}" >&2 exit 1 fi + if [[ "$PRERELEASE" == "true" ]]; then + printf '\nExperimental prerelease from %s. Concurrency remains opt-in.\n' "$RELEASE_COMMIT" >> "$notes" + fi cat >> "$notes" <&2 shift @@ -203,6 +208,81 @@ persist_install_layout() { mv "$tmp" "$GLOBAL_LAYOUT_SELECTION_FILE" } +operation="" +update_phase=checking +installed_sha256="" +previous_sha256="" +download_pid="" +update_lock="$INSTALL_DIR/.update-lock" +owns_update_lock=false + +record_update() { + [ -n "$operation" ] || return 0 + printf 'version=%s\nphase=%s\nowner_pid=%s\nexit_code=%s\nbinary_sha256=%s\nprevious_sha256=%s\n' \ + "${specific_version:-${requested_version:-unknown}}" "$update_phase" "$$" "${1:-}" "$installed_sha256" "$previous_sha256" > "$operation/status.next" + mv -f "$operation/status.next" "$operation/status" + printf 'MENDCODE_UPDATE_PHASE=%s\n' "$update_phase" +} + +finish_update() { + local code=$? + trap - EXIT + if [ -n "$download_pid" ]; then + kill -KILL "$download_pid" 2>/dev/null || true + wait "$download_pid" 2>/dev/null || true + fi + if [ "$owns_update_lock" = true ]; then + if [ "$code" -ne 0 ]; then + print_message error "Update failed during $update_phase. Recovery record: $operation/status" + fi + record_update "$code" || true + # Retain ownership evidence without leaving a lock after a normal exit. + mv "$update_lock" "$operation/lock" || true + fi + exit "$code" +} + +begin_update() { + operation=$(mktemp -d "$INSTALL_DIR/.update.XXXXXXXX") + chmod 700 "$operation" + if ! mkdir "$update_lock" 2>/dev/null; then + if [ "$recover_lock" != true ]; then + print_message error "Another update owns $update_lock. Inspect its owner record; use --recover-lock only after its process has exited." + exit 1 + fi + local owner + owner=$(sed -n 's/^pid=//p' "$update_lock/owner" 2>/dev/null || true) + if [[ ! "$owner" =~ ^[1-9][0-9]*$ ]] || kill -0 "$owner" 2>/dev/null || ps -p "$owner" >/dev/null 2>&1; then + print_message error "Cannot recover update lock: owner is live or ownership cannot be verified." + exit 1 + fi + # Elect one recoverer inside the old lock; contenders cannot move a new owner's lock. + if ! mkdir "$update_lock/recovering" 2>/dev/null; then + print_message error "Update lock recovery is already in progress." + exit 1 + fi + if [ "$(sed -n 's/^pid=//p' "$update_lock/owner" 2>/dev/null || true)" != "$owner" ] || \ + kill -0 "$owner" 2>/dev/null || ps -p "$owner" >/dev/null 2>&1; then + print_message error "Update lock ownership changed during recovery. No installation was changed." + exit 1 + fi + mv "$update_lock" "$operation/recovered-lock" + if ! mkdir "$update_lock" 2>/dev/null; then + print_message error "Another updater acquired the recovered lock. Retry after it exits." + exit 1 + fi + fi + owns_update_lock=true + trap finish_update EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + chmod 700 "$update_lock" + printf 'pid=%s\noperation=%s\n' "$$" "$operation" > "$update_lock/owner" + record_update +} + +begin_update + # If --binary is provided, skip all download/detection logic if [ -n "$binary_path" ]; then if [ ! -f "$binary_path" ]; then @@ -316,8 +396,7 @@ else fi if [ -z "$requested_version" ]; then - url="$GITHUB_BASE_URL/releases/latest/download/$filename" - specific_version=$(curl -s "$GITHUB_API_URL/releases/latest" | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p') + specific_version=$(curl --fail --show-error --silent --location --connect-timeout 15 --max-time 30 "$GITHUB_API_URL/releases/latest" | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p') if [[ $? -ne 0 || -z "$specific_version" ]]; then echo -e "${RED}Failed to fetch version information${NC}" @@ -329,14 +408,13 @@ else url="$GITHUB_BASE_URL/releases/download/v${requested_version}/$filename" specific_version=$requested_version - # Verify the release exists before downloading - http_status=$(curl -sI -o /dev/null -w "%{http_code}" "$GITHUB_BASE_URL/releases/tag/v${requested_version}") - if [ "$http_status" = "404" ]; then - echo -e "${RED}Error: Release v${requested_version} not found${NC}" - echo -e "${MUTED}Available releases: $GITHUB_BASE_URL/releases${NC}" - exit 1 - fi fi + # Resolve latest once so the archive and checksums always belong to one release. + if [[ ! "$specific_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][a-zA-Z0-9.+-]+)?$ ]]; then + print_message error "Invalid release version: $specific_version" + exit 1 + fi + url="$GITHUB_BASE_URL/releases/download/v${specific_version}/$filename" fi print_banner() { @@ -371,151 +449,117 @@ print_success() { STEP_TOTAL=4 PATH_STEP=4 -check_version() { - local installed_path="$INSTALL_DIR/$BINARY_NAME" - if [ "$os" = "windows" ] && [ -f "$INSTALL_DIR/$BINARY_NAME.exe" ]; then - installed_path="$INSTALL_DIR/$BINARY_NAME.exe" - fi - - if [ -x "$installed_path" ]; then - ## Check the installed version - installed_version=$("$installed_path" --version 2>/dev/null || echo "") - - if [[ "$installed_version" != "$specific_version" ]]; then - print_message info "${MUTED}Installed version: ${NC}$installed_version." - else - print_message info "${MUTED}Version ${NC}$specific_version${MUTED} already installed" - already_installed=true - fi - fi -} - -unbuffered_sed() { - if echo | sed -u -e "" >/dev/null 2>&1; then - sed -nu "$@" - elif echo | sed -l -e "" >/dev/null 2>&1; then - sed -nl "$@" - else - local pad="$(printf "\n%512s" "")" - sed -ne "s/$/\\${pad}/" "$@" - fi -} - -print_progress() { - local bytes="$1" - local length="$2" - [ "$length" -gt 0 ] || return 0 - - local width=50 - local percent=$(( bytes * 100 / length )) - [ "$percent" -gt 100 ] && percent=100 - local on=$(( percent * width / 100 )) - local off=$(( width - on )) - - local filled=$(printf "%*s" "$on" "") - filled=${filled// /█} - local empty=$(printf "%*s" "$off" "") - empty=${empty// /·} - - printf "\r${MOSS_SOFT}%s%s${NC} ${PARCHMENT}%3d%%${NC}" "$filled" "$empty" "$percent" >&4 -} - -download_with_progress() { - local url="$1" - local output="$2" - - if [ -t 2 ]; then - exec 4>&2 - else - exec 4>/dev/null - fi - - local tmp_dir=${TMPDIR:-/tmp} - local basename="${tmp_dir}/mendcode_install_$$" - local tracefile="${basename}.trace" - - rm -f "$tracefile" - mkfifo "$tracefile" - - # Hide cursor - printf "\033[?25l" >&4 - - trap "trap - RETURN; rm -f \"$tracefile\"; printf '\033[?25h' >&4; exec 4>&-" RETURN - - ( - curl --trace-ascii "$tracefile" -s -L -o "$output" "$url" - ) & - local curl_pid=$! - - unbuffered_sed \ - -e 'y/ACDEGHLNORTV/acdeghlnortv/' \ - -e '/^0000: content-length:/p' \ - -e '/^<= recv data/p' \ - "$tracefile" | \ - { - local length=0 - local bytes=0 - - while IFS=" " read -r -a line; do - [ "${#line[@]}" -lt 2 ] && continue - local tag="${line[0]} ${line[1]}" - - if [ "$tag" = "0000: content-length:" ]; then - length="${line[2]}" - length=$(echo "$length" | tr -d '\r') - bytes=0 - elif [ "$tag" = "<= recv" ]; then - local size="${line[3]}" - bytes=$(( bytes + size )) - if [ "$length" -gt 0 ]; then - print_progress "$bytes" "$length" - fi - fi - done - } - - wait $curl_pid - local ret=$? - echo "" >&4 - return $ret -} - download_and_install() { print_banner print_step 1 4 "Preparing download" - local tmp_dir="${TMPDIR:-/tmp}/mendcode_install_$$" - mkdir -p "$tmp_dir" + local tmp_dir=$operation print_step 2 4 "Downloading release asset" - if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then - # Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails - curl -# -L -o "$tmp_dir/$filename" "$url" + update_phase=downloading + record_update + curl --fail --show-error --silent --location --connect-timeout 15 --speed-limit 1 --speed-time 60 --max-time 900 \ + --dump-header "$tmp_dir/download.headers" -o "$tmp_dir/$filename" "$url" & + download_pid=$! + while kill -0 "$download_pid" 2>/dev/null; do + if [ -f "$tmp_dir/$filename" ]; then + local bytes total + bytes=$(wc -c < "$tmp_dir/$filename" | tr -d '[:space:]') + total="" + if [ -f "$tmp_dir/download.headers" ]; then + total=$(awk '/^HTTP\// {size=""} tolower($1) == "content-length:" {size=$2} END {gsub("\\r", "", size); print size}' "$tmp_dir/download.headers") + fi + [[ "$total" =~ ^[0-9]+$ ]] || total="" + printf 'MENDCODE_UPDATE_BYTES=%s/%s\n' "$bytes" "$total" + fi + sleep 1 + done + if ! wait "$download_pid"; then + download_pid="" + print_message error "Downloading failed. Current binary preserved; staging: $tmp_dir" + exit 1 fi + download_pid="" + printf 'MENDCODE_UPDATE_BYTES=%s/\n' "$(wc -c < "$tmp_dir/$filename" | tr -d '[:space:]')" print_success "Downloaded $filename" - print_step 3 4 "Installing binary" - if [ "$os" = "linux" ]; then - tar -xzf "$tmp_dir/$filename" -C "$tmp_dir" + print_step 3 4 "Verifying release checksum" + update_phase=verifying + record_update + if [ -n "${MENDCODE_VERIFIED_SUMS_FILE:-}" ]; then + cp "$MENDCODE_VERIFIED_SUMS_FILE" "$tmp_dir/SHA256SUMS" + elif ! curl --fail --show-error --silent --location --connect-timeout 15 --max-time 30 \ + -o "$tmp_dir/SHA256SUMS" "${url%/*}/SHA256SUMS"; then + print_message error "Verifying failed: cannot download SHA256SUMS. Current binary preserved." + exit 1 + fi + local expected actual + expected=$(awk -v name="$filename" '$2 == name || $2 == "./" name {print $1}' "$tmp_dir/SHA256SUMS") + if [[ ! "$expected" =~ ^[a-fA-F0-9]{64}$ ]]; then + print_message error "Verifying failed: missing, duplicate, or invalid checksum for $filename." + exit 1 + fi + if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$tmp_dir/$filename" | awk '{print $1}') + elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$tmp_dir/$filename" | awk '{print $1}') else - unzip -q "$tmp_dir/$filename" -d "$tmp_dir" + print_message error "Verifying failed: sha256sum or shasum is required." + exit 1 fi - + if [[ "$(printf '%s' "$actual" | tr 'A-F' 'a-f')" != "$(printf '%s' "$expected" | tr 'A-F' 'a-f')" ]]; then + print_message error "Verifying failed: checksum mismatch. Current binary preserved; staging: $tmp_dir" + exit 1 + fi + # Extract only the executable, never archive-controlled paths or symlinks. local extracted="$tmp_dir/$ARCHIVE_BINARY" - if [ "$os" = "windows" ] && [ -f "$tmp_dir/$ARCHIVE_BINARY.exe" ]; then - extracted="$tmp_dir/$ARCHIVE_BINARY.exe" - BINARY_NAME=mendcode.exe + if [ "$os" = "linux" ]; then + tar -xOzf "$tmp_dir/$filename" "$ARCHIVE_BINARY" > "$extracted" + else + if [ "$os" = "windows" ]; then + BINARY_NAME=mendcode.exe + extracted="$tmp_dir/$BINARY_NAME" + unzip -p "$tmp_dir/$filename" "$ARCHIVE_BINARY.exe" > "$extracted" + else + unzip -p "$tmp_dir/$filename" "$ARCHIVE_BINARY" > "$extracted" + fi fi - if [ ! -f "$extracted" ]; then + if [ ! -s "$extracted" ]; then echo -e "${RED}Error: Release asset did not contain $ARCHIVE_BINARY${NC}" exit 1 fi - mv "$extracted" "$INSTALL_DIR/$BINARY_NAME" - chmod 755 "${INSTALL_DIR}/$BINARY_NAME" - rm -rf "$tmp_dir" + activate_binary "$extracted" "$tmp_dir" print_success "Installed ${INSTALL_DIR}/$BINARY_NAME" } +activate_binary() { + local staged=$1 + local operation=$2 + if command -v sha256sum >/dev/null 2>&1; then + installed_sha256=$(sha256sum "$staged" | awk '{print $1}') + elif command -v shasum >/dev/null 2>&1; then + installed_sha256=$(shasum -a 256 "$staged" | awk '{print $1}') + else + print_message error "Cannot record executable identity: sha256sum or shasum is required." + exit 1 + fi + chmod 755 "$staged" + if [ -f "$INSTALL_DIR/$BINARY_NAME" ]; then + cp -p "$INSTALL_DIR/$BINARY_NAME" "$operation/previous" + if command -v sha256sum >/dev/null 2>&1; then + previous_sha256=$(sha256sum "$operation/previous" | awk '{print $1}') + else + previous_sha256=$(shasum -a 256 "$operation/previous" | awk '{print $1}') + fi + fi + update_phase=activating + record_update + mv -f "$staged" "$INSTALL_DIR/$BINARY_NAME" + update_phase=activated + record_update + print_message info "Recovery files retained at $operation; startup readiness has not been checked." +} + install_from_binary() { specific_version="local" target="local" @@ -523,45 +567,21 @@ install_from_binary() { PATH_STEP=2 print_banner print_step 1 "$STEP_TOTAL" "Installing local binary" - cp "$binary_path" "${INSTALL_DIR}/$BINARY_NAME" - chmod 755 "${INSTALL_DIR}/$BINARY_NAME" + cp "$binary_path" "$operation/candidate" + activate_binary "$operation/candidate" "$operation" print_success "Installed ${INSTALL_DIR}/$BINARY_NAME" } -restart_loaded_loop_services() { - if [[ "$(uname -s)" != "Darwin" ]] || ! command -v launchctl >/dev/null 2>&1; then - return 0 - fi - - local uid - uid=$(id -u) - local restarted=0 - while IFS= read -r label; do - [[ -n "$label" ]] || continue - if launchctl kickstart -k "gui/${uid}/${label}" >/dev/null 2>&1; then - restarted=$((restarted + 1)) - fi - done < <(launchctl list 2>/dev/null | awk '$3 ~ /^com\\.mendcode\\.loops\\./ { print $3 }') - - if [[ "$restarted" -gt 0 ]]; then - print_success "Refreshed $restarted loaded MendCode loop service(s)" - fi -} - if [ -n "$binary_path" ]; then install_from_binary else - check_version - if [ "$already_installed" != "true" ]; then - download_and_install - fi + # Recovery must work even when the currently installed executable cannot start. + download_and_install fi persist_install_layout -if [ "$already_installed" != "true" ]; then - restart_loaded_loop_services -fi +print_message info "Existing sessions and services keep running. Restart them when their work is finished." add_to_path() { local config_file=$1 diff --git a/src/mendcode/install.ps1 b/src/mendcode/install.ps1 index a34ef67b..879b7735 100644 --- a/src/mendcode/install.ps1 +++ b/src/mendcode/install.ps1 @@ -3,7 +3,11 @@ param( [switch]$NoModifyPath, [switch]$Setup, [switch]$SkipSetup, - [int]$WaitForExitPid = 0 + [int]$WaitForExitPid = 0, + [long]$WaitForExitStartedAt = 0, + [switch]$RecoverLock, + [string]$ActivateOperation, + [string]$OperationToken ) $ErrorActionPreference = "Stop" @@ -14,7 +18,138 @@ $SetupMode = if ($SkipSetup) { "skip" } elseif ($Setup) { "run" } else { "ask" } $Repo = if ($env:MENDCODE_GITHUB_REPO) { $env:MENDCODE_GITHUB_REPO } else { "MendCode/MendCode" } $BaseUrl = if ($env:MENDCODE_GITHUB_BASE_URL) { $env:MENDCODE_GITHUB_BASE_URL } else { "https://github.com/$Repo" } $ApiUrl = if ($env:MENDCODE_GITHUB_API_URL) { $env:MENDCODE_GITHUB_API_URL } else { "https://api.github.com/repos/$Repo" } -$InstallDir = Join-Path $HOME ".mendcode\bin" +$InstallHome = if ($env:OPENCODE_TEST_HOME) { $env:OPENCODE_TEST_HOME } else { $HOME } +$InstallDir = [IO.Path]::GetFullPath((Join-Path $InstallHome ".mendcode\bin")) +$LockDir = Join-Path $InstallDir ".update-lock" +$Utf8 = New-Object System.Text.UTF8Encoding($false) +$script:HaveLock = $false +$script:Operation = $null +$script:Phase = "checking" +$script:BinaryDigest = "" +$script:PreviousDigest = "" +$script:Token = [Guid]::NewGuid().ToString("N") + +function Assert-RegularPath([string]$Path) { + if ((Test-Path -LiteralPath $Path) -and ((Get-Item -LiteralPath $Path -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "Refusing a redirected update path: $Path" + } +} + +function Write-AtomicText([string]$Path, [string]$Text) { + Assert-RegularPath $Path + $temporary = "$Path.$([Guid]::NewGuid().ToString('N')).tmp" + $stream = [IO.File]::Open($temporary, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { + $bytes = $Utf8.GetBytes($Text) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { $stream.Dispose() } + if ([IO.File]::Exists($Path)) { [IO.File]::Replace($temporary, $Path, $null) } + else { [IO.File]::Move($temporary, $Path) } +} + +function Write-UpdateStatus([int]$ExitCode = 0) { + if (-not $script:Operation) { return } + # LF and BOM-free UTF-8 are shared with the startup receipt reader. + Write-AtomicText (Join-Path $script:Operation "status") ( + "version=$Version`nphase=$script:Phase`nowner_pid=$PID`nexit_code=$ExitCode`nbinary_sha256=$script:BinaryDigest`nprevious_sha256=$script:PreviousDigest`n" + ) + Write-Host "MENDCODE_UPDATE_PHASE=$script:Phase" +} + +function Read-LockOwner { + Assert-RegularPath $LockDir + $file = Join-Path $LockDir "owner.json" + Assert-RegularPath $file + if (-not [IO.File]::Exists($file) -or (Get-Item -LiteralPath $file).Length -gt 4096) { throw "Update lock owner cannot be verified." } + $owner = [IO.File]::ReadAllText($file) | ConvertFrom-Json + if (-not $owner.pid -or [long]$owner.pid -le 0 -or -not $owner.token) { throw "Update lock owner is invalid." } + return $owner +} + +function Write-LockOwner { + Write-AtomicText (Join-Path $LockDir "owner.json") ((@{ + pid = $PID; startedAt = (Get-Process -Id $PID).StartTime.ToUniversalTime().Ticks + token = $script:Token; operation = $script:Operation + } | ConvertTo-Json -Compress) + "`n") +} + +function Acquire-UpdateLock { + if (Test-Path -LiteralPath $LockDir) { + $owner = Read-LockOwner + if (-not $RecoverLock -or (Get-Process -Id $owner.pid -ErrorAction SilentlyContinue)) { + throw "Another updater owns the lock (PID $($owner.pid)). Recover only a dead owner with -RecoverLock." + } + # Election prevents concurrent recoverers from moving a newly acquired lock. + $election = [IO.File]::Open((Join-Path $LockDir "recovering"), [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { + $current = Read-LockOwner + if ($current.token -ne $owner.token -or $current.pid -ne $owner.pid -or (Get-Process -Id $current.pid -ErrorAction SilentlyContinue)) { + throw "Update lock ownership changed; recovery stopped." + } + } finally { $election.Dispose() } + [IO.Directory]::Move($LockDir, (Join-Path $script:Operation "recovered-lock")) + } + New-Item -ItemType Directory -Path $LockDir -ErrorAction Stop | Out-Null + $script:HaveLock = $true + Write-LockOwner +} + +function Release-UpdateLock { + if (-not $script:HaveLock) { return } + $owner = Read-LockOwner + if ($owner.token -ne $script:Token -or [long]$owner.pid -ne $PID) { throw "Updater no longer owns its lock." } + [IO.Directory]::Move($LockDir, (Join-Path $script:Operation "lock")) + $script:HaveLock = $false +} + +function Receive-UpdateFile([string]$Url, [string]$Destination, [long]$MaxBytes = 2147483648) { + $uri = [Uri]$Url + if ($uri.Scheme -ne "https" -and -not ($uri.Scheme -eq "http" -and $uri.IsLoopback)) { throw "Downloads require HTTPS." } + Add-Type -AssemblyName System.Net.Http + $client = New-Object System.Net.Http.HttpClient + $client.Timeout = [Threading.Timeout]::InfiniteTimeSpan + $client.DefaultRequestHeaders.UserAgent.ParseAdd("MendCode-Updater") + $cancel = New-Object Threading.CancellationTokenSource + $watch = [Diagnostics.Stopwatch]::StartNew() + $response = $null; $source = $null; $output = $null + try { + # Works on Windows PowerShell 5.1: the header deadline includes connection setup. + $headers = $client.GetAsync($uri, [Net.Http.HttpCompletionOption]::ResponseHeadersRead, $cancel.Token) + if (-not $headers.Wait(15000)) { throw "Connection/response headers timed out after 15 seconds." } + $response = $headers.GetAwaiter().GetResult() + $response.EnsureSuccessStatusCode() | Out-Null + $source = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + $output = [IO.File]::Open($Destination, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::Read) + $buffer = New-Object byte[] 65536 + [long]$received = 0 + $lastReport = -1000 + while ($true) { + $remaining = 900000 - $watch.ElapsedMilliseconds + if ($remaining -le 0) { throw "Download exceeded 15 minutes." } + $read = $source.ReadAsync($buffer, 0, $buffer.Length, $cancel.Token) + if (-not $read.Wait([int][Math]::Min(60000, $remaining))) { throw "Download stalled for 60 seconds or exceeded its total deadline." } + $count = $read.GetAwaiter().GetResult() + if ($count -eq 0) { break } + $received += $count + if ($received -gt $MaxBytes) { throw "Download exceeds the size limit." } + $output.Write($buffer, 0, $count) + if ($watch.ElapsedMilliseconds - $lastReport -ge 1000) { + Write-Host "MENDCODE_UPDATE_BYTES=$received/$($response.Content.Headers.ContentLength)" + $lastReport = $watch.ElapsedMilliseconds + } + } + if ($null -ne $response.Content.Headers.ContentLength -and $received -ne $response.Content.Headers.ContentLength) { throw "Downloaded file was truncated." } + $output.Flush($true) + Write-Host "MENDCODE_UPDATE_BYTES=$received/$received" + } finally { + $cancel.Cancel() + if ($output) { $output.Dispose() } + if ($source) { $source.Dispose() } + if ($response) { $response.Dispose() } + $client.Dispose(); $cancel.Dispose() + } +} function Write-MendCodeBanner { param( @@ -78,22 +213,33 @@ function Write-SetupCommand { } function Wait-MendCodeProcessExit { - param([int]$ProcessId) - + param([int]$ProcessId, [long]$StartedAt) if ($ProcessId -le 0) { return } - while (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue) { - Start-Sleep -Milliseconds 250 + if ($StartedAt -le 0) { throw "Cannot verify the process identity to wait for." } + $script:Phase = "waiting-for-restart" + Write-UpdateStatus + $deadline = [DateTime]::UtcNow.AddHours(24) + Write-AtomicText (Join-Path $script:Operation "waiting.json") ((@{ pid = $ProcessId; startedAt = $StartedAt; deadline = $deadline.ToString("o") } | ConvertTo-Json) + "`n") + while ($true) { + $parent = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue + if (-not $parent) { return } + # PID reuse must never wait on or stop an unrelated process. + if ($parent.StartTime.ToUniversalTime().Ticks -ne $StartedAt) { return } + if ([DateTime]::UtcNow -ge $deadline) { throw "Still waiting for MendCode to close after 24 hours. Current binary preserved; run upgrade again after closing it." } + Start-Sleep -Milliseconds 500 } } function Start-DeferredUpdate { - if ($env:MENDCODE_UPDATE_WORKER -eq "1") { return $false } if (-not $env:MENDCODE_UPDATE_PARENT_PID) { return $false } [int]$parentPid = 0 if (-not [int]::TryParse($env:MENDCODE_UPDATE_PARENT_PID, [ref]$parentPid) -or $parentPid -le 0) { throw "MendCode could not schedule the Windows updater: invalid parent process id." } + $parent = Get-Process -Id $parentPid -ErrorAction SilentlyContinue + if (-not $parent) { return $false } + $parentStartedAt = $parent.StartTime.ToUniversalTime().Ticks $shell = Get-Command powershell.exe -ErrorAction SilentlyContinue if (-not $shell) { $shell = Get-Command pwsh.exe -ErrorAction SilentlyContinue } @@ -107,7 +253,10 @@ function Start-DeferredUpdate { $workerArguments.Add("-ExecutionPolicy") $workerArguments.Add("Bypass") $workerArguments.Add("-File") - $workerArguments.Add('"' + $PSCommandPath + '"') + # Retain the exact verified installer with this operation, rather than a shared TEMP script. + $workerScript = Join-Path $script:Operation "installer.ps1" + [IO.File]::Copy($PSCommandPath, $workerScript) + $workerArguments.Add('"' + $workerScript + '"') if ($Version) { $workerArguments.Add("-Version") $workerArguments.Add($Version) @@ -116,9 +265,29 @@ function Start-DeferredUpdate { $workerArguments.Add("-SkipSetup") $workerArguments.Add("-WaitForExitPid") $workerArguments.Add($parentPid.ToString()) - - $env:MENDCODE_UPDATE_WORKER = "1" - Start-Process -FilePath $shell.Source -ArgumentList $workerArguments -WindowStyle Hidden | Out-Null + $workerArguments.Add("-WaitForExitStartedAt") + $workerArguments.Add($parentStartedAt.ToString()) + $workerArguments.Add("-ActivateOperation") + $workerArguments.Add('"' + $script:Operation + '"') + $workerArguments.Add("-OperationToken") + $workerArguments.Add($script:Token) + $script:Phase = "waiting-for-restart" + Write-UpdateStatus + $worker = Start-Process -FilePath $shell.Source -ArgumentList $workerArguments -WindowStyle Hidden -PassThru ` + -RedirectStandardOutput (Join-Path $script:Operation "worker.stdout.log") ` + -RedirectStandardError (Join-Path $script:Operation "worker.stderr.log") + # Child takes ownership using the operation token. Parent cannot release its lock afterwards. + $script:HaveLock = $false + $accepted = $false + $deadline = [DateTime]::UtcNow.AddSeconds(15) + while ([DateTime]::UtcNow -lt $deadline) { + $owner = Read-LockOwner + if ([long]$owner.pid -eq $worker.Id -and $owner.token -eq $script:Token) { $accepted = $true; break } + $worker.Refresh() + if ($worker.HasExited) { break } + Start-Sleep -Milliseconds 100 + } + if (-not $accepted) { throw "Deferred updater did not confirm ownership. Recovery files: $script:Operation" } Write-Host "Update scheduled; it will finish after MendCode closes." -ForegroundColor DarkGreen return $true } @@ -206,85 +375,169 @@ function Add-MendCodeToPath { } } -function Restart-MendCodeLoopServices { - if (-not (Get-Command Get-ScheduledTask -ErrorAction SilentlyContinue)) { - return - } - - $tasks = @(Get-ScheduledTask -TaskPath "\MendCode\Loops\" -ErrorAction SilentlyContinue | - Where-Object { $_.TaskName -like "com.mendcode.loops.*" }) - foreach ($task in $tasks) { - Stop-ScheduledTask -TaskName $task.TaskName -TaskPath $task.TaskPath -ErrorAction SilentlyContinue - Start-ScheduledTask -TaskName $task.TaskName -TaskPath $task.TaskPath -ErrorAction SilentlyContinue - } - - if ($tasks.Count -gt 0) { - Write-Ok "Refreshed $($tasks.Count) MendCode loop task(s)" - } -} - -if (-not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { - throw "install.ps1 is for Windows. On macOS or Linux, run: curl -fsSL https://raw.githubusercontent.com/MendCode/MendCode/main/src/mendcode/install | bash" -} - -if (Start-DeferredUpdate) { exit 0 } -Wait-MendCodeProcessExit -ProcessId $WaitForExitPid - -New-Item -ItemType Directory -Force $InstallDir | Out-Null - -$target = Get-MendCodeTarget -$versionLabel = "latest" - -if ($Version) { - $Version = $Version.TrimStart("v") - $versionLabel = "v$Version" - $url = "$BaseUrl/releases/download/v$Version/$App-$target.zip" -} else { - $release = Invoke-RestMethod "$ApiUrl/releases/latest" - $versionLabel = $release.tag_name - $url = "$BaseUrl/releases/latest/download/$App-$target.zip" +function Expand-VerifiedExecutable([string]$Archive, [string]$Destination) { + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [IO.Compression.ZipFile]::OpenRead($Archive) + try { + $entries = @($zip.Entries | Where-Object { $_.FullName -ceq "mendcode.exe" }) + if ($entries.Count -ne 1 -or $entries[0].Length -le 0 -or $entries[0].Length -gt 2147483648) { + throw "Archive must contain exactly one root mendcode.exe of valid size." + } + $source = $entries[0].Open() + $output = [IO.File]::Open($Destination, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { $source.CopyTo($output); $output.Flush($true) } + finally { $output.Dispose(); $source.Dispose() } + } finally { $zip.Dispose() } } -$zip = Join-Path $env:TEMP "$App-$target.zip" -$extractDir = Join-Path $env:TEMP "$App-install-$PID" - -Write-MendCodeBanner -VersionLabel $versionLabel -Target $target -Write-Step 1 4 "Preparing download" -Write-Step 2 4 "Downloading release asset" -Invoke-WebRequest $url -OutFile $zip -Write-Ok "Downloaded $App-$target.zip" - -Write-Step 3 4 "Installing binary" -if (Test-Path $extractDir) { - Remove-Item -Recurse -Force $extractDir +function Assert-Candidate([string]$Candidate, [string]$Target) { + Assert-RegularPath $Candidate + $stream = [IO.File]::OpenRead($Candidate) + $reader = New-Object IO.BinaryReader($stream) + try { + if ($stream.Length -lt 64 -or $reader.ReadUInt16() -ne 0x5a4d) { throw "Candidate is not a Windows executable." } + $stream.Position = 60 + $offset = $reader.ReadUInt32() + if ($offset -gt ($stream.Length - 6)) { throw "Candidate PE header is truncated." } + $stream.Position = $offset + if ($reader.ReadUInt32() -ne 0x00004550) { throw "Candidate PE signature is invalid." } + $machine = $reader.ReadUInt16() + $expectedMachine = if ($Target -eq "windows-arm64") { 0xaa64 } else { 0x8664 } + if ($machine -ne $expectedMachine) { throw "Candidate architecture does not match $Target." } + } finally { $reader.Dispose() } + $info = New-Object Diagnostics.ProcessStartInfo + $info.FileName = $Candidate; $info.Arguments = "--version" + $info.UseShellExecute = $false; $info.CreateNoWindow = $true + $info.RedirectStandardOutput = $true; $info.RedirectStandardError = $true + $process = New-Object Diagnostics.Process + $process.StartInfo = $info + try { + if (-not $process.Start()) { throw "Could not verify candidate version." } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(30000)) { + $process.Kill() + throw "Candidate --version exceeded 30 seconds. Current binary preserved." + } + if ($process.ExitCode -ne 0 -or $stdout.GetAwaiter().GetResult().Trim() -ne $Version) { + throw "Candidate version check failed. Expected $Version." + } + } finally { $process.Dispose() } } -New-Item -ItemType Directory -Force $extractDir | Out-Null -Expand-Archive -Force $zip $extractDir -$binary = Get-ChildItem -Path $extractDir -Recurse -Filter "mendcode.exe" | Select-Object -First 1 -if (-not $binary) { - throw "Release asset did not contain mendcode.exe" +function Activate-Candidate { + $candidate = Join-Path $script:Operation "candidate.exe" + Assert-RegularPath $candidate + if ((Get-FileHash -LiteralPath $candidate -Algorithm SHA256).Hash.ToLowerInvariant() -ne $script:BinaryDigest) { + throw "Staged executable changed before activation." + } + $destination = Join-Path $InstallDir "mendcode.exe" + Assert-RegularPath $destination + $previous = Join-Path $script:Operation "previous" + if (Test-Path -LiteralPath $previous) { throw "Previous executable is already retained; inspect this operation before retrying." } + if ([IO.File]::Exists($destination)) { + $script:PreviousDigest = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash.ToLowerInvariant() + } + $script:Phase = "activating" + Write-UpdateStatus + # File.Replace is one same-volume operation and preserves the original as its backup. + if ([IO.File]::Exists($destination)) { [IO.File]::Replace($candidate, $destination, $previous) } + else { [IO.File]::Move($candidate, $destination) } + $script:Phase = "activated" + Write-UpdateStatus + Write-Ok "Installed $destination" + Write-Host "Restart required to verify backend and TUI readiness. Recovery files: $script:Operation" + Write-Host "Existing loop services were left running. Restart them when their work finishes." } -Copy-Item -Force $binary.FullName (Join-Path $InstallDir "mendcode.exe") -Remove-Item -Recurse -Force $extractDir -Remove-Item -Force $zip -Write-Ok "Installed $(Join-Path $InstallDir "mendcode.exe")" -Restart-MendCodeLoopServices - -Write-Step 4 4 "Updating PATH" -Add-MendCodeToPath - -Write-Host "" -Write-Host "MendCode is ready." -ForegroundColor Green -& (Join-Path $InstallDir "mendcode.exe") --version -Write-Host "" -Write-Host " cd # open your repo" -Write-Host " $(Join-Path $InstallDir "mendcode.exe") # run now in this terminal" -Write-Host "" -Write-Host "Open a new terminal to use: mendcode" -Maybe-LaunchSetup - -if ($env:MENDCODE_UPDATE_SCRIPT_PATH -and (Test-Path $env:MENDCODE_UPDATE_SCRIPT_PATH)) { - Remove-Item -Force $env:MENDCODE_UPDATE_SCRIPT_PATH -ErrorAction SilentlyContinue +if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw "install.ps1 requires Windows." } +$failure = $null +$deferred = $false +try { + New-Item -ItemType Directory -Force $InstallDir | Out-Null + Assert-RegularPath (Split-Path $InstallDir -Parent) + Assert-RegularPath $InstallDir + if ($ActivateOperation) { + $script:Operation = [IO.Path]::GetFullPath($ActivateOperation) + if ((Split-Path $script:Operation -Parent) -ne $InstallDir -or (Split-Path $script:Operation -Leaf) -notmatch '^\.update\.[a-f0-9]{32}$') { + throw "Invalid deferred operation path." + } + Assert-RegularPath $script:Operation + $owner = Read-LockOwner + if ($OperationToken -notmatch '^[a-f0-9]{32}$' -or $owner.token -ne $OperationToken -or $owner.operation -ne $script:Operation) { throw "Deferred updater ownership changed." } + $script:Token = $OperationToken + $statusFile = Join-Path $script:Operation "status" + Assert-RegularPath $statusFile + if ((Get-Item -LiteralPath $statusFile).Length -gt 4096) { throw "Invalid operation status." } + $fields = @{} + foreach ($line in [IO.File]::ReadAllLines($statusFile)) { + $entry = $line.Split(@('='), 2) + if ($entry.Length -eq 2) { $fields[$entry[0]] = $entry[1] } + } + if ($fields.version -ne $Version -or $fields.phase -ne "waiting-for-restart" -or $fields.binary_sha256 -notmatch '^[a-f0-9]{64}$') { throw "Deferred operation was not verified." } + $script:BinaryDigest = $fields.binary_sha256 + Write-LockOwner + $script:HaveLock = $true + Wait-MendCodeProcessExit -ProcessId $WaitForExitPid -StartedAt $WaitForExitStartedAt + Activate-Candidate + } else { + $script:Operation = Join-Path $InstallDir (".update." + [Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $script:Operation | Out-Null + Acquire-UpdateLock + Write-UpdateStatus + $target = Get-MendCodeTarget + if (-not $Version) { + $releaseFile = Join-Path $script:Operation "release.json" + Receive-UpdateFile "$ApiUrl/releases/latest" $releaseFile 524288 + $Version = ([IO.File]::ReadAllText($releaseFile) | ConvertFrom-Json).tag_name + } + $Version = $Version.TrimStart('v') + if ($Version -notmatch '^\d+\.\d+\.\d+([-+][a-zA-Z0-9.+-]+)?$') { throw "Invalid release version." } + Write-MendCodeBanner -VersionLabel $Version -Target $target + $filename = "$App-$target.zip" + $url = "$BaseUrl/releases/download/v$Version/$filename" + $archive = Join-Path $script:Operation $filename + $script:Phase = "downloading" + Write-UpdateStatus + Receive-UpdateFile $url $archive + $script:Phase = "verifying" + Write-UpdateStatus + $sums = Join-Path $script:Operation "SHA256SUMS" + if ($env:MENDCODE_VERIFIED_SUMS_FILE) { + Assert-RegularPath $env:MENDCODE_VERIFIED_SUMS_FILE + [IO.File]::Copy($env:MENDCODE_VERIFIED_SUMS_FILE, $sums) + } else { Receive-UpdateFile "$BaseUrl/releases/download/v$Version/SHA256SUMS" $sums 524288 } + if ((Get-Item -LiteralPath $sums).Length -gt 524288) { throw "Checksums exceed the size limit." } + $digests = @([IO.File]::ReadAllLines($sums) | ForEach-Object { + if ($_ -match ('^([a-fA-F0-9]{64})\s+\*?(?:\./)?' + [Regex]::Escape($filename) + '$')) { $Matches[1].ToLowerInvariant() } + }) + if ($digests.Count -ne 1) { throw "Missing, duplicate, or invalid release checksum." } + if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $digests[0]) { throw "Release checksum mismatch. Current binary preserved." } + $candidate = Join-Path $script:Operation "candidate.exe" + Expand-VerifiedExecutable $archive $candidate + Assert-Candidate $candidate $target + $script:BinaryDigest = (Get-FileHash -LiteralPath $candidate -Algorithm SHA256).Hash.ToLowerInvariant() + Write-UpdateStatus + $deferred = Start-DeferredUpdate + if (-not $deferred) { + if ($WaitForExitPid -gt 0) { Wait-MendCodeProcessExit -ProcessId $WaitForExitPid -StartedAt $WaitForExitStartedAt } + Activate-Candidate + Add-MendCodeToPath + } + } +} catch { + $failure = $_ + if ($script:HaveLock) { + try { + Write-AtomicText (Join-Path $script:Operation "failure.txt") ("$script:Phase`: $($_.Exception.Message)`n") + $script:Phase = "failed" + Write-UpdateStatus 1 + } catch { Write-Warning "Could not record update failure: $($_.Exception.Message)" } + } +} finally { + if ($script:HaveLock) { + try { Release-UpdateLock } catch { if (-not $failure) { $failure = $_ } } + } } +if ($failure) { Write-Error $failure; exit 1 } +if (-not $deferred -and -not $ActivateOperation) { Maybe-LaunchSetup } diff --git a/src/mendcode/packages/opencode/migration/20260904120000_continuity_runtime/migration.sql b/src/mendcode/packages/opencode/migration/20260904120000_continuity_runtime/migration.sql new file mode 100644 index 00000000..f0567720 --- /dev/null +++ b/src/mendcode/packages/opencode/migration/20260904120000_continuity_runtime/migration.sql @@ -0,0 +1,13 @@ +CREATE TABLE `continuity_record` ( + `id` text PRIMARY KEY NOT NULL, + `session_id` text NOT NULL REFERENCES `session`(`id`) ON DELETE CASCADE, + `directory` text NOT NULL, + `kind` text NOT NULL, + `generation` integer NOT NULL, + `status` text NOT NULL, + `data` text NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `continuity_session_kind_idx` ON `continuity_record` (`session_id`, `kind`); diff --git a/src/mendcode/packages/opencode/src/cli/cmd/run.ts b/src/mendcode/packages/opencode/src/cli/cmd/run.ts index d6111103..ada372c0 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/run.ts +++ b/src/mendcode/packages/opencode/src/cli/cmd/run.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from "url" import { Effect } from "effect" import { UI } from "../ui" import { effectCmd } from "../effect-cmd" +import { cmd } from "./cmd" import { Flag } from "@mendcode/core/flag/flag" import { ServerAuth } from "@/server/auth" import { EOL } from "os" @@ -28,9 +29,10 @@ import { TodoWriteTool } from "../../tool/todo" import { Locale } from "@/util/locale" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" -import { resolveRuntimeModel } from "../model-selection" +import { resolveRuntimeModel, type RuntimeModelResolution } from "../model-selection" import { writeAutomationEnvelope } from "../automation" import { AppRuntime } from "@/effect/app-runtime" +import { withSharedClient } from "../shared-client" type ToolProps = { input: Tool.InferParameters @@ -207,15 +209,9 @@ function normalizePath(input?: string) { return input } -export const RunCommand = effectCmd({ +const ClientRunCommand = cmd({ command: "run [message..]", describe: "run MendCode runtime with a message", - // --attach connects to a remote server (no local instance needed); the - // default path runs an in-process server and needs the project instance. - instance: (args) => !args.attach, - // For --dir without --attach, load instance for the resolved target dir. - // The handler also chdirs (preserving the legacy order: chdir → file resolution). - directory: (args) => (args.dir && !args.attach ? path.resolve(process.cwd(), args.dir) : process.cwd()), builder: (yargs: Argv) => yargs .positional("message", { @@ -307,314 +303,292 @@ export const RunCommand = effectCmd({ describe: "auto-approve permissions that are not explicitly denied (dangerous!)", default: false, }), - handler: Effect.fn("Cli.run")(function* (args) { - const agentSvc = yield* Agent.Service - const sessions = yield* Session.Service - yield* Effect.promise(async () => { - let message = [...args.message, ...(args["--"] || [])] - .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) - .join(" ") - - const directory = (() => { - if (!args.dir) return undefined - if (args.attach) return args.dir - try { - process.chdir(args.dir) - return process.cwd() - } catch { - UI.error("Failed to change directory to " + args.dir) - process.exit(1) - } - })() - - const files: { type: "file"; url: string; filename: string; mime: string }[] = [] - if (args.file) { - const list = Array.isArray(args.file) ? args.file : [args.file] - - for (const filePath of list) { - const resolvedPath = path.resolve(process.cwd(), filePath) - if (!(await Filesystem.exists(resolvedPath))) { - UI.error(`File not found: ${filePath}`) - process.exit(1) - } + handler: async (args) => { + let message = [...args.message, ...(args["--"] || [])] + .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) + .join(" ") + + const directory = (() => { + if (!args.dir) return undefined + if (args.attach) return args.dir + try { + process.chdir(args.dir) + return process.cwd() + } catch { + UI.error("Failed to change directory to " + args.dir) + process.exit(1) + } + })() - const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain" + const files: { type: "file"; url: string; filename: string; mime: string }[] = [] + if (args.file) { + const list = Array.isArray(args.file) ? args.file : [args.file] - files.push({ - type: "file", - url: pathToFileURL(resolvedPath).href, - filename: path.basename(resolvedPath), - mime, - }) + for (const filePath of list) { + const resolvedPath = path.resolve(process.cwd(), filePath) + if (!(await Filesystem.exists(resolvedPath))) { + UI.error(`File not found: ${filePath}`) + process.exit(1) } - } - if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) + const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain" - if (message.trim().length === 0 && !args.command) { - UI.error("You must provide a message or a command") - process.exit(1) + files.push({ + type: "file", + url: pathToFileURL(resolvedPath).href, + filename: path.basename(resolvedPath), + mime, + }) } - - if (args.fork && !args.continue && !args.session) { - UI.error("--fork requires --continue or --session") - process.exit(1) + } + + if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) + + if (message.trim().length === 0 && !args.command) { + UI.error("You must provide a message or a command") + process.exit(1) + } + + if (args.fork && !args.continue && !args.session) { + UI.error("--fork requires --continue or --session") + process.exit(1) + } + + const rules: Permission.Ruleset = [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + { + permission: "plan_enter", + action: "deny", + pattern: "*", + }, + { + permission: "plan_exit", + action: "deny", + pattern: "*", + }, + ] + + function title() { + if (args.title === undefined) return + if (args.title !== "") return args.title + return message.slice(0, 50) + (message.length > 50 ? "..." : "") + } + + async function session(sdk: OpencodeClient) { + const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session + + if (baseID && args.fork) { + const forked = await sdk.session.fork({ sessionID: baseID }) + return forked.data?.id } - const rules: Permission.Ruleset = [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - { - permission: "plan_enter", - action: "deny", - pattern: "*", - }, - { - permission: "plan_exit", - action: "deny", - pattern: "*", - }, - ] - - function title() { - if (args.title === undefined) return - if (args.title !== "") return args.title - return message.slice(0, 50) + (message.length > 50 ? "..." : "") - } + if (baseID) return baseID - async function session(sdk: OpencodeClient) { - const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session + const name = title() + const result = await sdk.session.create({ title: name, permission: rules }) + return result.data?.id + } - if (baseID && args.fork) { - const forked = await sdk.session.fork({ sessionID: baseID }) - return forked.data?.id + async function share(sdk: OpencodeClient, sessionID: string) { + const cfg = await sdk.config.get() + if (!cfg.data) return + if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return + const res = await sdk.session.share({ sessionID }).catch((error) => { + if (error instanceof Error && error.message.includes("disabled")) { + UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) } - - if (baseID) return baseID - - const name = title() - const result = await sdk.session.create({ title: name, permission: rules }) - return result.data?.id + return { error } + }) + if (!res.error && "data" in res && res.data?.share?.url) { + UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url) } + } - async function share(sdk: OpencodeClient, sessionID: string) { - const cfg = await sdk.config.get() - if (!cfg.data) return - if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return - const res = await sdk.session.share({ sessionID }).catch((error) => { - if (error instanceof Error && error.message.includes("disabled")) { - UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) - } - return { error } - }) - if (!res.error && "data" in res && res.data?.share?.url) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url) + async function execute(sdk: OpencodeClient) { + function tool(part: ToolPart) { + try { + if (part.tool === ShellID.ToolID) return shell(props(part)) + if (part.tool === "glob") return glob(props(part)) + if (part.tool === "grep") return grep(props(part)) + if (part.tool === "read") return read(props(part)) + if (part.tool === "write") return write(props(part)) + if (part.tool === "webfetch") return webfetch(props(part)) + if (part.tool === "edit") return edit(props(part)) + if (part.tool === "websearch") return websearch(props(part)) + if (part.tool === "task") return task(props(part)) + if (part.tool === "todowrite") return todo(props(part)) + if (part.tool === "skill") return skill(props(part)) + return fallback(part) + } catch { + return fallback(part) } } - async function execute(sdk: OpencodeClient) { - function tool(part: ToolPart) { - try { - if (part.tool === ShellID.ToolID) return shell(props(part)) - if (part.tool === "glob") return glob(props(part)) - if (part.tool === "grep") return grep(props(part)) - if (part.tool === "read") return read(props(part)) - if (part.tool === "write") return write(props(part)) - if (part.tool === "webfetch") return webfetch(props(part)) - if (part.tool === "edit") return edit(props(part)) - if (part.tool === "websearch") return websearch(props(part)) - if (part.tool === "task") return task(props(part)) - if (part.tool === "todowrite") return todo(props(part)) - if (part.tool === "skill") return skill(props(part)) - return fallback(part) - } catch { - return fallback(part) - } + function emit(type: string, data: Record) { + if (args.format === "json") { + writeAutomationEnvelope({ kind: "event", event: type, sessionID, data }) + return true } + return false + } - function emit(type: string, data: Record) { - if (args.format === "json") { - writeAutomationEnvelope({ kind: "event", event: type, sessionID, data }) - return true + const eventController = new AbortController() + const events = await sdk.event.subscribe(undefined, { signal: eventController.signal }) + let error: string | undefined + + async function loop() { + const toggles = new Map() + + for await (const event of events.stream) { + if ( + event.type === "message.updated" && + event.properties.info.sessionID === sessionID && + event.properties.info.role === "assistant" && + args.format !== "json" && + toggles.get("start") !== true + ) { + UI.empty() + UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) + UI.empty() + toggles.set("start", true) } - return false - } - const events = await sdk.event.subscribe() - let error: string | undefined + if (event.type === "message.part.updated") { + const part = event.properties.part + if (part.sessionID !== sessionID) continue - async function loop() { - const toggles = new Map() + if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { + if (emit("tool_use", { part })) continue + if (part.state.status === "completed") { + tool(part) + continue + } + inline({ + icon: "✗", + title: `${part.tool} failed`, + }) + UI.error(part.state.error) + } - for await (const event of events.stream) { if ( - event.type === "message.updated" && - event.properties.info.role === "assistant" && - args.format !== "json" && - toggles.get("start") !== true + part.type === "tool" && + part.tool === "task" && + part.state.status === "running" && + args.format !== "json" ) { - UI.empty() - UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) - UI.empty() - toggles.set("start", true) + if (toggles.get(part.id) === true) continue + task(props(part)) + toggles.set(part.id, true) } - if (event.type === "message.part.updated") { - const part = event.properties.part - if (part.sessionID !== sessionID) continue - - if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { - if (emit("tool_use", { part })) continue - if (part.state.status === "completed") { - tool(part) - continue - } - inline({ - icon: "✗", - title: `${part.tool} failed`, - }) - UI.error(part.state.error) - } - - if ( - part.type === "tool" && - part.tool === "task" && - part.state.status === "running" && - args.format !== "json" - ) { - if (toggles.get(part.id) === true) continue - task(props(part)) - toggles.set(part.id, true) - } + if (part.type === "step-start") { + if (emit("step_start", { part })) continue + } - if (part.type === "step-start") { - if (emit("step_start", { part })) continue - } + if (part.type === "step-finish") { + if (emit("step_finish", { part })) continue + } - if (part.type === "step-finish") { - if (emit("step_finish", { part })) continue + if (part.type === "text" && part.time?.end) { + if (emit("text", { part })) continue + const text = part.text.trim() + if (!text) continue + if (!process.stdout.isTTY) { + process.stdout.write(text + EOL) + continue } + UI.empty() + UI.println(text) + UI.empty() + } - if (part.type === "text" && part.time?.end) { - if (emit("text", { part })) continue - const text = part.text.trim() - if (!text) continue - if (!process.stdout.isTTY) { - process.stdout.write(text + EOL) - continue - } + if (part.type === "reasoning" && part.time?.end && args.thinking) { + if (emit("reasoning", { part })) continue + const text = part.text.trim() + if (!text) continue + const line = `Thinking: ${text}` + if (process.stdout.isTTY) { UI.empty() - UI.println(text) + UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) UI.empty() + continue } - - if (part.type === "reasoning" && part.time?.end && args.thinking) { - if (emit("reasoning", { part })) continue - const text = part.text.trim() - if (!text) continue - const line = `Thinking: ${text}` - if (process.stdout.isTTY) { - UI.empty() - UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) - UI.empty() - continue - } - process.stdout.write(line + EOL) - } - } - - if (event.type === "session.error") { - const props = event.properties - if (props.sessionID !== sessionID || !props.error) continue - let err = String(props.error.name) - if ("data" in props.error && props.error.data && "message" in props.error.data) { - err = String(props.error.data.message) - } - error = error ? error + EOL + err : err - if (emit("error", { error: props.error })) continue - UI.error(err) - } - - if ( - event.type === "session.status" && - event.properties.sessionID === sessionID && - event.properties.status.type === "idle" - ) { - break + process.stdout.write(line + EOL) } + } - if (event.type === "permission.asked") { - const permission = event.properties - if (permission.sessionID !== sessionID) continue - - if (args["dangerously-skip-permissions"]) { - await sdk.permission.reply({ - requestID: permission.id, - reply: "once", - }) - } else { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL + - `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, - ) - await sdk.permission.reply({ - requestID: permission.id, - reply: "reject", - }) - } + if (event.type === "session.error") { + const props = event.properties + if (props.sessionID !== sessionID || !props.error) continue + let err = String(props.error.name) + if ("data" in props.error && props.error.data && "message" in props.error.data) { + err = String(props.error.data.message) } + error = error ? error + EOL + err : err + process.exitCode = 1 + if (emit("error", { error: props.error })) continue + UI.error(err) } - } - // Validate agent if specified - const agent = await (async () => { - if (!args.agent) return undefined - const name = args.agent + if ( + event.type === "session.status" && + event.properties.sessionID === sessionID && + event.properties.status.type === "idle" + ) { + break + } - // When attaching, validate against the running server instead of local Instance state. - if (args.attach) { - const modes = await sdk.app - .agents(undefined, { throwOnError: true }) - .then((x) => x.data ?? []) - .catch(() => undefined) + if (event.type === "permission.asked") { + const permission = event.properties + if (permission.sessionID !== sessionID) continue - if (!modes) { + if (args["dangerously-skip-permissions"]) { + await sdk.permission.reply({ + requestID: permission.id, + reply: "once", + }) + } else { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `failed to list agents from ${args.attach}. Falling back to default agent`, + UI.Style.TEXT_NORMAL + + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, ) - return undefined + await sdk.permission.reply({ + requestID: permission.id, + reply: "reject", + }) } + } + } + } - const agent = modes.find((a) => a.name === name) - if (!agent) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } + // Validate agent if specified + const agent = await (async () => { + if (!args.agent) return undefined + const name = args.agent - if (agent.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } + // When attaching, validate against the running server instead of local Instance state. + if (args.attach) { + const modes = await sdk.app + .agents(undefined, { throwOnError: true }) + .then((x) => x.data ?? []) + .catch(() => undefined) - return name + if (!modes) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `failed to list agents from ${args.attach}. Falling back to default agent`, + ) + return undefined } - const entry = await Effect.runPromise(agentSvc.get(name)) - if (!entry) { + const agent = modes.find((a) => a.name === name) + if (!agent) { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, @@ -622,7 +596,8 @@ export const RunCommand = effectCmd({ ) return undefined } - if (entry.mode === "subagent") { + + if (agent.mode === "subagent") { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, @@ -630,80 +605,160 @@ export const RunCommand = effectCmd({ ) return undefined } + return name - })() + } - const sessionID = await session(sdk) - if (!sessionID) { - UI.error("Session not found") - process.exit(1) + const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name))) + if (!entry) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" not found. Falling back to default agent`, + ) + return undefined } - await share(sdk, sessionID) - - const sessionInfo = await Effect.runPromise(sessions.get(SessionID.make(sessionID))) - const resolved = await AppRuntime.runPromise( - resolveRuntimeModel({ - session: sessionInfo, - explicitAgent: agent, - explicitModel: args.model, - explicitVariant: args.variant, + if (entry.mode === "subagent") { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + return name + })() + + const sessionID = await session(sdk) + if (!sessionID) { + UI.error("Session not found") + process.exit(1) + } + await share(sdk, sessionID) + + const selection = { explicitAgent: agent, explicitModel: args.model, explicitVariant: args.variant } + const resolved: RuntimeModelResolution = await (async () => { + if (args.attach) { + const url = new URL(`/continuity/${encodeURIComponent(sessionID)}/model-resolution`, args.attach) + const headers = new Headers(ServerAuth.headers({ password: args.password, username: args.username })) + headers.set("content-type", "application/json") + if (directory) headers.set("x-opencode-directory", encodeURIComponent(directory)) + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(selection), + signal: AbortSignal.timeout(30_000), + }) + if (!response.ok) throw new Error(`Backend model resolution failed (HTTP ${response.status})`) + return response.json() + } + return AppRuntime.runPromise( + Effect.gen(function* () { + const sessions = yield* Session.Service + const session = yield* sessions.get(SessionID.make(sessionID)) + return yield* resolveRuntimeModel({ session, ...selection }) }), ) - emit("session.started", { - resolution: resolved, - }) + })() + emit("session.started", { + resolution: resolved, + }) - const eventLoop = loop() + const eventLoop = loop() - try { - if (args.command) { - await sdk.session.command({ + try { + if (args.command) { + await sdk.session.command( + { sessionID, agent: resolved.agent, model: resolved.model ? `${resolved.model.providerID}/${resolved.model.modelID}` : undefined, command: args.command, arguments: message, variant: resolved.variant, - }) - } else { - await sdk.session.prompt({ + }, + { throwOnError: true }, + ) + } else { + await sdk.session.prompt( + { sessionID, agent: resolved.agent, model: resolved.model, variant: resolved.variant, parts: [...files, { type: "text", text: message }], - }) - } - } finally { - await eventLoop - } - - if (args.format === "json") { - writeAutomationEnvelope({ - kind: "result", - event: "session.completed", - sessionID, - data: { - status: error ? "failed" : "completed", - resolution: resolved, - error: error ?? null, }, - }) + { throwOnError: true }, + ) } + } catch (failure) { + eventController.abort() + throw failure + } finally { + await eventLoop + eventController.abort() } - if (args.attach) { - const headers = ServerAuth.headers({ password: args.password, username: args.username }) - const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) - return await execute(sdk) + if (args.format === "json") { + writeAutomationEnvelope({ + kind: "result", + event: "session.completed", + sessionID, + data: { + status: error ? "failed" : "completed", + resolution: resolved, + error: error ?? null, + }, + }) } + } + + if (args.attach) { + const headers = ServerAuth.headers({ password: args.password, username: args.username }) + const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) + return await execute(sdk) + } + + const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + return Server.Default().app.fetch(request) + }) as typeof globalThis.fetch + const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) + await execute(sdk) + }, +}) - const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { - const request = new Request(input, init) - return Server.Default().app.fetch(request) - }) as typeof globalThis.fetch - const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) - await execute(sdk) - }) - }), +const LocalRunCommand = effectCmd({ + command: "run", + describe: "run a local session", + directory: (args: Parameters[0]) => path.resolve(args.dir ?? process.cwd()), + handler: (args: Parameters[0]) => + Effect.promise(async () => { + await ClientRunCommand.handler(args) + }), }) + +export const RunCommand = { + ...ClientRunCommand, + async handler(args: Parameters[0]) { + if (args.attach) return ClientRunCommand.handler(args) + if (process.env.MENDCODE_DISABLE_SHARED_SERVER === "1" || process.env.OPENCODE_DISABLE_SHARED_SERVER === "1") { + await (await import("@/storage/startup-migration")).migrateLegacyStorage() + return LocalRunCommand.handler(args) + } + if (args.dir) process.chdir(path.resolve(args.dir)) + return withSharedClient(process.cwd(), async (connection) => { + const authorization = connection.headers.get("authorization") + if (!authorization?.startsWith("Basic ")) throw new Error("Local backend credentials are unavailable") + const credentials = Buffer.from(authorization.slice(6), "base64").toString("utf8") + const separator = credentials.indexOf(":") + return ClientRunCommand.handler({ + ...args, + attach: connection.url, + dir: connection.directory, + username: credentials.slice(0, separator), + password: credentials.slice(separator + 1), + }) + }) + }, +} diff --git a/src/mendcode/packages/opencode/src/cli/cmd/stats.ts b/src/mendcode/packages/opencode/src/cli/cmd/stats.ts index 0124a269..da40504a 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/stats.ts +++ b/src/mendcode/packages/opencode/src/cli/cmd/stats.ts @@ -1,12 +1,12 @@ import { Effect } from "effect" -import { effectCmd } from "../effect-cmd" +import { cmd } from "./cmd" import { Session } from "@/session/session" import { Database } from "@/storage/db" import { SessionTable } from "../../session/session.sql" import { Project } from "@/project/project" -import { InstanceRef } from "@/effect/instance-ref" +import { withSharedClient } from "../shared-client" -interface SessionStats { +export interface SessionStats { totalSessions: number totalMessages: number totalCost: number @@ -45,7 +45,7 @@ interface SessionStats { medianTokensPerSession: number } -export const StatsCommand = effectCmd({ +export const StatsCommand = cmd({ command: "stats", describe: "show token usage and cost statistics", builder: (yargs) => @@ -65,10 +65,15 @@ export const StatsCommand = effectCmd({ describe: "filter by project (default: all projects, empty string: current project)", type: "string", }), - handler: Effect.fn("Cli.stats")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const stats = yield* aggregateSessionStats(args.days, args.project, ctx.project) + handler: async (args) => { + const stats = await withSharedClient(process.cwd(), async (connection) => { + const url = new URL("/usage", connection.url) + if (args.days !== undefined) url.searchParams.set("days", String(args.days)) + if (args.project !== undefined) url.searchParams.set("project", args.project) + const response = await fetch(url, { headers: connection.headers, signal: AbortSignal.timeout(30_000) }) + if (!response.ok) throw new Error(`Backend statistics failed (HTTP ${response.status})`) + return (await response.json()) as SessionStats + }) let modelLimit: number | undefined if (args.models === true) { modelLimit = Infinity @@ -76,14 +81,14 @@ export const StatsCommand = effectCmd({ modelLimit = args.models } displayStats(stats, args.tools, modelLimit) - }), + }, }) const getAllSessions = Effect.sync(() => Database.use((db) => db.select().from(SessionTable).all()).map((row) => Session.fromRow(row)), ) -const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( +export const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( days?: number, projectFilter?: string, currentProject?: Project.Info, diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/app.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/app.tsx index c47b37db..2c3ddeb7 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/app.tsx @@ -601,6 +601,7 @@ export function tui(input: { config?: unknown } onSnapshot?: () => Promise + onStartupReady?: () => Promise onDiagnostics?: () => Promise directory?: string fetch?: typeof fetch @@ -692,6 +693,7 @@ export function tui(input: { @@ -723,7 +725,7 @@ export function tui(input: { }) } -function App(props: { onSnapshot?: () => Promise; onDiagnostics?: () => Promise }) { +function App(props: { onSnapshot?: () => Promise; onDiagnostics?: () => Promise; onStartupReady?: () => Promise }) { const tuiConfig = useTuiConfig() const route = useRoute() const dimensions = useTerminalDimensions() @@ -4455,6 +4457,14 @@ function App(props: { onSnapshot?: () => Promise; onDiagnostics?: () = dialog.clear() }, }, + { + title: "Release channel", + value: "mendcode.release.channel", + category: mendCategory, + description: "Choose stable, beta or nightly without installing an update", + slash: { name: "release-channel" }, + onSelect: () => void showReleaseChannel(), + }, { title: "Loop Workflows", value: "mendcode.loops.dashboard", @@ -5125,13 +5135,46 @@ function App(props: { onSnapshot?: () => Promise; onDiagnostics?: () = }), ) + async function releaseChannelRequest(channel?: "stable" | "beta" | "nightly") { + const headers = new Headers(sdk.headers) + headers.set("content-type", "application/json") + const response = await sdk.fetch(new URL("/global/release-channel", sdk.url), { + method: channel ? "PUT" : "GET", headers, + body: channel ? JSON.stringify({ channel }) : undefined, signal: AbortSignal.timeout(5000), + }) + if (!response.ok) throw new Error("The server could not read or change the release channel.") + const result = await response.json() as { channel?: string } + if (result.channel !== "stable" && result.channel !== "beta" && result.channel !== "nightly") throw new Error("Invalid release channel response.") + return result.channel + } + + async function showReleaseChannel() { + try { + const current = await releaseChannelRequest() + dialog.replace(() => { + if (option.value !== "stable" && option.value !== "beta" && option.value !== "nightly") return + void releaseChannelRequest(option.value).then((channel) => { + dialog.clear() + toast.show({ variant: "info", message: `Release channel: ${channel}. Run mendcode upgrade when you want to install.`, duration: 6000 }) + }).catch((error) => toast.show({ variant: "error", message: errorMessage(error), duration: 6000 })) + }} />) + } catch (error) { toast.show({ variant: "error", message: errorMessage(error), duration: 6000 }) } + } + const showUpdateAvailable = async (version: string) => { const skipped = skippedUpdateVersion(kv.store) if (skipped && !semver.gt(version, skipped)) return + const channel = await releaseChannelRequest().catch(() => undefined) const choice = await DialogConfirm.show( dialog, - `Update Available`, + channel ? `Update Available · ${channel}` : "Update Available", `A new release v${version} is available. Would you like to update now?`, "skip", ) @@ -5149,7 +5192,10 @@ function App(props: { onSnapshot?: () => Promise; onDiagnostics?: () = duration: 30000, }) - const result = await sdk.client.global.upgrade({ target: version }) + const result = await sdk.client.global.upgrade({ target: version }).catch((error: unknown) => ({ + error, + data: undefined, + })) if (result.error || !result.data?.success) { toast.show({ @@ -5168,8 +5214,8 @@ function App(props: { onSnapshot?: () => Promise; onDiagnostics?: () = await DialogAlert.show( dialog, - "Update Complete", - `Successfully updated to ${productName()} runtime v${result.data.version}. Please restart the application.`, + "Restart Required", + `${productName()} runtime v${result.data.version} was installed. Restart the application to check startup.`, ) void exit() @@ -5198,6 +5244,24 @@ function App(props: { onSnapshot?: () => Promise; onDiagnostics?: () = return value as JSX.Element }) const startupReady = createMemo(() => ready() && pluginsReady() && sync.status !== "loading") + let startupReported = false + const startupDeadline = props.onStartupReady ? setTimeout(() => { + if (startupReported) return + toast.show({ + variant: "error", title: "Update startup timed out", + message: "Backend and TUI readiness were not confirmed. Run mendcode upgrade --check for the startup record.", + duration: 30_000, + }) + }, 30_000) : undefined + onCleanup(() => clearTimeout(startupDeadline)) + createEffect(() => { + if (startupReported || !startupReady() || sync.status !== "complete") return + startupReported = true + clearTimeout(startupDeadline) + void props.onStartupReady?.().catch((error) => { + toast.show({ variant: "error", title: "Update startup check failed", message: errorMessage(error), duration: 10_000 }) + }) + }) const startupMessage = createMemo(() => startupLoadingText({ pluginsReady: pluginsReady(), syncLoading: sync.status === "loading" }), ) diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/agent-command-panel.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/agent-command-panel.tsx index 9c59630e..b2bfb63e 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/agent-command-panel.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/agent-command-panel.tsx @@ -131,7 +131,7 @@ export function AgentCommandPanel(props: { when={props.height !== undefined} fallback={rows()} > - + {rows()} diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 3db17978..0d4a0ba9 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -222,6 +222,17 @@ import { } from "@/mend/tui/presentation" import { blurCompactionArcade, CompactionPanel, isCompactionArcadeFocused } from "../../component/compaction-panel" import { AgentCommandPanel } from "../../component/agent-command-panel" +import { SessionWidgetTray } from "@/mend/tui/widgets-tray" +import { + SessionQuestionsWidget, + SessionJobsWidget, + splitWidgetQuestions, + isWidgetJobActive, + widgetReasoningLabel, + type WidgetReasoningState, + type WidgetJob, + type AsyncQuestionRequest, +} from "@/mend/tui/widgets-runtime" import { agentViewCommandStateRank, agentViewCommandTouchesSession, @@ -840,8 +851,18 @@ export function Session() { return pendingPermissions() }) const activePermission = createMemo(() => permissions()[0]) - const questions = createMemo(() => { - return pendingInputSessionIDs().flatMap((sessionID) => sync.data.question[sessionID] ?? []) + const questionGroups = createMemo(() => + splitWidgetQuestions(pendingInputSessionIDs().flatMap((sessionID) => sync.data.question[sessionID] ?? [])), + ) + const asyncQuestions = createMemo(() => questionGroups().asynchronous) + const [answeringQuestionID, setAnsweringQuestionID] = createSignal() + const questions = createMemo(() => [ + ...questionGroups().blocking, + ...asyncQuestions().filter((request) => request.id === answeringQuestionID()), + ]) + createEffect(() => { + const id = answeringQuestionID() + if (id && !asyncQuestions().some((request) => request.id === id)) setAnsweringQuestionID(undefined) }) const planReviews = createMemo(() => { return pendingInputSessionIDs().flatMap((sessionID) => sync.data.plan_review[sessionID] ?? []) @@ -1341,6 +1362,13 @@ export function Session() { } } const [agentCommands, setAgentCommands] = createSignal([]) + const [runtimeJobs, setRuntimeJobs] = createSignal([]) + const [reasoningState, setReasoningState] = createSignal() + let reasoningStateRevision = 0 + let lastReasoningUpdateAt = 0 + const [runtimeJobBusyID, setRuntimeJobBusyID] = createSignal() + let runtimeRefreshTimer: ReturnType | undefined + let runtimeRefreshRevision = 0 const [agentCommandBusyID, setAgentCommandBusyID] = createSignal() let agentCommandRefreshTimer: ReturnType | undefined const editor = useEditorContext() @@ -1390,6 +1418,48 @@ export function Session() { ) } + const refreshRuntimeJobs = async (sessionID = route.sessionID) => { + if (route.sessionID !== sessionID) return + const revision = ++runtimeRefreshRevision + const reasoningRevision = reasoningStateRevision + const response = await agentCommandJSON<{ records: WidgetJob[]; currentReasoning?: WidgetReasoningState }>(`/continuity/${sessionID}`, { + signal: AbortSignal.timeout(15_000), + }).catch(() => undefined) + if (revision !== runtimeRefreshRevision || route.sessionID !== sessionID || !response) return + if (reasoningRevision === reasoningStateRevision) { + if (response.currentReasoning) updateReasoningState(response.currentReasoning) + else setReasoningState(undefined) + } + setRuntimeJobs( + response.records.filter((record) => record.kind === "job" && record.sessionID === sessionID) + .toSorted((a, b) => Number(isWidgetJobActive(b)) - Number(isWidgetJobActive(a)) || b.timeUpdated - a.timeUpdated), + ) + } + + const updateReasoningState = (state: WidgetReasoningState) => { + if (state.sessionID !== route.sessionID || state.requestedAt < lastReasoningUpdateAt) return + lastReasoningUpdateAt = state.requestedAt + reasoningStateRevision++ + setReasoningState(state) + } + + const cancelRuntimeJob = async (id: string) => { + if (runtimeJobBusyID()) return + const sessionID = route.sessionID + setRuntimeJobBusyID(id) + try { + await agentCommandJSON(`/continuity/${sessionID}/${encodeURIComponent(id)}/cancel`, { + method: "POST", + signal: AbortSignal.timeout(30_000), + }) + await refreshRuntimeJobs(sessionID) + } catch (error) { + toast.show({ variant: "error", message: errorMessage(error), duration: 4000 }) + } finally { + setRuntimeJobBusyID(undefined) + } + } + const refreshAgentCommands = async (sessionID = route.sessionID) => { const [incoming, outgoing] = await Promise.all([ agentCommandJSON(`/session/${sessionID}/agent-command`).catch(() => []), @@ -1434,13 +1504,21 @@ export function Session() { () => route.sessionID, (sessionID) => { setAgentCommands([]) + setRuntimeJobs([]) + setReasoningState(undefined) + reasoningStateRevision++ + lastReasoningUpdateAt = 0 + setAnsweringQuestionID(undefined) void refreshAgentCommands(sessionID).catch(() => undefined) + void refreshRuntimeJobs(sessionID).catch(() => undefined) }, ), ) onCleanup(() => { + runtimeRefreshRevision++ if (agentCommandRefreshTimer) clearTimeout(agentCommandRefreshTimer) + if (runtimeRefreshTimer) clearTimeout(runtimeRefreshTimer) }) const [permissionsConfig, { refetch: refetchPermissionsConfig }] = createResource( () => route.sessionID, @@ -2110,6 +2188,24 @@ export function Session() { }) if (agentCommandTouchesCurrentSession) scheduleAgentCommandRefresh() const evtSessionID = eventSessionID(evt) + if (evtType === "session.reasoning.updated" && evtSessionID === route.sessionID) { + updateReasoningState(evt.properties as unknown as WidgetReasoningState) + } + if (evtType === "session.reasoning.cleared" && evtSessionID === route.sessionID) { + const cleared = evt.properties as unknown as { requestedAt: number } + if (cleared.requestedAt >= lastReasoningUpdateAt) { + lastReasoningUpdateAt = cleared.requestedAt + reasoningStateRevision++ + setReasoningState(undefined) + } + } + if (evtType === "continuity.updated" && evtSessionID === route.sessionID) { + if (runtimeRefreshTimer) clearTimeout(runtimeRefreshTimer) + runtimeRefreshTimer = setTimeout(() => { + runtimeRefreshTimer = undefined + void refreshRuntimeJobs().catch(() => undefined) + }, 50) + } if (evtSessionID !== route.sessionID && !agentCommandTouchesCurrentSession) return lastSessionEventAt = Date.now() if (evt.type === "message.part.updated") { @@ -3606,6 +3702,7 @@ export function Session() { const command = useCommandDialog() function toggleSessionWidgets(dialog?: ReturnType) { + if (!showSessionWidgets()) void refreshRuntimeJobs().catch(() => undefined) setShowSessionWidgets((visible) => { const next = !visible setFocusSessionWidgets(next) @@ -4761,6 +4858,11 @@ export function Session() { todos={todos()} subagents={subagents()} commands={agentCommands()} + questions={asyncQuestions()} + jobs={runtimeJobs()} + jobBusyID={runtimeJobBusyID()} + onCancelJob={(id) => void cancelRuntimeJob(id)} + onAnswerQuestion={setAnsweringQuestionID} commandBusyID={agentCommandBusyID()} width={contentWidth()} sessionID={route.sessionID} @@ -4787,6 +4889,18 @@ export function Session() { }} /> + 0 || runtimeJobs().some(isWidgetJobActive)) && !showSessionBottomDock() && !answeringQuestionID()}> + toggleSessionWidgets()}> + {asyncQuestions().length} pending questions · {runtimeJobs().filter(isWidgetJobActive).length} active tools · {keybind.print("todo_toggle")} widgets + + + + {(state) => ( + + {Locale.truncate(widgetReasoningLabel(state()), contentWidth())} + + )} + {(item) => } {/* Do not keep the hidden prompt subtree mounted while a question/permission owns input. */} @@ -5403,6 +5517,11 @@ function SessionBottomDock(props: { todos: SessionTodo[] subagents: SessionSubagentInfo[] commands: readonly AgentViewCommand[] + questions: readonly AsyncQuestionRequest[] + jobs: readonly WidgetJob[] + jobBusyID?: string + onCancelJob: (id: string) => void + onAnswerQuestion: (id: string) => void commandBusyID?: string width: number sessionID: string @@ -5419,6 +5538,8 @@ function SessionBottomDock(props: { const commandWidth = createMemo(() => props.commands.length > 0 ? Math.max(42, Math.min(72, Math.floor(props.width * 0.55))) : 0, ) + const questionsWidth = createMemo(() => props.questions.length > 0 ? Math.max(42, Math.min(64, props.width)) : 0) + const jobsWidth = createMemo(() => props.jobs.length > 0 ? 42 : 0) const builtinDockWidgetIDs = ["todo", "notes", "subagents", "info"] const profileControlsBuiltins = createMemo(() => { const widgets = mend.profile.widgets @@ -5451,6 +5572,8 @@ function SessionBottomDock(props: { dockWidth: layout().dockWidth, widgetWidths: [ commandWidth(), + questionsWidth(), + jobsWidth(), layout().todoWidth, layout().showNotes ? layout().notesWidth : 0, layout().showSubagents ? layout().subagentsWidth : 0, @@ -5459,35 +5582,15 @@ function SessionBottomDock(props: { ], }), ) - const trayOverflow = createMemo(() => trayContentWidth() > layout().dockWidth) - const trayHeight = createMemo(() => layout().dockHeight + (trayOverflow() ? 1 : 0)) - let tray: ScrollBoxRenderable | undefined - - onMount(() => { - if (!props.autoFocus) return - tray?.focus() - props.onAutoFocus?.() - }) - return ( - { - tray = value - }} + + 0}> + + 0}> + 0}> + + @@ -5551,7 +5668,7 @@ function SessionBottomDock(props: { )} - + ) } diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/shared-server.ts b/src/mendcode/packages/opencode/src/cli/cmd/tui/shared-server.ts index 814586ed..f8659558 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/shared-server.ts +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/shared-server.ts @@ -1,6 +1,10 @@ import fs from "fs/promises" import path from "path" +import { realpathSync } from "fs" import { Global } from "@mendcode/core/global" +import { Flag } from "@mendcode/core/flag/flag" +import { InstallationChannel } from "@mendcode/core/installation/version" +import { resolveDefaultSqliteDbPath } from "@/storage/resolve-default-sqlite-path" const STATE_VERSION = 1 as const const LOCK_STALE_AFTER_MS = 30_000 @@ -19,8 +23,50 @@ export type SharedServerState = { runtimeID: string } +function canonicalPath(file: string): string { + try { + return realpathSync(file) + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error + const parent = path.dirname(file) + if (parent === file) throw error + return path.join(canonicalPath(parent), path.basename(file)) + } +} + +export function resolveSharedDatabasePath(database: string | undefined, dataDir = Global.Path.data) { + if (!database || database === ":memory:") return database + return canonicalPath(path.resolve(dataDir, database)) +} + +export function resolveSharedServerRoot(input: { + database?: string + defaultDatabase: string + stateDirectory: string + pid?: number +}) { + const legacy = path.join(input.stateDirectory, "shared-server") + if (!input.database) return legacy + if (input.database === ":memory:") return path.join(legacy, `memory-${input.pid ?? process.pid}`) + const database = canonicalPath(path.resolve(input.database)) + if (database === canonicalPath(path.resolve(input.defaultDatabase))) return legacy + // A DB-specific lock must not depend on the client's XDG state directory. + return `${database}.shared-server` +} + function rootPath() { - return path.join(Global.Path.state, "shared-server") + if (process.env.MENDCODE_SHARED_SERVER_STATE_FILE) { + return path.dirname(process.env.MENDCODE_SHARED_SERVER_STATE_FILE) + } + return resolveSharedServerRoot({ + database: resolveSharedDatabasePath(Flag.OPENCODE_DB), + defaultDatabase: resolveDefaultSqliteDbPath({ + dataDir: Global.Path.data, + installationChannel: InstallationChannel, + disableChannelDb: Flag.OPENCODE_DISABLE_CHANNEL_DB, + }), + stateDirectory: Global.Path.state, + }) } export function statePath() { @@ -328,7 +374,11 @@ export async function acquireLock(timeoutMs = 12_000) { } catch { try { const stat = await fs.stat(lockPath()) - if (Date.now() - stat.mtimeMs > LOCK_STALE_AFTER_MS) { + const owner = Number(await fs.readFile(path.join(lockPath(), "owner"), "utf8")) + if ( + Date.now() - stat.mtimeMs > LOCK_STALE_AFTER_MS && + Number.isSafeInteger(owner) && owner > 0 && !isProcessAlive(owner) + ) { await fs.rm(lockPath(), { recursive: true, force: true }) continue } diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/thread.ts b/src/mendcode/packages/opencode/src/cli/cmd/tui/thread.ts index 667c692f..f6c534ad 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/thread.ts @@ -30,6 +30,8 @@ import { loadMendTuiProfile } from "@/mend/profile" import { ServerAuth } from "@/server/auth" import { SharedServer, type SharedServerClientLease, type SharedServerState } from "./shared-server" import { isProcessMemoryUsage, processMemoryUsage, type DiagnosticsSnapshot } from "@/util/process-memory" +import { Installation } from "@/installation" +import { trackUpdateStartup } from "@/installation/startup" const SHARED_SERVER_PROBE_TIMEOUT_MS = 2_000 const SHARED_SERVER_WAIT_TIMEOUT_MS = 8_000 @@ -72,14 +74,15 @@ export function resolveSharedServerURL(value?: string, environment = process.env return url.toString() } -async function probeSharedServer(input: { url: string; directory: string; headers?: RequestInit["headers"] }) { +async function probeSharedServer(input: { url: string; directory: string; headers?: RequestInit["headers"]; fetch?: typeof fetch }) { const client = createOpencodeClient({ baseUrl: input.url, directory: input.directory, headers: input.headers, + fetch: input.fetch, signal: AbortSignal.timeout(SHARED_SERVER_PROBE_TIMEOUT_MS), }) - await client.global.health({ throwOnError: true }) + return (await client.global.health({ throwOnError: true })).data } function sharedServerConnection(state: SharedServerState) { @@ -217,8 +220,9 @@ function sharedServerCommand(runtimeCwd: string) { } } -function sharedServerEnvironment(credentials: ReturnType, runtimeID: string) { +export function sharedServerEnvironment(credentials: ReturnType, runtimeID: string) { const env = { ...process.env } + const database = SharedServer.resolveSharedDatabasePath(env.MENDCODE_DB || env.OPENCODE_DB) for (const key of [ "MENDCODE_ROOT", "OPENCODE_ROOT", @@ -240,6 +244,7 @@ function sharedServerEnvironment(credentials: ReturnType[0]) { + const connection = await ensureLocalSharedServer(input) + if (connection) return connection + throw new Error( + "The shared backend could not become ready. Finish active sessions in other terminals before restarting MendCode. " + + "If it remains unavailable, inspect the shared-server logs and lock; no separate database writer was started.", + ) +} + function createWorkerFetch(client: RpcClient): typeof fetch { const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise => { const request = new Request(input, init) @@ -486,6 +500,7 @@ export const TuiThreadCommand = cmd({ // Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it. // (Important when running under `bun run` wrappers on Windows.) const unguard = win32InstallCtrlCGuard() + let startup: Awaited> try { // Must be the very first thing — disables CTRL_C_EVENT before any Worker // spawn or async work so the OS cannot kill the process group. @@ -499,6 +514,14 @@ export const TuiThreadCommand = cmd({ // Resolve relative --project paths from PWD, then use the real cwd after // chdir so the thread and worker share the same directory key. const runtimeCwd = process.cwd() + if (!Installation.isLocal() && !args.serverUrl && !process.env.MENDCODE_SERVER_URL) { + startup = await trackUpdateStartup({ executable: process.execPath, version: Installation.displayVersion(), + journal: (await import("@/storage/migration-journal")).supportedMigrationJournal() }) + .catch((error) => { + Log.Default.warn("update startup record unavailable", { error: errorMessage(error) }) + return undefined + }) + } const next = resolveThreadDirectory(args.project) try { process.chdir(next) @@ -559,11 +582,16 @@ export const TuiThreadCommand = cmd({ networkOptionSet, }) ) { - localSharedServer = await ensureLocalSharedServer({ directory: cwd, runtimeCwd }) - if (localSharedServer) { - serverURL = localSharedServer.url - serverHeaders = localSharedServer.headers + try { + localSharedServer = await requireLocalSharedServer({ directory: cwd, runtimeCwd }) + } catch (error) { + // Falling through would start a private worker on the same database. + UI.error(errorMessage(error)) + process.exitCode = 1 + return } + serverURL = localSharedServer.url + serverHeaders = localSharedServer.headers } const file = serverURL ? undefined : await target() @@ -704,6 +732,14 @@ export const TuiThreadCommand = cmd({ await tui({ url: transport.url, + onStartupReady: startup ? async () => { + const health = await probeSharedServer({ ...transport, directory: cwd }) + if (health?.healthy !== true || health.version !== Installation.displayVersion()) { + await startup?.close("Connected backend does not match the installed update version.") + return + } + await startup?.ready() + } : undefined, ...(args.diagnostics ? { async onSnapshot() { @@ -749,6 +785,7 @@ export const TuiThreadCommand = cmd({ await stop() } } finally { + await startup?.close().catch((error) => Log.Default.warn("update startup record failed", { error: errorMessage(error) })) unguard?.() } process.exit(process.exitCode ?? 0) diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/worker.ts b/src/mendcode/packages/opencode/src/cli/cmd/tui/worker.ts index a8ffa3d3..2a4dc160 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/worker.ts @@ -12,6 +12,7 @@ import { AppRuntime } from "@/effect/app-runtime" import { ensureProcessMetadata } from "@mendcode/core/util/opencode-process" import { Effect } from "effect" import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" +import { migrateLegacyStorage } from "@/storage/startup-migration" ensureProcessMetadata("worker") @@ -25,6 +26,7 @@ await Log.init({ }) Heap.start() +await migrateLegacyStorage() process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { @@ -88,6 +90,7 @@ export const rpc = { await InstanceRuntime.disposeAllInstances() if (server) await server.stop(true) + ;(await import("@/storage/db")).Database.close() }, } diff --git a/src/mendcode/packages/opencode/src/cli/cmd/upgrade.ts b/src/mendcode/packages/opencode/src/cli/cmd/upgrade.ts index 65cce848..85cf6a4a 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/upgrade.ts +++ b/src/mendcode/packages/opencode/src/cli/cmd/upgrade.ts @@ -3,6 +3,9 @@ import { UI } from "../ui" import * as prompts from "@clack/prompts" import { Installation } from "../../installation" import { InstallationVersion } from "@mendcode/core/installation/version" +import { channels, readChannel, writeChannel } from "../../installation/release-channel" +import { latestUpdateStartup, latestUpdateOperation } from "../../installation/startup" +import { updateLabel } from "../../installation/progress" export const UpgradeCommand = { command: "upgrade [target]", @@ -10,6 +13,22 @@ export const UpgradeCommand = { describe: "upgrade MendCode runtime to the latest or a specific version", builder: (yargs: Argv) => { return yargs + .command("channel [action] [channel]", "show or set the release channel without installing", (cli) => cli + .positional("action", { choices: ["set"] as const }) + .positional("channel", { choices: [...channels] }), async (args) => { + if (args.action === "set") { + const channel = await writeChannel(args.channel) + UI.println(`Release channel: ${channel}. Run mendcode upgrade to install.`) + return + } + UI.println(`Release channel: ${await readChannel()}`) + }) + .option("check", { type: "boolean", describe: "check the selected channel without installing" }) + .option("rollback", { type: "boolean", describe: "restore the retained previous executable when its data compatibility is verified" }) + .check((args) => { + if (args.rollback && (args.check || args.target || args.method)) throw new Error("--rollback cannot be combined with --check, a target version, or --method") + return true + }) .positional("target", { describe: "version to upgrade to, for ex '0.1.48' or 'v0.1.48'", type: "string", @@ -21,7 +40,45 @@ export const UpgradeCommand = { choices: ["curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"], }) }, - handler: async (args: { target?: string; method?: string }) => { + handler: async (args: { target?: string; method?: string; check?: boolean; rollback?: boolean }) => { + if (args.rollback) { + const { rollback } = await import("../../installation/rollback") + const result = await rollback({ executable: process.execPath, version: Installation.displayVersion(), + maintain: async (metadata) => { + const { SharedServer } = await import("./tui/shared-server") + const unlock = await SharedServer.acquireLock() + if (!unlock) throw new Error("Backend startup is busy; close active terminals before rolling back.") + try { + const state = await SharedServer.readState() + if (state && SharedServer.isProcessAlive(state.pid)) { + throw new Error("The shared backend is still running. Finish its work and close connected terminals before rollback.") + } + const { Path } = await import("../../storage/db") + const { acquireDatabaseMaintenance } = await import("../../storage/compatibility") + const maintenance = acquireDatabaseMaintenance(Path, metadata.journal) + return async () => { try { maintenance.release() } finally { await unlock() } } + } catch (error) { await unlock(); throw error } + }, + }) + UI.println(`Restored ${result.version}; restart MendCode to verify backend and TUI readiness. Your database was not restored.`) + return + } + if (args.check) { + UI.println(`Channel: ${await readChannel()} · Installed: ${Installation.displayVersion()}`) + const operation = await latestUpdateOperation(process.execPath) + if (operation) { + UI.println(`Last update: ${operation.version} · ${operation.failed ? "failed during " : ""}${operation.phase}`) + UI.println(`Update record: ${operation.file}`) + } + const startup = await latestUpdateStartup(process.execPath, Installation.displayVersion()) + if (startup) { + UI.println(`Last startup: ${startup.state}${startup.error ? ` · ${startup.error}` : ""}`) + UI.println(`Startup record: ${startup.file}`) + } + const available = await Installation.latest().catch(() => undefined) + UI.println(available ? `Available: ${available}` : "Release check unavailable; local installation was not changed.") + return + } UI.empty() UI.println(UI.logo(" ")) UI.empty() @@ -38,7 +95,7 @@ export const UpgradeCommand = { ], initialValue: false, }) - if (!install) { + if (prompts.isCancel(install) || !install) { prompts.outro("Done") return } @@ -54,8 +111,10 @@ export const UpgradeCommand = { prompts.log.info(`From ${InstallationVersion} → ${target}`) const spinner = prompts.spinner() - spinner.start("Upgrading...") - const err = await Installation.upgrade(method, target).catch((err) => err) + spinner.start("Checking release...") + const err = await Installation.upgrade(method, target, (phase, progress) => { + spinner.message(`${updateLabel(phase, progress)}...`) + }).catch((err) => err) if (err) { spinner.stop("Upgrade failed", 1) if (err instanceof Installation.UpgradeFailedError) { @@ -69,7 +128,7 @@ export const UpgradeCommand = { prompts.outro("Done") return } - spinner.stop("Upgrade complete") + spinner.stop("Installed; restart required to check startup") prompts.outro("Done") }, } diff --git a/src/mendcode/packages/opencode/src/cli/shared-client.ts b/src/mendcode/packages/opencode/src/cli/shared-client.ts new file mode 100644 index 00000000..8587e04a --- /dev/null +++ b/src/mendcode/packages/opencode/src/cli/shared-client.ts @@ -0,0 +1,17 @@ +import path from "node:path" + +export async function withSharedClient( + directory: string, + use: (connection: { url: string; headers: Headers; directory: string }) => Promise, +) { + const { requireLocalSharedServer } = await import("./cmd/tui/thread") + const resolved = path.resolve(directory) + const connection = await requireLocalSharedServer({ directory: resolved, runtimeCwd: process.cwd() }) + const headers = new Headers(connection.headers) + headers.set("x-opencode-directory", encodeURIComponent(resolved)) + try { + return await use({ url: connection.url, headers, directory: resolved }) + } finally { + await connection.lease.release() + } +} diff --git a/src/mendcode/packages/opencode/src/config/config.ts b/src/mendcode/packages/opencode/src/config/config.ts index a1f6555a..0d448a41 100644 --- a/src/mendcode/packages/opencode/src/config/config.ts +++ b/src/mendcode/packages/opencode/src/config/config.ts @@ -41,6 +41,7 @@ import { ConfigServer } from "./server" import { ConfigSkills } from "./skills" import { ConfigVariable } from "./variable" import { Npm } from "@mendcode/core/npm" +import { notifyDisabled } from "@/session/continuity-control" const log = Log.create({ service: "config" }) @@ -244,9 +245,7 @@ export const Info = Schema.Struct({ model: Schema.optional(ConfigModelID).annotate({ description: "Image generation model in provider/model format", }), - adapter: Schema.optional( - Schema.Literals(["auto", "codex-oauth", "openrouter", "openai-compatible"]), - ).annotate({ + adapter: Schema.optional(Schema.Literals(["auto", "codex-oauth", "openrouter", "openai-compatible"])).annotate({ description: "Image API adapter. Auto selects only verified provider contracts.", }), base_url: Schema.optional(Schema.String).annotate({ @@ -357,6 +356,18 @@ export const Info = Schema.Struct({ ).annotate({ description: "Default handling for prompts submitted while an assistant turn is active." }), experimental: Schema.optional( Schema.Struct({ + async_tools: Schema.optional(Schema.Boolean).annotate({ + description: "Opt in to emulated background read tools (default: false).", + }), + async_questions: Schema.optional(Schema.Boolean).annotate({ + description: "Opt in to persistent nonblocking questions (default: false).", + }), + session_recall: Schema.optional(Schema.Boolean).annotate({ + description: "Opt in to bounded current/child session history recall (default: false).", + }), + reasoning_auto: Schema.optional(Schema.Boolean).annotate({ + description: "Opt in to balanced automatic reasoning; manual selection takes precedence (default: false).", + }), disable_paste_summary: Schema.optional(Schema.Boolean), paste_summary_min_chars: Schema.optional(PositiveInt).annotate({ description: @@ -881,6 +892,7 @@ export const layer = Layer.effect( yield* fs .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) .pipe(Effect.orDie) + notifyDisabled({ directory: dir, experimental: config.experimental }) }) const invalidate = Effect.fn("Config.invalidate")(function* () { @@ -908,7 +920,10 @@ export const layer = Layer.effect( if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) } - if (changed) yield* invalidate() + if (changed) { + yield* invalidate() + notifyDisabled({ experimental: config.experimental }) + } return { info: next, changed } }) diff --git a/src/mendcode/packages/opencode/src/index.ts b/src/mendcode/packages/opencode/src/index.ts index 7ce5a9c1..69a0b76c 100644 --- a/src/mendcode/packages/opencode/src/index.ts +++ b/src/mendcode/packages/opencode/src/index.ts @@ -1,7 +1,6 @@ import yargs from "yargs" import { hideBin } from "yargs/helpers" import * as Log from "@mendcode/core/util/log" -import { Global } from "@mendcode/core/global" import { UI } from "./cli/ui" import { Installation } from "./installation" import { NamedError } from "@mendcode/core/util/error" @@ -10,8 +9,7 @@ import { EOL } from "os" import { errorMessage } from "./util/error" import { Heap } from "./cli/heap" import { ensureProcessMetadata } from "@mendcode/core/util/opencode-process" -import path from "path" -import { existsSync } from "fs" +import { commandOwnsStartupMigration, migrateLegacyStorage } from "./storage/startup-migration" import { automationJSONRequested, writeAutomationEnvelope } from "./cli/automation" const processMetadata = ensureProcessMetadata("main") @@ -159,55 +157,8 @@ const cli = yargs(args) run_id: processMetadata.runID, }) - const migrationDonePath = path.join(Global.Path.data, ".mendcode-json-storage-migration-v0.done") - if (!existsSync(migrationDonePath)) { - const [{ JsonMigration }, { Database }, { drizzle }] = await Promise.all([ - import("@/storage/json-migration"), - import("@/storage/db"), - import("drizzle-orm/bun-sqlite"), - ]) - const tty = process.stderr.isTTY - process.stderr.write("Performing one time database migration, may take a few minutes..." + EOL) - const width = 36 - const orange = "\x1b[38;5;214m" - const muted = "\x1b[0;2m" - const reset = "\x1b[0m" - let last = -1 - if (tty) process.stderr.write("\x1b[?25l") - try { - const stats = await JsonMigration.run(drizzle({ client: Database.Client().$client }), { - progress: (event) => { - const percent = Math.floor((event.current / event.total) * 100) - if (percent === last && event.current !== event.total) return - last = percent - if (tty) { - const fill = Math.round((percent / 100) * width) - const bar = `${"■".repeat(fill)}${"・".repeat(width - fill)}` - process.stderr.write( - `\r${orange}${bar} ${percent.toString().padStart(3)}%${reset} ${muted}${event.label.padEnd(12)} ${event.current}/${event.total}${reset}`, - ) - if (event.current === event.total) process.stderr.write("\n") - } else { - process.stderr.write(`sqlite-migration:${percent}${EOL}`) - } - }, - }) - if (JsonMigration.jsonStorageMigrationSucceeded(stats)) { - await JsonMigration.writeJsonStorageMigrationDoneMarker() - process.stderr.write("Database migration complete." + EOL) - } else { - process.stderr.write( - `Database migration incomplete (${stats.errors.length} errors); it will retry on the next start.${EOL}`, - ) - for (const error of stats.errors.slice(0, 10)) process.stderr.write(`- ${error}${EOL}`) - } - } finally { - if (tty) process.stderr.write("\x1b[?25h") - else { - process.stderr.write(`sqlite-migration:done${EOL}`) - } - } - } + const command = typeof opts._[0] === "string" && opts._[0] in commandLoaders ? opts._[0] : undefined + if (commandOwnsStartupMigration(command)) await migrateLegacyStorage() }) .usage("") .completion("completion", "generate shell completion script") diff --git a/src/mendcode/packages/opencode/src/installation/index.ts b/src/mendcode/packages/opencode/src/installation/index.ts index fecc67f1..23e97ec6 100644 --- a/src/mendcode/packages/opencode/src/installation/index.ts +++ b/src/mendcode/packages/opencode/src/installation/index.ts @@ -15,14 +15,14 @@ import { makeRuntime } from "@mendcode/core/effect/runtime" import semver from "semver" import { InstallationChannel, InstallationVersion } from "@mendcode/core/installation/version" import { which } from "@/util/which" +import { readChannel, selectRelease, type Release } from "./release-channel" +import { updateProgress, type UpdateObserver } from "./progress" +import { verifyReleaseIndex, verifyInstallerBytes } from "./release-index" const log = Log.create({ service: "installation" }) const GITHUB_REPO = process.env.MENDCODE_GITHUB_REPO ?? "MendCode/MendCode" const GITHUB_RAW_INSTALL_URL = process.env.MENDCODE_INSTALL_URL ?? `https://raw.githubusercontent.com/${GITHUB_REPO}/main/src/mendcode/install` -const GITHUB_RAW_INSTALL_PS1_URL = - process.env.MENDCODE_INSTALL_PS1_URL ?? - `https://raw.githubusercontent.com/${GITHUB_REPO}/main/src/mendcode/install.ps1` const GITHUB_LATEST_RELEASE_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest` const PackageVersion = (() => { try { @@ -158,13 +158,17 @@ export function windowsUpdaterArgs(scriptPath: string, target: string) { } // Response schemas for external version APIs -const GitHubRelease = Schema.Struct({ tag_name: Schema.String }) +const GitHubRelease = Schema.Struct({ + tag_name: Schema.String, + draft: Schema.optional(Schema.Boolean), + prerelease: Schema.optional(Schema.Boolean), +}) export interface Interface { readonly info: () => Effect.Effect readonly method: () => Effect.Effect readonly latest: (method?: Method) => Effect.Effect - readonly upgrade: (method: Method, target: string) => Effect.Effect + readonly upgrade: (method: Method, target: string, observer?: UpdateObserver) => Effect.Effect } export class Service extends Context.Service()("@opencode/Installation") {} @@ -213,10 +217,45 @@ export const layer: Layer.Layer verifyReleaseIndex({ version: target, + directory: path.join(tmpdir(), "mendcode-release-verification"), + run: async (command, args, options) => { + const result = await Effect.runPromise(run([command, ...args]).pipe(Effect.timeout(options.timeoutMs))) + return { exitCode: Number(result.code) } + }, + fetch: ((url: string | URL | Request) => Effect.runPromise(Effect.gen(function* () { + const response = yield* http.execute(HttpClientRequest.get(String(url)).pipe(HttpClientRequest.acceptJson)) + const bytes = yield* response.stream.pipe(Stream.runFold(() => Buffer.alloc(0), (result, chunk) => { + if (result.length + chunk.length > 512 * 1024) throw new Error("Release metadata exceeds the size limit") + return Buffer.concat([result, chunk]) + })) + return new Response(bytes, { status: response.status, headers: response.headers }) + }).pipe(Effect.timeout(15_000)))) as typeof fetch, + }), catch: (error) => new UpgradeFailedError({ stderr: describeUpgradeFailure(error) }) }) + const { Path } = yield* Effect.promise(() => import("@/storage/db")) + const { assertCompatibility } = yield* Effect.promise(() => import("@/storage/compatibility")) + yield* Effect.sync(() => assertCompatibility(Path, verified.index.schema.journal)) + installerURL = usesNativeWindowsUpdater() ? verified.windowsInstallerURL : verified.installerURL + installerSHA256 = usesNativeWindowsUpdater() ? verified.windowsInstallerSHA256 : verified.installerSHA256 + verifiedSums = path.join(path.dirname(verified.file), "verified-sums.txt") + yield* Effect.sync(() => writeFileSync(verifiedSums!, Object.entries(verified.checksums) + .map(([name, digest]) => `${digest} ${name}`).join("\n") + "\n", { mode: 0o600 })) + } if (usesNativeWindowsUpdater()) { - const response = yield* httpOk.execute(HttpClientRequest.get(GITHUB_RAW_INSTALL_PS1_URL)) + const response = yield* httpOk.execute(HttpClientRequest.get(installerURL)).pipe(Effect.timeout(15_000)) const body = yield* response.text + if (installerSHA256) yield* Effect.sync(() => verifyInstallerBytes(new TextEncoder().encode(body), installerSHA256)) const powershell = installerPowerShell() if (!powershell) { return yield* new UpgradeFailedError({ @@ -232,6 +271,7 @@ export const layer: Layer.Layer verifyInstallerBytes(new TextEncoder().encode(body), installerSHA256)) const bash = installerShell() if (!bash) { return yield* new UpgradeFailedError({ @@ -258,17 +299,30 @@ export const layer: Layer.Layer Effect.sync(() => progress(chunk))), + Stream.runFold(() => "", (output, chunk) => (output + chunk).slice(-65_536)), + ), + Stream.decodeText(handle.stderr).pipe( + Stream.runFold(() => "", (output, chunk) => (output + chunk).slice(-65_536)), + ), + ], { concurrency: 2 }, ) const code = yield* handle.exitCode return { code, stdout, stderr } }, + Effect.timeout(930_000), Effect.scoped, + Effect.catchTag("TimeoutError", () => Effect.fail(new UpgradeFailedError({ + stderr: "The updater timed out. Check the download connection and retry; inspect the installed version before starting another update.", + }))), Effect.catchDefect((defect) => Effect.fail(new UpgradeFailedError({ stderr: describeUpgradeFailure(defect) }))), Effect.mapError((error) => error instanceof UpgradeFailedError @@ -317,19 +371,40 @@ export const layer: Layer.Layer Effect.fail(new UpgradeFailedError({ + stderr: "The installed binary did not answer within 30 seconds. Restart MendCode or recover the previous version before retrying.", + }))), + )).trim() if (installedVersion !== target) { return yield* new UpgradeFailedError({ stderr: `MendCode upgrade did not install the requested version. Expected ${target}, found ${installedVersion || "unknown"}. Restart the current process and retry.`, diff --git a/src/mendcode/packages/opencode/src/installation/progress.ts b/src/mendcode/packages/opencode/src/installation/progress.ts new file mode 100644 index 00000000..eb14198c --- /dev/null +++ b/src/mendcode/packages/opencode/src/installation/progress.ts @@ -0,0 +1,41 @@ +export const updatePhases = ["checking", "downloading", "verifying", "activating", "activated"] as const +export type UpdatePhase = (typeof updatePhases)[number] +export type DownloadProgress = { bytes: number; total?: number } +export type UpdateObserver = (phase: UpdatePhase, progress?: DownloadProgress) => void + +export function updateLabel(phase: UpdatePhase, progress?: DownloadProgress) { + if (progress && phase === "downloading") { + const received = `${(progress.bytes / 1024 / 1024).toFixed(1)} MiB` + return progress.total ? `Downloading · ${received} / ${(progress.total / 1024 / 1024).toFixed(1)} MiB (${Math.floor(progress.bytes * 100 / progress.total)}%)` + : `Downloading · ${received}` + } + return phase === "activated" ? "Checking installed version" : `${phase[0].toUpperCase()}${phase.slice(1)}` +} + +export function updateProgress(observer?: UpdateObserver) { + let pending = "" + let previous: UpdatePhase | undefined + let previousBytes = -1 + return (chunk: string) => { + const lines = (pending + chunk).split("\n") + pending = (lines.pop() ?? "").slice(-1024) + for (const line of lines) { + const download = /^MENDCODE_UPDATE_BYTES=(\d+)\/(\d*)$/.exec(line.trim()) + if (download && previous === "downloading") { + const bytes = Number(download[1]) + const total = download[2] ? Number(download[2]) : undefined + if (!Number.isSafeInteger(bytes) || bytes < previousBytes || bytes === previousBytes || + (total !== undefined && (!Number.isSafeInteger(total) || total < bytes || total <= 0))) continue + previousBytes = bytes + try { observer?.("downloading", { bytes, total }) } catch {} + continue + } + if (!line.startsWith("MENDCODE_UPDATE_PHASE=")) continue + const phase = line.slice("MENDCODE_UPDATE_PHASE=".length).trim() + if (!updatePhases.includes(phase as UpdatePhase) || phase === previous) continue + previous = phase as UpdatePhase + // An observer cannot interrupt installation halfway through activation. + try { observer?.(previous) } catch {} + } + } +} diff --git a/src/mendcode/packages/opencode/src/installation/release-channel.ts b/src/mendcode/packages/opencode/src/installation/release-channel.ts new file mode 100644 index 00000000..3fd37883 --- /dev/null +++ b/src/mendcode/packages/opencode/src/installation/release-channel.ts @@ -0,0 +1,51 @@ +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { randomUUID } from "node:crypto" +import semver from "semver" + +export const channels = ["stable", "beta", "nightly"] as const +export type ReleaseChannel = (typeof channels)[number] + +export function parseChannel(value: unknown): ReleaseChannel { + if (value === "stable" || value === "beta" || value === "nightly") return value + throw new Error("Release channel must be stable, beta, or nightly") +} + +function configPath() { + // Update preferences must remain independent of build channel and database layout. + return path.join(process.env.OPENCODE_TEST_HOME ?? os.homedir(), ".mendcode", "release-channel.json") +} + +export async function readChannel(): Promise { + const text = await fs.readFile(configPath(), "utf8").catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null + throw error + }) + if (text === null) return "stable" + return parseChannel(JSON.parse(text).channel) +} + +export async function writeChannel(value: unknown) { + const channel = parseChannel(value) + const file = configPath() + await fs.mkdir(path.dirname(file), { recursive: true }) + const temporary = `${file}.${randomUUID()}.tmp` + await fs.writeFile(temporary, JSON.stringify({ version: 1, channel }) + "\n", { mode: 0o600 }) + await fs.rename(temporary, file) + return channel +} + +export type Release = { tag_name: string; draft?: boolean; prerelease?: boolean } + +export function selectRelease(releases: Release[], channel: ReleaseChannel) { + return releases + .filter((release) => { + if (release.draft) return false + const version = semver.parse(release.tag_name) + if (!version) return false + if (channel === "stable") return !release.prerelease && version.prerelease.length === 0 + return release.prerelease === true && version.prerelease[0] === channel + }) + .sort((a, b) => semver.rcompare(a.tag_name, b.tag_name))[0]?.tag_name.replace(/^v/, "") +} diff --git a/src/mendcode/packages/opencode/src/installation/release-index.ts b/src/mendcode/packages/opencode/src/installation/release-index.ts new file mode 100644 index 00000000..36c6287a --- /dev/null +++ b/src/mendcode/packages/opencode/src/installation/release-index.ts @@ -0,0 +1,129 @@ +import { createHash } from "node:crypto" +import { mkdir, mkdtemp, writeFile } from "node:fs/promises" +import path from "node:path" +import z from "zod" + +const repository = "MendCode/MendCode" +const workflow = ".github/workflows/release.yml" +const sha256 = z.string().regex(/^[a-f0-9]{64}$/) +const commit = z.string().regex(/^[a-f0-9]{40}$/) +const checksum = (bytes: Uint8Array | string) => createHash("sha256").update(bytes).digest("hex") +const MAX_INDEX_BYTES = 512 * 1024 + +const Index = z.object({ + formatVersion: z.literal(1), + version: z.string(), + channel: z.enum(["stable", "beta", "nightly"]), + tag: z.string(), + commit, + repository: z.literal(repository), + provenance: z.object({ workflow: z.literal(workflow), runID: z.string().regex(/^[1-9]\d*$/) }), + installer: z.object({ path: z.literal("src/mendcode/install"), commit, sha256 }), + windowsInstaller: z.object({ path: z.literal("src/mendcode/install.ps1"), commit, sha256 }), + schema: z.object({ + journal: z.array(z.object({ name: z.string().regex(/^\d{14}_[a-z0-9_-]+$/), timestamp: z.number().int().nonnegative(), hash: sha256 })).min(1).max(2048), + fingerprint: sha256, + }), + assets: z.array(z.object({ name: z.string(), platform: z.string(), size: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), sha256 })).min(1).max(64), +}) + +export type ReleaseIndex = z.infer +export type AttestationRunner = (command: string, args: string[], options: { timeoutMs: number }) => Promise<{ exitCode: number }> +export type ReleaseFetch = (url: string, init?: RequestInit) => Promise + +function channel(version: string) { + if (/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) return "stable" + if (/^\d+\.\d+\.\d+-beta\.[1-9]\d*$/.test(version)) return "beta" + if (/^\d+\.\d+\.\d+-nightly\.\d{8}\.[1-9]\d*$/.test(version)) return "nightly" + throw new Error("Unsupported release version; select stable, beta.N or nightly.DATE.RUN.") +} + +export function validateReleaseIndex(value: unknown, expected: { version: string; commit?: string }): ReleaseIndex { + const data = Index.parse(value) + if (data.version !== expected.version || data.tag !== `v${expected.version}` || data.channel !== channel(expected.version)) + throw new Error("Release index version, tag or channel does not match the requested release.") + if ((expected.commit && data.commit !== expected.commit) || data.installer.commit !== data.commit || data.windowsInstaller.commit !== data.commit) + throw new Error("Release index source commit does not match the immutable release tag.") + const journal = data.schema.journal + for (let index = 0; index < journal.length; index++) { + const entry = journal[index] + const values = entry.name.slice(0, 14).match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/)!.slice(1).map(Number) + if (Date.UTC(values[0], values[1] - 1, values[2], values[3], values[4], values[5]) !== entry.timestamp) + throw new Error("Release schema migration timestamp does not match its identity.") + if (index > 0 && (journal[index - 1].name >= entry.name || journal[index - 1].timestamp >= entry.timestamp)) + throw new Error("Release schema journal is duplicated or out of order.") + } + if (checksum(JSON.stringify(journal)) !== data.schema.fingerprint) throw new Error("Release schema fingerprint is invalid.") + const names = new Set() + for (const asset of data.assets) { + const match = /^mendcode-((linux|darwin|windows)-(arm64|x64)(-baseline)?(-musl)?)\.(zip|tar\.gz)$/.exec(asset.name) + if (!match || match[1] !== asset.platform || names.has(asset.name)) throw new Error("Invalid or duplicate release asset.") + if ((match[2] === "linux") !== (match[6] === "tar.gz") || (match[5] && match[2] !== "linux") || (match[4] && match[3] !== "x64")) + throw new Error("Release asset platform and archive type disagree.") + names.add(asset.name) + } + return data +} + +async function boundedFetch(url: string, fetcher: ReleaseFetch, limit: number) { + const response = await fetcher(url, { signal: AbortSignal.timeout(15_000), headers: { Accept: "application/json" } }) + if (response.status === 404 && url.endsWith("/release-index.json")) + throw new Error("This release has no verified release index. Use the documented external recovery path for legacy versions; automatic installation is blocked.") + if (!response.ok) throw new Error(`Release verification download failed (HTTP ${response.status}).`) + const size = Number(response.headers.get("content-length")) + if (size > limit) { await response.body?.cancel(); throw new Error("Release verification metadata exceeds the size limit.") } + if (!response.body) throw new Error("Release verification metadata is empty.") + const reader = response.body.getReader() + const parts: Uint8Array[] = [] + let length = 0 + try { + while (true) { + const chunk = await reader.read() + if (chunk.done) break + length += chunk.value.byteLength + if (length > limit) { await reader.cancel(); throw new Error("Release verification metadata exceeds the size limit.") } + parts.push(chunk.value) + } + } finally { reader.releaseLock() } + return Buffer.concat(parts, length) +} + +/** The returned installer and checksums are usable only after the verifier exits successfully. */ +export async function verifyReleaseIndex(input: { + version: string + directory: string + run: AttestationRunner + fetch?: ReleaseFetch +}) { + channel(input.version) + const fetcher = input.fetch ?? fetch + const bytes = await boundedFetch(`https://github.com/${repository}/releases/download/v${input.version}/release-index.json`, fetcher, MAX_INDEX_BYTES) + const parsed = validateReleaseIndex(JSON.parse(bytes.toString("utf8")), { version: input.version }) + await mkdir(input.directory, { recursive: true, mode: 0o700 }) + const operation = await mkdtemp(path.join(input.directory, "verify-release-")) + const file = path.join(operation, "release-index.json") + await writeFile(file, bytes, { mode: 0o600, flag: "wx" }) + let result: { exitCode: number } + try { + result = await input.run("gh", ["attestation", "verify", file, "--repo", repository, "--signer-workflow", `${repository}/${workflow}`, "--deny-self-hosted-runners"], { timeoutMs: 30_000 }) + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") throw new Error("GitHub CLI (gh) is required to verify release provenance. Install it explicitly before retrying.") + throw new Error("Release provenance verification could not complete; installation is blocked.") + } + if (result.exitCode !== 0) throw new Error("Release provenance verification failed; installation is blocked.") + const resolved = JSON.parse((await boundedFetch(`https://api.github.com/repos/${repository}/commits/v${input.version}`, fetcher, MAX_INDEX_BYTES)).toString("utf8")) + const source = commit.parse(resolved.sha) + const index = validateReleaseIndex(parsed, { version: input.version, commit: source }) + const verified = { index, file, installerURL: `https://raw.githubusercontent.com/${repository}/${index.commit}/${index.installer.path}`, + installerSHA256: index.installer.sha256, + windowsInstallerURL: `https://raw.githubusercontent.com/${repository}/${index.commit}/${index.windowsInstaller.path}`, + windowsInstallerSHA256: index.windowsInstaller.sha256, + checksums: Object.fromEntries(index.assets.map((asset) => [asset.name, asset.sha256])), + } + await writeFile(path.join(operation, "verified.json"), JSON.stringify({ version: index.version, commit: index.commit, indexSHA256: checksum(bytes), verifiedAt: new Date().toISOString() }) + "\n", { mode: 0o600, flag: "wx" }) + return verified +} + +export function verifyInstallerBytes(bytes: Uint8Array, expectedSHA256: string) { + if (checksum(bytes) !== sha256.parse(expectedSHA256)) throw new Error("Pinned installer checksum does not match the verified release index.") +} diff --git a/src/mendcode/packages/opencode/src/installation/rollback.ts b/src/mendcode/packages/opencode/src/installation/rollback.ts new file mode 100644 index 00000000..4bad766e --- /dev/null +++ b/src/mendcode/packages/opencode/src/installation/rollback.ts @@ -0,0 +1,117 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { createReadStream } from "node:fs" +import { createHash } from "node:crypto" +import z from "zod" +import semver from "semver" +import { pendingUpdate } from "./startup" + +const digest = z.string().regex(/^[a-f0-9]{64}$/) +const compatibility = z.object({ + formatVersion: z.literal(1), + version: z.string().refine((value) => Boolean(semver.valid(value))), + digest, + journal: z.array(z.object({ name: z.string().max(200), timestamp: z.number().int().nonnegative(), hash: digest })).max(2048), +}) +export type RollbackCompatibility = z.infer + +async function regularFile(file: string, maxBytes: number) { + const stat = await fs.lstat(file) + if (!stat.isFile() || stat.size > maxBytes) throw new Error(`Invalid update record: ${path.basename(file)}`) + return fs.readFile(file, "utf8") +} + +async function hash(file: string) { + if (!(await fs.lstat(file)).isFile()) throw new Error("Retained executable must be a regular file.") + const hash = createHash("sha256") + for await (const chunk of createReadStream(file)) hash.update(chunk) + return hash.digest("hex") +} + +async function status(directory: string) { + return Object.fromEntries((await regularFile(path.join(directory, "status"), 4096)).trim().split("\n").map((line) => { + const separator = line.indexOf("=") + return [line.slice(0, separator), line.slice(separator + 1)] + })) +} + +export async function rollbackTarget(executable: string, version: string) { + const current = await pendingUpdate(executable, version) + if (!current) throw new Error("No verified update history matches this executable; automatic rollback is unavailable.") + const record = await status(current.directory) + const previousDigest = digest.safeParse(record.previous_sha256) + if (!previousDigest.success) throw new Error("The previous executable has no recorded identity; automatic rollback is unavailable.") + const previous = path.join(current.directory, "previous") + if (await hash(previous) !== previousDigest.data) throw new Error("The retained executable checksum changed; rollback was refused.") + const directory = path.dirname(current.directory) + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith(".update.")) continue + const history = path.join(directory, entry.name) + const metadata = await regularFile(path.join(history, "compatibility.json"), 512_000) + .then((text) => compatibility.parse(JSON.parse(text))).catch(() => undefined) + if (!metadata || metadata.digest !== previousDigest.data) continue + const installed = await status(history).catch(() => undefined) + if (installed?.phase !== "activated" || installed.version !== metadata.version || installed.binary_sha256 !== metadata.digest) continue + return { previous, metadata, currentDigest: current.digest } + } + throw new Error("Compatibility of the previous executable is unknown. Automatic rollback is unavailable; no data was restored or changed.") +} + +export async function rollback(input: { + executable: string + version: string + // The caller fences backend startup and SQLite writers before activation. + maintain: (metadata: RollbackCompatibility) => Promise<() => void | Promise> + platform?: NodeJS.Platform +}) { + if ((input.platform ?? process.platform) === "win32") { + throw new Error("Rollback requires verified deferred replacement on Windows; this installation cannot perform it yet.") + } + const executable = await fs.realpath(input.executable) + const directory = path.dirname(executable) + const lock = path.join(directory, ".update-lock") + await fs.mkdir(lock, { mode: 0o700 }).catch((error: NodeJS.ErrnoException) => { + if (error.code === "EEXIST") throw new Error("Another update owns the installation lock. Wait for it to finish or recover its verified dead owner.") + throw error + }) + let operation: string | undefined + let release: (() => void | Promise) | undefined + let phase = "checking" + let target: Awaited> | undefined + const record = async (code = "") => { + if (!operation) return + const text = `version=${target?.metadata.version ?? input.version}\nphase=${phase}\nowner_pid=${process.pid}\nexit_code=${code}\nbinary_sha256=${target?.metadata.digest ?? ""}\nprevious_sha256=${target?.currentDigest ?? ""}\n` + await fs.writeFile(path.join(operation, "status.next"), text, { mode: 0o600 }) + await fs.rename(path.join(operation, "status.next"), path.join(operation, "status")) + } + try { + operation = await fs.mkdtemp(path.join(directory, ".update.rollback-")) + await fs.writeFile(path.join(lock, "owner"), `pid=${process.pid}\noperation=${operation}\n`, { mode: 0o600 }) + await record() + target = await rollbackTarget(executable, input.version) + phase = "verifying" + await record() + release = await input.maintain(target.metadata) + if (await hash(executable) !== target.currentDigest) throw new Error("Installed executable changed during rollback verification.") + await fs.copyFile(executable, path.join(operation, "previous")) + const candidate = path.join(operation, "candidate") + await fs.copyFile(target.previous, candidate) + if (await hash(candidate) !== target.metadata.digest) throw new Error("Retained executable changed during rollback staging.") + await fs.chmod(candidate, 0o755) + await fs.writeFile(path.join(operation, "compatibility.json"), JSON.stringify(target.metadata) + "\n", { mode: 0o600 }) + phase = "activating" + await record() + await fs.rename(candidate, executable) + phase = "activated" + await record("0") + return { version: target.metadata.version, operation } + } catch (error) { + await record("1").catch(() => undefined) + throw error + } finally { + try { await release?.() } finally { + if (operation) await fs.rename(lock, path.join(operation, "lock")) + else await fs.rename(lock, path.join(directory, `.update.abandoned-${process.pid}-${Date.now()}`)) + } + } +} diff --git a/src/mendcode/packages/opencode/src/installation/startup.ts b/src/mendcode/packages/opencode/src/installation/startup.ts new file mode 100644 index 00000000..f3be9326 --- /dev/null +++ b/src/mendcode/packages/opencode/src/installation/startup.ts @@ -0,0 +1,123 @@ +import fs from "node:fs/promises" +import { createReadStream } from "node:fs" +import { createHash, randomUUID } from "node:crypto" +import path from "node:path" + +export async function latestUpdateOperation(executable: string) { + const directory = path.dirname(await fs.realpath(executable)) + const records: Array<{ file: string; phase: string; version: string; failed: boolean; modified: number }> = [] + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith(".update.")) continue + const file = path.join(directory, entry.name, "status") + const stat = await fs.lstat(file).catch(() => undefined) + if (!stat?.isFile() || stat.size > 4096) continue + const fields = Object.fromEntries((await fs.readFile(file, "utf8")).trim().split(/\r?\n/).map((line) => { + const separator = line.indexOf("=") + return [line.slice(0, separator), line.slice(separator + 1)] + })) + if (!/^[a-z-]+$/.test(fields.phase ?? "") || !/^[0-9][a-zA-Z0-9.+-]*$/.test(fields.version ?? "")) continue + records.push({ file, phase: fields.phase, version: fields.version, + failed: fields.exit_code !== undefined && fields.exit_code !== "0", modified: stat.mtimeMs }) + } + return records.sort((a, b) => b.modified - a.modified)[0] +} + +export async function pendingUpdate(executable: string, version: string) { + const directory = path.dirname(await fs.realpath(executable)) + const entries = await fs.readdir(directory, { withFileTypes: true }) + const candidates: Array<{ directory: string; digest: string; modified: number }> = [] + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith(".update.")) continue + const operation = path.join(directory, entry.name) + const status = path.join(operation, "status") + const metadata = await fs.lstat(status).catch(() => undefined) + if (!metadata?.isFile() || metadata.size > 4096) continue + const fields = Object.fromEntries((await fs.readFile(status, "utf8")).trim().split("\n").map((line) => { + const separator = line.indexOf("=") + return [line.slice(0, separator), line.slice(separator + 1)] + })) + if (fields.version !== version || fields.phase !== "activated" || !/^[a-fA-F0-9]{64}$/.test(fields.binary_sha256 ?? "")) continue + candidates.push({ directory: operation, digest: fields.binary_sha256.toLowerCase(), modified: metadata.mtimeMs }) + } + if (!candidates.length) return + const hash = createHash("sha256") + for await (const chunk of createReadStream(executable)) hash.update(chunk) + const digest = hash.digest("hex") + return candidates.sort((a, b) => b.modified - a.modified).find((candidate) => candidate.digest === digest) +} + +export async function trackUpdateStartup(input: { + executable: string + version: string + timeoutMs?: number + journal?: Array<{ name: string; timestamp: number; hash: string }> +}) { + const operation = await pendingUpdate(input.executable, input.version) + if (!operation) return + if (input.journal) { + const temporary = path.join(operation.directory, `compatibility-${randomUUID()}.tmp`) + await fs.writeFile(temporary, JSON.stringify({ formatVersion: 1, version: input.version, + digest: operation.digest, journal: input.journal }) + "\n", { mode: 0o600 }) + await fs.rename(temporary, path.join(operation.directory, "compatibility.json")) + } + const started = Date.now() + const file = path.join(operation.directory, `startup-${randomUUID()}.json`) + let state: "starting" | "failed" | "ready" = "starting" + let writes = Promise.resolve() + const write = (error?: string) => { + const snapshot = { version: input.version, digest: operation.digest, pid: process.pid, state, error, + startedAt: new Date(started).toISOString(), elapsedMs: Date.now() - started } + writes = writes.then(async () => { + const temporary = `${file}.tmp` + await fs.writeFile(temporary, JSON.stringify(snapshot) + "\n", { mode: 0o600 }) + await fs.rename(temporary, file) + }) + return writes + } + await write() + const timer = setTimeout(() => { + state = "failed" + void write("Backend and TUI readiness were not confirmed within the startup deadline.").catch(() => {}) + }, input.timeoutMs ?? 30_000) + timer.unref() + return { + file, + async ready() { + clearTimeout(timer) + state = "ready" + await write() + }, + async close(error = "Client exited before backend and TUI readiness were confirmed.") { + clearTimeout(timer) + if (state === "ready" || state === "failed") return writes + state = "failed" + await write(error) + }, + } +} + +export async function latestUpdateStartup(executable: string, version: string) { + const operation = await pendingUpdate(executable, version) + if (!operation) return + const attempts: Array<{ file: string; state: string; error?: string; startedAt: string }> = [] + for (const entry of await fs.readdir(operation.directory, { withFileTypes: true })) { + if (!entry.isFile() || !/^startup-[a-f0-9-]+\.json$/.test(entry.name)) continue + const file = path.join(operation.directory, entry.name) + const metadata = await fs.lstat(file) + if (!metadata.isFile() || metadata.size > 8192) continue + const record = await fs.readFile(file, "utf8").then((text) => JSON.parse(text)).catch(() => undefined) + if (!record || record.version !== version || record.digest !== operation.digest || + !["starting", "failed", "ready"].includes(record.state) || + typeof record.startedAt !== "string" || !Number.isFinite(Date.parse(record.startedAt)) || + !Number.isSafeInteger(record.pid) || record.pid <= 0) continue + let state = record.state as string + if (state === "starting") { + try { process.kill(record.pid, 0) } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") state = "interrupted" + } + } + attempts.push({ file, state, startedAt: record.startedAt, + error: typeof record.error === "string" ? record.error : undefined }) + } + return attempts.sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt))[0] +} diff --git a/src/mendcode/packages/opencode/src/mend/prompt/model-family.ts b/src/mendcode/packages/opencode/src/mend/prompt/model-family.ts new file mode 100644 index 00000000..52a17941 --- /dev/null +++ b/src/mendcode/packages/opencode/src/mend/prompt/model-family.ts @@ -0,0 +1,37 @@ +/** Behavioral aliases never rewrite the provider's actual request model. */ +export function normalizedPromptModel(modelID: string) { + return modelID.trim().toLowerCase().replace(/^openai\//, "").replace(/-(fast|pro)$/, "") +} + +export function isAstraModel(modelID: string) { + return /^gpt[-_.:]?6[-_.:]?astra$/.test(normalizedPromptModel(modelID)) +} + +export const ASTRA_PROMPT_SOURCE = { + revision: "mendcode-astra-2026-09-04.1", + url: "https://developers.openai.com/api/docs/guides/latest-model?model=gpt-6-astra", + verifiedAt: "2026-09-04", +} as const + +/** Responses request contract shared by API and OAuth; no private headers. */ +export function normalizeAstraRequest(request: Record) { + if (typeof request.model !== "string" || !isAstraModel(request.model)) return request + const result = { ...request } + for (const key of ["temperature", "top_p", "top_logprobs", "logprobs"]) delete result[key] + if (Array.isArray(result.include)) result.include = result.include.filter((item) => item !== "message.output_text.logprobs") + if (result.reasoning && typeof result.reasoning === "object" && !Array.isArray(result.reasoning)) { + const reasoning = result.reasoning as Record + if (reasoning.effort === "none" || reasoning.effort === "minimal") result.reasoning = { ...reasoning, effort: "low" } + } + return result +} + +export function normalizeAstraOptions(modelID: string, options: Record) { + if (!isAstraModel(modelID)) return options + const result = { ...options } + for (const key of ["temperature", "topP", "top_p", "topLogprobs", "top_logprobs", "logprobs"]) delete result[key] + if (result.reasoningEffort === "none" || result.reasoningEffort === "minimal") result.reasoningEffort = "low" + if (Array.isArray(result.include)) result.include = result.include.filter((item: unknown) => item !== "message.output_text.logprobs") + if (result.reasoning?.effort === "none" || result.reasoning?.effort === "minimal") result.reasoning = { ...result.reasoning, effort: "low" } + return result +} diff --git a/src/mendcode/packages/opencode/src/mend/prompt/reasoning-auto.ts b/src/mendcode/packages/opencode/src/mend/prompt/reasoning-auto.ts new file mode 100644 index 00000000..cf2d4f29 --- /dev/null +++ b/src/mendcode/packages/opencode/src/mend/prompt/reasoning-auto.ts @@ -0,0 +1,38 @@ +import type { ModelMessage } from "ai" + +export type ReasoningAutoSignal = "verification_failed" | "technical_block" + +export function autoReasoningSignal(messages: ModelMessage[]): ReasoningAutoSignal | undefined { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index] + if (message.role === "user") return + if (message.role !== "tool") continue + for (const part of message.content) { + if (part.type !== "tool-result" || part.toolName !== "reasoning_auto") continue + const output = part.output + let value: unknown + if (output.type === "json") value = output.value + if (output.type === "text") { + try { value = JSON.parse(output.value) } catch { continue } + } + if (!value || typeof value !== "object") continue + const signal = (value as { signal?: unknown }).signal + if (signal === "verification_failed" || signal === "technical_block") return signal + } + } +} + +export function selectAutoReasoning(input: { + enabled: boolean + manual?: string + variants?: Record + signal?: ReasoningAutoSignal +}) { + if (!input.enabled || input.manual) return undefined + const supported = ["low", "medium", "high"].filter((effort) => input.variants?.[effort] !== undefined) + const initial = supported.includes("medium") ? "medium" : supported[0] + if (!initial) return undefined + const index = supported.indexOf(initial) + const effort = input.signal ? supported[Math.min(index + 1, supported.length - 1)] : initial + return { effort, reason: input.signal ?? "balanced_initial", options: input.variants![effort] as Record } +} diff --git a/src/mendcode/packages/opencode/src/mend/prompt/reasoning-state.ts b/src/mendcode/packages/opencode/src/mend/prompt/reasoning-state.ts new file mode 100644 index 00000000..dcd874c7 --- /dev/null +++ b/src/mendcode/packages/opencode/src/mend/prompt/reasoning-state.ts @@ -0,0 +1,38 @@ +import { Schema } from "effect" +import { BusEvent } from "@/bus/bus-event" + +export const ReasoningRequested = BusEvent.define("session.reasoning.updated", Schema.Struct({ + sessionID: Schema.String, + messageID: Schema.String, + mode: Schema.Literals(["auto", "manual"]), + effort: Schema.String, + reason: Schema.String, + modelID: Schema.String, + providerID: Schema.String, + requestedAt: Schema.Number, +})) +export const ReasoningCleared = BusEvent.define("session.reasoning.cleared", Schema.Struct({ + sessionID: Schema.String, messageID: Schema.String, requestedAt: Schema.Number, +})) + +export type ReasoningRequestState = Schema.Schema.Type +const current = new Map() + +/** Live last-request telemetry; an absent entry after restart never implies a default was applied. */ +export function getReasoningState(sessionID: string) { + const value = current.get(sessionID) + return value ? { ...value } : undefined +} + +export function recordReasoningState(value: ReasoningRequestState) { + current.delete(value.sessionID) + current.set(value.sessionID, { ...value }) + while (current.size > 128) current.delete(current.keys().next().value!) +} + +export function clearReasoningState(sessionID: string) { current.delete(sessionID) } + +export function requestedReasoningEffort(options: Record) { + const value = options.reasoningEffort ?? options.reasoning?.effort ?? options.effort ?? options.thinkingLevel + return typeof value === "string" ? value : undefined +} diff --git a/src/mendcode/packages/opencode/src/mend/prompt/runtime-capabilities.ts b/src/mendcode/packages/opencode/src/mend/prompt/runtime-capabilities.ts new file mode 100644 index 00000000..f79d219e --- /dev/null +++ b/src/mendcode/packages/opencode/src/mend/prompt/runtime-capabilities.ts @@ -0,0 +1,40 @@ +import { isAstraModel } from "./model-family" + +export type RuntimeCapability = { mode: "native" | "emulated" | "unavailable"; reason: string } + +export function resolveRuntimeCapabilities(input: { + providerID: string + modelID: string + endpoint: string + auth: "api" | "oauth" | "unknown" + transport: "responses-http" | "responses-websocket" | "other" + tools: string[] +}) { + let officialAPI = false + try { + const endpoint = new URL(input.endpoint) + officialAPI = endpoint.protocol === "https:" && endpoint.hostname === "api.openai.com" + } catch {} + const nativeReason = input.auth === "oauth" + ? "Subscription transport support has not been verified independently." + : !isAstraModel(input.modelID) || input.providerID !== "openai" || !officialAPI || input.auth !== "api" + ? "This provider, model, endpoint and authentication combination has no verified native adapter." + : input.transport !== "responses-websocket" + ? "The current HTTP adapter does not implement native async calls, steering or configuration_update." + : "A native WebSocket adapter has not passed the required integration checks." + const tools = new Set(input.tools) + const capability = (enabled: boolean, reason: string): RuntimeCapability => enabled + ? { mode: "emulated", reason } + : { mode: "unavailable", reason: "The corresponding runtime tools are disabled or unavailable." } + return { + asyncTools: capability(["tool_start", "tool_status", "tool_wait", "tool_cancel"].every((name) => tools.has(name)), "MendCode owns durable jobs and returns results at a safe loop boundary."), + asyncQuestions: capability(tools.has("question_async"), "MendCode posts a question and continues independent work."), + recall: capability(tools.has("session_search") && tools.has("session_read"), "MendCode retrieves linked session history on demand."), + nativeTransport: { mode: "unavailable", reason: nativeReason } satisfies RuntimeCapability, + } +} + +export function runtimeCapabilityPrompt(input: Parameters[0]) { + const capabilities = resolveRuntimeCapabilities(input) + return ["", ...Object.entries(capabilities).map(([name, value]) => `${name}: ${value.mode}. ${value.reason}`), ""].join("\n") +} diff --git a/src/mendcode/packages/opencode/src/mend/prompt/sources.ts b/src/mendcode/packages/opencode/src/mend/prompt/sources.ts index 1cbd354f..b35f1c84 100644 --- a/src/mendcode/packages/opencode/src/mend/prompt/sources.ts +++ b/src/mendcode/packages/opencode/src/mend/prompt/sources.ts @@ -2,6 +2,7 @@ import { existsSync } from "fs" import { readFile } from "fs/promises" import path from "path" import { mendPaths } from "../config/paths" +import { ASTRA_PROMPT_SOURCE, normalizedPromptModel } from "./model-family" export type PromptSource = { label: string @@ -18,6 +19,7 @@ export type PromptBehaviorProfile = { focusID: string label: string sourcePolicy: "public-model-guidance" | "mendcode-compatibility" + provenance?: { revision: string; url: string; verifiedAt: string } behavior: string[] } @@ -36,12 +38,20 @@ const modelBehaviorProfiles: Array = { id: "gpt-6-astra", focusID: "codex", - label: "GPT-6 Astra compatibility", - sourcePolicy: "mendcode-compatibility", + label: "GPT-6 Astra public behavior guidance", + sourcePolicy: "public-model-guidance", + provenance: ASTRA_PROMPT_SOURCE, match: /(^|[^a-z0-9])gpt[-_.:/]?6[-_.:/]?astra([^a-z0-9]|$)/i, behavior: [ "Use the actual GPT-6 Astra runtime contract and exposed tools instead of assuming that every Codex CLI feature is available through MendCode.", "Treat reasoning modes, transport, caching, context limits, and advanced features as runtime configuration; verify capabilities before promising them.", + "Carry authorized work through implementation and proportionate verification. Resolve routine details from project evidence; ask only when a missing decision materially affects the outcome.", + "Incorporate corrections and side questions while preserving the current objective and earlier constraints unless the user replaces or cancels them.", + "When a non-blocking question tool is exposed, continue independent work while its answer is pending. Silence never grants permission; dependent actions must wait.", + "Use exposed async tools for independent work and retain returned job IDs; retrieve results before relying on them. Never infer native transport support from the model name.", + "Keep bounded working notes and retrieve earlier session evidence with exposed recall tools. Do not replace the objective with a compaction summary or create global memory automatically.", + "Verify the changed behavior with the narrowest meaningful check. Broaden testing only for a concrete remaining risk; report failed or unavailable checks accurately.", + "Write concise connected explanations. Avoid repeated plans, unnecessary headings, canned conclusions, and repeating context already given to the user.", ], }, { @@ -271,7 +281,7 @@ export function sourceForFocus(focusID: string) { export function promptBehaviorForModel(input: { focusID?: string | null; modelID?: string | null }) { const focusID = input.focusID || "" - const modelID = input.modelID || "" + const modelID = normalizedPromptModel(input.modelID || "") return modelBehaviorProfiles.find((profile) => profile.focusID === focusID && profile.match.test(modelID)) || null } @@ -279,6 +289,7 @@ export function promptBehaviorText(profile: PromptBehaviorProfile) { return [ `Model-specific MendCode adapter: ${profile.label}.`, `Source policy: ${profile.sourcePolicy}; this is behavioral guidance, not an upstream hidden prompt.`, + ...(profile.provenance ? [`Profile revision: ${profile.provenance.revision}; guidance verified ${profile.provenance.verifiedAt}; source: ${profile.provenance.url}`] : []), ...profile.behavior.map((item) => `- ${item}`), ].join("\n") } diff --git a/src/mendcode/packages/opencode/src/mend/tui/widgets-runtime.tsx b/src/mendcode/packages/opencode/src/mend/tui/widgets-runtime.tsx new file mode 100644 index 00000000..50fb064a --- /dev/null +++ b/src/mendcode/packages/opencode/src/mend/tui/widgets-runtime.tsx @@ -0,0 +1,139 @@ +import { For, Show } from "solid-js" +import type { QuestionRequest } from "@mendcode/sdk/v2" +import type { TuiThemeCurrent } from "@mendcode/plugin/tui" +import { Locale } from "@/util/locale" + +export type AsyncQuestionRequest = QuestionRequest & { async?: boolean } +export type WidgetReasoningState = { + sessionID: string + mode: "auto" | "manual" + effort: string + reason: string + modelID: string + providerID: string + messageID: string + requestedAt: number +} + +export function widgetReasoningLabel(state: WidgetReasoningState) { + return `Last request: ${state.mode === "auto" ? "Auto" : "Manual"} ${state.effort}` +} + +export type WidgetJob = { + id: string + sessionID: string + kind: "job" + status: string + data: { tool?: unknown; error?: unknown } + timeUpdated: number +} +export const isWidgetJobActive = (job: WidgetJob) => job.status === "queued" || job.status === "running" + +export function SessionJobsWidget(props: { + jobs: readonly WidgetJob[] + width: number + height: number + theme: Pick + busyID?: string + onCancel: (id: string) => void +}) { + return ( + + + Tools · {props.jobs.filter(isWidgetJobActive).length} active + + + + {(job) => ( + + + {Locale.truncate( + `${typeof job.data.tool === "string" ? job.data.tool : "Tool"} · ${job.status}${typeof job.data.error === "string" ? ` · ${job.data.error}` : ""}`.replace( + /\s+/g, + " ", + ), + Math.max(1, props.width - (isWidgetJobActive(job) ? 11 : 2)), + )} + + + { + if (!props.busyID) props.onCancel(job.id) + }} + > + [cancel] + + + + )} + + + + ) +} + +export function splitWidgetQuestions(requests: readonly AsyncQuestionRequest[]) { + return { + blocking: requests.filter((request) => request.async !== true), + asynchronous: requests.filter((request) => request.async === true), + } +} + +export function SessionQuestionsWidget(props: { + questions: readonly AsyncQuestionRequest[] + width: number + height: number + theme: Pick + onAnswer: (id: string) => void +}) { + return ( + + + Questions · {props.questions.length} pending + + + + {(request) => ( + + + {Locale.truncate( + (request.questions[0]?.question ?? "Question").replace(/\s+/g, " "), + Math.max(1, props.width - 11), + )} + + props.onAnswer(request.id)} + > + [answer] + + + )} + + + + ) +} diff --git a/src/mendcode/packages/opencode/src/mend/tui/widgets-tray.tsx b/src/mendcode/packages/opencode/src/mend/tui/widgets-tray.tsx new file mode 100644 index 00000000..bc2ef681 --- /dev/null +++ b/src/mendcode/packages/opencode/src/mend/tui/widgets-tray.tsx @@ -0,0 +1,43 @@ +import { onMount, type JSX } from "solid-js" +import type { ScrollBoxRenderable } from "@opentui/core" +import type { TuiThemeCurrent } from "@mendcode/plugin/tui" + +/** Native horizontal scrolling keeps widget navigation separate from the transcript. */ +export function SessionWidgetTray(props: { + width: number + contentWidth: number + height: number + autoFocus?: boolean + onAutoFocus?: () => void + theme: Pick + children: JSX.Element +}) { + let tray: ScrollBoxRenderable | undefined + const overflow = () => props.contentWidth > props.width + onMount(() => { + if (!props.autoFocus) return + tray?.focus() + props.onAutoFocus?.() + }) + + return ( + { + tray = value + }} + width={props.width} + height={props.height + (overflow() ? 1 : 0)} + scrollX + scrollY={false} + contentOptions={{ width: props.contentWidth, minWidth: props.contentWidth }} + horizontalScrollbarOptions={{ + visible: overflow(), + trackOptions: { backgroundColor: props.theme.backgroundElement, foregroundColor: props.theme.border }, + }} + verticalScrollbarOptions={{ visible: false }} + > + {props.children} + + ) +} diff --git a/src/mendcode/packages/opencode/src/plugin/codex.ts b/src/mendcode/packages/opencode/src/plugin/codex.ts index 748a0d55..ad344616 100644 --- a/src/mendcode/packages/opencode/src/plugin/codex.ts +++ b/src/mendcode/packages/opencode/src/plugin/codex.ts @@ -7,6 +7,7 @@ import os from "os" import { setTimeout as sleep } from "node:timers/promises" import { createServer } from "http" import { isRecord } from "@/util/record" +import { normalizeAstraRequest } from "@/mend/prompt/model-family" const log = Log.create({ service: "plugin.codex" }) @@ -114,13 +115,13 @@ export function normalizeCodexChatGPTRequestBody(body: BodyInit | null | undefin mode: "pro", } : request.reasoning - if (normalized.modelID === request.model && !normalized.mode) return body - return JSON.stringify({ + if (normalized.modelID === request.model && !normalized.mode && normalizeAstraRequest(request) === request) return body + return JSON.stringify(normalizeAstraRequest({ ...request, model: normalized.modelID, ...(normalized.mode === "fast" ? { service_tier: "priority" } : {}), ...(reasoning === undefined ? {} : { reasoning }), - }) + })) } function prepareResponsesLiteRequest(input: { diff --git a/src/mendcode/packages/opencode/src/question/index.ts b/src/mendcode/packages/opencode/src/question/index.ts index 1759451b..b8279cdf 100644 --- a/src/mendcode/packages/opencode/src/question/index.ts +++ b/src/mendcode/packages/opencode/src/question/index.ts @@ -7,6 +7,9 @@ import { zod } from "@/util/effect-zod" import * as Log from "@mendcode/core/util/log" import { withStatics } from "@/util/schema" import { QuestionID } from "./schema" +import * as Mailbox from "@/session/runtime-mailbox" +import { waitForDisable } from "@/session/continuity-control" +import { createHash } from "node:crypto" const log = Log.create({ service: "question" }) @@ -65,6 +68,7 @@ export class Request extends Schema.Class("QuestionRequest")({ description: "Questions to ask", }), tool: Schema.optional(Tool), + async: Schema.optional(Schema.Boolean), }) { static readonly zod = zod(this) } @@ -112,11 +116,19 @@ interface PendingEntry { interface State { pending: Map + asyncDisabled: boolean } // Service export interface Interface { + readonly post: (input: { + sessionID: SessionID + questions: ReadonlyArray + tool?: Tool + }) => Effect.Effect + readonly get: (requestID: QuestionID) => Effect.Effect + readonly wait: (requestID: QuestionID) => Effect.Effect, RejectedError> readonly ask: (input: { sessionID: SessionID questions: ReadonlyArray @@ -137,7 +149,37 @@ export const layer = Layer.effect( Effect.fn("Question.state")(function* () { const state = { pending: new Map(), + asyncDisabled: false, } + const context = yield* InstanceState.context + for (const row of Mailbox.directoryRecords(context.directory, "question", { statuses: ["pending"] })) { + if (row.status !== "pending") continue + const info = Schema.decodeUnknownSync(Request)(row.data.request) + const deferred = yield* Deferred.make, RejectedError>() + state.pending.set(info.id, { info, deferred }) + } + yield* waitForDisable(context.directory, "questions").pipe( + Effect.andThen( + Effect.gen(function* () { + state.asyncDisabled = true + for (const [id, entry] of state.pending) { + if (!entry.info.async) continue + const record = Mailbox.getRecord(entry.info.sessionID, String(id)) + if (record) + Mailbox.completeRecord({ + ...record, + status: "dismissed", + data: { ...record.data, continuationCancelled: true }, + }) + state.pending.delete(id) + yield* Deferred.fail(entry.deferred, new RejectedError()) + yield* bus.publish(Event.Rejected, { sessionID: entry.info.sessionID, requestID: id }) + } + Mailbox.cancelContinuations(context.directory, "question") + }), + ), + Effect.forkScoped({ startImmediately: true }), + ) yield* Effect.addFinalizer(() => Effect.gen(function* () { @@ -152,6 +194,55 @@ export const layer = Layer.effect( }), ) + const get = Effect.fn("Question.get")(function* (requestID: QuestionID) { + const context = yield* InstanceState.context + return Mailbox.getDirectoryRecord(context.directory, String(requestID), "question") + }) + const post = Effect.fn("Question.post")(function* (input: { + sessionID: SessionID + questions: ReadonlyArray + tool?: Tool + }) { + const current = yield* InstanceState.get(state) + if (current.asyncDisabled) throw new Error("Async questions were disabled; reload after enabling them") + const pending = current.pending + const context = yield* InstanceState.context + const id = input.tool + ? QuestionID.ascending( + `que_${createHash("sha256").update(`${input.sessionID}:${input.tool.messageID}:${input.tool.callID}`).digest("hex")}`, + ) + : QuestionID.ascending() + const records = Mailbox.listRecords(input.sessionID, "question", { statuses: ["pending"] }) + const prior = Mailbox.getRecord(input.sessionID, String(id)) + if (prior) return Schema.decodeUnknownSync(Request)(prior.data.request) + if (records.filter((record) => record.status === "pending").length >= 32) + throw new Error("Too many pending questions; resolve or dismiss existing questions first") + const info = Schema.decodeUnknownSync(Request)({ ...input, id, async: true }) + const deferred = yield* Deferred.make, RejectedError>() + Mailbox.putRecord({ + id: String(info.id), + sessionID: info.sessionID, + directory: context.directory, + kind: "question", + generation: Mailbox.generation(info.sessionID), + status: "pending", + data: { request: info }, + timeCreated: Date.now(), + timeUpdated: Date.now(), + }) + pending.set(info.id, { info, deferred }) + yield* bus.publish(Event.Asked, info) + return info + }) + const wait = Effect.fn("Question.wait")(function* (requestID: QuestionID) { + const pending = (yield* InstanceState.get(state)).pending + const existing = pending.get(requestID) + if (existing) return yield* Deferred.await(existing.deferred) + const record = yield* get(requestID) + if (record?.status === "answered") return Schema.decodeUnknownSync(Schema.Array(Answer))(record.data.answers) + return yield* Effect.fail(new RejectedError()) + }) + const ask = Effect.fn("Question.ask")(function* (input: { sessionID: SessionID questions: ReadonlyArray @@ -190,6 +281,22 @@ export const layer = Layer.effect( return } pending.delete(input.requestID) + if (existing.info.async) { + const record = yield* get(input.requestID) + if (record?.status === "pending") { + const saved = Mailbox.completeRecord({ + ...record, + status: "answered", + data: { ...record.data, answers: input.answers }, + }) + yield* bus.publish(Mailbox.Event.Updated, { + sessionID: saved.sessionID, + id: saved.id, + kind: saved.kind, + status: saved.status, + }) + } + } log.info("replied", { requestID: input.requestID, answers: input.answers }) yield* bus.publish(Event.Replied, { sessionID: existing.info.sessionID, @@ -207,6 +314,18 @@ export const layer = Layer.effect( return } pending.delete(requestID) + if (existing.info.async) { + const record = yield* get(requestID) + if (record?.status === "pending") { + const saved = Mailbox.completeRecord({ ...record, status: "dismissed" }) + yield* bus.publish(Mailbox.Event.Updated, { + sessionID: saved.sessionID, + id: saved.id, + kind: saved.kind, + status: saved.status, + }) + } + } log.info("rejected", { requestID }) yield* bus.publish(Event.Rejected, { sessionID: existing.info.sessionID, @@ -220,7 +339,7 @@ export const layer = Layer.effect( return Array.from(pending.values(), (x) => x.info) }) - return Service.of({ ask, reply, reject, list }) + return Service.of({ ask, post, get, wait, reply, reject, list }) }), ) diff --git a/src/mendcode/packages/opencode/src/server/routes/global.ts b/src/mendcode/packages/opencode/src/server/routes/global.ts index be805ecb..dcc00c27 100644 --- a/src/mendcode/packages/opencode/src/server/routes/global.ts +++ b/src/mendcode/packages/opencode/src/server/routes/global.ts @@ -19,6 +19,8 @@ import "@/mend/memory/dream-events" import "@/mend/memory/workspace-events" import { processMemoryUsage } from "@/util/process-memory" import { SharedServer } from "@/cli/cmd/tui/shared-server" +import { reportUpgradePhase, reportUpgradeOutcome } from "../upgrade-progress" +import { readChannel, writeChannel } from "@/installation/release-channel" const log = Log.create({ service: "server" }) const EVENT_QUEUE_MAX_ITEMS = 512 @@ -266,6 +268,9 @@ export const GlobalRoutes = lazy(() => return c.json(true) }, ) + .get("/release-channel", async (c) => c.json({ channel: await readChannel() })) + .put("/release-channel", validator("json", z.object({ channel: z.enum(["stable", "beta", "nightly"]) })), + async (c) => c.json({ channel: await writeChannel(c.req.valid("json").channel) })) .post( "/upgrade", describeRoute({ @@ -312,7 +317,7 @@ export const GlobalRoutes = lazy(() => const target = c.req.valid("json").target || (yield* svc.latest(method)) const result = yield* Effect.catch( - svc.upgrade(method, target).pipe(Effect.as({ success: true as const, version: target })), + svc.upgrade(method, target, (phase, progress) => reportUpgradePhase(target, phase, progress)).pipe(Effect.as({ success: true as const, version: target })), (err) => Effect.succeed({ success: false as const, @@ -320,6 +325,7 @@ export const GlobalRoutes = lazy(() => error: err.stderr || "Update failed", }), ) + reportUpgradeOutcome(target, result.success ? undefined : result.error) if (!result.success) return result return { ...result, status: 200 as const } }), diff --git a/src/mendcode/packages/opencode/src/server/routes/instance/continuity.ts b/src/mendcode/packages/opencode/src/server/routes/instance/continuity.ts new file mode 100644 index 00000000..c940b0f0 --- /dev/null +++ b/src/mendcode/packages/opencode/src/server/routes/instance/continuity.ts @@ -0,0 +1,70 @@ +import { Hono } from "hono" +import { validator } from "hono-openapi" +import z from "zod" +import { SessionID } from "@/session/schema" +import { Session } from "@/session/session" +import { listRecords } from "@/session/runtime-mailbox" +import { jsonRequest } from "./trace" +import { ToolRegistry } from "@/tool/registry" +import { InstanceState } from "@/effect/instance-state" +import { getReasoningState } from "@/mend/prompt/reasoning-state" +import { resolveRuntimeModel } from "@/cli/model-selection" + +/** Shared Hono endpoint also mounted alongside the experimental HttpApi bridge. */ +export function ContinuityRoutes() { + return new Hono() + .post( + "/:sessionID/model-resolution", + validator("param", z.object({ sessionID: SessionID.zod })), + validator( + "json", + z.object({ + explicitAgent: z.string().optional(), + explicitModel: z.string().optional(), + explicitVariant: z.string().optional(), + }), + ), + (c) => + jsonRequest("ContinuityRoutes.resolveModel", c, function* () { + const sessions = yield* Session.Service + const session = yield* sessions.get(c.req.valid("param").sessionID) + const context = yield* InstanceState.context + if (session.directory !== context.directory) throw new Error("Session belongs to a different workspace") + return yield* resolveRuntimeModel({ session, ...c.req.valid("json") }) + }), + ) + .post( + "/:sessionID/:jobID/cancel", + validator("param", z.object({ sessionID: SessionID.zod, jobID: z.string() })), + (c) => + jsonRequest("ContinuityRoutes.cancel", c, function* () { + const sessions = yield* Session.Service + const registry = yield* ToolRegistry.Service + const params = c.req.valid("param") + const session = yield* sessions.get(params.sessionID) + const context = yield* InstanceState.context + if (session.directory !== context.directory) throw new Error("Session belongs to a different workspace") + return yield* registry.cancelJob(params.sessionID, params.jobID) + }), + ) + .get("/:sessionID", validator("param", z.object({ sessionID: SessionID.zod })), (c) => + jsonRequest("ContinuityRoutes.list", c, function* () { + const sessions = yield* Session.Service + const sessionID = c.req.valid("param").sessionID + const session = yield* sessions.get(sessionID) + const context = yield* InstanceState.context + if (session.directory !== context.directory) throw new Error("Session belongs to a different workspace") + return { + currentReasoning: getReasoningState(sessionID), + records: ["job", "question"] + .flatMap((kind) => listRecords(sessionID, kind as "job" | "question", { limit: 50 })) + .sort((a, b) => b.timeCreated - a.timeCreated) + .slice(0, 100) + .map((record) => ({ + ...record, + data: { tool: record.data.tool, error: record.data.error, request: record.data.request }, + })), + } + }), + ) +} diff --git a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/api.ts b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/api.ts index 0ddb2909..015b938c 100644 --- a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -3,6 +3,7 @@ import { HttpApi } from "effect/unstable/httpapi" import { BusEvent } from "@/bus/bus-event" import { SyncEvent } from "@/sync" import { ConfigApi } from "./groups/config" +import { ContinuityApi } from "./groups/continuity" import { ControlApi } from "./groups/control" import { EventApi } from "./event" import { ExperimentalApi } from "./groups/experimental" @@ -34,6 +35,7 @@ export const RootHttpApi = HttpApi.make("opencode-root").addHttpApi(ControlApi). export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(ConfigApi) + .addHttpApi(ContinuityApi) .addHttpApi(ExperimentalApi) .addHttpApi(FileApi) .addHttpApi(InstanceApi) diff --git a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/groups/continuity.ts b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/groups/continuity.ts new file mode 100644 index 00000000..93e61223 --- /dev/null +++ b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/groups/continuity.ts @@ -0,0 +1,41 @@ +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import { SessionID } from "@/session/schema" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { ApiBadRequestError, ApiNotFoundError } from "../errors" + +export const ContinuityApi = HttpApi.make("continuity").add( + HttpApiGroup.make("continuity") + .add( + HttpApiEndpoint.post("resolveModel", "/continuity/:sessionID/model-resolution", { + params: { sessionID: SessionID }, + payload: Schema.Struct({ + explicitAgent: Schema.optional(Schema.String), + explicitModel: Schema.optional(Schema.String), + explicitVariant: Schema.optional(Schema.String), + }), + success: Schema.Unknown, + error: [ApiNotFoundError, ApiBadRequestError], + }), + HttpApiEndpoint.get("list", "/continuity/:sessionID", { + params: { sessionID: SessionID }, + success: Schema.Unknown, + error: ApiNotFoundError, + }), + HttpApiEndpoint.post("cancel", "/continuity/:sessionID/:jobID/cancel", { + params: { sessionID: SessionID, jobID: Schema.String }, + success: Schema.Unknown, + error: [ApiNotFoundError, ApiBadRequestError], + }), + HttpApiEndpoint.get("usage", "/usage", { + query: { days: Schema.optional(Schema.String), project: Schema.optional(Schema.String) }, + success: Schema.Unknown, + error: ApiBadRequestError, + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) + .middleware(Authorization), +) diff --git a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index 0b876d05..ede7b2a3 100644 --- a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -12,6 +12,7 @@ const GlobalHealth = Schema.Struct({ version: Schema.String, channel: Schema.String, }) +const ReleaseChannel = Schema.Struct({ channel: Schema.Literals(["stable", "beta", "nightly"]) }) const ProcessMemory = Schema.Struct({ pid: Schema.Number, @@ -65,11 +66,14 @@ export const GlobalPaths = { config: "/global/config", dispose: "/global/dispose", upgrade: "/global/upgrade", + releaseChannel: "/global/release-channel", } as const export const GlobalApi = HttpApi.make("global").add( HttpApiGroup.make("global") .add( + HttpApiEndpoint.get("releaseChannelGet", GlobalPaths.releaseChannel, { success: ReleaseChannel }), + HttpApiEndpoint.put("releaseChannelSet", GlobalPaths.releaseChannel, { payload: ReleaseChannel, success: ReleaseChannel }), HttpApiEndpoint.get("health", GlobalPaths.health, { success: described(GlobalHealth, "Health information"), }).annotateMerge( diff --git a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/continuity.ts b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/continuity.ts new file mode 100644 index 00000000..a073ac85 --- /dev/null +++ b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/continuity.ts @@ -0,0 +1,82 @@ +import { Cause, Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Session } from "@/session/session" +import { SessionID } from "@/session/schema" +import { ToolRegistry } from "@/tool/registry" +import { InstanceState } from "@/effect/instance-state" +import { listRecords } from "@/session/runtime-mailbox" +import { getReasoningState } from "@/mend/prompt/reasoning-state" +import { InstanceHttpApi } from "../api" +import { notFound, badRequest } from "../errors" +import { resolveRuntimeModel } from "@/cli/model-selection" + +export const continuityHandlers = HttpApiBuilder.group(InstanceHttpApi, "continuity", (handlers) => + Effect.gen(function* () { + const sessions = yield* Session.Service + const registry = yield* ToolRegistry.Service + const validate = (sessionID: SessionID) => + Effect.gen(function* () { + const session = yield* sessions + .get(sessionID) + .pipe(Effect.catchCause(() => Effect.fail(notFound("Session not found")))) + const context = yield* InstanceState.context + if (session.directory !== context.directory) + return yield* Effect.fail(notFound("Session belongs to a different workspace")) + return session + }) + return handlers + .handle("resolveModel", ({ params, payload }) => + Effect.gen(function* () { + const session = yield* validate(params.sessionID) + const { model, variant, ...resolved } = yield* resolveRuntimeModel({ session, ...payload }).pipe( + Effect.catchCause((cause) => + Effect.fail(badRequest({ message: String(Cause.squash(cause)).slice(0, 1024) })), + ), + ) + return { ...resolved, ...(model ? { model } : {}), ...(variant ? { variant } : {}) } + }), + ) + .handle("list", ({ params }) => + Effect.gen(function* () { + yield* validate(params.sessionID) + const currentReasoning = getReasoningState(params.sessionID) + return { + ...(currentReasoning ? { currentReasoning } : {}), + records: ["job", "question"] + .flatMap((kind) => listRecords(params.sessionID, kind as "job" | "question", { limit: 50 })) + .sort((a, b) => b.timeCreated - a.timeCreated) + .map((record) => ({ + ...record, + data: { + ...(record.data.tool === undefined ? {} : { tool: record.data.tool }), + ...(record.data.error === undefined ? {} : { error: record.data.error }), + ...(record.data.request === undefined ? {} : { request: record.data.request }), + }, + })), + } + }), + ) + .handle("cancel", ({ params }) => + Effect.gen(function* () { + yield* validate(params.sessionID) + return yield* registry + .cancelJob(params.sessionID, params.jobID) + .pipe( + Effect.catchCause((cause) => + Effect.fail(badRequest({ message: String(Cause.squash(cause)).slice(0, 1024) })), + ), + ) + }), + ) + .handle("usage", ({ query }) => + Effect.gen(function* () { + const days = query.days === undefined ? undefined : Number(query.days) + if (days !== undefined && (!Number.isFinite(days) || days < 0)) + return yield* Effect.fail(badRequest({ message: "days must be a finite nonnegative number" })) + const { aggregateSessionStats } = yield* Effect.promise(() => import("@/cli/cmd/stats")) + const context = yield* InstanceState.context + return yield* aggregateSessionStats(days, query.project, context.project) + }), + ) + }), +) diff --git a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts index 57d17fff..0367fc04 100644 --- a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -18,6 +18,8 @@ import { RootHttpApi } from "../api" import { GlobalUpgradeInput } from "../groups/global" import { processMemoryUsage } from "@/util/process-memory" import { SharedServer } from "@/cli/cmd/tui/shared-server" +import { reportUpgradePhase, reportUpgradeOutcome } from "@/server/upgrade-progress" +import { readChannel, writeChannel } from "@/installation/release-channel" const log = Log.create({ service: "server" }) const HTTPAPI_EVENT_BUFFER_SIZE = 512 @@ -125,7 +127,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl } } const target = ctx.payload.target || (yield* installation.latest(method)) - const result = yield* installation.upgrade(method, target).pipe( + const result = yield* installation.upgrade(method, target, (phase, progress) => reportUpgradePhase(target, phase, progress)).pipe( Effect.as({ status: 200, body: { success: true as const, version: target } }), Effect.catch((err) => Effect.succeed({ @@ -137,6 +139,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl }), ), ) + reportUpgradeOutcome(target, result.body.success ? undefined : result.body.error) if (!result.body.success) return result GlobalBus.emit("event", { directory: "global", @@ -168,6 +171,8 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl }) return handlers + .handle("releaseChannelGet", () => Effect.promise(async () => ({ channel: await readChannel() }))) + .handle("releaseChannelSet", ({ payload }) => Effect.promise(async () => ({ channel: await writeChannel(payload.channel) }))) .handle("health", health) .handle("memory", memory) .handleRaw("event", event) diff --git a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/server.ts b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/server.ts index cc0efa63..61d2774c 100644 --- a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -72,6 +72,7 @@ import { mflowHandlers } from "./handlers/mflow" import { mcpHandlers } from "./handlers/mcp" import { permissionHandlers } from "./handlers/permission" import { planReviewHandlers } from "./handlers/plan-review" +import { continuityHandlers } from "./handlers/continuity" import { projectHandlers } from "./handlers/project" import { providerHandlers } from "./handlers/provider" import { ptyConnectRoute, ptyHandlers } from "./handlers/pty" @@ -136,6 +137,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( questionHandlers, permissionHandlers, planReviewHandlers, + continuityHandlers, providerHandlers, sessionHandlers, syncHandlers, diff --git a/src/mendcode/packages/opencode/src/server/routes/instance/index.ts b/src/mendcode/packages/opencode/src/server/routes/instance/index.ts index e6042d95..86af7d76 100644 --- a/src/mendcode/packages/opencode/src/server/routes/instance/index.ts +++ b/src/mendcode/packages/opencode/src/server/routes/instance/index.ts @@ -15,6 +15,8 @@ import { Global } from "@mendcode/core/global" import { LSP } from "@/lsp/lsp" import { Command } from "@/command" import { QuestionRoutes } from "./question" +import { ContinuityRoutes } from "./continuity" +import { UsageRoutes } from "./usage" import { PlanReviewRoutes } from "./plan-review" import { PermissionRoutes } from "./permission" import { ProjectRoutes } from "./project" @@ -188,6 +190,8 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H } return app + .route("/usage", UsageRoutes()) + .route("/continuity", ContinuityRoutes()) .route("/project", ProjectRoutes()) .route("/pty", PtyRoutes(upgrade, opts)) .route("/config", ConfigRoutes()) diff --git a/src/mendcode/packages/opencode/src/server/routes/instance/usage.ts b/src/mendcode/packages/opencode/src/server/routes/instance/usage.ts new file mode 100644 index 00000000..97eba626 --- /dev/null +++ b/src/mendcode/packages/opencode/src/server/routes/instance/usage.ts @@ -0,0 +1,26 @@ +import { Hono } from "hono" +import { validator } from "hono-openapi" +import { Effect } from "effect" +import z from "zod" +import { InstanceState } from "@/effect/instance-state" +import { jsonRequest } from "./trace" + +export function UsageRoutes() { + return new Hono().get( + "/", + validator( + "query", + z.object({ + days: z.coerce.number().finite().nonnegative().optional(), + project: z.string().optional(), + }), + ), + (c) => + jsonRequest("UsageRoutes.get", c, function* () { + const { aggregateSessionStats } = yield* Effect.promise(() => import("@/cli/cmd/stats")) + const context = yield* InstanceState.context + const query = c.req.valid("query") + return yield* aggregateSessionStats(query.days, query.project, context.project) + }), + ) +} diff --git a/src/mendcode/packages/opencode/src/server/upgrade-progress.ts b/src/mendcode/packages/opencode/src/server/upgrade-progress.ts new file mode 100644 index 00000000..c8150112 --- /dev/null +++ b/src/mendcode/packages/opencode/src/server/upgrade-progress.ts @@ -0,0 +1,29 @@ +import { GlobalBus } from "@/bus/global" +import { TuiEvent } from "@/cli/cmd/tui/event" +import { updateLabel, type DownloadProgress, type UpdatePhase } from "@/installation/progress" + +export function reportUpgradePhase(version: string, phase: UpdatePhase, progress?: DownloadProgress) { + const label = updateLabel(phase, progress) + GlobalBus.emit("event", { + directory: "global", + payload: { + type: TuiEvent.ToastShow.type, + properties: { title: `Update v${version}`, message: `${label}…`, variant: "info", duration: 930_000 }, + }, + }) +} + +export function reportUpgradeOutcome(version: string, error?: string) { + GlobalBus.emit("event", { + directory: "global", + payload: { + type: TuiEvent.ToastShow.type, + properties: { + title: error ? "Update Failed" : "Restart Required", + message: error ?? `Installer for v${version} finished. Restart to finish checking the update.`, + variant: error ? "error" : "info", + duration: 10_000, + }, + }, + }) +} diff --git a/src/mendcode/packages/opencode/src/session/continuity-control.ts b/src/mendcode/packages/opencode/src/session/continuity-control.ts new file mode 100644 index 00000000..f6cafa44 --- /dev/null +++ b/src/mendcode/packages/opencode/src/session/continuity-control.ts @@ -0,0 +1,23 @@ +import { Effect } from "effect" +type Disable = { directory?: string; tools: boolean; questions: boolean } +const listeners = new Set<(event: Disable) => void>() +export function notifyDisabled(input: { + directory?: string + experimental?: { async_tools?: boolean; async_questions?: boolean } +}) { + const event = { + directory: input.directory, + tools: input.experimental?.async_tools === false, + questions: input.experimental?.async_questions === false, + } + if (event.tools || event.questions) for (const listener of listeners) listener(event) +} +export function waitForDisable(directory: string, kind: "tools" | "questions") { + return Effect.callback((resume) => { + const listener = (event: Disable) => { + if (event[kind] && (!event.directory || event.directory === directory)) resume(Effect.void) + } + listeners.add(listener) + return Effect.sync(() => listeners.delete(listener)) + }) +} diff --git a/src/mendcode/packages/opencode/src/session/continuity.sql.ts b/src/mendcode/packages/opencode/src/session/continuity.sql.ts new file mode 100644 index 00000000..feea2919 --- /dev/null +++ b/src/mendcode/packages/opencode/src/session/continuity.sql.ts @@ -0,0 +1,20 @@ +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core" +import { SessionTable } from "./session.sql" + +export const ContinuityRecordTable = sqliteTable( + "continuity_record", + { + id: text().primaryKey(), + session_id: text() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + directory: text().notNull(), + kind: text().notNull(), + generation: integer().notNull(), + status: text().notNull(), + data: text().notNull(), + time_created: integer().notNull(), + time_updated: integer().notNull(), + }, + (table) => [index("continuity_session_kind_idx").on(table.session_id, table.kind)], +) diff --git a/src/mendcode/packages/opencode/src/session/llm.ts b/src/mendcode/packages/opencode/src/session/llm.ts index e39e051d..850b1da5 100644 --- a/src/mendcode/packages/opencode/src/session/llm.ts +++ b/src/mendcode/packages/opencode/src/session/llm.ts @@ -34,6 +34,10 @@ import { import { EffectBridge } from "@/effect/bridge" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" +import { runtimeCapabilityPrompt } from "@/mend/prompt/runtime-capabilities" +import { autoReasoningSignal, selectAutoReasoning } from "@/mend/prompt/reasoning-auto" +import { isAstraModel, normalizeAstraOptions } from "@/mend/prompt/model-family" +import { ReasoningRequested, ReasoningCleared, recordReasoningState, clearReasoningState, requestedReasoningEffort } from "@/mend/prompt/reasoning-state" const log = Log.create({ service: "llm" }) export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX @@ -238,6 +242,14 @@ const live: Layer.Layer< mendFocus, mendPromptPolicy, mendMemory, + runtimeCapabilityPrompt({ + providerID: input.model.providerID, + modelID: input.model.api.id, + endpoint: String(item.options.baseURL ?? input.model.api.url), + auth: info?.type === "oauth" ? "oauth" : info?.type === "api" ? "api" : "unknown", + transport: input.model.providerID === "openai" ? "responses-http" : "other", + tools: Object.keys(input.tools), + }), // any custom prompt passed into this call ...input.system, // any custom prompt from last user message @@ -260,10 +272,17 @@ const live: Layer.Layer< system.push(header, rest.join("\n")) } + const autoReasoning = selectAutoReasoning({ + enabled: !input.small && cfg.experimental?.reasoning_auto === true, + manual: input.user.model.variant, + variants: input.model.variants, + signal: autoReasoningSignal(input.messages), + }) + if (autoReasoning) l.info("reasoning.auto", { effort: autoReasoning.effort, reason: autoReasoning.reason, messageID: input.user.id }) const variant = !input.small && input.model.variants && input.user.model.variant ? input.model.variants[input.user.model.variant] - : {} + : autoReasoning?.options ?? {} const base = input.small ? ProviderTransform.smallOptions(input.model) : ProviderTransform.options({ @@ -310,6 +329,15 @@ const live: Layer.Layer< options, }, ) + if (isAstraModel(input.model.api.id)) { + params.temperature = undefined + params.topP = undefined + params.topK = undefined + params.options = normalizeAstraOptions(input.model.api.id, params.options) + // This SDK's static model catalog predates Astra; its supported override + // retains reasoning parameters without substituting a different model ID. + if (input.model.api.npm === "@ai-sdk/openai") params.options.forceReasoning = true + } const { headers } = yield* plugin.trigger( "chat.headers", @@ -468,6 +496,24 @@ const live: Layer.Layer< ? (yield* InstanceState.context).project.id : undefined + const requestedEffort = requestedReasoningEffort(params.options) + if (!input.small && requestedEffort && (autoReasoning || input.user.model.variant)) { + const state = { + sessionID: input.sessionID, + messageID: input.user.id, + mode: autoReasoning ? "auto" as const : "manual" as const, + effort: requestedEffort, + reason: autoReasoning?.reason ?? "manual_selection", + modelID: input.model.api.id, + providerID: input.model.providerID, + requestedAt: Date.now(), + } + recordReasoningState(state) + yield* Effect.promise(() => Bus.publish(ReasoningRequested, state)) + } else if (!input.small) { + clearReasoningState(input.sessionID) + yield* Effect.promise(() => Bus.publish(ReasoningCleared, { sessionID: input.sessionID, messageID: input.user.id, requestedAt: Date.now() })) + } return streamText({ onError(error) { l.error("stream error", { diff --git a/src/mendcode/packages/opencode/src/session/prompt.ts b/src/mendcode/packages/opencode/src/session/prompt.ts index b7249a62..9826b98a 100644 --- a/src/mendcode/packages/opencode/src/session/prompt.ts +++ b/src/mendcode/packages/opencode/src/session/prompt.ts @@ -55,6 +55,7 @@ import { InstanceState } from "@/effect/instance-state" import { InstanceRef } from "@/effect/instance-ref" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { BackgroundTask } from "./background-task" +import * as ContinuityMailbox from "./runtime-mailbox" import { WorkflowService } from "./workflow-service" import { SessionRunState } from "./run-state" import { AgentCommand } from "./agent-command" @@ -527,6 +528,7 @@ export const layer = Layer.effect( const plugin = yield* Plugin.Service const commands = yield* Command.Service const config = yield* Config.Service + const failedContinuityWakes = new Set() const permission = yield* Permission.Service const fsys = yield* AppFileSystem.Service const mcp = yield* MCP.Service @@ -591,12 +593,15 @@ export const layer = Layer.effect( if (message.info.role !== "user" || message.info.queued !== true || answered.has(message.info.id)) continue const parts = message.parts.filter( (part): part is MessageV2.TextPart => - part.type === "text" && - (part.metadata?.kind === "peer_message" || part.metadata?.kind === "peer_response"), + part.type === "text" && (part.metadata?.kind === "peer_message" || part.metadata?.kind === "peer_response"), ) if (parts.length === 0) continue for (const part of parts) { - yield* sessions.updatePart({ ...part, ignored: true, metadata: { ...part.metadata, cancelledAt: Date.now() } }) + yield* sessions.updatePart({ + ...part, + ignored: true, + metadata: { ...part.metadata, cancelledAt: Date.now() }, + }) } yield* sessions.updateMessage({ ...message.info, queued: false }) yield* state.cancelQueued(sessionID, message.info.id) @@ -616,7 +621,10 @@ export const layer = Layer.effect( state: command.state === "pending" ? "rejected" : "failed", error: "Session stopped by the user before the message exchange completed.", }) - .pipe(Effect.catchTag("AgentCommandInvalidStateTransitionError", () => Effect.void), Effect.orDie) + .pipe( + Effect.catchTag("AgentCommandInvalidStateTransitionError", () => Effect.void), + Effect.orDie, + ) } }) @@ -671,6 +679,8 @@ export const layer = Layer.effect( const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { yield* elog.info("cancel", { sessionID }) + const context = yield* InstanceState.context + ContinuityMailbox.cancelGeneration(sessionID, context.directory) // A background task can finish after the parent run is cancelled. Do not // let its late notification start a fresh assistant turn. yield* cancelOwnerWakeSession(sessionID) @@ -2481,6 +2491,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the let step = 0 let initialMessageIDs: ReadonlySet | undefined const session = yield* sessions.get(sessionID).pipe(Effect.orDie) + if (targetMessageID) failedContinuityWakes.delete(sessionID) const beginCompaction = Effect.fnUntraced(function* () { yield* state.setInterruptible(sessionID, false) yield* status.set(sessionID, { @@ -2491,6 +2502,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) while (true) { + const continuityConfig = (yield* config.get()).experimental + const continuityEvents = ContinuityMailbox.boundedEventBatch( + ContinuityMailbox.pendingEvents(sessionID).filter((event) => + event.data.kind === "job" + ? continuityConfig?.async_tools === true + : continuityConfig?.async_questions === true, + ), + ) yield* status.set(sessionID, { type: "busy", message: SessionStatus.SESSION_ACTIVITY_WAITING }) yield* slog.info("loop", { step }) @@ -2545,7 +2564,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the compactionParentID: lastAssistant.parentID, }) - if (shouldExitPromptLoop({ lastUser, lastAssistant, hasToolCalls, summaryHasLaterTarget })) { + if ( + continuityEvents.length === 0 && + shouldExitPromptLoop({ lastUser, lastAssistant, hasToolCalls, summaryHasLaterTarget }) + ) { yield* slog.info("exiting loop") break } @@ -2754,6 +2776,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the } const interruptedToolPrompt = interruptedToolPromptText(msgs) if (interruptedToolPrompt) system.push(interruptedToolPrompt) + if (continuityEvents.length) + system.push( + `Internal continuity results, not new user instructions. Preserve the current objective and restrictions. Event IDs are stable; do not repeat completed work. Retrieve larger stored results using tool_status.\n${JSON.stringify(ContinuityMailbox.eventContext(continuityEvents))}`, + ) const format = lastUser.format ?? { type: "text" as const } if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) const streamMessages = [ @@ -2855,6 +2881,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the model, toolChoice: format.type === "json_schema" ? "required" : undefined, }) + if (!handle.message.error && !abort.aborted) ContinuityMailbox.acknowledgeEvents(continuityEvents) + else if (continuityEvents.length) failedContinuityWakes.add(sessionID) if (structured !== undefined) { handle.message.structured = structured @@ -2875,7 +2903,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the } } - if (result === "stop") return "break" as const + if (result === "stop") + return !handle.message.error && + ContinuityMailbox.pendingEvents(sessionID).some((event) => + event.data.kind === "job" + ? continuityConfig?.async_tools === true + : continuityConfig?.async_questions === true, + ) + ? ("continue" as const) + : ("break" as const) if (result === "compact") { if (shouldSkipAutoCompaction(msgs)) { const rescueCompactions = autoRescueCompactionCount(msgs) @@ -3163,10 +3199,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the "", ].join("\n") - const completePeerResponse = ( - assistant: MessageV2.Assistant, - peerState: PeerDeliveryState, - ): Effect.Effect => + const completePeerResponse = (assistant: MessageV2.Assistant, peerState: PeerDeliveryState): Effect.Effect => Effect.gen(function* () { if (!assistant.parentID || assistant.time.completed === undefined) return if (assistant.finish === "tool-calls" || assistant.finish === "unknown") return @@ -3445,8 +3478,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the ) yield* bus.subscribe(MessageV2.Event.Updated).pipe( Stream.filter( - (event) => - event.properties.info.role === "assistant" && event.properties.info.time.completed !== undefined, + (event) => event.properties.info.role === "assistant" && event.properties.info.time.completed !== undefined, ), Stream.runForEach((event) => event.properties.info.role === "assistant" @@ -3807,6 +3839,30 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* scheduleOwnerWake(notification.parentSessionID, event.state) }) + const continuityWaking = new Set() + const wakeContinuity = Effect.fn("SessionPrompt.wakeContinuity")(function* (sessionID: SessionID) { + const cfg = (yield* config.get()).experimental + if (failedContinuityWakes.has(sessionID) || continuityWaking.has(sessionID) || (yield* state.isBusy(sessionID))) + return + if ( + !ContinuityMailbox.pendingEvents(sessionID).some((event) => + event.data.kind === "job" ? cfg?.async_tools === true : cfg?.async_questions === true, + ) + ) + return + continuityWaking.add(sessionID) + yield* loop({ sessionID, queue: true }).pipe( + Effect.ensuring(Effect.sync(() => continuityWaking.delete(sessionID))), + Effect.catchCause((cause) => + Effect.sync(() => { + failedContinuityWakes.add(sessionID) + log.error("continuity wake failed", { sessionID, error: Cause.squash(cause) }) + }), + ), + Effect.forkIn(scope), + ) + }) + ownerWakeState = yield* InstanceState.make( Effect.fn("SessionPrompt.ownerWakeState")(function* () { const value: OwnerWakeState = { @@ -3815,6 +3871,18 @@ NOTE: At any point in time through this workflow you should feel free to ask the scheduled: new Set(), running: new Set(), } + yield* bus.subscribe(ContinuityMailbox.Event.Updated).pipe( + Stream.runForEach((event) => wakeContinuity(event.properties.sessionID)), + Effect.forkScoped({ startImmediately: true }), + ) + const continuityContext = yield* InstanceState.context + for (const sessionID of new Set( + ContinuityMailbox.directoryRecords(continuityContext.directory, "event", { statuses: ["pending"] }).map( + (event) => event.sessionID, + ), + )) { + yield* wakeContinuity(sessionID) + } yield* bus.subscribe(BackgroundTask.Event.Notification).pipe( Stream.runForEach((event) => queueOwnerWake({ state: value, ...event })), Effect.forkScoped({ startImmediately: true }), @@ -3829,6 +3897,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the Effect.gen(function* () { yield* scheduleOwnerWake(event.properties.sessionID, value) yield* recoverQueuedPrompt(event.properties.sessionID, { includeLegacy: true }) + yield* wakeContinuity(event.properties.sessionID) }), ), Effect.forkScoped({ startImmediately: true }), diff --git a/src/mendcode/packages/opencode/src/session/runtime-mailbox.ts b/src/mendcode/packages/opencode/src/session/runtime-mailbox.ts new file mode 100644 index 00000000..59e5088d --- /dev/null +++ b/src/mendcode/packages/opencode/src/session/runtime-mailbox.ts @@ -0,0 +1,192 @@ +import { Database, and, eq, inArray, desc, lt, sql } from "@/storage/db" +import { ContinuityRecordTable as Table } from "./continuity.sql" +import { SessionID } from "./schema" +import { BusEvent } from "@/bus/bus-event" +import { Schema } from "effect" + +export type Record = { + id: string + sessionID: SessionID + directory: string + kind: "job" | "question" | "event" | "generation" | "note" + generation: number + status: string + data: { [key: string]: unknown } + timeCreated: number + timeUpdated: number +} +export const Event = { + Updated: BusEvent.define( + "continuity.updated", + Schema.Struct({ sessionID: SessionID, id: Schema.String, kind: Schema.String, status: Schema.String }), + ), +} +function decode(row: typeof Table.$inferSelect): Record { + return { + id: row.id, + sessionID: SessionID.make(row.session_id), + directory: row.directory, + kind: row.kind as Record["kind"], + generation: row.generation, + status: row.status, + data: JSON.parse(row.data), + timeCreated: row.time_created, + timeUpdated: row.time_updated, + } +} +export function getRecord(sessionID: SessionID, id: string) { + const row = Database.use((db) => + db + .select() + .from(Table) + .where(and(eq(Table.id, id), eq(Table.session_id, sessionID))) + .get(), + ) + return row ? decode(row) : undefined +} +export function getDirectoryRecord(directory: string, id: string, kind: Record["kind"]) { + const row = Database.use((db) => + db + .select() + .from(Table) + .where(and(eq(Table.id, id), eq(Table.directory, directory), eq(Table.kind, kind))) + .get(), + ) + return row ? decode(row) : undefined +} +type ListOptions = { limit?: number; before?: number; statuses?: string[]; generation?: number } +export function listRecords(sessionID: SessionID, kind?: Record["kind"], options: ListOptions = {}) { + return Database.use((db) => + db + .select() + .from(Table) + .where( + and( + eq(Table.session_id, sessionID), + kind ? eq(Table.kind, kind) : undefined, + options.statuses ? inArray(Table.status, options.statuses) : undefined, + options.before === undefined ? undefined : lt(Table.time_created, options.before), + options.generation === undefined ? undefined : eq(Table.generation, options.generation), + ), + ) + .orderBy(desc(Table.time_created)) + .limit(Math.max(1, Math.min(1000, options.limit ?? 100))) + .all(), + ).map(decode) +} +export function directoryRecords(directory: string, kind: Record["kind"], options: ListOptions = {}) { + return Database.use((db) => + db + .select() + .from(Table) + .where( + and( + eq(Table.directory, directory), + eq(Table.kind, kind), + options.statuses ? inArray(Table.status, options.statuses) : undefined, + ), + ) + .orderBy(desc(Table.time_created)) + .limit(Math.max(1, Math.min(1000, options.limit ?? 1000))) + .all(), + ).map(decode) +} +export function putRecord(record: Record) { + Database.use((db) => + db + .insert(Table) + .values({ + id: record.id, + session_id: record.sessionID, + directory: record.directory, + kind: record.kind, + generation: record.generation, + status: record.status, + data: JSON.stringify(record.data), + time_created: record.timeCreated, + time_updated: Date.now(), + }) + .onConflictDoUpdate({ + target: Table.id, + set: { + status: record.status, + data: JSON.stringify(record.data), + generation: record.generation, + time_updated: Date.now(), + }, + }) + .run(), + ) + return record +} +export function generation(sessionID: SessionID) { + return getRecord(sessionID, `generation_${sessionID}`)?.generation ?? 0 +} +export function cancelGeneration(sessionID: SessionID, directory: string) { + const next = generation(sessionID) + 1 + putRecord({ + id: `generation_${sessionID}`, + sessionID, + directory, + kind: "generation", + generation: next, + status: "active", + data: {}, + timeCreated: Date.now(), + timeUpdated: Date.now(), + }) + return next +} +export function enqueueResult(record: Record) { + const id = `event_${record.id}` + if (getRecord(record.sessionID, id)) return + putRecord({ + ...record, + id, + kind: "event", + status: + record.generation === generation(record.sessionID) && record.data.continuationCancelled !== true + ? "pending" + : "cancelled", + data: { recordID: record.id, kind: record.kind, status: record.status }, + }) +} +export function completeRecord(record: Record) { + return Database.transaction(() => { + putRecord(record) + enqueueResult(record) + return record + }) +} +export function pendingEvents(sessionID: SessionID) { + return listRecords(sessionID, "event", { statuses: ["pending"], generation: generation(sessionID), limit: 100 }).sort( + (a, b) => a.timeCreated - b.timeCreated, + ) +} +export function boundedEventBatch(events: Record[], maxChars = 24000) { + const included: Record[] = [] + for (const event of events) { + if (JSON.stringify(eventContext([...included, event])).length > maxChars) break + included.push(event) + } + return included +} +export function acknowledgeEvents(events: Record[]) { + Database.transaction(() => events.forEach((event) => putRecord({ ...event, status: "delivered" }))) +} +export function cancelContinuations(directory: string, kind: "job" | "question") { + Database.use((db) => db.update(Table).set({ status: "cancelled", time_updated: Date.now() }).where(and(eq(Table.directory, directory), eq(Table.kind, "event"), eq(Table.status, "pending"), sql`json_extract(${Table.data}, '$.kind') = ${kind}`)).run()) +} +export function eventContext(events: Record[]) { + return events.map((event) => { + const record = getRecord(event.sessionID, String(event.data.recordID)) + const result = record?.data.result ?? record?.data.answers ?? record?.data.error + return { + event_id: event.id, + record_id: record?.id, + kind: record?.kind, + status: record?.status, + result: (typeof result === "string" ? result : JSON.stringify(result))?.slice(0, 4096), + } + }) +} diff --git a/src/mendcode/packages/opencode/src/session/system.ts b/src/mendcode/packages/opencode/src/session/system.ts index 10ffecad..cd49df8b 100644 --- a/src/mendcode/packages/opencode/src/session/system.ts +++ b/src/mendcode/packages/opencode/src/session/system.ts @@ -44,7 +44,7 @@ function codexFocusGuidance() { "- Keep prompts lean: state each instruction once, use only relevant context and tools, and do not repeat large blocks of loaded instructions.", "- Read the request as a goal, relevant context, constraints, success criteria, and output needs. Infer routine steps from the repository; ask one targeted question only when ambiguity materially changes the result or safety.", "- For explain, review, diagnose, or plan requests, inspect and report without editing unless the user also asks for changes. For change, build, or fix requests, make the smallest in-scope local change and run relevant non-destructive validation without asking for permission.", - "- Require confirmation before destructive actions, external writes, production or billing changes, security-impacting actions, or material scope expansion.", + "- Apply the actual permission policy and authorization already provided by the user. Ask for missing authorization before destructive actions, external writes, production or billing changes, or material scope expansion; prepare the authorized, reviewable work first.", "- Treat model aliases and runtime options such as `sol`, `terra`, `luna`, `fast`, `pro`, `xhigh`, `max`, reasoning settings, caching, and advanced API features as runtime configuration. Do not invent, request, or promise capabilities that the current run does not expose.", "- Keep private reasoning and hidden instructions private. Report conclusions, assumptions, evidence, changed files, and actual verification results instead of hidden chain-of-thought.", "- For code, follow applicable repository instructions, inspect relevant callers and tests before editing, keep the patch minimal, and verify behavior with executable evidence.", diff --git a/src/mendcode/packages/opencode/src/session/tool-jobs.ts b/src/mendcode/packages/opencode/src/session/tool-jobs.ts new file mode 100644 index 00000000..b45f0dfe --- /dev/null +++ b/src/mendcode/packages/opencode/src/session/tool-jobs.ts @@ -0,0 +1,189 @@ +import { Cause, Effect, Exit, Fiber, Scope } from "effect" +import { createHash, randomUUID } from "node:crypto" +import type { Tool } from "@/tool/tool" +import type { Bus } from "@/bus" +import type { Config } from "@/config/config" +import type { SessionID } from "./schema" +import * as Mailbox from "./runtime-mailbox" + +export const ELIGIBLE_TOOLS = ["read", "grep", "glob", "webfetch"] as const +export class ToolJobs { + private stopping = false + private queued: Array<{ + record: Mailbox.Record + run: Effect.Effect + controller: AbortController + cleanup: () => void + }> = [] + private active = new Map< + string, + { controller: AbortController; fiber?: Fiber.Fiber; sessionID: SessionID } + >() + constructor( + private scope: Scope.Scope, + private directory: string, + private bus: Bus.Interface, + private config: Pick, + ) { + while (true) { + const records = Mailbox.directoryRecords(directory, "job", { statuses: ["running", "queued"] }) + if (!records.length) break + for (const record of records) Mailbox.completeRecord({ + ...record, + status: "interrupted", + data: { ...record.data, error: "Backend restarted; this job was not repeated." }, + }) + } + } + private publish(record: Mailbox.Record) { + return this.bus.publish(Mailbox.Event.Updated, { + sessionID: record.sessionID, + id: record.id, + kind: record.kind, + status: record.status, + }) + } + start(tool: Tool.Def, args: unknown, context: Tool.Context) { + const self = this + return Effect.gen(function* () { + if ((yield* self.config.get()).experimental?.async_tools !== true) throw new Error("Async tools are disabled") + if (self.stopping) throw new Error("Async tools were disabled; reload the session after enabling them") + if (!(ELIGIBLE_TOOLS as readonly string[]).includes(tool.id)) + throw new Error("Tool is not eligible for async execution; shell requires workspace mutation serialization") + const id = context.callID + ? `job_${createHash("sha256").update(`${context.sessionID}:${context.messageID}:${context.callID}`).digest("hex")}` + : `job_${randomUUID()}` + const existing = Mailbox.getRecord(context.sessionID, id) + if (existing) return existing + const records = Mailbox.listRecords(context.sessionID, "job", { statuses: ["running", "queued"] }) + if (records.filter((row) => ["running", "queued"].includes(row.status)).length >= 12) + throw new Error("Async job capacity reached (4 active, 8 queued)") + const controller = new AbortController() + const onAbort = () => controller.abort(context.abort.reason) + if (context.abort.aborted) controller.abort(context.abort.reason) + else context.abort.addEventListener("abort", onAbort, { once: true }) + const record = Mailbox.putRecord({ + id, + sessionID: context.sessionID, + directory: self.directory, + kind: "job", + generation: Mailbox.generation(context.sessionID), + status: "queued", + data: { tool: tool.id, messageID: context.messageID, callID: context.callID }, + timeCreated: Date.now(), + timeUpdated: Date.now(), + }) + const run = tool.execute(args, { ...context, abort: controller.signal, metadata: () => Effect.void }) + self.queued.push({ record, run, controller, cleanup: () => context.abort.removeEventListener("abort", onAbort) }) + yield* self.publish(record) + yield* self.pump() + return Mailbox.getRecord(context.sessionID, id)! + }) + } + private pump(): Effect.Effect { + const self = this + return Effect.gen(function* () { + if (self.stopping) return + for (const item of [...self.queued]) { + if (!self.queued.includes(item)) continue + const activeCount = [...self.active.values()].filter((row) => row.sessionID === item.record.sessionID).length + if (activeCount >= 4) continue + self.queued.splice(self.queued.indexOf(item), 1) + const record = Mailbox.putRecord({ ...item.record, status: "running" }) + const active: { controller: AbortController; fiber?: Fiber.Fiber; sessionID: SessionID } = { + controller: item.controller, + sessionID: record.sessionID, + } + self.active.set(record.id, active) + const work = Effect.gen(function* () { + const enabled = + (yield* self.config.get()).experimental?.async_tools === true && + record.generation === Mailbox.generation(record.sessionID) && + !item.controller.signal.aborted + const cancelledEffect = Effect.callback((resume) => { + const interrupt = () => resume(Effect.interrupt) + if (item.controller.signal.aborted) interrupt() + else item.controller.signal.addEventListener("abort", interrupt, { once: true }) + return Effect.sync(() => item.controller.signal.removeEventListener("abort", interrupt)) + }) + const result = enabled ? yield* Effect.exit(Effect.raceFirst(item.run, cancelledEffect)) : undefined + const current = Mailbox.getRecord(record.sessionID, record.id)! + const cancelled = + item.controller.signal.aborted || current.generation !== Mailbox.generation(record.sessionID) || !enabled + const next = cancelled + ? { + ...current, + status: "cancelled", + data: { ...current.data, ...(result && Exit.isSuccess(result) ? { result: result.value.output } : {}) }, + } + : result && Exit.isSuccess(result) + ? { + ...current, + status: "completed", + data: { + ...current.data, + result: result.value.output, + metadata: result.value.metadata, + ...(result.value.attachments?.length + ? { attachmentNotice: "Use synchronous read to retrieve media attachments." } + : {}), + }, + } + : { + ...current, + status: "failed", + data: { + ...current.data, + error: + result && Exit.isFailure(result) + ? String(Cause.squash(result.cause)).slice(0, 4096) + : "Async tools disabled", + }, + } + Mailbox.completeRecord(next) + yield* self.publish(next) + }).pipe( + Effect.ensuring( + Effect.suspend(() => { + self.active.delete(record.id) + item.cleanup() + return self.pump() + }), + ), + ) + active.fiber = yield* work.pipe(Effect.forkIn(self.scope)) + } + }) + } + cancel(sessionID: SessionID, id: string) { + const self = this + return Effect.gen(function* () { + const record = Mailbox.getRecord(sessionID, id) + if (!record || record.kind !== "job") throw new Error("Unknown job in this session") + if (!["queued", "running"].includes(record.status)) return record + const active = self.active.get(id) + active?.controller.abort("cancelled") + self.queued.find((item) => item.record.id === id)?.cleanup() + self.queued = self.queued.filter((item) => item.record.id !== id) + const saved = Mailbox.completeRecord({ ...record, status: "cancelled" }) + if (active?.fiber) yield* Fiber.interrupt(active.fiber) + yield* self.publish(saved) + return saved + }) + } + stop() { + const self = this + return Effect.gen(function* () { + self.stopping = true + const records = [...self.queued.map((item) => item.record), ...[...self.active].flatMap(([id, active]) => { + const record = Mailbox.getRecord(active.sessionID, id) + return record ? [record] : [] + })] + for (const record of records) { + Mailbox.putRecord({ ...record, data: { ...record.data, continuationCancelled: true } }) + yield* self.cancel(record.sessionID, record.id) + } + Mailbox.cancelContinuations(self.directory, "job") + }) + } +} diff --git a/src/mendcode/packages/opencode/src/storage/compatibility.ts b/src/mendcode/packages/opencode/src/storage/compatibility.ts new file mode 100644 index 00000000..7d07c174 --- /dev/null +++ b/src/mendcode/packages/opencode/src/storage/compatibility.ts @@ -0,0 +1,139 @@ +import { init } from "#db" +import { existsSync, mkdirSync, chmodSync } from "node:fs" +import { randomUUID } from "node:crypto" +import type { MigrationIdentity } from "./migration-journal" +import { acquireWriterLease } from "./writer-lease" + +type Reader = Pick, "all" | "run"> +export type Compatibility = { compatible: boolean; reason?: string; pending: string[]; legacy: boolean } + +function inspect(db: Reader, supported: MigrationIdentity[]): Compatibility { + const tables = new Set( + db.all<{ name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'").map((row) => row.name), + ) + const known = new Map(supported.map((entry) => [entry.name, entry])) + const rows = tables.has("__drizzle_migrations") + ? db.all<{ name: string; created_at: number }>("SELECT name, created_at FROM __drizzle_migrations") + : [] + const incompatible = rows.find( + (row) => !known.has(row.name) || known.get(row.name)!.timestamp !== Number(row.created_at), + ) + if (incompatible) + return { + compatible: false, + reason: `Unsupported database migration: ${incompatible.name ?? "unnamed"}`, + pending: [], + legacy: true, + } + const identities = tables.has("__mendcode_schema_identity") + ? db.all<{ name: string; timestamp: number; hash: string }>( + "SELECT name, timestamp, hash FROM __mendcode_schema_identity", + ) + : [] + const drift = identities.find( + (row) => known.get(row.name)?.hash !== row.hash || known.get(row.name)?.timestamp !== Number(row.timestamp), + ) + if (drift) + return { + compatible: false, + reason: `Incompatible database schema identity: ${drift.name}`, + pending: [], + legacy: false, + } + const applied = new Set(rows.map((row) => row.name)) + return { + compatible: true, + pending: supported.filter((entry) => !applied.has(entry.name)).map((entry) => entry.name), + legacy: !tables.has("__mendcode_schema_identity"), + } +} + +/** Opens an existing file read-only: no migration, writer PRAGMA or file creation. */ +export function inspectCompatibility(file: string, supported: MigrationIdentity[]): Compatibility { + if (file === ":memory:" || !existsSync(file)) + return { compatible: true, pending: supported.map((entry) => entry.name), legacy: true } + const db = init(file, true) + try { + return inspect(db, supported) + } catch (error) { + return { + compatible: false, + reason: `Cannot verify database compatibility: ${error instanceof Error ? error.message : String(error)}`, + pending: [], + legacy: true, + } + } finally { + db.$client.close() + } +} + +export function assertCompatibility(file: string, supported: MigrationIdentity[]) { + const result = inspectCompatibility(file, supported) + if (!result.compatible) + throw new Error(`${result.reason}. Use a compatible MendCode version; this database was not migrated.`) + return result +} + +/** SQLite produces a consistent snapshot including committed WAL pages. */ +export function backupBeforeMigration(file: string, supported: MigrationIdentity[]) { + const state = assertCompatibility(file, supported) + if (file === ":memory:" || !existsSync(file) || state.pending.length === 0) return + const dir = `${file}.backups` + mkdirSync(dir, { recursive: true, mode: 0o700 }) + const destination = `${dir}/before-migration-${Date.now()}-${randomUUID()}.db` + const db = init(file, true) + try { + db.run(`VACUUM INTO '${destination.replaceAll("'", "''")}'`) + chmodSync(destination, 0o600) + return destination + } finally { + db.$client.close() + } +} + +export function recordSchemaIdentity(db: Reader, supported: MigrationIdentity[]) { + db.run("SAVEPOINT mendcode_schema_identity") + try { + db.run( + "CREATE TABLE IF NOT EXISTS __mendcode_schema_identity (name TEXT PRIMARY KEY, timestamp INTEGER NOT NULL, hash TEXT NOT NULL)", + ) + for (const entry of supported) { + db.run( + `INSERT OR IGNORE INTO __mendcode_schema_identity VALUES ('${entry.name.replaceAll("'", "''")}', ${entry.timestamp}, '${entry.hash}')`, + ) + } + db.run("RELEASE SAVEPOINT mendcode_schema_identity") + } catch (error) { + db.run("ROLLBACK TO SAVEPOINT mendcode_schema_identity") + db.run("RELEASE SAVEPOINT mendcode_schema_identity") + throw error + } +} + +export function acquireDatabaseMaintenance(file: string, supported: MigrationIdentity[]) { + const releaseLease = acquireWriterLease(file) + let db: ReturnType | undefined + try { + assertCompatibility(file, supported) + if (file === ":memory:" || !existsSync(file)) return { release: releaseLease } + db = init(file, false, true) + db.run("BEGIN IMMEDIATE") + const state = inspect(db, supported) + if (!state.compatible) throw new Error(state.reason) + } catch (error) { + try { db?.$client.close() } finally { releaseLease() } + throw error + } + let released = false + return { + release() { + if (released) return + released = true + try { + db!.run("ROLLBACK") + } finally { + try { db!.$client.close() } finally { releaseLease() } + } + }, + } +} diff --git a/src/mendcode/packages/opencode/src/storage/db.bun.ts b/src/mendcode/packages/opencode/src/storage/db.bun.ts index fa619092..909b80f3 100644 --- a/src/mendcode/packages/opencode/src/storage/db.bun.ts +++ b/src/mendcode/packages/opencode/src/storage/db.bun.ts @@ -1,8 +1,11 @@ import { Database } from "bun:sqlite" import { drizzle } from "drizzle-orm/bun-sqlite" -export function init(path: string) { - const sqlite = new Database(path, { create: true }) +export function init(path: string, readonly = false, existing = false) { + const sqlite = new Database( + path, + readonly ? { readonly: true } : existing ? { readwrite: true, create: false } : { create: true }, + ) const db = drizzle({ client: sqlite }) return db } diff --git a/src/mendcode/packages/opencode/src/storage/db.node.ts b/src/mendcode/packages/opencode/src/storage/db.node.ts index 0dba8dce..f9600765 100644 --- a/src/mendcode/packages/opencode/src/storage/db.node.ts +++ b/src/mendcode/packages/opencode/src/storage/db.node.ts @@ -1,8 +1,10 @@ import { DatabaseSync } from "node:sqlite" import { drizzle } from "drizzle-orm/node-sqlite" +import { existsSync } from "node:fs" -export function init(path: string) { - const sqlite = new DatabaseSync(path) +export function init(path: string, readonly = false, existing = false) { + if (existing && !existsSync(path)) throw new Error("Database no longer exists") + const sqlite = new DatabaseSync(path, { readOnly: readonly }) const db = drizzle({ client: sqlite }) return db } diff --git a/src/mendcode/packages/opencode/src/storage/db.ts b/src/mendcode/packages/opencode/src/storage/db.ts index ff806a07..0141c963 100644 --- a/src/mendcode/packages/opencode/src/storage/db.ts +++ b/src/mendcode/packages/opencode/src/storage/db.ts @@ -8,8 +8,6 @@ import { Global } from "@mendcode/core/global" import * as Log from "@mendcode/core/util/log" import { NamedError } from "@mendcode/core/util/error" import z from "zod" -import path from "path" -import { readFileSync, readdirSync, existsSync } from "fs" import { Flag } from "@mendcode/core/flag/flag" import { InstallationChannel } from "@mendcode/core/installation/version" import { InstanceState } from "@/effect/instance-state" @@ -23,6 +21,9 @@ import { assertTestSqliteIsolation, } from "./resolve-default-sqlite-path" import { reconcileLegacyMigrationJournal } from "./legacy-migration" +import { migrationEntries, identifyMigrations } from "./migration-journal" +import { assertCompatibility, backupBeforeMigration, recordSchemaIdentity } from "./compatibility" +import { acquireWriterLease } from "./writer-lease" declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined @@ -80,83 +81,65 @@ function applyMigrations(db: SQLiteBunDatabase, entries: Journal) { migrateFromJournal(db, entries) } -function time(tag: string) { - const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag) - if (!match) return 0 - return Date.UTC( - Number(match[1]), - Number(match[2]) - 1, - Number(match[3]), - Number(match[4]), - Number(match[5]), - Number(match[6]), - ) -} - -function migrations(dir: string): Journal { - const dirs = readdirSync(dir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - - const sql = dirs - .map((name) => { - const file = path.join(dir, name, "migration.sql") - if (!existsSync(file)) return - return { - sql: readFileSync(file, "utf-8"), - timestamp: time(name), - name, - } - }) - .filter(Boolean) as Journal - - return sql.sort((a, b) => a.timestamp - b.timestamp) -} - +let releaseWriter: (() => void) | undefined export const Client = lazy(() => { log.info("opening database", { path: Path }) - - const db = init(Path) - - db.run("PRAGMA journal_mode = WAL") - db.run("PRAGMA synchronous = NORMAL") - db.run("PRAGMA busy_timeout = 5000") - db.run("PRAGMA cache_size = -64000") - db.run("PRAGMA foreign_keys = ON") - db.run("PRAGMA wal_checkpoint(PASSIVE)") - - // Apply schema migrations - const entries = - typeof OPENCODE_MIGRATIONS !== "undefined" - ? OPENCODE_MIGRATIONS - : migrations(path.join(import.meta.dirname, "../../migration")) - if (entries.length > 0) { - log.info("applying migrations", { - count: entries.length, - mode: typeof OPENCODE_MIGRATIONS !== "undefined" ? "bundled" : "dev", - }) + const entries = migrationEntries() + const identity = identifyMigrations(entries) + const release = acquireWriterLease(Path) + let db: ReturnType | undefined + try { + assertCompatibility(Path, identity) if (!Flag.OPENCODE_SKIP_MIGRATIONS) { - const reconciled = reconcileLegacyMigrationJournal(db, entries) - if (reconciled) { - log.info("reconciled legacy sqlite migration journal", { - count: entries.length, - }) - } - } else { - for (const item of entries) { - item.sql = "select 1;" + const backup = backupBeforeMigration(Path, identity) + if (backup) log.info("created pre-migration database backup", { path: backup }) + } + db = init(Path) + db.run("PRAGMA journal_mode = WAL") + db.run("PRAGMA synchronous = NORMAL") + db.run("PRAGMA busy_timeout = 5000") + db.run("PRAGMA cache_size = -64000") + db.run("PRAGMA foreign_keys = ON") + db.run("PRAGMA wal_checkpoint(PASSIVE)") + + // Apply schema migrations + if (entries.length > 0) { + log.info("applying migrations", { + count: entries.length, + mode: typeof OPENCODE_MIGRATIONS !== "undefined" ? "bundled" : "dev", + }) + if (!Flag.OPENCODE_SKIP_MIGRATIONS) { + const reconciled = reconcileLegacyMigrationJournal(db, entries) + if (reconciled) { + log.info("reconciled legacy sqlite migration journal", { + count: entries.length, + }) + } + } else { + for (const item of entries) { + item.sql = "select 1;" + } } + applyMigrations(db, entries) + if (!Flag.OPENCODE_SKIP_MIGRATIONS) recordSchemaIdentity(db, identity) } - applyMigrations(db, entries) - } - return db + releaseWriter = release + return db + } catch (error) { + try { db?.$client.close() } finally { release() } + throw error + } }) export function close() { if (!Client.loaded()) return - Client().$client.close() - Client.reset() + try { Client().$client.close() } finally { + Client.reset() + const release = releaseWriter + releaseWriter = undefined + release?.() + } } export type TxOrDb = Transaction | Client diff --git a/src/mendcode/packages/opencode/src/storage/migration-journal.ts b/src/mendcode/packages/opencode/src/storage/migration-journal.ts new file mode 100644 index 00000000..6bb2d2e0 --- /dev/null +++ b/src/mendcode/packages/opencode/src/storage/migration-journal.ts @@ -0,0 +1,47 @@ +import { createHash } from "node:crypto" +import { existsSync, readdirSync, readFileSync } from "node:fs" +import path from "node:path" + +export type MigrationEntry = { sql: string; timestamp: number; name: string } +export type MigrationIdentity = { name: string; timestamp: number; hash: string } +declare const OPENCODE_MIGRATIONS: MigrationEntry[] | undefined + +export function migrationEntries(): MigrationEntry[] { + if (typeof OPENCODE_MIGRATIONS !== "undefined") return OPENCODE_MIGRATIONS.map((entry) => ({ ...entry })) + const dir = path.join(import.meta.dirname, "../../migration") + return readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .flatMap((entry) => { + const file = path.join(dir, entry.name, "migration.sql") + if (!existsSync(file)) return [] + const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(entry.name) + if (!match) throw new Error(`Invalid migration name: ${entry.name}`) + return [ + { + name: entry.name, + sql: readFileSync(file, "utf8"), + timestamp: Date.UTC( + Number(match[1]), + Number(match[2]) - 1, + Number(match[3]), + Number(match[4]), + Number(match[5]), + Number(match[6]), + ), + }, + ] + }) + .sort((a, b) => a.timestamp - b.timestamp) +} + +export function identifyMigrations(entries: MigrationEntry[]): MigrationIdentity[] { + return entries.map(({ name, timestamp, sql }) => ({ + name, + timestamp, + hash: createHash("sha256").update(sql).digest("hex"), + })) +} + +export function supportedMigrationJournal(): MigrationIdentity[] { + return identifyMigrations(migrationEntries()) +} diff --git a/src/mendcode/packages/opencode/src/storage/resolve-default-sqlite-path.ts b/src/mendcode/packages/opencode/src/storage/resolve-default-sqlite-path.ts index 21d88ee7..b2e23e8f 100644 --- a/src/mendcode/packages/opencode/src/storage/resolve-default-sqlite-path.ts +++ b/src/mendcode/packages/opencode/src/storage/resolve-default-sqlite-path.ts @@ -14,7 +14,7 @@ export type TestSqliteIsolationInput = { allowedRoots: string[] } -const SIMPLE_NAME_CHANNELS = new Set(["latest", "beta", "prod"]) +const SIMPLE_NAME_CHANNELS = new Set(["latest", "stable", "beta", "nightly", "prod"]) function useSimpleDbBasename(installationChannel: string, disableChannelDb: boolean) { return SIMPLE_NAME_CHANNELS.has(installationChannel) || disableChannelDb diff --git a/src/mendcode/packages/opencode/src/storage/startup-migration.ts b/src/mendcode/packages/opencode/src/storage/startup-migration.ts new file mode 100644 index 00000000..b62df37e --- /dev/null +++ b/src/mendcode/packages/opencode/src/storage/startup-migration.ts @@ -0,0 +1,41 @@ +import { existsSync } from "node:fs" +import path from "node:path" +import { EOL } from "node:os" +import { Global } from "@mendcode/core/global" + +// Clients and maintenance commands must not migrate data before the backend or +// updater has established ownership and checked compatibility. +export function commandOwnsStartupMigration(command: string | undefined) { + return command !== undefined && !new Set([ + "upgrade", "attach", "uninstall", "models", "providers", "console", "completion", "stats", "run", + ]).has(command) +} + +export async function migrateLegacyStorage() { + const marker = path.join(Global.Path.data, ".mendcode-json-storage-migration-v0.done") + if (existsSync(marker)) return + const [{ JsonMigration }, { Database }] = await Promise.all([ + import("./json-migration"), import("./db"), + ]) + process.stderr.write("Performing one time database migration..." + EOL) + let last = -1 + try { + const stats = await JsonMigration.run(Database.Client(), { + progress: (event) => { + const percent = event.total ? Math.floor(event.current / event.total * 100) : 100 + if (percent === last) return + last = percent + process.stderr.write(`sqlite-migration:${percent}${EOL}`) + }, + }) + if (!JsonMigration.jsonStorageMigrationSucceeded(stats)) { + throw new Error(`Database migration incomplete (${stats.errors.length} errors): ${stats.errors.slice(0, 10).join("; ")}`) + } + await JsonMigration.writeJsonStorageMigrationDoneMarker() + process.stderr.write("Database migration complete." + EOL) + } finally { + // The importer changes PRAGMAs; normal runtime access must start fresh. + Database.close() + process.stderr.write(`sqlite-migration:done${EOL}`) + } +} diff --git a/src/mendcode/packages/opencode/src/storage/writer-lease.ts b/src/mendcode/packages/opencode/src/storage/writer-lease.ts new file mode 100644 index 00000000..8c0adc8f --- /dev/null +++ b/src/mendcode/packages/opencode/src/storage/writer-lease.ts @@ -0,0 +1,63 @@ +import { mkdirSync, realpathSync, readFileSync, writeFileSync, renameSync, lstatSync } from "node:fs" +import path from "node:path" +import { randomUUID } from "node:crypto" + +function canonical(file: string): string { + try { return realpathSync(file) } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error + const parent = path.dirname(file) + if (parent === file) throw error + return path.join(canonical(parent), path.basename(file)) + } +} + +function owner(directory: string): { pid: number; token: string } { + const file = path.join(directory, "owner.json") + const stat = lstatSync(file) + if (!stat.isFile() || stat.size > 4096) throw new Error("Database writer owner cannot be verified") + const value = JSON.parse(readFileSync(file, "utf8")) + if (!Number.isSafeInteger(value.pid) || value.pid <= 0 || typeof value.token !== "string" || !/^[a-f0-9-]{36}$/.test(value.token)) { + throw new Error("Database writer owner is invalid") + } + return value +} + +function alive(pid: number) { + try { process.kill(pid, 0); return true } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ESRCH" + } +} + +/** All new runtime writers and maintenance share this lease, independent of XDG and channel. */ +export function acquireWriterLease(file: string) { + if (file === ":memory:") return () => {} + const resolved = canonical(path.resolve(file)) + const directory = `${resolved}.writer-lock` + mkdirSync(path.dirname(resolved), { recursive: true, mode: 0o700 }) + const token = randomUUID() + try { + mkdirSync(directory, { mode: 0o700 }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error + if (!lstatSync(directory).isDirectory() || lstatSync(directory).isSymbolicLink()) throw new Error("Database writer lock is redirected") + const previous = owner(directory) + if (alive(previous.pid)) throw new Error(`Database already has a writer (PID ${previous.pid}). Connect to its backend or close it after its work finishes.`) + // Only one recoverer can move this dead lease; an incomplete recovery fails + // visibly and retains evidence instead of deleting a possibly new lock. + mkdirSync(path.join(directory, "recovery"), { mode: 0o700 }) + const current = owner(directory) + if (current.token !== previous.token || current.pid !== previous.pid || alive(current.pid)) throw new Error("Database writer ownership changed during recovery") + renameSync(directory, `${directory}.recovered-${randomUUID()}`) + mkdirSync(directory, { mode: 0o700 }) + } + writeFileSync(path.join(directory, "owner.json"), JSON.stringify({ pid: process.pid, token, acquiredAt: new Date().toISOString() }), { flag: "wx", mode: 0o600 }) + let released = false + return () => { + if (released) return + const current = owner(directory) + if (current.pid !== process.pid || current.token !== token) throw new Error("Database writer lease is no longer owned by this process") + // Retain the tiny ownership receipt; never remove another process's state. + renameSync(directory, `${directory}.released-${token}`) + released = true + } +} diff --git a/src/mendcode/packages/opencode/src/tool/continuity.ts b/src/mendcode/packages/opencode/src/tool/continuity.ts new file mode 100644 index 00000000..bb433bf5 --- /dev/null +++ b/src/mendcode/packages/opencode/src/tool/continuity.ts @@ -0,0 +1,221 @@ +import { Effect, Schema, Scope } from "effect" +import type { Tool } from "./tool" +import { ToolJobs } from "@/session/tool-jobs" +import * as Mailbox from "@/session/runtime-mailbox" +import type { Config } from "@/config/config" +import type { Bus } from "@/bus" +import type { Question } from "@/question" +import { QuestionID } from "@/question/schema" +import { Parameters as QuestionParameters } from "./question" +import { MessageV2 } from "@/session/message-v2" +import { Session } from "@/session/session" +import { SessionID, MessageID } from "@/session/schema" +import { sessionNotes } from "./session-notes" + +function result(value: unknown) { + return { title: "Continuity", output: JSON.stringify(value), metadata: { truncated: false } } +} +export function continuityTools(input: { + eligible: Tool.Def[] + scope: Scope.Scope + directory: string + bus: Bus.Interface + config: Config.Interface + question: Question.Interface + sessions: Session.Interface +}) { + const jobs = new ToolJobs(input.scope, input.directory, input.bus, input.config) + const enabled = (key: "async_tools" | "async_questions" | "session_recall") => + Effect.gen(function* () { + if ((yield* input.config.get()).experimental?.[key] !== true) throw new Error(`${key} is disabled`) + }) + const startParameters = Schema.Struct({ + tool: Schema.Literals(["read", "grep", "glob", "webfetch"]), + arguments: Schema.Record(Schema.String, Schema.Unknown), + }) + const start: Tool.Def = { + id: "tool_start", + description: + "Start a read, grep, glob or webfetch job without waiting. Continue independent work; results arrive at safe checkpoints. Shell, mutations and arbitrary MCP are unavailable asynchronously. Original tool permissions still apply.", + parameters: startParameters, + execute: (args, ctx) => + Effect.gen(function* () { + yield* enabled("async_tools") + const tool = input.eligible.find((tool) => tool.id === args.tool) + if (!tool) throw new Error("Tool unavailable") + const job = yield* jobs.start(tool, args.arguments, ctx) + return result({ job_id: job.id, status: job.status }) + }), + } + const idParameters = Schema.Struct({ + job_id: Schema.String, + offset: Schema.optional(Schema.Number), + limit: Schema.optional(Schema.Number), + }) + const status: Tool.Def = { + id: "tool_status", + description: "Read a job and its stored result in the current session.", + parameters: idParameters, + execute: (args, ctx) => + Effect.sync(() => { + const record = Mailbox.getRecord(ctx.sessionID, args.job_id) + if (!record || record.kind !== "job") throw new Error("Unknown job in this session") + const output = typeof record.data.result === "string" ? record.data.result : "" + const offset = Number.isFinite(args.offset) ? Math.max(0, Math.floor(args.offset!)) : 0 + const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(16000, Math.floor(args.limit!))) : 8000 + return result({ + job_id: record.id, + status: record.status, + tool: record.data.tool, + error: record.data.error, + output: output.slice(offset, offset + limit), + next_offset: offset + limit < output.length ? offset + limit : undefined, + attachment_notice: record.data.attachmentNotice, + }) + }), + } + const cancel: Tool.Def = { + id: "tool_cancel", + description: + "Cancel a job from the current session. Retains history and late results without resuming cancelled work.", + parameters: idParameters, + execute: (args, ctx) => jobs.cancel(ctx.sessionID, args.job_id).pipe(Effect.map(result)), + } + const waitParameters = Schema.Struct({ + job_ids: Schema.Array(Schema.String), + timeout_ms: Schema.optional(Schema.Number), + }) + const wait: Tool.Def = { + id: "tool_wait", + description: "Wait up to 60 seconds for selected jobs. Returns immediately when all are terminal.", + parameters: waitParameters, + execute: (args, ctx) => + Effect.gen(function* () { + if (!args.job_ids.length || args.job_ids.length > 12) throw new Error("Provide 1 to 12 job IDs") + const deadline = + Date.now() + (Number.isFinite(args.timeout_ms) ? Math.max(0, Math.min(60000, args.timeout_ms!)) : 10000) + while (true) { + const records = args.job_ids.map((id) => Mailbox.getRecord(ctx.sessionID, id)) + if (records.some((record) => !record || record.kind !== "job")) throw new Error("Unknown job in this session") + if ( + ctx.abort.aborted || + Date.now() >= deadline || + records.every((record) => !["running", "queued"].includes(record!.status)) + ) + return result(records.map((record) => ({ job_id: record!.id, status: record!.status }))) + yield* Effect.sleep(100) + } + }), + } + const question: Tool.Def = { + id: "question_async", + description: + "Post questions and continue independent work. User replies arrive later. Silence never grants approval; tool authorizations use permissions.", + parameters: QuestionParameters, + execute: (args, ctx) => + Effect.gen(function* () { + yield* enabled("async_questions") + const request = yield* input.question.post({ + sessionID: ctx.sessionID, + questions: args.questions, + tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, + }) + return result({ question_id: request.id, status: "pending" }) + }), + } + const questionGetParameters = Schema.Struct({ question_id: Schema.String }) + const questionGet: Tool.Def = { + id: "question_get", + description: "Read the current session's posted question, real reply or dismissal.", + parameters: questionGetParameters, + execute: (args, ctx) => + Effect.gen(function* () { + const record = yield* input.question.get(QuestionID.make(args.question_id)) + if (!record || record.sessionID !== ctx.sessionID) throw new Error("Unknown question in this session") + return result(record) + }), + } + const recallParameters = Schema.Struct({ + session_id: Schema.optional(Schema.String), + before: Schema.optional(Schema.String), + limit: Schema.optional(Schema.Number), + query: Schema.optional(Schema.String), + message_id: Schema.optional(Schema.String), + offset: Schema.optional(Schema.Number), + }) + const recall = (search: boolean): Tool.Def => ({ + id: search ? "session_search" : "session_read", + description: search + ? "Search one bounded page of historical messages including before compaction. Follow next_cursor for older pages. Only current session or linked descendants." + : "Read one bounded page of session history, including before compaction. Only current session or linked descendants.", + parameters: recallParameters, + execute: (args, ctx) => + Effect.gen(function* () { + yield* enabled("session_recall") + const target = SessionID.make(args.session_id ?? ctx.sessionID) + let current = yield* input.sessions.get(target).pipe(Effect.orDie) + const seen = new Set() + while (current.id !== ctx.sessionID) { + if (!current.parentID || seen.has(current.id)) + throw new Error("Session is outside the current session and its linked children") + seen.add(current.id) + current = yield* input.sessions.get(current.parentID).pipe(Effect.orDie) + } + if (args.message_id && !search) { + const message = MessageV2.get({ sessionID: target, messageID: MessageID.make(args.message_id) }) + const text = message.parts + .flatMap((part) => + part.type === "text" + ? [part.text] + : part.type === "tool" && part.state.status === "completed" + ? [`${part.tool}: ${part.state.output}`] + : [], + ) + .join("\n") + const offset = Number.isFinite(args.offset) ? Math.max(0, Math.floor(args.offset!)) : 0 + return result({ + session_id: target, + message_id: message.info.id, + text: text.slice(offset, offset + 16000), + next_offset: offset + 16000 < text.length ? offset + 16000 : undefined, + }) + } + const page = MessageV2.page({ + sessionID: target, + limit: Number.isFinite(args.limit) ? Math.max(1, Math.min(10, Math.floor(args.limit!))) : 10, + before: args.before, + }) + const query = args.query?.toLowerCase() + if (search && !query) throw new Error("Search requires query") + const messages = page.items.flatMap((message) => { + const text = message.parts + .flatMap((part) => + part.type === "text" + ? [part.text] + : part.type === "tool" && part.state.status === "completed" + ? [`${part.tool}: ${part.state.output}`] + : [], + ) + .join("\n") + const match = query ? text.toLowerCase().indexOf(query) : 0 + if (search && match < 0) return [] + const offset = search ? Math.max(0, match - 200) : 0 + return [ + { + message_id: message.info.id, + role: message.info.role, + text: text.slice(offset, offset + 2000), + truncated: text.length > offset + 2000, + }, + ] + }) + return result({ session_id: target, messages, next_cursor: page.cursor, more: page.more }) + }), + }) + return { + jobs, + jobTools: [start, status, wait, cancel] as Tool.Def[], + questionTools: [question, questionGet] as Tool.Def[], + recallTools: [recall(true), recall(false), sessionNotes(input.directory, input.config)] as Tool.Def[], + } +} diff --git a/src/mendcode/packages/opencode/src/tool/reasoning_auto.ts b/src/mendcode/packages/opencode/src/tool/reasoning_auto.ts new file mode 100644 index 00000000..b2d4e0ca --- /dev/null +++ b/src/mendcode/packages/opencode/src/tool/reasoning_auto.ts @@ -0,0 +1,26 @@ +import { Effect, Schema } from "effect" +import { Config } from "@/config/config" +import * as Tool from "./tool" + +const Parameters = Schema.Struct({ + reason: Schema.Literals(["verification_failed", "technical_block"]), + evidence: Schema.String.pipe(Schema.check(Schema.isMinLength(1), Schema.isMaxLength(1000))), +}) + +export const ReasoningAutoTool = Tool.define("reasoning_auto", Effect.gen(function* () { + const config = yield* Config.Service + return { + description: "Request one supported reasoning increase, capped at high, for this turn after a relevant verification failure or explicit technical block. Explain concrete evidence. Never use for network failures, permission requests, pending questions or routine work. Manual reasoning selection always wins; this tool never changes model or service tier.", + parameters: Parameters, + execute: (args, ctx) => Effect.gen(function* () { + const cfg = yield* config.get() + const user = [...ctx.messages].reverse().find((message) => message.info.role === "user") + const manual = user?.info.role === "user" ? user.info.model.variant : undefined + const enabled = cfg.experimental?.reasoning_auto === true && !manual + const result = enabled + ? { signal: args.reason, evidence: args.evidence, status: "requested" } + : { status: "unchanged", reason: manual ? "Manual reasoning selection takes precedence." : "Reasoning Auto is disabled." } + return { title: enabled ? "Reasoning Auto: increase requested" : "Reasoning unchanged", metadata: result, output: JSON.stringify(result) } + }), + } satisfies Tool.DefWithoutID +})) diff --git a/src/mendcode/packages/opencode/src/tool/registry.ts b/src/mendcode/packages/opencode/src/tool/registry.ts index efdce857..279db059 100644 --- a/src/mendcode/packages/opencode/src/tool/registry.ts +++ b/src/mendcode/packages/opencode/src/tool/registry.ts @@ -24,7 +24,10 @@ import { ImageGenTool, resolveImageGenerationModel, usesCodexImageAdapter } from import * as Tool from "./tool" import { Config } from "@/config/config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@mendcode/plugin" -import { Schema } from "effect" +import { Schema, Scope } from "effect" +import { continuityTools } from "./continuity" +import { ReasoningAutoTool } from "./reasoning_auto" +import { waitForDisable } from "@/session/continuity-control" import z from "zod" import { ZodOverride } from "@/util/effect-zod" import { Plugin } from "../plugin" @@ -72,6 +75,7 @@ type TaskDef = Tool.InferDef type ReadDef = Tool.InferDef type State = { + jobs: ReturnType["jobs"] custom: Tool.Def[] builtin: Tool.Def[] task: TaskDef @@ -79,6 +83,10 @@ type State = { } export interface Interface { + readonly cancelJob: ( + sessionID: import("@/session/schema").SessionID, + id: string, + ) => Effect.Effect readonly ids: () => Effect.Effect readonly all: () => Effect.Effect readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }> @@ -131,6 +139,10 @@ export const layer: Layer.Layer< const taskStatus = yield* TaskStatusTool const read = yield* ReadTool const question = yield* QuestionTool + const questionService = yield* Question.Service + const sessionService = yield* Session.Service + const busService = yield* Bus.Service + const reasoningAuto = yield* ReasoningAutoTool const planReview = yield* PlanReviewTool const todo = yield* TodoWriteTool const lsptool = yield* LspTool @@ -229,11 +241,12 @@ export const layer: Layer.Layer< } } - yield* config.get() + const cfg = yield* config.get() const questionEnabled = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL const tool = yield* Effect.all({ + reasoningAuto: Tool.init(reasoningAuto), invalid: Tool.init(invalid), shell: Tool.init(shell), read: Tool.init(read), @@ -262,9 +275,29 @@ export const layer: Layer.Layer< plan: Tool.init(plan), }) + const scope = yield* Scope.Scope + const continuity = continuityTools({ + eligible: [tool.read, tool.glob, tool.grep, tool.fetch], + scope, + directory: ctx.directory, + bus: busService, + config, + question: questionService, + sessions: sessionService, + }) + yield* Effect.addFinalizer(() => continuity.jobs.stop()) + yield* waitForDisable(ctx.directory, "tools").pipe( + Effect.andThen(continuity.jobs.stop()), + Effect.forkScoped({ startImmediately: true }), + ) return { + jobs: continuity.jobs, custom, builtin: [ + ...(cfg.experimental?.async_tools === true ? continuity.jobTools : []), + ...(cfg.experimental?.async_questions === true ? continuity.questionTools : []), + ...(cfg.experimental?.session_recall === true ? continuity.recallTools : []), + ...(cfg.experimental?.reasoning_auto === true ? [tool.reasoningAuto] : []), tool.invalid, ...(questionEnabled ? [tool.question] : []), tool.shell, @@ -433,7 +466,12 @@ export const layer: Layer.Layer< return { task: s.task, read: s.read } }) - return Service.of({ ids, all, named, tools }) + const cancelJob: Interface["cancelJob"] = (sessionID, id) => + Effect.gen(function* () { + const s = yield* InstanceState.get(state) + return yield* s.jobs.cancel(sessionID, id) + }) + return Service.of({ ids, all, named, tools, cancelJob }) }), ) diff --git a/src/mendcode/packages/opencode/src/tool/session-notes.ts b/src/mendcode/packages/opencode/src/tool/session-notes.ts new file mode 100644 index 00000000..842c69ec --- /dev/null +++ b/src/mendcode/packages/opencode/src/tool/session-notes.ts @@ -0,0 +1,43 @@ +import { Effect, Schema } from "effect" +import { Database } from "@/storage/db" +import * as Mailbox from "@/session/runtime-mailbox" +import type { Tool } from "./tool" +import type { Config } from "@/config/config" + +const Parameters = Schema.Struct({ + action: Schema.Literals(["read", "write"]), + version: Schema.optional(Schema.Int), + text: Schema.optional(Schema.String), +}) + +export function sessionNotes(directory: string, config: Pick): Tool.Def { + return { + id: "session_notes", + description: "Read or replace this session's working notes across compaction. Read first; write requires the current version to prevent lost updates. At most 16,000 characters. Read a specific version to recover earlier notes. Never writes global memory.", + parameters: Parameters, + execute: (args, context) => Effect.gen(function* () { + if ((yield* config.get()).experimental?.session_recall !== true) throw new Error("Session recall is disabled") + const headID = `notes_${context.sessionID}` + const value = Database.transaction(() => { + const head = Mailbox.getRecord(context.sessionID, headID) + const currentVersion = Number(head?.data.version ?? 0) + if (args.action === "read") { + if (args.version !== undefined && (!Number.isSafeInteger(args.version) || args.version < 0)) throw new Error("Invalid note version") + const record = args.version === undefined ? head : Mailbox.getRecord(context.sessionID, `${headID}_${args.version}`) + if (args.version && !record) throw new Error("Unknown note version in this session") + return { version: Number(record?.data.version ?? 0), text: String(record?.data.text ?? ""), current_version: currentVersion } + } + if (args.version !== currentVersion) throw new Error(`Notes changed or version missing; read current notes before writing (current version ${currentVersion})`) + if (typeof args.text !== "string" || args.text.length > 16000) throw new Error("Notes require text of at most 16,000 characters") + const version = currentVersion + 1 + const record: Mailbox.Record = { id: headID, sessionID: context.sessionID, directory, + kind: "note", generation: Mailbox.generation(context.sessionID), status: "saved", + data: { version, text: args.text }, timeCreated: Date.now(), timeUpdated: Date.now() } + Mailbox.putRecord({ ...record, id: `${headID}_${version}` }) + Mailbox.putRecord(record) + return { version, text: args.text, current_version: version } + }) + return { title: "Session notes", output: JSON.stringify(value), metadata: { truncated: false } } + }), + } +} diff --git a/src/mendcode/packages/opencode/test/cli/run-attach.test.ts b/src/mendcode/packages/opencode/test/cli/run-attach.test.ts new file mode 100644 index 00000000..d58fd830 --- /dev/null +++ b/src/mendcode/packages/opencode/test/cli/run-attach.test.ts @@ -0,0 +1,130 @@ +import { expect, test } from "bun:test" +import { existsSync } from "node:fs" +import path from "node:path" + +test.each(["completed", "failed", "http-rejected"])( + "attached run stays client-only and exits after %s", + async (outcome) => { + const failed = outcome === "failed" + const base = path.join(process.env.XDG_DATA_HOME!, `run-attach-${outcome}`) + const db = path.join(base, "runtime.db") + const sessionID = "ses_fixture" + let events: ReadableStreamDefaultController | undefined + let prompt: unknown + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: async (request) => { + const url = new URL(request.url) + if (url.pathname === "/event") + return new Response( + new ReadableStream({ + start(controller) { + events = controller + controller.enqueue(new TextEncoder().encode(": connected\n\n")) + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) + if (url.pathname === "/session") return Response.json({ id: sessionID }) + if (url.pathname === "/config") return Response.json({ share: "disabled" }) + if (url.pathname === `/continuity/${sessionID}/model-resolution`) { + expect(await request.json()).toEqual({ explicitModel: "fixture/model" }) + return Response.json({ + agent: "build", + model: { providerID: "fixture", modelID: "model" }, + source: "explicit", + }) + } + if (url.pathname === `/session/${sessionID}/message`) { + prompt = await request.json() + if (outcome === "http-rejected") return Response.json({ message: "Fixture HTTP failure" }, { status: 500 }) + if (failed) + events!.enqueue( + new TextEncoder().encode( + `data: ${JSON.stringify({ + type: "session.error", + properties: { + sessionID, + error: { name: "UnknownError", data: { message: "Fixture failure" } }, + }, + })}\n\n`, + ), + ) + events!.enqueue( + new TextEncoder().encode( + `data: ${JSON.stringify({ + type: "session.status", + properties: { + sessionID, + status: { type: "idle" }, + }, + })}\n\n`, + ), + ) + return Response.json({}) + } + return new Response("Unexpected fixture request", { status: 404 }) + }, + }) + const child = Bun.spawn( + [ + process.execPath, + "run", + "--conditions=browser", + "src/index.ts", + "run", + "--attach", + server.url.origin, + "--model", + "fixture/model", + "--format", + "json", + "Fixture prompt", + ], + { + cwd: path.resolve(import.meta.dir, "../.."), + env: { + ...process.env, + MENDCODE_DB: db, + OPENCODE_DB: db, + XDG_DATA_HOME: base, + XDG_CONFIG_HOME: path.join(base, "config"), + XDG_STATE_HOME: path.join(base, "state"), + XDG_CACHE_HOME: path.join(base, "cache"), + HOME: path.join(base, "home"), + OPENCODE_TEST_HOME: path.join(base, "home"), + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }, + ) + const timer = setTimeout(() => child.kill(), 15_000) + try { + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + expect(code).toBe(outcome === "completed" ? 0 : 1) + if (outcome === "http-rejected") expect(stdout).not.toContain("session.completed") + else { + expect(stderr).toBe("") + expect(stdout).toContain("session.completed") + expect(stdout).toContain(failed ? '"status":"failed"' : '"status":"completed"') + } + expect(prompt).toMatchObject({ agent: "build", model: { providerID: "fixture", modelID: "model" } }) + expect(existsSync(db)).toBe(false) + expect(existsSync(`${db}.writer-lock`)).toBe(false) + } finally { + clearTimeout(timer) + if (child.exitCode === null) { + child.kill() + await child.exited + } + server.stop(true) + } + }, + 20_000, +) diff --git a/src/mendcode/packages/opencode/test/cli/tui/agent-command-panel.test.tsx b/src/mendcode/packages/opencode/test/cli/tui/agent-command-panel.test.tsx index d9dd0b28..c0cc90d2 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/agent-command-panel.test.tsx +++ b/src/mendcode/packages/opencode/test/cli/tui/agent-command-panel.test.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @opentui/solid */ import { expect, test } from "bun:test" -import { RGBA } from "@opentui/core" +import { RGBA, type ScrollBoxRenderable } from "@opentui/core" import { testRender } from "@opentui/solid" import { createSignal } from "solid-js" import { AgentCommandPanel } from "@/cli/cmd/tui/component/agent-command-panel" @@ -138,3 +138,41 @@ for (const width of [42, 100]) { } }) } + +test("historical commands scroll within the widget and keep the source-session action", async () => { + const opened: string[] = [] + const app = await testRender(() => ( + + {}} + onOpenSession={(id) => opened.push(id)} /> + Draft below stays visible + + ), { width: 42, height: 6 }) + try { + await settle(app) + const toggle = app.renderer.root.findDescendantById("agent-command-history-toggle")! + await app.mockMouse.click(toggle.x + 1, toggle.y) + await settle(app) + const list = app.renderer.root.findDescendantById("agent-command-list") as ScrollBoxRenderable + expect(app.captureCharFrame()).toContain("Cache worker") + expect(app.captureCharFrame()).toContain("Draft below stays visible") + expect(app.captureCharFrame()).not.toContain("You") + list.focus() + for (let i = 0; i < 6; i++) app.mockInput.pressArrow("down") + await settle(app) + expect(list.scrollTop).toBeGreaterThan(0) + const open = app.renderer.root.findDescendantById("agent-command-open-fourth")! + expect(open.y).toBeLessThan(4) + expect(open.x + open.width).toBeLessThanOrEqual(42) + await app.mockMouse.click(open.x + 1, open.y) + expect(opened).toEqual(["source"]) + expect(app.captureCharFrame()).toContain("Draft below stays visible") + } finally { + app.renderer.destroy() + } +}) diff --git a/src/mendcode/packages/opencode/test/cli/tui/shared-server.test.ts b/src/mendcode/packages/opencode/test/cli/tui/shared-server.test.ts index 1ce6b1ba..260c6886 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/shared-server.test.ts +++ b/src/mendcode/packages/opencode/test/cli/tui/shared-server.test.ts @@ -9,6 +9,9 @@ import { shouldReplaceLiveServer, shouldUseSharedServer, waitForClientLeases, + resolveSharedDatabasePath, + resolveSharedServerRoot, + acquireLock, } from "../../../src/cli/cmd/tui/shared-server" import { tmpdir } from "../../fixture/fixture" import fs from "fs/promises" @@ -25,6 +28,57 @@ const valid = { } describe("shared server state", () => { + test("does not steal a live owner's startup lock based on age", async () => { + await using tmp = await tmpdir() + const previous = process.env.MENDCODE_SHARED_SERVER_STATE_FILE + process.env.MENDCODE_SHARED_SERVER_STATE_FILE = path.join(tmp.path, "server.json") + const lock = path.join(tmp.path, "server.lock") + try { + await fs.mkdir(lock) + await fs.writeFile(path.join(lock, "owner"), String(process.pid)) + const old = new Date(Date.now() - 60_000) + await fs.utimes(lock, old, old) + expect(await acquireLock(50)).toBeUndefined() + expect(await fs.readFile(path.join(lock, "owner"), "utf8")).toBe(String(process.pid)) + } finally { + if (previous === undefined) delete process.env.MENDCODE_SHARED_SERVER_STATE_FILE + else process.env.MENDCODE_SHARED_SERVER_STATE_FILE = previous + } + }) + + test("isolates explicit databases and shares their lock across state directories", async () => { + await using tmp = await tmpdir() + const database = path.join(tmp.path, "selected.db") + const defaults = path.join(tmp.path, "default.db") + const root = (selected: string, stateDirectory: string) => resolveSharedServerRoot({ + database: selected, defaultDatabase: defaults, stateDirectory, + }) + expect(root(database, path.join(tmp.path, "state-a"))).toBe(`${database}.shared-server`) + expect(root(database, path.join(tmp.path, "state-b"))).toBe(`${database}.shared-server`) + expect(root(path.join(tmp.path, "other.db"), tmp.path)).not.toBe(root(database, tmp.path)) + expect(root(defaults, tmp.path)).toBe(path.join(tmp.path, "shared-server")) + }) + + test("canonicalizes relative and symlink database aliases before spawning", async () => { + await using tmp = await tmpdir() + const data = path.join(tmp.path, "data") + const alias = path.join(tmp.path, "alias") + await fs.mkdir(data) + await fs.symlink(data, alias, process.platform === "win32" ? "junction" : "dir") + expect(resolveSharedDatabasePath("nested/db.sqlite", alias)).toBe(path.join(data, "nested/db.sqlite")) + expect(resolveSharedServerRoot({ + database: path.join(alias, "db.sqlite"), + defaultDatabase: path.join(data, "db.sqlite"), + stateDirectory: tmp.path, + })).toBe(path.join(tmp.path, "shared-server")) + }) + + test("keeps in-memory servers private to their initiating process", () => { + const input = { database: ":memory:", defaultDatabase: "/unused.db", stateDirectory: "/state" } + expect(resolveSharedServerRoot({ ...input, pid: 101 })).not.toBe(resolveSharedServerRoot({ ...input, pid: 202 })) + expect(resolveSharedDatabasePath(":memory:")).toBe(":memory:") + }) + test("accepts a valid loopback server state", () => { expect(parseState(valid)).toEqual(valid) }) diff --git a/src/mendcode/packages/opencode/test/cli/tui/thread.test.ts b/src/mendcode/packages/opencode/test/cli/tui/thread.test.ts index 4b839ffb..ff73a1f0 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/thread.test.ts +++ b/src/mendcode/packages/opencode/test/cli/tui/thread.test.ts @@ -7,9 +7,51 @@ import { resolveSharedServerURL, resolveThreadDirectory, SHARED_SERVER_RECONNECT_MAX_ATTEMPTS, + sharedServerEnvironment, + requireLocalSharedServer, } from "../../../src/cli/cmd/tui/thread" +import { SharedServer } from "../../../src/cli/cmd/tui/shared-server" describe("tui thread", () => { + test("refuses a private fallback while another runtime owns active clients", async () => { + await using tmp = await tmpdir() + const previous = process.env.MENDCODE_SHARED_SERVER_STATE_FILE + process.env.MENDCODE_SHARED_SERVER_STATE_FILE = path.join(tmp.path, "server.json") + let lease: Awaited> | undefined + try { + await SharedServer.writeState({ + version: 1, pid: process.pid, url: "http://127.0.0.1:1/", username: "test", password: "test", + startedAt: new Date().toISOString(), runtimeID: "previous-installed-runtime", + }) + lease = await SharedServer.acquireClientLease(process.pid) + await expect(requireLocalSharedServer({ directory: tmp.path, runtimeCwd: tmp.path })) + .rejects.toThrow("no separate database writer was started") + expect((await SharedServer.readState())?.runtimeID).toBe("previous-installed-runtime") + expect(SharedServer.isProcessAlive(process.pid)).toBe(true) + } finally { + await lease?.release() + if (previous === undefined) delete process.env.MENDCODE_SHARED_SERVER_STATE_FILE + else process.env.MENDCODE_SHARED_SERVER_STATE_FILE = previous + } + }) + + test("forwards an explicit database to the shared child with primary alias precedence", () => { + const original = { primary: process.env.MENDCODE_DB, legacy: process.env.OPENCODE_DB } + try { + process.env.MENDCODE_DB = ":memory:" + process.env.OPENCODE_DB = "ignored.db" + const env = sharedServerEnvironment({ username: "test", password: "test" }, "test-runtime") + expect(env.MENDCODE_DB).toBe(":memory:") + expect(env.OPENCODE_DB).toBe(":memory:") + expect(env.MENDCODE_SHARED_SERVER_STATE_FILE).toEndWith("server.json") + } finally { + if (original.primary === undefined) delete process.env.MENDCODE_DB + else process.env.MENDCODE_DB = original.primary + if (original.legacy === undefined) delete process.env.OPENCODE_DB + else process.env.OPENCODE_DB = original.legacy + } + }) + async function check(project?: string) { await using tmp = await tmpdir({ git: true }) const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link") diff --git a/src/mendcode/packages/opencode/test/cli/tui/widgets-runtime.test.tsx b/src/mendcode/packages/opencode/test/cli/tui/widgets-runtime.test.tsx new file mode 100644 index 00000000..cca9b5f5 --- /dev/null +++ b/src/mendcode/packages/opencode/test/cli/tui/widgets-runtime.test.tsx @@ -0,0 +1,100 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { RGBA } from "@opentui/core" +import { testRender } from "@opentui/solid" +import { createSignal } from "solid-js" +import { + SessionQuestionsWidget, + SessionJobsWidget, + splitWidgetQuestions, + widgetReasoningLabel, + type AsyncQuestionRequest, + type WidgetJob, +} from "@/mend/tui/widgets-runtime" + +test("reasoning indicator labels the actual last request and distinguishes manual selection", () => { + const state = { + sessionID: "session1", + mode: "auto" as const, + effort: "medium", + reason: "balanced", + modelID: "model", + providerID: "provider", + messageID: "message", + requestedAt: 1, + } + expect(widgetReasoningLabel(state)).toBe("Last request: Auto medium") + expect(widgetReasoningLabel({ ...state, mode: "manual", effort: "high" })).toBe("Last request: Manual high") +}) + +const theme = { + text: RGBA.fromHex("#ffffff"), + textMuted: RGBA.fromHex("#999999"), + accent: RGBA.fromHex("#00ff00"), + backgroundPanel: RGBA.fromHex("#000000"), +} +const request: AsyncQuestionRequest = { + id: "question1", + sessionID: "session1", + async: true, + questions: [ + { + question: "Which verification should run next?", + header: "Verify", + options: [{ label: "Tests", description: "Run tests" }], + }, + ], +} + +test("async questions stay separate from blocking requests", () => { + const groups = splitWidgetQuestions([request, { ...request, id: "blocking", async: undefined }]) + expect(groups.blocking.map((item) => item.id)).toEqual(["blocking"]) + expect(groups.asynchronous.map((item) => item.id)).toEqual(["question1"]) +}) + +test("async answer is explicit and job completion updates the widget", async () => { + const answers: string[] = [] + const cancelled: string[] = [] + const [jobs, setJobs] = createSignal([ + { id: "job1", sessionID: "session1", kind: "job", status: "running", data: { tool: "grep" }, timeUpdated: 1 }, + ]) + const app = await testRender( + () => ( + + answers.push(id)} + /> + cancelled.push(id)} /> + + + ), + { width: 42, height: 8 }, + ) + try { + await app.renderOnce() + await app.renderOnce() + expect(answers).toEqual([]) + expect(app.captureCharFrame()).toContain("Questions · 1 pending") + expect(app.captureCharFrame()).toContain("Tools · 1 active") + const answer = app.renderer.root.findDescendantById("async-question-answer-question1")! + expect(answer.x + answer.width).toBeLessThanOrEqual(42) + await app.mockMouse.click(answer.x + 1, answer.y) + expect(answers).toEqual(["question1"]) + const cancel = app.renderer.root.findDescendantById("async-job-cancel-job1")! + expect(cancel.x + cancel.width).toBeLessThanOrEqual(42) + await app.mockMouse.click(cancel.x + 1, cancel.y) + expect(cancelled).toEqual(["job1"]) + setJobs((current) => current.map((job) => ({ ...job, status: "completed" }))) + await app.renderOnce() + expect(app.captureCharFrame()).toContain("Tools · 0 active") + expect(app.captureCharFrame()).toContain("grep · completed") + expect(app.captureCharFrame()).not.toContain("[cancel]") + expect(app.captureCharFrame()).toContain("Unsent draft") + } finally { + app.renderer.destroy() + } +}) diff --git a/src/mendcode/packages/opencode/test/cli/tui/widgets-tray.test.tsx b/src/mendcode/packages/opencode/test/cli/tui/widgets-tray.test.tsx new file mode 100644 index 00000000..f2a5c7ed --- /dev/null +++ b/src/mendcode/packages/opencode/test/cli/tui/widgets-tray.test.tsx @@ -0,0 +1,72 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { RGBA, type ScrollBoxRenderable } from "@opentui/core" +import { testRender } from "@opentui/solid" +import { createSignal } from "solid-js" +import { SessionWidgetTray } from "@/mend/tui/widgets-tray" + +const theme = { backgroundElement: RGBA.fromHex("#000000"), border: RGBA.fromHex("#999999") } + +test("focused widget tray scrolls horizontally without moving the transcript and reacts to resize", async () => { + const [width, setWidth] = createSignal(40) + let focused = 0 + const app = await testRender( + () => ( + + + {Array.from({ length: 20 }, (_, index) => `Transcript line ${index}`).join("\n")} + + { + focused++ + }} + > + + + Commands + + + Second widget + + + + + + ), + { width: 110, height: 10 }, + ) + try { + await app.renderOnce() + await app.renderOnce() + const tray = app.renderer.root.findDescendantById("session-widget-tray") as ScrollBoxRenderable + const transcript = app.renderer.root.findDescendantById("transcript") as ScrollBoxRenderable + transcript.scrollTo(10) + await app.renderOnce() + const transcriptOffset = transcript.scrollTop + expect(transcriptOffset).toBeGreaterThan(0) + expect(focused).toBe(1) + expect(tray.focused).toBe(true) + expect(app.captureCharFrame()).not.toContain("Second widget") + for (let i = 0; i < 4; i++) app.mockInput.pressArrow("right") + await app.renderOnce() + expect(tray.scrollLeft).toBeGreaterThan(0) + expect(app.captureCharFrame()).toContain("Second widget") + expect(transcript.scrollTop).toBe(transcriptOffset) + expect(app.captureCharFrame()).toContain("Transcript line 10") + expect(app.captureCharFrame()).toContain("Unsent draft") + setWidth(100) + await app.renderOnce() + await app.renderOnce() + expect(tray.scrollLeft).toBe(0) + expect(app.captureCharFrame()).toContain("Commands") + expect(app.captureCharFrame()).toContain("Second widget") + expect(focused).toBe(1) + } finally { + app.renderer.destroy() + } +}) diff --git a/src/mendcode/packages/opencode/test/cli/upgrade-channel.test.ts b/src/mendcode/packages/opencode/test/cli/upgrade-channel.test.ts new file mode 100644 index 00000000..7df1e128 --- /dev/null +++ b/src/mendcode/packages/opencode/test/cli/upgrade-channel.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "bun:test" +import yargs from "yargs" +import { UpgradeCommand } from "../../src/cli/cmd/upgrade" +import { readChannel, writeChannel } from "../../src/installation/release-channel" + +test("channel subcommands persist preference without running the upgrade handler", async () => { + let upgrades = 0 + const cli = () => yargs().exitProcess(false).command({ ...UpgradeCommand, handler: async () => { upgrades++ } }) + try { + await cli().parseAsync(["upgrade", "channel", "set", "beta"]) + expect(await readChannel()).toBe("beta") + await cli().parseAsync(["upgrade", "channel"]) + expect(upgrades).toBe(0) + await cli().parseAsync(["upgrade", "0.1.43"]) + expect(upgrades).toBe(1) + expect(await readChannel()).toBe("beta") + } finally { + await writeChannel("stable") + } +}) diff --git a/src/mendcode/packages/opencode/test/cli/upgrade-readonly.test.ts b/src/mendcode/packages/opencode/test/cli/upgrade-readonly.test.ts new file mode 100644 index 00000000..0d986e6d --- /dev/null +++ b/src/mendcode/packages/opencode/test/cli/upgrade-readonly.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test" +import { existsSync } from "node:fs" +import path from "node:path" +import { commandOwnsStartupMigration } from "../../src/storage/startup-migration" + +test("maintenance CLI leaves an unmigrated database and legacy marker untouched", async () => { + const base = path.join(process.env.XDG_DATA_HOME!, "readonly-upgrade") + const db = path.join(base, "share", "mendcode.db") + const child = Bun.spawn([process.execPath, "run", "--conditions=browser", "src/index.ts", "upgrade", "channel"], { + cwd: path.resolve(import.meta.dir, "../.."), + env: { ...process.env, MENDCODE_DB: db, OPENCODE_DB: db, + XDG_DATA_HOME: path.join(base, "share"), XDG_CONFIG_HOME: path.join(base, "config"), + XDG_STATE_HOME: path.join(base, "state"), XDG_CACHE_HOME: path.join(base, "cache"), + HOME: path.join(base, "home"), OPENCODE_TEST_HOME: path.join(base, "home"), + }, + stdout: "pipe", stderr: "pipe", + }) + const timer = setTimeout(() => child.kill(), 15_000) + try { + const [code, stdout, stderr] = await Promise.all([ + child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), + ]) + expect(code).toBe(0) + expect(stdout + stderr).toContain("Release channel: stable") + expect(stderr).not.toContain("sqlite-migration") + expect(existsSync(db)).toBe(false) + expect(existsSync(path.join(base, "share", "mendcode", ".mendcode-json-storage-migration-v0.done"))).toBe(false) + } finally { clearTimeout(timer) } +}, 20_000) + +test("TUI clients defer migration to the backend; backend and local data commands retain migration", () => { + for (const command of [undefined, "attach", "upgrade", "models", "providers", "run", "stats"]) { + expect(commandOwnsStartupMigration(command)).toBe(false) + } + for (const command of ["serve", "import", "export", "session"]) { + expect(commandOwnsStartupMigration(command)).toBe(true) + } +}) diff --git a/src/mendcode/packages/opencode/test/installation/install-layout.test.ts b/src/mendcode/packages/opencode/test/installation/install-layout.test.ts index e60d7716..58a40c39 100644 --- a/src/mendcode/packages/opencode/test/installation/install-layout.test.ts +++ b/src/mendcode/packages/opencode/test/installation/install-layout.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test" -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "fs" +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, writeFileSync } from "fs" import os from "os" import path from "path" +import { createHash } from "node:crypto" const installer = path.resolve(import.meta.dir, "../../../../install") const roots: string[] = [] @@ -36,7 +37,26 @@ function fixture() { chmodSync(path.join(tools, "uname"), 0o755) writeFileSync( path.join(tools, "curl"), - '#!/usr/bin/env bash\nout=""\nwhile (($#)); do\n if [[ "$1" == "-o" ]]; then out=$2; shift 2; continue; fi\n shift\ndone\nif [[ -n "$out" ]]; then cp "${MENDCODE_TEST_ARCHIVE:?}" "$out"; else printf \'{"tag_name":"v9.9.9"}\\n\'; fi\n', + `#!/usr/bin/env bash +out="" +url="" +while (($#)); do + if [[ "$1" == "-o" ]]; then out=$2; shift 2; continue; fi + url=$1 + shift +done +if [[ "$url" == */SHA256SUMS ]]; then + hash=$(shasum -a 256 "$MENDCODE_TEST_ARCHIVE" | awk '{print $1}') + if [[ "\${MENDCODE_TEST_FAILURE:-}" == checksum ]]; then hash=$(printf '%064d' 0); fi + printf '%s ./mendcode-linux-x64.tar.gz\\n%s ./mendcode-linux-x64-baseline.tar.gz\\n%s ./mendcode-darwin-arm64.zip\\n' "$hash" "$hash" "$hash" > "$out" +elif [[ -n "$out" ]]; then + if [[ "\${MENDCODE_TEST_FAILURE:-}" == http ]]; then exit 22; fi + cp "$MENDCODE_TEST_ARCHIVE" "$out" + if [[ "\${MENDCODE_TEST_FAILURE:-}" == truncated ]]; then printf truncated > "$out"; fi +else + printf '{"tag_name":"v9.9.9"}\\n' +fi +`, ) chmodSync(path.join(tools, "curl"), 0o755) writeFileSync(path.join(tools, "launchctl"), "#!/usr/bin/env bash\nexit 0\n") @@ -54,6 +74,8 @@ function run( source?: "local" | "latest" platform?: "Linux" | "Darwin" arch?: "x86_64" | "arm64" + failure?: "http" | "checksum" | "truncated" + recoverLock?: boolean } = {}, ) { const env = { ...process.env } @@ -72,12 +94,14 @@ function run( env.MENDCODE_TEST_OS = input.platform ?? "Linux" env.MENDCODE_TEST_ARCH = input.arch ?? "x86_64" env.MENDCODE_TEST_ARCHIVE = input.platform === "Darwin" ? item.zipArchive : item.tarArchive + env.MENDCODE_TEST_FAILURE = input.failure const sourceArgs = input.source === "latest" ? [] : ["--binary", item.binary] return Bun.spawnSync({ cmd: [ "bash", installer, ...sourceArgs, + ...(input.recoverLock ? ["--recover-lock"] : []), "--no-modify-path", input.setup ? "--setup" : "--skip-setup", ], @@ -92,10 +116,87 @@ function selection(item: ReturnType) { } afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) + const trash = path.join(os.homedir(), ".Trash") + mkdirSync(trash, { recursive: true }) + for (const root of roots.splice(0)) renameSync(root, path.join(trash, path.basename(root))) }) describe("installer global layout", () => { + test("recovery never executes the existing broken binary", () => { + const item = fixture() + const installed = path.join(item.home, ".mendcode", "bin", "mendcode") + mkdirSync(path.dirname(installed), { recursive: true }) + writeFileSync(installed, '#!/bin/sh\nprintf invoked > "${MENDCODE_TEST_SETUP_LOG:?}"\nexit 1\n') + chmodSync(installed, 0o755) + expect(run(item, { source: "latest" }).exitCode).toBe(0) + expect(() => readFileSync(item.setupLog)).toThrow() + }) + + test("an existing update lock prevents binary replacement", () => { + const item = fixture() + const bin = path.join(item.home, ".mendcode", "bin") + const lock = path.join(bin, ".update-lock") + mkdirSync(lock, { recursive: true }) + writeFileSync(path.join(lock, "owner"), `pid=${process.pid}\n`) + writeFileSync(path.join(bin, "mendcode"), "original") + const result = run(item) + expect(result.exitCode).not.toBe(0) + expect(result.stdout.toString()).toContain("Another update owns") + expect(readFileSync(path.join(bin, "mendcode"), "utf8")).toBe("original") + expect(readFileSync(path.join(lock, "owner"), "utf8")).toBe(`pid=${process.pid}\n`) + expect(run(item, { recoverLock: true }).exitCode).not.toBe(0) + expect(readFileSync(path.join(bin, "mendcode"), "utf8")).toBe("original") + }) + + test("explicit recovery preserves a dead owner's lock and installs", () => { + const item = fixture() + const child = Bun.spawnSync(["sh", "-c", "echo $$"]) + const deadPid = Number(child.stdout.toString().trim()) + expect(deadPid).toBeGreaterThan(0) + const bin = path.join(item.home, ".mendcode", "bin") + const lock = path.join(bin, ".update-lock") + mkdirSync(lock, { recursive: true }) + writeFileSync(path.join(lock, "owner"), `pid=${deadPid}\n`) + expect(run(item, { recoverLock: true }).exitCode).toBe(0) + expect(readdirSync(bin)).not.toContain(".update-lock") + const operation = readdirSync(bin).find((name) => name.startsWith(".update."))! + expect(readFileSync(path.join(bin, operation, "recovered-lock", "owner"), "utf8")).toBe(`pid=${deadPid}\n`) + }) + + test.each(["http", "checksum", "truncated"] as const)("preserves installed executable after %s failure", (failure) => { + const item = fixture() + const installed = path.join(item.home, ".mendcode", "bin", "mendcode") + mkdirSync(path.dirname(installed), { recursive: true }) + writeFileSync(installed, "#!/bin/sh\necho 0.1.42\n") + chmodSync(installed, 0o755) + const before = readFileSync(installed) + const result = run(item, { source: "latest", failure }) + expect(result.exitCode).not.toBe(0) + expect(readFileSync(installed)).toEqual(before) + expect(result.stdout.toString()).toContain("preserved") + expect(() => selection(item)).toThrow() + const bin = path.dirname(installed) + expect(readdirSync(bin)).not.toContain(".update-lock") + const operation = readdirSync(bin).find((name) => name.startsWith(".update."))! + const status = readFileSync(path.join(bin, operation, "status"), "utf8") + expect(status).toContain(failure === "http" ? "phase=downloading" : "phase=verifying") + expect(status).toContain("exit_code=1") + }) + + test("activation retains the prior binary on the destination filesystem", () => { + const item = fixture() + const installed = path.join(item.home, ".mendcode", "bin", "mendcode") + mkdirSync(path.dirname(installed), { recursive: true }) + writeFileSync(installed, "#!/bin/sh\necho 0.1.42\n") + chmodSync(installed, 0o755) + const before = readFileSync(installed) + expect(run(item, { source: "latest" }).exitCode).toBe(0) + const operation = readdirSync(path.dirname(installed)).find((name) => name.startsWith(".update."))! + expect(readFileSync(path.join(path.dirname(installed), operation, "previous"))).toEqual(before) + expect(readFileSync(path.join(path.dirname(installed), operation, "status"), "utf8")).toContain("phase=activated") + const digest = createHash("sha256").update(readFileSync(installed)).digest("hex") + expect(readFileSync(path.join(path.dirname(installed), operation, "status"), "utf8")).toContain(`binary_sha256=${digest}`) + }) test("separates a new MendCode install from existing external OpenCode data and scopes immediate setup", () => { const item = fixture() const legacy = path.join(item.data, "opencode") diff --git a/src/mendcode/packages/opencode/test/installation/installation.test.ts b/src/mendcode/packages/opencode/test/installation/installation.test.ts index b849d33a..2eadbbe8 100644 --- a/src/mendcode/packages/opencode/test/installation/installation.test.ts +++ b/src/mendcode/packages/opencode/test/installation/installation.test.ts @@ -3,6 +3,9 @@ import { Effect, Layer, PlatformError, Stream } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { Installation } from "../../src/installation" +import { writeChannel } from "../../src/installation/release-channel" +import { createHash } from "node:crypto" +import { supportedMigrationJournal } from "../../src/storage/migration-journal" const encoder = new TextEncoder() @@ -11,9 +14,27 @@ function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest) return Layer.succeed(HttpClient.HttpClient, client) } +function installerResponse(request: HttpClientRequest.HttpClientRequest) { + if (request.url.includes("/commits/")) return jsonResponse({ sha: "a".repeat(40) }) + if (request.url.endsWith("/release-index.json")) { + const version = /\/download\/v([^/]+)\//.exec(request.url)![1] + const journal = supportedMigrationJournal() + const hash = (text: string) => createHash("sha256").update(text).digest("hex") + return jsonResponse({ formatVersion: 1, version, channel: "stable", tag: `v${version}`, + commit: "a".repeat(40), repository: "MendCode/MendCode", provenance: { workflow: ".github/workflows/release.yml", runID: "1" }, + installer: { path: "src/mendcode/install", commit: "a".repeat(40), sha256: hash("#!/usr/bin/env bash\n") }, + windowsInstaller: { path: "src/mendcode/install.ps1", commit: "a".repeat(40), sha256: hash("#!/usr/bin/env bash\n") }, + schema: { journal, fingerprint: hash(JSON.stringify(journal)) }, + assets: [{ name: "mendcode-darwin-arm64.zip", platform: "darwin-arm64", size: 1, sha256: "b".repeat(64) }], + }) + } + return new Response("#!/usr/bin/env bash\n") +} + function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "", spawnError?: Error) { const spawner = ChildProcessSpawner.make((command) => { - if (spawnError) { + const std = ChildProcess.isStandardCommand(command) ? command : undefined + if (spawnError && std?.command !== "gh") { return Effect.fail( PlatformError.systemError({ _tag: "NotFound", @@ -24,7 +45,6 @@ function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = }), ) } - const std = ChildProcess.isStandardCommand(command) ? command : undefined const output = handler(std?.command ?? "", std?.args ?? []) return Effect.succeed( ChildProcessSpawner.makeHandle({ @@ -84,6 +104,16 @@ describe("installation", () => { }) describe("latest", () => { + test("selects the highest channel version across publication-order pages", async () => { + await writeChannel("beta") + try { + const layer = testLayer((request) => jsonResponse(request.url.endsWith("page=1") + ? Array.from({ length: 100 }, (_, i) => ({ tag_name: i === 0 ? "v0.1.44-beta.1" : "v0.1.43", prerelease: i === 0 })) + : [{ tag_name: "v0.1.44-beta.3", prerelease: true }])) + const result = await Effect.runPromise(Installation.Service.use((svc) => svc.latest("curl")).pipe(Effect.provide(layer))) + expect(result).toBe("0.1.44-beta.3") + } finally { await writeChannel("stable") } + }) test("reads release version from GitHub releases", async () => { const layer = testLayer(() => jsonResponse({ tag_name: "v1.2.3" })) @@ -93,13 +123,13 @@ describe("installation", () => { expect(result).toBe("1.2.3") }) - test("strips v prefix from GitHub release tag", async () => { - const layer = testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" })) + test("strips v prefix from stable GitHub release tag", async () => { + const layer = testLayer(() => jsonResponse({ tag_name: "v4.0.0" })) const result = await Effect.runPromise( Installation.Service.use((svc) => svc.latest("curl")).pipe(Effect.provide(layer)), ) - expect(result).toBe("4.0.0-beta.1") + expect(result).toBe("4.0.0") }) test.each(["npm", "bun", "pnpm", "scoop", "choco", "brew"] as const)( @@ -121,10 +151,44 @@ describe("installation", () => { }) describe("upgrade", () => { + test("delivers installer phases to the caller while consuming output", async () => { + const phases: string[] = [] + const layer = testLayer(installerResponse, (cmd, args) => + cmd === process.execPath && args[0] === "--version" + ? "0.1.25" + : "MENDCODE_UPDATE_PHASE=downloading\nMENDCODE_UPDATE_PHASE=verifying\n", + ) + await Effect.runPromise(Installation.Service.use((svc) => svc.upgrade("curl", "0.1.25", (phase) => phases.push(phase))) + .pipe(Effect.provide(layer))) + expect(phases).toEqual(["checking", "downloading", "verifying"]) + }) + + test("resolves a release to an immutable installer revision before spawning", async () => { + const urls: string[] = [] + const layer = testLayer((request) => { + urls.push(request.url) + return installerResponse(request) + }, (cmd, args) => cmd === process.execPath && args[0] === "--version" ? "0.1.25" : "") + await Effect.runPromise(Installation.Service.use((svc) => svc.upgrade("curl", "0.1.25")).pipe(Effect.provide(layer))) + expect(urls).toEqual([ + "https://github.com/MendCode/MendCode/releases/download/v0.1.25/release-index.json", + "https://api.github.com/repos/MendCode/MendCode/commits/v0.1.25", + `https://raw.githubusercontent.com/MendCode/MendCode/${"a".repeat(40)}/src/mendcode/install`, + ]) + }) + + test("rejects an invalid release revision without running an installer", async () => { + let spawned = false + const layer = testLayer((request) => request.url.includes("/commits/") ? jsonResponse({ sha: "main" }) : installerResponse(request), (cmd) => { if (cmd !== "gh") spawned = true; return "" }) + await expect(Effect.runPromise(Installation.Service.use((svc) => svc.upgrade("curl", "0.1.25")).pipe(Effect.provide(layer)))) + .rejects.toMatchObject({ stderr: expect.any(String) }) + expect(spawned).toBe(false) + }) + test("runs curl upgrades without opening setup inside the active TUI", async () => { const calls: Array<{ cmd: string; args: readonly string[] }> = [] const layer = testLayer( - () => new Response("#!/usr/bin/env bash\n"), + installerResponse, (cmd, args) => { calls.push({ cmd, args }) if (cmd === process.execPath && args.length === 1 && args[0] === "--version") return "0.1.25" @@ -136,7 +200,7 @@ describe("installation", () => { Installation.Service.use((svc) => svc.upgrade("curl", "0.1.25")).pipe(Effect.provide(layer)), ) - expect(calls[0]).toEqual({ + expect(calls.find((call) => /(?:^|[\\/])bash(?:\.exe)?$/.test(call.cmd))).toEqual({ cmd: expect.stringMatching(/(?:^|[\\/])bash(?:\.exe)?$/), args: ["-s", "--", "--version", "0.1.25", "--no-modify-path", "--skip-setup"], }) @@ -144,7 +208,7 @@ describe("installation", () => { test("turns a missing updater process into an actionable error", async () => { const layer = Installation.layer.pipe( - Layer.provide(mockHttpClient(() => new Response("#!/usr/bin/env bash\n"))), + Layer.provide(mockHttpClient(installerResponse)), Layer.provide(mockSpawner(() => "", new Error("spawn bash ENOENT"))), ) @@ -163,7 +227,7 @@ describe("installation", () => { test("rejects an upgrade when the installed binary reports another version", async () => { const layer = testLayer( - () => new Response("#!/usr/bin/env bash\n"), + installerResponse, (cmd, args) => (cmd === process.execPath && args.length === 1 && args[0] === "--version" ? "0.1.24" : ""), ) diff --git a/src/mendcode/packages/opencode/test/installation/progress.test.ts b/src/mendcode/packages/opencode/test/installation/progress.test.ts new file mode 100644 index 00000000..86e16c07 --- /dev/null +++ b/src/mendcode/packages/opencode/test/installation/progress.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test" +import { updateLabel, updateProgress, type UpdatePhase, type DownloadProgress } from "../../src/installation/progress" + +test("streams phases across chunk boundaries and never treats installer output as readiness", () => { + const phases: UpdatePhase[] = [] + const consume = updateProgress((phase) => phases.push(phase)) + consume("download log\nMENDCODE_UPDATE_PHA") + consume("SE=downloading\nMENDCODE_UPDATE_PHASE=downloading\n") + consume("MENDCODE_UPDATE_PHASE=ready\nMENDCODE_UPDATE_PHASE=verifying\n") + consume("MENDCODE_UPDATE_PHASE=activated\n") + expect(phases).toEqual(["downloading", "verifying", "activated"]) +}) + +test("a broken progress observer cannot interrupt activation", () => { + const consume = updateProgress(() => { throw new Error("observer unavailable") }) + expect(() => consume("MENDCODE_UPDATE_PHASE=activating\n")).not.toThrow() +}) + +test("download bytes are bounded, monotonic and only accepted while downloading", () => { + const updates: DownloadProgress[] = [] + const consume = updateProgress((_, progress) => { if (progress) updates.push(progress) }) + consume("MENDCODE_UPDATE_BYTES=1/2\nMENDCODE_UPDATE_PHASE=downloading\nMENDCODE_UPDATE_BYTES=1048576/") + consume("2097152\nMENDCODE_UPDATE_BYTES=1/2\nMENDCODE_UPDATE_BYTES=1048576/2097152\n") + consume("MENDCODE_UPDATE_BYTES=999999999999999999999/\nMENDCODE_UPDATE_BYTES=2097152/1\n") + consume("MENDCODE_UPDATE_BYTES=2097152/\nMENDCODE_UPDATE_PHASE=verifying\nMENDCODE_UPDATE_BYTES=4194304/\n") + expect(updates).toEqual([{ bytes: 1048576, total: 2097152 }, { bytes: 2097152, total: undefined }]) + expect(updateLabel("downloading", updates[0])).toBe("Downloading · 1.0 MiB / 2.0 MiB (50%)") + expect(updateLabel("downloading", updates[1])).toBe("Downloading · 2.0 MiB") +}) diff --git a/src/mendcode/packages/opencode/test/installation/release-channel.test.ts b/src/mendcode/packages/opencode/test/installation/release-channel.test.ts new file mode 100644 index 00000000..2af78ffd --- /dev/null +++ b/src/mendcode/packages/opencode/test/installation/release-channel.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test" +import { parseChannel, selectRelease } from "../../src/installation/release-channel" + +const releases = [ + { tag_name: "v0.2.0-nightly.20260905.1", prerelease: true }, + { tag_name: "v0.2.0-beta.2", prerelease: true }, + { tag_name: "v0.2.0-beta.10", prerelease: true }, + { tag_name: "v0.1.43", prerelease: false }, + { tag_name: "v9.0.0", draft: true }, + { tag_name: "invalid" }, +] + +test("channels never select another prerelease track or a draft", () => { + expect(selectRelease(releases, "stable")).toBe("0.1.43") + expect(selectRelease(releases, "beta")).toBe("0.2.0-beta.10") + expect(selectRelease(releases, "nightly")).toBe("0.2.0-nightly.20260905.1") + expect(selectRelease([{ tag_name: "v0.1.43" }], "beta")).toBeUndefined() + expect(selectRelease([{ tag_name: "v0.2.0-beta.1", prerelease: false }], "stable")).toBeUndefined() +}) + +test("invalid channel cannot silently select stable", () => { + expect(() => parseChannel("preview")).toThrow("stable, beta, or nightly") +}) diff --git a/src/mendcode/packages/opencode/test/installation/release-index.test.ts b/src/mendcode/packages/opencode/test/installation/release-index.test.ts new file mode 100644 index 00000000..7b170e3e --- /dev/null +++ b/src/mendcode/packages/opencode/test/installation/release-index.test.ts @@ -0,0 +1,92 @@ +import { afterAll, expect, test } from "bun:test" +import { createHash, randomUUID } from "node:crypto" +import { mkdtemp, mkdir, rename, readFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { validateReleaseIndex, verifyReleaseIndex, verifyInstallerBytes, type ReleaseIndex } from "../../src/installation/release-index" +import { supportedMigrationJournal } from "../../src/storage/migration-journal" + +const digest = (text: string) => createHash("sha256").update(text).digest("hex") +const roots: string[] = [] +async function directory() { const root = await mkdtemp(path.join(os.tmpdir(), "mendcode-index-test-")); roots.push(root); return root } +afterAll(async () => { + const trash = path.join(os.homedir(), ".Trash") + await mkdir(trash, { recursive: true }) + for (const root of roots) await rename(root, path.join(trash, `${path.basename(root)}-${randomUUID()}`)) +}) + +function fixture(): ReleaseIndex { + const journal = [{ name: "20260803032053_dynamic_workflows", timestamp: Date.UTC(2026, 7, 3, 3, 20, 53), hash: "d".repeat(64) }] + return { formatVersion: 1, version: "0.1.44-beta.1", channel: "beta", tag: "v0.1.44-beta.1", commit: "a".repeat(40), repository: "MendCode/MendCode", + provenance: { workflow: ".github/workflows/release.yml", runID: "123" }, + installer: { path: "src/mendcode/install", commit: "a".repeat(40), sha256: digest("installer") }, + windowsInstaller: { path: "src/mendcode/install.ps1", commit: "a".repeat(40), sha256: digest("windows installer") }, + schema: { journal, fingerprint: digest(JSON.stringify(journal)) }, + assets: [{ name: "mendcode-darwin-arm64.zip", platform: "darwin-arm64", size: 1024, sha256: "b".repeat(64) }], + } +} +function fetcher(index: ReleaseIndex = fixture(), source = index.commit) { + return (async (url: string | URL | Request) => { + if (String(url).endsWith("/release-index.json")) return Response.json(index) + if (String(url).startsWith("https://api.github.com/repos/MendCode/MendCode/commits/")) return Response.json({ sha: source }) + throw new Error("Unexpected request") + }) as typeof fetch +} + +test("verified index returns only commit-pinned installer and authenticated checksums", async () => { + const calls: string[][] = [] + const output = await verifyReleaseIndex({ version: fixture().version, directory: await directory(), fetch: fetcher(), + run: async (command, args, options) => { calls.push([command, ...args]); expect(options.timeoutMs).toBe(30_000); return { exitCode: 0 } }, + }) + expect(output.installerURL).toBe(`https://raw.githubusercontent.com/MendCode/MendCode/${fixture().commit}/src/mendcode/install`) + expect(output.checksums).toEqual({ "mendcode-darwin-arm64.zip": "b".repeat(64) }) + expect(output.windowsInstallerURL).toEndWith(`/${fixture().commit}/src/mendcode/install.ps1`) + verifyInstallerBytes(Buffer.from("windows installer"), output.windowsInstallerSHA256) + expect(calls).toHaveLength(1) + expect(calls[0]).toContain("--deny-self-hosted-runners") + expect(calls[0]).toContain("MendCode/MendCode/.github/workflows/release.yml") + expect(JSON.parse(await readFile(output.file, "utf8"))).toEqual(fixture()) + verifyInstallerBytes(Buffer.from("installer"), output.installerSHA256) + expect(() => verifyInstallerBytes(Buffer.from("tampered"), output.installerSHA256)).toThrow("checksum") +}) + +test("rejects mismatched source and attestation failures without an installer execution", async () => { + let calls = 0 + const input = { version: fixture().version, directory: await directory(), fetch: fetcher(), run: async (command: string) => { expect(command).toBe("gh"); calls++; return { exitCode: 1 } } } + await expect(verifyReleaseIndex(input)).rejects.toThrow("provenance verification failed") + await expect(verifyReleaseIndex({ ...input, fetch: fetcher(fixture(), "c".repeat(40)), run: async () => ({ exitCode: 0 }) })).rejects.toThrow("source commit") + await expect(verifyReleaseIndex({ ...input, run: async () => { throw Object.assign(new Error("missing"), { code: "ENOENT" }) } })).rejects.toThrow("GitHub CLI") + expect(calls).toBe(1) +}) + +test("legacy missing metadata and over-limit bodies fail closed before subprocess", async () => { + const input = { version: fixture().version, directory: await directory(), run: async () => { throw new Error("must not execute") } } + await expect(verifyReleaseIndex({ ...input, fetch: async () => new Response("missing", { status: 404 }) })).rejects.toThrow("legacy") + await expect(verifyReleaseIndex({ ...input, fetch: async () => new Response("x".repeat(512 * 1024 + 1)) })).rejects.toThrow("size limit") + await expect(verifyReleaseIndex({ ...input, fetch: async () => new Response("bad JSON") })).rejects.toThrow() +}) + +test("validates schema fingerprint, identity, assets, channels and repository", () => { + for (const modify of [ + (value: ReleaseIndex) => { value.schema.fingerprint = "c".repeat(64) }, + (value: ReleaseIndex) => { value.schema.journal[0].timestamp++ }, + (value: ReleaseIndex) => { value.assets[0].name = "../mendcode-darwin-arm64.zip" }, + (value: ReleaseIndex) => { value.assets.push(value.assets[0]) }, + (value: ReleaseIndex) => { value.channel = "stable" }, + (value: ReleaseIndex) => { value.installer.commit = "c".repeat(40) }, + (value: ReleaseIndex) => { value.windowsInstaller.commit = "c".repeat(40) }, + (value: ReleaseIndex) => { value.assets[0].platform = "linux-x64" }, + ]) { const value = fixture(); modify(value); expect(() => validateReleaseIndex(value, { version: fixture().version })).toThrow() } + expect(() => validateReleaseIndex({ ...fixture(), repository: "other/repo" }, { version: fixture().version })).toThrow() +}) + +test("accepts the actual supported migration journal including historical hyphenated names", async () => { + const value = fixture() + const journal = supportedMigrationJournal() + value.schema = { journal, fingerprint: digest(JSON.stringify(journal)) } + expect(journal.some((entry) => entry.name.includes("workspace-name"))).toBe(true) + expect(validateReleaseIndex(value, { version: value.version }).schema.journal).toEqual(journal) + const { makeIndex } = await import(path.join(import.meta.dir, "../../../../script/release-index.mjs")) + const generated = makeIndex({ ...value, journal, runID: value.provenance.runID }) + expect(validateReleaseIndex(generated, { version: value.version }).schema).toEqual(value.schema) +}) diff --git a/src/mendcode/packages/opencode/test/installation/rollback.test.ts b/src/mendcode/packages/opencode/test/installation/rollback.test.ts new file mode 100644 index 00000000..70ff6ede --- /dev/null +++ b/src/mendcode/packages/opencode/test/installation/rollback.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { createHash } from "node:crypto" +import { Database } from "bun:sqlite" +import { tmpdir } from "../fixture/fixture" +import { rollback, rollbackTarget } from "../../src/installation/rollback" +import { acquireDatabaseMaintenance } from "../../src/storage/compatibility" + +const hash = (text: string) => createHash("sha256").update(text).digest("hex") +const journal = [{ name: "20260904000000_base", timestamp: 1, hash: hash("base") }] + +async function installation(root: string) { + const executable = path.join(root, "mendcode") + await fs.writeFile(executable, "current binary") + const previous = path.join(root, ".update.old") + const current = path.join(root, ".update.current") + await fs.mkdir(previous) + await fs.mkdir(current) + await fs.writeFile(path.join(previous, "status"), `version=0.1.44\nphase=activated\nbinary_sha256=${hash("old binary")}\n`) + await fs.writeFile(path.join(previous, "compatibility.json"), JSON.stringify({ + formatVersion: 1, version: "0.1.44", digest: hash("old binary"), journal, + })) + await fs.writeFile(path.join(current, "status"), `version=0.1.45\nphase=activated\nbinary_sha256=${hash("current binary")}\nprevious_sha256=${hash("old binary")}\n`) + await fs.writeFile(path.join(current, "previous"), "old binary") + return { executable, current, previous } +} + +test("rollback verifies retained identity, holds maintenance through activation and preserves the current executable", async () => { + await using tmp = await tmpdir() + const { executable } = await installation(tmp.path) + let released = false + const result = await rollback({ executable, version: "0.1.45", maintain: async (metadata) => { + expect(metadata.version).toBe("0.1.44") + expect(await fs.readFile(executable, "utf8")).toBe("current binary") + return async () => { + expect(await fs.readFile(executable, "utf8")).toBe("old binary") + released = true + } + } }) + expect(released).toBe(true) + expect(result.version).toBe("0.1.44") + expect(await fs.readFile(path.join(result.operation, "previous"), "utf8")).toBe("current binary") + expect(await fs.readFile(path.join(result.operation, "status"), "utf8")).toContain("phase=activated") + expect(await fs.readdir(tmp.path)).not.toContain(".update-lock") +}) + +test("a previous binary without compatibility history cannot be executed or activated", async () => { + await using tmp = await tmpdir() + const { executable, previous } = await installation(tmp.path) + await fs.rename(path.join(previous, "compatibility.json"), path.join(previous, "compatibility.unavailable")) + await expect(rollbackTarget(executable, "0.1.45")).rejects.toThrow("Compatibility of the previous executable is unknown") + expect(await fs.readFile(executable, "utf8")).toBe("current binary") +}) + +test("tampered retained executable is rejected before database maintenance", async () => { + await using tmp = await tmpdir() + const { executable, current } = await installation(tmp.path) + await fs.writeFile(path.join(current, "previous"), "different binary") + let maintained = false + await expect(rollback({ executable, version: "0.1.45", maintain: async () => { + maintained = true + return () => {} + } })).rejects.toThrow("checksum changed") + expect(maintained).toBe(false) + expect(await fs.readFile(executable, "utf8")).toBe("current binary") +}) + +test("incompatible data blocks rollback without changing database or binary bytes", async () => { + await using tmp = await tmpdir() + const { executable } = await installation(tmp.path) + const file = path.join(tmp.path, "future.db") + const db = new Database(file) + db.run("CREATE TABLE __drizzle_migrations(name TEXT, created_at INTEGER)") + db.run("INSERT INTO __drizzle_migrations VALUES ('future', 2)") + db.close() + const before = await fs.readFile(file) + await expect(rollback({ executable, version: "0.1.45", maintain: async ({ journal }) => { + const maintenance = acquireDatabaseMaintenance(file, journal) + return () => maintenance.release() + } })).rejects.toThrow("Unsupported database migration") + expect(await fs.readFile(file)).toEqual(before) + expect(await fs.readFile(executable, "utf8")).toBe("current binary") +}) + +test("an active updater lock prevents rollback without reclaiming its owner", async () => { + await using tmp = await tmpdir() + const { executable } = await installation(tmp.path) + await fs.mkdir(path.join(tmp.path, ".update-lock")) + await expect(rollback({ executable, version: "0.1.45", maintain: async () => () => {} })).rejects.toThrow("Another update") + expect(await fs.readdir(tmp.path)).toContain(".update-lock") + expect(await fs.readFile(executable, "utf8")).toBe("current binary") +}) + +test("Windows cannot silently use POSIX binary replacement", async () => { + await expect(rollback({ executable: "unused", version: "0.1.45", platform: "win32", maintain: async () => () => {} })) + .rejects.toThrow("verified deferred replacement") +}) diff --git a/src/mendcode/packages/opencode/test/installation/startup.test.ts b/src/mendcode/packages/opencode/test/installation/startup.test.ts new file mode 100644 index 00000000..29faa1f5 --- /dev/null +++ b/src/mendcode/packages/opencode/test/installation/startup.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { createHash } from "node:crypto" +import { tmpdir } from "../fixture/fixture" +import { pendingUpdate, trackUpdateStartup, latestUpdateStartup, latestUpdateOperation } from "../../src/installation/startup" + +async function installed(root: string) { + const executable = path.join(root, "mendcode") + const operation = path.join(root, ".update.fixture") + await fs.mkdir(operation) + await fs.writeFile(executable, "verified candidate") + const digest = createHash("sha256").update("verified candidate").digest("hex") + await fs.writeFile(path.join(operation, "status"), `version=0.1.44\nphase=activated\nbinary_sha256=${digest}\n`) + return executable +} + +test("deferred installer failures remain visible even when the previous executable is still installed", async () => { + await using tmp = await tmpdir() + const executable = await installed(tmp.path) + const status = path.join(tmp.path, ".update.fixture", "status") + await fs.writeFile(status, "version=0.1.45\r\nphase=waiting-for-restart\r\nexit_code=1\r\n") + expect(await latestUpdateOperation(executable)).toMatchObject({ version: "0.1.45", phase: "waiting-for-restart", failed: true, file: status }) + expect(await pendingUpdate(executable, "0.1.44")).toBeUndefined() +}) + +test("readiness records are bound to the installed executable and release", async () => { + await using tmp = await tmpdir() + const executable = await installed(tmp.path) + expect(await pendingUpdate(executable, "0.1.43")).toBeUndefined() + expect(await pendingUpdate(executable, "0.1.44")).toBeDefined() + await fs.writeFile(executable, "other candidate") + expect(await pendingUpdate(executable, "0.1.44")).toBeUndefined() +}) + +test("only explicit client readiness changes a startup from starting to ready", async () => { + await using tmp = await tmpdir() + const executable = await installed(tmp.path) + const journal = [{ name: "baseline", timestamp: 1, hash: "a".repeat(64) }] + const attempt = (await trackUpdateStartup({ executable, version: "0.1.44", journal }))! + try { + expect(JSON.parse(await fs.readFile(attempt.file, "utf8")).state).toBe("starting") + const metadata = JSON.parse(await fs.readFile(path.join(path.dirname(attempt.file), "compatibility.json"), "utf8")) + expect(metadata).toMatchObject({ formatVersion: 1, version: "0.1.44", journal, + digest: createHash("sha256").update("verified candidate").digest("hex") }) + await attempt.ready() + await attempt.close() + expect(JSON.parse(await fs.readFile(attempt.file, "utf8")).state).toBe("ready") + expect((await latestUpdateStartup(executable, "0.1.44"))?.state).toBe("ready") + } finally { await attempt.close() } +}) + +test("a startup deadline is durable and preserves the failure reason on exit", async () => { + await using tmp = await tmpdir() + const executable = await installed(tmp.path) + const attempt = (await trackUpdateStartup({ executable, version: "0.1.44", timeoutMs: 5 }))! + try { + await new Promise((resolve) => setTimeout(resolve, 20)) + await attempt.close() + const result = JSON.parse(await fs.readFile(attempt.file, "utf8")) + expect(result.state).toBe("failed") + expect(result.error).toContain("startup deadline") + } finally { await attempt.close() } +}) + +test("a second terminal failure does not overwrite a ready client's record", async () => { + await using tmp = await tmpdir() + const executable = await installed(tmp.path) + const first = (await trackUpdateStartup({ executable, version: "0.1.44" }))! + const second = (await trackUpdateStartup({ executable, version: "0.1.44" }))! + try { + await first.ready() + await second.close("Client disconnected") + expect(first.file).not.toBe(second.file) + expect(JSON.parse(await fs.readFile(first.file, "utf8")).state).toBe("ready") + expect(JSON.parse(await fs.readFile(second.file, "utf8")).state).toBe("failed") + } finally { await first.close(); await second.close() } +}) diff --git a/src/mendcode/packages/opencode/test/mend/concurrent-model-policy.test.ts b/src/mendcode/packages/opencode/test/mend/concurrent-model-policy.test.ts new file mode 100644 index 00000000..f4de8931 --- /dev/null +++ b/src/mendcode/packages/opencode/test/mend/concurrent-model-policy.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "bun:test" +import type { ModelMessage } from "ai" +import { normalizeAstraRequest, normalizeAstraOptions, isAstraModel } from "../../src/mend/prompt/model-family" +import { resolveRuntimeCapabilities } from "../../src/mend/prompt/runtime-capabilities" +import { autoReasoningSignal, selectAutoReasoning } from "../../src/mend/prompt/reasoning-auto" +import { promptBehaviorForModel } from "../../src/mend/prompt/sources" +import { normalizeCodexChatGPTRequestBody } from "../../src/plugin/codex" +import { getReasoningState, recordReasoningState } from "../../src/mend/prompt/reasoning-state" + +test("Astra profile aliases preserve official ID and identify the versioned guidance", () => { + for (const id of ["gpt-6-astra", "openai/gpt-6-astra-fast", "GPT-6-ASTRA"]) { + expect(isAstraModel(id)).toBe(true) + expect(promptBehaviorForModel({ focusID: "codex", modelID: id })?.provenance?.revision).toBe("mendcode-astra-2026-09-04.1") + } + expect(isAstraModel("gpt-6-something-else")).toBe(false) + expect(promptBehaviorForModel({ focusID: "claude", modelID: "gpt-6-astra" })).toBeNull() +}) + +test("Astra API and subscription normalization remove unsupported sampling without enabling async", () => { + const request = { model: "gpt-6-astra", temperature: 1, top_p: 1, top_logprobs: 2, logprobs: true, + include: ["message.output_text.logprobs", "reasoning.encrypted_content"], reasoning: { effort: "minimal" }, + tools: [{ type: "function", name: "read", parameters: { type: "object" } }], + } + const expected = { model: "gpt-6-astra", include: ["reasoning.encrypted_content"], reasoning: { effort: "low" }, tools: request.tools } + expect(normalizeAstraRequest(request)).toEqual(expected) + expect(JSON.parse(normalizeCodexChatGPTRequestBody(JSON.stringify(request)) as string)).toEqual(expected) + expect(request.temperature).toBe(1) + expect(normalizeAstraOptions("gpt-6-astra", { reasoningEffort: "minimal", logprobs: true, serviceTier: "default", include: ["message.output_text.logprobs"] })).toEqual({ reasoningEffort: "low", serviceTier: "default", include: [] }) + expect(normalizeAstraRequest({ model: "gpt-5.5", temperature: 1 })).toEqual({ model: "gpt-5.5", temperature: 1 }) +}) + +test("native support stays unavailable separately for API, OAuth and gateways", () => { + const base = { providerID: "openai", modelID: "gpt-6-astra", endpoint: "https://api.openai.com/v1", auth: "api" as const, + transport: "responses-http" as const, tools: ["tool_start", "tool_status", "tool_wait", "tool_cancel", "question_async", "session_search", "session_read"] } + expect(resolveRuntimeCapabilities(base).asyncTools.mode).toBe("emulated") + expect(resolveRuntimeCapabilities(base).nativeTransport.mode).toBe("unavailable") + expect(resolveRuntimeCapabilities({ ...base, auth: "oauth" }).nativeTransport.reason).toContain("Subscription") + expect(resolveRuntimeCapabilities({ ...base, endpoint: "https://api.openai.com.invalid/v1" }).nativeTransport.reason).toContain("combination") + expect(resolveRuntimeCapabilities({ ...base, tools: [] }).asyncTools.mode).toBe("unavailable") +}) + +const variants = { low: { reasoningEffort: "low" }, medium: { reasoningEffort: "medium" }, high: { reasoningEffort: "high" }, max: { reasoningEffort: "max" } } +test("balanced Auto is optional, supported-only, capped and subordinate to manual", () => { + expect(selectAutoReasoning({ enabled: false, variants })).toBeUndefined() + expect(selectAutoReasoning({ enabled: true, manual: "low", signal: "verification_failed", variants })).toBeUndefined() + expect(selectAutoReasoning({ enabled: true, variants })?.effort).toBe("medium") + expect(selectAutoReasoning({ enabled: true, signal: "technical_block", variants })?.effort).toBe("high") + expect(selectAutoReasoning({ enabled: true, signal: "technical_block", variants: { max: {} } })).toBeUndefined() + expect(selectAutoReasoning({ enabled: true, variants: { low: {} } })?.effort).toBe("low") +}) + +test("Auto consumes only its explicit result from the current turn", () => { + const result = (toolName: string, value: string): ModelMessage => ({ role: "tool", content: [{ type: "tool-result", toolCallId: "c1", toolName, output: { type: "text", value } }] }) + const signal = result("reasoning_auto", JSON.stringify({ signal: "verification_failed" })) + expect(autoReasoningSignal([{ role: "user", content: "fix" }, signal])).toBe("verification_failed") + expect(autoReasoningSignal([signal, { role: "user", content: "new turn" }])).toBeUndefined() + expect(autoReasoningSignal([result("bash", JSON.stringify({ signal: "technical_block" }))])).toBeUndefined() + expect(autoReasoningSignal([result("reasoning_auto", JSON.stringify({ signal: "network_failed" }))])).toBeUndefined() + expect(autoReasoningSignal([result("reasoning_auto", "invalid")])).toBeUndefined() +}) + +test("last-request reasoning state is copied, bounded and unknown before a request", () => { + expect(getReasoningState("unseen-session")).toBeUndefined() + const state = { sessionID: "state-0", messageID: "user1", mode: "auto" as const, effort: "medium", reason: "balanced_initial", modelID: "gpt-6-astra", providerID: "openai", requestedAt: 1 } + recordReasoningState(state) + state.effort = "high" + expect(getReasoningState("state-0")?.effort).toBe("medium") + for (let i = 1; i <= 128; i++) recordReasoningState({ ...state, sessionID: `state-${i}` }) + expect(getReasoningState("state-0")).toBeUndefined() + expect(getReasoningState("state-128")?.effort).toBe("high") +}) diff --git a/src/mendcode/packages/opencode/test/server/release-channel.test.ts b/src/mendcode/packages/opencode/test/server/release-channel.test.ts new file mode 100644 index 00000000..78c713b8 --- /dev/null +++ b/src/mendcode/packages/opencode/test/server/release-channel.test.ts @@ -0,0 +1,30 @@ +import { afterEach, expect, test } from "bun:test" +import { Flag } from "@mendcode/core/flag/flag" +import { Server } from "../../src/server/server" +import { readChannel, writeChannel } from "../../src/installation/release-channel" + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await writeChannel("stable") +}) + +test.each([false, true])("release channel preference uses the selected backend (HttpApi=%s)", async (experimental) => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + const app = experimental ? Server.Default().app : Server.Legacy().app + await writeChannel("stable") + const before = await app.request("/global/release-channel") + expect(before.status).toBe(200) + expect(await before.json()).toEqual({ channel: "stable" }) + const changed = await app.request("/global/release-channel", { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ channel: "nightly" }), + }) + expect(changed.status).toBe(200) + expect(await changed.json()).toEqual({ channel: "nightly" }) + expect(await readChannel()).toBe("nightly") + const invalid = await app.request("/global/release-channel", { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ channel: "preview" }), + }) + expect(invalid.status).toBe(400) + expect(await readChannel()).toBe("nightly") +}) diff --git a/src/mendcode/packages/opencode/test/server/upgrade-progress.test.ts b/src/mendcode/packages/opencode/test/server/upgrade-progress.test.ts new file mode 100644 index 00000000..c75d79b7 --- /dev/null +++ b/src/mendcode/packages/opencode/test/server/upgrade-progress.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test" +import { Schema } from "effect" +import { GlobalBus, type GlobalEvent } from "../../src/bus/global" +import { TuiEvent } from "../../src/cli/cmd/tui/event" +import { reportUpgradeOutcome, reportUpgradePhase } from "../../src/server/upgrade-progress" + +test("upgrade progress uses the existing TUI contract and failure replaces its long-lived notification", () => { + const events: GlobalEvent[] = [] + const listener = (event: GlobalEvent) => { events.push(event) } + GlobalBus.on("event", listener) + try { + reportUpgradePhase("0.1.44-beta.1", "downloading") + reportUpgradeOutcome("0.1.44-beta.1", "Checksum mismatch; installed version preserved") + expect(events).toHaveLength(2) + for (const event of events) { + expect(event.directory).toBe("global") + expect(event.payload.type).toBe(TuiEvent.ToastShow.type) + Schema.decodeUnknownSync(TuiEvent.ToastShow.properties)(event.payload.properties) + } + expect(events[0].payload.properties.message).toBe("Downloading…") + expect(events[1].payload.properties.variant).toBe("error") + expect(events[1].payload.properties.duration).toBe(10_000) + } finally { + GlobalBus.off("event", listener) + } +}) diff --git a/src/mendcode/packages/opencode/test/server/usage.test.ts b/src/mendcode/packages/opencode/test/server/usage.test.ts new file mode 100644 index 00000000..136e2670 --- /dev/null +++ b/src/mendcode/packages/opencode/test/server/usage.test.ts @@ -0,0 +1,83 @@ +import { afterEach, expect, test } from "bun:test" +import { Flag } from "@mendcode/core/flag/flag" +import { Server } from "../../src/server/server" +import { tmpdir } from "../fixture/fixture" +import { putRecord } from "../../src/session/runtime-mailbox" +import { SessionID } from "../../src/session/schema" + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI +afterEach(() => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original +}) +test.each([false, true])( + "backend statistics preserve filtering and reject invalid days (HttpApi=%s)", + async (experimental) => { + await using tmp = await tmpdir({ git: true }) + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + const app = experimental ? Server.Default().app : Server.Legacy().app + const headers = { "x-opencode-directory": encodeURIComponent(tmp.path), "content-type": "application/json" } + const created = await app.request("/session", { + method: "POST", + headers, + body: JSON.stringify({ title: "Stats fixture" }), + }) + expect(created.status).toBe(200) + const session = await created.json() + const resolution = await app.request(`/continuity/${session.id}/model-resolution`, { + method: "POST", + headers, + body: JSON.stringify({ explicitModel: "fixture/model", explicitVariant: "high" }), + }) + expect({ status: resolution.status, error: resolution.ok ? undefined : await resolution.clone().text() }).toEqual({ + status: 200, + error: undefined, + }) + expect(await resolution.json()).toMatchObject({ + model: { providerID: "fixture", modelID: "model" }, + variant: "high", + }) + putRecord({ + id: `job_${session.id}`, + sessionID: SessionID.make(session.id), + directory: tmp.path, + kind: "job", + generation: 0, + status: "completed", + data: { tool: "read", result: "private result" }, + timeCreated: 1, + timeUpdated: 1, + }) + for (let i = 0; i < 101; i++) + putRecord({ + id: `note_${session.id}_${i}`, + sessionID: SessionID.make(session.id), + directory: tmp.path, + kind: "note", + generation: 0, + status: "saved", + data: {}, + timeCreated: i + 2, + timeUpdated: i + 2, + }) + const continuity = await app.request(`/continuity/${session.id}`, { headers }) + expect({ status: continuity.status, error: continuity.ok ? undefined : await continuity.clone().text() }).toEqual({ + status: 200, + error: undefined, + }) + const jobs = await continuity.json() + expect(jobs.records).toHaveLength(1) + expect(jobs.records[0].data).toEqual({ tool: "read" }) + const stats = await app.request("/usage?project=", { headers }) + expect({ status: stats.status, error: stats.ok ? undefined : await stats.clone().text() }).toEqual({ + status: 200, + error: undefined, + }) + expect(await stats.json()).toMatchObject({ totalSessions: 1, totalMessages: 0, totalCost: 0 }) + const other = await app.request("/usage?project=missing", { headers }) + expect(await other.json()).toMatchObject({ totalSessions: 0 }) + expect((await app.request("/usage?days=-1", { headers })).status).toBe(400) + expect((await app.request("/usage?days=NaN", { headers })).status).toBe(400) + await app.request(`/session/${session.id}`, { method: "DELETE", headers }) + }, + 30_000, +) diff --git a/src/mendcode/packages/opencode/test/session/continuity.test.ts b/src/mendcode/packages/opencode/test/session/continuity.test.ts new file mode 100644 index 00000000..a31fc7ec --- /dev/null +++ b/src/mendcode/packages/opencode/test/session/continuity.test.ts @@ -0,0 +1,263 @@ +import { expect } from "bun:test" +import { Effect, Layer, Scope, Schema } from "effect" +import { Session } from "@/session/session" +import { MessageID } from "@/session/schema" +import { Question } from "@/question" +import { Bus } from "@/bus" +import { InstanceState } from "@/effect/instance-state" +import { ToolJobs } from "@/session/tool-jobs" +import * as Mailbox from "@/session/runtime-mailbox" +import type { Tool } from "@/tool/tool" +import { testEffect } from "../lib/effect" +import { notifyDisabled, waitForDisable } from "@/session/continuity-control" +import { provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" +import { CrossSpawnSpawner } from "@mendcode/core/cross-spawn-spawner" + +const it = testEffect(Layer.mergeAll(Session.defaultLayer, Question.defaultLayer, Bus.layer, CrossSpawnSpawner.defaultLayer)) +it.live("pending async questions survive instance reload and use the existing reply path", () => + Effect.gen(function* () { + const directory = yield* tmpdirScoped() + const sessions = yield* Session.Service + const questions = yield* Question.Service + const session = yield* sessions.create({ title: "Reload question" }).pipe(provideInstance(directory)) + const request = yield* questions.post({ sessionID: session.id, questions: [] }).pipe(provideInstance(directory)) + yield* Effect.promise(() => reloadTestInstance({ directory })) + const pending = yield* questions.list().pipe(provideInstance(directory)) + expect(pending.some((item) => item.id === request.id && item.async)).toBe(true) + yield* questions.reply({ requestID: request.id, answers: [] }).pipe(provideInstance(directory)) + expect((yield* questions.get(request.id).pipe(provideInstance(directory)))?.status).toBe("answered") + expect(Mailbox.pendingEvents(session.id)).toHaveLength(1) + }), +) +it.instance("immediate jobs release slots without double execution", () => + Effect.gen(function* () { + const session = yield* (yield* Session.Service).create({ title: "Immediate" }) + const scope = yield* Scope.Scope + const bus = yield* Bus.Service + const context = yield* InstanceState.context + const jobs = new ToolJobs(scope, context.directory, bus, { + get: () => Effect.succeed({ experimental: { async_tools: true } }), + }) + yield* Effect.addFinalizer(() => jobs.stop()) + let executions = 0 + const tool: Tool.Def = { + id: "read", + description: "Test", + parameters: Schema.Unknown, + execute: () => Effect.sync(() => ({ title: "done", output: String(++executions), metadata: {} })), + } + for (let i = 0; i < 20; i++) { + yield* jobs.start( + tool, + {}, + { + sessionID: session.id, + messageID: MessageID.ascending(), + callID: String(i), + agent: "build", + abort: new AbortController().signal, + messages: [], + ask: () => Effect.void, + metadata: () => Effect.void, + }, + ) + yield* Effect.yieldNow + } + yield* Effect.sleep(20) + expect(executions).toBe(20) + expect(Mailbox.listRecords(session.id, "job", { statuses: ["running", "queued"] })).toHaveLength(0) + }), +) + +it.instance("disable cancels active jobs and suppresses their continuations", () => + Effect.gen(function* () { + const session = yield* (yield* Session.Service).create({ title: "Disable" }) + const scope = yield* Scope.Scope + const bus = yield* Bus.Service + const context = yield* InstanceState.context + const jobs = new ToolJobs(scope, context.directory, bus, { + get: () => Effect.succeed({ experimental: { async_tools: true } }), + }) + yield* Effect.addFinalizer(() => jobs.stop()) + yield* waitForDisable(context.directory, "tools").pipe( + Effect.andThen(jobs.stop()), + Effect.forkScoped({ startImmediately: true }), + ) + const tool: Tool.Def = { id: "read", description: "Test", parameters: Schema.Unknown, execute: () => Effect.never } + const ctx: Tool.Context = { + sessionID: session.id, + messageID: MessageID.ascending(), + callID: "active", + agent: "build", + abort: new AbortController().signal, + messages: [], + ask: () => Effect.void, + metadata: () => Effect.void, + } + const job = yield* jobs.start(tool, {}, ctx) + notifyDisabled({ directory: context.directory, experimental: { async_tools: false } }) + yield* Effect.sleep(20) + expect(Mailbox.getRecord(session.id, job.id)?.status).toBe("cancelled") + expect(Mailbox.pendingEvents(session.id)).toHaveLength(0) + expect((yield* Effect.exit(jobs.start(tool, {}, { ...ctx, callID: "new" })))._tag).toBe("Failure") + }), +) + +it.instance("bounded mailbox acknowledges only complete included events", () => + Effect.gen(function* () { + const session = yield* (yield* Session.Service).create({ title: "Batch" }) + const context = yield* InstanceState.context + for (let i = 0; i < 10; i++) + Mailbox.completeRecord({ + id: `large_${session.id}_${i}`, + sessionID: session.id, + directory: context.directory, + kind: "job", + generation: 0, + status: "completed", + data: { result: "x".repeat(10000) }, + timeCreated: Date.now() + i, + timeUpdated: Date.now(), + }) + const batch = Mailbox.boundedEventBatch(Mailbox.pendingEvents(session.id)) + expect(batch.length).toBeGreaterThan(0) + expect(batch.length).toBeLessThan(10) + expect(JSON.stringify(Mailbox.eventContext(batch)).length).toBeLessThanOrEqual(24000) + Mailbox.acknowledgeEvents(batch) + expect(Mailbox.pendingEvents(session.id)).toHaveLength(10 - batch.length) + }), +) +it.instance("async questions persist replies once without blocking posting", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const questions = yield* Question.Service + const session = yield* sessions.create({ title: "Questions" }) + const request = yield* questions.post({ + sessionID: session.id, + questions: [ + { question: "Which output?", header: "Output", options: [{ label: "Text", description: "Plain text" }] }, + ], + }) + expect(request.async).toBe(true) + expect((yield* questions.list()).some((row) => row.id === request.id)).toBe(true) + yield* questions.reply({ requestID: request.id, answers: [["Text"]] }) + yield* questions.reply({ requestID: request.id, answers: [["changed"]] }) + expect(yield* questions.wait(request.id)).toEqual([["Text"]]) + expect(Mailbox.pendingEvents(session.id)).toHaveLength(1) + Mailbox.acknowledgeEvents(Mailbox.pendingEvents(session.id)) + expect(Mailbox.pendingEvents(session.id)).toHaveLength(0) + }), +) + +it.instance("cancelled generation retains late answers without wake", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const questions = yield* Question.Service + const context = yield* InstanceState.context + const session = yield* sessions.create({ title: "Cancel" }) + const request = yield* questions.post({ sessionID: session.id, questions: [] }) + Mailbox.cancelGeneration(session.id, context.directory) + yield* questions.reply({ requestID: request.id, answers: [] }) + expect((yield* questions.get(request.id))?.status).toBe("answered") + expect(Mailbox.pendingEvents(session.id)).toHaveLength(0) + }), +) + +it.instance("jobs enforce active and queued bounds, idempotency, and cancellation", () => + Effect.gen(function* () { + const session = yield* (yield* Session.Service).create({ title: "Jobs" }) + const scope = yield* Scope.Scope + const bus = yield* Bus.Service + const context = yield* InstanceState.context + const jobs = new ToolJobs(scope, context.directory, bus, { + get: () => Effect.succeed({ experimental: { async_tools: true } }), + }) + yield* Effect.addFinalizer(() => jobs.stop()) + const tool: Tool.Def = { id: "read", description: "Test", parameters: Schema.Unknown, execute: () => Effect.never } + const controller = new AbortController() + const makeContext = (callID: string): Tool.Context => ({ + sessionID: session.id, + messageID: MessageID.ascending(), + callID, + agent: "build", + abort: controller.signal, + messages: [], + ask: () => Effect.void, + metadata: () => Effect.void, + }) + const firstContext = makeContext("first") + const first = yield* jobs.start(tool, {}, firstContext) + expect((yield* jobs.start(tool, {}, firstContext)).id).toBe(first.id) + for (let i = 0; i < 11; i++) yield* jobs.start(tool, {}, makeContext(`call_${i}`)) + const records = Mailbox.listRecords(session.id, "job") + expect(records.filter((row) => row.status === "running")).toHaveLength(4) + expect(records.filter((row) => row.status === "queued")).toHaveLength(8) + const over = yield* Effect.exit(jobs.start(tool, {}, makeContext("overflow"))) + expect(over._tag).toBe("Failure") + yield* jobs.cancel(session.id, first.id) + expect(Mailbox.getRecord(session.id, first.id)?.status).toBe("cancelled") + }), +) + +it.instance("jobs finish out of order and retain correlated results", () => + Effect.gen(function* () { + const session = yield* (yield* Session.Service).create({ title: "Order" }) + const scope = yield* Scope.Scope + const bus = yield* Bus.Service + const context = yield* InstanceState.context + const jobs = new ToolJobs(scope, context.directory, bus, { + get: () => Effect.succeed({ experimental: { async_tools: true } }), + }) + yield* Effect.addFinalizer(() => jobs.stop()) + const tool: Tool.Def = { + id: "read", + description: "Test", + parameters: Schema.Unknown, + execute: (args) => + Effect.gen(function* () { + const delay = Number(args) + yield* Effect.sleep(delay) + return { title: "result", output: String(delay), metadata: {} } + }), + } + const ctx: Tool.Context = { + sessionID: session.id, + messageID: MessageID.ascending(), + callID: "slow", + agent: "build", + abort: new AbortController().signal, + messages: [], + ask: () => Effect.void, + metadata: () => Effect.void, + } + const slow = yield* jobs.start(tool, 60, ctx) + const fast = yield* jobs.start(tool, 1, { ...ctx, callID: "fast" }) + yield* Effect.sleep(120) + expect(Mailbox.getRecord(session.id, slow.id)?.data.result).toBe("60") + expect(Mailbox.getRecord(session.id, fast.id)?.data.result).toBe("1") + expect(Mailbox.pendingEvents(session.id)).toHaveLength(2) + }), +) + +it.instance("restart marks lost jobs interrupted without executing them", () => + Effect.gen(function* () { + const session = yield* (yield* Session.Service).create({ title: "Restart" }) + const scope = yield* Scope.Scope + const bus = yield* Bus.Service + const context = yield* InstanceState.context + Mailbox.putRecord({ + id: "lost", + sessionID: session.id, + directory: context.directory, + kind: "job", + generation: 0, + status: "running", + data: { tool: "read" }, + timeCreated: Date.now(), + timeUpdated: Date.now(), + }) + new ToolJobs(scope, context.directory, bus, { get: () => Effect.succeed({}) }) + expect(Mailbox.getRecord(session.id, "lost")?.status).toBe("interrupted") + expect(Mailbox.pendingEvents(session.id)).toHaveLength(1) + }), +) diff --git a/src/mendcode/packages/opencode/test/session/llm.test.ts b/src/mendcode/packages/opencode/test/session/llm.test.ts index edbd105f..88e3b0be 100644 --- a/src/mendcode/packages/opencode/test/session/llm.test.ts +++ b/src/mendcode/packages/opencode/test/session/llm.test.ts @@ -17,6 +17,7 @@ import type { Agent } from "../../src/agent/agent" import { MessageV2 } from "../../src/session/message-v2" import { SessionID, MessageID } from "../../src/session/schema" import { AppRuntime } from "../../src/effect/app-runtime" +import { getReasoningState } from "../../src/mend/prompt/reasoning-state" async function getModel(providerID: ProviderID, modelID: ModelID) { return AppRuntime.runPromise( @@ -573,14 +574,20 @@ describe("session.llm.stream", () => { }) }) - test("sends responses API payload for OpenAI models", async () => { + test.each([ + { modelID: "gpt-5.2", auto: false, manual: "high", signal: false, expected: "high" }, + { modelID: "gpt-6-astra", auto: false, manual: "high", signal: false, expected: "high" }, + { modelID: "gpt-6-astra", auto: true, manual: undefined, signal: false, expected: "medium" }, + { modelID: "gpt-6-astra", auto: true, manual: undefined, signal: true, expected: "high" }, + { modelID: "gpt-6-astra", auto: true, manual: "low", signal: true, expected: "low" }, + ])("sends Responses options for $modelID auto=$auto manual=$manual signal=$signal", async (scenario) => { const server = state.server if (!server) { throw new Error("Server not initialized") } const source = await loadFixture("openai", "gpt-5.2") - const model = source.model + const model = { ...source.model, id: scenario.modelID } const responseChunks = [ { @@ -621,6 +628,7 @@ describe("session.llm.stream", () => { JSON.stringify({ $schema: "https://mendcode.ai/config.json", enabled_providers: ["openai"], + experimental: { reasoning_auto: scenario.auto }, provider: { openai: { name: "OpenAI", @@ -660,7 +668,7 @@ describe("session.llm.stream", () => { role: "user", time: { created: Date.now() }, agent: agent.name, - model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" }, + model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: scenario.manual }, } satisfies MessageV2.User await drain({ @@ -669,7 +677,13 @@ describe("session.llm.stream", () => { model: resolved, agent, system: ["You are a helpful assistant."], - messages: [{ role: "user", content: "Hello" }], + messages: [ + { role: "user", content: "Hello" }, + ...(scenario.signal ? [ + { role: "assistant", content: [{ type: "tool-call", toolCallId: "auto-1", toolName: "reasoning_auto", input: { reason: "verification_failed", evidence: "Regression check failed" } }] }, + { role: "tool", content: [{ type: "tool-result", toolCallId: "auto-1", toolName: "reasoning_auto", output: { type: "text", value: JSON.stringify({ signal: "verification_failed" }) } }] }, + ] as ModelMessage[] : []), + ], tools: {}, }) @@ -679,7 +693,13 @@ describe("session.llm.stream", () => { expect(capture.url.pathname.endsWith("/responses")).toBe(true) expect(body.model).toBe(resolved.api.id) expect(body.stream).toBe(true) - expect((body.reasoning as { effort?: string } | undefined)?.effort).toBe("high") + expect((body.reasoning as { effort?: string } | undefined)?.effort).toBe(scenario.expected) + expect(getReasoningState(sessionID)).toMatchObject({ effort: scenario.expected, mode: scenario.manual ? "manual" : "auto", messageID: user.id }) + if (scenario.modelID === "gpt-6-astra") { + expect(body.temperature).toBeUndefined() + expect(body.top_p).toBeUndefined() + expect(body.service_tier).toBeUndefined() + } expect((body.reasoning as { summary?: string } | undefined)?.summary).toBe("auto") expect(body.include).toContain("reasoning.encrypted_content") diff --git a/src/mendcode/packages/opencode/test/session/prompt.test.ts b/src/mendcode/packages/opencode/test/session/prompt.test.ts index 6fbd65a8..ffce7b44 100644 --- a/src/mendcode/packages/opencode/test/session/prompt.test.ts +++ b/src/mendcode/packages/opencode/test/session/prompt.test.ts @@ -56,6 +56,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { recoverStaleSessionStatuses, STALE_SESSION_RECOVERY_MS } from "../../src/session/recovery" import { BackgroundTask } from "../../src/session/background-task" +import * as ContinuityMailbox from "../../src/session/runtime-mailbox" import { SessionV2 } from "../../src/v2/session" import { Modelv2 } from "../../src/v2/model" import { Skill } from "../../src/skill" @@ -2561,7 +2562,8 @@ it.live("does not wake a stopped sender when a peer response arrives", () => !(yield* sessions.messages({ sessionID: target.id })).some( (message) => message.info.role === "assistant" && message.info.time.completed !== undefined, ) - ) yield* Effect.sleep("1 millis") + ) + yield* Effect.sleep("1 millis") }).pipe(Effect.timeout("2 seconds")) yield* Effect.sleep("100 millis") expect(yield* llm.calls).toBe(1) @@ -2610,7 +2612,8 @@ it.live("stopping drops an already queued peer reply while preserving the queued message.info.parentID === human.info.id && message.info.time.completed, ) - ) yield* Effect.sleep("1 millis") + ) + yield* Effect.sleep("1 millis") }).pipe(Effect.timeout("2 seconds")) yield* Effect.sleep("50 millis") const messages = yield* sessions.messages({ sessionID: chat.id, view: "full" }) @@ -5454,6 +5457,60 @@ it.live("handles filenames with # character", () => // Regression: empty assistant turn loop +it.live("continuity wakes the existing executor without inventing a user message", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const bus = yield* Bus.Service + const session = yield* sessions.create({ title: "Continuity wake" }) + yield* llm.text("Initial response") + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "Inspect the result when ready" }], + }) + const settled = yield* Deferred.make() + const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (event) => { + if ( + event.properties.sessionID === session.id && + event.properties.status.type === "idle" && + ContinuityMailbox.pendingEvents(session.id).length === 0 + ) { + Effect.runFork(Deferred.succeed(settled, undefined)) + } + }) + yield* llm.text("Result acknowledged") + const record = ContinuityMailbox.completeRecord({ + id: `job_${session.id}`, + sessionID: session.id, + directory: session.directory, + kind: "job", + generation: 0, + status: "completed", + data: { result: "Read complete" }, + timeCreated: Date.now(), + timeUpdated: Date.now(), + }) + yield* bus.publish(ContinuityMailbox.Event.Updated, { + sessionID: session.id, + id: record.id, + kind: record.kind, + status: record.status, + }) + yield* llm.wait(2).pipe(Effect.timeout("3 seconds")) + yield* Deferred.await(settled).pipe(Effect.timeout("3 seconds")) + off() + expect(yield* llm.calls).toBe(2) + expect(ContinuityMailbox.pendingEvents(session.id)).toHaveLength(0) + const messages = yield* sessions.messages({ sessionID: session.id }) + expect(messages.filter((message) => message.info.role === "user")).toHaveLength(1) + }), + { git: true, config: (url) => ({ ...providerCfg(url), experimental: { async_tools: true } }) }, + ), +) + it.live("does not loop empty assistant turns for a simple reply", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { diff --git a/src/mendcode/packages/opencode/test/session/session-notes.test.ts b/src/mendcode/packages/opencode/test/session/session-notes.test.ts new file mode 100644 index 00000000..9d7e4903 --- /dev/null +++ b/src/mendcode/packages/opencode/test/session/session-notes.test.ts @@ -0,0 +1,28 @@ +import { expect } from "bun:test" +import { Effect } from "effect" +import { Session } from "@/session/session" +import { MessageID } from "@/session/schema" +import { sessionNotes } from "@/tool/session-notes" +import type { Tool } from "@/tool/tool" +import { testEffect } from "../lib/effect" + +const it = testEffect(Session.defaultLayer) +it.instance("session notes retain revisions, reject stale writes and isolate sessions", () => Effect.gen(function* () { + const sessions = yield* Session.Service + const a = yield* sessions.create({ title: "Notes A" }) + const b = yield* sessions.create({ title: "Notes B" }) + const tool = sessionNotes("fixture", { get: () => Effect.succeed({ experimental: { session_recall: true } }) }) + const ctx: Tool.Context = { sessionID: a.id, messageID: MessageID.ascending(), agent: "build", + abort: new AbortController().signal, messages: [], ask: () => Effect.void, metadata: () => Effect.void } + const read = (args: Parameters[0], context = ctx) => tool.execute(args, context).pipe(Effect.map((result) => JSON.parse(result.output))) + expect(yield* read({ action: "read" })).toMatchObject({ version: 0, text: "" }) + yield* read({ action: "write", version: 0, text: "First objective" }) + yield* read({ action: "write", version: 1, text: "Updated objective" }) + expect(yield* read({ action: "read", version: 1 })).toMatchObject({ version: 1, text: "First objective", current_version: 2 }) + expect(yield* read({ action: "read" }, { ...ctx, sessionID: b.id })).toMatchObject({ version: 0, text: "" }) + expect(yield* read({ action: "write", version: 1, text: "Lost update" }).pipe(Effect.exit)).toHaveProperty("_tag", "Failure") + expect(yield* read({ action: "write", version: 2, text: "x".repeat(16001) }).pipe(Effect.exit)).toHaveProperty("_tag", "Failure") + expect(yield* read({ action: "read" })).toMatchObject({ version: 2, text: "Updated objective" }) + const disabled = sessionNotes("fixture", { get: () => Effect.succeed({}) }) + expect(yield* disabled.execute({ action: "write", version: 2, text: "disabled" }, ctx).pipe(Effect.exit)).toHaveProperty("_tag", "Failure") +})) diff --git a/src/mendcode/packages/opencode/test/storage/compatibility.test.ts b/src/mendcode/packages/opencode/test/storage/compatibility.test.ts new file mode 100644 index 00000000..657d75ab --- /dev/null +++ b/src/mendcode/packages/opencode/test/storage/compatibility.test.ts @@ -0,0 +1,81 @@ +import { afterAll, expect, test } from "bun:test" +import { Database } from "bun:sqlite" +import { mkdtempSync, mkdirSync, readFileSync, renameSync } from "node:fs" +import { tmpdir, homedir } from "node:os" +import path from "node:path" +import { + acquireDatabaseMaintenance, + assertCompatibility, + backupBeforeMigration, + inspectCompatibility, + recordSchemaIdentity, +} from "../../src/storage/compatibility" +import { identifyMigrations } from "../../src/storage/migration-journal" +import { init } from "../../src/storage/db.bun" +import { mendChannelDbPath } from "../../src/storage/resolve-default-sqlite-path" + +const root = mkdtempSync(path.join(tmpdir(), "mendcode-compatibility-")) +afterAll(() => { + const trash = path.join(homedir(), ".Trash") + mkdirSync(trash, { recursive: true }) + renameSync(root, path.join(trash, path.basename(root))) +}) +const entries = [{ name: "20260904000000_initial", timestamp: 1788480000000, sql: "CREATE TABLE item(value TEXT);" }] +const journal = identifyMigrations(entries) +function fixture(name: string) { + const file = path.join(root, `${name}.db`) + const db = new Database(file, { create: true }) + db.run("CREATE TABLE __drizzle_migrations (name TEXT, created_at NUMERIC)") + db.run("INSERT INTO __drizzle_migrations VALUES (?, ?)", [entries[0].name, entries[0].timestamp]) + return { file, db } +} + +test("future journal is rejected without changing database bytes", () => { + const { file, db } = fixture("future") + db.run("INSERT INTO __drizzle_migrations VALUES ('future', 9999999999999)") + db.close() + const before = readFileSync(file) + expect(() => assertCompatibility(file, journal)).toThrow("Unsupported database migration") + expect(readFileSync(file)).toEqual(before) +}) + +test("known legacy schema is compatible; persisted SQL identity rejects drift", () => { + const { file, db } = fixture("hash") + db.close() + expect(inspectCompatibility(file, journal)).toEqual({ compatible: true, pending: [], legacy: true }) + const writer = init(file) + recordSchemaIdentity(writer, journal) + writer.$client.close() + expect(inspectCompatibility(file, journal).legacy).toBe(false) + expect(inspectCompatibility(file, [{ ...journal[0], hash: "f".repeat(64) }]).compatible).toBe(false) +}) + +test("snapshot includes WAL commits only when migrations are pending", () => { + const { file, db } = fixture("backup") + db.run("PRAGMA journal_mode=WAL") + db.run("CREATE TABLE item(value TEXT)") + db.run("INSERT INTO item VALUES ('preserved')") + expect(backupBeforeMigration(file, journal)).toBeUndefined() + const next = [...journal, { name: "next", timestamp: entries[0].timestamp + 1, hash: "a".repeat(64) }] + const backup = backupBeforeMigration(file, next)! + const copy = new Database(backup, { readonly: true }) + expect(copy.query("SELECT value FROM item").get()).toEqual({ value: "preserved" }) + copy.close() + db.close() +}) + +test("maintenance excludes another writer and releases cleanly", () => { + const { file, db } = fixture("lock") + const guard = acquireDatabaseMaintenance(file, journal) + expect(() => db.run("CREATE TABLE blocked(id INTEGER)")).toThrow() + guard.release() + guard.release() + db.run("CREATE TABLE allowed(id INTEGER)") + db.close() +}) + +test("release channels share new-install database path", () => { + expect(["stable", "beta", "nightly"].map((channel) => mendChannelDbPath(root, channel, false))).toEqual( + Array(3).fill(path.join(root, "mendcode.db")), + ) +}) diff --git a/src/mendcode/packages/opencode/test/storage/writer-lease.test.ts b/src/mendcode/packages/opencode/test/storage/writer-lease.test.ts new file mode 100644 index 00000000..8425d9ed --- /dev/null +++ b/src/mendcode/packages/opencode/test/storage/writer-lease.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { tmpdir } from "../fixture/fixture" +import { acquireWriterLease } from "../../src/storage/writer-lease" + +test("one writer owns the canonical database path until release", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "sessions.db") + const release = acquireWriterLease(file) + try { + expect(() => acquireWriterLease(path.join(tmp.path, "child", "..", "sessions.db"))).toThrow("already has a writer") + expect(await Bun.file(file).exists()).toBe(false) + } finally { release() } + release() + acquireWriterLease(file)() +}) + +test("a second process cannot acquire the same writer lease and dead owners recover without deleting evidence", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "sessions.db") + const module = path.resolve(import.meta.dir, "../../src/storage/writer-lease.ts") + const source = `import {acquireWriterLease} from ${JSON.stringify(module)}; acquireWriterLease(process.argv[1]);` + const run = async () => { + const child = Bun.spawn([process.execPath, "-e", source, file], { stdout: "pipe", stderr: "pipe" }) + const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]) + return { code, stderr } + } + const release = acquireWriterLease(file) + try { + const blocked = await run() + expect(blocked.code).not.toBe(0) + expect(blocked.stderr).toContain("already has a writer") + } finally { release() } + expect((await run()).code).toBe(0) + acquireWriterLease(file)() + expect((await fs.readdir(tmp.path)).some((entry) => entry.startsWith("sessions.db.writer-lock.recovered-"))).toBe(true) +}) + +test("missing or changed ownership fails closed", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "sessions.db") + const release = acquireWriterLease(file) + const metadata = path.join(`${file}.writer-lock`, "owner.json") + const original = await fs.readFile(metadata, "utf8") + await fs.writeFile(metadata, JSON.stringify({ pid: process.pid, token: "00000000-0000-0000-0000-000000000000" })) + expect(release).toThrow("no longer owned") + await fs.writeFile(metadata, original) + release() +}) diff --git a/src/mendcode/script/release-index.mjs b/src/mendcode/script/release-index.mjs new file mode 100644 index 00000000..2cc1b64e --- /dev/null +++ b/src/mendcode/script/release-index.mjs @@ -0,0 +1,76 @@ +import { createHash } from "node:crypto" +import { readFile, readdir, stat, writeFile } from "node:fs/promises" +import path from "node:path" +import { fileURLToPath } from "node:url" + +export const digest = (value) => createHash("sha256").update(value).digest("hex") + +export function releaseChannel(version) { + if (/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) return "stable" + if (/^\d+\.\d+\.\d+-beta\.[1-9]\d*$/.test(version)) return "beta" + if (/^\d+\.\d+\.\d+-nightly\.\d{8}\.[1-9]\d*$/.test(version)) return "nightly" + throw new Error("Unsupported release version") +} + +export function migrationEntry(name, sql) { + const date = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})_/.exec(name) + if (!date) throw new Error(`Invalid migration name: ${name}`) + const [, year, month, day, hour, minute, second] = date.map(Number) + return { name, timestamp: Date.UTC(year, month - 1, day, hour, minute, second), hash: digest(sql) } +} + +export function makeIndex({ version, commit, repository, runID, journal, assets, installer, windowsInstaller }) { + const channel = releaseChannel(version) + if (!/^[a-f0-9]{40}$/.test(commit)) throw new Error("Release commit must be a full SHA") + if (repository !== "MendCode/MendCode") throw new Error("Unauthorized release repository") + if (!/^[1-9]\d*$/.test(runID)) throw new Error("Invalid workflow run") + if (!journal.length) throw new Error("Missing schema journal") + for (let i = 0; i < journal.length; i++) { + const entry = journal[i] + if (!/^\d{14}_/.test(entry.name) || !/^[a-f0-9]{64}$/.test(entry.hash) || !Number.isSafeInteger(entry.timestamp)) + throw new Error("Invalid schema journal entry") + if (i > 0 && (journal[i - 1].name >= entry.name || journal[i - 1].timestamp >= entry.timestamp)) + throw new Error("Schema journal must be ordered and unique") + } + const names = new Set() + for (const asset of assets) { + if (!/^mendcode-(linux|darwin|windows)-(arm64|x64)(-baseline)?(-musl)?\.(zip|tar\.gz)$/.test(asset.name)) + throw new Error("Invalid release asset name") + if (names.has(asset.name)) throw new Error("Duplicate release asset") + names.add(asset.name) + if (!/^[a-f0-9]{64}$/.test(asset.sha256) || !Number.isSafeInteger(asset.size) || asset.size <= 0) + throw new Error("Invalid release asset metadata") + } + if (!assets.length || !/^[a-f0-9]{64}$/.test(installer.sha256) || !/^[a-f0-9]{64}$/.test(windowsInstaller?.sha256)) throw new Error("Missing release artifacts") + return { + formatVersion: 1, version, channel, tag: `v${version}`, commit, repository, + provenance: { workflow: ".github/workflows/release.yml", runID }, + installer: { path: "src/mendcode/install", commit, sha256: installer.sha256 }, + windowsInstaller: { path: "src/mendcode/install.ps1", commit, sha256: windowsInstaller.sha256 }, + schema: { journal, fingerprint: digest(JSON.stringify(journal)) }, + assets: assets.map((asset) => ({ ...asset, platform: asset.name.replace(/^mendcode-/, "").replace(/\.(zip|tar\.gz)$/, "") })), + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + const dist = path.resolve(process.argv[2] ?? "dist") + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") + const migrationDir = path.join(root, "packages/opencode/migration") + const journal = [] + for (const entry of (await readdir(migrationDir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isDirectory()) continue + journal.push(migrationEntry(entry.name, await readFile(path.join(migrationDir, entry.name, "migration.sql"), "utf8"))) + } + const assets = [] + for (const name of (await readdir(dist)).sort()) { + if (!/\.(zip|tar\.gz)$/.test(name)) continue + const file = path.join(dist, name) + assets.push({ name, size: (await stat(file)).size, sha256: digest(await readFile(file)) }) + } + const index = makeIndex({ + version: process.env.VERSION, commit: process.env.RELEASE_COMMIT, repository: process.env.GITHUB_REPOSITORY, + runID: process.env.GITHUB_RUN_ID, journal, assets, installer: { sha256: digest(await readFile(path.join(root, "install"))) }, + windowsInstaller: { sha256: digest(await readFile(path.join(root, "install.ps1"))) }, + }) + await writeFile(path.join(dist, "release-index.json"), JSON.stringify(index, null, 2) + "\n") +} diff --git a/src/mendcode/script/release-index.test.mjs b/src/mendcode/script/release-index.test.mjs new file mode 100644 index 00000000..2d2aff04 --- /dev/null +++ b/src/mendcode/script/release-index.test.mjs @@ -0,0 +1,45 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { digest, makeIndex, migrationEntry, releaseChannel } from "./release-index.mjs" + +test("channels are disjoint and reject unrecognized prereleases", () => { + assert.equal(releaseChannel("0.1.44"), "stable") + assert.equal(releaseChannel("0.1.44-beta.2"), "beta") + assert.equal(releaseChannel("0.1.44-nightly.20260904.123"), "nightly") + for (const version of ["v0.1.44", "0.1.44-rc.1", "0.1.44+dirty", "0.1.44-beta.0"]) + assert.throws(() => releaseChannel(version)) +}) + +const input = () => ({ + version: "0.1.44-beta.1", commit: "a".repeat(40), repository: "MendCode/MendCode", runID: "42", + journal: [migrationEntry("20260803032053_dynamic_workflows", "CREATE TABLE sample(id TEXT);")], + assets: [{ name: "mendcode-linux-x64-baseline-musl.tar.gz", size: 123, sha256: "b".repeat(64) }], + installer: { sha256: "c".repeat(64) }, + windowsInstaller: { sha256: "d".repeat(64) }, +}) + +test("index binds installer, platform and schema to exact source", () => { + const value = makeIndex(input()) + assert.equal(value.installer.commit, value.commit) + assert.equal(value.assets[0].platform, "linux-x64-baseline-musl") + assert.equal(value.schema.fingerprint, digest(JSON.stringify(value.schema.journal))) + assert.equal(value.schema.journal[0].timestamp, Date.UTC(2026, 7, 3, 3, 20, 53)) + const changed = input() + changed.journal[0] = migrationEntry(changed.journal[0].name, "different SQL") + assert.notEqual(makeIndex(changed).schema.fingerprint, value.schema.fingerprint) +}) + +test("index rejects untrusted source and unsafe asset metadata", () => { + for (const override of [{ commit: "main" }, { repository: "other/repo" }, { runID: "0" }]) + assert.throws(() => makeIndex({ ...input(), ...override })) + const duplicate = input() + duplicate.assets.push(duplicate.assets[0]) + assert.throws(() => makeIndex(duplicate)) + const traversal = input() + traversal.assets[0].name = "../mendcode-linux-x64.tar.gz" + assert.throws(() => makeIndex(traversal)) + const unordered = input() + unordered.journal.push(migrationEntry("20260722100000_subagent_orchestration", "SELECT 1;")) + assert.throws(() => makeIndex(unordered)) + assert.throws(() => makeIndex({ ...input(), journal: [] })) +}) diff --git a/src/mendcode/script/windows-installer-smoke.ts b/src/mendcode/script/windows-installer-smoke.ts new file mode 100644 index 00000000..52d6b0b5 --- /dev/null +++ b/src/mendcode/script/windows-installer-smoke.ts @@ -0,0 +1,73 @@ +import { strict as assert } from "node:assert" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { createHash } from "node:crypto" + +if (process.platform !== "win32") throw new Error("This acceptance check requires native Windows") +const binary = process.env.MENDCODE_INSTALLER_TEST_BINARY +const version = process.env.MENDCODE_VERSION +if (!binary || !version) throw new Error("Provide MENDCODE_INSTALLER_TEST_BINARY and MENDCODE_VERSION") +const root = await fs.mkdtemp(path.join(os.tmpdir(), "mendcode-windows-installer-")) +const digest = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex") +const quote = (value: string) => `'${value.replaceAll("'", "''")}'` +async function powershell(source: string, env: Record = {}) { + const child = Bun.spawn(["powershell.exe", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(source, "utf16le").toString("base64")], { + env: { ...process.env, ...env }, stdout: "pipe", stderr: "pipe", + }) + const timer = setTimeout(() => child.kill(), 90_000) + try { + const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]) + return { code, output: stdout + stderr } + } finally { clearTimeout(timer) } +} +const archive = path.join(root, "candidate.zip") +const compressed = await powershell(`$ErrorActionPreference='Stop'; Compress-Archive -LiteralPath ${quote(path.resolve(binary))} -DestinationPath ${quote(archive)}`) +assert.equal(compressed.code, 0, compressed.output) +const bytes = await Bun.file(archive).bytes() +const binaryDigest = digest(await Bun.file(binary).bytes()) +let scenario = "success" +const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { + const url = new URL(request.url) + if (scenario === "http-failure") return new Response("Fixture unavailable", { status: 503 }) + if (url.pathname.endsWith("/SHA256SUMS")) { + const hash = scenario === "checksum-failure" ? "0".repeat(64) : digest(bytes) + return new Response(["windows-x64", "windows-x64-baseline", "windows-arm64"].map((target) => `${hash} mendcode-${target}.zip`).join("\n")) + } + if (url.pathname.endsWith(".zip")) return new Response(scenario === "truncated" ? bytes.slice(0, 100) : bytes) + return new Response("Not found", { status: 404 }) +} }) +try { + for (scenario of ["success", "checksum-failure", "http-failure", "truncated"]) { + const home = path.join(root, scenario) + const installed = path.join(home, ".mendcode", "bin", "mendcode.exe") + await fs.mkdir(path.dirname(installed), { recursive: true }) + // An unusable existing executable must not be run to perform recovery. + await fs.writeFile(installed, "previous damaged executable") + const previousDigest = digest(await Bun.file(installed).bytes()) + const result = await powershell(`& ${quote(path.resolve(import.meta.dir, "../install.ps1"))} -Version ${quote(version)} -SkipSetup -NoModifyPath; exit $LASTEXITCODE`, { + OPENCODE_TEST_HOME: home, MENDCODE_GITHUB_BASE_URL: server.url.toString().replace(/\/$/, ""), + MENDCODE_UPDATE_PARENT_PID: undefined, MENDCODE_VERIFIED_SUMS_FILE: undefined, + MENDCODE_DB: path.join(home, "data", "test.db"), + }) + await fs.writeFile(path.join(home, "installer.log"), result.output) + const operations = (await fs.readdir(path.dirname(installed))).filter((name) => name.startsWith(".update.")) + assert.equal(operations.length, 1) + const operation = path.join(path.dirname(installed), operations[0]) + const status = await fs.readFile(path.join(operation, "status"), "utf8") + if (scenario === "success") { + assert.equal(result.code, 0, result.output) + assert.equal(digest(await Bun.file(installed).bytes()), binaryDigest) + assert.equal(digest(await Bun.file(path.join(operation, "previous")).bytes()), previousDigest) + assert.match(status, /phase=activated\n/) + assert.match(status, new RegExp(`binary_sha256=${binaryDigest}\\n`)) + } else { + assert.notEqual(result.code, 0, result.output) + assert.equal(digest(await Bun.file(installed).bytes()), previousDigest) + assert.match(status, /phase=failed\n/) + } + assert.equal(await Bun.file(path.join(home, "data", "test.db")).exists(), false) + console.log(`PASS Windows installer: ${scenario}`) + } +} finally { server.stop(true) } +console.log(`Evidence retained at ${root}; backend/TUI and deferred-replacement checks remain separate gates.`) From 27d5eb9661dc5c4d16e5ae8c5437d95f750166aa Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Sat, 5 Sep 2026 00:28:57 -0500 Subject: [PATCH 2/9] ci: exercise runtime and recovery contracts on Linux --- .github/workflows/runtime-recovery.yml | 65 ++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .github/workflows/runtime-recovery.yml diff --git a/.github/workflows/runtime-recovery.yml b/.github/workflows/runtime-recovery.yml new file mode 100644 index 00000000..26cb68a5 --- /dev/null +++ b/.github/workflows/runtime-recovery.yml @@ -0,0 +1,65 @@ +name: Runtime and recovery tests + +on: + pull_request: + paths: + - "src/mendcode/**" + - ".github/workflows/runtime-recovery.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: runtime-recovery-${{ github.ref }} + cancel-in-progress: true + +jobs: + contracts: + name: Linux runtime and recovery contracts + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + with: + persist-credentials: false + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 + with: + version: 11.0.9 + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version-file: src/mendcode/package.json + - name: Install frozen dependencies + working-directory: src/mendcode + run: pnpm --pm-on-fail=ignore install --frozen-lockfile --ignore-scripts + - name: Prepare deterministic test runtime + working-directory: src/mendcode/packages/opencode + run: | + bun run script/fix-node-pty.ts + MODELS_DEV_API_JSON="$PWD/test/tool/fixtures/models-api.json" bun run script/generate.ts + - name: Typecheck + working-directory: src/mendcode + run: bun run --cwd packages/opencode typecheck + - name: Release index contracts + working-directory: src/mendcode + run: node --test script/release-index.test.mjs + - name: Runtime and updater regressions + working-directory: src/mendcode/packages/opencode + shell: bash + run: | + set -euo pipefail + export MENDCODE_DB="${RUNNER_TEMP}/mendcode-contracts-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.db" + bun test --timeout 30000 \ + test/installation \ + test/storage/compatibility.test.ts test/storage/writer-lease.test.ts \ + test/cli/run-attach.test.ts test/cli/upgrade-channel.test.ts test/cli/upgrade-readonly.test.ts \ + test/cli/tui/shared-server.test.ts test/cli/tui/thread.test.ts \ + test/cli/tui/widgets-runtime.test.tsx test/cli/tui/widgets-tray.test.tsx \ + test/cli/tui/agent-command-panel.test.tsx \ + test/mend/concurrent-model-policy.test.ts \ + test/session/continuity.test.ts test/session/session-notes.test.ts test/session/llm.test.ts \ + test/server/usage.test.ts test/server/release-channel.test.ts test/server/upgrade-progress.test.ts + export MENDCODE_DB="${RUNNER_TEMP}/mendcode-executor-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.db" + bun test test/session/prompt.test.ts --timeout 30000 --test-name-pattern "continuity wakes the existing executor" From 736d0189e5b6e7480d6fd276e987ac2dae3b339f Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Sat, 5 Sep 2026 00:31:55 -0500 Subject: [PATCH 3/9] fix: normalize bundled auth plugins and escape run arguments --- .github/workflows/runtime-recovery.yml | 1 + src/mendcode/packages/opencode/src/cli/cmd/run.ts | 2 +- src/mendcode/packages/opencode/src/plugin/index.ts | 5 +++-- src/mendcode/packages/opencode/test/cli/run-attach.test.ts | 4 +++- .../packages/opencode/test/plugin/auth-override.test.ts | 4 ++++ 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/runtime-recovery.yml b/.github/workflows/runtime-recovery.yml index 26cb68a5..ba33ec7d 100644 --- a/.github/workflows/runtime-recovery.yml +++ b/.github/workflows/runtime-recovery.yml @@ -59,6 +59,7 @@ jobs: test/cli/tui/widgets-runtime.test.tsx test/cli/tui/widgets-tray.test.tsx \ test/cli/tui/agent-command-panel.test.tsx \ test/mend/concurrent-model-policy.test.ts \ + test/plugin/auth-override.test.ts \ test/session/continuity.test.ts test/session/session-notes.test.ts test/session/llm.test.ts \ test/server/usage.test.ts test/server/release-channel.test.ts test/server/upgrade-progress.test.ts export MENDCODE_DB="${RUNNER_TEMP}/mendcode-executor-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.db" diff --git a/src/mendcode/packages/opencode/src/cli/cmd/run.ts b/src/mendcode/packages/opencode/src/cli/cmd/run.ts index ada372c0..0020aea5 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/run.ts +++ b/src/mendcode/packages/opencode/src/cli/cmd/run.ts @@ -305,7 +305,7 @@ const ClientRunCommand = cmd({ }), handler: async (args) => { let message = [...args.message, ...(args["--"] || [])] - .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) + .map((arg) => (arg.includes(" ") ? JSON.stringify(arg) : arg)) .join(" ") const directory = (() => { diff --git a/src/mendcode/packages/opencode/src/plugin/index.ts b/src/mendcode/packages/opencode/src/plugin/index.ts index bf5aacef..797d0610 100644 --- a/src/mendcode/packages/opencode/src/plugin/index.ts +++ b/src/mendcode/packages/opencode/src/plugin/index.ts @@ -61,8 +61,9 @@ const INTERNAL_PLUGINS: PluginInstance[] = [ HerdrAgentStatePlugin, CodexAuthPlugin, CopilotAuthPlugin, - GitlabAuthPlugin, - PoeAuthPlugin, + // Published auth modules carry upstream SDK types; use the same legacy + // plugin boundary as installed modules instead of equating the SDKs. + ...getLegacyPlugins({ GitlabAuthPlugin, PoeAuthPlugin }), CloudflareWorkersAuthPlugin, CloudflareAIGatewayAuthPlugin, AzureAuthPlugin, diff --git a/src/mendcode/packages/opencode/test/cli/run-attach.test.ts b/src/mendcode/packages/opencode/test/cli/run-attach.test.ts index d58fd830..05335450 100644 --- a/src/mendcode/packages/opencode/test/cli/run-attach.test.ts +++ b/src/mendcode/packages/opencode/test/cli/run-attach.test.ts @@ -9,6 +9,7 @@ test.each(["completed", "failed", "http-rejected"])( const base = path.join(process.env.XDG_DATA_HOME!, `run-attach-${outcome}`) const db = path.join(base, "runtime.db") const sessionID = "ses_fixture" + const message = 'Inspect "C:\\fixture path\\file.txt"' let events: ReadableStreamDefaultController | undefined let prompt: unknown const server = Bun.serve({ @@ -80,7 +81,7 @@ test.each(["completed", "failed", "http-rejected"])( "fixture/model", "--format", "json", - "Fixture prompt", + message, ], { cwd: path.resolve(import.meta.dir, "../.."), @@ -115,6 +116,7 @@ test.each(["completed", "failed", "http-rejected"])( expect(stdout).toContain(failed ? '"status":"failed"' : '"status":"completed"') } expect(prompt).toMatchObject({ agent: "build", model: { providerID: "fixture", modelID: "model" } }) + expect(prompt).toMatchObject({ parts: [{ type: "text", text: `${JSON.stringify(message)}\n` }] }) expect(existsSync(db)).toBe(false) expect(existsSync(`${db}.writer-lock`)).toBe(false) } finally { diff --git a/src/mendcode/packages/opencode/test/plugin/auth-override.test.ts b/src/mendcode/packages/opencode/test/plugin/auth-override.test.ts index 0a8ba51a..c90e4ce1 100644 --- a/src/mendcode/packages/opencode/test/plugin/auth-override.test.ts +++ b/src/mendcode/packages/opencode/test/plugin/auth-override.test.ts @@ -105,6 +105,10 @@ describe("plugin.auth-override", () => { expect(copilot.length).toBe(1) expect(copilot[0].label).toBe("Test Override Auth") expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth") + for (const provider of ["gitlab", "poe"]) { + expect(plainMethods[ProviderID.make(provider)].some((method) => method.type === "oauth")).toBe(true) + expect(plainMethods[ProviderID.make(provider)].some((method) => method.type === "api")).toBe(true) + } }, 30000) }) From 4f2e8a9f702c2ad06ffe95112c6e23251c79d22a Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Sat, 5 Sep 2026 00:34:38 -0500 Subject: [PATCH 4/9] ci: isolate test processes and their temporary databases --- .github/workflows/runtime-recovery.yml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/runtime-recovery.yml b/.github/workflows/runtime-recovery.yml index ba33ec7d..80cf0e45 100644 --- a/.github/workflows/runtime-recovery.yml +++ b/.github/workflows/runtime-recovery.yml @@ -50,9 +50,13 @@ jobs: shell: bash run: | set -euo pipefail - export MENDCODE_DB="${RUNNER_TEMP}/mendcode-contracts-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.db" - bun test --timeout 30000 \ - test/installation \ + # Some suites replace Bun modules globally. Separate processes keep + # those mocks and the test/preload.ts database roots isolated. + run_test() { + bash -c 'export MENDCODE_DB="${TMPDIR:-/tmp}/opencode-test-data-$$/share/contracts.db"; exec bun test --timeout 30000 "$@"' bash "$@" + } + for test_file in \ + test/installation/*.test.ts \ test/storage/compatibility.test.ts test/storage/writer-lease.test.ts \ test/cli/run-attach.test.ts test/cli/upgrade-channel.test.ts test/cli/upgrade-readonly.test.ts \ test/cli/tui/shared-server.test.ts test/cli/tui/thread.test.ts \ @@ -61,6 +65,7 @@ jobs: test/mend/concurrent-model-policy.test.ts \ test/plugin/auth-override.test.ts \ test/session/continuity.test.ts test/session/session-notes.test.ts test/session/llm.test.ts \ - test/server/usage.test.ts test/server/release-channel.test.ts test/server/upgrade-progress.test.ts - export MENDCODE_DB="${RUNNER_TEMP}/mendcode-executor-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.db" - bun test test/session/prompt.test.ts --timeout 30000 --test-name-pattern "continuity wakes the existing executor" + test/server/usage.test.ts test/server/release-channel.test.ts test/server/upgrade-progress.test.ts; do + run_test "$test_file" + done + run_test test/session/prompt.test.ts --test-name-pattern "continuity wakes the existing executor" From fca5abeb024fd82ef0e92e29b0b8e970c4c03900 Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Sat, 5 Sep 2026 00:44:08 -0500 Subject: [PATCH 5/9] release: prepare v0.1.44-beta.1 --- .github/workflows/release.yml | 5 ++- CHANGELOG.md | 42 +++++++++++++++++++++ src/mendcode/packages/opencode/package.json | 2 +- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab9dcb2c..c64edf22 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -157,9 +157,10 @@ jobs: set -euo pipefail node --test script/release-index.test.mjs bun run --cwd packages/opencode typecheck - export MENDCODE_DB="${TMPDIR:-/tmp}/opencode-test-data-$$/share/release.db" cd packages/opencode - exec bun test test/installation test/cli/upgrade-channel.test.ts test/cli/tui/shared-server.test.ts test/cli/tui/thread.test.ts + for test_file in test/installation/*.test.ts test/cli/upgrade-channel.test.ts test/cli/tui/shared-server.test.ts test/cli/tui/thread.test.ts; do + bash -c 'export MENDCODE_DB="${TMPDIR:-/tmp}/opencode-test-data-$$/share/release.db"; exec bun test --timeout 30000 "$@"' bash "$test_file" + done - name: Upload release artifacts to workflow uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a diff --git a/CHANGELOG.md b/CHANGELOG.md index 852a6eb0..3190237d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## 0.1.44-beta.1 - 2026-09-05 + +This beta introduces release channels and updater recovery. It is an opt-in +prerelease; stable installations remain on the stable channel. + +### Fixed + +- Bound installer downloads, verify checksums, stage replacement atomically, + retain the previous executable, and record update and startup failures. +- Preserve explicit database paths when starting the shared backend and report + incompatible or unavailable backends instead of silently opening another writer. +- Route `run` and `stats` through the shared backend. Failed runs return a failure + exit status, and rejected requests no longer wait indefinitely for session events. +- Preserve quotes and backslashes in run arguments and load bundled GitLab/Poe + authentication plugins through the existing compatibility boundary. + +### Added + +- `mendcode upgrade channel`, `channel set stable|beta|nightly`, `--check`, and + compatibility-checked `--rollback`; channel selection never installs by itself. +- Immutable release indexes with workflow provenance verification and pinned + installers, plus separate beta/nightly publication workflows. +- Experimental async read tools, questions, session recall and versioned working + notes under the existing executor. All experimental flags default off. +- Ctrl+T widgets for questions and jobs, model-family prompt profiles, and optional + balanced reasoning Auto that preserves manual choices. + +### Limitations + +- Native OpenAI async/steering and async shell are not enabled. Windows rollback + is unavailable. Some local database commands still require closing the backend. +- An incompatible downgrade is blocked by this version; database backups are never + restored automatically. Keep beta opt-in when sharing sessions with stable. +- The original v0.1.43 existing-installation incident was not reproduced; these + changes address independently demonstrated updater and startup defects. + +### Validation + +- Linux CI: 153 Bun tests, release-index contracts, typecheck and security checks. +- Isolated macOS installation/upgrade and two-client TUI checks; no live-provider + parity or token-savings claim is made. + ## 0.1.43 - 2026-09-04 MendCode v0.1.43 adds the current OpenAI GPT-5.6 family and GPT-6 Astra to diff --git a/src/mendcode/packages/opencode/package.json b/src/mendcode/packages/opencode/package.json index 4c70e975..21534588 100644 --- a/src/mendcode/packages/opencode/package.json +++ b/src/mendcode/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "0.1.43", + "version": "0.1.44", "name": "mendcode", "type": "module", "license": "MIT", From 8160a9e8cc0a802d48e0b9772656b7810d6d3d75 Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Sat, 5 Sep 2026 00:49:37 -0500 Subject: [PATCH 6/9] ci: expose Windows installer errors with a verified fixture --- .github/workflows/windows-installer.yml | 49 +++++++++++++++++++ .../script/windows-installer-smoke.ts | 3 +- 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/windows-installer.yml diff --git a/.github/workflows/windows-installer.yml b/.github/workflows/windows-installer.yml new file mode 100644 index 00000000..91ad341b --- /dev/null +++ b/.github/workflows/windows-installer.yml @@ -0,0 +1,49 @@ +name: Windows installer contracts + +on: + pull_request: + paths: + - "src/mendcode/install.ps1" + - "src/mendcode/script/windows-installer-smoke.ts" + - ".github/workflows/windows-installer.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: windows-installer-${{ github.ref }} + cancel-in-progress: true + +jobs: + installer: + runs-on: windows-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version-file: src/mendcode/package.json + - name: Verify published executable fixture + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = "Stop" + $fixture = Join-Path $env:RUNNER_TEMP "mendcode-fixture" + gh release download v0.1.43 --repo MendCode/MendCode --pattern mendcode-windows-x64.zip --pattern SHA256SUMS --dir $fixture + if ($LASTEXITCODE -ne 0) { throw "Fixture download failed" } + $archive = Join-Path $fixture "mendcode-windows-x64.zip" + gh attestation verify $archive --repo MendCode/MendCode --signer-workflow MendCode/MendCode/.github/workflows/release.yml --deny-self-hosted-runners + if ($LASTEXITCODE -ne 0) { throw "Fixture provenance failed" } + Expand-Archive -LiteralPath $archive -DestinationPath (Join-Path $fixture "bin") + "MENDCODE_INSTALLER_TEST_BINARY=$(Join-Path $fixture 'bin/mendcode.exe')" >> $env:GITHUB_ENV + "MENDCODE_VERSION=0.1.43" >> $env:GITHUB_ENV + - name: Exercise current installer with verified fixture + working-directory: src/mendcode + shell: pwsh + run: | + bun run script/windows-installer-smoke.ts + if ($LASTEXITCODE -ne 0) { throw "Windows installer contract failed" } diff --git a/src/mendcode/script/windows-installer-smoke.ts b/src/mendcode/script/windows-installer-smoke.ts index 52d6b0b5..69d4026a 100644 --- a/src/mendcode/script/windows-installer-smoke.ts +++ b/src/mendcode/script/windows-installer-smoke.ts @@ -51,8 +51,9 @@ try { MENDCODE_DB: path.join(home, "data", "test.db"), }) await fs.writeFile(path.join(home, "installer.log"), result.output) + if (scenario === "success") assert.equal(result.code, 0, result.output) const operations = (await fs.readdir(path.dirname(installed))).filter((name) => name.startsWith(".update.")) - assert.equal(operations.length, 1) + assert.equal(operations.length, 1, `${scenario}: ${result.output}`) const operation = path.join(path.dirname(installed), operations[0]) const status = await fs.readFile(path.join(operation, "status"), "utf8") if (scenario === "success") { From 105f1bd7b7d3b67497cb1b797b43c6bc75ed13b6 Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Sat, 5 Sep 2026 00:54:09 -0500 Subject: [PATCH 7/9] fix: keep Windows installer compatible with PowerShell 5.1 --- src/mendcode/install.ps1 | 3 ++- src/mendcode/script/windows-installer-smoke.ts | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/mendcode/install.ps1 b/src/mendcode/install.ps1 index 879b7735..39aff39c 100644 --- a/src/mendcode/install.ps1 +++ b/src/mendcode/install.ps1 @@ -185,7 +185,8 @@ function Write-Step { function Write-Ok { param([string]$Message) - Write-Host "✓ $Message" -ForegroundColor Green + # Windows PowerShell 5.1 reads BOM-less scripts using the system code page. + Write-Host "$([char]0x2713) $Message" -ForegroundColor Green } function Invoke-MendCodeSetup { diff --git a/src/mendcode/script/windows-installer-smoke.ts b/src/mendcode/script/windows-installer-smoke.ts index 69d4026a..5c000f6c 100644 --- a/src/mendcode/script/windows-installer-smoke.ts +++ b/src/mendcode/script/windows-installer-smoke.ts @@ -22,6 +22,9 @@ async function powershell(source: string, env: Record Date: Sat, 5 Sep 2026 00:55:33 -0500 Subject: [PATCH 8/9] fix: pass a null backup path for atomic Windows status updates --- src/mendcode/install.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mendcode/install.ps1 b/src/mendcode/install.ps1 index 39aff39c..a5967c3b 100644 --- a/src/mendcode/install.ps1 +++ b/src/mendcode/install.ps1 @@ -44,7 +44,8 @@ function Write-AtomicText([string]$Path, [string]$Text) { $stream.Write($bytes, 0, $bytes.Length) $stream.Flush($true) } finally { $stream.Dispose() } - if ([IO.File]::Exists($Path)) { [IO.File]::Replace($temporary, $Path, $null) } + # A plain $null becomes an empty backup path in Windows PowerShell 5.1. + if ([IO.File]::Exists($Path)) { [IO.File]::Replace($temporary, $Path, [System.Management.Automation.Language.NullString]::Value) } else { [IO.File]::Move($temporary, $Path) } } From 080ef14749b6359745fd6ed8ab20c8e5ab184616 Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Sat, 5 Sep 2026 00:57:39 -0500 Subject: [PATCH 9/9] fix: verify Windows installer hashes without module autoloading --- src/mendcode/install.ps1 | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/mendcode/install.ps1 b/src/mendcode/install.ps1 index a5967c3b..0f9f9601 100644 --- a/src/mendcode/install.ps1 +++ b/src/mendcode/install.ps1 @@ -35,6 +35,14 @@ function Assert-RegularPath([string]$Path) { } } +function Get-MendCodeFileDigest([string]$Path) { + # Avoid module auto-loading differences when PowerShell 7 launches 5.1. + $hash = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { return [BitConverter]::ToString($hash.ComputeHash($stream)).Replace("-", "").ToLowerInvariant() } + finally { $stream.Dispose(); $hash.Dispose() } +} + function Write-AtomicText([string]$Path, [string]$Text) { Assert-RegularPath $Path $temporary = "$Path.$([Guid]::NewGuid().ToString('N')).tmp" @@ -430,7 +438,7 @@ function Assert-Candidate([string]$Candidate, [string]$Target) { function Activate-Candidate { $candidate = Join-Path $script:Operation "candidate.exe" Assert-RegularPath $candidate - if ((Get-FileHash -LiteralPath $candidate -Algorithm SHA256).Hash.ToLowerInvariant() -ne $script:BinaryDigest) { + if ((Get-MendCodeFileDigest $candidate) -ne $script:BinaryDigest) { throw "Staged executable changed before activation." } $destination = Join-Path $InstallDir "mendcode.exe" @@ -438,7 +446,7 @@ function Activate-Candidate { $previous = Join-Path $script:Operation "previous" if (Test-Path -LiteralPath $previous) { throw "Previous executable is already retained; inspect this operation before retrying." } if ([IO.File]::Exists($destination)) { - $script:PreviousDigest = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash.ToLowerInvariant() + $script:PreviousDigest = Get-MendCodeFileDigest $destination } $script:Phase = "activating" Write-UpdateStatus @@ -514,11 +522,11 @@ try { if ($_ -match ('^([a-fA-F0-9]{64})\s+\*?(?:\./)?' + [Regex]::Escape($filename) + '$')) { $Matches[1].ToLowerInvariant() } }) if ($digests.Count -ne 1) { throw "Missing, duplicate, or invalid release checksum." } - if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $digests[0]) { throw "Release checksum mismatch. Current binary preserved." } + if ((Get-MendCodeFileDigest $archive) -ne $digests[0]) { throw "Release checksum mismatch. Current binary preserved." } $candidate = Join-Path $script:Operation "candidate.exe" Expand-VerifiedExecutable $archive $candidate Assert-Candidate $candidate $target - $script:BinaryDigest = (Get-FileHash -LiteralPath $candidate -Algorithm SHA256).Hash.ToLowerInvariant() + $script:BinaryDigest = Get-MendCodeFileDigest $candidate Write-UpdateStatus $deferred = Start-DeferredUpdate if (-not $deferred) {