diff --git a/.github/workflows/ci-bot.yml b/.github/workflows/ci-bot.yml
deleted file mode 100644
index d2f0b4cf4..000000000
--- a/.github/workflows/ci-bot.yml
+++ /dev/null
@@ -1,178 +0,0 @@
-name: CI Bot
-on:
- issue_comment:
- types:
- - created
-
- pull_request_review_comment:
- types:
- - created
-
- issues:
- types:
- - opened
-
- pull_request_target:
- types:
- - opened
- - synchronize
-
-env:
- PLUGINS: |-
- assign
- auto-cc
- cc
-
- AUTHOR_PLUGINS: |-
- label-bug
- label-documentation
- label-enhancement
- label-question
- retest
-
- MEMBERS_PLUGINS: |-
- label-duplicate
- label-good-first-issue
- label-help-wanted
- label-invalid
- label-kind
- label-wontfix
- lifecycle
- retest
-
- REVIEWERS_PLUGINS: |-
- label-lgtm
- retitle
-
- APPROVERS_PLUGINS: |-
- label-approve
- label
- merge
- base
- rebase
- cherry-pick
-
- MAINTAINERS_PLUGINS: ''
-
- REVIEWERS: |-
- samzong
- yankay
-
- APPROVERS: |-
- samzong
- yankay
-
- MAINTAINERS: ''
-
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GH_REPOSITORY: ${{ github.repository }}
- DETAILS: |-
-
-
- Details
-
- Instructions for interacting with me using comments are available here.
- If you have questions or suggestions related to my behavior, please file an issue against the [gh-ci-bot](https://github.com/wzshiming/gh-ci-bot) repository.
-
-
-
-permissions:
- actions: write
- checks: read
- contents: write
- deployments: none
- id-token: none
- issues: write
- discussions: read
- packages: none
- pages: none
- pull-requests: write
- repository-projects: read
- security-events: none
- statuses: read
-
-jobs:
- bot:
- name: Bot
- runs-on: ubuntu-latest
- steps:
- - name: Issue Opened
- uses: wzshiming/gh-ci-bot@v1.5.0
- if: ${{ github.event_name == 'issues' }}
- env:
- LOGIN: ${{ github.event.issue.user.login }}
- AUTHOR: ${{ github.event.issue.user.login }}
- MESSAGE: ${{ github.event.issue.body }}
- ISSUE_NUMBER: ${{ github.event.issue.number }}
- AUTHOR_ASSOCIATION: ${{ github.event.issue.author_association }}
- ISSUE_KIND: issue
- TYPE: created
- GREETING: |-
- Hi @${{ github.event.issue.user.login }},
- Thanks for opening an issue!
- We will look into it as soon as possible.
-
- - name: PR Opened
- uses: wzshiming/gh-ci-bot@v1.5.0
- if: ${{ github.event_name == 'pull_request_target' && github.event.action == 'opened' }}
- env:
- LOGIN: ${{ github.event.pull_request.user.login }}
- AUTHOR: ${{ github.event.pull_request.user.login }}
- MESSAGE: ${{ github.event.pull_request.body }}
- ISSUE_NUMBER: ${{ github.event.pull_request.number }}
- AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
- ISSUE_KIND: pr
- TYPE: created
- GREETING: |-
- Hi @${{ github.event.pull_request.user.login }},
- Thanks for your pull request!
- If the PR is ready, use the `/auto-cc` command to assign Reviewer to Review.
- We will review it shortly.
-
- - name: PR Synchronize
- uses: wzshiming/gh-ci-bot@v1.5.0
- if: ${{ github.event_name == 'pull_request_target' && github.event.action == 'synchronize' }}
- env:
- LOGIN: ${{ github.event.pull_request.user.login }}
- AUTHOR: ${{ github.event.pull_request.user.login }}
- MESSAGE: ''
- ISSUE_NUMBER: ${{ github.event.pull_request.number }}
- AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
- ISSUE_KIND: pr
- TYPE: synchronize
-
- - name: Issue Commented
- uses: wzshiming/gh-ci-bot@v1.5.0
- if: ${{ github.event_name == 'issue_comment' && !github.event.issue.pull_request }}
- env:
- LOGIN: ${{ github.event.comment.user.login }}
- AUTHOR: ${{ github.event.issue.user.login }}
- MESSAGE: ${{ github.event.comment.body }}
- ISSUE_NUMBER: ${{ github.event.issue.number }}
- AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }}
- ISSUE_KIND: issue
- TYPE: comment
-
- - name: PR Review Commented
- uses: wzshiming/gh-ci-bot@v1.5.0
- if: ${{ github.event_name == 'pull_request_review_comment' }}
- env:
- LOGIN: ${{ github.event.comment.user.login }}
- AUTHOR: ${{ github.event.pull_request.user.login }}
- MESSAGE: ${{ github.event.comment.body }}
- ISSUE_NUMBER: ${{ github.event.pull_request.number }}
- AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }}
- ISSUE_KIND: pr
- TYPE: comment
-
- - name: PR Commented
- uses: wzshiming/gh-ci-bot@v1.5.0
- if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
- env:
- LOGIN: ${{ github.event.comment.user.login }}
- AUTHOR: ${{ github.event.issue.user.login }}
- MESSAGE: ${{ github.event.comment.body }}
- ISSUE_NUMBER: ${{ github.event.issue.number }}
- AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }}
- ISSUE_KIND: pr
- TYPE: comment
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
deleted file mode 100644
index 560dc4827..000000000
--- a/.github/workflows/e2e.yml
+++ /dev/null
@@ -1,194 +0,0 @@
-name: E2E Tests
-
-on:
- pull_request:
- branches: [main]
-
-concurrency:
- group: e2e-${{ github.event.pull_request.number }}
- cancel-in-progress: true
-
-jobs:
- changes:
- runs-on: ubuntu-latest
- permissions:
- pull-requests: read
- outputs:
- smoke: ${{ steps.plan.outputs.smoke }}
- gateway: ${{ steps.plan.outputs.gateway }}
- steps:
- - uses: actions/checkout@v6
-
- - id: filter
- uses: dorny/paths-filter@v4
- with:
- filters: |
- global:
- - 'package.json'
- - 'pnpm-lock.yaml'
- - 'pnpm-workspace.yaml'
- - 'tsconfig.base.json'
- - '.github/workflows/e2e.yml'
- - '.github/workflows/pr-check.yml'
- - 'e2e/playwright.config.ts'
- - 'e2e/helpers/**'
- shared:
- - 'packages/shared/**'
- core:
- - 'packages/core/**'
- desktop_renderer:
- - 'packages/desktop/src/renderer/**'
- desktop_main:
- - 'packages/desktop/src/main/**'
- - 'packages/desktop/src/preload/**'
- desktop_package:
- - 'packages/desktop/package.json'
- - 'packages/desktop/electron.vite.config.ts'
- - 'packages/desktop/scripts/**'
- smoke_specs:
- - 'e2e/smoke/**'
- gateway_specs:
- - 'e2e/gateway/**'
- - 'e2e/docker-compose.yml'
-
- - id: plan
- env:
- GLOBAL: ${{ steps.filter.outputs.global }}
- SHARED: ${{ steps.filter.outputs.shared }}
- CORE: ${{ steps.filter.outputs.core }}
- DESKTOP_RENDERER: ${{ steps.filter.outputs.desktop_renderer }}
- DESKTOP_MAIN: ${{ steps.filter.outputs.desktop_main }}
- DESKTOP_PACKAGE: ${{ steps.filter.outputs.desktop_package }}
- SMOKE_SPECS: ${{ steps.filter.outputs.smoke_specs }}
- GATEWAY_SPECS: ${{ steps.filter.outputs.gateway_specs }}
- run: |
- set_flag() {
- local name="$1"
- shift
- local value=false
- for item in "$@"; do
- if [ "$item" = "true" ]; then
- value=true
- break
- fi
- done
- echo "$name=$value" >> "$GITHUB_OUTPUT"
- }
-
- set_flag smoke "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_RENDERER" "$DESKTOP_MAIN" "$DESKTOP_PACKAGE" "$SMOKE_SPECS"
- set_flag gateway "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_MAIN" "$DESKTOP_PACKAGE" "$GATEWAY_SPECS"
-
- smoke:
- needs: [changes]
- if: needs.changes.outputs.smoke == 'true'
- runs-on: ubuntu-latest
- timeout-minutes: 10
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup pnpm
- uses: pnpm/action-setup@v5
- with:
- version: 10
-
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version: 20
- cache: pnpm
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- - name: Build shared package
- run: pnpm --filter @clawwork/desktop exec tsc -b ../../packages/shared/tsconfig.json
-
- - name: Build Electron app
- run: pnpm --filter @clawwork/desktop exec electron-vite build
-
- - name: Install Xvfb
- run: sudo apt-get install -y xvfb
-
- - name: Run smoke tests
- run: xvfb-run --auto-servernum -- npx playwright test --config=e2e/playwright.config.ts --project=smoke
-
- - name: Upload smoke report
- if: always()
- uses: actions/upload-artifact@v7
- with:
- name: e2e-smoke-report
- path: playwright-report/
- retention-days: 7
-
- gateway:
- needs: [changes]
- if: needs.changes.outputs.gateway == 'true'
- runs-on: ubuntu-latest
- timeout-minutes: 10
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup pnpm
- uses: pnpm/action-setup@v5
- with:
- version: 10
-
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version: 20
- cache: pnpm
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- - name: Build shared package
- run: pnpm --filter @clawwork/desktop exec tsc -b ../../packages/shared/tsconfig.json
-
- - name: Build Electron app
- run: pnpm --filter @clawwork/desktop exec electron-vite build
-
- - name: Install Xvfb
- run: sudo apt-get install -y xvfb
-
- - name: Pull OpenClaw image
- run: docker pull ghcr.io/openclaw/openclaw@$OPENCLAW_DIGEST
- env:
- OPENCLAW_DIGEST: sha256:a5a4c83b773aca85a8ba99cf155f09afa33946c0aa5cc6a9ccb6162738b5da02
-
- - name: Start OpenClaw Gateway
- run: docker compose -f e2e/docker-compose.yml up -d --wait
-
- - name: Run gateway tests
- run: xvfb-run --auto-servernum -- npx playwright test --config=e2e/playwright.config.ts --project=gateway
-
- - name: Stop OpenClaw Gateway
- if: always()
- run: docker compose -f e2e/docker-compose.yml down
-
- - name: Upload gateway report
- if: always()
- uses: actions/upload-artifact@v7
- with:
- name: e2e-gateway-report
- path: playwright-report/
- retention-days: 7
-
- e2e-passed:
- needs: [changes, smoke, gateway]
- if: always()
- runs-on: ubuntu-latest
- steps:
- - name: Verify aggregate status
- env:
- NEEDS: ${{ toJson(needs) }}
- run: |
- echo "$NEEDS"
- failed=$(jq -r '[to_entries[] | select(.value.result == "failure" or .value.result == "cancelled") | .key] | join(", ")' <<< "$NEEDS")
- if [ -n "$failed" ]; then
- echo "::error::Required gates failed or cancelled: $failed"
- exit 1
- fi
- echo "All required gates passed or skipped cleanly."
diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml
deleted file mode 100644
index cec9fac6b..000000000
--- a/.github/workflows/pr-check.yml
+++ /dev/null
@@ -1,307 +0,0 @@
-name: PR Check
-
-on:
- pull_request:
- branches: [main]
-
-concurrency:
- group: pr-${{ github.event.pull_request.number }}
- cancel-in-progress: true
-
-jobs:
- changes:
- runs-on: ubuntu-latest
- permissions:
- pull-requests: read
- outputs:
- quality: ${{ steps.plan.outputs.quality }}
- architecture: ${{ steps.plan.outputs.architecture }}
- ui_contract: ${{ steps.plan.outputs.ui_contract }}
- renderer_copy: ${{ steps.plan.outputs.renderer_copy }}
- i18n: ${{ steps.plan.outputs.i18n }}
- dead_code: ${{ steps.plan.outputs.dead_code }}
- typecheck_shared: ${{ steps.plan.outputs.typecheck_shared }}
- typecheck_core: ${{ steps.plan.outputs.typecheck_core }}
- typecheck_desktop: ${{ steps.plan.outputs.typecheck_desktop }}
- typecheck_pwa: ${{ steps.plan.outputs.typecheck_pwa }}
- typecheck_website: ${{ steps.plan.outputs.typecheck_website }}
- test_shared: ${{ steps.plan.outputs.test_shared }}
- test_core: ${{ steps.plan.outputs.test_core }}
- test_desktop: ${{ steps.plan.outputs.test_desktop }}
- test_pwa: ${{ steps.plan.outputs.test_pwa }}
- build_desktop: ${{ steps.plan.outputs.build_desktop }}
- test_job: ${{ steps.plan.outputs.test_job }}
- steps:
- - uses: actions/checkout@v6
-
- - id: filter
- uses: dorny/paths-filter@v4
- with:
- filters: |
- global:
- - 'package.json'
- - 'pnpm-lock.yaml'
- - 'pnpm-workspace.yaml'
- - 'tsconfig.base.json'
- - 'eslint.config.mjs'
- - 'knip.json'
- - 'scripts/**'
- - '.github/workflows/pr-check.yml'
- - '.github/workflows/e2e.yml'
- shared:
- - 'packages/shared/**'
- core:
- - 'packages/core/**'
- desktop_renderer:
- - 'packages/desktop/src/renderer/**'
- - 'packages/desktop/test/**/*.tsx'
- desktop_main:
- - 'packages/desktop/src/main/**'
- - 'packages/desktop/src/preload/**'
- - 'packages/desktop/test/**/*.ts'
- desktop_package:
- - 'packages/desktop/package.json'
- - 'packages/desktop/electron.vite.config.ts'
- - 'packages/desktop/scripts/**'
- pwa:
- - 'packages/pwa/**'
- website:
- - 'website/**'
- - '.github/workflows/website.yml'
- keynote:
- - 'keynote/**'
-
- - id: plan
- env:
- GLOBAL: ${{ steps.filter.outputs.global }}
- SHARED: ${{ steps.filter.outputs.shared }}
- CORE: ${{ steps.filter.outputs.core }}
- DESKTOP_RENDERER: ${{ steps.filter.outputs.desktop_renderer }}
- DESKTOP_MAIN: ${{ steps.filter.outputs.desktop_main }}
- DESKTOP_PACKAGE: ${{ steps.filter.outputs.desktop_package }}
- PWA: ${{ steps.filter.outputs.pwa }}
- WEBSITE: ${{ steps.filter.outputs.website }}
- KEYNOTE: ${{ steps.filter.outputs.keynote }}
- run: |
- set_flag() {
- local name="$1"
- shift
- local value=false
- for item in "$@"; do
- if [ "$item" = "true" ]; then
- value=true
- break
- fi
- done
- echo "$name=$value" >> "$GITHUB_OUTPUT"
- }
-
- set_flag quality "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_RENDERER" "$DESKTOP_MAIN" "$DESKTOP_PACKAGE" "$PWA" "$WEBSITE"
- set_flag architecture "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_MAIN"
- set_flag ui_contract "$GLOBAL" "$DESKTOP_RENDERER" "$PWA"
- set_flag renderer_copy "$GLOBAL" "$DESKTOP_RENDERER"
- set_flag i18n "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_RENDERER" "$PWA"
- set_flag dead_code "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_RENDERER" "$DESKTOP_MAIN" "$DESKTOP_PACKAGE" "$PWA" "$WEBSITE"
- set_flag typecheck_shared "$GLOBAL" "$SHARED"
- set_flag typecheck_core "$GLOBAL" "$SHARED" "$CORE"
- set_flag typecheck_desktop "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_RENDERER" "$DESKTOP_MAIN" "$DESKTOP_PACKAGE"
- set_flag typecheck_pwa "$GLOBAL" "$SHARED" "$CORE" "$PWA"
- set_flag typecheck_website "$GLOBAL" "$WEBSITE"
- set_flag test_shared "$GLOBAL" "$SHARED"
- set_flag test_core "$GLOBAL" "$SHARED" "$CORE"
- set_flag test_desktop "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_RENDERER" "$DESKTOP_MAIN" "$DESKTOP_PACKAGE"
- set_flag test_pwa "$GLOBAL" "$SHARED" "$CORE" "$PWA"
- set_flag build_desktop "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_RENDERER" "$DESKTOP_MAIN" "$DESKTOP_PACKAGE"
- set_flag test_job "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_RENDERER" "$DESKTOP_MAIN" "$DESKTOP_PACKAGE" "$PWA" "$WEBSITE"
-
- quality:
- needs: [changes]
- if: needs.changes.outputs.quality == 'true'
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup pnpm
- uses: pnpm/action-setup@v5
- with:
- version: 10
-
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version: 20
- cache: pnpm
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- - name: Lint
- run: pnpm lint
-
- - name: Format check
- run: pnpm format:check
-
- - name: Architecture guardrails
- if: needs.changes.outputs.architecture == 'true'
- run: pnpm check:architecture
-
- - name: UI contract
- if: needs.changes.outputs.ui_contract == 'true'
- run: pnpm check:ui-contract
-
- - name: Renderer copy contract
- if: needs.changes.outputs.renderer_copy == 'true'
- run: pnpm check:renderer-copy
-
- - name: i18n key parity
- if: needs.changes.outputs.i18n == 'true'
- run: pnpm check:i18n
-
- - name: Dead code detection
- if: needs.changes.outputs.dead_code == 'true'
- run: pnpm check:dead-code
-
- test:
- needs: [changes]
- if: needs.changes.outputs.test_job == 'true'
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup pnpm
- uses: pnpm/action-setup@v5
- with:
- version: 10
-
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version: 20
- cache: pnpm
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- - name: Type check shared
- if: needs.changes.outputs.typecheck_shared == 'true' || needs.changes.outputs.typecheck_core == 'true' || needs.changes.outputs.typecheck_desktop == 'true' || needs.changes.outputs.typecheck_pwa == 'true'
- run: pnpm --filter @clawwork/shared exec tsc -b
-
- - name: Type check core
- if: needs.changes.outputs.typecheck_core == 'true' || needs.changes.outputs.typecheck_desktop == 'true' || needs.changes.outputs.typecheck_pwa == 'true'
- run: pnpm --filter @clawwork/core exec tsc -b
-
- - name: Type check desktop
- if: needs.changes.outputs.typecheck_desktop == 'true'
- run: pnpm --filter @clawwork/desktop exec tsc --noEmit
-
- - name: Type check PWA
- if: needs.changes.outputs.typecheck_pwa == 'true'
- run: pnpm --filter @clawwork/pwa exec tsc --noEmit
-
- - name: Type check website
- if: needs.changes.outputs.typecheck_website == 'true'
- run: pnpm --filter @clawwork/website exec tsc --noEmit
-
- - name: Type check shared tests
- if: needs.changes.outputs.test_shared == 'true'
- run: pnpm --filter @clawwork/shared exec tsc --noEmit -p tsconfig.test.json
-
- - name: Type check core tests
- if: needs.changes.outputs.test_core == 'true'
- run: pnpm --filter @clawwork/core exec tsc --noEmit -p tsconfig.test.json
-
- - name: Type check desktop tests
- if: needs.changes.outputs.test_desktop == 'true'
- run: pnpm --filter @clawwork/desktop exec tsc --noEmit -p tsconfig.test.json
-
- - name: Type check PWA tests
- if: needs.changes.outputs.test_pwa == 'true'
- run: pnpm --filter @clawwork/pwa exec tsc --noEmit -p tsconfig.test.json
-
- - name: Test shared
- if: needs.changes.outputs.test_shared == 'true'
- run: pnpm --filter @clawwork/shared test
-
- - name: Test core
- if: needs.changes.outputs.test_core == 'true'
- run: pnpm --filter @clawwork/core test
-
- - name: Test desktop
- if: needs.changes.outputs.test_desktop == 'true'
- run: pnpm --filter @clawwork/desktop test
-
- - name: Test PWA
- if: needs.changes.outputs.test_pwa == 'true'
- run: pnpm --filter @clawwork/pwa test
-
- build:
- needs: [changes, quality, test]
- if: needs.changes.outputs.build_desktop == 'true' && needs.quality.result != 'failure' && needs.test.result != 'failure'
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup pnpm
- uses: pnpm/action-setup@v5
- with:
- version: 10
-
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version: 20
- cache: pnpm
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- - name: Build Electron app
- run: pnpm --filter @clawwork/desktop build
-
- - name: Package Linux smoke build
- run: pnpm --filter @clawwork/desktop exec electron-builder --linux --x64 --publish never
-
- secrets-scan:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- env:
- GITLEAKS_VERSION: 8.30.1
- steps:
- - name: Checkout
- uses: actions/checkout@v6
- with:
- fetch-depth: 0
-
- - name: Install gitleaks
- run: |
- curl -fsSL \
- "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
- | tar -xz -C /usr/local/bin gitleaks
- gitleaks version
-
- - name: Scan PR diff
- env:
- BASE_SHA: ${{ github.event.pull_request.base.sha }}
- HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- run: gitleaks detect --source . --log-opts "${BASE_SHA}..${HEAD_SHA}" --redact --verbose --no-banner
-
- ci-passed:
- needs: [changes, quality, test, build, secrets-scan]
- if: always()
- runs-on: ubuntu-latest
- steps:
- - name: Verify aggregate status
- env:
- NEEDS: ${{ toJson(needs) }}
- run: |
- echo "$NEEDS"
- failed=$(jq -r '[to_entries[] | select(.value.result == "failure" or .value.result == "cancelled") | .key] | join(", ")' <<< "$NEEDS")
- if [ -n "$failed" ]; then
- echo "::error::Required gates failed or cancelled: $failed"
- exit 1
- fi
- echo "All required gates passed or skipped cleanly."
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
deleted file mode 100644
index fff091485..000000000
--- a/.github/workflows/release.yml
+++ /dev/null
@@ -1,263 +0,0 @@
-name: Release
-
-on:
- push:
- tags:
- - 'v*'
- workflow_dispatch:
- inputs:
- tag:
- description: 'Release tag (for example: v1.0.0 or v1.0.0-beta.1)'
- required: true
- type: string
-
-permissions:
- contents: write
- actions: write
-
-jobs:
- prepare-release:
- runs-on: ubuntu-latest
- env:
- RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
- outputs:
- release_tag: ${{ steps.meta.outputs.release_tag }}
- is_prerelease: ${{ steps.meta.outputs.is_prerelease }}
- steps:
- - name: Checkout
- uses: actions/checkout@v6
- with:
- ref: ${{ env.RELEASE_TAG }}
-
- - name: Verify tag format and version consistency
- id: meta
- run: |
- if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-((alpha|beta|rc)\.[0-9]+))?$ ]]; then
- echo "::error::Unsupported release tag format: $RELEASE_TAG"
- exit 1
- fi
-
- DESKTOP_VERSION=$(node -p "require('./packages/desktop/package.json').version")
- TAG_VERSION="${RELEASE_TAG#v}"
- if [ "$DESKTOP_VERSION" != "$TAG_VERSION" ]; then
- echo "::error::Version mismatch: package.json=${DESKTOP_VERSION}, tag=${TAG_VERSION}"
- exit 1
- fi
-
- echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
- if [[ "$RELEASE_TAG" == *-* ]]; then
- echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
- else
- echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
- fi
-
- release:
- needs: prepare-release
- strategy:
- fail-fast: false
- matrix:
- include:
- - os: macos-latest
- platform: mac
- arch: arm64
- build_args: '--mac --arm64'
- - os: macos-latest
- platform: mac
- arch: x64
- build_args: '--mac --x64'
- - os: windows-latest
- platform: win
- arch: x64
- build_args: '--win --x64'
- - os: ubuntu-latest
- platform: linux
- arch: x64
- build_args: '--linux --x64'
-
- runs-on: ${{ matrix.os }}
- env:
- RELEASE_TAG: ${{ needs.prepare-release.outputs.release_tag }}
- IS_PRERELEASE: ${{ needs.prepare-release.outputs.is_prerelease }}
- defaults:
- run:
- shell: bash
-
- steps:
- - name: Checkout
- uses: actions/checkout@v6
- with:
- ref: ${{ env.RELEASE_TAG }}
-
- - name: Setup pnpm
- uses: pnpm/action-setup@v5
- with:
- version: 10
-
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version: 20
- cache: pnpm
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- - name: Import Apple signing certificate (macOS)
- if: matrix.platform == 'mac'
- env:
- APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
- APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- run: |
- if [ -z "$APPLE_CERTIFICATE" ]; then
- echo "⚠️ Apple certificate not configured, skipping code signing"
- exit 0
- fi
-
- KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
- KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
-
- echo -n "$APPLE_CERTIFICATE" | base64 --decode -o $RUNNER_TEMP/certificate.p12
-
- security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
- security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
- security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
- security import $RUNNER_TEMP/certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
- security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
- security list-keychain -d user -s $KEYCHAIN_PATH
-
- echo "✅ Certificate imported successfully"
-
- - name: Build Electron app
- run: pnpm --filter @clawwork/desktop build
-
- - name: Package and publish (${{ matrix.platform }})
- if: matrix.platform != 'mac'
- run: pnpm --filter @clawwork/desktop exec electron-builder ${{ matrix.build_args }} --publish always -c.buildVersion="$BUILD_VERSION"
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- BUILD_VERSION: ${{ github.run_number }}
- EP_PRE_RELEASE: ${{ env.IS_PRERELEASE }}
-
- - name: Package and publish (mac, with adhoc fallback)
- if: matrix.platform == 'mac'
- run: |
- LOG=$(mktemp)
- set +e
- pnpm --filter @clawwork/desktop exec electron-builder ${{ matrix.build_args }} --publish always -c.buildVersion="$BUILD_VERSION" 2>&1 | tee "$LOG"
- status=${PIPESTATUS[0]}
- set -e
- if [ "$status" -eq 0 ]; then
- exit 0
- fi
- if ! grep -q "Failed to notarize" "$LOG"; then
- echo "::error::electron-builder failed for a non-notarization reason — not falling back to ad-hoc."
- exit "$status"
- fi
- echo "::warning::Notarization failed — falling back to ad-hoc signing for ${{ matrix.arch }}."
- rm -rf packages/desktop/dist
- unset APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID
- export CSC_IDENTITY_AUTO_DISCOVERY=false
- pnpm --filter @clawwork/desktop exec electron-builder ${{ matrix.build_args }} --publish always -c.buildVersion="$BUILD_VERSION" -c.mac.identity=null -c.mac.notarize=false
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- BUILD_VERSION: ${{ github.run_number }}
- EP_PRE_RELEASE: ${{ env.IS_PRERELEASE }}
- APPLE_ID: ${{ secrets.APPLE_ID }}
- APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
- APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
-
- - name: Upload arch-specific update metadata (macOS arm64)
- if: matrix.platform == 'mac' && matrix.arch == 'arm64'
- run: |
- cp packages/desktop/dist/latest-mac.yml packages/desktop/dist/latest-mac-arm64.yml
- gh release upload "$RELEASE_TAG" packages/desktop/dist/latest-mac-arm64.yml --clobber --repo clawwork-ai/clawwork
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- verify-release:
- needs: [prepare-release, release]
- runs-on: ubuntu-latest
- env:
- RELEASE_TAG: ${{ needs.prepare-release.outputs.release_tag }}
- steps:
- - name: Verify update metadata files
- run: |
- ASSETS=$(gh release view "$RELEASE_TAG" --repo clawwork-ai/clawwork --json assets -q '.assets[].name')
- echo "Release assets:"
- echo "$ASSETS"
- echo "$ASSETS" | grep -q "latest-mac-arm64.yml" || { echo "::error::latest-mac-arm64.yml missing from release"; exit 1; }
- echo "$ASSETS" | grep -q "latest-mac.yml" || { echo "::error::latest-mac.yml (x64) missing from release"; exit 1; }
- echo "$ASSETS" | grep -q "latest.yml" || { echo "::error::latest.yml missing from release"; exit 1; }
- echo "$ASSETS" | grep -q "latest-linux.yml" || { echo "::error::latest-linux.yml missing from release"; exit 1; }
- echo "$ASSETS" | grep -q "\.AppImage$" || { echo "::error::AppImage missing from release"; exit 1; }
- echo "$ASSETS" | grep -q "\.deb$" || { echo "::error::deb package missing from release"; exit 1; }
- echo "All update metadata files present"
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- publish-release-notes:
- needs: [prepare-release, verify-release]
- runs-on: ubuntu-latest
- permissions:
- contents: write
- pull-requests: read
- env:
- RELEASE_TAG: ${{ needs.prepare-release.outputs.release_tag }}
- steps:
- - name: Checkout
- uses: actions/checkout@v6
- with:
- ref: ${{ env.RELEASE_TAG }}
- fetch-depth: 0
-
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version: 20
-
- - name: Generate release notes draft
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- node scripts/generate-release-notes.mjs "$RELEASE_TAG" > /tmp/release-notes.md
- {
- echo "## Draft release notes for $RELEASE_TAG"
- echo ''
- cat /tmp/release-notes.md
- } >> "$GITHUB_STEP_SUMMARY"
-
- - name: Upload draft as artifact
- uses: actions/upload-artifact@v7
- with:
- name: release-notes-${{ env.RELEASE_TAG }}
- path: /tmp/release-notes.md
-
- - name: Fill Release body only if empty
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- if ! current=$(gh release view "$RELEASE_TAG" --repo ${{ github.repository }} --json body -q '.body' 2>/dev/null); then
- echo "::warning::Could not read current Release body — skipping notes fill to avoid overwriting hand-written content"
- exit 0
- fi
- stripped=$(printf '%s' "$current" | tr -d '[:space:]')
- if [ -z "$stripped" ]; then
- echo "Release body is empty — writing draft"
- gh release edit "$RELEASE_TAG" --repo ${{ github.repository }} --notes-file /tmp/release-notes.md
- else
- echo "Release body already populated — leaving it alone"
- fi
-
- update-homebrew:
- needs: [prepare-release, release, verify-release]
- if: ${{ needs.prepare-release.outputs.is_prerelease != 'true' }}
- runs-on: ubuntu-latest
- steps:
- - name: Trigger Homebrew tap update
- run: |
- gh workflow run update-homebrew.yml \
- --repo clawwork-ai/clawwork \
- --field tag="$RELEASE_TAG"
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- RELEASE_TAG: ${{ needs.prepare-release.outputs.release_tag }}
diff --git a/.github/workflows/update-homebrew.yml b/.github/workflows/update-homebrew.yml
deleted file mode 100644
index dde04a6f0..000000000
--- a/.github/workflows/update-homebrew.yml
+++ /dev/null
@@ -1,61 +0,0 @@
-name: Update Homebrew Tap
-
-on:
- release:
- types: [published]
- workflow_dispatch:
- inputs:
- tag:
- description: 'Git tag to publish to Homebrew tap (for example: v0.0.2)'
- required: true
- type: string
-
-permissions:
- contents: read
-
-jobs:
- update-homebrew-tap:
- if: ${{ github.event_name != 'release' || github.event.release.prerelease != true }}
- runs-on: ubuntu-latest
- env:
- CLAWWORK_REPO: clawwork-ai/clawwork
- TAP_DIR: homebrew-clawwork
- RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }}
-
- steps:
- - name: Checkout source repository
- uses: actions/checkout@v6
-
- - name: Checkout tap repository
- uses: actions/checkout@v6
- with:
- repository: clawwork-ai/homebrew-clawwork
- token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
- path: homebrew-clawwork
-
- - name: Update clawwork cask
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- CLAWWORK_REPO: ${{ env.CLAWWORK_REPO }}
- TAP_DIR: ${{ env.TAP_DIR }}
- RELEASE_TAG: ${{ env.RELEASE_TAG }}
- run: |
- bash scripts/update-homebrew-tap.sh
-
- - name: Configure git identity
- working-directory: homebrew-clawwork
- run: |
- git config user.name "github-actions[bot]"
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
-
- - name: Commit and push tap update
- working-directory: homebrew-clawwork
- run: |
- if git diff --quiet; then
- echo "No Homebrew tap changes to commit."
- exit 0
- fi
-
- git add Casks/clawwork.rb
- git commit -m "chore: update clawwork cask for ${RELEASE_TAG}"
- git push origin HEAD:main
diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml
deleted file mode 100644
index 670d0507b..000000000
--- a/.github/workflows/website.yml
+++ /dev/null
@@ -1,75 +0,0 @@
-name: Website
-
-on:
- push:
- branches: [main]
- paths:
- - 'website/**'
- - 'keynote/**'
- - '.github/workflows/website.yml'
- pull_request:
- paths:
- - 'website/**'
- - 'keynote/**'
- - '.github/workflows/website.yml'
- workflow_dispatch:
-
-concurrency:
- group: pages
- cancel-in-progress: false
-
-jobs:
- build:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v6
-
- - uses: pnpm/action-setup@v5
- with:
- version: 10
-
- - uses: actions/setup-node@v6
- with:
- node-version: 20
- cache: pnpm
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- - name: Typecheck
- run: pnpm --filter @clawwork/website exec tsc --noEmit
-
- - name: Build website
- run: pnpm --filter @clawwork/website build
- env:
- VITE_BASE: /ClawWork/
-
- - name: Build keynote
- run: pnpm --filter @clawwork/keynote build --base /ClawWork/keynote/
-
- - name: Patch keynote SPA redirect
- run: node -e "const f='keynote/dist/index.html';const fs=require('fs');const h=fs.readFileSync(f,'utf8');fs.writeFileSync(f,h.replace('
',''))"
-
- - name: Merge outputs
- run: cp -r keynote/dist website/dist/keynote
-
- - name: Upload Pages artifact
- if: github.ref == 'refs/heads/main'
- uses: actions/upload-pages-artifact@v5
- with:
- path: website/dist
-
- deploy:
- if: github.ref == 'refs/heads/main'
- needs: build
- runs-on: ubuntu-latest
- permissions:
- pages: write
- id-token: write
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- steps:
- - name: Deploy to GitHub Pages
- id: deployment
- uses: actions/deploy-pages@v5
diff --git a/packages/desktop/src/main/workspace/init.ts b/packages/desktop/src/main/workspace/init.ts
index 26374adee..f25b42158 100644
--- a/packages/desktop/src/main/workspace/init.ts
+++ b/packages/desktop/src/main/workspace/init.ts
@@ -12,7 +12,7 @@ export async function migrateWorkspace(oldPath: string, newPath: string): Promis
if (!existsSync(oldPath)) throw new Error(`Source workspace does not exist: ${oldPath}`);
const resolvedOld = resolve(oldPath);
const resolvedNew = resolve(newPath);
- if (resolvedNew.startsWith(resolvedOld + '/') || resolvedNew === resolvedOld) {
+ if (resolvedNew.startsWith(resolvedOld + sep) || resolvedNew === resolvedOld) {
throw new Error('New workspace path must not be inside or equal to the current workspace');
}
await cp(resolvedOld, resolvedNew, {
diff --git a/packages/desktop/src/renderer/layouts/Settings/sections/GatewaysSection.tsx b/packages/desktop/src/renderer/layouts/Settings/sections/GatewaysSection.tsx
index 86dcf2162..9bdb8d99b 100644
--- a/packages/desktop/src/renderer/layouts/Settings/sections/GatewaysSection.tsx
+++ b/packages/desktop/src/renderer/layouts/Settings/sections/GatewaysSection.tsx
@@ -480,34 +480,46 @@ export default function GatewaysSection() {
return;
}
setTesting(true);
- const auth = {
- token: form.token || undefined,
- password: form.password || undefined,
- };
- const res = await window.clawwork.testGateway(form.url, auth);
- setTesting(false);
- if (res.ok) {
- toast.success(t('settings.testSuccess'));
- } else if (res.pairingRequired) {
- setShowPairingDialog(true);
- } else {
- toast.error(t('settings.testFailed'), { description: res.error });
+ try {
+ const auth = {
+ token: form.token || undefined,
+ password: form.password || undefined,
+ };
+ const res = await window.clawwork.testGateway(form.url, auth);
+ if (res.ok) {
+ toast.success(t('settings.testSuccess'));
+ } else if (res.pairingRequired) {
+ setShowPairingDialog(true);
+ } else {
+ toast.error(t('settings.testFailed'), { description: res.error });
+ }
+ } catch (err) {
+ console.error('[GatewaysSection] test failed:', err);
+ toast.error(t('errors.failed'));
+ } finally {
+ setTesting(false);
}
}, [form, t]);
const handlePairingRetry = useCallback(async () => {
setPairingRetrying(true);
- const auth = {
- token: form.token || undefined,
- password: form.password || undefined,
- };
- const res = await window.clawwork.testGateway(form.url, auth);
- setPairingRetrying(false);
- if (res.ok) {
- setShowPairingDialog(false);
- toast.success(t('pairing.approved'));
- } else {
- toast.error(t('pairing.stillPending'), { description: res.error });
+ try {
+ const auth = {
+ token: form.token || undefined,
+ password: form.password || undefined,
+ };
+ const res = await window.clawwork.testGateway(form.url, auth);
+ if (res.ok) {
+ setShowPairingDialog(false);
+ toast.success(t('pairing.approved'));
+ } else {
+ toast.error(t('pairing.stillPending'), { description: res.error });
+ }
+ } catch (err) {
+ console.error('[GatewaysSection] pairing retry failed:', err);
+ toast.error(t('errors.failed'));
+ } finally {
+ setPairingRetrying(false);
}
}, [form.url, form.token, form.password, t]);
@@ -527,43 +539,49 @@ export default function GatewaysSection() {
}
setSaving(true);
- if (editingId) {
- const res = await window.clawwork.updateGateway(editingId, {
- name: form.name.trim(),
- url: form.url.trim(),
- token: form.token.trim() || undefined,
- password: form.password.trim() || undefined,
- pairingCode: form.pairingCode.trim() || undefined,
- authMode,
- });
- if (res.ok) {
- toast.success(t('settings.gatewayUpdated'));
- closeForm();
- await loadGateways();
- } else {
- toast.error(res.error ?? t('errors.failed'));
- }
- } else {
- const newGw: GatewayServerConfig = {
- id: crypto.randomUUID(),
- name: form.name.trim(),
- url: form.url.trim(),
- token: form.token.trim() || undefined,
- password: form.password.trim() || undefined,
- pairingCode: form.pairingCode.trim() || undefined,
- authMode,
- type: 'openclaw',
- };
- const res = await window.clawwork.addGateway(newGw);
- if (res.ok) {
- toast.success(t('settings.gatewayAdded'));
- closeForm();
- await loadGateways();
+ try {
+ if (editingId) {
+ const res = await window.clawwork.updateGateway(editingId, {
+ name: form.name.trim(),
+ url: form.url.trim(),
+ token: form.token.trim() || undefined,
+ password: form.password.trim() || undefined,
+ pairingCode: form.pairingCode.trim() || undefined,
+ authMode,
+ });
+ if (res.ok) {
+ toast.success(t('settings.gatewayUpdated'));
+ closeForm();
+ await loadGateways();
+ } else {
+ toast.error(res.error ?? t('errors.failed'));
+ }
} else {
- toast.error(res.error ?? t('errors.failed'));
+ const newGw: GatewayServerConfig = {
+ id: crypto.randomUUID(),
+ name: form.name.trim(),
+ url: form.url.trim(),
+ token: form.token.trim() || undefined,
+ password: form.password.trim() || undefined,
+ pairingCode: form.pairingCode.trim() || undefined,
+ authMode,
+ type: 'openclaw',
+ };
+ const res = await window.clawwork.addGateway(newGw);
+ if (res.ok) {
+ toast.success(t('settings.gatewayAdded'));
+ closeForm();
+ await loadGateways();
+ } else {
+ toast.error(res.error ?? t('errors.failed'));
+ }
}
+ } catch (err) {
+ console.error('[GatewaysSection] save failed:', err);
+ toast.error(t('errors.failed'));
+ } finally {
+ setSaving(false);
}
- setSaving(false);
}, [form, editingId, closeForm, loadGateways, t]);
const handleRemove = useCallback(
diff --git a/packages/desktop/src/renderer/layouts/Settings/sections/GeneralSection.tsx b/packages/desktop/src/renderer/layouts/Settings/sections/GeneralSection.tsx
index 9a10960a0..720a6b53d 100644
--- a/packages/desktop/src/renderer/layouts/Settings/sections/GeneralSection.tsx
+++ b/packages/desktop/src/renderer/layouts/Settings/sections/GeneralSection.tsx
@@ -101,7 +101,8 @@ export default function GeneralSection() {
const handleNotificationToggle = useCallback(
(key: 'taskComplete' | 'approvalRequest' | 'gatewayDisconnect', value: boolean) => {
- void updateSettings({ notifications: { ...notifyState, [key]: value } }).catch((err: unknown) => {
+ const next = { ...notifyState, [key]: value };
+ void updateSettings({ notifications: next }).catch((err: unknown) => {
console.error('[GeneralSection] updateSettings failed:', err);
});
},
diff --git a/packages/desktop/src/renderer/layouts/Settings/sections/SkillsSection.tsx b/packages/desktop/src/renderer/layouts/Settings/sections/SkillsSection.tsx
index 1bc24c22a..93a13d0f4 100644
--- a/packages/desktop/src/renderer/layouts/Settings/sections/SkillsSection.tsx
+++ b/packages/desktop/src/renderer/layouts/Settings/sections/SkillsSection.tsx
@@ -583,18 +583,24 @@ function ClawHubTab({ gatewayId, onInstalled }: { gatewayId: string; onInstalled
const handleInstall = useCallback(
async (slug: string) => {
setInstallingSlugs((prev) => new Set(prev).add(slug));
- const res = await window.clawwork.installSkill(gatewayId, { source: 'clawhub', slug });
- if (res.ok && res.result?.ok) {
- toast.success(t('settings.skillHubInstalled'));
- onInstalled();
- } else {
- toast.error(res.error ?? res.result?.message ?? t('settings.skillHubInstallFailed'));
+ try {
+ const res = await window.clawwork.installSkill(gatewayId, { source: 'clawhub', slug });
+ if (res.ok && res.result?.ok) {
+ toast.success(t('settings.skillHubInstalled'));
+ onInstalled();
+ } else {
+ toast.error(res.error ?? res.result?.message ?? t('settings.skillHubInstallFailed'));
+ }
+ } catch (err) {
+ console.error('[SkillsSection] install failed:', err);
+ toast.error(t('settings.skillHubInstallFailed'));
+ } finally {
+ setInstallingSlugs((prev) => {
+ const next = new Set(prev);
+ next.delete(slug);
+ return next;
+ });
}
- setInstallingSlugs((prev) => {
- const next = new Set(prev);
- next.delete(slug);
- return next;
- });
},
[gatewayId, onInstalled, t],
);
@@ -738,22 +744,28 @@ export default function SkillsSection() {
async (skill: SkillStatusEntry) => {
if (!selectedGatewayId) return;
setTogglingKeys((prev) => new Set(prev).add(skill.skillKey));
- const newEnabled = skill.disabled;
- const res = await window.clawwork.updateSkill(selectedGatewayId, {
- skillKey: skill.skillKey,
- enabled: newEnabled,
- });
- if (res.ok) {
- toast.success(newEnabled ? t('settings.skillEnabled') : t('settings.skillDisabledToast'));
- await refreshSkills();
- } else {
+ try {
+ const newEnabled = skill.disabled;
+ const res = await window.clawwork.updateSkill(selectedGatewayId, {
+ skillKey: skill.skillKey,
+ enabled: newEnabled,
+ });
+ if (res.ok) {
+ toast.success(newEnabled ? t('settings.skillEnabled') : t('settings.skillDisabledToast'));
+ await refreshSkills();
+ } else {
+ toast.error(t('settings.skillUpdateFailed'));
+ }
+ } catch (err) {
+ console.error('[SkillsSection] toggle failed:', err);
toast.error(t('settings.skillUpdateFailed'));
+ } finally {
+ setTogglingKeys((prev) => {
+ const next = new Set(prev);
+ next.delete(skill.skillKey);
+ return next;
+ });
}
- setTogglingKeys((prev) => {
- const next = new Set(prev);
- next.delete(skill.skillKey);
- return next;
- });
},
[selectedGatewayId, refreshSkills, t],
);
diff --git a/packages/desktop/src/renderer/layouts/Settings/sections/SystemSection.tsx b/packages/desktop/src/renderer/layouts/Settings/sections/SystemSection.tsx
index 7cc0dc60c..05a4416cc 100644
--- a/packages/desktop/src/renderer/layouts/Settings/sections/SystemSection.tsx
+++ b/packages/desktop/src/renderer/layouts/Settings/sections/SystemSection.tsx
@@ -65,20 +65,33 @@ export default function SystemSection() {
);
const handleChangeWorkspace = useCallback(async () => {
- const selected = await window.clawwork.browseWorkspace();
+ let selected: string | null = null;
+ try {
+ selected = await window.clawwork.browseWorkspace();
+ } catch (err) {
+ console.error('[SystemSection] browseWorkspace failed:', err);
+ toast.error(t('errors.failed'));
+ return;
+ }
if (!selected || selected === workspacePath) return;
const oldPath = workspacePath;
setChangingWorkspace(true);
- const result = await window.clawwork.changeWorkspace(selected);
- setChangingWorkspace(false);
- if (result.ok) {
- await refreshSettings().catch(() => {});
- toast.success(t('settings.workspaceChanged'), {
- description: t('settings.workspaceOldPathHint', { path: oldPath }),
- duration: 8000,
- });
- } else {
- toast.error(t('settings.workspaceChangeFailed', { error: result.error }));
+ try {
+ const result = await window.clawwork.changeWorkspace(selected);
+ if (result.ok) {
+ await refreshSettings().catch(() => {});
+ toast.success(t('settings.workspaceChanged'), {
+ description: t('settings.workspaceOldPathHint', { path: oldPath }),
+ duration: 8000,
+ });
+ } else {
+ toast.error(t('settings.workspaceChangeFailed', { error: result.error }));
+ }
+ } catch (err) {
+ console.error('[SystemSection] changeWorkspace failed:', err);
+ toast.error(t('settings.workspaceChangeFailed', { error: String(err) }));
+ } finally {
+ setChangingWorkspace(false);
}
}, [refreshSettings, workspacePath, t]);
diff --git a/packages/desktop/test/workspace-init.test.ts b/packages/desktop/test/workspace-init.test.ts
new file mode 100644
index 000000000..c9eda6a69
--- /dev/null
+++ b/packages/desktop/test/workspace-init.test.ts
@@ -0,0 +1,103 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { mkdirSync, existsSync } from 'fs';
+import { cp } from 'fs/promises';
+import { resolve } from 'path';
+
+vi.mock('fs', () => ({
+ mkdirSync: vi.fn(),
+ existsSync: vi.fn(),
+}));
+
+vi.mock('fs/promises', () => ({
+ cp: vi.fn(),
+}));
+
+const mockExistsSync = vi.mocked(existsSync);
+const mockCp = vi.mocked(cp);
+const mockMkdirSync = vi.mocked(mkdirSync);
+
+describe('migrateWorkspace', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ async function loadMigrateWorkspace() {
+ const mod = await import('../src/main/workspace/init.js');
+ return mod.migrateWorkspace;
+ }
+
+ it('throws when new path is inside old path', async () => {
+ mockExistsSync.mockReturnValue(true);
+ const migrateWorkspace = await loadMigrateWorkspace();
+ const oldPath = resolve('/workspace');
+ const newPath = resolve('/workspace', 'sub');
+
+ await expect(migrateWorkspace(oldPath, newPath)).rejects.toThrow(
+ 'New workspace path must not be inside or equal to the current workspace',
+ );
+ expect(mockCp).not.toHaveBeenCalled();
+ });
+
+ it('throws when new path equals old path', async () => {
+ mockExistsSync.mockReturnValue(true);
+ const migrateWorkspace = await loadMigrateWorkspace();
+ const path = resolve('/workspace');
+
+ await expect(migrateWorkspace(path, path)).rejects.toThrow(
+ 'New workspace path must not be inside or equal to the current workspace',
+ );
+ expect(mockCp).not.toHaveBeenCalled();
+ });
+
+ it('throws when source workspace does not exist', async () => {
+ mockExistsSync.mockReturnValue(false);
+ const migrateWorkspace = await loadMigrateWorkspace();
+
+ await expect(migrateWorkspace('/old', '/new')).rejects.toThrow('Source workspace does not exist: /old');
+ });
+
+ it('copies workspace when paths are valid', async () => {
+ mockExistsSync.mockReturnValue(true);
+ mockCp.mockResolvedValue(undefined);
+ const migrateWorkspace = await loadMigrateWorkspace();
+ const oldPath = resolve('/workspace/old');
+ const newPath = resolve('/workspace/new');
+
+ await migrateWorkspace(oldPath, newPath);
+
+ expect(mockCp).toHaveBeenCalledWith(oldPath, newPath, {
+ recursive: true,
+ errorOnExist: false,
+ force: true,
+ });
+ });
+});
+
+describe('initWorkspace', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ async function loadInitWorkspace() {
+ const mod = await import('../src/main/workspace/init.js');
+ return mod.initWorkspace;
+ }
+
+ it('creates directory when it does not exist', async () => {
+ mockExistsSync.mockReturnValue(false);
+ const initWorkspace = await loadInitWorkspace();
+
+ await initWorkspace('/workspace');
+
+ expect(mockMkdirSync).toHaveBeenCalledWith('/workspace', { recursive: true });
+ });
+
+ it('does nothing when directory already exists', async () => {
+ mockExistsSync.mockReturnValue(true);
+ const initWorkspace = await loadInitWorkspace();
+
+ await initWorkspace('/workspace');
+
+ expect(mockMkdirSync).not.toHaveBeenCalled();
+ });
+});