mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
feat(acp): support desktop qwen integration (#4728)
* feat(acp): support desktop qwen integration * feat(providers): add qwen3.7 standard models
This commit is contained in:
parent
9e4c87a7e4
commit
423cac110c
28 changed files with 9267 additions and 37 deletions
545
.github/workflows/desktop-release.yml
vendored
Normal file
545
.github/workflows/desktop-release.yml
vendored
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
name: 'Desktop Release'
|
||||
|
||||
run-name: 'Desktop release ${{ inputs.version }}'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Desktop app version to release, for example 0.0.2 or v0.0.2'
|
||||
required: true
|
||||
type: 'string'
|
||||
release_name:
|
||||
description: 'Release title. Defaults to the tag.'
|
||||
required: false
|
||||
type: 'string'
|
||||
qwen_code_source:
|
||||
description: 'Qwen Code runtime source to vendor into the desktop app.'
|
||||
required: true
|
||||
default: 'source_branch'
|
||||
type: 'choice'
|
||||
options:
|
||||
- 'npm_latest'
|
||||
- 'source_branch'
|
||||
qwen_code_ref:
|
||||
description: 'Current repository branch, tag, or commit when qwen_code_source is source_branch.'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: 'string'
|
||||
dry_run:
|
||||
description: 'Build installers only. Do not create or update a GitHub Release.'
|
||||
required: true
|
||||
default: true
|
||||
type: 'boolean'
|
||||
draft:
|
||||
description: 'Create a draft release.'
|
||||
required: true
|
||||
default: true
|
||||
type: 'boolean'
|
||||
prerelease:
|
||||
description: 'Mark the release as a prerelease.'
|
||||
required: true
|
||||
default: false
|
||||
type: 'boolean'
|
||||
clobber:
|
||||
description: 'Replace same-named assets when uploading to an existing release.'
|
||||
required: true
|
||||
default: false
|
||||
type: 'boolean'
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
|
||||
concurrency:
|
||||
group: 'desktop-release-${{ inputs.version }}'
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
BUN_VERSION: '1.3.9'
|
||||
CRAFT_BRAND: 'qwen-code'
|
||||
|
||||
jobs:
|
||||
release_metadata:
|
||||
name: 'Prepare Release Source'
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: 'write'
|
||||
outputs:
|
||||
release_branch: '${{ steps.release-branch.outputs.branch }}'
|
||||
release_ref: '${{ steps.release-branch.outputs.ref }}'
|
||||
tag: '${{ steps.release-version.outputs.tag }}'
|
||||
version: '${{ steps.release-version.outputs.version }}'
|
||||
|
||||
steps:
|
||||
- name: 'Check out source'
|
||||
uses: 'actions/checkout@v4'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Set up Node'
|
||||
uses: 'actions/setup-node@v6'
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: 'Set up Bun'
|
||||
uses: 'oven-sh/setup-bun@v2'
|
||||
with:
|
||||
bun-version: '${{ env.BUN_VERSION }}'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
working-directory: 'packages/desktop'
|
||||
run: 'bun install --frozen-lockfile'
|
||||
|
||||
- name: 'Configure Git user'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: 'Require main for publishing'
|
||||
if: '${{ inputs.dry_run == false }}'
|
||||
env:
|
||||
SOURCE_REF: '${{ github.ref_name }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$SOURCE_REF" != "main" ]; then
|
||||
echo "::error::Desktop releases with dry_run=false must be run from main. Current ref: $SOURCE_REF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 'Bump desktop version'
|
||||
working-directory: 'packages/desktop'
|
||||
env:
|
||||
INPUT_VERSION: '${{ inputs.version }}'
|
||||
run: 'bun run bump-desktop-version "$INPUT_VERSION"'
|
||||
|
||||
- name: 'Validate release version'
|
||||
working-directory: 'packages/desktop'
|
||||
id: 'release-version'
|
||||
env:
|
||||
INPUT_VERSION: '${{ inputs.version }}'
|
||||
run: 'bun run check-release-version --version "$INPUT_VERSION"'
|
||||
|
||||
- name: 'Create release branch'
|
||||
working-directory: 'packages/desktop'
|
||||
id: 'release-branch'
|
||||
env:
|
||||
IS_DRY_RUN: '${{ inputs.dry_run }}'
|
||||
RELEASE_TAG: '${{ steps.release-version.outputs.tag }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
branch="release/desktop-${RELEASE_TAG}"
|
||||
git switch -C "$branch"
|
||||
git add package.json apps/electron/package.json packages/shared/package.json
|
||||
|
||||
if git diff --staged --quiet; then
|
||||
echo "No desktop version changes to commit."
|
||||
else
|
||||
git commit -m "chore(release): desktop ${RELEASE_TAG}"
|
||||
fi
|
||||
|
||||
echo "branch=$branch" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "$IS_DRY_RUN" = "false" ]; then
|
||||
remote_sha="$(git ls-remote --heads origin "$branch" | awk '{print $1}')"
|
||||
if [ -n "$remote_sha" ]; then
|
||||
git push --force-with-lease="refs/heads/$branch:$remote_sha" origin "HEAD:refs/heads/$branch"
|
||||
else
|
||||
git push origin "HEAD:refs/heads/$branch"
|
||||
fi
|
||||
echo "ref=$branch" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Dry run enabled. Skipping release branch push."
|
||||
echo "ref=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
name: 'Build ${{ matrix.name }}'
|
||||
runs-on: '${{ matrix.os }}'
|
||||
timeout-minutes: 90
|
||||
needs: 'release_metadata'
|
||||
env:
|
||||
RELEASE_TAG: '${{ needs.release_metadata.outputs.tag }}'
|
||||
RELEASE_VERSION: '${{ needs.release_metadata.outputs.version }}'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: 'macOS'
|
||||
os: 'macos-latest'
|
||||
command: 'bun run dist:mac:no-publish'
|
||||
- name: 'Windows'
|
||||
os: 'windows-latest'
|
||||
command: 'bun run dist:win:no-publish'
|
||||
- name: 'Linux'
|
||||
os: 'ubuntu-22.04'
|
||||
command: 'bun run dist:linux:no-publish'
|
||||
|
||||
steps:
|
||||
- name: 'Check out source'
|
||||
uses: 'actions/checkout@v4'
|
||||
with:
|
||||
ref: '${{ needs.release_metadata.outputs.release_ref }}'
|
||||
|
||||
- name: 'Set up Node'
|
||||
uses: 'actions/setup-node@v6'
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: 'Check out Qwen Code source'
|
||||
if: "${{ inputs.qwen_code_source == 'source_branch' }}"
|
||||
shell: 'bash'
|
||||
env:
|
||||
QWEN_CODE_REF_INPUT: '${{ inputs.qwen_code_ref }}'
|
||||
QWEN_CODE_SOURCE_ROOT: '${{ runner.temp }}/qwen-code-source'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "$QWEN_CODE_REF_INPUT" ]; then
|
||||
echo "::error::qwen_code_ref is required when qwen_code_source is source_branch."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$QWEN_CODE_SOURCE_ROOT"
|
||||
git init "$QWEN_CODE_SOURCE_ROOT"
|
||||
git -C "$QWEN_CODE_SOURCE_ROOT" remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
|
||||
if ! git -C "$QWEN_CODE_SOURCE_ROOT" fetch --depth=1 origin "$QWEN_CODE_REF_INPUT"; then
|
||||
if ! git -C "$QWEN_CODE_SOURCE_ROOT" fetch --depth=1 origin "refs/heads/$QWEN_CODE_REF_INPUT"; then
|
||||
git -C "$QWEN_CODE_SOURCE_ROOT" fetch --depth=1 origin "refs/tags/$QWEN_CODE_REF_INPUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
git -C "$QWEN_CODE_SOURCE_ROOT" checkout --detach FETCH_HEAD
|
||||
git config --global --add safe.directory "$QWEN_CODE_SOURCE_ROOT"
|
||||
|
||||
- name: 'Set up Bun'
|
||||
uses: 'oven-sh/setup-bun@v2'
|
||||
with:
|
||||
bun-version: '${{ env.BUN_VERSION }}'
|
||||
|
||||
- name: 'Install Linux packaging dependencies'
|
||||
if: "runner.os == 'Linux'"
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libfuse2
|
||||
|
||||
- name: 'Install dependencies'
|
||||
working-directory: 'packages/desktop'
|
||||
run: 'bun install --frozen-lockfile'
|
||||
|
||||
- name: 'Install Qwen Code source dependencies'
|
||||
if: "${{ inputs.qwen_code_source == 'source_branch' }}"
|
||||
working-directory: '${{ runner.temp }}/qwen-code-source'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Bump desktop version'
|
||||
working-directory: 'packages/desktop'
|
||||
run: 'bun run bump-desktop-version "${{ needs.release_metadata.outputs.version }}"'
|
||||
|
||||
- name: 'Confirm release version'
|
||||
working-directory: 'packages/desktop'
|
||||
run: 'bun run check-release-version --version "${{ needs.release_metadata.outputs.version }}"'
|
||||
|
||||
- name: 'Configure Qwen Code runtime source'
|
||||
shell: 'bash'
|
||||
env:
|
||||
QWEN_CODE_REF_INPUT: '${{ inputs.qwen_code_ref }}'
|
||||
QWEN_CODE_SOURCE_INPUT: '${{ inputs.qwen_code_source }}'
|
||||
QWEN_CODE_SOURCE_ROOT: '${{ runner.temp }}/qwen-code-source'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
case "$QWEN_CODE_SOURCE_INPUT" in
|
||||
npm_latest)
|
||||
echo "QWEN_CODE_VERSION=latest" >> "$GITHUB_ENV"
|
||||
echo "Using Qwen Code runtime from npm dist-tag: latest"
|
||||
;;
|
||||
source_branch)
|
||||
if [ -z "$QWEN_CODE_REF_INPUT" ]; then
|
||||
echo "::error::qwen_code_ref is required when qwen_code_source is source_branch."
|
||||
exit 1
|
||||
fi
|
||||
echo "QWEN_CODE_ROOT=$QWEN_CODE_SOURCE_ROOT" >> "$GITHUB_ENV"
|
||||
echo "Using Qwen Code runtime from ${GITHUB_REPOSITORY} ref: $QWEN_CODE_REF_INPUT"
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown qwen_code_source: $QWEN_CODE_SOURCE_INPUT"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: 'Verify desktop update feed target'
|
||||
working-directory: 'packages/desktop'
|
||||
shell: 'bash'
|
||||
env:
|
||||
EXPECTED_REPOSITORY: '${{ github.repository }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
bun run electron:builder-config
|
||||
|
||||
actual_repository="$(node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
const config = yaml.load(
|
||||
fs.readFileSync('apps/electron/electron-builder.generated.yml', 'utf8'),
|
||||
);
|
||||
const publish = config?.publish;
|
||||
if (
|
||||
!publish ||
|
||||
publish.provider !== 'github' ||
|
||||
!publish.owner ||
|
||||
!publish.repo
|
||||
) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`${publish.owner}/${publish.repo}`);
|
||||
NODE
|
||||
)"
|
||||
|
||||
if [ "$actual_repository" != "$EXPECTED_REPOSITORY" ]; then
|
||||
echo "::error::Desktop update feed points to $actual_repository, expected $EXPECTED_REPOSITORY."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Desktop update feed: https://github.com/${actual_repository}/releases"
|
||||
|
||||
- name: 'Configure optional signing secrets'
|
||||
shell: 'bash'
|
||||
env:
|
||||
APPLE_APP_SPECIFIC_PASSWORD_SECRET: '${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}'
|
||||
APPLE_ID_SECRET: '${{ secrets.APPLE_ID }}'
|
||||
APPLE_TEAM_ID_SECRET: '${{ secrets.APPLE_TEAM_ID }}'
|
||||
CSC_KEY_PASSWORD_SECRET: '${{ secrets.CSC_KEY_PASSWORD }}'
|
||||
CSC_LINK_SECRET: '${{ secrets.CSC_LINK }}'
|
||||
SENTRY_ELECTRON_INGEST_URL_SECRET: '${{ secrets.SENTRY_ELECTRON_INGEST_URL }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
append_env() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
|
||||
if [ -z "$value" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
{
|
||||
echo "$name<<__${name}__"
|
||||
printf '%s\n' "$value"
|
||||
echo "__${name}__"
|
||||
} >> "$GITHUB_ENV"
|
||||
}
|
||||
|
||||
if [ -n "$CSC_LINK_SECRET" ]; then
|
||||
append_env "CSC_LINK" "$CSC_LINK_SECRET"
|
||||
append_env "CSC_KEY_PASSWORD" "$CSC_KEY_PASSWORD_SECRET"
|
||||
append_env "APPLE_ID" "$APPLE_ID_SECRET"
|
||||
append_env "APPLE_APP_SPECIFIC_PASSWORD" "$APPLE_APP_SPECIFIC_PASSWORD_SECRET"
|
||||
append_env "APPLE_TEAM_ID" "$APPLE_TEAM_ID_SECRET"
|
||||
echo "CSC_IDENTITY_AUTO_DISCOVERY=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "CSC_IDENTITY_AUTO_DISCOVERY=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
append_env "SENTRY_ELECTRON_INGEST_URL" "$SENTRY_ELECTRON_INGEST_URL_SECRET"
|
||||
|
||||
- name: 'Build desktop installer'
|
||||
working-directory: 'packages/desktop'
|
||||
# Build jobs only produce artifacts. The publish job below owns GitHub
|
||||
# Release creation/upload so dry-run, draft, prerelease, and replace
|
||||
# behavior stays centralized.
|
||||
run: '${{ matrix.command }}'
|
||||
|
||||
- name: 'Upload installer artifacts'
|
||||
uses: 'actions/upload-artifact@v4'
|
||||
with:
|
||||
name: 'desktop-${{ matrix.name }}'
|
||||
if-no-files-found: 'error'
|
||||
retention-days: 14
|
||||
path: |
|
||||
packages/desktop/apps/electron/release/*.AppImage
|
||||
packages/desktop/apps/electron/release/*.blockmap
|
||||
packages/desktop/apps/electron/release/*.dmg
|
||||
packages/desktop/apps/electron/release/*.exe
|
||||
packages/desktop/apps/electron/release/*.yml
|
||||
packages/desktop/apps/electron/release/*.zip
|
||||
|
||||
publish:
|
||||
name: 'Publish GitHub Release'
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 20
|
||||
needs:
|
||||
- 'build'
|
||||
- 'release_metadata'
|
||||
if: '${{ inputs.dry_run == false }}'
|
||||
permissions:
|
||||
contents: 'write'
|
||||
env:
|
||||
RELEASE_TAG: '${{ needs.release_metadata.outputs.tag }}'
|
||||
RELEASE_VERSION: '${{ needs.release_metadata.outputs.version }}'
|
||||
|
||||
steps:
|
||||
- name: 'Download installer artifacts'
|
||||
uses: 'actions/download-artifact@v4'
|
||||
with:
|
||||
path: 'release-assets'
|
||||
merge-multiple: true
|
||||
|
||||
- name: 'Publish release assets'
|
||||
env:
|
||||
GH_REPO: '${{ github.repository }}'
|
||||
GH_TOKEN: '${{ github.token }}'
|
||||
RELEASE_DRAFT: '${{ inputs.draft }}'
|
||||
RELEASE_NAME: '${{ inputs.release_name }}'
|
||||
RELEASE_PRERELEASE: '${{ inputs.prerelease }}'
|
||||
RELEASE_TARGET: '${{ needs.release_metadata.outputs.release_ref }}'
|
||||
UPLOAD_CLOBBER: '${{ inputs.clobber }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
assets=()
|
||||
while IFS= read -r -d '' file; do
|
||||
assets+=("$file")
|
||||
done < <(find release-assets -type f -print0 | sort -z)
|
||||
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release assets were downloaded."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Release assets:\n'
|
||||
printf ' %s\n' "${assets[@]}"
|
||||
|
||||
title="${RELEASE_NAME:-$RELEASE_TAG}"
|
||||
|
||||
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
upload_args=("$RELEASE_TAG" "${assets[@]}")
|
||||
if [ "$UPLOAD_CLOBBER" = "true" ]; then
|
||||
upload_args+=(--clobber)
|
||||
fi
|
||||
gh release upload "${upload_args[@]}"
|
||||
else
|
||||
create_args=(
|
||||
"$RELEASE_TAG"
|
||||
"${assets[@]}"
|
||||
--generate-notes
|
||||
--target "$RELEASE_TARGET"
|
||||
--title "$title"
|
||||
)
|
||||
if [ "$RELEASE_DRAFT" = "true" ]; then
|
||||
create_args+=(--draft)
|
||||
fi
|
||||
if [ "$RELEASE_PRERELEASE" = "true" ]; then
|
||||
create_args+=(--prerelease)
|
||||
fi
|
||||
gh release create "${create_args[@]}"
|
||||
fi
|
||||
|
||||
sync-version:
|
||||
name: 'Sync Release Version to Main'
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 10
|
||||
needs:
|
||||
- 'publish'
|
||||
- 'release_metadata'
|
||||
if: '${{ inputs.dry_run == false && inputs.draft == false }}'
|
||||
permissions:
|
||||
contents: 'write'
|
||||
pull-requests: 'write'
|
||||
|
||||
steps:
|
||||
- name: 'Create version sync PR'
|
||||
id: 'version-pr'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.CI_BOT_PAT || github.token }}'
|
||||
RELEASE_BRANCH: '${{ needs.release_metadata.outputs.release_branch }}'
|
||||
RELEASE_TAG: '${{ needs.release_metadata.outputs.tag }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
pr_url="$(gh pr list \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--head "$RELEASE_BRANCH" \
|
||||
--base main \
|
||||
--json url \
|
||||
--jq '.[0].url')"
|
||||
|
||||
if [ -z "$pr_url" ]; then
|
||||
pr_url="$(gh pr create \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--base main \
|
||||
--head "$RELEASE_BRANCH" \
|
||||
--title "chore(release): desktop ${RELEASE_TAG}" \
|
||||
--body "Automated desktop release PR for ${RELEASE_TAG}. Syncs desktop package versions on main.")"
|
||||
fi
|
||||
|
||||
echo "url=$pr_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Enable auto-merge'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.CI_BOT_PAT || github.token }}'
|
||||
PR_URL: '${{ steps.version-pr.outputs.url }}'
|
||||
RELEASE_TAG: '${{ needs.release_metadata.outputs.tag }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
gh pr merge "$PR_URL" \
|
||||
--squash \
|
||||
--auto \
|
||||
--delete-branch \
|
||||
--subject "chore(release): desktop ${RELEASE_TAG} [skip ci]"
|
||||
|
||||
dry-run-summary:
|
||||
name: 'Dry Run Summary'
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 10
|
||||
needs:
|
||||
- 'build'
|
||||
- 'release_metadata'
|
||||
if: '${{ inputs.dry_run }}'
|
||||
env:
|
||||
RELEASE_TAG: '${{ needs.release_metadata.outputs.tag }}'
|
||||
RELEASE_VERSION: '${{ needs.release_metadata.outputs.version }}'
|
||||
|
||||
steps:
|
||||
- name: 'Download installer artifacts'
|
||||
uses: 'actions/download-artifact@v4'
|
||||
with:
|
||||
path: 'release-assets'
|
||||
merge-multiple: true
|
||||
|
||||
- name: 'List release assets'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
assets=()
|
||||
while IFS= read -r -d '' file; do
|
||||
assets+=("$file")
|
||||
done < <(find release-assets -type f -print0 | sort -z)
|
||||
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release assets were downloaded."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "## Desktop release dry run"
|
||||
echo
|
||||
echo "Version: $RELEASE_VERSION"
|
||||
echo "Release tag: $RELEASE_TAG"
|
||||
echo
|
||||
echo "Built ${#assets[@]} asset(s). No GitHub Release was created or updated."
|
||||
echo
|
||||
echo "| Asset | Size |"
|
||||
echo "| --- | ---: |"
|
||||
for file in "${assets[@]}"; do
|
||||
size=$(du -h "$file" | cut -f1)
|
||||
echo "| $(basename "$file") | $size |"
|
||||
done
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
102
.qwen/skills/openwork-desktop-sync/SKILL.md
Normal file
102
.qwen/skills/openwork-desktop-sync/SKILL.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
---
|
||||
name: openwork-desktop-sync
|
||||
description: Sync qwen-code packages/desktop with modelstudioai/openwork using commit-by-commit path migration, not subtree split or tree overwrite. Use when exporting qwen-code desktop changes to OpenWork, importing OpenWork desktop changes into qwen-code, preserving target-owned overlay files such as README.md, resolving sync conflicts, or preparing sync PR branches between the two repositories.
|
||||
---
|
||||
|
||||
# OpenWork Desktop Sync
|
||||
|
||||
Use this skill to sync desktop changes between this qwen-code repo and an
|
||||
OpenWork checkout. The repository script owns the Git mechanics:
|
||||
|
||||
```bash
|
||||
OPENWORK_DIR=/path/to/openwork bun run desktop-openwork-sync --mode export
|
||||
```
|
||||
|
||||
Default overlay is `README.md`. Overlay paths are excluded from migrated
|
||||
commits and stay target-owned.
|
||||
|
||||
```bash
|
||||
OPENWORK_OVERLAY_PATHS='README.md'
|
||||
```
|
||||
|
||||
## Contract
|
||||
|
||||
This is commit-by-commit path migration, not snapshot replacement. The script
|
||||
walks source commits from `source-base..source-head`, rewrites paths between
|
||||
qwen-code `packages/desktop` and the OpenWork repository root, then applies each
|
||||
commit with `git apply -3`.
|
||||
|
||||
Commits that already came from the receiving repository are skipped by their
|
||||
sync trailers. During import, qwen-code-origin export commits are skipped;
|
||||
during export, OpenWork-origin import commits are skipped.
|
||||
|
||||
Merge commits are not migrated as merge commits. The script migrates the regular
|
||||
commits inside the merged branch; when it later sees the merge wrapper, it
|
||||
checks that the regular commits were already handled and that the merge tree
|
||||
matches Git's automatic merge result. If the merge wrapper contains manual
|
||||
resolution changes, the sync stops so the agent can convert that resolution into
|
||||
a normal follow-up commit.
|
||||
|
||||
Target-side changes are preserved unless a migrated source commit touches the
|
||||
same hunk. If that happens, Git leaves a normal conflict for the agent to
|
||||
resolve. Do not use `git subtree split` or full tree replacement for normal
|
||||
sync.
|
||||
|
||||
Successful sync commits include trailers such as `Qwen-Code-Commit` or
|
||||
`OpenWork-Commit`. Later syncs can use the latest trailer as the next source
|
||||
base. The first sync needs an explicit source base when no previous sync trailer
|
||||
exists:
|
||||
|
||||
```bash
|
||||
bun run desktop-openwork-sync --mode export --source-base <qwen-code-ref>
|
||||
bun run desktop-openwork-sync --mode import --source-base <openwork-ref>
|
||||
```
|
||||
|
||||
## Modes
|
||||
|
||||
- `--mode export`: qwen-code `packages/desktop` commits -> OpenWork.
|
||||
- `--mode import`: OpenWork commits -> qwen-code `packages/desktop`.
|
||||
- `--mode auto`: guardrail only; use explicit directions for real sync.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Confirm repo paths and clean worktrees:
|
||||
|
||||
```bash
|
||||
git rev-parse --show-toplevel
|
||||
git -C /path/to/openwork rev-parse --show-toplevel
|
||||
git status --short
|
||||
git -C /path/to/openwork status --short
|
||||
```
|
||||
|
||||
2. Run the requested direction:
|
||||
|
||||
```bash
|
||||
OPENWORK_DIR=/path/to/openwork \
|
||||
OPENWORK_OVERLAY_PATHS='README.md' \
|
||||
bun run desktop-openwork-sync --mode export --source-base <qwen-code-ref>
|
||||
```
|
||||
|
||||
3. If Git reports conflicts, resolve only the conflicted hunks, preserving
|
||||
target-owned repository metadata unless the source change intentionally
|
||||
updates that same behavior.
|
||||
|
||||
4. After sync, verify:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git diff --check HEAD
|
||||
git diff --name-status <target-base>..HEAD
|
||||
```
|
||||
|
||||
5. If the user asked to publish, push the branch and create a PR after the
|
||||
branch is clean.
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep only `README.md` as the default overlay unless the user adds paths to
|
||||
`OPENWORK_OVERLAY_PATHS`.
|
||||
- OpenWork-specific files not touched by source commits must remain unchanged.
|
||||
- Prefer PR branches. The script prints the push command for export branches.
|
||||
- Do not manually import PR merge commits. Let the script migrate regular
|
||||
commits and treat merge commits as wrappers.
|
||||
2049
package-lock.json
generated
2049
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -74,6 +74,7 @@
|
|||
"release:version": "node scripts/version.js",
|
||||
"telemetry": "node scripts/telemetry.js",
|
||||
"check:lockfile": "node scripts/check-lockfile.js",
|
||||
"desktop-openwork-sync": "bun run scripts/desktop-openwork-sync.ts",
|
||||
"clean": "node scripts/clean.js",
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -100,6 +100,15 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
|
|||
}),
|
||||
APPROVAL_MODE_INFO: {},
|
||||
APPROVAL_MODES: [],
|
||||
DEFAULT_STOP_HOOK_BLOCK_CAP: 8,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES: 1000,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD: 25_000,
|
||||
ApprovalMode: {
|
||||
DEFAULT: 'default',
|
||||
AUTO_EDIT: 'auto-edit',
|
||||
YOLO: 'yolo',
|
||||
PLAN: 'plan',
|
||||
},
|
||||
AuthType: {},
|
||||
clearCachedCredentialFile: vi.fn(),
|
||||
QwenOAuth2Event: {},
|
||||
|
|
@ -113,6 +122,27 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
|
|||
SessionStartSource: { Startup: 'startup', Resume: 'resume' },
|
||||
SessionEndReason: { PromptInputExit: 'prompt_input_exit', Other: 'other' },
|
||||
restoreWorktreeContext: mockRestoreWorktreeContext,
|
||||
HookEventName: {
|
||||
PreToolUse: 'PreToolUse',
|
||||
PostToolUse: 'PostToolUse',
|
||||
PostToolUseFailure: 'PostToolUseFailure',
|
||||
PostToolBatch: 'PostToolBatch',
|
||||
Notification: 'Notification',
|
||||
UserPromptSubmit: 'UserPromptSubmit',
|
||||
UserPromptExpansion: 'UserPromptExpansion',
|
||||
SessionStart: 'SessionStart',
|
||||
Stop: 'Stop',
|
||||
SubagentStart: 'SubagentStart',
|
||||
SubagentStop: 'SubagentStop',
|
||||
PreCompact: 'PreCompact',
|
||||
PostCompact: 'PostCompact',
|
||||
SessionEnd: 'SessionEnd',
|
||||
PermissionRequest: 'PermissionRequest',
|
||||
PermissionDenied: 'PermissionDenied',
|
||||
StopFailure: 'StopFailure',
|
||||
TodoCreated: 'TodoCreated',
|
||||
TodoCompleted: 'TodoCompleted',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./runtimeOutputDirContext.js', () => ({
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ describe('Session', () => {
|
|||
let getAvailableCommandsSpy: ReturnType<typeof vi.fn>;
|
||||
let mockChatRecordingService: {
|
||||
recordUserMessage: ReturnType<typeof vi.fn>;
|
||||
recordMidTurnUserMessage: ReturnType<typeof vi.fn>;
|
||||
recordUiTelemetryEvent: ReturnType<typeof vi.fn>;
|
||||
recordToolResult: ReturnType<typeof vi.fn>;
|
||||
recordSlashCommand: ReturnType<typeof vi.fn>;
|
||||
|
|
@ -254,6 +255,7 @@ describe('Session', () => {
|
|||
|
||||
mockChatRecordingService = {
|
||||
recordUserMessage: vi.fn(),
|
||||
recordMidTurnUserMessage: vi.fn(),
|
||||
recordUiTelemetryEvent: vi.fn(),
|
||||
recordToolResult: vi.fn(),
|
||||
recordSlashCommand: vi.fn(),
|
||||
|
|
@ -860,12 +862,22 @@ describe('Session', () => {
|
|||
},
|
||||
]);
|
||||
mockConfig.getSkillManager = vi.fn().mockReturnValue({
|
||||
listSkills: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ name: 'code-review-expert' },
|
||||
{ name: 'verification-pack' },
|
||||
]),
|
||||
listSkills: vi.fn().mockResolvedValue([
|
||||
{
|
||||
name: 'code-review-expert',
|
||||
description: 'Review code changes',
|
||||
body: 'Review instructions',
|
||||
filePath: '/skills/code-review-expert/SKILL.md',
|
||||
level: 'user',
|
||||
},
|
||||
{
|
||||
name: 'verification-pack',
|
||||
description: 'Verify changes',
|
||||
body: 'Verification instructions',
|
||||
filePath: '/skills/verification-pack/SKILL.md',
|
||||
level: 'project',
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
await session.sendAvailableCommandsUpdate();
|
||||
|
|
@ -892,11 +904,134 @@ describe('Session', () => {
|
|||
],
|
||||
_meta: {
|
||||
availableSkills: ['code-review-expert', 'verification-pack'],
|
||||
availableSkillDetails: [
|
||||
{
|
||||
name: 'code-review-expert',
|
||||
description: 'Review code changes',
|
||||
body: 'Review instructions',
|
||||
filePath: '/skills/code-review-expert/SKILL.md',
|
||||
level: 'user',
|
||||
modelInvocable: true,
|
||||
},
|
||||
{
|
||||
name: 'verification-pack',
|
||||
description: 'Verify changes',
|
||||
body: 'Verification instructions',
|
||||
filePath: '/skills/verification-pack/SKILL.md',
|
||||
level: 'project',
|
||||
modelInvocable: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('derives skill details from skill slash commands', async () => {
|
||||
getAvailableCommandsSpy.mockResolvedValueOnce([
|
||||
{
|
||||
name: 'batch',
|
||||
description: 'Run a batch operation',
|
||||
kind: 'skill',
|
||||
argumentHint: '<operation> <file-pattern>',
|
||||
skillDetail: {
|
||||
name: 'batch',
|
||||
description: 'Run a batch operation',
|
||||
body: 'Batch instructions',
|
||||
level: 'bundled',
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockConfig.getSkillManager = vi.fn().mockReturnValue(null);
|
||||
|
||||
await session.sendAvailableCommandsUpdate();
|
||||
|
||||
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: 'test-session-id',
|
||||
update: {
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands: [
|
||||
{
|
||||
name: 'batch',
|
||||
description: 'Run a batch operation',
|
||||
input: { hint: '<operation> <file-pattern>' },
|
||||
_meta: {
|
||||
argumentHint: '<operation> <file-pattern>',
|
||||
source: undefined,
|
||||
sourceLabel: undefined,
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'],
|
||||
subcommands: [],
|
||||
modelInvocable: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
_meta: {
|
||||
availableSkills: ['batch'],
|
||||
availableSkillDetails: [
|
||||
{
|
||||
name: 'batch',
|
||||
description: 'Run a batch operation',
|
||||
body: 'Batch instructions',
|
||||
level: 'bundled',
|
||||
modelInvocable: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('derives availableSkills from skillManager and skill slash commands combined', async () => {
|
||||
// Both sources contribute: a skillManager skill AND a bundled skill
|
||||
// slash-command. The unconditional derivation must list both and keep
|
||||
// availableSkills consistent with availableSkillDetails (the `??=` fix).
|
||||
getAvailableCommandsSpy.mockResolvedValueOnce([
|
||||
{
|
||||
name: 'batch',
|
||||
description: 'Run a batch operation',
|
||||
kind: 'skill',
|
||||
skillDetail: {
|
||||
name: 'batch',
|
||||
description: 'Run a batch operation',
|
||||
body: 'Batch instructions',
|
||||
level: 'bundled',
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockConfig.getSkillManager = vi.fn().mockReturnValue({
|
||||
listSkills: vi.fn().mockResolvedValue([
|
||||
{
|
||||
name: 'mgr-skill',
|
||||
description: 'From the skill manager',
|
||||
body: 'Manager instructions',
|
||||
filePath: '/skills/mgr-skill/SKILL.md',
|
||||
level: 'user',
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
await session.sendAvailableCommandsUpdate();
|
||||
|
||||
const meta = (
|
||||
vi.mocked(mockClient.sessionUpdate).mock.calls.at(-1)![0] as {
|
||||
update: {
|
||||
_meta: {
|
||||
availableSkills: string[];
|
||||
availableSkillDetails: Array<{ name: string }>;
|
||||
};
|
||||
};
|
||||
}
|
||||
).update._meta;
|
||||
expect(meta.availableSkills).toEqual(
|
||||
expect.arrayContaining(['mgr-skill', 'batch']),
|
||||
);
|
||||
expect(meta.availableSkills).toHaveLength(2);
|
||||
// Name list stays in lockstep with the details list.
|
||||
expect([...meta.availableSkills].sort()).toEqual(
|
||||
meta.availableSkillDetails.map((detail) => detail.name).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('swallows errors and does not throw', async () => {
|
||||
getAvailableCommandsSpy.mockRejectedValueOnce(
|
||||
new Error('Command discovery failed'),
|
||||
|
|
@ -2064,6 +2199,184 @@ describe('Session', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('injects drained mid-turn user messages with tool responses', async () => {
|
||||
const executeSpy = vi.fn().mockResolvedValue({
|
||||
llmContent: 'file contents',
|
||||
returnDisplay: 'file contents',
|
||||
});
|
||||
const tool = {
|
||||
name: 'read_file',
|
||||
kind: core.Kind.Read,
|
||||
build: vi.fn().mockReturnValue({
|
||||
params: { path: '/tmp/test.txt' },
|
||||
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
|
||||
getDescription: vi.fn().mockReturnValue('Read file'),
|
||||
toolLocations: vi.fn().mockReturnValue([]),
|
||||
execute: executeSpy,
|
||||
}),
|
||||
};
|
||||
|
||||
mockToolRegistry.getTool.mockReturnValue(tool);
|
||||
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
|
||||
mockClient.extMethod = vi.fn().mockResolvedValue({
|
||||
messages: ['please also check tests'],
|
||||
});
|
||||
mockChat.sendMessageStream = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
createStreamWithChunks([
|
||||
{
|
||||
type: core.StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'read_file',
|
||||
args: { path: '/tmp/test.txt' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(createEmptyStream());
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'test-session-id',
|
||||
prompt: [{ type: 'text', text: 'read file' }],
|
||||
});
|
||||
|
||||
expect(mockClient.extMethod).toHaveBeenCalledWith(
|
||||
'craft/drainMidTurnQueue',
|
||||
{ sessionId: 'test-session-id' },
|
||||
);
|
||||
const secondCall = vi.mocked(mockChat.sendMessageStream).mock.calls[1];
|
||||
const midTurnPart = {
|
||||
text: '\n[User message received during tool execution]: please also check tests',
|
||||
};
|
||||
expect(secondCall?.[1].message).toEqual(
|
||||
expect.arrayContaining([midTurnPart]),
|
||||
);
|
||||
expect(
|
||||
mockChatRecordingService.recordMidTurnUserMessage,
|
||||
).toHaveBeenCalledWith([midTurnPart], 'please also check tests');
|
||||
});
|
||||
|
||||
it('latches mid-turn drain off after a permanent (-32601) error', async () => {
|
||||
const tool = {
|
||||
name: 'read_file',
|
||||
kind: core.Kind.Read,
|
||||
build: vi.fn().mockReturnValue({
|
||||
params: { path: '/tmp/test.txt' },
|
||||
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
|
||||
getDescription: vi.fn().mockReturnValue('Read file'),
|
||||
toolLocations: vi.fn().mockReturnValue([]),
|
||||
execute: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ llmContent: 'ok', returnDisplay: 'ok' }),
|
||||
}),
|
||||
};
|
||||
mockToolRegistry.getTool.mockReturnValue(tool);
|
||||
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
|
||||
// The ACP SDK rejects with a raw JSON-RPC error object, not an Error.
|
||||
mockClient.extMethod = vi
|
||||
.fn()
|
||||
.mockRejectedValue({ code: -32601, message: 'Method not found' });
|
||||
|
||||
const toolCallStream = () =>
|
||||
createStreamWithChunks([
|
||||
{
|
||||
type: core.StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [
|
||||
{
|
||||
id: 'c',
|
||||
name: 'read_file',
|
||||
args: { path: '/tmp/test.txt' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(toolCallStream())
|
||||
.mockResolvedValueOnce(createEmptyStream())
|
||||
.mockResolvedValueOnce(toolCallStream())
|
||||
.mockResolvedValueOnce(createEmptyStream());
|
||||
|
||||
const prompt = {
|
||||
sessionId: 'test-session-id',
|
||||
prompt: [{ type: 'text' as const, text: 'read file' }],
|
||||
};
|
||||
await session.prompt(prompt);
|
||||
await session.prompt(prompt);
|
||||
|
||||
// After the permanent error the latch trips, so the drain extMethod is
|
||||
// attempted only on the first tool batch, not the second.
|
||||
const drainCalls = vi
|
||||
.mocked(mockClient.extMethod)
|
||||
.mock.calls.filter((call) => call[0] === 'craft/drainMidTurnQueue');
|
||||
expect(drainCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps mid-turn drain enabled after a transient error', async () => {
|
||||
const tool = {
|
||||
name: 'read_file',
|
||||
kind: core.Kind.Read,
|
||||
build: vi.fn().mockReturnValue({
|
||||
params: { path: '/tmp/test.txt' },
|
||||
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
|
||||
getDescription: vi.fn().mockReturnValue('Read file'),
|
||||
toolLocations: vi.fn().mockReturnValue([]),
|
||||
execute: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ llmContent: 'ok', returnDisplay: 'ok' }),
|
||||
}),
|
||||
};
|
||||
mockToolRegistry.getTool.mockReturnValue(tool);
|
||||
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
|
||||
mockClient.extMethod = vi
|
||||
.fn()
|
||||
.mockRejectedValue({ code: -32000, message: 'temporary failure' });
|
||||
|
||||
const toolCallStream = () =>
|
||||
createStreamWithChunks([
|
||||
{
|
||||
type: core.StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [
|
||||
{
|
||||
id: 'c',
|
||||
name: 'read_file',
|
||||
args: { path: '/tmp/test.txt' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(toolCallStream())
|
||||
.mockResolvedValueOnce(createEmptyStream())
|
||||
.mockResolvedValueOnce(toolCallStream())
|
||||
.mockResolvedValueOnce(createEmptyStream());
|
||||
|
||||
const prompt = {
|
||||
sessionId: 'test-session-id',
|
||||
prompt: [{ type: 'text' as const, text: 'read file' }],
|
||||
};
|
||||
await session.prompt(prompt);
|
||||
await session.prompt(prompt);
|
||||
|
||||
// A transient error must NOT latch: the drain is retried on the second
|
||||
// tool batch.
|
||||
const drainCalls = vi
|
||||
.mocked(mockClient.extMethod)
|
||||
.mock.calls.filter((call) => call[0] === 'craft/drainMidTurnQueue');
|
||||
expect(drainCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('wraps tool execution with the sleep inhibitor (acquire before execute, release after)', async () => {
|
||||
const releaseSpy = vi.fn();
|
||||
const acquireSpy = vi
|
||||
|
|
|
|||
|
|
@ -138,6 +138,8 @@ type AutoCompressionSendResult =
|
|||
| { responseStream: AsyncGenerator<StreamEvent>; stopReason?: never }
|
||||
| { responseStream: null; stopReason: PromptResponse['stopReason'] };
|
||||
|
||||
const MID_TURN_QUEUE_DRAIN_METHOD = 'craft/drainMidTurnQueue';
|
||||
|
||||
interface BackgroundNotificationQueueItem {
|
||||
displayText: string;
|
||||
modelText: string;
|
||||
|
|
@ -249,6 +251,14 @@ function isUserPromptRecord(record: ChatRecord): boolean {
|
|||
export interface AvailableCommandsSnapshot {
|
||||
availableCommands: AvailableCommand[];
|
||||
availableSkills?: string[];
|
||||
availableSkillDetails?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
body?: string;
|
||||
filePath?: string;
|
||||
level?: string;
|
||||
modelInvocable?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function buildAvailableCommandsSnapshot(
|
||||
|
|
@ -280,19 +290,56 @@ export async function buildAvailableCommandsSnapshot(
|
|||
});
|
||||
|
||||
let availableSkills: string[] | undefined;
|
||||
const skillDetailsByName = new Map<
|
||||
string,
|
||||
NonNullable<AvailableCommandsSnapshot['availableSkillDetails']>[number]
|
||||
>();
|
||||
try {
|
||||
const skillManager = config.getSkillManager();
|
||||
if (skillManager) {
|
||||
const skills = await skillManager.listSkills();
|
||||
availableSkills = skills.map((skill) => skill.name);
|
||||
for (const skill of skills) {
|
||||
skillDetailsByName.set(skill.name, {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
body: skill.body,
|
||||
filePath: skill.filePath,
|
||||
level: skill.level,
|
||||
modelInvocable: skill.disableModelInvocation !== true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error('Error loading available skills:', error);
|
||||
}
|
||||
|
||||
for (const command of slashCommands) {
|
||||
if (command.kind !== CommandKind.SKILL || !command.skillDetail) {
|
||||
continue;
|
||||
}
|
||||
const existing = skillDetailsByName.get(command.skillDetail.name);
|
||||
skillDetailsByName.set(command.skillDetail.name, {
|
||||
...existing,
|
||||
...command.skillDetail,
|
||||
modelInvocable: command.modelInvocable === true,
|
||||
});
|
||||
}
|
||||
const availableSkillDetails =
|
||||
skillDetailsByName.size > 0
|
||||
? Array.from(skillDetailsByName.values())
|
||||
: undefined;
|
||||
// Always derive the name list from the details map so the two stay in sync.
|
||||
// skillManager only contributes its own skills to `availableSkills`, but the
|
||||
// slashCommands loop above also adds bundled skills to `skillDetailsByName`;
|
||||
// a `??=` would leave bundled skills in details but missing from the name
|
||||
// list whenever skillManager succeeded.
|
||||
availableSkills = availableSkillDetails?.map((skill) => skill.name);
|
||||
|
||||
return {
|
||||
availableCommands,
|
||||
...(availableSkills !== undefined ? { availableSkills } : {}),
|
||||
...(availableSkillDetails !== undefined ? { availableSkillDetails } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -325,6 +372,7 @@ export class Session implements SessionContext {
|
|||
private cronDisabledByTokenLimit = false;
|
||||
private lastPromptTokenCount = 0;
|
||||
private lastPromptTokenCountChat: GeminiChat | null = null;
|
||||
private midTurnDrainUnavailable = false;
|
||||
|
||||
// Background notification drain state. ACP does not have the TUI's idle
|
||||
// hook, so the session serializes registry callbacks through this queue.
|
||||
|
|
@ -936,7 +984,13 @@ export class Session implements SessionContext {
|
|||
promptId,
|
||||
functionCalls,
|
||||
);
|
||||
nextMessage = { role: 'user', parts: toolResponseParts };
|
||||
nextMessage = {
|
||||
role: 'user',
|
||||
parts: [
|
||||
...toolResponseParts,
|
||||
...(await this.#drainMidTurnUserMessages()),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1182,7 +1236,13 @@ export class Session implements SessionContext {
|
|||
promptId,
|
||||
functionCalls,
|
||||
);
|
||||
nextMessage = { role: 'user', parts: toolResponseParts };
|
||||
nextMessage = {
|
||||
role: 'user',
|
||||
parts: [
|
||||
...toolResponseParts,
|
||||
...(await this.#drainMidTurnUserMessages()),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1449,6 +1509,68 @@ export class Session implements SessionContext {
|
|||
});
|
||||
}
|
||||
|
||||
async #drainMidTurnUserMessages(): Promise<Part[]> {
|
||||
if (this.midTurnDrainUnavailable) return [];
|
||||
|
||||
try {
|
||||
const response = await this.client.extMethod(
|
||||
MID_TURN_QUEUE_DRAIN_METHOD,
|
||||
{
|
||||
sessionId: this.sessionId,
|
||||
},
|
||||
);
|
||||
// A client may legally resolve with `result: null` (passed through
|
||||
// unwrapped by the ACP SDK); guard the object access so that doesn't
|
||||
// throw a TypeError and get misclassified as a transient drain error.
|
||||
const messages =
|
||||
response &&
|
||||
typeof response === 'object' &&
|
||||
Array.isArray(response['messages'])
|
||||
? response['messages'].filter(
|
||||
(message): message is string =>
|
||||
typeof message === 'string' && message.trim().length > 0,
|
||||
)
|
||||
: [];
|
||||
|
||||
return messages.map((message) => {
|
||||
const part = {
|
||||
text: `\n[User message received during tool execution]: ${message}`,
|
||||
};
|
||||
this.config
|
||||
.getChatRecordingService()
|
||||
?.recordMidTurnUserMessage([part], message);
|
||||
return part;
|
||||
});
|
||||
} catch (error) {
|
||||
// The ACP SDK rejects with the raw JSON-RPC error object
|
||||
// (`{ code, message, data }`), which is not an `Error` instance, so
|
||||
// classify on the JSON-RPC code (-32601 = "Method not found") and fall
|
||||
// back to the message. Otherwise the one-shot latch never trips and every
|
||||
// tool batch keeps paying a failed `extMethod` round-trip all session.
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: error && typeof error === 'object' && 'message' in error
|
||||
? String((error as { message?: unknown }).message)
|
||||
: String(error);
|
||||
const errorCode =
|
||||
error && typeof error === 'object' && 'code' in error
|
||||
? (error as { code?: unknown }).code
|
||||
: undefined;
|
||||
const isPermanentError =
|
||||
errorCode === -32601 || /method not found/i.test(errorMessage);
|
||||
|
||||
if (isPermanentError) {
|
||||
this.midTurnDrainUnavailable = true;
|
||||
}
|
||||
|
||||
debugLogger.warn(
|
||||
`Mid-turn queue drain ${isPermanentError ? 'permanently ' : ''}unavailable [session ${this.sessionId}]: ${errorMessage}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the cron scheduler if cron is enabled and jobs exist.
|
||||
* The scheduler runs in the background, pushing fired prompts into
|
||||
|
|
@ -1617,7 +1739,13 @@ export class Session implements SessionContext {
|
|||
promptId,
|
||||
functionCalls,
|
||||
);
|
||||
nextMessage = { role: 'user', parts: toolResponseParts };
|
||||
nextMessage = {
|
||||
role: 'user',
|
||||
parts: [
|
||||
...toolResponseParts,
|
||||
...(await this.#drainMidTurnUserMessages()),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -1972,7 +2100,7 @@ export class Session implements SessionContext {
|
|||
|
||||
async sendAvailableCommandsUpdate(): Promise<void> {
|
||||
try {
|
||||
const { availableCommands, availableSkills } =
|
||||
const { availableCommands, availableSkills, availableSkillDetails } =
|
||||
await buildAvailableCommandsSnapshot(this.config);
|
||||
|
||||
const update: SessionUpdate = {
|
||||
|
|
@ -1982,6 +2110,7 @@ export class Session implements SessionContext {
|
|||
? {
|
||||
_meta: {
|
||||
availableSkills,
|
||||
...(availableSkillDetails ? { availableSkillDetails } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -719,6 +719,10 @@ describe('SubAgentTracker', () => {
|
|||
type: 'text',
|
||||
text: 'Hello, this is a response from the model.',
|
||||
},
|
||||
_meta: expect.objectContaining({
|
||||
parentToolCallId: 'parent-call-123',
|
||||
subagentType: 'test-subagent',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -276,6 +276,8 @@ export class SubAgentTracker {
|
|||
event.text,
|
||||
'assistant',
|
||||
event.thought ?? false,
|
||||
undefined,
|
||||
this.getSubagentMeta(),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,22 @@ describe('MessageEmitter', () => {
|
|||
content: { type: 'text', text: 'I can help you with that.' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should include subagent parent metadata when provided', async () => {
|
||||
await emitter.emitAgentMessage('Subagent progress', undefined, {
|
||||
parentToolCallId: 'agent-parent-1',
|
||||
subagentType: 'general-purpose',
|
||||
});
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Subagent progress' },
|
||||
_meta: {
|
||||
parentToolCallId: 'agent-parent-1',
|
||||
subagentType: 'general-purpose',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitAgentThought', () => {
|
||||
|
|
@ -77,6 +93,22 @@ describe('MessageEmitter', () => {
|
|||
content: { type: 'text', text: 'Let me think about this...' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should include subagent parent metadata when provided', async () => {
|
||||
await emitter.emitAgentThought('Subagent thought', undefined, {
|
||||
parentToolCallId: 'agent-parent-1',
|
||||
subagentType: 'general-purpose',
|
||||
});
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text: 'Subagent thought' },
|
||||
_meta: {
|
||||
parentToolCallId: 'agent-parent-1',
|
||||
subagentType: 'general-purpose',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitMessage', () => {
|
||||
|
|
|
|||
|
|
@ -69,12 +69,17 @@ export class MessageEmitter extends BaseEmitter {
|
|||
async emitAgentThought(
|
||||
text: string,
|
||||
timestamp?: string | number,
|
||||
subagentMeta?: SubagentMeta,
|
||||
): Promise<void> {
|
||||
const epochMs = BaseEmitter.toEpochMs(timestamp);
|
||||
const meta = {
|
||||
...subagentMeta,
|
||||
...(epochMs != null && { timestamp: epochMs }),
|
||||
};
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text },
|
||||
...(epochMs != null && { _meta: { timestamp: epochMs } }),
|
||||
...(Object.keys(meta).length > 0 && { _meta: meta }),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -87,12 +92,17 @@ export class MessageEmitter extends BaseEmitter {
|
|||
async emitAgentMessage(
|
||||
text: string,
|
||||
timestamp?: string | number,
|
||||
subagentMeta?: SubagentMeta,
|
||||
): Promise<void> {
|
||||
const epochMs = BaseEmitter.toEpochMs(timestamp);
|
||||
const meta = {
|
||||
...subagentMeta,
|
||||
...(epochMs != null && { timestamp: epochMs }),
|
||||
};
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text },
|
||||
...(epochMs != null && { _meta: { timestamp: epochMs } }),
|
||||
...(Object.keys(meta).length > 0 && { _meta: meta }),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -139,12 +149,13 @@ export class MessageEmitter extends BaseEmitter {
|
|||
role: 'user' | 'assistant',
|
||||
isThought: boolean = false,
|
||||
timestamp?: string | number,
|
||||
subagentMeta?: SubagentMeta,
|
||||
): Promise<void> {
|
||||
if (role === 'user') {
|
||||
return this.emitUserMessage(text, timestamp);
|
||||
}
|
||||
return isThought
|
||||
? this.emitAgentThought(text, timestamp)
|
||||
: this.emitAgentMessage(text, timestamp);
|
||||
? this.emitAgentThought(text, timestamp, subagentMeta)
|
||||
: this.emitAgentMessage(text, timestamp, subagentMeta);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -792,6 +792,27 @@ describe('parseArguments', () => {
|
|||
expect(argv.approvalMode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should accept desktop as a channel identifier', async () => {
|
||||
process.argv = ['node', 'script.js', '--channel', 'desktop'];
|
||||
const argv = await parseArguments();
|
||||
expect(argv.channel).toBe('desktop');
|
||||
});
|
||||
|
||||
it('should default ACP mode to the ACP channel when no channel is provided', async () => {
|
||||
process.argv = ['node', 'script.js', '--acp'];
|
||||
const argv = await parseArguments();
|
||||
expect(argv.channel).toBe('ACP');
|
||||
});
|
||||
|
||||
it('keeps an explicit --channel when combined with --acp (the desktop invocation)', async () => {
|
||||
process.argv = ['node', 'script.js', '--acp', '--channel', 'desktop'];
|
||||
const argv = await parseArguments();
|
||||
// The `!result['channel']` guard must not override an explicitly provided
|
||||
// channel with the ACP default.
|
||||
expect(argv.channel).toBe('desktop');
|
||||
expect(argv.acp).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid --approval-mode values', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'invalid'];
|
||||
|
||||
|
|
|
|||
|
|
@ -690,8 +690,8 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
})
|
||||
.option('channel', {
|
||||
type: 'string',
|
||||
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
|
||||
description: 'Channel identifier (VSCode, ACP, SDK, CI)',
|
||||
choices: ['VSCode', 'ACP', 'SDK', 'CI', 'desktop'],
|
||||
description: 'Channel identifier (VSCode, ACP, SDK, CI, desktop)',
|
||||
})
|
||||
.option('allowed-mcp-server-names', {
|
||||
type: 'array',
|
||||
|
|
|
|||
|
|
@ -83,6 +83,12 @@ export class BundledSkillLoader implements ICommandLoader {
|
|||
modelInvocable: !skill.disableModelInvocation,
|
||||
argumentHint: skill.argumentHint,
|
||||
whenToUse: skill.whenToUse,
|
||||
skillDetail: {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
body: skill.body,
|
||||
level: skill.level,
|
||||
},
|
||||
action: async (context, _args): Promise<SlashCommandActionReturn> => {
|
||||
// Auto-approve the skill's declared allowedTools before its body is submitted.
|
||||
applySkillAllowedTools(
|
||||
|
|
|
|||
|
|
@ -107,6 +107,12 @@ export class SkillCommandLoader implements ICommandLoader {
|
|||
modelInvocable,
|
||||
argumentHint: skill.argumentHint,
|
||||
whenToUse: skill.whenToUse,
|
||||
skillDetail: {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
body: skill.body,
|
||||
level: skill.level,
|
||||
},
|
||||
action: async (context, _args): Promise<SlashCommandActionReturn> => {
|
||||
// Auto-approve the skill's declared allowedTools before its body is submitted.
|
||||
applySkillAllowedTools(
|
||||
|
|
|
|||
|
|
@ -397,6 +397,15 @@ export interface SlashCommand {
|
|||
/** Usage examples shown in Help and completion. */
|
||||
examples?: string[];
|
||||
|
||||
/** Parsed skill metadata for skill-backed commands. Used by ACP clients. */
|
||||
skillDetail?: {
|
||||
name: string;
|
||||
description?: string;
|
||||
body?: string;
|
||||
filePath?: string;
|
||||
level?: string;
|
||||
};
|
||||
|
||||
// The action to run. Optional for parent commands that only group sub-commands.
|
||||
action?: (
|
||||
context: CommandContext,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
AuthType,
|
||||
alibabaStandardProvider,
|
||||
buildInstallPlan,
|
||||
getDefaultModelIds,
|
||||
resolveBaseUrl,
|
||||
providerMatchesCredentials,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -35,6 +36,17 @@ describe('alibabaStandardProvider', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('includes qwen3.7 models in default model IDs', () => {
|
||||
expect(getDefaultModelIds(alibabaStandardProvider)).toEqual([
|
||||
'qwen3.6-plus',
|
||||
'qwen3.7-plus',
|
||||
'qwen3.7-max',
|
||||
'glm-5.1',
|
||||
'deepseek-v4-pro',
|
||||
'deepseek-v4-flash',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves baseUrl for known region', () => {
|
||||
const url = resolveBaseUrl(
|
||||
alibabaStandardProvider,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ export const alibabaStandardProvider: ProviderConfig = {
|
|||
envKey: 'DASHSCOPE_API_KEY',
|
||||
models: [
|
||||
{ id: 'qwen3.6-plus', contextWindowSize: 1000000, enableThinking: true },
|
||||
{ id: 'qwen3.7-plus', contextWindowSize: 1000000, enableThinking: true },
|
||||
{ id: 'qwen3.7-max', contextWindowSize: 1000000, enableThinking: true },
|
||||
{ id: 'glm-5.1', contextWindowSize: 202752, enableThinking: true },
|
||||
{
|
||||
id: 'deepseek-v4-pro',
|
||||
|
|
|
|||
|
|
@ -799,6 +799,29 @@ describe('SkillTool', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('returns the executor error from the disabled-skill delegation path', async () => {
|
||||
// Disabled skill that shadows a same-named command whose executor fails:
|
||||
// the { error } result must surface as the tool result, not fall through
|
||||
// to the generic "skill is disabled" message.
|
||||
vi.mocked(config.getDisabledSkillNames).mockReturnValue(
|
||||
new Set(['blocked']),
|
||||
);
|
||||
const executor = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ error: 'command failed: boom' });
|
||||
vi.mocked(config.getModelInvocableCommandsExecutor).mockReturnValue(
|
||||
executor,
|
||||
);
|
||||
|
||||
const invocation = (
|
||||
skillTool as SkillToolWithProtectedMethods
|
||||
).createInvocation({ skill: 'blocked' });
|
||||
const result = await invocation.execute();
|
||||
|
||||
expect(result.llmContent).toBe('command failed: boom');
|
||||
expect(result.returnDisplay).toBe('command failed: boom');
|
||||
});
|
||||
|
||||
it('propagates prompt_id through the not-found branch', async () => {
|
||||
// Both loadSkillForRuntime and commandExecutor return null → L399
|
||||
// branch in skill.ts logs a failed SkillLaunchEvent.
|
||||
|
|
|
|||
32
packages/core/src/utils/getPty.test.ts
Normal file
32
packages/core/src/utils/getPty.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getPty } from './getPty.js';
|
||||
|
||||
describe('getPty', () => {
|
||||
it('falls back when running under Bun', async () => {
|
||||
const original = Object.getOwnPropertyDescriptor(process.versions, 'bun');
|
||||
|
||||
Object.defineProperty(process.versions, 'bun', {
|
||||
value: '1.3.8',
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(getPty()).resolves.toBeNull();
|
||||
} finally {
|
||||
if (original) {
|
||||
Object.defineProperty(process.versions, 'bun', original);
|
||||
} else {
|
||||
const versions = process.versions as typeof process.versions & {
|
||||
bun?: string;
|
||||
};
|
||||
delete versions.bun;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -18,6 +18,11 @@ export interface PtyProcess {
|
|||
}
|
||||
|
||||
export const getPty = async (): Promise<PtyImplementation> => {
|
||||
// Bun can load @lydell/node-pty, but it hangs under Desktop's runtime.
|
||||
if ('bun' in process.versions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const lydell = '@lydell/node-pty';
|
||||
const module = await import(lydell);
|
||||
|
|
|
|||
|
|
@ -298,6 +298,7 @@
|
|||
"@types/vscode": "^1.85.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.1",
|
||||
"@typescript-eslint/parser": "^8.31.1",
|
||||
"@vscode/vsce": "^3.9.2",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"esbuild": "^0.25.3",
|
||||
"eslint": "^9.25.1",
|
||||
|
|
|
|||
|
|
@ -40,6 +40,14 @@ export interface SessionUpdateMeta {
|
|||
durationMs?: number | null;
|
||||
timestamp?: number | null;
|
||||
availableSkills?: string[] | null;
|
||||
availableSkillDetails?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
body?: string;
|
||||
filePath?: string;
|
||||
level?: string;
|
||||
modelInvocable?: boolean;
|
||||
}> | null;
|
||||
source?: string | null;
|
||||
qwenDiscreteMessage?: boolean | null;
|
||||
// Set on the summary emitted by MessageRewriteMiddleware so consumers can
|
||||
|
|
|
|||
839
scripts/desktop-openwork-sync.ts
Normal file
839
scripts/desktop-openwork-sync.ts
Normal file
|
|
@ -0,0 +1,839 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { spawn } from 'bun';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
type SyncMode = 'auto' | 'export' | 'import';
|
||||
type MigrationMode = Exclude<SyncMode, 'auto'>;
|
||||
|
||||
type RunOptions = {
|
||||
allowFailure?: boolean;
|
||||
capture?: boolean;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
mode: SyncMode;
|
||||
openworkDir: string;
|
||||
openworkRef: string;
|
||||
qwenBase: string;
|
||||
branch?: string;
|
||||
overlayPaths: string[];
|
||||
sourceBase?: string;
|
||||
allowDirtySource: boolean;
|
||||
};
|
||||
|
||||
const desktopPrefix = 'packages/desktop';
|
||||
const repoRoot = resolve(import.meta.dir, '..');
|
||||
|
||||
function timestamp(): string {
|
||||
return new Date().toISOString().replace(/[-:.TZ]/g, '');
|
||||
}
|
||||
|
||||
function normalizeGitPath(value: string): string {
|
||||
const path = value
|
||||
.trim()
|
||||
.replaceAll('\\', '/')
|
||||
.replace(/^\.\/+/, '');
|
||||
if (!path || path === '.') {
|
||||
throw new Error('Overlay path cannot be empty.');
|
||||
}
|
||||
if (path.startsWith('/') || path.split('/').includes('..')) {
|
||||
throw new Error(`Overlay path must be repository-relative: ${value}`);
|
||||
}
|
||||
return path.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function parsePathList(value: string | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(',')
|
||||
.map((path) => path.trim())
|
||||
.filter(Boolean)
|
||||
.map(normalizeGitPath);
|
||||
}
|
||||
|
||||
function parseMode(value: string): SyncMode {
|
||||
if (value === 'auto' || value === 'export' || value === 'import') {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Invalid mode: ${value}`);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function defaultBranch(mode: Exclude<SyncMode, 'auto'>): string {
|
||||
const verb = mode === 'export' ? 'sync' : 'import';
|
||||
return `chore/${verb}-openwork-desktop-${timestamp()}`;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`Usage: bun run desktop-openwork-sync --openwork-dir /path/to/openwork [options]
|
||||
|
||||
Commit-migrate changes between qwen-code packages/desktop and OpenWork.
|
||||
|
||||
Modes:
|
||||
--mode auto Refuse if direction is ambiguous
|
||||
--mode export Apply qwen-code packages/desktop commits to OpenWork
|
||||
--mode import Apply OpenWork commits to qwen-code packages/desktop
|
||||
|
||||
Options:
|
||||
--openwork-dir <path> Path to a clean OpenWork checkout
|
||||
--openwork-ref <ref> OpenWork ref to read or branch from (default: main)
|
||||
--base <ref> Alias for --openwork-ref
|
||||
--qwen-base <ref> qwen-code base for import branches (default: HEAD)
|
||||
--source-base <ref> Source-side base ref for the commit range
|
||||
--branch <name> Target branch name in the repo being changed
|
||||
--overlay <path[,path]> Do not migrate these source paths (repeatable)
|
||||
--allow-dirty-source Allow uncommitted packages/desktop changes to be omitted during export
|
||||
--no-abort-on-conflict Accepted for compatibility; conflicts are left for resolution
|
||||
-h, --help Show this help
|
||||
|
||||
Environment:
|
||||
OPENWORK_DIR
|
||||
OPENWORK_REF
|
||||
OPENWORK_BASE_REF Alias for OPENWORK_REF
|
||||
QWEN_BASE_REF
|
||||
OPENWORK_SYNC_BRANCH
|
||||
OPENWORK_SYNC_SOURCE_BASE
|
||||
OPENWORK_OVERLAY_PATHS Comma-separated overlays (default: README.md)
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Options {
|
||||
const overlayPaths = new Set(
|
||||
parsePathList(process.env.OPENWORK_OVERLAY_PATHS || 'README.md'),
|
||||
);
|
||||
let mode: SyncMode = 'auto';
|
||||
let openworkDir = process.env.OPENWORK_DIR?.trim();
|
||||
let openworkRef =
|
||||
process.env.OPENWORK_REF?.trim() ||
|
||||
process.env.OPENWORK_BASE_REF?.trim() ||
|
||||
'main';
|
||||
let qwenBase = process.env.QWEN_BASE_REF?.trim() || 'HEAD';
|
||||
let branch = process.env.OPENWORK_SYNC_BRANCH?.trim();
|
||||
let sourceBase = process.env.OPENWORK_SYNC_SOURCE_BASE?.trim();
|
||||
let allowDirtySource = false;
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const next = (): string => {
|
||||
const value = argv[index + 1];
|
||||
if (!value) throw new Error(`Missing value for ${arg}`);
|
||||
index += 1;
|
||||
return value;
|
||||
};
|
||||
|
||||
switch (arg) {
|
||||
case '--mode':
|
||||
mode = parseMode(next());
|
||||
break;
|
||||
case '--openwork-dir':
|
||||
openworkDir = next();
|
||||
break;
|
||||
case '--openwork-ref':
|
||||
case '--base':
|
||||
openworkRef = next();
|
||||
break;
|
||||
case '--qwen-base':
|
||||
qwenBase = next();
|
||||
break;
|
||||
case '--source-base':
|
||||
sourceBase = next();
|
||||
break;
|
||||
case '--branch':
|
||||
branch = next();
|
||||
break;
|
||||
case '--overlay':
|
||||
for (const path of parsePathList(next())) {
|
||||
overlayPaths.add(path);
|
||||
}
|
||||
break;
|
||||
case '--allow-dirty-source':
|
||||
allowDirtySource = true;
|
||||
break;
|
||||
case '--no-abort-on-conflict':
|
||||
break;
|
||||
case '-h':
|
||||
case '--help':
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!openworkDir) {
|
||||
throw new Error(
|
||||
'Missing OpenWork checkout. Pass --openwork-dir or set OPENWORK_DIR.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
openworkDir: resolve(openworkDir),
|
||||
openworkRef,
|
||||
qwenBase,
|
||||
branch,
|
||||
overlayPaths: [...overlayPaths],
|
||||
sourceBase,
|
||||
allowDirtySource,
|
||||
};
|
||||
}
|
||||
|
||||
async function run(
|
||||
cmd: string[],
|
||||
cwd: string,
|
||||
options: RunOptions = {},
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
const proc = spawn({
|
||||
cmd,
|
||||
cwd,
|
||||
stdin: 'inherit',
|
||||
stdout: options.capture ? 'pipe' : 'inherit',
|
||||
stderr: options.capture ? 'pipe' : 'inherit',
|
||||
});
|
||||
|
||||
const stdoutPromise =
|
||||
options.capture && proc.stdout
|
||||
? new Response(proc.stdout).text()
|
||||
: Promise.resolve('');
|
||||
const stderrPromise =
|
||||
options.capture && proc.stderr
|
||||
? new Response(proc.stderr).text()
|
||||
: Promise.resolve('');
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
stdoutPromise,
|
||||
stderrPromise,
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
if (exitCode !== 0 && !options.allowFailure) {
|
||||
const detail = stderr.trim() || stdout.trim();
|
||||
throw new Error(
|
||||
`${cmd.join(' ')} failed with exit code ${exitCode}${
|
||||
detail ? `\n${detail}` : ''
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { exitCode, stdout, stderr };
|
||||
}
|
||||
|
||||
async function git(
|
||||
cwd: string,
|
||||
args: string[],
|
||||
options: RunOptions = {},
|
||||
): ReturnType<typeof run> {
|
||||
return run(['git', ...args], cwd, options);
|
||||
}
|
||||
|
||||
async function getRepoRoot(path: string): Promise<string> {
|
||||
const result = await git(path, ['rev-parse', '--show-toplevel'], {
|
||||
capture: true,
|
||||
});
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
async function revParse(cwd: string, ref: string): Promise<string> {
|
||||
const result = await git(cwd, ['rev-parse', '--verify', `${ref}^{commit}`], {
|
||||
capture: true,
|
||||
});
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
async function objectExists(cwd: string, ref: string): Promise<boolean> {
|
||||
const result = await git(cwd, ['cat-file', '-e', `${ref}^{commit}`], {
|
||||
allowFailure: true,
|
||||
capture: true,
|
||||
});
|
||||
return result.exitCode === 0;
|
||||
}
|
||||
|
||||
async function localBranchExists(
|
||||
cwd: string,
|
||||
branch: string,
|
||||
): Promise<boolean> {
|
||||
const result = await git(
|
||||
cwd,
|
||||
['show-ref', '--verify', `refs/heads/${branch}`],
|
||||
{ allowFailure: true, capture: true },
|
||||
);
|
||||
return result.exitCode === 0;
|
||||
}
|
||||
|
||||
async function switchTargetBranch(
|
||||
cwd: string,
|
||||
branch: string,
|
||||
baseRef: string,
|
||||
): Promise<void> {
|
||||
if (await localBranchExists(cwd, branch)) {
|
||||
await git(cwd, ['switch', branch]);
|
||||
return;
|
||||
}
|
||||
|
||||
await git(cwd, ['switch', '-c', branch, baseRef]);
|
||||
}
|
||||
|
||||
async function ensureCleanWorktree(
|
||||
cwd: string,
|
||||
label: string,
|
||||
paths: string[] = [],
|
||||
): Promise<void> {
|
||||
const args = ['status', '--porcelain'];
|
||||
if (paths.length > 0) {
|
||||
args.push('--', ...paths);
|
||||
}
|
||||
|
||||
const status = await git(cwd, args, { capture: true });
|
||||
if (status.stdout.trim()) {
|
||||
throw new Error(
|
||||
`${label} must be clean before syncing:\n${status.stdout.trim()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCommittedDesktopSource(
|
||||
allowDirtySource: boolean,
|
||||
): Promise<void> {
|
||||
if (allowDirtySource) return;
|
||||
await ensureCleanWorktree(repoRoot, desktopPrefix, [desktopPrefix]);
|
||||
}
|
||||
|
||||
async function findLatestTrailer(
|
||||
cwd: string,
|
||||
ref: string,
|
||||
trailer: string,
|
||||
): Promise<string | undefined> {
|
||||
const result = await git(cwd, ['log', '--format=%B%x00', ref], {
|
||||
capture: true,
|
||||
});
|
||||
const pattern = new RegExp(
|
||||
`^${escapeRegExp(trailer)}:\\s*([^\\s]+)\\s*$`,
|
||||
'im',
|
||||
);
|
||||
return result.stdout.match(pattern)?.[1];
|
||||
}
|
||||
|
||||
async function getCommitBody(cwd: string, commit: string): Promise<string> {
|
||||
const result = await git(cwd, ['log', '-n', '1', '--format=%B', commit], {
|
||||
capture: true,
|
||||
});
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function findTrailer(body: string, trailer: string): string | undefined {
|
||||
const pattern = new RegExp(
|
||||
`^${escapeRegExp(trailer)}:\\s*([^\\s]+)\\s*$`,
|
||||
'im',
|
||||
);
|
||||
return body.match(pattern)?.[1];
|
||||
}
|
||||
|
||||
async function findTrailerValues(
|
||||
cwd: string,
|
||||
ref: string,
|
||||
trailer: string,
|
||||
): Promise<Set<string>> {
|
||||
const result = await git(cwd, ['log', '--format=%B%x00', ref], {
|
||||
capture: true,
|
||||
});
|
||||
const pattern = new RegExp(
|
||||
`^${escapeRegExp(trailer)}:\\s*([^\\s]+)\\s*$`,
|
||||
'gim',
|
||||
);
|
||||
const values = new Set<string>();
|
||||
for (const match of result.stdout.matchAll(pattern)) {
|
||||
values.add(match[1]);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
async function resolveSourceBase(
|
||||
sourceRepo: string,
|
||||
explicitBase: string | undefined,
|
||||
targetRepo: string,
|
||||
targetRef: string,
|
||||
trailer: string,
|
||||
): Promise<string> {
|
||||
const base =
|
||||
explicitBase ?? (await findLatestTrailer(targetRepo, targetRef, trailer));
|
||||
if (!base) {
|
||||
throw new Error(
|
||||
`Missing source base. Pass --source-base or create a prior sync commit ` +
|
||||
`with a ${trailer} trailer.`,
|
||||
);
|
||||
}
|
||||
if (!(await objectExists(sourceRepo, base))) {
|
||||
throw new Error(`Source base does not exist in source repo: ${base}`);
|
||||
}
|
||||
return revParse(sourceRepo, base);
|
||||
}
|
||||
|
||||
function exportPathspecs(overlayPaths: string[]): string[] {
|
||||
return [
|
||||
desktopPrefix,
|
||||
...overlayPaths.map((path) => `:!${desktopPrefix}/${path}`),
|
||||
];
|
||||
}
|
||||
|
||||
function importPathspecs(overlayPaths: string[]): string[] {
|
||||
return ['.', ...overlayPaths.map((path) => `:!${path}`)];
|
||||
}
|
||||
|
||||
async function createExportPatch(
|
||||
base: string,
|
||||
source: string,
|
||||
overlayPaths: string[],
|
||||
): Promise<string> {
|
||||
const result = await git(
|
||||
repoRoot,
|
||||
[
|
||||
'diff',
|
||||
'--binary',
|
||||
'--full-index',
|
||||
`--relative=${desktopPrefix}`,
|
||||
base,
|
||||
source,
|
||||
'--',
|
||||
...exportPathspecs(overlayPaths),
|
||||
],
|
||||
{ capture: true },
|
||||
);
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
async function createImportPatch(
|
||||
openworkRoot: string,
|
||||
base: string,
|
||||
source: string,
|
||||
overlayPaths: string[],
|
||||
): Promise<string> {
|
||||
const result = await git(
|
||||
openworkRoot,
|
||||
[
|
||||
'diff',
|
||||
'--binary',
|
||||
'--full-index',
|
||||
`--src-prefix=a/${desktopPrefix}/`,
|
||||
`--dst-prefix=b/${desktopPrefix}/`,
|
||||
base,
|
||||
source,
|
||||
'--',
|
||||
...importPathspecs(overlayPaths),
|
||||
],
|
||||
{ capture: true },
|
||||
);
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
async function getSourceCommits(
|
||||
cwd: string,
|
||||
base: string,
|
||||
source: string,
|
||||
pathspecs: string[],
|
||||
): Promise<string[]> {
|
||||
const result = await git(
|
||||
cwd,
|
||||
[
|
||||
'log',
|
||||
'--reverse',
|
||||
'--topo-order',
|
||||
'--format=%H',
|
||||
`${base}..${source}`,
|
||||
'--',
|
||||
...pathspecs,
|
||||
],
|
||||
{ capture: true },
|
||||
);
|
||||
return result.stdout.split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
async function getMergeIntroducedCommits(
|
||||
cwd: string,
|
||||
firstParent: string,
|
||||
mergeCommit: string,
|
||||
pathspecs: string[],
|
||||
): Promise<string[]> {
|
||||
const result = await git(
|
||||
cwd,
|
||||
[
|
||||
'log',
|
||||
'--reverse',
|
||||
'--topo-order',
|
||||
'--no-merges',
|
||||
'--format=%H',
|
||||
`${firstParent}..${mergeCommit}`,
|
||||
'--',
|
||||
...pathspecs,
|
||||
],
|
||||
{ capture: true },
|
||||
);
|
||||
return result.stdout.split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
async function getParents(cwd: string, commit: string): Promise<string[]> {
|
||||
const result = await git(cwd, ['rev-list', '--parents', '-n', '1', commit], {
|
||||
capture: true,
|
||||
});
|
||||
const [, ...parents] = result.stdout.trim().split(/\s+/);
|
||||
return parents;
|
||||
}
|
||||
|
||||
async function shouldSkipSyncedCommit(
|
||||
cwd: string,
|
||||
commit: string,
|
||||
mode: MigrationMode,
|
||||
): Promise<string | undefined> {
|
||||
const body = await getCommitBody(cwd, commit);
|
||||
const syncMode = findTrailer(body, 'OpenWork-Sync-Mode');
|
||||
|
||||
if (
|
||||
mode === 'import' &&
|
||||
(syncMode === 'export' || findTrailer(body, 'Qwen-Code-Commit'))
|
||||
) {
|
||||
return 'already came from qwen-code';
|
||||
}
|
||||
|
||||
if (
|
||||
mode === 'export' &&
|
||||
(syncMode === 'import' || findTrailer(body, 'OpenWork-Commit'))
|
||||
) {
|
||||
return 'already came from OpenWork';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function targetAlreadySyncedCommit(
|
||||
commit: string,
|
||||
targetTrailer: string,
|
||||
targetSyncedCommits: Set<string>,
|
||||
): string | undefined {
|
||||
if (targetSyncedCommits.has(commit)) {
|
||||
return `target already has ${targetTrailer}: ${commit}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function ensureSimpleMergeCommit(params: {
|
||||
mode: MigrationMode;
|
||||
sourceRepo: string;
|
||||
commit: string;
|
||||
parents: string[];
|
||||
pathspecs: string[];
|
||||
handledCommits: Set<string>;
|
||||
targetSyncedCommits: Set<string>;
|
||||
}): Promise<void> {
|
||||
const [firstParent, secondParent] = params.parents;
|
||||
if (!firstParent || !secondParent || params.parents.length !== 2) {
|
||||
throw new Error(`Cannot inspect octopus merge commit: ${params.commit}`);
|
||||
}
|
||||
|
||||
const introducedCommits = await getMergeIntroducedCommits(
|
||||
params.sourceRepo,
|
||||
firstParent,
|
||||
params.commit,
|
||||
params.pathspecs,
|
||||
);
|
||||
const missingCommits: string[] = [];
|
||||
for (const commit of introducedCommits) {
|
||||
if (
|
||||
params.handledCommits.has(commit) ||
|
||||
params.targetSyncedCommits.has(commit) ||
|
||||
(await shouldSkipSyncedCommit(params.sourceRepo, commit, params.mode))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
missingCommits.push(commit);
|
||||
}
|
||||
if (missingCommits.length > 0) {
|
||||
throw new Error(
|
||||
`Merge commit introduced unhandled commits: ${params.commit}\n` +
|
||||
missingCommits.map((commit) => ` ${commit}`).join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
const mergeTree = await git(
|
||||
params.sourceRepo,
|
||||
['merge-tree', '--write-tree', firstParent, secondParent],
|
||||
{ allowFailure: true, capture: true },
|
||||
);
|
||||
if (mergeTree.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Merge commit requires manual resolution: ${params.commit}`,
|
||||
);
|
||||
}
|
||||
|
||||
const tree = mergeTree.stdout.trim().split(/\s+/)[0];
|
||||
const diff = await git(
|
||||
params.sourceRepo,
|
||||
['diff', '--quiet', tree, params.commit, '--', ...params.pathspecs],
|
||||
{ allowFailure: true },
|
||||
);
|
||||
if (diff.exitCode === 0) return;
|
||||
if (diff.exitCode === 1) {
|
||||
throw new Error(
|
||||
`Merge commit has manual resolution changes: ${params.commit}`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unable to inspect merge commit: ${params.commit}`);
|
||||
}
|
||||
|
||||
async function getCommitSubject(cwd: string, commit: string): Promise<string> {
|
||||
const result = await git(cwd, ['log', '-n', '1', '--format=%s', commit], {
|
||||
capture: true,
|
||||
});
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
async function applyPatch(cwd: string, patch: string): Promise<void> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'openwork-sync-'));
|
||||
const patchPath = join(dir, 'sync.patch');
|
||||
try {
|
||||
await writeFile(patchPath, patch);
|
||||
await git(cwd, ['apply', '-3', '--binary', patchPath]);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function commitChanges(
|
||||
cwd: string,
|
||||
subject: string,
|
||||
trailers: string[],
|
||||
): Promise<boolean> {
|
||||
await git(cwd, ['add', '-A']);
|
||||
const diff = await git(cwd, ['diff', '--cached', '--quiet'], {
|
||||
allowFailure: true,
|
||||
});
|
||||
|
||||
if (diff.exitCode === 0) {
|
||||
console.log('Source patch produced no target changes; no commit created.');
|
||||
return false;
|
||||
}
|
||||
if (diff.exitCode !== 1) {
|
||||
throw new Error('Unable to inspect staged sync diff.');
|
||||
}
|
||||
|
||||
await git(cwd, [
|
||||
'-c',
|
||||
'core.hooksPath=/dev/null',
|
||||
'commit',
|
||||
'-m',
|
||||
[subject, '', ...trailers].join('\n'),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function migrateCommits(params: {
|
||||
mode: MigrationMode;
|
||||
sourceRepo: string;
|
||||
targetRepo: string;
|
||||
commits: string[];
|
||||
pathspecs: string[];
|
||||
createPatch: (parent: string, commit: string) => Promise<string>;
|
||||
trailers: (parent: string, commit: string) => string[];
|
||||
}): Promise<number> {
|
||||
let count = 0;
|
||||
const handledCommits = new Set<string>();
|
||||
const targetTrailer =
|
||||
params.mode === 'import' ? 'OpenWork-Commit' : 'Qwen-Code-Commit';
|
||||
const targetSyncedCommits = await findTrailerValues(
|
||||
params.targetRepo,
|
||||
'HEAD',
|
||||
targetTrailer,
|
||||
);
|
||||
|
||||
for (const commit of params.commits) {
|
||||
const parents = await getParents(params.sourceRepo, commit);
|
||||
const parent = parents[0];
|
||||
if (!parent) {
|
||||
throw new Error(`Cannot migrate root commit as a patch: ${commit}`);
|
||||
}
|
||||
|
||||
const targetReason = targetAlreadySyncedCommit(
|
||||
commit,
|
||||
targetTrailer,
|
||||
targetSyncedCommits,
|
||||
);
|
||||
if (targetReason) {
|
||||
console.log(`Skipping ${commit.slice(0, 12)}; ${targetReason}.`);
|
||||
handledCommits.add(commit);
|
||||
continue;
|
||||
}
|
||||
|
||||
const skipReason = await shouldSkipSyncedCommit(
|
||||
params.sourceRepo,
|
||||
commit,
|
||||
params.mode,
|
||||
);
|
||||
if (skipReason) {
|
||||
console.log(`Skipping ${commit.slice(0, 12)}; ${skipReason}.`);
|
||||
handledCommits.add(commit);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parents.length > 1) {
|
||||
await ensureSimpleMergeCommit({
|
||||
mode: params.mode,
|
||||
sourceRepo: params.sourceRepo,
|
||||
commit,
|
||||
parents,
|
||||
pathspecs: params.pathspecs,
|
||||
handledCommits,
|
||||
targetSyncedCommits,
|
||||
});
|
||||
console.log(
|
||||
`Skipping merge ${commit.slice(0, 12)}; regular commits handled.`,
|
||||
);
|
||||
handledCommits.add(commit);
|
||||
continue;
|
||||
}
|
||||
|
||||
const patch = await params.createPatch(parent, commit);
|
||||
if (!patch.trim()) {
|
||||
handledCommits.add(commit);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Applying ${commit.slice(0, 12)}...`);
|
||||
await applyPatch(params.targetRepo, patch);
|
||||
const subject = await getCommitSubject(params.sourceRepo, commit);
|
||||
if (
|
||||
await commitChanges(params.targetRepo, subject, [
|
||||
...params.trailers(parent, commit),
|
||||
])
|
||||
) {
|
||||
count += 1;
|
||||
}
|
||||
handledCommits.add(commit);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
async function runExport(options: Options): Promise<void> {
|
||||
const openworkRoot = await getRepoRoot(options.openworkDir);
|
||||
const branch = options.branch || defaultBranch('export');
|
||||
|
||||
await ensureCleanWorktree(openworkRoot, 'OpenWork checkout');
|
||||
await ensureCommittedDesktopSource(options.allowDirtySource);
|
||||
|
||||
const source = await revParse(repoRoot, 'HEAD');
|
||||
const base = await resolveSourceBase(
|
||||
repoRoot,
|
||||
options.sourceBase,
|
||||
openworkRoot,
|
||||
options.openworkRef,
|
||||
'Qwen-Code-Commit',
|
||||
);
|
||||
const pathspecs = exportPathspecs(options.overlayPaths);
|
||||
const commits = await getSourceCommits(repoRoot, base, source, pathspecs);
|
||||
if (commits.length === 0) {
|
||||
console.log('No qwen-code source changes to export.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Preparing ${branch} in ${openworkRoot}...`);
|
||||
await switchTargetBranch(openworkRoot, branch, options.openworkRef);
|
||||
const openworkBase = await revParse(openworkRoot, options.openworkRef);
|
||||
const count = await migrateCommits({
|
||||
mode: 'export',
|
||||
sourceRepo: repoRoot,
|
||||
targetRepo: openworkRoot,
|
||||
commits,
|
||||
pathspecs,
|
||||
createPatch: (parent, commit) =>
|
||||
createExportPatch(parent, commit, options.overlayPaths),
|
||||
trailers: (parent, commit) => [
|
||||
'OpenWork-Sync-Mode: export',
|
||||
`Qwen-Code-Base: ${parent}`,
|
||||
`Qwen-Code-Commit: ${commit}`,
|
||||
`OpenWork-Base: ${openworkBase}`,
|
||||
],
|
||||
});
|
||||
if (count === 0) return;
|
||||
|
||||
console.log(`Created ${branch} in ${openworkRoot} with ${count} commits.`);
|
||||
console.log(`Next: git -C ${openworkRoot} push -u origin ${branch}`);
|
||||
}
|
||||
|
||||
async function runImport(options: Options): Promise<void> {
|
||||
const openworkRoot = await getRepoRoot(options.openworkDir);
|
||||
const branch = options.branch || defaultBranch('import');
|
||||
|
||||
await ensureCleanWorktree(openworkRoot, 'OpenWork checkout');
|
||||
await ensureCleanWorktree(repoRoot, 'qwen-code checkout');
|
||||
|
||||
const source = await revParse(openworkRoot, options.openworkRef);
|
||||
const base = await resolveSourceBase(
|
||||
openworkRoot,
|
||||
options.sourceBase,
|
||||
repoRoot,
|
||||
options.qwenBase,
|
||||
'OpenWork-Commit',
|
||||
);
|
||||
const pathspecs = importPathspecs(options.overlayPaths);
|
||||
const commits = await getSourceCommits(openworkRoot, base, source, pathspecs);
|
||||
if (commits.length === 0) {
|
||||
console.log('No OpenWork source changes to import.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Preparing ${branch} in ${repoRoot}...`);
|
||||
await switchTargetBranch(repoRoot, branch, options.qwenBase);
|
||||
const qwenBase = await revParse(repoRoot, options.qwenBase);
|
||||
const count = await migrateCommits({
|
||||
mode: 'import',
|
||||
sourceRepo: openworkRoot,
|
||||
targetRepo: repoRoot,
|
||||
commits,
|
||||
pathspecs,
|
||||
createPatch: (parent, commit) =>
|
||||
createImportPatch(openworkRoot, parent, commit, options.overlayPaths),
|
||||
trailers: (parent, commit) => [
|
||||
'OpenWork-Sync-Mode: import',
|
||||
`OpenWork-Base: ${parent}`,
|
||||
`OpenWork-Commit: ${commit}`,
|
||||
`Qwen-Code-Base: ${qwenBase}`,
|
||||
],
|
||||
});
|
||||
if (count === 0) return;
|
||||
|
||||
console.log(`Created ${branch} in ${repoRoot} with ${count} commits.`);
|
||||
}
|
||||
|
||||
async function runAuto(): Promise<void> {
|
||||
throw new Error(
|
||||
'Auto mode is intentionally conservative. Use --mode export or --mode ' +
|
||||
'import so the receiving repository is explicit.',
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
|
||||
switch (options.mode) {
|
||||
case 'auto':
|
||||
await runAuto();
|
||||
break;
|
||||
case 'export':
|
||||
await runExport(options);
|
||||
break;
|
||||
case 'import':
|
||||
await runImport(options);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -109,24 +109,27 @@ const env = {
|
|||
|
||||
// On Windows, use tsx.cmd; on Unix, use tsx directly
|
||||
const isWin = platform() === 'win32';
|
||||
const localTsxCmd = join(
|
||||
root,
|
||||
'node_modules',
|
||||
'.bin',
|
||||
isWin ? 'tsx.cmd' : 'tsx',
|
||||
);
|
||||
const tsxCmd = existsSync(localTsxCmd)
|
||||
? localTsxCmd
|
||||
: isWin
|
||||
? 'tsx.cmd'
|
||||
: 'tsx';
|
||||
const tsxArgs = [cliEntry, ...process.argv.slice(2)];
|
||||
const tsxBinName = isWin ? 'tsx.cmd' : 'tsx';
|
||||
const localTsxCli = join(root, 'node_modules', 'tsx', 'dist', 'cli.mjs');
|
||||
const localTsxCmd = join(root, 'node_modules', '.bin', tsxBinName);
|
||||
const hasLocalTsxCli = existsSync(localTsxCli);
|
||||
const tsxCmd = hasLocalTsxCli
|
||||
? process.execPath
|
||||
: existsSync(localTsxCmd)
|
||||
? localTsxCmd
|
||||
: tsxBinName;
|
||||
const tsxArgs = [
|
||||
...(hasLocalTsxCli ? [localTsxCli] : []),
|
||||
cliEntry,
|
||||
...process.argv.slice(2),
|
||||
];
|
||||
const useShell = isWin && !hasLocalTsxCli;
|
||||
|
||||
const child = spawn(tsxCmd, tsxArgs, {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
cwd: process.cwd(),
|
||||
shell: isWin, // Use shell on Windows to resolve .cmd files
|
||||
shell: useShell, // Needed only when falling back to tsx.cmd on Windows.
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
|
|
|
|||
95
scripts/tests/dev.test.js
Normal file
95
scripts/tests/dev.test.js
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { spawnMock, platformMock, existsSyncMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(() => ({ on: vi.fn() })),
|
||||
platformMock: vi.fn(() => 'darwin'),
|
||||
existsSyncMock: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: spawnMock,
|
||||
}));
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
platform: platformMock,
|
||||
tmpdir: vi.fn(() => '/tmp'),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
writeFileSync: vi.fn(),
|
||||
mkdtempSync: vi.fn(() => '/tmp/qwen-dev-test'),
|
||||
rmSync: vi.fn(),
|
||||
existsSync: existsSyncMock,
|
||||
symlinkSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('scripts/dev.js launcher', () => {
|
||||
const originalArgv = process.argv;
|
||||
const execPathDescriptor = Object.getOwnPropertyDescriptor(
|
||||
process,
|
||||
'execPath',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
process.argv = ['node', 'scripts/dev.js'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
if (execPathDescriptor) {
|
||||
Object.defineProperty(process, 'execPath', execPathDescriptor);
|
||||
}
|
||||
});
|
||||
|
||||
it('spawns Node without a shell on Windows when local tsx cli.mjs exists', async () => {
|
||||
platformMock.mockReturnValue('win32');
|
||||
existsSyncMock.mockImplementation((filePath) =>
|
||||
String(filePath).endsWith('node_modules/tsx/dist/cli.mjs'),
|
||||
);
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
configurable: true,
|
||||
value: 'C:\\Program Files\\nodejs\\node.exe',
|
||||
});
|
||||
process.argv = ['node', 'scripts/dev.js', '--help'];
|
||||
|
||||
await import('../dev.js?direct-node');
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'C:\\Program Files\\nodejs\\node.exe',
|
||||
[
|
||||
expect.stringContaining('node_modules/tsx/dist/cli.mjs'),
|
||||
expect.stringContaining('packages/cli/index.ts'),
|
||||
'--help',
|
||||
],
|
||||
expect.objectContaining({ shell: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps shell fallback for Windows tsx.cmd resolution', async () => {
|
||||
platformMock.mockReturnValue('win32');
|
||||
existsSyncMock.mockImplementation((filePath) =>
|
||||
String(filePath).endsWith('node_modules/.bin/tsx.cmd'),
|
||||
);
|
||||
|
||||
await import('../dev.js?cmd-fallback');
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('tsx.cmd'),
|
||||
[expect.stringContaining('packages/cli/index.ts')],
|
||||
expect.objectContaining({ shell: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue