mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-10 10:01:48 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6b520c39b | ||
|
|
f733345587 | ||
|
|
4f1637e05f | ||
|
|
8f7e4a0748 | ||
|
|
3770cb3856 | ||
|
|
2835c52d2b | ||
|
|
64638bccd1 | ||
|
|
ae31f7388e | ||
|
|
ade8df6c1f | ||
|
|
420718b717 | ||
|
|
4e8dc79184 | ||
|
|
97f5a265de | ||
|
|
b76822bc19 | ||
|
|
c2974487dd | ||
|
|
1d75becebc | ||
|
|
d39c0ff888 | ||
|
|
24dc82f313 | ||
|
|
b73f1174b2 | ||
|
|
d62e151fcb | ||
|
|
d5c4e196a6 | ||
|
|
e8e7af5996 | ||
|
|
7e45e66110 | ||
|
|
bf979b7831 | ||
|
|
1405c2a043 | ||
|
|
ca4fbe80aa | ||
|
|
9c3579256d | ||
|
|
5d88b179b2 | ||
|
|
1132215f4a | ||
|
|
d9ba90ed04 | ||
|
|
4a09d5546b | ||
|
|
4a5f86a0d1 | ||
|
|
555b96966c | ||
|
|
790c6be08e | ||
|
|
52f465e4f5 | ||
|
|
3d129f0d6f | ||
|
|
e66c50c76b | ||
|
|
b474d12fdd | ||
|
|
b66fdb30f6 | ||
|
|
092ab722cc | ||
|
|
390f996103 | ||
|
|
46dd766262 | ||
|
|
4cf81e89fe | ||
|
|
63582bc7c8 | ||
|
|
54c73168c3 | ||
|
|
36c1faba14 | ||
|
|
1808ff6d63 | ||
|
|
9c3123c7a3 | ||
|
|
aecf4e6960 | ||
|
|
d4e11410cb | ||
|
|
2f7809c10d | ||
|
|
d2cb0aa894 | ||
|
|
3cb73b3bf4 | ||
|
|
ec866730e0 | ||
|
|
3065db7a49 | ||
|
|
ae2e9053f5 | ||
|
|
c2d10fb7e0 | ||
|
|
053136c488 | ||
|
|
49939444ff | ||
|
|
3a946c048d | ||
|
|
54f4898b2c | ||
|
|
03254360ee | ||
|
|
35fc8b746a | ||
|
|
befb5e258c | ||
|
|
8e9f7cfed0 | ||
|
|
96297a2146 | ||
|
|
48b340a877 | ||
|
|
d67f2835ac | ||
|
|
6512f0db40 | ||
|
|
a0ac937c65 | ||
|
|
15a1f262fb | ||
|
|
7574f8b4c9 | ||
|
|
d945284cec | ||
|
|
c55c6bee78 | ||
|
|
2a3855f16a | ||
|
|
6d0c756228 | ||
|
|
dd9b441f99 | ||
|
|
acf3bfdcbc | ||
|
|
96b296cd90 | ||
|
|
b18f84ee22 | ||
|
|
29c8349c3a | ||
|
|
51fe2e9255 | ||
|
|
8cf5b64418 | ||
|
|
463840813f | ||
|
|
c04f7dd247 | ||
|
|
a44319982f | ||
|
|
a8bea84e68 | ||
|
|
f457bbbd61 | ||
|
|
3007c94084 | ||
|
|
c09dbf56b9 | ||
|
|
26984605be | ||
|
|
85759d074c | ||
|
|
e47c32200c | ||
|
|
861a7b8d12 | ||
|
|
32107d7bb6 | ||
|
|
d5c9f2d739 | ||
|
|
1a74c7450f | ||
|
|
947ce21896 | ||
|
|
872ad88677 | ||
|
|
4512a84cbb | ||
|
|
9424d402fb | ||
|
|
2dfa1ca44c | ||
|
|
e5795e5e0f | ||
|
|
86db9b4df3 | ||
|
|
8829515d5e | ||
|
|
8b2504fb6b | ||
|
|
42503a4d36 | ||
|
|
598ecbf1b2 | ||
|
|
d730d8c8f8 |
No files matched your search
@@ -10,9 +10,7 @@ on:
|
||||
# conservative: when in doubt, run.
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'site/**'
|
||||
- 'release-notes/**'
|
||||
- 'workers/**'
|
||||
- 'benchmarks/**'
|
||||
- 'npm/**'
|
||||
- '*.md'
|
||||
|
||||
@@ -1636,16 +1636,13 @@ jobs:
|
||||
-ignore 'label "windows-11-arm" is unknown' \
|
||||
-ignore 'label "macos-15-intel" is unknown' \
|
||||
.github/workflows/release-stable.yml \
|
||||
.github/workflows/prepare-release-notes.yml \
|
||||
.github/workflows/release-candidate.yml \
|
||||
.github/workflows/release-candidate-verify.yml \
|
||||
.github/workflows/release-promote.yml \
|
||||
.github/workflows/release-verify.yml \
|
||||
.github/workflows/release-site-recovery.yml \
|
||||
.github/workflows/release.yml \
|
||||
.github/workflows/release-npm.yml \
|
||||
.github/workflows/release-desktop.yml \
|
||||
.github/workflows/pages.yml \
|
||||
.github/workflows/apple-notary-log.yml \
|
||||
.github/workflows/macos-signing-check.yml \
|
||||
.github/workflows/release-verify-issues.yml \
|
||||
@@ -1669,14 +1666,14 @@ jobs:
|
||||
node scripts/check-single-release-public-contract.mjs
|
||||
|
||||
# `lint` is a protected check name. Keep it as the fail-closed aggregate so
|
||||
# Required check for the root-module and site jobs the per-OS `test` legs do
|
||||
# required checks for root-module jobs the per-OS `test` legs do
|
||||
# not cover. windows-control and sdk gate internally through RUN_STEPS and so
|
||||
# always report a result: `success` here means "did not fail", not "validated
|
||||
# this diff" — the same trade-off the required `test` legs already make. Do
|
||||
# not add govulncheck: it sets continue-on-error, so needs.*.result is
|
||||
# `success` even when its steps fail and the assertion would be a tautology.
|
||||
root:
|
||||
needs: [changes, windows-control, windows-isolated, sdk, site, coverage]
|
||||
needs: [changes, windows-control, windows-isolated, sdk, coverage]
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -1684,12 +1681,10 @@ jobs:
|
||||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
CODE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.code != 'false' }}
|
||||
SITE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.site != 'false' }}
|
||||
COVERAGE_REQUIRED: ${{ github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true' }}
|
||||
CONTROL_RESULT: ${{ needs.windows-control.result }}
|
||||
ISOLATED_RESULT: ${{ needs.windows-isolated.result }}
|
||||
SDK_RESULT: ${{ needs.sdk.result }}
|
||||
SITE_RESULT: ${{ needs.site.result }}
|
||||
COVERAGE_RESULT: ${{ needs.coverage.result }}
|
||||
run: |
|
||||
test "$CHANGES_RESULT" = success
|
||||
@@ -1697,7 +1692,6 @@ jobs:
|
||||
test "$CONTROL_RESULT" = success
|
||||
test "$SDK_RESULT" = success
|
||||
test "$ISOLATED_RESULT" = "$(expected "$CODE_REQUIRED")"
|
||||
test "$SITE_RESULT" = "$(expected "$SITE_REQUIRED")"
|
||||
test "$COVERAGE_RESULT" = "$(expected "$COVERAGE_REQUIRED")"
|
||||
|
||||
# frontend unit coverage can run once in desktop-frontend without weakening
|
||||
@@ -1731,31 +1725,6 @@ jobs:
|
||||
test "$FRONTEND_RESULT" = skipped
|
||||
fi
|
||||
|
||||
# The site/ auth client has security-sensitive redirect-validation logic
|
||||
# (safeNext) covered by node:test unit tests. Those tests use only Node
|
||||
# builtins, so no `npm install` is needed — run them directly on every PR so
|
||||
# a regression in redirect validation fails the build instead of shipping.
|
||||
# The notes_only term matches every other gated job here. Without it a
|
||||
# release-notes-only push runs this job while the root aggregate expects the
|
||||
# house-style skip, which would leave main-v2 permanently red. A pure
|
||||
# release-notes/** diff cannot touch site/, so skipping it loses no coverage.
|
||||
site:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.site != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: site
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: test
|
||||
run: npm test
|
||||
|
||||
govulncheck:
|
||||
needs: changes
|
||||
if: (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true')
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
name: Deploy accounts worker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-accounts-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: workers/accounts/pnpm-lock.yaml
|
||||
- name: Deploy
|
||||
working-directory: workers/accounts
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm test
|
||||
pnpm typecheck
|
||||
npx wrangler deploy
|
||||
- name: Sync RESEND_API_KEY to the worker
|
||||
working-directory: workers/accounts
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||
run: |
|
||||
if [ -n "$RESEND_API_KEY" ]; then
|
||||
printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY
|
||||
else
|
||||
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
|
||||
fi
|
||||
@@ -1,168 +0,0 @@
|
||||
name: Deploy crash worker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
firebase_data_action:
|
||||
description: Firebase crash history operation (migration actions do not deploy the Worker)
|
||||
required: true
|
||||
default: none
|
||||
type: choice
|
||||
options:
|
||||
- none
|
||||
- dry-run
|
||||
- apply
|
||||
- verify-only
|
||||
confirmation:
|
||||
description: Enter APPLY_FIREBASE_CRASH_DATA when selecting apply
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-crash-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.event_name == 'push' || inputs.firebase_data_action == 'none'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: workers/crash-report/package-lock.json
|
||||
- name: Validate
|
||||
working-directory: workers/crash-report
|
||||
run: |
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
- name: Apply and verify diagnostics v2 D1 migration
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:diagnostics-v2
|
||||
- name: Apply and verify Firebase crash D1 migration
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:firebase-crash
|
||||
- name: Apply dashboard performance indexes
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:dashboard-indexes
|
||||
- name: Apply registry performance indexes and install rollup
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:registry-indexes
|
||||
- name: Sync Firebase crash secrets
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
|
||||
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
|
||||
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
|
||||
run: |
|
||||
configured=0
|
||||
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
|
||||
if [ "$configured" -ne 0 ] && [ "$configured" -ne 3 ]; then
|
||||
echo "Firebase crash secrets must be either all configured or all absent."
|
||||
exit 1
|
||||
fi
|
||||
node -e 'process.stdout.write(JSON.stringify({FIREBASE_DATABASE_URL:process.env.FIREBASE_DATABASE_URL||null,FIREBASE_CLIENT_EMAIL:process.env.FIREBASE_CLIENT_EMAIL||null,FIREBASE_PRIVATE_KEY:process.env.FIREBASE_PRIVATE_KEY||null}))' | npx wrangler secret bulk
|
||||
- name: Deploy
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
npx wrangler deploy
|
||||
# Mirrors the ALERT_WEBHOOK repo secret into the worker so the ingest
|
||||
# sentinel can push alerts (see runIngestSentinel). Managed here so
|
||||
# nobody needs local Cloudflare credentials; when the repo secret is
|
||||
# unset the sentinel stays log-only.
|
||||
- name: Sync alert webhook secret
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
ALERT_WEBHOOK: ${{ secrets.ALERT_WEBHOOK }}
|
||||
run: |
|
||||
if [ -z "$ALERT_WEBHOOK" ]; then
|
||||
echo "ALERT_WEBHOOK repo secret not set; removing any existing worker secret."
|
||||
printf '{"ALERT_WEBHOOK":null}' | npx wrangler secret bulk
|
||||
exit 0
|
||||
fi
|
||||
printf '%s' "$ALERT_WEBHOOK" | npx wrangler secret put ALERT_WEBHOOK
|
||||
|
||||
migrate-firebase-data:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.firebase_data_action != 'none'
|
||||
runs-on: ubuntu-latest
|
||||
environment: canary
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- name: Require the protected production branch
|
||||
run: |
|
||||
if [ "$GITHUB_REF" != "refs/heads/main-v2" ]; then
|
||||
echo "Firebase crash data migration must run from main-v2."
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: workers/crash-report/package-lock.json
|
||||
- name: Validate
|
||||
working-directory: workers/crash-report
|
||||
run: |
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
- name: Run Firebase crash history operation
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
|
||||
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
|
||||
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
|
||||
FIREBASE_DATA_ACTION: ${{ inputs.firebase_data_action }}
|
||||
FIREBASE_DATA_CONFIRMATION: ${{ inputs.confirmation }}
|
||||
run: |
|
||||
configured=0
|
||||
[ -n "$CLOUDFLARE_API_TOKEN" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
|
||||
if [ "$configured" -ne 4 ]; then
|
||||
echo "Cloudflare and Firebase migration secrets must all be configured."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$FIREBASE_DATA_ACTION" in
|
||||
dry-run)
|
||||
npm run migrate:firebase-data
|
||||
;;
|
||||
apply)
|
||||
if [ "$FIREBASE_DATA_CONFIRMATION" != "APPLY_FIREBASE_CRASH_DATA" ]; then
|
||||
echo "Apply requires the exact confirmation APPLY_FIREBASE_CRASH_DATA."
|
||||
exit 1
|
||||
fi
|
||||
npm run migrate:firebase-data -- --apply
|
||||
npm run migrate:firebase-data -- --verify-only
|
||||
;;
|
||||
verify-only)
|
||||
npm run migrate:firebase-data -- --verify-only
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported Firebase crash data action."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,36 +0,0 @@
|
||||
name: Deploy forum worker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-forum-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: workers/forum/pnpm-lock.yaml
|
||||
- name: Deploy
|
||||
working-directory: workers/forum
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm test
|
||||
pnpm typecheck
|
||||
npx wrangler d1 execute reasonix-forum --remote --file=migrate-performance-indexes.sql
|
||||
npx wrangler deploy
|
||||
@@ -1,76 +0,0 @@
|
||||
name: Deploy site
|
||||
run-name: Deploy site ${{ inputs.release_version && format('v{0}', inputs.release_version) || github.sha }}${{ inputs.release_request && format(' [{0}]', inputs.release_request) || '' }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_version:
|
||||
description: "Optional release version that owns this deployment"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
release_request:
|
||||
description: "Release workflow correlation ID (optional)"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.ref == 'refs/heads/main-v2' && github.ref_protected
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: site/package-lock.json
|
||||
- name: Build
|
||||
working-directory: site
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
|
||||
with:
|
||||
path: site/dist
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deploy.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: inputs.release_version != ''
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
if: inputs.release_version != ''
|
||||
with:
|
||||
node-version: "22"
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
if: inputs.release_version != ''
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Recheck release ownership immediately before deployment
|
||||
if: inputs.release_version != ''
|
||||
env:
|
||||
VERSION: ${{ inputs.release_version }}
|
||||
run: test "$(bash scripts/observe-release-site.sh "$VERSION" recover)" = true
|
||||
- id: deploy
|
||||
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5
|
||||
@@ -1,106 +0,0 @@
|
||||
name: Prepare release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Official version without a tag prefix (for example 1.20.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
draft:
|
||||
name: generate reviewable bilingual draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Select the review branch
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git fetch origin main-v2
|
||||
|
||||
if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||||
echo "::error::version must be MAJOR.MINOR.PATCH"
|
||||
exit 1
|
||||
fi
|
||||
branch="release-notes/v${VERSION}"
|
||||
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
|
||||
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
|
||||
git checkout -b "$branch" --track "origin/$branch"
|
||||
source_sha="$(git merge-base origin/main-v2 HEAD)"
|
||||
else
|
||||
git checkout -b "$branch"
|
||||
source_sha="$(git rev-parse HEAD)"
|
||||
fi
|
||||
git merge-base --is-ancestor "$source_sha" origin/main-v2
|
||||
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_NOTES_SOURCE_SHA=$source_sha" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate and validate release notes
|
||||
env:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
DEEPSEEK_MODEL: deepseek-v4-pro
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The first preparation fixes the product range. Reruns update the
|
||||
# draft without silently absorbing unrelated main-v2 merges.
|
||||
node scripts/generate-release-notes.mjs --version "$VERSION" --to "$RELEASE_NOTES_SOURCE_SHA"
|
||||
node scripts/release-notes.mjs validate
|
||||
node --test scripts/release-notes.test.mjs
|
||||
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
|
||||
{
|
||||
echo "## Review draft for v${VERSION#v}"
|
||||
echo
|
||||
cat /tmp/release-notes.md
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Open or update the review PR
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add release-notes/releases.json
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "docs(release): prepare v${VERSION#v} notes" \
|
||||
-m "Summary:
|
||||
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.
|
||||
|
||||
Verification:
|
||||
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing."
|
||||
fi
|
||||
git push -u origin "$RELEASE_NOTES_BRANCH"
|
||||
if ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
|
||||
if ! gh pr create \
|
||||
--base main-v2 \
|
||||
--head "$RELEASE_NOTES_BRANCH" \
|
||||
--title "docs(release): 准备 v${VERSION#v} 中英更新日志" \
|
||||
--body-file /tmp/release-notes.md; then
|
||||
echo "::warning::GitHub Actions could not open the PR; the reviewed branch is preserved."
|
||||
{
|
||||
echo "## Manual PR handoff"
|
||||
echo
|
||||
echo '```sh'
|
||||
echo "gh pr create --repo ${{ github.repository }} --base main-v2 --head $RELEASE_NOTES_BRANCH --fill"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
fi
|
||||
@@ -194,6 +194,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -210,6 +212,7 @@ jobs:
|
||||
env:
|
||||
DOCS_BUILD_VERSION: v${{ needs.resolve.outputs.version }}
|
||||
DOCS_SOURCE_REVISION: ${{ github.sha }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
node scripts/release-notes.mjs validate
|
||||
node scripts/release-notes.mjs render --version "$DOCS_BUILD_VERSION" --output /tmp/release-notes.md
|
||||
@@ -259,6 +262,8 @@ jobs:
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/release-desktop.yml
|
||||
with:
|
||||
channel: stable
|
||||
@@ -285,6 +290,8 @@ jobs:
|
||||
attestations: write
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -338,6 +345,16 @@ jobs:
|
||||
test -s payload/evidence/windows-amd64.json
|
||||
test -s payload/evidence/windows-arm64.json
|
||||
test -s payload/evidence/macos-universal-intel.json
|
||||
# Rendered once and sealed: promotion publishes these bytes and never
|
||||
# reads GitHub again, so a later rename or outage cannot change them.
|
||||
- name: Render reviewed notes into the sealed payload
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
|
||||
git worktree add --detach "$worktree" "${{ github.sha }}"
|
||||
node "$worktree/scripts/release-notes.mjs" render --version "v${{ needs.resolve.outputs.version }}" --output payload/evidence/release-notes.md
|
||||
git worktree remove "$worktree"
|
||||
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||
with:
|
||||
subject-path: payload/**/*
|
||||
@@ -359,13 +376,6 @@ jobs:
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 90
|
||||
- name: Render reviewed notes from the immutable product
|
||||
run: |
|
||||
git show "${{ github.sha }}:release-notes/releases.json" > /tmp/releases.json
|
||||
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
|
||||
git worktree add --detach "$worktree" "${{ github.sha }}"
|
||||
node "$worktree/scripts/release-notes.mjs" render --version "v${{ needs.resolve.outputs.version }}" --output /tmp/release-notes.md
|
||||
git worktree remove "$worktree"
|
||||
- name: Seal candidate record
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
@@ -385,7 +395,7 @@ jobs:
|
||||
RELEASE_ACCEPTANCE_JSON: >-
|
||||
[{"kind":"windows-amd64","status":"passed","evidencePath":"evidence/windows-amd64.json"},{"kind":"windows-arm64","status":"passed","evidencePath":"evidence/windows-arm64.json"},{"kind":"macos-universal-intel","status":"passed","evidencePath":"evidence/macos-universal-intel.json"}]
|
||||
run: |
|
||||
RELEASE_RENDERED_NOTES_SHA256="$(sha256sum /tmp/release-notes.md | awk '{print $1}')"
|
||||
RELEASE_RENDERED_NOTES_SHA256="$(sha256sum payload/evidence/release-notes.md | awk '{print $1}')"
|
||||
RELEASE_CREATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
RELEASE_EXPIRES_AT="$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)"
|
||||
export RELEASE_RENDERED_NOTES_SHA256 RELEASE_CREATED_AT RELEASE_EXPIRES_AT
|
||||
|
||||
@@ -1084,6 +1084,8 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
# Approved orchestrators have already passed the matching GitHub environment.
|
||||
# Direct prereleases and manual Stable recovery pass release-gate above. The
|
||||
# Certum signing completes before either platform bundle reaches publication.
|
||||
@@ -1200,6 +1202,8 @@ jobs:
|
||||
# GitHub release as usual.
|
||||
- name: Render reviewed release notes
|
||||
if: ${{ !inputs.orchestrated && needs.resolve.outputs.channel != 'preview' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node scripts/release-notes.mjs render --version "${{ needs.resolve.outputs.notes_version }}" --output /tmp/release-notes.md
|
||||
|
||||
- name: Revalidate approved release ref
|
||||
@@ -1292,7 +1296,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # gh release download + compatibility manifest upload
|
||||
actions: write # dispatch pages.yml to re-bake the site version
|
||||
# Stable keeps GitHub as a fallback when R2 is unavailable. Preview has no
|
||||
# GitHub release, so it must fail closed before attempting publication.
|
||||
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.publish.result == 'success' && github.repository_owner == 'esengine' }}
|
||||
@@ -1818,11 +1821,3 @@ jobs:
|
||||
else
|
||||
echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
|
||||
fi
|
||||
|
||||
# Stable release moved R2 latest/ — rebuild the site so its build-time baked
|
||||
# version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
|
||||
- name: Refresh site to the new version
|
||||
if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel != 'preview' && needs.resolve.outputs.prerelease != 'true' && steps.mirror_r2.outputs.pointer_moved == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh workflow run pages.yml --ref main-v2
|
||||
@@ -4,16 +4,9 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "release-notes/**"
|
||||
- "scripts/generate-release-notes.mjs"
|
||||
- "scripts/release-notes*.mjs"
|
||||
- "scripts/release-credits*.mjs"
|
||||
- "scripts/release-event.mjs"
|
||||
- "site/src/pages/changelog/**"
|
||||
- "site/src/components/ChangelogPage.astro"
|
||||
- "site/src/data/releases.ts"
|
||||
- "site/src/styles/changelog.css"
|
||||
- "desktop/frontend/src/App.tsx"
|
||||
- "desktop/frontend/src/components/SettingsPanel.tsx"
|
||||
- "desktop/frontend/src/components/UpdateBanner.tsx"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -27,4 +20,4 @@ jobs:
|
||||
with:
|
||||
node-version: "22"
|
||||
- run: node scripts/release-notes.mjs validate
|
||||
- run: node --test scripts/release-notes.test.mjs
|
||||
- run: node --test scripts/release-notes.test.mjs scripts/release-credits.test.mjs
|
||||
@@ -23,6 +23,8 @@ permissions:
|
||||
actions: write
|
||||
attestations: read
|
||||
contents: write
|
||||
issues: read # granted to the nested release workflows, which can render notes
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
@@ -146,12 +148,19 @@ jobs:
|
||||
echo "- Payload files: \`$(jq '.files | length' record/record.json)\`"
|
||||
echo "- Native acceptance: \`$(jq -r '[.acceptance[] | select(.status == "passed") | .kind] | join(", ")' record/record.json)\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Render and bind reviewed release notes
|
||||
# A candidate sealed with its notes publishes those bytes without touching
|
||||
# the network. One sealed before that renders offline from its own source.
|
||||
- name: Bind reviewed release notes
|
||||
run: |
|
||||
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
|
||||
git worktree add --detach "$worktree" "${{ steps.record.outputs.source_sha }}"
|
||||
node "$worktree/scripts/release-notes.mjs" render --version "v${{ steps.record.outputs.version }}" --output release-notes.md
|
||||
git worktree remove "$worktree"
|
||||
sealed="$(jq -r '.notes.renderedPath // empty' record/record.json)"
|
||||
if [ -n "$sealed" ]; then
|
||||
cp "payload/$sealed" release-notes.md
|
||||
else
|
||||
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
|
||||
git worktree add --detach "$worktree" "${{ steps.record.outputs.source_sha }}"
|
||||
node "$worktree/scripts/release-notes.mjs" render --version "v${{ steps.record.outputs.version }}" --output release-notes.md
|
||||
git worktree remove "$worktree"
|
||||
fi
|
||||
test "$(sha256sum release-notes.md | awk '{print $1}')" = "$(jq -r .notes.renderedSha256 record/record.json)"
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
@@ -306,7 +315,6 @@ jobs:
|
||||
if: ${{ always() && !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Require every publisher
|
||||
@@ -326,7 +334,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Reconcile public artifacts, owned Pages deployment, and durable ledger
|
||||
- name: Reconcile public artifacts, release event, and durable ledger
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
@@ -334,7 +342,7 @@ jobs:
|
||||
RELEASE_OPERATION: ${{ inputs.operation }}
|
||||
RELEASE_EXPECTED_SHA: ${{ needs.preflight.outputs.source_sha }}
|
||||
RELEASE_LEDGER_OUTPUT: /tmp/publication-ledger.json
|
||||
run: bash scripts/sync-release-site.sh
|
||||
run: bash scripts/reconcile-release-publication.sh
|
||||
- name: Upload publication ledger
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
name: Recover release site
|
||||
run-name: Recover site v${{ inputs.version }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Already-published stable version; no builds or package publication"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: stable-release-publication
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
if: github.repository == 'esengine/DeepSeek-Reasonix' && github.ref == 'refs/heads/main-v2' && github.ref_protected
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
source_sha: ${{ steps.identity.outputs.source_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Validate executable contracts and public access before approval
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
bash scripts/validate-release-control-plane.sh
|
||||
bash scripts/check-release-public-access.sh "$VERSION"
|
||||
- name: Bind existing public tags to protected history
|
||||
id: identity
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
|
||||
source_sha="$(git rev-parse "refs/tags/v$VERSION^{commit}")"
|
||||
git merge-base --is-ancestor "$source_sha" origin/main-v2
|
||||
bash scripts/release-candidate-tags.sh check recover "$VERSION" "$source_sha"
|
||||
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
|
||||
- name: Re-read public package identities before approval
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
RELEASE_OPERATION: recover
|
||||
RELEASE_EXPECTED_SHA: ${{ steps.identity.outputs.source_sha }}
|
||||
CLI_TAG: v${{ inputs.version }}
|
||||
DESKTOP_TAG: desktop-v${{ inputs.version }}
|
||||
RELEASE_LEDGER_OUTPUT: ${{ runner.temp }}/site-recovery-preflight.json
|
||||
run: bash scripts/verify-stable-release-artifacts.sh
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: site-recovery-preflight-${{ inputs.version }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/site-recovery-preflight.json
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
|
||||
sync:
|
||||
needs: preflight
|
||||
environment: release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Recover only the website and verify public state again
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
RELEASE_OPERATION: recover
|
||||
RELEASE_EXPECTED_SHA: ${{ needs.preflight.outputs.source_sha }}
|
||||
RELEASE_SITE_RECOVERY_ONLY: "true"
|
||||
RELEASE_LEDGER_OUTPUT: ${{ runner.temp }}/publication-ledger.json
|
||||
run: bash scripts/sync-release-site.sh
|
||||
- name: Preserve successful or incomplete recovery evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: release-site-recovery-ledger-${{ inputs.version }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/publication-ledger.json
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
@@ -55,6 +55,8 @@ concurrency:
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
@@ -63,6 +65,8 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
cli_tag: ${{ steps.release.outputs.cli_tag }}
|
||||
@@ -111,6 +115,8 @@ jobs:
|
||||
bash scripts/validate-stable-candidate.sh "$RELEASE_VERSION" "$RELEASE_SHA"
|
||||
bash scripts/verify-release-push-ci.sh "$RELEASE_SHA"
|
||||
- name: Validate reviewed release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node scripts/release-notes.mjs render --version "${{ steps.release.outputs.cli_tag }}" --output /tmp/release-notes.md
|
||||
# Recovery builds deliberately check out the immutable tagged candidate,
|
||||
# which can predate its reviewed release-note entry. Carry the exact file
|
||||
@@ -226,7 +232,6 @@ jobs:
|
||||
if: ${{ always() && !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Require every publisher to succeed
|
||||
@@ -303,7 +308,3 @@ jobs:
|
||||
else
|
||||
gh release upload "$CLI_TAG" /tmp/release-event.json
|
||||
fi
|
||||
- name: Refresh public changelog
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh workflow run pages.yml --ref main-v2
|
||||
@@ -35,5 +35,5 @@ jobs:
|
||||
RELEASE_OPERATION: recover
|
||||
CLI_TAG: v${{ inputs.version }}
|
||||
DESKTOP_TAG: desktop-v${{ inputs.version }}
|
||||
VERIFY_HOMEPAGE: "true"
|
||||
VERIFY_PUBLIC_POINTERS: "true"
|
||||
run: bash scripts/verify-stable-release-artifacts.sh
|
||||
@@ -212,6 +212,10 @@ jobs:
|
||||
needs: [resolve, cache-guard]
|
||||
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
# The Stable caller has already passed the single GitHub release approval.
|
||||
# Official standalone recovery passes release-gate above. This job therefore
|
||||
# must not add a second GitHub environment approval.
|
||||
@@ -274,6 +278,7 @@ jobs:
|
||||
if: ${{ !inputs.orchestrated }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.resolve.outputs.notes_version }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node scripts/release-notes.mjs render --version "$RELEASE_TAG" --output /tmp/release-notes.md
|
||||
- name: Revalidate approved release ref
|
||||
env:
|
||||
|
||||
+53
-6
@@ -56,9 +56,9 @@ Supported boundaries include:
|
||||
- Sandbox behavior for built-in shell execution where the platform supports it.
|
||||
- Secret handling for provider keys, bot credentials, OAuth tokens, plugin
|
||||
headers, and credential-store fallback files.
|
||||
- HTTP `serve` protections for the unauthenticated local server, including
|
||||
localhost binding assumptions, JSON-only state-changing requests, and CORS
|
||||
restrictions.
|
||||
- HTTP `serve` protections, including the launch token every state-changing
|
||||
request requires when authentication is off, localhost binding assumptions,
|
||||
JSON-only state-changing requests, and CORS restrictions.
|
||||
- Desktop and bot session isolation, including per-workspace session metadata
|
||||
and configured bot allowlists.
|
||||
- Updater, install, and release verification paths.
|
||||
@@ -67,10 +67,10 @@ The following are normally treated as trusted local/operator-controlled inputs
|
||||
unless another bug lets an untrusted actor supply them:
|
||||
|
||||
- CLI arguments and text typed directly by the local user.
|
||||
- Project configuration files intentionally loaded from the current workspace.
|
||||
- The user's own configuration under the Reasonix home directory.
|
||||
- Explicit `@path` references supplied by the local user to attach local files.
|
||||
- MCP servers, language servers, hooks, and slash commands installed or enabled
|
||||
by the local user.
|
||||
- MCP servers, language servers, hooks, and slash commands installed, enabled,
|
||||
or approved by the local user.
|
||||
- Provider base URLs and model names configured by the local user.
|
||||
|
||||
The following can be security issues when reachable by an untrusted actor or
|
||||
@@ -89,6 +89,53 @@ when they bypass the intended boundary:
|
||||
- Trusting unverified update artifacts, plugin definitions, or downloaded
|
||||
binaries.
|
||||
|
||||
## Project Configuration
|
||||
|
||||
Files that arrive with a workspace are untrusted input: `reasonix.toml`,
|
||||
`.reasonix/settings.json`, `.mcp.json` and the project `.env`. Cloning a
|
||||
repository is not a decision to trust its author, so these files may narrow
|
||||
what the user configured and never widen it.
|
||||
|
||||
| Setting | What a workspace file may do |
|
||||
| --- | --- |
|
||||
| `sandbox.bash` | Move `off` to `enforce`, never back |
|
||||
| `sandbox.network` | Move `true` to `false`, never back |
|
||||
| `sandbox.forbid_read` | Add to the user's list |
|
||||
| `sandbox.allow_write`, `sandbox.workspace_root` | Apply only when they resolve inside the workspace, symlinks followed; `${VAR}` in any sandbox path expands from the process environment only |
|
||||
| `permissions.deny`, `permissions.ask` | Add to the user's rules |
|
||||
| `permissions.mode`, `permissions.allow`, `permissions.allow_dynamic_bash` | Nothing |
|
||||
| `desktop.default_tool_approval_mode` | Nothing |
|
||||
| `[network]` proxy settings, `[bot]` | Nothing |
|
||||
| `[[providers]]` | Declare them; they stay out of use until approved, and model settings naming them fall back to the user's |
|
||||
|
||||
An "always allow" answer, or extra write access, granted in a workspace is
|
||||
stored under the Reasonix home, not in the workspace.
|
||||
|
||||
Programs a workspace names for the host to run stay off until the user
|
||||
approves them for that workspace:
|
||||
|
||||
- Hooks, language server commands, `tools.search.rg_path`,
|
||||
`tools.shell.path` and `[browser]` launch settings are approved with
|
||||
`reasonix trust`, or for hooks from the desktop hooks settings.
|
||||
- An approval is stored under the Reasonix home with a digest of the
|
||||
declaration and of the workspace files it names; any change needs approval
|
||||
again. Approvals and grants belong to one workspace folder.
|
||||
- Hooks and language servers are checked against their approval again before
|
||||
each start, and refused if a file they name changed, appeared or now
|
||||
resolves elsewhere.
|
||||
- A single program path (`tools.shell.path`, `tools.search.rg_path`,
|
||||
`browser.chrome_path`) may not point into the workspace, an `allow_write`
|
||||
root, a temporary directory or a toolchain cache the bash jail leaves
|
||||
writable, and may not climb with `..`; its approval covers the program
|
||||
file's content.
|
||||
- Without an interactive frontend they stay off.
|
||||
- MCP servers a workspace declares stay off until the user enables that
|
||||
declaration for that workspace.
|
||||
|
||||
A project value that is not applied is reported as a load warning with the
|
||||
reason. A way for workspace files to widen any of the above without the
|
||||
user's approval is a security issue.
|
||||
|
||||
## `@` File References
|
||||
|
||||
Reasonix supports `@path` references so users can include local files and images
|
||||
|
||||
@@ -190,8 +190,8 @@ func migrateWithRelaunch(installRoot, activeVersion string, relaunch bool) error
|
||||
}
|
||||
}
|
||||
shellMembers, err := installlayout.ShellMembers(installRoot, runtime.GOOS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate shell: %w", err)
|
||||
if err != nil || len(shellMembers) == 0 {
|
||||
return refuseShellMissing(installRoot, activeVersion, relaunch, err)
|
||||
}
|
||||
for _, member := range shellMembers {
|
||||
members = append(members, member)
|
||||
@@ -431,25 +431,35 @@ func ensureLauncherEntry(installRoot string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
|
||||
// readLegacyPendingUpdate returns the transaction an old file updater left for
|
||||
// this install and version, or nil when there is none.
|
||||
func readLegacyPendingUpdate(installRoot, activeVersion string) (*repair.UpdateTransaction, error) {
|
||||
tx, err := repair.ReadPendingUpdate()
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate: read legacy pending update: %w", err)
|
||||
return nil, fmt.Errorf("migrate: read legacy pending update: %w", err)
|
||||
}
|
||||
if tx.TargetKind != "file" {
|
||||
return fmt.Errorf("migrate: legacy pending update target kind %q is not supported", tx.TargetKind)
|
||||
return nil, fmt.Errorf("migrate: legacy pending update target kind %q is not supported", tx.TargetKind)
|
||||
}
|
||||
if strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(activeVersion) {
|
||||
return fmt.Errorf("migrate: pending update targets %s, not %s", tx.ToVersion, activeVersion)
|
||||
return nil, fmt.Errorf("migrate: pending update targets %s, not %s", tx.ToVersion, activeVersion)
|
||||
}
|
||||
for _, target := range append([]repair.UpdateTransactionFile{{TargetPath: tx.TargetPath}}, tx.Files...) {
|
||||
if !pathWithinInstallRoot(installRoot, target.TargetPath) {
|
||||
return fmt.Errorf("migrate: pending update target escapes install root")
|
||||
return nil, fmt.Errorf("migrate: pending update target escapes install root")
|
||||
}
|
||||
}
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
|
||||
tx, err := readLegacyPendingUpdate(installRoot, activeVersion)
|
||||
if err != nil || tx == nil {
|
||||
return err
|
||||
}
|
||||
id := repair.UpdateTransactionID(tx)
|
||||
if err := repair.MarkUpdateHealthyExact(activeVersion, tx.CreatedAt, id); err != nil {
|
||||
return fmt.Errorf("migrate: commit legacy pending update: %w", err)
|
||||
|
||||
@@ -11,6 +11,10 @@ import (
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// A legacy-updater child keeps the home its parent test chose.
|
||||
if mode := os.Getenv(legacyChildModeEnv); mode != "" {
|
||||
os.Exit(runLegacyChild(mode))
|
||||
}
|
||||
testenv.RunWithIsolatedUserState(m)
|
||||
}
|
||||
|
||||
@@ -69,6 +73,7 @@ func writeInstallerStaging(t *testing.T, root, label string, includeLauncher boo
|
||||
func TestMigrateFlatInstallToVersioned(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFlatUnit(t, root, "flat")
|
||||
writeShellTree(t, root, "flat")
|
||||
// Thin launcher entry must already exist (packaging places it). Use a
|
||||
// non-executable marker so startLauncher fails closed without hanging.
|
||||
_ = os.WriteFile(filepath.Join(root, "reasonix-launcher"), []byte("launcher"), 0o644)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/installlayout"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
// errShellMissing identifies a flat release unit whose desktop cannot start:
|
||||
// the desktop needs the Electron app/ tree beside it, and the 1.18–1.19.2 file
|
||||
// updaters publish only the three binaries they know by name.
|
||||
var errShellMissing = errors.New("migrate: flat release unit has no Electron desktop shell")
|
||||
|
||||
const migrateLogName = "legacy-migrate.log"
|
||||
|
||||
// refuseShellMissing commits nothing for an absent or incomplete shell: no
|
||||
// version directory, no current.json, no health mark and no flat cleanup.
|
||||
// Where an old updater's transaction is still pending it restores the release
|
||||
// unit that transaction replaced.
|
||||
func refuseShellMissing(installRoot, activeVersion string, relaunch bool, cause error) error {
|
||||
refusal := fmt.Errorf("%w: %s has no %s/ tree", errShellMissing, installRoot, installlayout.AppShellDirName)
|
||||
if cause != nil {
|
||||
refusal = fmt.Errorf("%w: %s/ tree is unusable: %w", errShellMissing, installlayout.AppShellDirName, cause)
|
||||
}
|
||||
result, rollbackErr := rollbackLegacyPendingUpdate(installRoot, activeVersion)
|
||||
switch {
|
||||
case rollbackErr != nil:
|
||||
refusal = fmt.Errorf("%w; restore the previous release: %w", refusal, rollbackErr)
|
||||
case result.RolledBack:
|
||||
refusal = fmt.Errorf("%w; restored %s", refusal, result.FromVersion)
|
||||
if relaunch {
|
||||
if err := startRestoredRelease(installRoot); err != nil {
|
||||
refusal = fmt.Errorf("%w; start the restored release: %w", refusal, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
logMigrateRefusal(installRoot, result.RolledBack, refusal)
|
||||
return refusal
|
||||
}
|
||||
|
||||
func rollbackLegacyPendingUpdate(installRoot, activeVersion string) (repair.UpdateRollbackResult, error) {
|
||||
// Windows cannot replace the running migrator image, and its installer
|
||||
// carries the shell into update mode, so this state is Unix-only.
|
||||
if runtime.GOOS == "windows" {
|
||||
return repair.UpdateRollbackResult{}, nil
|
||||
}
|
||||
tx, err := readLegacyPendingUpdate(installRoot, activeVersion)
|
||||
if err != nil || tx == nil {
|
||||
return repair.UpdateRollbackResult{}, err
|
||||
}
|
||||
result, err := repair.RollbackPendingUpdateExact(tx)
|
||||
if err == nil && result.MixedInstall {
|
||||
err = fmt.Errorf("rollback left a mix of two releases")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// startRestoredRelease relaunches the way the legacy updater itself does:
|
||||
// through its Guard when the restored unit has one, else the desktop directly.
|
||||
func startRestoredRelease(installRoot string) error {
|
||||
if guard := optionalRegular(filepath.Join(installRoot, "reasonix-guard")); guard != "" {
|
||||
return startDetached(installRoot, guard, "launch", "--detach")
|
||||
}
|
||||
if desktop := optionalRegular(filepath.Join(installRoot, installlayout.DesktopBinaryName())); desktop != "" {
|
||||
return startDetached(installRoot, desktop)
|
||||
}
|
||||
return fmt.Errorf("restored release has no entry point")
|
||||
}
|
||||
|
||||
func startDetached(dir, path string, args ...string) error {
|
||||
cmd := exec.Command(path, args...)
|
||||
cmd.Dir = dir
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// logMigrateRefusal persists the refusal: a migrator relaunched by an old
|
||||
// updater has no terminal, so stderr alone reaches nobody.
|
||||
func logMigrateRefusal(installRoot string, rolledBack bool, refusal error) {
|
||||
home := config.ReasonixHomeDir()
|
||||
if home == "" {
|
||||
return
|
||||
}
|
||||
path := filepath.Join(home, "desktop-shell", "logs", migrateLogName)
|
||||
if os.MkdirAll(filepath.Dir(path), 0o700) != nil {
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
_, _ = fmt.Fprintf(f, "%s event=legacy_migrate_refused code=shell_missing version=%s rolled_back=%t root=%q detail=%q\n",
|
||||
time.Now().UTC().Format(time.RFC3339), version, rolledBack, installRoot, refusal.Error())
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/installlayout"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
const (
|
||||
legacyChildModeEnv = "REASONIX_LEGACY_MIGRATOR_TEST_CHILD"
|
||||
legacyChildRootEnv = "REASONIX_LEGACY_MIGRATOR_TEST_ROOT"
|
||||
legacyFromVersion = "v1.19.2"
|
||||
legacyToVersion = "v1.39.4"
|
||||
childShellMissing = 3
|
||||
)
|
||||
|
||||
func writeShellTree(t *testing.T, root, label string) {
|
||||
t.Helper()
|
||||
for _, name := range installlayout.ShellRequiredNames(runtime.GOOS) {
|
||||
p := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(label+"-"+name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotFlat(t *testing.T, root string) map[string]string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, entry := range entries {
|
||||
if !entry.Type().IsRegular() {
|
||||
out[entry.Name()] = "<dir>"
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, entry.Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out[entry.Name()] = string(data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertNothingCommitted(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
if _, err := os.Lstat(filepath.Join(root, installlayout.CurrentFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("current.json written for a release unit without a shell: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(root, "versions")); !os.IsNotExist(err) {
|
||||
t.Fatalf("versions/ created for a release unit without a shell: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertRefusalLogged(t *testing.T, want ...string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(config.ReasonixHomeDir(), "desktop-shell", "logs", migrateLogName))
|
||||
if err != nil {
|
||||
t.Fatalf("refusal was not logged: %v", err)
|
||||
}
|
||||
for _, w := range append([]string{"code=shell_missing"}, want...) {
|
||||
if !strings.Contains(string(data), w) {
|
||||
t.Fatalf("refusal log %q lacks %q", data, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateRefusesFlatUnitWithoutShell(t *testing.T) {
|
||||
cases := map[string]func(t *testing.T, root string){
|
||||
"absent": func(*testing.T, string) {},
|
||||
"incomplete": func(t *testing.T, root string) {
|
||||
p := filepath.Join(root, installlayout.AppShellDirName, "resources", "app.asar")
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("asar"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
for name, shell := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
root := t.TempDir()
|
||||
writeFlatUnit(t, root, "new")
|
||||
if err := os.WriteFile(filepath.Join(root, "reasonix-guard"), []byte("migrator"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shell(t, root)
|
||||
before := snapshotFlat(t, root)
|
||||
|
||||
err := migrateWithRelaunch(root, legacyToVersion, false)
|
||||
if !errors.Is(err, errShellMissing) {
|
||||
t.Fatalf("migrate = %v, want errShellMissing", err)
|
||||
}
|
||||
assertNothingCommitted(t, root)
|
||||
if after := snapshotFlat(t, root); fmt.Sprint(after) != fmt.Sprint(before) {
|
||||
t.Fatalf("install root changed on refusal:\nbefore %v\nafter %v", before, after)
|
||||
}
|
||||
assertRefusalLogged(t, "rolled_back=false")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// legacyUpdatedInstall reproduces what a 1.18–1.19.2 updater leaves behind: it
|
||||
// runs the updater's own transaction steps from inside the install, publishing
|
||||
// only desktop, CLI and the migrator under the Guard name.
|
||||
func legacyUpdatedInstall(t *testing.T, withShell bool) (root string, oldDesktop []byte) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("the file-by-file legacy updater is the Unix portable path")
|
||||
}
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldDesktop, err = os.ReadFile(self)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root = t.TempDir()
|
||||
for name, body := range map[string][]byte{
|
||||
"reasonix-desktop": oldDesktop,
|
||||
"reasonix-guard": []byte("old-guard"),
|
||||
"reasonix": []byte("old-cli"),
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(root, name), body, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if withShell {
|
||||
writeShellTree(t, root, "new")
|
||||
}
|
||||
if code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-desktop"), "legacy-update", root); code != 0 {
|
||||
t.Fatalf("legacy updater child exited %d: %s", code, out)
|
||||
}
|
||||
if got, _ := os.ReadFile(filepath.Join(root, "reasonix-desktop")); string(got) != "new-desktop" {
|
||||
t.Fatalf("legacy updater did not publish the new desktop: %q", got)
|
||||
}
|
||||
return root, oldDesktop
|
||||
}
|
||||
|
||||
func runLegacyChildProcess(t *testing.T, exe, mode, root string) (int, string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(exe, "-test.run=^$")
|
||||
cmd.Env = append(os.Environ(), legacyChildModeEnv+"="+mode, legacyChildRootEnv+"="+root)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &out
|
||||
err := cmd.Run()
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
return exit.ExitCode(), out.String()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("run %s child: %v", mode, err)
|
||||
}
|
||||
return 0, out.String()
|
||||
}
|
||||
|
||||
func TestLegacyUpdaterWithoutShellRestoresPreviousRelease(t *testing.T) {
|
||||
root, oldDesktop := legacyUpdatedInstall(t, false)
|
||||
code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-guard"), "migrate", root)
|
||||
if code != childShellMissing {
|
||||
t.Fatalf("migrator child exited %d, want errShellMissing: %s", code, out)
|
||||
}
|
||||
assertNothingCommitted(t, root)
|
||||
for name, want := range map[string][]byte{
|
||||
"reasonix-desktop": oldDesktop,
|
||||
"reasonix-guard": []byte("old-guard"),
|
||||
"reasonix": []byte("old-cli"),
|
||||
} {
|
||||
if got, err := os.ReadFile(filepath.Join(root, name)); err != nil || !bytes.Equal(got, want) {
|
||||
t.Fatalf("%s was not restored to the previous release (err %v)", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(root, installlayout.LauncherBinaryName())); !os.IsNotExist(err) {
|
||||
t.Fatalf("thin launcher installed beside a restored legacy release: %v", err)
|
||||
}
|
||||
if repair.PendingUpdateExists() {
|
||||
t.Fatal("legacy transaction still pending after rollback")
|
||||
}
|
||||
assertRefusalLogged(t, "rolled_back=true")
|
||||
}
|
||||
|
||||
func TestLegacyUpdaterWithShellStillCommitsTransaction(t *testing.T) {
|
||||
root, _ := legacyUpdatedInstall(t, true)
|
||||
if code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-guard"), "migrate", root); code != 0 {
|
||||
t.Fatalf("migrator child exited %d: %s", code, out)
|
||||
}
|
||||
ptr, err := installlayout.ReadCurrent(root)
|
||||
if err != nil || ptr.ActiveVersion != legacyToVersion {
|
||||
t.Fatalf("pointer=%+v err=%v", ptr, err)
|
||||
}
|
||||
desktop, err := installlayout.ActiveDesktopPath(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := os.ReadFile(desktop); string(got) != "new-desktop" {
|
||||
t.Fatalf("active desktop = %q", got)
|
||||
}
|
||||
if !installlayout.HasActiveShell(root) {
|
||||
t.Fatal("active version lost its shell")
|
||||
}
|
||||
if repair.PendingUpdateExists() {
|
||||
t.Fatal("legacy transaction was not committed")
|
||||
}
|
||||
}
|
||||
|
||||
func runLegacyChild(mode string) int {
|
||||
root := os.Getenv(legacyChildRootEnv)
|
||||
var err error
|
||||
switch mode {
|
||||
case "legacy-update":
|
||||
err = applyLegacyUpdate(root)
|
||||
case "migrate":
|
||||
err = migrateWithRelaunch(root, legacyToVersion, false)
|
||||
if errors.Is(err, errShellMissing) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return childShellMissing
|
||||
}
|
||||
default:
|
||||
err = fmt.Errorf("unknown child mode %q", mode)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// applyLegacyUpdate follows applyLinux from v1.19.2 desktop/updater.go step
|
||||
// for step, with the running desktop as the updater.
|
||||
func applyLegacyUpdate(root string) error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
migrator, err := os.ReadFile(exe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
desktop := filepath.Join(root, "reasonix-desktop")
|
||||
guard := filepath.Join(root, "reasonix-guard")
|
||||
cli := filepath.Join(root, "reasonix")
|
||||
tx, err := repair.PrepareFileUpdate(legacyFromVersion, legacyToVersion, desktop, guard, cli)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare: %w", err)
|
||||
}
|
||||
claimed, release, err := repair.ClaimPendingFileUpdateExact(tx.ToVersion, tx.CreatedAt, repair.UpdateTransactionID(tx), desktop, []string{desktop, guard, cli}, 2*time.Minute)
|
||||
if err != nil {
|
||||
return fmt.Errorf("claim: %w", err)
|
||||
}
|
||||
defer release()
|
||||
if err := repair.MarkUpdateApplyFailedExact(claimed, "Linux update publish did not complete"); err != nil {
|
||||
return err
|
||||
}
|
||||
var receipts []repair.FileUpdateInstallReceipt
|
||||
for _, member := range []struct {
|
||||
path string
|
||||
body []byte
|
||||
}{{cli, []byte("new-cli")}, {guard, migrator}, {desktop, []byte("new-desktop")}} {
|
||||
receipt, err := repair.PublishClaimedFileUpdateMemberExact(claimed, member.path, member.body, 0o700)
|
||||
if err != nil {
|
||||
return fmt.Errorf("publish %s: %w", filepath.Base(member.path), err)
|
||||
}
|
||||
receipts = append(receipts, receipt)
|
||||
}
|
||||
if _, err := repair.RecordClaimedFileUpdateInstalled(claimed, receipts...); err != nil {
|
||||
return fmt.Errorf("record installed: %w", err)
|
||||
}
|
||||
return repair.ClearUpdateApplyFailureExact(claimed)
|
||||
}
|
||||
+16
-11
@@ -4820,7 +4820,7 @@ func migrateLegacyWorkspacesIntoProjects() {
|
||||
if len(legacy) == 0 {
|
||||
return
|
||||
}
|
||||
_ = updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
_ = updateProjectsFilePreservingLegacyState(func(f *desktopProjectFile) (bool, error) {
|
||||
seen := make(map[string]bool, len(f.Projects)+len(legacy))
|
||||
for _, p := range f.Projects {
|
||||
seen[p.Root] = true
|
||||
@@ -6148,6 +6148,13 @@ func firstNonEmpty(values ...string) string {
|
||||
}
|
||||
|
||||
func (a *App) ContextUsageForTab(tabID string) ContextInfo {
|
||||
if a.isRemoteTab(tabID) {
|
||||
used, window, ok := a.remoteContextSnapshot(tabID)
|
||||
if ok {
|
||||
return ContextInfo{Used: used, Window: window}
|
||||
}
|
||||
return ContextInfo{}
|
||||
}
|
||||
read := a.captureContextRead(tabID)
|
||||
ctrl := read.ctrl
|
||||
var info ContextInfo
|
||||
@@ -7449,11 +7456,7 @@ func (a *App) mcpServersView() []ServerView {
|
||||
}
|
||||
|
||||
func mcpEntryEnabled(p config.PluginEntry, workspace string) bool {
|
||||
enabled, err := config.DefaultMCPActivationStore().IsEnabled(p, workspace)
|
||||
if err != nil {
|
||||
return p.ShouldAutoStart()
|
||||
}
|
||||
return enabled
|
||||
return config.MCPServerEnabled(p, workspace)
|
||||
}
|
||||
|
||||
func mcpRuntimeState(status string) string {
|
||||
@@ -8517,6 +8520,9 @@ func (a *App) ReconnectMCPServer(name string) error {
|
||||
if !found {
|
||||
return fmt.Errorf("no configured MCP server named %q", name)
|
||||
}
|
||||
if err := config.RecordExplicitStart(entry, root); err != nil {
|
||||
return err
|
||||
}
|
||||
controllers := a.mcpControllersSharingHost(host, name, ctrl)
|
||||
for i := range controllers {
|
||||
if controllers[i].ctrl == ctrl {
|
||||
@@ -8673,6 +8679,9 @@ func (a *App) SetMCPServerTier(name, tier string) error {
|
||||
}
|
||||
a.bumpExtensionGeneration()
|
||||
if tab != nil && ctrl != nil && !mcpConnected(ctrl, name) {
|
||||
if err := config.RecordExplicitStart(updated, root); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := ctrl.ConnectMCPServer(updated); err != nil {
|
||||
recordMCPFailure(ctrl, updated, err)
|
||||
return nil
|
||||
@@ -8707,7 +8716,7 @@ func (a *App) saveDesktopMCPServer(root string, entry config.PluginEntry) error
|
||||
if err := ensureMCPServerDirectlyWritable(root, entry.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := config.UpsertPluginInSourceForRoot(root, entry)
|
||||
_, err := config.UpsertPluginKeepingDecision(root, entry)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9871,10 +9880,6 @@ func previewMediaKind(path string) (kind string, mime string) {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func (a *App) activeWorkspaceBase() (string, error) {
|
||||
return workspaceBaseFromRoot(a.activeWorkspaceRoot())
|
||||
}
|
||||
|
||||
func (a *App) workspaceTargetForTab(tabID string) (string, control.SessionAPI, bool) {
|
||||
tabID = strings.TrimSpace(tabID)
|
||||
a.mu.RLock()
|
||||
|
||||
+42
-71
@@ -1103,6 +1103,7 @@ status_bar_items = ["cost", "balance"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
approveWorkspace(t, project)
|
||||
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if err := userCfg.SetDesktopLanguage("en"); err != nil {
|
||||
@@ -1345,54 +1346,26 @@ func TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T)
|
||||
t.Fatalf("settings provider missing from settings: %+v", got.Providers)
|
||||
}
|
||||
|
||||
func TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {
|
||||
// With no user config yet, Settings start from the defaults, not from the
|
||||
// checkout's reasonix.toml, and the first edit writes only the user's choice.
|
||||
func TestSettingsNeverSeedTheUserConfigFromTheProjectFile(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
default_model = "legacy-provider/legacy-model"
|
||||
|
||||
[desktop]
|
||||
language = "zh"
|
||||
layout_style = "workbench"
|
||||
theme = "light"
|
||||
theme_style = "glacier"
|
||||
close_behavior = "quit"
|
||||
status_bar_style = "text"
|
||||
status_bar_items = ["model", "cache", "balance"]
|
||||
`), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("[desktop]\ntheme = \"light\"\n\n[permissions]\nmode = \"allow\"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
approveWorkspace(t, project)
|
||||
t.Chdir(project)
|
||||
app := NewApp()
|
||||
got := app.Settings()
|
||||
if got.ConfigPath != config.UserConfigPath() {
|
||||
t.Fatalf("Settings configPath = %q, want user config %q", got.ConfigPath, config.UserConfigPath())
|
||||
}
|
||||
if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopLayoutStyle != "workbench" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" || got.StatusBarStyle != "icon" {
|
||||
t.Fatalf("Settings did not seed from legacy project config: %+v", got)
|
||||
}
|
||||
if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(got.StatusBarItems, want) {
|
||||
t.Fatalf("Settings did not seed status bar items from legacy project config: got %v want %v", got.StatusBarItems, want)
|
||||
}
|
||||
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("Settings() should not write user config before an edit, stat err = %v", err)
|
||||
if got := app.Settings(); got.ConfigPath != config.UserConfigPath() || got.DesktopTheme == "light" {
|
||||
t.Fatalf("Settings = path %q theme %q, want the user config's defaults", got.ConfigPath, got.DesktopTheme)
|
||||
}
|
||||
if err := app.SetDesktopLanguage("en"); err != nil {
|
||||
t.Fatalf("SetDesktopLanguage: %v", err)
|
||||
}
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if userCfg.DesktopLanguage() != "en" || userCfg.DesktopLayoutStyle() != "workbench" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" || userCfg.DesktopStatusBarStyle() != "icon" {
|
||||
t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())
|
||||
}
|
||||
if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {
|
||||
t.Fatalf("saved user config did not preserve seeded status bar items: got %v want %v", userCfg.DesktopStatusBarItems(), want)
|
||||
if userCfg.DesktopLanguage() != "en" || userCfg.DesktopTheme() == "light" || userCfg.Permissions.Mode == "allow" {
|
||||
t.Fatalf("user config took the checkout's values: theme %q mode %q", userCfg.DesktopTheme(), userCfg.Permissions.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3227,6 +3200,7 @@ api_key_env = "DEEPSEEK_API_KEY"
|
||||
if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte(projectConfig), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
approveWorkspace(t, projectRoot)
|
||||
|
||||
app := NewApp()
|
||||
tab := &WorkspaceTab{ID: "project", WorkspaceRoot: projectRoot, Ready: true}
|
||||
@@ -3954,6 +3928,7 @@ default_effort = "max"
|
||||
if err := os.WriteFile(filepath.Join(projectA, "reasonix.toml"), []byte(ownerConfig), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, projectA)
|
||||
staleConfig := `default_model = "stale/stale-model"
|
||||
[[providers]]
|
||||
name = "stale"
|
||||
@@ -3966,6 +3941,7 @@ reasoning_protocol = "none"
|
||||
if err := os.WriteFile(filepath.Join(projectB, "reasonix.toml"), []byte(staleConfig), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, projectB)
|
||||
|
||||
topicID := "topic_effort_owner"
|
||||
topicTitle := "Effort owner"
|
||||
@@ -6909,39 +6885,6 @@ func (r *appendingDesktopRunner) Run(_ context.Context, input string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[[plugins]]
|
||||
name = "playwright"
|
||||
command = "npx"
|
||||
args = ["-y", "@playwright/mcp"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer func() {
|
||||
if c := app.activeCtrl(); c != nil {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
if s.Name == "playwright" {
|
||||
if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
|
||||
t.Fatalf("default MCP view = %+v, want deferred automatic idle", s)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
|
||||
}
|
||||
|
||||
func TestCapabilitiesIncludesInstalledPlugins(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
reasonixHome := config.ReasonixHomeDir()
|
||||
@@ -7006,6 +6949,8 @@ url = %q
|
||||
`, srv.URL), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForTest(t, dir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -7093,6 +7038,7 @@ args = ["-y", "@playwright/mcp"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -7116,6 +7062,7 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -7164,6 +7111,7 @@ url = "http://127.0.0.1:1/mcp"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
host := plugin.NewHost()
|
||||
defer host.Close()
|
||||
@@ -7202,6 +7150,7 @@ url = %q
|
||||
`, srv.URL), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -7352,6 +7301,7 @@ url = %q
|
||||
`, projectServer.URL), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
host := plugin.NewHost()
|
||||
t.Cleanup(host.Close)
|
||||
@@ -7505,6 +7455,7 @@ network = true
|
||||
`, exe, singleInstanceAddr, gateConfig), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
entry := config.PluginEntry{
|
||||
Name: "h", Command: exe, Args: helperArgs,
|
||||
@@ -8323,6 +8274,7 @@ args = ["serve"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -8434,6 +8386,8 @@ command = "reasonix-missing-mcp-binary"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -8961,6 +8915,8 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -8992,6 +8948,7 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9022,6 +8979,8 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
host := plugin.NewHost()
|
||||
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
|
||||
@@ -9056,6 +9015,8 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
host := plugin.NewHost()
|
||||
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
|
||||
@@ -9115,6 +9076,7 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9192,6 +9154,7 @@ args = ["-y", "@playwright/mcp"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9230,6 +9193,8 @@ tier = "background"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9280,6 +9245,8 @@ name = "codegraph"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(desktopFakeTool{name: "mcp__codegraph__connect"})
|
||||
@@ -9338,6 +9305,8 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9431,6 +9400,8 @@ tier = "eager"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
|
||||
+30
-155
@@ -265,19 +265,18 @@ func TestDesktopBotRuntimeConfigLoadsAllSavedCredentialsAfterRestart(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimeMigratesLegacyProjectBotSettings(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
userCfg := config.Default()
|
||||
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
|
||||
t.Fatalf("set desktop appearance: %v", err)
|
||||
}
|
||||
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save user config: %v", err)
|
||||
}
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
// A checkout's [bot] never reaches the bot runtime or the user's config, with
|
||||
// or without a user config of its own.
|
||||
func TestDesktopBotRuntimeIgnoresProjectBotSettings(t *testing.T) {
|
||||
for _, withUser := range []bool{false, true} {
|
||||
isolateDesktopUserDirs(t)
|
||||
if withUser {
|
||||
if err := config.Default().SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save user config: %v", err)
|
||||
}
|
||||
}
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
[bot]
|
||||
enabled = true
|
||||
|
||||
@@ -289,154 +288,30 @@ feishu_users = ["ou-legacy"]
|
||||
id = "feishu-lark"
|
||||
provider = "feishu"
|
||||
domain = "lark"
|
||||
label = "Lark"
|
||||
enabled = true
|
||||
status = "connected"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
|
||||
}
|
||||
|
||||
// The bot-runtime load is a pure read: the merge above stays in memory and
|
||||
// the user config file is not rewritten.
|
||||
preWrite := config.LoadForEdit(config.UserConfigPath())
|
||||
if preWrite.Bot.Enabled || len(preWrite.Bot.Connections) != 0 {
|
||||
t.Fatalf("read path persisted bot config = %+v, want disk untouched until a locked write", preWrite.Bot)
|
||||
}
|
||||
|
||||
// The first locked write path performs the on-disk migration.
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
|
||||
}
|
||||
if persisted.DesktopTheme() != "dark" {
|
||||
t.Fatalf("desktop theme = %q, want preserved user preference", persisted.DesktopTheme())
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
t.Chdir(project)
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
if got.Bot.Enabled || len(got.Bot.Connections) != 0 {
|
||||
t.Fatalf("desktop bot config = %+v, want the checkout's [bot] ignored", got.Bot)
|
||||
}
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
if persisted := config.LoadForEdit(config.UserConfigPath()); persisted.Bot.Enabled || len(persisted.Bot.Connections) != 0 {
|
||||
t.Fatalf("user config took the checkout's [bot]: %+v", persisted.Bot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimePersistsLegacyProjectBotWhenUserConfigMissing(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
[desktop]
|
||||
theme = "dark"
|
||||
|
||||
[bot]
|
||||
enabled = true
|
||||
|
||||
[bot.allowlist]
|
||||
enabled = true
|
||||
feishu_users = ["ou-legacy"]
|
||||
|
||||
[[bot.connections]]
|
||||
id = "feishu-lark"
|
||||
provider = "feishu"
|
||||
domain = "lark"
|
||||
label = "Lark"
|
||||
enabled = true
|
||||
status = "connected"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
|
||||
}
|
||||
|
||||
// The bot-runtime load is a pure read: it serves the legacy config from
|
||||
// memory and must not create the user config file.
|
||||
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("read path must not create the user config, stat err = %v", err)
|
||||
}
|
||||
|
||||
// The first locked write path creates the user config with the migrated
|
||||
// bot settings (adopting the legacy config, ConfigVersion-bumped).
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
[desktop]
|
||||
theme = "dark"
|
||||
close_behavior = "quit"
|
||||
|
||||
[bot]
|
||||
enabled = true
|
||||
|
||||
[bot.allowlist]
|
||||
enabled = true
|
||||
feishu_users = ["ou-legacy"]
|
||||
|
||||
[[bot.connections]]
|
||||
id = "feishu-lark"
|
||||
provider = "feishu"
|
||||
domain = "lark"
|
||||
label = "Lark"
|
||||
enabled = true
|
||||
status = "connected"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
settings := NewApp().Settings()
|
||||
if !settings.Bot.Enabled || len(settings.Bot.Connections) != 1 || settings.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("settings bot = %+v, want migrated legacy Lark connection", settings.Bot)
|
||||
}
|
||||
if settings.DesktopTheme != "dark" || settings.CloseBehavior != "quit" {
|
||||
t.Fatalf("settings desktop prefs = theme:%q close:%q, want legacy seed visible before first edit", settings.DesktopTheme, settings.CloseBehavior)
|
||||
}
|
||||
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if persisted.DesktopTheme() == "dark" || persisted.DesktopCloseBehavior() == "quit" {
|
||||
t.Fatalf("persisted desktop prefs = theme:%q close:%q, want bot-only migration", persisted.DesktopTheme(), persisted.DesktopCloseBehavior())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimeMigrationDoesNotOverwriteUserBotSettings(t *testing.T) {
|
||||
func TestDesktopBotRuntimeKeepsUserBotSettingsBesideAProjectBot(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
userCfg := config.Default()
|
||||
|
||||
@@ -46,7 +46,11 @@ type DeliveryWorktreeOpenResult = IsolatedWorktreeOpenResult
|
||||
// optional Git isolation path. A false result never disables writing itself;
|
||||
// the cross-platform workspace writer lease remains the no-Git fallback.
|
||||
func (a *App) IsolatedWorktreeAvailability(workspaceRoot string) worktree.Availability {
|
||||
return inspectDeliveryWorktree(a.bootContext(), workspaceRoot)
|
||||
repo, err := a.workspaceRepoForRoot(workspaceRoot)
|
||||
if err != nil {
|
||||
return worktree.Availability{Reason: err.Error()}
|
||||
}
|
||||
return inspectDeliveryWorktree(a.bootContext(), repo)
|
||||
}
|
||||
|
||||
// CreateIsolatedWorktree creates a durable branch-backed worktree and opens it
|
||||
@@ -61,7 +65,11 @@ func (a *App) CreateIsolatedWorktree(workspaceRoot string) (IsolatedWorktreeOpen
|
||||
return worktree.Result{}, err
|
||||
}
|
||||
defer releaseAdmission()
|
||||
return createDeliveryWorktree(a.bootContext(), workspaceRoot, config.DeliveryWorktreeDir())
|
||||
repo, err := a.workspaceRepoForRoot(workspaceRoot)
|
||||
if err != nil {
|
||||
return worktree.Result{}, err
|
||||
}
|
||||
return createDeliveryWorktree(a.bootContext(), repo, config.DeliveryWorktreeDir())
|
||||
}()
|
||||
if err != nil {
|
||||
return IsolatedWorktreeOpenResult{}, err
|
||||
|
||||
@@ -7,13 +7,15 @@ import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/gitcmd"
|
||||
"reasonix/internal/worktree"
|
||||
)
|
||||
|
||||
func TestDeliveryWorktreeAvailabilityDelegatesWithoutRequiringGit(t *testing.T) {
|
||||
original := inspectDeliveryWorktree
|
||||
t.Cleanup(func() { inspectDeliveryWorktree = original })
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := filepath.Base(repo.Dir)
|
||||
return worktree.Availability{Available: false, Reason: "Git is not installed", RepoRoot: root}
|
||||
}
|
||||
got := NewApp().DeliveryWorktreeAvailability("project")
|
||||
@@ -35,7 +37,8 @@ func TestCreateDeliveryWorktreeRegistersAndOpensManagedProject(t *testing.T) {
|
||||
|
||||
original := createDeliveryWorktree
|
||||
t.Cleanup(func() { createDeliveryWorktree = original })
|
||||
createDeliveryWorktree = func(_ context.Context, source, gotManaged string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(_ context.Context, sourceRepo gitcmd.Repo, gotManaged string) (worktree.Result, error) {
|
||||
source := filepath.Base(sourceRepo.Dir)
|
||||
if source != "source-project" {
|
||||
t.Fatalf("source = %q", source)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -23,8 +24,12 @@ func acquireDesktopProjectsFileLock() (func(), error) {
|
||||
|
||||
// updateProjectsFileCrossProcessLocked requires desktopProjectsFileMu and the
|
||||
// desktop-projects cross-process file lock.
|
||||
func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (bool, error)) error {
|
||||
func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (bool, error), assignCollisions bool) error {
|
||||
f := loadProjectsFile()
|
||||
previous := make([]string, 0, len(f.Projects))
|
||||
for _, project := range f.Projects {
|
||||
previous = append(previous, project.Root)
|
||||
}
|
||||
changed, err := mutator(&f)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -32,5 +37,10 @@ func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (boo
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
if assignCollisions {
|
||||
if err := assignAddedProjectStateCollisions(previous, f.Projects); err != nil {
|
||||
return fmt.Errorf("%w: %w", errProjectStateCollisionAssignment, err)
|
||||
}
|
||||
}
|
||||
return saveProjectsFile(f)
|
||||
}
|
||||
+6
-3
@@ -66,7 +66,8 @@ func (a *App) forkForTabWithOptions(tabID string, turn int, isolateWorkspace boo
|
||||
if scope != "project" || strings.TrimSpace(srcRoot) == "" {
|
||||
result.FallbackToShared = true
|
||||
} else {
|
||||
avail := inspectDeliveryWorktree(a.bootContext(), srcRoot)
|
||||
srcRepo, _ := workspaceRepo(srcRoot, ctrl)
|
||||
avail := inspectDeliveryWorktree(a.bootContext(), srcRepo)
|
||||
if !avail.Available {
|
||||
result.FallbackToShared = true
|
||||
} else if avail.SourceDirty {
|
||||
@@ -80,7 +81,7 @@ func (a *App) forkForTabWithOptions(tabID string, turn int, isolateWorkspace boo
|
||||
return worktree.Result{}, err
|
||||
}
|
||||
defer releaseAdmission()
|
||||
return createDeliveryWorktree(a.bootContext(), srcRoot, config.DeliveryWorktreeDir())
|
||||
return createDeliveryWorktree(a.bootContext(), srcRepo, config.DeliveryWorktreeDir())
|
||||
}()
|
||||
if createErr != nil {
|
||||
return ForkWorktreeResultView{}, fmt.Errorf("create isolated fork worktree: %w", createErr)
|
||||
@@ -280,7 +281,9 @@ func (a *App) openForkedSessionTabWithWorkspace(sourceTab *WorkspaceTab, locator
|
||||
if err := prependTopicInProjectsFile(workspaceRoot, topicID, true); err != nil {
|
||||
slog.Warn("desktop: persist isolated fork topic", "workspace", workspaceRoot, "topic", topicID, "err", err)
|
||||
}
|
||||
a.registerProjectRoot(workspaceRoot)
|
||||
if err := a.registerProjectRoot(workspaceRoot); err != nil {
|
||||
return forkedSessionTabOpen{}, err
|
||||
}
|
||||
}
|
||||
if hook := forkTabBeforePublishHookForTest.Load(); hook != nil {
|
||||
(*hook)()
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/gitcmd"
|
||||
"reasonix/internal/worktree"
|
||||
)
|
||||
|
||||
@@ -28,10 +29,12 @@ func TestForkWorktreeForTabCreatesIsolatedWorkspace(t *testing.T) {
|
||||
rollbackDeliveryWorktree = origRollback
|
||||
})
|
||||
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := repo.Dir
|
||||
return worktree.Availability{Available: true, RepoRoot: root, Branch: "main"}
|
||||
}
|
||||
createDeliveryWorktree = func(_ context.Context, source, gotManaged string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(_ context.Context, sourceRepo gitcmd.Repo, gotManaged string) (worktree.Result, error) {
|
||||
source := sourceRepo.Dir
|
||||
return worktree.Result{
|
||||
WorkspaceRoot: isolatedRoot,
|
||||
WorktreeRoot: filepath.Dir(isolatedRoot),
|
||||
@@ -90,11 +93,12 @@ func TestForkWorktreeForTabRefusesDirtySourceWithoutMutation(t *testing.T) {
|
||||
inspectDeliveryWorktree = origInspect
|
||||
createDeliveryWorktree = origCreate
|
||||
})
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := repo.Dir
|
||||
return worktree.Availability{Available: true, RepoRoot: root, SourceDirty: true}
|
||||
}
|
||||
createCalls := 0
|
||||
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
|
||||
createCalls++
|
||||
return worktree.Result{}, nil
|
||||
}
|
||||
@@ -125,10 +129,10 @@ func TestForkWorktreeForTabFallsBackToSharedFork(t *testing.T) {
|
||||
inspectDeliveryWorktree = origInspect
|
||||
createDeliveryWorktree = origCreate
|
||||
})
|
||||
inspectDeliveryWorktree = func(context.Context, string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(context.Context, gitcmd.Repo) worktree.Availability {
|
||||
return worktree.Availability{Reason: "not a repository"}
|
||||
}
|
||||
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
|
||||
t.Fatal("fallback must not create a worktree")
|
||||
return worktree.Result{}, nil
|
||||
}
|
||||
@@ -164,7 +168,8 @@ func TestForkWorktreeForTabRollsBackUnusedCreation(t *testing.T) {
|
||||
createDeliveryWorktree = origCreate
|
||||
rollbackDeliveryWorktree = origRollback
|
||||
})
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := repo.Dir
|
||||
return worktree.Availability{Available: true, RepoRoot: root}
|
||||
}
|
||||
created := worktree.Result{
|
||||
@@ -174,7 +179,7 @@ func TestForkWorktreeForTabRollsBackUnusedCreation(t *testing.T) {
|
||||
Branch: "reasonix/delivery-test",
|
||||
Head: "deadbeef",
|
||||
}
|
||||
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
|
||||
return created, nil
|
||||
}
|
||||
rollbackCalls := 0
|
||||
@@ -211,7 +216,8 @@ func TestForkWorktreeForTabPreservesReferencedWorkspaceWhenSourceCloses(t *testi
|
||||
rollbackDeliveryWorktree = origRollback
|
||||
forkTabBeforePublishHookForTest.Store(nil)
|
||||
})
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := repo.Dir
|
||||
return worktree.Availability{Available: true, RepoRoot: root}
|
||||
}
|
||||
worktreeRoot := filepath.Join(t.TempDir(), "worktree")
|
||||
@@ -226,7 +232,7 @@ func TestForkWorktreeForTabPreservesReferencedWorkspaceWhenSourceCloses(t *testi
|
||||
Branch: "reasonix/delivery-preserved",
|
||||
Head: "deadbeef",
|
||||
}
|
||||
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
|
||||
return created, nil
|
||||
}
|
||||
rollbackCalls := 0
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"test:code-browser": "node bench/code-theme.mjs",
|
||||
"test:dock-responsive-browser": "node bench/responsive-dock.mjs",
|
||||
"test:diagnostics": "tsx src/__tests__/diagnostics-settings.test.tsx",
|
||||
"test:transcript": "tsx src/__tests__/canonical-user-display.test.ts && tsx src/__tests__/steer-transcript.test.ts && tsx src/__tests__/chat-tool-presentation.test.ts && tsx src/__tests__/chat-view-source.test.ts && tsx src/__tests__/maintenance-lifecycle.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/maintenance-card.test.tsx && tsx src/__tests__/presented-files.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/presented-files-ui.test.tsx && tsx src/__tests__/chat-content-loader.test.ts && tsx src/__tests__/chat-details-race.test.tsx && tsx src/__tests__/tool-payload-preview.test.ts && tsx src/__tests__/markdown-natural-flow.test.tsx && tsx src/__tests__/chat-natural-flow.test.tsx && tsx src/__tests__/chat-scroll-controller.test.ts && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/markdown-worker-client.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-memory-pressure.test.ts && tsx src/__tests__/transcript-tool-association.test.ts && tsx src/__tests__/transcript-follow-client.test.ts && tsx src/__tests__/transcript-session-follower.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/session-export-routing.test.tsx && tsx src/__tests__/session-export-operation.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/transcript-outline-store.test.ts && tsx src/__tests__/chat-turn-jump.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/chat-turn-outline-jump.test.tsx && tsx src/__tests__/native-transcript-history.test.ts && tsx src/__tests__/history-read-binding.test.ts",
|
||||
"test:transcript": "tsx src/__tests__/canonical-user-display.test.ts && tsx src/__tests__/steer-transcript.test.ts && tsx src/__tests__/chat-tool-presentation.test.ts && tsx src/__tests__/chat-view-source.test.ts && tsx src/__tests__/maintenance-lifecycle.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/maintenance-card.test.tsx && tsx src/__tests__/presented-files.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/presented-files-ui.test.tsx && tsx src/__tests__/chat-content-loader.test.ts && tsx src/__tests__/chat-details-race.test.tsx && tsx src/__tests__/tool-payload-preview.test.ts && tsx src/__tests__/markdown-natural-flow.test.tsx && tsx src/__tests__/chat-natural-flow.test.tsx && tsx src/__tests__/chat-scroll-controller.test.ts && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/markdown-worker-client.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-memory-pressure.test.ts && tsx src/__tests__/transcript-tool-association.test.ts && tsx src/__tests__/transcript-follow-client.test.ts && tsx src/__tests__/transcript-session-follower.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/session-export-routing.test.tsx && tsx src/__tests__/session-export-operation.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/transcript-outline-store.test.ts && tsx src/__tests__/chat-turn-jump.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/chat-turn-outline-jump.test.tsx && tsx src/__tests__/native-transcript-history.test.ts && tsx src/__tests__/history-read-binding.test.ts && tsx src/__tests__/history-reload-identity.test.ts",
|
||||
"test:session-export-browser": "node bench/session-export.mjs",
|
||||
"test:session-loading-browser": "node bench/session-loading.mjs && node bench/draft-recovery.mjs && node bench/manual-creation.mjs && node bench/manual-creation-recovery.mjs",
|
||||
"test:draft-recovery-browser": "node bench/draft-recovery.mjs",
|
||||
@@ -72,14 +72,14 @@
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@modelcontextprotocol/ext-apps": "1.7.5",
|
||||
"@tanstack/react-virtual": "^3.14.10",
|
||||
"@tanstack/react-virtual": "^3.14.13",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"anser": "^2.3.5",
|
||||
"hast-util-to-jsx-runtime": "2.3.6",
|
||||
"highlight.js": "^11.12.0",
|
||||
"html-url-attributes": "3.0.1",
|
||||
"katex": "^0.18.6",
|
||||
"katex": "^0.18.9",
|
||||
"lucide-react": "^1.41.0",
|
||||
"mermaid": "^11.17.2",
|
||||
"qrcode.react": "^4.2.0",
|
||||
@@ -103,7 +103,7 @@
|
||||
"@types/node": "^26.4.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@typescript-eslint/parser": "^8.69.0",
|
||||
"@typescript-eslint/parser": "^8.70.1",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"decode-named-character-reference": "^1.3.0",
|
||||
"eslint": "^10.10.0",
|
||||
@@ -113,7 +113,7 @@
|
||||
"mdast-util-math": "^3.0.0",
|
||||
"playwright": "^1.63.0",
|
||||
"terser": "^5.51.2",
|
||||
"tsx": "^4.23.13",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.2.2"
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@ const background = [{ id: "local-runtime" }] as unknown as BackgroundRuntimeView
|
||||
const view = projectConversation({ local, remote, tab, activeTabId: tab.id, backgroundRuntimes: background, connectingLabel: "connecting" });
|
||||
assert.equal(view.runtime, remote.transcript, "projection shares the canonical state and message arrays");
|
||||
assert.equal(view.context.items, remote.transcript.items);
|
||||
assert.equal(view.context.tabId, undefined, "remote context cannot fetch local telemetry");
|
||||
assert.equal(view.context.tabId, tab.id, "remote context reads the selected remote tab");
|
||||
assert.equal(view.composer.modelLabel, "remote-model");
|
||||
assert.equal(view.composer.cwd, "remote-cwd");
|
||||
assert.equal(view.composer.turnTokens, 7);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Run: tsx src/__tests__/decision-surface-remote.test.ts
|
||||
import assert from "node:assert/strict";
|
||||
import { projectDecisionSurface } from "../app-runtime/decisionSurfaceProjection";
|
||||
|
||||
const base = { approval: undefined, ask: undefined, mcpInteraction: undefined, extensionForm: undefined, workspaceConflict: null, pendingClose: null, clearContextPending: false } as unknown as Parameters<typeof projectDecisionSurface>[0];
|
||||
const ask = { id: "1", questions: [{ id: "q1", prompt: "?", options: [{ label: "a" }, { label: "b" }] }] } as unknown as Parameters<typeof projectDecisionSurface>[0]["ask"];
|
||||
const approval = { id: "call-1", tool: "bash" } as unknown as Parameters<typeof projectDecisionSurface>[0]["approval"];
|
||||
|
||||
assert.equal(projectDecisionSurface({ ...base, ask }), "ask", "a local ask docks in the footer");
|
||||
assert.equal(projectDecisionSurface({ ...base, ask, remote: true }), null, "a remote ask belongs to the remote surface, not a second footer card");
|
||||
assert.equal(projectDecisionSurface({ ...base, approval, remote: true }), null, "a remote approval is not duplicated in the footer");
|
||||
assert.equal(projectDecisionSurface({ ...base, ask, remote: true, clearContextPending: true }), "clear_context", "shell decisions still reach the footer on a remote tab");
|
||||
console.log("decision-surface-remote: 4 passed");
|
||||
@@ -16,6 +16,7 @@ for (const locale of ["en", "zh", "zh-TW"] as const) {
|
||||
const translate: Translator = key => dict[key];
|
||||
for (const [error, key] of [
|
||||
["dial tcp: connection refused", "error.connection"],
|
||||
[{ code: "transport_protocol", message: "connection error: PROTOCOL_ERROR" }, "error.transportProtocol"],
|
||||
["lookup host: no such host", "error.dns"],
|
||||
["deepseek: status 503: invalid api key mentioned in upstream diagnostics", "error.service"],
|
||||
["HTTP 429: too many requests", "error.rateLimit"],
|
||||
@@ -36,6 +37,10 @@ for (const locale of ["en", "zh", "zh-TW"] as const) {
|
||||
] as const) {
|
||||
assert.equal(presentError(error, translate, locale).summary, dict[key], `${locale}: ${JSON.stringify(error)}`);
|
||||
}
|
||||
assert.deepEqual(presentError("opaque failure", translate, locale, { kind: "transport_protocol", transportCode: "PROTOCOL_ERROR" }), {
|
||||
summary: dict["error.transportProtocol"], detail: "opaque failure\nHTTP/2: PROTOCOL_ERROR",
|
||||
});
|
||||
assert.notEqual(presentError("connection error: PROTOCOL_ERROR", translate, locale).summary, dict["error.transportProtocol"], "untyped wording is not transport evidence");
|
||||
if (locale !== "en") {
|
||||
for (const raw of ["unknown backend failure", "read /tmp/timeout/404.txt: unsupported format", "read /tmp/中文文件: unsupported format", "<img src=x onerror=alert(1)>"]) {
|
||||
assert.deepEqual(presentError(raw, translate, locale), { summary: dict["error.unknown"], detail: raw });
|
||||
@@ -59,10 +64,11 @@ await Promise.all([preloadLocale("zh"), preloadLocale("zh-TW")]);
|
||||
let changeLocale: (locale: Locale) => void;
|
||||
let toast: ReturnType<typeof useToast>["showToast"];
|
||||
const raw = "unknown library failure <img src=x onerror=alert(1)>";
|
||||
const protocolError = "connection error: PROTOCOL_ERROR <img src=x onerror=alert(1)>";
|
||||
function Probe() {
|
||||
changeLocale = useI18n().setPref;
|
||||
toast = useToast().showToast;
|
||||
return <><p id="inline-error"><ErrorMessage error={raw} /></p><p id="summary-only"><ErrorMessage error={undefined} summary="仅有说明" /></p></>;
|
||||
return <><p id="inline-error"><ErrorMessage error={raw} /></p><p id="transport-error"><ErrorMessage error={protocolError} diagnostic={{ kind: "transport_protocol", transportCode: "PROTOCOL_ERROR" }} /></p><p id="summary-only"><ErrorMessage error={undefined} summary="仅有说明" /></p></>;
|
||||
}
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
await act(async () => root.render(<LocaleProvider><ToastProvider><Probe /></ToastProvider></LocaleProvider>));
|
||||
@@ -76,9 +82,14 @@ assert.equal(toggle.getAttribute("aria-expanded"), "true");
|
||||
assert.equal(document.querySelector(".user-error__detail")?.textContent, raw);
|
||||
assert.equal(document.querySelector("#inline-error img"), null, "diagnostics are text, never executable markup");
|
||||
await act(async () => changeLocale("zh-TW"));
|
||||
assert.equal(document.querySelector("#transport-error .user-error__summary")?.textContent, zhTW["error.transportProtocol"]);
|
||||
assert.equal(document.querySelector("#inline-error .user-error__summary")?.textContent, zhTW["error.unknown"]);
|
||||
assert.equal(document.querySelector(".user-error__detail")?.textContent, raw, "language switching preserves diagnostics");
|
||||
await act(async () => changeLocale("en"));
|
||||
assert.equal(document.querySelector("#transport-error .user-error__summary")?.textContent, en["error.transportProtocol"]);
|
||||
await act(async () => document.querySelector<HTMLButtonElement>("#transport-error .user-error__toggle")!.click());
|
||||
assert.equal(document.querySelector("#transport-error .user-error__detail")?.textContent, `${protocolError}\nHTTP/2: PROTOCOL_ERROR`);
|
||||
assert.equal(document.querySelector("#transport-error img"), null);
|
||||
assert.equal(document.querySelector("#inline-error .user-error__summary")?.textContent, raw, "English errors follow the active English UI locale");
|
||||
await act(async () => changeLocale("zh-TW"));
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { installDesktopHostStub } from "./desktopHostStub";
|
||||
import { getTranscriptStore } from "../lib/transcriptStore";
|
||||
import { loadHistoryWindow, type HistoryWindowAction } from "../lib/historyWindowController";
|
||||
import { initialState, reducer, type State } from "../lib/useController";
|
||||
|
||||
const dom = new JSDOM("", { url: "http://localhost/" });
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
|
||||
let revision = 1;
|
||||
const stub = installDesktopHostStub({
|
||||
SessionHistoryWindowForTab: async () => ({
|
||||
status: "ready",
|
||||
snapshotSequence: revision,
|
||||
generation: `generation-${revision}`,
|
||||
totalTurns: 30,
|
||||
hasOlder: true,
|
||||
olderCursor: "older-cursor",
|
||||
messages: [{ messageId: `user-${revision}`, position: 30, version: 1, role: "user", eventSequence: 30,
|
||||
visibleTurn: 30, preview: `revision ${revision}`, inline: { id: `user-${revision}`, role: "user", content: `revision ${revision}` } }],
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
const store = getTranscriptStore();
|
||||
for (const protocol of [1, 2] as const) {
|
||||
revision = 1;
|
||||
const tabId = `history-reload-tab-${protocol}`;
|
||||
const sessionPath = `/fixture/history-reload-${protocol}.jsonl`;
|
||||
const initial = await store.loadLatest(tabId, sessionPath);
|
||||
assert.equal(initial?.revision, 1);
|
||||
store.evictTab(tabId);
|
||||
revision = 2;
|
||||
let state: State = {
|
||||
...initialState,
|
||||
transcriptProtocol: protocol,
|
||||
historyStartTurn: initial!.startTurn,
|
||||
historyTotalTurns: initial!.totalTurns,
|
||||
historyHasOlder: true,
|
||||
historyHasNewer: false,
|
||||
historyOlderLoading: false,
|
||||
historyNewerLoading: false,
|
||||
historyRevision: 1,
|
||||
historyDigest: "generation-1",
|
||||
running: false,
|
||||
meta: { label: "fixture", ready: true, eventChannel: "fixture", cwd: "/fixture", sessionPath,
|
||||
sessionRevision: 1, sessionDigest: "generation-1" },
|
||||
};
|
||||
const actions: HistoryWindowAction[] = [];
|
||||
const outcome = await loadHistoryWindow({
|
||||
tabId, direction: "older", trigger: "retry", state, requestSeq: 1,
|
||||
isCurrent: () => true,
|
||||
currentState: () => state,
|
||||
dispatch: action => {
|
||||
actions.push(action);
|
||||
state = reducer(state, action);
|
||||
},
|
||||
});
|
||||
assert.equal(outcome, "loaded", `protocol ${protocol}: refreshed page should replace an evicted old window: ${JSON.stringify(actions)}`);
|
||||
assert.equal(actions[actions.length - 1]?.type, protocol === 2 ? "transcript_records" : "history_replace");
|
||||
assert.equal(actions.find(action => action.type === "history_older_error"), undefined);
|
||||
assert.equal(state.historyRevision, 2);
|
||||
assert.equal(state.historyDigest, "generation-2");
|
||||
assert.equal(state.historyOlderLoading, false);
|
||||
assert.ok(state.items.some(item => item.kind === "user" && item.text === "revision 2"));
|
||||
}
|
||||
} finally {
|
||||
stub.uninstall();
|
||||
dom.window.close();
|
||||
}
|
||||
@@ -51,7 +51,7 @@ type ControllerState = Parameters<typeof reducer>[0];
|
||||
const appSource = readFileSync(new URL("../AppRuntime.tsx", import.meta.url), "utf8");
|
||||
const sessionCompositionSource = readFileSync(new URL("../app-runtime/useAppSessionComposition.ts", import.meta.url), "utf8");
|
||||
ok(
|
||||
/\[clearContextPending, pendingClose, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource),
|
||||
/\[clearContextPending, pendingClose, remoteSurfaceActive, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource),
|
||||
"App decision surface recomputes when an MCP interaction arrives",
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Run: tsx src/__tests__/project-tree-organization-group-drop.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { useProjectTreeOrganization } from "../components/ProjectTreeOrganization";
|
||||
import type { ProjectNode, ProjectTreeOrganizationBindings, SessionGroup } from "../lib/types";
|
||||
import type { SessionOrganizationMutation, SessionOrganizationSnapshot } from "../generated/desktopContract.generated";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
|
||||
if (value) passed += 1; else failed += 1;
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
return dom;
|
||||
}
|
||||
|
||||
function topic(id: string): ProjectNode {
|
||||
return { key: `topic-${id}`, kind: "topic", label: id, root: "/repo", topicId: id, session: { hostId: "local", sessionId: id } } as ProjectNode;
|
||||
}
|
||||
|
||||
const inside = topic("inside");
|
||||
const sibling = topic("sibling");
|
||||
const outside = topic("outside");
|
||||
const loose = topic("loose");
|
||||
const folder: ProjectNode = { key: "project-/repo", kind: "project", label: "Repo", root: "/repo", children: [inside, sibling, outside, loose] };
|
||||
const groups: SessionGroup[] = [{ id: "g1", title: "Group", sessionKeys: ["ref\0local\0inside", "ref\0local\0sibling"] }];
|
||||
|
||||
function Harness({ bindings }: { bindings: ProjectTreeOrganizationBindings }) {
|
||||
const organization = useProjectTreeOrganization({ tree: [folder], refresh: async () => {}, bindings });
|
||||
const loaded = organization.groupsFor(folder).length > 0;
|
||||
return <>
|
||||
<output id="loaded">{loaded ? "yes" : "no"}</output>
|
||||
{folder.children!.map((node) => {
|
||||
const row = organization.topicRow(node, false);
|
||||
return <div key={node.key} id={`row-${node.topicId}`} {...row.props}>{node.label}</div>;
|
||||
})}
|
||||
</>;
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function fireDrag(type: string, target: Element, data: Map<string, string>) {
|
||||
const event = new window.Event(type, { bubbles: true, cancelable: true });
|
||||
const dataTransfer = {
|
||||
setData: (format: string, value: string) => { data.set(format, value); },
|
||||
getData: (format: string) => data.get(format) ?? "",
|
||||
effectAllowed: "all",
|
||||
dropEffect: "none",
|
||||
};
|
||||
Object.defineProperty(event, "dataTransfer", { value: dataTransfer });
|
||||
Object.defineProperty(event, "clientY", { value: 10 });
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
async function dropOnto(draggedID: string, targetID: string): Promise<SessionOrganizationMutation[]> {
|
||||
const dom = installDom();
|
||||
const mutations: SessionOrganizationMutation[] = [];
|
||||
const snapshot = (): SessionOrganizationSnapshot => ({ groups: structuredClone(groups), revision: 1, applied: true, order: [], manualOrderEnabled: false });
|
||||
const bindings: ProjectTreeOrganizationBindings = {
|
||||
ReorderTopics: async () => {},
|
||||
ListProjectGroups: async () => [],
|
||||
SaveSessionGroups: async () => {},
|
||||
GetSessionOrganization: async () => snapshot(),
|
||||
UpdateSessionOrganization: async (_workspace, _revision, mutation) => { mutations.push(mutation); return snapshot(); },
|
||||
};
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
await act(async () => {
|
||||
root.render(<LocaleProvider><ToastProvider><Harness bindings={bindings} /></ToastProvider></LocaleProvider>);
|
||||
await flush();
|
||||
});
|
||||
for (let attempt = 0; attempt < 30 && document.getElementById("loaded")?.textContent !== "yes"; attempt += 1) await act(flush);
|
||||
const data = new Map<string, string>();
|
||||
await act(async () => { fireDrag("dragstart", document.getElementById(`row-${draggedID}`)!, data); await flush(); });
|
||||
await act(async () => { fireDrag("dragover", document.getElementById(`row-${targetID}`)!, data); await flush(); });
|
||||
await act(async () => { fireDrag("drop", document.getElementById(`row-${targetID}`)!, data); await flush(); });
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) await act(flush);
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
return mutations;
|
||||
}
|
||||
|
||||
console.log("\nproject tree drop onto a group member row");
|
||||
|
||||
{
|
||||
const mutations = await dropOnto("outside", "inside");
|
||||
ok(mutations.length === 1 && mutations[0].kind === "set-group" && mutations[0].groupId === "g1",
|
||||
`dropping a non-member on a member row adds it to that group (got ${JSON.stringify(mutations.map((m) => [m.kind, m.groupId ?? ""]))})`);
|
||||
}
|
||||
|
||||
{
|
||||
const mutations = await dropOnto("sibling", "inside");
|
||||
ok(mutations.length === 1 && mutations[0].kind === "move",
|
||||
`dropping a member on another member of its group reorders (got ${JSON.stringify(mutations.map((m) => m.kind))})`);
|
||||
}
|
||||
|
||||
{
|
||||
const mutations = await dropOnto("outside", "loose");
|
||||
ok(mutations.length === 1 && mutations[0].kind === "move",
|
||||
`dropping on an ungrouped row reorders (got ${JSON.stringify(mutations.map((m) => m.kind))})`);
|
||||
}
|
||||
|
||||
process.stdout.write(`\nproject-tree-organization-group-drop: ${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -25,8 +25,9 @@ const { ToastProvider } = await import("../lib/toast");
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
let reject!: (reason: Error) => void;
|
||||
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const pending: ReturnType<typeof deferred<ProjectTreeSnapshot>>[] = [];
|
||||
@@ -78,9 +79,21 @@ try {
|
||||
try {
|
||||
await render(0);
|
||||
assert.equal(pending.length, 1, "mount starts one shell read");
|
||||
assert.match(container.querySelector(".project-tree__empty-state")?.textContent ?? "", /读取|讀取|Reading/,
|
||||
"an unanswered shell read must say it is loading");
|
||||
assert.doesNotMatch(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/,
|
||||
"pending shell read must not claim there are no projects");
|
||||
if (scenario.name === "addition") {
|
||||
assert.equal(container.querySelector(".project-tree__empty-state .project-tree__skeleton"), null,
|
||||
"fast shell reads must not flash a skeleton");
|
||||
await act(async () => mock.timers.tick(250));
|
||||
assert.ok(container.querySelector(".project-tree__empty-state .project-tree__skeleton"),
|
||||
"a delayed shell read shows a skeleton");
|
||||
}
|
||||
await act(async () => pending[0].resolve(snapshot(scenario.before)));
|
||||
await flush();
|
||||
assert.deepEqual(labels(), scenario.before.map(item => item.label));
|
||||
assert.equal(container.querySelector(".project-tree__empty-state .project-tree__skeleton"), null);
|
||||
|
||||
// A read started before the mutation retains the old shell contents.
|
||||
// Its scalar revision may equal the post-mutation read: this fixture
|
||||
@@ -114,6 +127,44 @@ try {
|
||||
await act(async () => root.unmount());
|
||||
}
|
||||
}
|
||||
|
||||
pending.length = 0;
|
||||
localStorage.clear();
|
||||
const emptyRoot = createRoot(container);
|
||||
try {
|
||||
await act(async () => emptyRoot.render(<LocaleProvider><ToastProvider><ProjectTree
|
||||
onOpenTopic={noop} onAddProject={addProject}
|
||||
/></ToastProvider></LocaleProvider>));
|
||||
await flush();
|
||||
assert.doesNotMatch(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/);
|
||||
await act(async () => pending[0].resolve(snapshot([])));
|
||||
await flush();
|
||||
assert.match(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/,
|
||||
"an empty answer restores the true empty state");
|
||||
} finally {
|
||||
await act(async () => emptyRoot.unmount());
|
||||
}
|
||||
|
||||
pending.length = 0;
|
||||
localStorage.clear();
|
||||
const failedRoot = createRoot(container);
|
||||
try {
|
||||
await act(async () => failedRoot.render(<LocaleProvider><ToastProvider><ProjectTree
|
||||
onOpenTopic={noop} onAddProject={addProject}
|
||||
/></ToastProvider></LocaleProvider>));
|
||||
await flush();
|
||||
assert.match(container.querySelector(".project-tree__empty-state")?.textContent ?? "", /读取|讀取|Reading/);
|
||||
await act(async () => pending[0].reject(new Error("snapshot unavailable")));
|
||||
await flush();
|
||||
assert.doesNotMatch(container.textContent ?? "", /读取项目|讀取專案|Reading projects/,
|
||||
"a failed first shell read must settle the loading state");
|
||||
assert.match(container.textContent ?? "", /Add new project|添加新项目|新增專案/,
|
||||
"the empty-state project action must remain available after a failed read");
|
||||
assert.match(container.textContent ?? "", /Remote connection|远程连接|遠端連線/,
|
||||
"the empty-state remote action must remain available after a failed read");
|
||||
} finally {
|
||||
await act(async () => failedRoot.unmount());
|
||||
}
|
||||
} finally {
|
||||
mock.timers.reset();
|
||||
dom.window.close();
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { initialState, reducer } from "../lib/useController";
|
||||
import { historyMessagesToItems } from "../lib/historyItems";
|
||||
import { historyNoticeItems } from "../lib/controllerNotices";
|
||||
import { presentError } from "../lib/errorPresentation";
|
||||
import { en } from "../locales/en";
|
||||
import type { HistoryMessage } from "../lib/types";
|
||||
|
||||
const started = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
|
||||
const failed = reducer(started, {
|
||||
@@ -25,3 +30,17 @@ assert.equal(
|
||||
"Connection ID: deepseek-anthropic\nRequest path: /anthropic/v1/chat/completions",
|
||||
"stable provider id and sanitized path stay in live diagnostic details",
|
||||
);
|
||||
|
||||
const diagnostic = { kind: "transport_protocol", transportCode: "PROTOCOL_ERROR" };
|
||||
const history: HistoryMessage = { role: "notice", content: "opaque provider failure", level: "warn", diagnostic };
|
||||
const live = reducer(started, { type: "event", e: { kind: "turn_done", err: history.content, diagnostic } });
|
||||
const interrupted = reducer(started, { type: "event", e: { kind: "turn_done", status: "interrupted", err: history.content, diagnostic } });
|
||||
const liveNotice = reducer(started, { type: "event", e: { kind: "notice", level: "warn", text: history.content, diagnostic } });
|
||||
for (const items of [live.items, interrupted.items, liveNotice.items, historyMessagesToItems([history], "full").items, historyNoticeItems(history, "windowed")]) {
|
||||
const item = items.find(item => item.kind === "notice" && item.level === "warn");
|
||||
assert.ok(item?.kind === "notice");
|
||||
assert.deepEqual(item.diagnostic, diagnostic, "live and both history projections retain structured evidence");
|
||||
const presentation = presentError(item.text, key => en[key], "en", item.diagnostic);
|
||||
assert.equal(presentation.summary, en["error.transportProtocol"]);
|
||||
assert.match(presentation.detail, /PROTOCOL_ERROR/);
|
||||
}
|
||||
@@ -391,6 +391,22 @@ await act(async () => {
|
||||
ok(Boolean(document.querySelector(".prompt-shelf--ask")) && document.body.textContent?.includes("remote answer failed") === true && document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.disabled === false, "a failed remote Ask answer preserves the card, surfaces the error, and re-enables retry"); failAnswer = false; await act(async () => { document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.click(); await flush(); }); ok(tape.filter((entry) => entry.startsWith("answer:tab-remote-1:ask-7:")).length === 2 && !document.querySelector(".prompt-shelf--ask"), "a successful remote Ask retry resubmits the complete answer and clears the card");
|
||||
}
|
||||
|
||||
// A CLI serve never binds desktop routing metadata, so its prompts carry no
|
||||
// runtimeEpoch; the serve fences on the epoch only when it has one.
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", turnId: "turn-main", ask: { id: "ask-serve", questions: [{ id: "q1", prompt: "Ship it?", options: [{ label: "ship" }, { label: "wait" }] }] } });
|
||||
await flush();
|
||||
});
|
||||
{
|
||||
ok(Boolean(document.querySelector(".prompt-shelf--ask")) && !document.body.textContent?.includes("exact request identity is unavailable"), "an ask without a runtime epoch is answerable, not refused as unidentified");
|
||||
await act(async () => {
|
||||
[...document.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent?.trim() === "ship")?.click();
|
||||
await flush();
|
||||
});
|
||||
await act(async () => { document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.click(); await flush(); });
|
||||
ok(tape.some((entry) => entry.startsWith("answer:tab-remote-1:ask-serve:")) && !document.querySelector(".prompt-shelf--ask"), "the answer reaches the serve and the card clears");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", turnId: "turn-main", runtimeEpoch: "runtime-main", ask: { id: "ask-custom", questions: [{ id: "q-custom", prompt: "Where?", options: [{ label: "staging" }] }] } });
|
||||
await flush();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { setAttentionPreference, setSuccessPreference } from "../lib/sound";
|
||||
import { createRuntimeNotifications } from "../lib/runtimeNotifications";
|
||||
import { t } from "../lib/i18n";
|
||||
|
||||
const stored = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem(key: string) { return stored.get(key) ?? null; },
|
||||
setItem(key: string, value: string) { stored.set(key, value); },
|
||||
removeItem(key: string) { stored.delete(key); },
|
||||
clear() { stored.clear(); },
|
||||
},
|
||||
});
|
||||
let contexts = 0;
|
||||
class FakeAudioContext {
|
||||
destination = {};
|
||||
constructor() { contexts++; }
|
||||
createOscillator() { return { type: "sine", frequency: { setValueAtTime() {} }, connect() {}, start() {}, stop() {} }; }
|
||||
createGain() {
|
||||
return { gain: { setValueAtTime() {}, linearRampToValueAtTime() {}, exponentialRampToValueAtTime() {} }, connect() {} };
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
Object.defineProperty(globalThis, "AudioContext", { configurable: true, value: FakeAudioContext });
|
||||
|
||||
const owner = createRuntimeNotifications(() => ({ activeTabId: "A", t, showToast() {} }));
|
||||
|
||||
function chimesFor(event: { err?: string; outcome?: string }, only: "success" | "attention"): number {
|
||||
setSuccessPreference(only === "success" ? "synth" : "off");
|
||||
setAttentionPreference(only === "attention" ? "synth" : "off");
|
||||
const before = contexts;
|
||||
owner.accept({ event: { kind: "turn_done", tabId: "A", ...event } });
|
||||
return contexts - before;
|
||||
}
|
||||
|
||||
const readiness = { err: "delivery checks are incomplete", outcome: "final_readiness" };
|
||||
assert.equal(chimesFor(readiness, "attention"), 1, "a turn that stopped for delivery checks must alert the user");
|
||||
assert.equal(chimesFor(readiness, "success"), 0, "a turn that stopped for delivery checks is not a success");
|
||||
for (const outcome of ["recovery_paused", "incomplete_read", "completion_uncertain"]) {
|
||||
assert.equal(chimesFor({ err: "paused", outcome }, "attention"), 1, `${outcome} waits on the user`);
|
||||
}
|
||||
assert.equal(chimesFor({}, "success"), 1, "a clean finish keeps the success chime");
|
||||
assert.equal(chimesFor({}, "attention"), 0, "a clean finish does not ring the attention chime");
|
||||
assert.equal(chimesFor({ err: "provider failed" }, "success") + chimesFor({ err: "provider failed" }, "attention"), 0,
|
||||
"an ordinary failure stays silent");
|
||||
owner.dispose();
|
||||
console.log("runtime notifications: paused turn outcomes ring the attention chime");
|
||||
@@ -35,7 +35,9 @@ try {
|
||||
assert.deepEqual([...document.querySelector("header")!.children].map(node => node.className),
|
||||
["topicbar__brand", "btn btn--small", "tooltip-trigger", "topicbar__identity", "topicbar__spacer", "topicbar__actions"],
|
||||
"the Windows bar leads with the app mark");
|
||||
assert.equal(document.querySelector(".topicbar__brand-logo")!.tagName, "IMG", "the leading mark is the app logo asset");
|
||||
const logo = document.querySelector(".topicbar__brand-logo")!;
|
||||
assert.equal(logo.getAttribute("role"), "img", "the leading mark is the app logo");
|
||||
assert.equal(logo.getAttribute("aria-label"), "Reasonix");
|
||||
await paint();
|
||||
const action = document.querySelector(".topicbar__actions button");
|
||||
assert.equal(document.querySelectorAll(".topicbar__subtitle .worktree-badge").length, 1);
|
||||
|
||||
@@ -87,7 +87,7 @@ export function projectConversation({ local, remote, tab, activeTabId, backgroun
|
||||
cacheMissTokens: runtime.usage?.cacheMissTokens, balance: runtime.balance,
|
||||
},
|
||||
context: {
|
||||
tabId: remote ? undefined : activeTabId,
|
||||
tabId: activeTabId,
|
||||
items: runtime.items, context: runtime.context, usage: runtime.usage,
|
||||
sessionTokens: runtime.sessionTokens, sessionCost: runtime.sessionCost,
|
||||
sessionCurrency: runtime.sessionCurrency, turnTokens: runtime.turnTotalTokens,
|
||||
|
||||
@@ -19,6 +19,26 @@ export function projectDecisionSurface(input: {
|
||||
workspaceConflict: WorkspaceConflictView | null;
|
||||
pendingClose: PendingClose;
|
||||
clearContextPending: boolean;
|
||||
remote?: boolean;
|
||||
}): AppDecisionSurfaceKind | null {
|
||||
// A remote session's prompts and forms are answered by RemoteSessionSurface,
|
||||
// the one owner of the serve's exact prompt route; a footer copy would be a
|
||||
// second card whose answer has no local session to reach.
|
||||
if (!input.remote) {
|
||||
const prompt = projectPromptSurface(input);
|
||||
if (prompt) return prompt;
|
||||
}
|
||||
if (input.workspaceConflict) return "workspace_conflict";
|
||||
if (input.pendingClose) return "close_active";
|
||||
if (input.clearContextPending) return "clear_context";
|
||||
return null;
|
||||
}
|
||||
|
||||
function projectPromptSurface(input: {
|
||||
approval: State["approval"];
|
||||
ask: State["ask"];
|
||||
mcpInteraction: State["mcpInteraction"];
|
||||
extensionForm: State["extensionForm"];
|
||||
}): AppDecisionSurfaceKind | null {
|
||||
if (input.approval) {
|
||||
return input.approval.tool === "exit_plan_mode" ? "plan_approval" : "tool_approval";
|
||||
@@ -26,8 +46,5 @@ export function projectDecisionSurface(input: {
|
||||
if (input.ask) return "ask";
|
||||
if (input.mcpInteraction) return "mcp_interaction";
|
||||
if (input.extensionForm) return "extension_form";
|
||||
if (input.workspaceConflict) return "workspace_conflict";
|
||||
if (input.pendingClose) return "close_active";
|
||||
if (input.clearContextPending) return "clear_context";
|
||||
return null;
|
||||
}
|
||||
@@ -345,8 +345,8 @@ export function useAppSessionComposition(input: AppSessionCompositionInput) {
|
||||
|
||||
const decisionSurface = useMemo((): AppDecisionSurfaceKind | null => projectDecisionSurface({
|
||||
approval: state.approval, ask: state.ask, mcpInteraction: state.mcpInteraction, extensionForm: state.extensionForm,
|
||||
workspaceConflict, pendingClose, clearContextPending,
|
||||
}), [clearContextPending, pendingClose, state.approval, state.ask, state.extensionForm, state.mcpInteraction, workspaceConflict]);
|
||||
workspaceConflict, pendingClose, clearContextPending, remote: remoteSurfaceActive,
|
||||
}), [clearContextPending, pendingClose, remoteSurfaceActive, state.approval, state.ask, state.extensionForm, state.mcpInteraction, workspaceConflict]);
|
||||
const visibleDecisionSurface = decisionSurface;
|
||||
// Navigation used to hide the entire composer until the controller/runtime
|
||||
// activation ticket and the transcript paint ticket both settled. That made
|
||||
|
||||
@@ -31,7 +31,7 @@ export function useRuntimeNotifications(activeTabId: string | undefined) {
|
||||
pending.current = [];
|
||||
};
|
||||
}, [readPorts]);
|
||||
const handleNotification = useCommittedCommand((event: AttentionChimeEvent & { err?: string }) => {
|
||||
const handleNotification = useCommittedCommand((event: AttentionChimeEvent & { err?: string; outcome?: string }) => {
|
||||
if (event.kind === "ask_request" || event.kind === "approval_request" || event.kind === "turn_done") accept({ event });
|
||||
});
|
||||
const resetLegacyAttention = useCommittedCommand((resetTabId?: string) => accept({ resetTabId }));
|
||||
|
||||
@@ -45,7 +45,9 @@ export function SidebarRegion(props: SidebarRegionProps) {
|
||||
</button>
|
||||
</div>
|
||||
<section className="sidebar__section sidebar__section--projects">
|
||||
<Suspense fallback={null}><ProjectTree {...props.projectTree} /></Suspense>
|
||||
<Suspense fallback={<div className="project-tree__empty project-tree__empty--subtle" role="status">{t("projectTree.loadingProjects")}</div>}>
|
||||
<ProjectTree {...props.projectTree} />
|
||||
</Suspense>
|
||||
</section>
|
||||
<nav className="sidebar__nav sidebar__nav--footer">
|
||||
<div className="sidebar__utility-row" aria-label={t("sidebar.utilityActions")}>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
import logoSymbol from "../assets/logo-symbol.svg";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { WorktreeBadge } from "../components/WorktreeBadge";
|
||||
|
||||
@@ -36,7 +35,7 @@ export function TopicbarRegion({ view, commands, children }: {
|
||||
const { sidebar, title, subtitle } = view;
|
||||
return <header className="topicbar">
|
||||
{view.brand && <div className="topicbar__brand">
|
||||
<img src={logoSymbol} alt="Reasonix" className="topicbar__brand-logo" draggable={false} />
|
||||
<span role="img" aria-label="Reasonix" className="topicbar__brand-logo" />
|
||||
</div>}
|
||||
{view.automationReturn && <button className="btn btn--small" type="button" onClick={event => {
|
||||
event.currentTarget.focus({ preventScroll: true });
|
||||
|
||||
@@ -144,7 +144,7 @@ function ChatNotice({ node, actions, scroll }: { node: Extract<ChatNode, { kind:
|
||||
// Empty delivery accounting is not a chat result. Keep meaningful records in details.
|
||||
if (summary && !summary.mutations && !summary.changed_files && !summary.checks_passed && !summary.checks_failed) return null;
|
||||
if (item.level === "warn" || item.action === "recover_context") return <div className="chat-notice" role="status" data-level={item.level}>
|
||||
{item.title && <strong>{item.title} </strong>}<ErrorMessage error={item.text} />
|
||||
{item.title && <strong>{item.title} </strong>}<ErrorMessage error={item.text} diagnostic={item.diagnostic} />
|
||||
{summary && <details className="chat-notice__details"><summary>{t("chat.details")}</summary><pre>{JSON.stringify(summary, null, 2)}</pre></details>}
|
||||
{item.detail && <ChatDisclosure label={t("chat.details")}><pre>{item.detail}</pre></ChatDisclosure>}
|
||||
{item.action === "recover_context" && item.recoveryId && <button className="btn" onClick={() => actions.recover(item.recoveryId!)}>{t("notice.protocolRecoveryAction")}</button>}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { presentError } from "../lib/errorPresentation";
|
||||
import type { WireEvent } from "../lib/types";
|
||||
|
||||
/** Inline-safe error surface, including inside paragraphs and existing banners. */
|
||||
export function ErrorMessage({ error, summary, onInspect }: { error: unknown; summary?: string; onInspect?: () => void }) {
|
||||
export function ErrorMessage({ error, summary, diagnostic, onInspect }: { error: unknown; summary?: string; diagnostic?: WireEvent["diagnostic"]; onInspect?: () => void }) {
|
||||
const { t, locale } = useI18n();
|
||||
const id = useId();
|
||||
const [expandedError, setExpandedError] = useState<{ error: unknown } | null>(null);
|
||||
const presentation = presentError(error, t, locale);
|
||||
const presentation = presentError(error, t, locale, diagnostic);
|
||||
const expanded = expandedError !== null && expandedError.error === error;
|
||||
const title = summary || presentation.summary;
|
||||
const detail = error == null || error === "" ? "" : presentation.detail || (summary ? presentation.summary : "");
|
||||
|
||||
@@ -128,6 +128,7 @@ export function ProjectTree({
|
||||
const compactTopics = variant === "workbench";
|
||||
const creationTopics = variant === "creation";
|
||||
const [tree, setTree] = useState<ProjectNode[]>([]);
|
||||
const [shellStage, setShellStage] = useState<"loading" | "slow" | "ready">("loading");
|
||||
const treeRef = useRef<ProjectNode[]>([]);
|
||||
const latestRevisionRef = useRef(0);
|
||||
const shellRequestRef = useRef(0);
|
||||
@@ -490,10 +491,11 @@ export function ProjectTree({
|
||||
})));
|
||||
await reloadRequestedProjects(projects);
|
||||
} catch (err) {
|
||||
// A shell snapshot is metadata-only. If it fails, the resident folder
|
||||
// identity can still drive the requested canonical topic reload.
|
||||
// A failed shell read can still reload topics from resident folders.
|
||||
if (request === shellRequestRef.current) await reloadRequestedProjects(treeRef.current);
|
||||
if (throwOnSnapshotError) throw err;
|
||||
} finally {
|
||||
if (request === shellRequestRef.current) setShellStage("ready");
|
||||
}
|
||||
}, [applyRuntimeProjection, reloadProjectTopicLists]);
|
||||
refreshRef.current = refresh;
|
||||
@@ -561,6 +563,10 @@ export function ProjectTree({
|
||||
return () => { shellRequestRef.current++; };
|
||||
}, [refresh, refreshSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shellStage !== "loading") return;
|
||||
const timer = setTimeout(() => setShellStage("slow"), 250); return () => clearTimeout(timer);
|
||||
}, [shellStage]);
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let latest: Parameters<Parameters<typeof onProjectTreeChangedV2>[0]>[0] | undefined;
|
||||
@@ -2010,6 +2016,11 @@ export function ProjectTree({
|
||||
);
|
||||
|
||||
const renderEmptyState = () => {
|
||||
if (shellStage !== "ready") return <div className="project-tree__empty-state" role="status"><div className="project-tree__empty project-tree__empty--subtle">{t("projectTree.loadingProjects")}</div>
|
||||
{shellStage === "slow" && <div className="project-tree__skeleton" aria-hidden="true">
|
||||
<span className="project-tree__skeleton-bar" /><span className="project-tree__skeleton-bar project-tree__skeleton-bar--short" />
|
||||
</div>}
|
||||
</div>;
|
||||
if (query.trim()) return <div className="project-tree__empty">{t("projectTree.emptyNoMatch")}</div>;
|
||||
return (
|
||||
<div className="project-tree__empty-state">
|
||||
|
||||
@@ -313,7 +313,10 @@ export function useProjectTreeOrganization({
|
||||
const position = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
|
||||
const folder = tree.find(folder => projectTreeOrganizationKey(folder) === key);
|
||||
const dragged = folder?.children?.find(row => projectSessionIdentity(row) === draggedID);
|
||||
if (bindings.UpdateSessionOrganization && dragged) {
|
||||
const targetGroup = (groupsRef.current[key] ?? []).find((group) => projectTreeGroupContainsNode(group, node));
|
||||
if (dragged && targetGroup && !projectTreeGroupContainsNode(targetGroup, dragged)) {
|
||||
mutateGroups(key, (groups) => moveNodeToGroup(groups, dragged, targetGroup.id), { kind: "set-group", target: projectNodeSelector(dragged), groupId: targetGroup.id });
|
||||
} else if (bindings.UpdateSessionOrganization && dragged) {
|
||||
void mutateSessionOrganization(bindings, { scope: context.scope, workspaceRoot: context.root, hostId: splitOrganizationKey(key).hostId },
|
||||
{ kind: "move", target: projectNodeSelector(dragged), anchor: projectNodeSelector(node), position })
|
||||
.then(saved => { setOrdersByKey(current => ({ ...current, [key]: saved.order })); return refresh({ reloadAllTopics: true }); })
|
||||
@@ -326,7 +329,7 @@ export function useProjectTreeOrganization({
|
||||
};
|
||||
props.onDragEnd = clearTopicDrag;
|
||||
return { className, props };
|
||||
}, [bindings, clearTopicDrag, dragTopicID, dropTopic, loadGroups, onTopicsChanged, refresh, showToast, tree]);
|
||||
}, [bindings, clearTopicDrag, dragTopicID, dropTopic, loadGroups, mutateGroups, onTopicsChanged, refresh, showToast, tree]);
|
||||
|
||||
const removeTopicFromGroups = useCallback((node: ProjectNode) => {
|
||||
const key = projectTreeOrganizationKey(node);
|
||||
|
||||
@@ -45,7 +45,9 @@ export function RemoteSessionSurface({ tab, session, surfaceCommitToken, onSurfa
|
||||
const visiblePrompt = approval || ask || mcpInteraction;
|
||||
const promptUpgradeRequired = Boolean(visiblePrompt && !tab.interactionTargetSupported);
|
||||
const promptIdentityUnavailable = Boolean(visiblePrompt && tab.interactionTargetSupported && (
|
||||
!tab.remote?.hostId || !tab.sessionId || !hasSessionGeneration(tab.sessionGeneration) || !visiblePrompt.turnId || !visiblePrompt.runtimeEpoch
|
||||
// runtimeEpoch stays optional: a serve fences on it only when it has one,
|
||||
// and a CLI serve never binds desktop routing metadata.
|
||||
!tab.remote?.hostId || !tab.sessionId || !hasSessionGeneration(tab.sessionGeneration) || !visiblePrompt.turnId
|
||||
));
|
||||
const promptActionDisabled = promptUpgradeRequired || promptIdentityUnavailable;
|
||||
const formUpgradeRequired = Boolean(extensionForm && !tab.extensionFormInstanceSupported);
|
||||
|
||||
@@ -6652,6 +6652,19 @@ function HooksSection({ onChanged }: { onChanged: (settings?: SettingsView | nul
|
||||
setPathMessage(t("settings.hooksJsonClipboardUnavailable"));
|
||||
}
|
||||
};
|
||||
const approve = async () => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await app.TrustProjectHooksForRoot(view?.projectRoot?.trim() ?? "");
|
||||
await load(scope);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setErr(String((e as Error)?.message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
@@ -6694,6 +6707,12 @@ function HooksSection({ onChanged }: { onChanged: (settings?: SettingsView | nul
|
||||
title={t("settings.hooks")}
|
||||
description={scope === "project" ? t("settings.hooksProjectHint") : t("settings.hooksGlobalHint")}
|
||||
>
|
||||
{view && scope === "project" && !view.trusted && view.hooks.length > 0 && (
|
||||
<div className="banner banner--warning" role="status">
|
||||
<span>{t("settings.hooksAwaitingApproval")}</span>
|
||||
<button className="btn btn--small" disabled={busy} onClick={() => void approve()}>{t("settings.hooksApprove")}</button>
|
||||
</div>
|
||||
)}
|
||||
{view && (
|
||||
<div className="hooks-json-panel">
|
||||
<div className="hooks-json-panel__head">
|
||||
|
||||
@@ -45032,6 +45032,13 @@
|
||||
"type": {
|
||||
"kind": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "transportCode",
|
||||
"optional": true,
|
||||
"type": {
|
||||
"kind": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
export const DESKTOP_PROTOCOL_VERSION = 11;
|
||||
|
||||
export const DESKTOP_CONTRACT_DIGEST = "sha256:857039e0868d57e1e367b080f009abd2d21baa856ae5d57e55fc8c3b1cef5734";
|
||||
export const DESKTOP_CONTRACT_DIGEST = "sha256:b4fd48e18f5b4e766fb0da34f9d382961a0f9e07ee8385ac7b387babf63fa6e0";
|
||||
|
||||
export const DESKTOP_COMMANDS = [
|
||||
"AIRenameSession",
|
||||
@@ -5202,6 +5202,7 @@ export interface FailureDiagnostic {
|
||||
providerDisplayName?: string;
|
||||
protocol?: string;
|
||||
requestPath?: string;
|
||||
transportCode?: string;
|
||||
}
|
||||
|
||||
export interface MCPAppPresentation {
|
||||
|
||||
@@ -4672,10 +4672,10 @@ function makeMockApp(): MockAppBindings {
|
||||
hookSettings[key].hooks = JSON.parse(JSON.stringify(hooks)) as HookConfigView[];
|
||||
},
|
||||
async TrustProjectHooks() {
|
||||
// Compatibility no-op: project hooks are enabled automatically.
|
||||
hookSettings.project.trusted = true;
|
||||
},
|
||||
async TrustProjectHooksForRoot(_projectRoot: string) {
|
||||
// Compatibility no-op: project hooks are enabled automatically.
|
||||
hookSettings.project.trusted = true;
|
||||
},
|
||||
async SetDefaultModel(ref: string) {
|
||||
settings.defaultModel = ref;
|
||||
|
||||
@@ -4,12 +4,12 @@ import type { HistoryMessage, WireFinalReadiness, WireDecisionReceipt } from "./
|
||||
import type { Item } from "./useController";
|
||||
import { readPauseItem } from "./readPause";
|
||||
|
||||
export function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt): { items: Item[]; seq: number } {
|
||||
export function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt, diagnostic?: HistoryMessage["diagnostic"]): { items: Item[]; seq: number } {
|
||||
if (quietTranscriptNoticeKey(rawText, code)) return { items, seq };
|
||||
const text = localizedNoticeText(rawText, code);
|
||||
if (quietTranscriptNoticeKey(text, code)) return { items, seq };
|
||||
const trimmedDetail = detail?.trim();
|
||||
return { items: [...items, { kind: "notice", id, level, text, ...(trimmedDetail ? { detail: trimmedDetail } : {}), ...(code ? { code } : {}), ...(decisionReceipt ? { decisionReceipt } : {}) }], seq: seq + 1 };
|
||||
return { items: [...items, { kind: "notice", id, level, text, ...(diagnostic ? { diagnostic } : {}), ...(trimmedDetail ? { detail: trimmedDetail } : {}), ...(code ? { code } : {}), ...(decisionReceipt ? { decisionReceipt } : {}) }], seq: seq + 1 };
|
||||
}
|
||||
|
||||
export function errorMessage(err: unknown): string {
|
||||
@@ -190,5 +190,5 @@ export function historyNoticeItems(m: HistoryMessage, id: string): Item[] {
|
||||
if (quietTranscriptNoticeKey(text, m.code)) return [];
|
||||
const detail = m.detail?.trim();
|
||||
return [{ kind: "notice", id, level: m.level === "warn" ? "warn" : "info", text,
|
||||
...(detail ? { detail } : {}), ...(m.decisionReceipt ? { decisionReceipt: m.decisionReceipt } : {}) }];
|
||||
...(m.diagnostic ? { diagnostic: m.diagnostic } : {}), ...(detail ? { detail } : {}), ...(m.decisionReceipt ? { decisionReceipt: m.decisionReceipt } : {}) }];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t, getLocale, type DictKey, type Translator, type Locale } from "./i18n";
|
||||
import type { WireEvent } from "./types";
|
||||
|
||||
export interface ErrorPresentation { summary: string; detail: string }
|
||||
|
||||
@@ -24,6 +25,7 @@ const codeKeys: Record<string, DictKey> = {
|
||||
invalid_api_key: "error.auth", insufficient_quota: "error.quota", rate_limit_exceeded: "error.rateLimit",
|
||||
stream_interrupted: "error.interrupted", empty_response: "error.empty", stale_generation: "error.conflict",
|
||||
provider_connection: "error.connection", cancelled: "error.cancelled",
|
||||
transport_protocol: "error.transportProtocol",
|
||||
};
|
||||
|
||||
// Narrow legacy matches bridge errors from older services and OS libraries.
|
||||
@@ -61,8 +63,11 @@ function statusKey(status: number): DictKey | undefined {
|
||||
if (status === 400 || status === 422) return "error.invalidInput";
|
||||
}
|
||||
|
||||
export function presentError(error: unknown, translate: Translator = t, locale: Locale = getLocale()): ErrorPresentation {
|
||||
export function presentError(error: unknown, translate: Translator = t, locale: Locale = getLocale(), diagnostic?: WireEvent["diagnostic"]): ErrorPresentation {
|
||||
const detail = errorDetail(error).trim();
|
||||
if (diagnostic?.kind === "transport_protocol") {
|
||||
return { summary: translate("error.transportProtocol"), detail: [detail, diagnostic.transportCode ? `HTTP/2: ${diagnostic.transportCode}` : ""].filter(Boolean).join("\n") };
|
||||
}
|
||||
const structured = error && typeof error === "object" ? error as { code?: unknown; status?: unknown } : undefined;
|
||||
const code = typeof structured?.code === "string" ? structured.code : detail;
|
||||
const explicitStatus = typeof structured?.status === "number" ? structured.status : 0;
|
||||
|
||||
@@ -102,7 +102,7 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
|
||||
continue;
|
||||
}
|
||||
if (m.content.trim() !== "" || m.decisionReceipt) {
|
||||
const next = appendNoticeItem(items, seq, recordItemId, m.level === "warn" ? "warn" : "info", m.content, m.detail, m.code, m.decisionReceipt);
|
||||
const next = appendNoticeItem(items, seq, recordItemId, m.level === "warn" ? "warn" : "info", m.content, m.detail, m.code, m.decisionReceipt, m.diagnostic);
|
||||
items = next.items;
|
||||
seq = next.seq;
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export async function loadHistoryWindow(input: LoadInput): Promise<HistoryWindow
|
||||
return "empty";
|
||||
}
|
||||
if (!result) { input.dispatch({ type: "history_older_error", error: "history page unavailable" }); return "empty"; }
|
||||
if (!fingerprintMatches(expectedRevision, result.revisionKnown ? result.revision : undefined) || !digestMatches(expectedDigest, result.digest)) {
|
||||
if (result.kind !== "reload" && (!fingerprintMatches(expectedRevision, result.revisionKnown ? result.revision : undefined) || !digestMatches(expectedDigest, result.digest))) {
|
||||
input.dispatch({ type: "history_older_error", error: "history identity changed" });
|
||||
return "empty";
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ import { attentionChimeEventKey, clearAttentionChimeKeys, playAttentionChime, pl
|
||||
import type { Translator } from "./i18n";
|
||||
import type { ToastContextValue } from "./toast";
|
||||
|
||||
export type NotificationOperation = { event: AttentionChimeEvent & { err?: string } } | { resetTabId?: string };
|
||||
export type NotificationOperation = { event: AttentionChimeEvent & { err?: string; outcome?: string } } | { resetTabId?: string };
|
||||
|
||||
// Outcomes where the turn stopped to wait on the user; the host records them as
|
||||
// paused or awaiting_delivery (desktop/topic_status.go), never as a finish.
|
||||
const PAUSED_TURN_OUTCOMES = new Set(["final_readiness", "recovery_paused", "incomplete_read", "completion_uncertain"]);
|
||||
type NotificationPorts = { activeTabId: string | undefined; t: Translator; showToast: ToastContextValue["showToast"] };
|
||||
|
||||
/** One owner deduplicates view events and authoritative background snapshots. */
|
||||
@@ -72,7 +76,9 @@ export function createRuntimeNotifications(readPorts: () => NotificationPorts |
|
||||
clearAttentionChimeKeys(seen, operation.resetTabId);
|
||||
clearAttentionChimeKeys(pendingSeen, operation.resetTabId);
|
||||
} else if (operation.event.kind === "turn_done") {
|
||||
if (readPorts() && !operation.event.err) playSuccessChime();
|
||||
if (!readPorts()) return;
|
||||
if (PAUSED_TURN_OUTCOMES.has(operation.event.outcome ?? "")) playAttentionChime();
|
||||
else if (!operation.event.err) playSuccessChime();
|
||||
} else handleAttention(operation.event);
|
||||
},
|
||||
start() { stop = runtimeStateStore.subscribe(handleSnapshot); handleSnapshot(); },
|
||||
|
||||
@@ -449,7 +449,7 @@ export interface WireEvent extends RecoveryEventFields {
|
||||
outcome?: "completed" | "partial" | "blocked" | "final_readiness" | "recovery_paused" | "completion_uncertain" | "incomplete_read";
|
||||
readiness?: WireFinalReadiness;
|
||||
protocolRecovery?: { id: string };
|
||||
diagnostic?: { kind: string; status?: number; traceId?: string; providerId?: string; providerDisplayName?: string; protocol?: string; requestPath?: string };
|
||||
diagnostic?: { kind: string; status?: number; traceId?: string; providerId?: string; providerDisplayName?: string; protocol?: string; requestPath?: string; transportCode?: string };
|
||||
/** Optional: "headers" | "stream". Older clients ignore unknown fields. */
|
||||
retryScope?: "headers" | "stream" | "protocol";
|
||||
streamAttempt?: WireStreamAttempt;
|
||||
@@ -856,7 +856,7 @@ export interface HistoryMessage extends TranscriptTurnMetadata {
|
||||
decisionReceipt?: WireDecisionReceipt;
|
||||
readiness?: WireFinalReadiness;
|
||||
protocolRecovery?: { id: string };
|
||||
diagnostic?: { kind: string; status?: number; traceId?: string; providerId?: string; providerDisplayName?: string; protocol?: string; requestPath?: string };
|
||||
diagnostic?: { kind: string; status?: number; traceId?: string; providerId?: string; providerDisplayName?: string; protocol?: string; requestPath?: string; transportCode?: string };
|
||||
serverSearch?: HistoryServerSearch[];
|
||||
attachments?: Array<{ kind?: string; digest?: string; name?: string; mime?: string; width?: number; height?: number; bytes?: number }>;
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@ export type Item = { turnId?: string } & (
|
||||
| { kind: "user"; id: string; messageId?: string; submissionId?: string; submissionState?: "sending" | "confirmed" | "failed" | "unknown"; text: string; submitText?: string; failed?: boolean; createdAt?: number; checkpointTurn?: number; historyTurn?: number }
|
||||
| { kind: "assistant"; id: string; text: string; reasoning: string; streaming: boolean; turnFinal?: boolean; samplingCount?: number; toolCount?: number; wasStreamed?: true; reasoningComplete?: boolean; reasoningDurationMs?: number; workDurationMs?: number; turnDurationMs?: number; turnUsage?: TurnUsage; tokensPerSecond?: number; createdAt?: number; memoryCitations?: MemoryCitation[]; searchSources?: SearchSource[] }
|
||||
| { kind: "phase"; id: string; text: string }
|
||||
| { kind: "notice"; id: string; local?: boolean; level: "info" | "warn"; text: string; detail?: string; code?: string; title?: string; variant?: "delivery" | "completion"; action?: "continue_delivery" | "open_changes" | "recover_context"; recoveryId?: string; completionSummary?: WireCompletionSummary; decisionReceipt?: WireDecisionReceipt; missing?: string[]; inboxItemId?: string }
|
||||
| { kind: "notice"; id: string; local?: boolean; level: "info" | "warn"; text: string; detail?: string; diagnostic?: WireEvent["diagnostic"]; code?: string; title?: string; variant?: "delivery" | "completion"; action?: "continue_delivery" | "open_changes" | "recover_context"; recoveryId?: string; completionSummary?: WireCompletionSummary; decisionReceipt?: WireDecisionReceipt; missing?: string[]; inboxItemId?: string }
|
||||
| {
|
||||
kind: "compaction";
|
||||
id: string;
|
||||
@@ -1646,7 +1646,7 @@ function applyEvent(s: State, e: WireEvent, preserveToolPayloads = false): State
|
||||
case "notice": {
|
||||
const noticeId = e.code === "unapplied_steer" && e.messageId ? `he:m:${e.messageId}` : undefined;
|
||||
if (noticeId && s.items.some(item => item.id === noticeId)) return s;
|
||||
const next = appendNoticeToState(s, e.level ?? "info", e.text ?? "", e.detail, e.code, e.decisionReceipt, noticeId);
|
||||
const next = appendNoticeToState(s, e.level ?? "info", e.text ?? "", e.detail, e.code, e.decisionReceipt, noticeId, e.diagnostic);
|
||||
return e.code?.startsWith("stream_interrupted_") ? { ...next, streamInterruptNoticeShown: true } : next;
|
||||
}
|
||||
case "context_maintenance": {
|
||||
@@ -1815,11 +1815,11 @@ function applyEvent(s: State, e: WireEvent, preserveToolPayloads = false): State
|
||||
});
|
||||
}
|
||||
if (e.err && e.diagnostic && e.diagnostic.kind !== "cancelled" && !s.streamInterruptNoticeShown) {
|
||||
interruptItems.push({ kind: "notice", id: `e${s.seq + interruptItems.length}`, level: "warn", text: e.err, detail: e.detail });
|
||||
interruptItems.push({ kind: "notice", id: `e${s.seq + interruptItems.length}`, level: "warn", text: e.err, detail: e.detail, ...(e.diagnostic ? { diagnostic: e.diagnostic } : {}) });
|
||||
}
|
||||
items = [...finalized, ...interruptItems];
|
||||
} else if (e.err && !s.streamInterruptNoticeShown) {
|
||||
items = [...finalized, { kind: "notice", id: `e${s.seq}`, level: "warn", text: e.err, detail: e.detail }];
|
||||
items = [...finalized, { kind: "notice", id: `e${s.seq}`, level: "warn", text: e.err, detail: e.detail, ...(e.diagnostic ? { diagnostic: e.diagnostic } : {}) }];
|
||||
}
|
||||
if (e.protocolRecovery?.id && e.status !== "interrupted" && !s.cancelRequested) {
|
||||
items = items.map(item => item.kind==="notice" && item.action==="recover_context" ? {...item,action:undefined} : item);
|
||||
@@ -2318,8 +2318,8 @@ function getOrCreateState(states: TabStates, tabId: string): State {
|
||||
return states.get(tabId)!;
|
||||
}
|
||||
|
||||
function appendNoticeToState(s: State, level: "info" | "warn", text: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt, id?: string): State {
|
||||
const next = appendNoticeItem(s.items, s.seq, id ?? `n${s.seq}`, level, text, detail, code, decisionReceipt);
|
||||
function appendNoticeToState(s: State, level: "info" | "warn", text: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt, id?: string, diagnostic?: WireEvent["diagnostic"]): State {
|
||||
const next = appendNoticeItem(s.items, s.seq, id ?? `n${s.seq}`, level, text, detail, code, decisionReceipt, diagnostic);
|
||||
return { ...s, running: s.turnActive ? s.running : false, seq: next.seq, items: next.items };
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
export const en = {
|
||||
"error.unknown": "An error occurred. Open details to view the reported cause.",
|
||||
"error.connection": "Could not connect. Check the network, proxy and service address.",
|
||||
"error.transportProtocol": "The HTTP/2 connection failed with a protocol error. Check the service or proxy compatibility; expand the details for error information.",
|
||||
"error.dns": "The server address could not be resolved. Check the address and DNS settings.",
|
||||
"error.interrupted": "The connection was interrupted. Check the network or service before trying again.",
|
||||
"error.timeout": "The request timed out. Check the service and connection before trying again.",
|
||||
@@ -1683,6 +1684,7 @@ export const en = {
|
||||
"projectTree.colorPink": "Pink",
|
||||
"projectTree.emptyNoMatch": "No matching projects or sessions",
|
||||
"projectTree.emptyNoProjects": "No projects yet",
|
||||
"projectTree.loadingProjects": "Reading projects…",
|
||||
"projectTree.newTopicTooltip": "New session",
|
||||
"projectTree.addProjectTooltip": "Add new project",
|
||||
"projectTree.remoteOpenWindow": "Open in remote window",
|
||||
@@ -3020,6 +3022,8 @@ export const en = {
|
||||
"settings.hooksPathUnavailable": "No project settings file is available",
|
||||
"settings.hooksGlobalHint": "Saved globally. Restart Reasonix to load new hooks.",
|
||||
"settings.hooksProjectHint": "Saved in this workspace. Restart Reasonix to load new hooks.",
|
||||
"settings.hooksAwaitingApproval": "These hooks came with this workspace and stay off until you approve them as they are now. Any later change needs approval again.",
|
||||
"settings.hooksApprove": "Approve",
|
||||
"settings.hooksJsonTitle": "JSON config",
|
||||
"settings.hooksJsonHint": "Edit the {\"hooks\": ...} block from settings.json directly. Arrays with event fields are also accepted; saving validates and formats the JSON.",
|
||||
"settings.hooksJsonCopy": "Copy JSON",
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { DictKey } from "./en";
|
||||
export const zhTW: Record<DictKey, string> = {
|
||||
"error.unknown": "操作發生問題,請展開詳情查看原始錯誤。",
|
||||
"error.connection": "無法連線至服務,請檢查網路、代理和服務位址。",
|
||||
"error.transportProtocol": "HTTP/2 連線發生協定錯誤,請檢查服務端或代理的相容性,展開詳情可查看錯誤資訊。",
|
||||
"error.dns": "無法解析伺服器位址,請檢查位址和 DNS 設定。",
|
||||
"error.interrupted": "連線意外中斷,請檢查網路或服務狀態後再試。",
|
||||
"error.timeout": "請求逾時,請檢查服務和網路連線後再試。",
|
||||
@@ -1437,6 +1438,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"projectTree.colorPink": "粉色",
|
||||
"projectTree.emptyNoMatch": "沒有匹配的專案或會話",
|
||||
"projectTree.emptyNoProjects": "還沒有專案",
|
||||
"projectTree.loadingProjects": "正在讀取專案…",
|
||||
"projectTree.newTopicTooltip": "新建會話",
|
||||
"projectTree.addProjectTooltip": "新增專案",
|
||||
"projectTree.remoteOpenWindow": "在遠端視窗開啟",
|
||||
@@ -3507,6 +3509,8 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.hooksPathUnavailable": "沒有可用的專案設定檔案",
|
||||
"settings.hooksGlobalHint": "儲存為全域性配置。重啟 Reasonix 後載入新的 hooks。",
|
||||
"settings.hooksProjectHint": "儲存在當前工作區。重啟 Reasonix 後載入新的 hooks。",
|
||||
"settings.hooksAwaitingApproval": "這些 hooks 隨工作區而來,在你核准目前內容之前不會執行;之後有任何變動都需要重新核准。",
|
||||
"settings.hooksApprove": "核准",
|
||||
"settings.hooksJsonTitle": "JSON 配置",
|
||||
"settings.hooksJsonHint": "直接編輯 settings.json 中的 {\"hooks\": ...}。也支援貼上帶 event 欄位的 hook 陣列;儲存前會校驗並格式化。",
|
||||
"settings.hooksJsonCopy": "複製 JSON",
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { DictKey } from "./en";
|
||||
export const zh: Record<DictKey, string> = {
|
||||
"error.unknown": "操作出现问题,请展开详情查看原始报错。",
|
||||
"error.connection": "无法连接服务,请检查网络、代理和服务地址。",
|
||||
"error.transportProtocol": "HTTP/2 连接发生协议错误,请检查服务端或代理的兼容性,展开详情可查看错误信息。",
|
||||
"error.dns": "无法解析服务器地址,请检查地址和 DNS 设置。",
|
||||
"error.interrupted": "连接意外中断,请检查网络或服务状态后再试。",
|
||||
"error.timeout": "请求超时,请检查服务和网络连接后再试。",
|
||||
@@ -1684,6 +1685,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"projectTree.colorPink": "粉色",
|
||||
"projectTree.emptyNoMatch": "没有匹配的项目或会话",
|
||||
"projectTree.emptyNoProjects": "还没有项目",
|
||||
"projectTree.loadingProjects": "正在读取项目…",
|
||||
"projectTree.newTopicTooltip": "新建会话",
|
||||
"projectTree.addProjectTooltip": "添加新项目",
|
||||
"projectTree.remoteOpenWindow": "在远程窗口打开",
|
||||
@@ -3023,6 +3025,8 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.hooksPathUnavailable": "没有可用的项目设置文件",
|
||||
"settings.hooksGlobalHint": "保存为全局配置。重启 Reasonix 后加载新的 hooks。",
|
||||
"settings.hooksProjectHint": "保存在当前工作区。重启 Reasonix 后加载新的 hooks。",
|
||||
"settings.hooksAwaitingApproval": "这些 hooks 随工作区而来,在你批准当前内容之前不会运行;之后有任何改动都需要重新批准。",
|
||||
"settings.hooksApprove": "批准",
|
||||
"settings.hooksJsonTitle": "JSON 配置",
|
||||
"settings.hooksJsonHint": "直接编辑 settings.json 中的 {\"hooks\": ...}。也支持粘贴带 event 字段的 hook 数组;保存前会校验并格式化。",
|
||||
"settings.hooksJsonCopy": "复制 JSON",
|
||||
|
||||
@@ -22277,7 +22277,9 @@ body > .mermaid-diagram--fullscreen {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
background: var(--accent);
|
||||
-webkit-mask: url("./assets/logo-symbol.svg") no-repeat center / contain;
|
||||
mask: url("./assets/logo-symbol.svg") no-repeat center / contain;
|
||||
}
|
||||
.app--windows-frameless .sidebar__head,
|
||||
.app--windows-frameless .sidebar__brand,
|
||||
|
||||
+5
-5
@@ -35,7 +35,7 @@ require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.2 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
@@ -45,13 +45,13 @@ require (
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/klauspost/compress v1.18.7 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.11.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-pointer v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.29 // indirect
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0-pre.2 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.30 // indirect
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||
github.com/pkg/sftp v1.13.11 // indirect
|
||||
@@ -77,7 +77,7 @@ require (
|
||||
modernc.org/libc v1.75.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
mvdan.cc/sh/v3 v3.14.0 // indirect
|
||||
mvdan.cc/sh/v3 v3.14.1 // indirect
|
||||
)
|
||||
|
||||
replace reasonix => ../
|
||||
|
||||
+10
-10
@@ -10,8 +10,8 @@ github.com/UserExistsError/conpty v0.1.4 h1:+3FhJhiqhyEJa+K5qaK3/w6w+sN3Nh9O9VbJ
|
||||
github.com/UserExistsError/conpty v0.1.4/go.mod h1:PDglKIkX3O/2xVk0MV9a6bCWxRmPVfxqZoTG/5sSd9I=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.2 h1:eF7W7HWKg3z9NrWV9pTLnNeoXaqq3Tq9DNKXVMfoCnw=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
@@ -56,8 +56,8 @@ github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRg
|
||||
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
|
||||
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -70,10 +70,10 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
|
||||
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
|
||||
github.com/mattn/go-runewidth v0.0.29 h1:3oGF3R/S2N9DQ3ptftzVIvg2eicmojCzlwBEmqEPDfQ=
|
||||
github.com/mattn/go-runewidth v0.0.29/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0-pre.2 h1:Sxti6bD15KmHiTqcJ54nt9G6885m8DVwAxZX6Iu4Kjc=
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0-pre.2/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
|
||||
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
@@ -228,5 +228,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
mvdan.cc/sh/v3 v3.14.0 h1:wsPjcfrSXLJomdxwhVZqZ5j5Cue68l549Aj/DCqzbpc=
|
||||
mvdan.cc/sh/v3 v3.14.0/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo=
|
||||
mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI=
|
||||
mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo=
|
||||
@@ -5,11 +5,14 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/desktop/internal/workspacestate"
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/session"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
// Explicit source archives stage content as archived from the beginning. They
|
||||
@@ -38,11 +41,33 @@ func (a *App) archiveHistoricalSource(selector SessionSelector) (SessionMutation
|
||||
slog.Warn("desktop: historical archive failed", "source_key", id, "operation", operationID, "err", err)
|
||||
return SessionMutationResult{}, sessionOperationErrorForTarget(err, id, operationID)
|
||||
}
|
||||
archivedPaths := []string{source.path}
|
||||
siblings := legacyHeadVersions(source)
|
||||
for _, path := range recoveredLegacySiblings(source) {
|
||||
siblings = append(siblings, SessionSourceRef{HostID: localDesktopHostID, Path: path})
|
||||
}
|
||||
for _, sibling := range siblings {
|
||||
siblingID, siblingSource, err := a.historicalSourceForSelector(SessionSelector{Source: &sibling})
|
||||
if err != nil {
|
||||
slog.Warn("desktop: sibling archive skipped", "source_key", id, "err", err)
|
||||
continue
|
||||
}
|
||||
siblingResult, err := a.archiveHistoricalSourceWithOperation(ctx, siblingID, siblingSource,
|
||||
"archive-source-"+strings.TrimPrefix(newTabID(), "tab_"))
|
||||
if err != nil {
|
||||
slog.Warn("desktop: sibling archive failed", "source_key", siblingID, "err", err)
|
||||
continue
|
||||
}
|
||||
result.IdentityAliases = append(result.IdentityAliases, siblingResult.IdentityAliases...)
|
||||
archivedPaths = append(archivedPaths, siblingSource.path)
|
||||
}
|
||||
c := &a.historicalImports
|
||||
c.mu.Lock()
|
||||
for i := range c.catalog {
|
||||
if c.catalog[i].node.Source != nil && sameDesktopPath(c.catalog[i].node.Source.Path, source.path) {
|
||||
c.catalog[i].sourceChanged = false
|
||||
for _, path := range archivedPaths {
|
||||
if c.catalog[i].node.Source != nil && sameDesktopPath(c.catalog[i].node.Source.Path, path) {
|
||||
c.catalog[i].sourceChanged = false
|
||||
}
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
@@ -50,6 +75,81 @@ func (a *App) archiveHistoricalSource(selector SessionSelector) (SessionMutation
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// recoveredLegacySiblings lists the other recovery copies of the conversation a
|
||||
// recovered legacy source was copied from: same directory, recovered, and the
|
||||
// same parent in the metadata their writer recorded. The sidebar lists each
|
||||
// copy as its own row of the same conversation, so archiving one row alone
|
||||
// only brings the next copy up in its place.
|
||||
func recoveredLegacySiblings(source historicalSource) []string {
|
||||
if source.format != "legacy" {
|
||||
return nil
|
||||
}
|
||||
meta, ok, err := agent.LoadBranchMeta(source.path)
|
||||
if err != nil || !ok || !meta.Recovered || strings.TrimSpace(meta.ParentID) == "" {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Dir(source.path)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var siblings []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !store.IsSessionTranscriptName(entry.Name()) {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
if sameDesktopPath(path, source.path) {
|
||||
continue
|
||||
}
|
||||
other, ok, err := agent.LoadBranchMeta(path)
|
||||
if err == nil && ok && other.Recovered && other.ParentID == meta.ParentID {
|
||||
siblings = append(siblings, path)
|
||||
}
|
||||
}
|
||||
return siblings
|
||||
}
|
||||
|
||||
// legacyHeadVersions lists the other live heads of a legacy transcript that are
|
||||
// versions of the selected head's conversation: each rewind keeps the head it
|
||||
// left as a version, and the sidebar lists every version as its own row with
|
||||
// the same title. Named forks start a conversation of their own and stay.
|
||||
func legacyHeadVersions(source historicalSource) []SessionSourceRef {
|
||||
if source.format != "legacy" || source.head == "" {
|
||||
return nil
|
||||
}
|
||||
heads, err := agent.ListSessionHeads(source.path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
byID := make(map[string]agent.SessionHead, len(heads))
|
||||
for _, head := range heads {
|
||||
byID[head.ID] = head
|
||||
}
|
||||
origin := func(id string) string {
|
||||
for range heads {
|
||||
head, ok := byID[id]
|
||||
if !ok || head.Kind != agent.HeadKindRewind || head.ParentHead == "" {
|
||||
break
|
||||
}
|
||||
id = head.ParentHead
|
||||
}
|
||||
return id
|
||||
}
|
||||
if _, ok := byID[source.head]; !ok {
|
||||
return nil
|
||||
}
|
||||
want := origin(source.head)
|
||||
var versions []SessionSourceRef
|
||||
for _, head := range heads {
|
||||
if head.ID == source.head || head.Retired || head.Kind == agent.HeadKindConcurrent || origin(head.ID) != want {
|
||||
continue
|
||||
}
|
||||
versions = append(versions, SessionSourceRef{HostID: localDesktopHostID, Path: source.path, HeadID: head.ID})
|
||||
}
|
||||
return versions
|
||||
}
|
||||
|
||||
func (a *App) archiveHistoricalSourceWithOperation(ctx context.Context, id string, source historicalSource, operationID string) (SessionMutationResult, error) {
|
||||
release, err := acquireHistoricalSource(ctx, id, source)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/sessioncatalog"
|
||||
)
|
||||
|
||||
// A write conflict leaves one recovery copy per writer, each listed as its own
|
||||
// row of the same conversation, so archiving any of them archives them all.
|
||||
func TestArchivingRecoveredLegacyRowArchivesEveryCopy(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
parent := filepath.Join(dir, "20260826-101500.000000000-fake-model.jsonl")
|
||||
p := agent.NewSession("system")
|
||||
p.Add(provider.Message{ID: "q0", Role: provider.RoleUser, Content: "shops from my search"})
|
||||
if err := p.Save(parent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := agent.BranchMeta{Scope: "global", TopicID: "topic_jd", TopicTitle: "shops from my search"}
|
||||
for _, answer := range []string{"first writer", "second writer", "third writer"} {
|
||||
s := agent.NewSession("system")
|
||||
s.Add(provider.Message{ID: "q0", Role: provider.RoleUser, Content: "shops from my search"})
|
||||
s.Add(provider.Message{ID: "a-" + answer, Role: provider.RoleAssistant, Content: answer})
|
||||
if _, err := s.SaveConflictRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: parent, Reason: "conflict", BranchMeta: meta}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), "20260826-101500.000000000-fake-model.") {
|
||||
_ = os.Remove(filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
// An unrelated recovered conversation must survive.
|
||||
other := filepath.Join(dir, "20260826-120000.000000000-fake-model.jsonl")
|
||||
o := agent.NewSession("system")
|
||||
o.Add(provider.Message{ID: "q0", Role: provider.RoleUser, Content: "another conversation"})
|
||||
if err := o.Save(other); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := o.SaveConflictRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: other, Reason: "conflict",
|
||||
BranchMeta: agent.BranchMeta{Scope: "global", TopicID: "topic_other", TopicTitle: "another conversation"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = t.Context()
|
||||
pinDesktopSessionRoot(t, app)
|
||||
installNoopRuntimeEvents(app)
|
||||
t.Cleanup(app.closeSessionServices)
|
||||
catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true, MetadataOnly: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.sessionCatalog.Store(catalog)
|
||||
t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
|
||||
recoveredRows := func(topic string) []ProjectNode {
|
||||
t.Helper()
|
||||
if err := catalog.ReconcileDirectory(context.Background(), sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.ReleaseReadSnapshot(page.SnapshotID)
|
||||
var rows []ProjectNode
|
||||
for _, n := range page.Items {
|
||||
if n.Recovered && n.TopicID == topic {
|
||||
rows = append(rows, n)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
rows := recoveredRows("topic_jd")
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("recovered rows before archive = %d, want the 3 copies", len(rows))
|
||||
}
|
||||
target := rows[0]
|
||||
if _, err := app.ArchiveSessionTarget(SessionSelector{Ref: target.Session, Source: target.Source, SessionPath: target.SessionPath}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if left := recoveredRows("topic_jd"); len(left) != 0 {
|
||||
t.Fatalf("archiving one recovered row left %d copies of the same conversation listed", len(left))
|
||||
}
|
||||
if kept := recoveredRows("topic_other"); len(kept) != 1 {
|
||||
t.Fatalf("an unrelated recovered conversation was archived too: %d rows left, want 1", len(kept))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/desktop/internal/workspacestate"
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// Every rewind keeps the head it left as a version, and the sidebar lists each
|
||||
// version of a legacy transcript as its own row with the same title. Archiving
|
||||
// one of them archives the conversation, once, however often it is repeated.
|
||||
func TestArchivingLegacyVersionRowArchivesEveryVersionOnce(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
path, legacy, mainHead := migrationSingleDAGFixture(t)
|
||||
legacy.Add(provider.Message{ID: "answer", Role: provider.RoleAssistant, Content: "first answer"})
|
||||
if err := legacy.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions := []string{mainHead}
|
||||
for _, retry := range []string{"second answer", "third answer", "fourth answer"} {
|
||||
head, err := legacy.ForkHead(path, "question", agent.HeadKindRewind, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy.Add(provider.Message{Role: provider.RoleAssistant, Content: retry})
|
||||
if err := legacy.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions = append(versions, head)
|
||||
}
|
||||
fork, err := legacy.ForkHead(path, "question", agent.HeadKindFork, "separate idea")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy.Add(provider.Message{Role: provider.RoleAssistant, Content: "forked answer"})
|
||||
if err := legacy.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := desktopSourceFingerprint(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = t.Context()
|
||||
root := pinDesktopSessionRoot(t, app)
|
||||
installNoopRuntimeEvents(app)
|
||||
t.Cleanup(app.closeSessionServices)
|
||||
installSessionCatalogForTest(t, app, filepath.Dir(path), "global", "")
|
||||
rowHeads := func() map[string]bool {
|
||||
t.Helper()
|
||||
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
heads := map[string]bool{}
|
||||
for _, row := range page.Items {
|
||||
if row.Source == nil {
|
||||
t.Fatalf("unexpected materialized row: %+v", row)
|
||||
}
|
||||
heads[row.Source.HeadID] = true
|
||||
}
|
||||
return heads
|
||||
}
|
||||
if got := rowHeads(); len(got) != len(versions)+1 || !got[fork] {
|
||||
t.Fatalf("fixture rows = %v; want %d versions and the fork", got, len(versions))
|
||||
}
|
||||
|
||||
selector := SessionSelector{Source: &SessionSourceRef{HostID: localDesktopHostID, Path: path, HeadID: versions[2]}}
|
||||
var dirs int
|
||||
for attempt := range 3 {
|
||||
result, err := app.ArchiveSessionTarget(selector)
|
||||
if err != nil || !result.Committed {
|
||||
t.Fatalf("archive %d = %+v, %v", attempt, result, err)
|
||||
}
|
||||
if got := rowHeads(); len(got) != 1 || !got[fork] {
|
||||
t.Fatalf("archive %d left rows %v; want only the fork", attempt, got)
|
||||
}
|
||||
state, err := app.workspaceRegistry().Load(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(state.SourceMappings) != len(versions) || len(state.SessionStates) != len(versions) {
|
||||
t.Fatalf("archive %d: %d mappings, %d sessions; want one per version (%d)",
|
||||
attempt, len(state.SourceMappings), len(state.SessionStates), len(versions))
|
||||
}
|
||||
mapped := map[string]string{}
|
||||
for _, mapping := range state.SourceMappings {
|
||||
if !sameDesktopPath(mapping.Path, path) {
|
||||
t.Fatalf("mapping for another source: %+v", mapping)
|
||||
}
|
||||
if previous, dup := mapped[mapping.HeadID]; dup {
|
||||
t.Fatalf("head %s materialized twice: %s and %s", mapping.HeadID, previous, mapping.SessionID)
|
||||
}
|
||||
mapped[mapping.HeadID] = mapping.SessionID
|
||||
if lifecycle := state.SessionStates[mapping.SessionID].Lifecycle; lifecycle != workspacestate.Archived {
|
||||
t.Fatalf("version %s is %v, not archived", mapping.HeadID, lifecycle)
|
||||
}
|
||||
}
|
||||
for _, head := range versions {
|
||||
if _, ok := mapped[head]; !ok {
|
||||
t.Fatalf("version %s was not archived: %v", head, mapped)
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempt == 0 {
|
||||
dirs = len(entries)
|
||||
} else if len(entries) != dirs {
|
||||
t.Fatalf("archive %d wrote new session storage: %d entries, was %d", attempt, len(entries), dirs)
|
||||
}
|
||||
}
|
||||
if after, err := desktopSourceFingerprint(path); err != nil || after != before {
|
||||
t.Fatalf("archive changed the legacy source: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/fileutil"
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
"reasonix/internal/hook"
|
||||
@@ -37,10 +40,15 @@ func (a *App) HooksSettings(scope string) HooksSettingsView {
|
||||
Scope: s,
|
||||
Path: path,
|
||||
ProjectRoot: root,
|
||||
// Retained for older Wails clients. Both scopes are enabled by default.
|
||||
Trusted: true,
|
||||
Hooks: []HookConfigView{},
|
||||
Events: hookEventNames(),
|
||||
Hooks: []HookConfigView{},
|
||||
Events: hookEventNames(),
|
||||
}
|
||||
// Project hooks run only once approved as they stand now; what this view
|
||||
// showed is what the Approve button may approve.
|
||||
program, pending := hook.PendingProjectHooks(hook.LoadOptions{ProjectRoot: root})
|
||||
view.Trusted = s != string(hook.ScopeProject) || !pending
|
||||
if !view.Trusted {
|
||||
shownProjectHooks.Store(root, program.Digest)
|
||||
}
|
||||
settings, err := readHooksSettingsFile(path)
|
||||
if err != nil || settings.Hooks == nil {
|
||||
@@ -85,21 +93,56 @@ func (a *App) SaveHooksSettingsForRoot(scope, projectRoot string, hooks []HookCo
|
||||
if s == string(hook.ScopeProject) && strings.TrimSpace(path) == "" {
|
||||
return fmt.Errorf("no active project workspace")
|
||||
}
|
||||
return writeHooksSettingsFile(path, settings)
|
||||
if err := writeHooksSettingsFile(path, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
// Saving from the editor is the person choosing these hooks as written.
|
||||
if s == string(hook.ScopeProject) {
|
||||
return hook.ApproveProjectHooksAs(hook.LoadOptions{ProjectRoot: projectRoot}, settings)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) TrustProjectHooks() error {
|
||||
// Retained for older generated Wails clients. Project hooks are enabled by
|
||||
// default, so there is no trust state to mutate.
|
||||
return nil
|
||||
return a.TrustProjectHooksForRoot(a.activeHookProjectRoot())
|
||||
}
|
||||
|
||||
// errHooksNotShown refuses approving hooks that carry fields this editor
|
||||
// cannot display: a person may only approve what they were shown.
|
||||
var errHooksNotShown = errors.New("these project hooks set env or contextFile, which this editor does not show; review and approve them with `reasonix trust`")
|
||||
|
||||
// TrustProjectHooksForRoot approves root's project hooks as they stand now.
|
||||
func (a *App) TrustProjectHooksForRoot(root string) error {
|
||||
// Retained for older generated Wails clients. Project hooks are enabled by
|
||||
// default, so there is no trust state to mutate.
|
||||
return nil
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return fmt.Errorf("no active project workspace")
|
||||
}
|
||||
settings, err := readHooksSettingsFile(hook.ProjectSettingsPath(root))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, list := range settings.Hooks {
|
||||
for _, cfg := range list {
|
||||
if len(cfg.Env) > 0 || strings.TrimSpace(cfg.ContextFile) != "" {
|
||||
return errHooksNotShown
|
||||
}
|
||||
}
|
||||
}
|
||||
program, ok := hook.ProjectHooksProgram(root)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if shown, _ := shownProjectHooks.Load(root); shown != program.Digest {
|
||||
return errHooksChangedSinceShown
|
||||
}
|
||||
return config.NewProjectProgramStore(config.ReasonixHomeDir()).Approve(root, program)
|
||||
}
|
||||
|
||||
// shownProjectHooks is the digest of the project hooks each root's settings
|
||||
// view last showed as waiting, so approving never covers a later rewrite.
|
||||
var shownProjectHooks sync.Map
|
||||
|
||||
var errHooksChangedSinceShown = errors.New("these project hooks changed after they were shown; reload them and review again")
|
||||
|
||||
func (a *App) activeHookProjectRoot() string {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/hook"
|
||||
)
|
||||
|
||||
// Hooks a checkout ships are shown as waiting, and load once approved.
|
||||
func TestTrustProjectHooksApprovesTheCheckoutsHooks(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
project := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(project, ".reasonix"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(project, ".reasonix", "settings.json"), []byte(`{"hooks":{"Stop":[{"command":"echo done"}]}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := NewApp()
|
||||
app.tabs = map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", Scope: "project", WorkspaceRoot: project, Ready: true},
|
||||
}
|
||||
app.activeTabID = "project"
|
||||
if view := app.HooksSettings("project"); view.Trusted {
|
||||
t.Fatalf("unapproved checkout hooks read as trusted: %+v", view)
|
||||
}
|
||||
if loaded := hook.Load(hook.LoadOptions{ProjectRoot: project}); len(loaded) != 0 {
|
||||
t.Fatalf("unapproved checkout hooks loaded: %+v", loaded)
|
||||
}
|
||||
if err := app.TrustProjectHooks(); err != nil {
|
||||
t.Fatalf("TrustProjectHooks: %v", err)
|
||||
}
|
||||
if view := app.HooksSettings("project"); !view.Trusted {
|
||||
t.Fatalf("approved hooks still read as waiting: %+v", view)
|
||||
}
|
||||
if loaded := hook.Load(hook.LoadOptions{ProjectRoot: project}); len(loaded) != 1 {
|
||||
t.Fatalf("approved hooks = %+v, want one", loaded)
|
||||
}
|
||||
if err := app.TrustProjectHooksForRoot(""); err == nil {
|
||||
t.Fatal("approving with no workspace succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// Approving from the editor covers only what the editor shows.
|
||||
func TestTrustProjectHooksRefusesFieldsTheEditorCannotShow(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
project := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(project, ".reasonix"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(project, ".reasonix", "settings.json"), []byte(`{"hooks":{"Stop":[{"command":"echo done","env":{"NODE_OPTIONS":"--require ./x.js"}}]}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := NewApp().TrustProjectHooksForRoot(project); !errors.Is(err, errHooksNotShown) {
|
||||
t.Fatalf("TrustProjectHooksForRoot = %v, want errHooksNotShown", err)
|
||||
}
|
||||
if loaded := hook.Load(hook.LoadOptions{ProjectRoot: project}); len(loaded) != 0 {
|
||||
t.Fatalf("hooks loaded after a refused approval: %+v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
// The Approve button covers what the view showed, not a later rewrite.
|
||||
func TestTrustProjectHooksRefusesHooksChangedSinceShown(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
project := t.TempDir()
|
||||
settings := filepath.Join(project, ".reasonix", "settings.json")
|
||||
if err := os.MkdirAll(filepath.Dir(settings), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(settings, []byte(`{"hooks":{"Stop":[{"command":"echo shown"}]}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := NewApp()
|
||||
if err := app.TrustProjectHooksForRoot(project); !errors.Is(err, errHooksChangedSinceShown) {
|
||||
t.Fatalf("approving hooks never shown = %v, want errHooksChangedSinceShown", err)
|
||||
}
|
||||
app.tabs = map[string]*WorkspaceTab{"p": {ID: "p", Scope: "project", WorkspaceRoot: project, Ready: true}}
|
||||
app.activeTabID = "p"
|
||||
_ = app.HooksSettings("project")
|
||||
if err := os.WriteFile(settings, []byte(`{"hooks":{"Stop":[{"command":"echo swapped"}]}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.TrustProjectHooksForRoot(project); !errors.Is(err, errHooksChangedSinceShown) {
|
||||
t.Fatalf("approving a rewrite = %v, want errHooksChangedSinceShown", err)
|
||||
}
|
||||
if loaded := hook.Load(hook.LoadOptions{ProjectRoot: project}); len(loaded) != 0 {
|
||||
t.Fatalf("rewritten hooks loaded: %+v", loaded)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -26,7 +27,7 @@ func TestRemoteInboxQueueCapabilitiesAndIdentity(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(serve.New(ctrl, nil, config.ServeConfig{}).Handler())
|
||||
server := httptest.NewServer(operatorServeHandler(serve.New(ctrl, nil, config.ServeConfig{})))
|
||||
defer server.Close()
|
||||
a, tab := remoteRuntimeTestApp(server.Client())
|
||||
tab.base, tab.routing.currentPath, tab.session.path = server.URL, path, path
|
||||
@@ -105,3 +106,13 @@ func TestTargetGuidanceQueuesEndedTurnAndRejectsReplacementSession(t *testing.T)
|
||||
t.Fatal("legacy request bypassed the session fence")
|
||||
}
|
||||
}
|
||||
|
||||
// operatorServeHandler stands in for the remote client, which holds the
|
||||
// launch token a serve requires for mutations.
|
||||
func operatorServeHandler(s *serve.Server) http.Handler {
|
||||
h := s.Handler()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Header.Set("Authorization", "Bearer "+s.AuthToken())
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func TestRemoteInboxTargetLostReceiptOnlyQueriesOriginalRequest(t *testing.T) {
|
||||
if err := ctrl.SetInboxPaused(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(serve.New(ctrl, nil, config.ServeConfig{}).Handler())
|
||||
server := httptest.NewServer(operatorServeHandler(serve.New(ctrl, nil, config.ServeConfig{})))
|
||||
defer server.Close()
|
||||
posts, lookups := 0, 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
|
||||
@@ -131,7 +131,10 @@ func (a *App) ClearMCPServerAuthentication(name string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, _, _, err := config.ClearPluginAuthenticationInSourceForRoot(root, name); err != nil {
|
||||
if err := config.KeepMCPDecisionAcross(root, name, func() (config.PluginEntry, error) {
|
||||
updated, _, _, err := config.ClearPluginAuthenticationInSourceForRoot(root, name)
|
||||
return updated, err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
disconnectMCPServerControllers(name, ctrl, controllers)
|
||||
|
||||
@@ -477,7 +477,7 @@ func setConnectionsCredentialConfig(c *config.Config, names []string, key string
|
||||
}
|
||||
entries = append(entries, *p)
|
||||
}
|
||||
env, err := c.StageModelCredentialLocked(key)
|
||||
env, err := c.RotateModelCredentialLocked(names, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import (
|
||||
"reasonix/internal/netclient"
|
||||
)
|
||||
|
||||
func TestNetworkProxySpecForRootMatchesEffectiveProjectConfig(t *testing.T) {
|
||||
// The proxy is the user's: a checkout's [network] is ignored.
|
||||
func TestNetworkProxySpecForRootKeepsTheUsersProxy(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -27,8 +28,8 @@ func TestNetworkProxySpecForRootMatchesEffectiveProjectConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
spec := NewApp().networkProxySpecForRoot(root)
|
||||
if spec.Mode != netclient.ModeCustom || spec.URL != "http://127.0.0.1:9876" {
|
||||
t.Fatalf("model probe proxy = %+v, want effective project proxy", spec)
|
||||
if spec.Mode == netclient.ModeCustom || spec.URL != "" {
|
||||
t.Fatalf("model probe proxy = %+v, want the user's proxy_mode off", spec)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/permission"
|
||||
"reasonix/internal/plugin"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
func validateSavedPermissionRule(list, raw string, tools []tool.ContractEntry, servers []string) error {
|
||||
rule, ok := permission.ParseRule(raw)
|
||||
if !ok {
|
||||
return nil // AddPermissionRule reports malformed rules.
|
||||
}
|
||||
for _, candidate := range tools {
|
||||
if permission.RuleMatchesString(rule.Tool, candidate.Name, "") {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
for _, server := range servers {
|
||||
prefix := plugin.ToolPrefix(server)
|
||||
suffix := strings.TrimPrefix(rule.Tool, prefix)
|
||||
if (suffix != rule.Tool && validConfiguredMCPToolSuffix(suffix)) || rule.Tool == plugin.MCPConnectPermissionName(server) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(rule.Tool, "mcp__") && strings.ContainsAny(rule.Tool, "*?") {
|
||||
return fmt.Errorf("permission rule %q uses an MCP tool-name wildcard; MCP permission rules require an exact tool name", raw)
|
||||
}
|
||||
if rule.Subject == "" && !strings.ContainsAny(rule.Tool, "()") {
|
||||
// Allow uses an exact reusable command; ask/deny use a command prefix
|
||||
// so arguments to the same command remain covered.
|
||||
if strings.EqualFold(strings.TrimSpace(list), "allow") {
|
||||
return fmt.Errorf("permission rule %q names no registered tool; if this is a shell command, use Bash(%s)", raw, rule.Tool)
|
||||
}
|
||||
return fmt.Errorf("permission rule %q names no registered tool; if this is a shell command, use Bash(%s:*)", raw, rule.Tool)
|
||||
}
|
||||
return fmt.Errorf("permission rule %q names no registered tool", raw)
|
||||
}
|
||||
|
||||
func validConfiguredMCPToolSuffix(suffix string) bool {
|
||||
if suffix == "" {
|
||||
return false
|
||||
}
|
||||
for _, c := range suffix {
|
||||
if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || c == '_' || c == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
func TestAddPermissionRuleRejectsBareShellCommandBeforeSaving(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
if err := (&App{}).AddPermissionRule("deny", "rm"); err == nil || !strings.Contains(err.Error(), "Bash(rm:*)") {
|
||||
t.Fatalf("AddPermissionRule(deny, rm) = %v, want a Bash rule suggestion", err)
|
||||
}
|
||||
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("rejected rule wrote config: stat error = %v", err)
|
||||
}
|
||||
cfg := config.Default()
|
||||
cfg.Permissions.Deny = []string{"rm"} // Existing entries must not be rewritten.
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save existing config: %v", err)
|
||||
}
|
||||
before, err := os.ReadFile(config.UserConfigPath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (&App{}).AddPermissionRule("deny", "git reset"); err == nil {
|
||||
t.Fatal("bare command was accepted into existing config")
|
||||
}
|
||||
after, err := os.ReadFile(config.UserConfigPath())
|
||||
if err != nil || !bytes.Equal(before, after) {
|
||||
t.Fatalf("rejected rule changed existing config: read error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSavedPermissionRuleUsesRegisteredTools(t *testing.T) {
|
||||
registered := []tool.ContractEntry{{Name: "bash"}, {Name: "write_file"}, {Name: "plugin_custom"}}
|
||||
for _, tc := range []struct {
|
||||
list, rule, suggestion string
|
||||
}{
|
||||
{"deny", "rm", "Bash(rm:*)"},
|
||||
{"ask", "git reset", "Bash(git reset:*)"},
|
||||
{"allow", "git branch", "Bash(git branch)"},
|
||||
} {
|
||||
err := validateSavedPermissionRule(tc.list, tc.rule, registered, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.suggestion) {
|
||||
t.Errorf("%s %q: got %v, want %q suggestion", tc.list, tc.rule, err, tc.suggestion)
|
||||
}
|
||||
}
|
||||
for _, rule := range []string{"Bash(rm:*)", "Edit(src/**)", "plugin_custom"} {
|
||||
if err := validateSavedPermissionRule("deny", rule, registered, nil); err != nil {
|
||||
t.Errorf("registered rule %q: %v", rule, err)
|
||||
}
|
||||
}
|
||||
if err := validateSavedPermissionRule("deny", "Bash(rm:*)", tool.BuiltinContractEntries(), nil); err != nil {
|
||||
t.Errorf("desktop built-in Bash rule: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPermissionRuleAcceptsConfiguredDisconnectedMCPServer(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
cfg := config.Default()
|
||||
cfg.Plugins = []config.PluginEntry{{Name: "github", Type: "http", URL: "https://example.invalid/mcp"}}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := &App{} // No active session or connected MCP registry.
|
||||
for _, rule := range []string{"mcp__github__create_issue", "mcp_connect__github"} {
|
||||
if err := app.AddPermissionRule("deny", rule); err != nil {
|
||||
t.Fatalf("configured disconnected MCP rule %q: %v", rule, err)
|
||||
}
|
||||
}
|
||||
if err := app.AddPermissionRule("deny", "mcp__other__create_issue"); err == nil {
|
||||
t.Fatal("accepted an unconfigured MCP server")
|
||||
}
|
||||
if err := app.AddPermissionRule("deny", "mcp__github__*"); err == nil || !strings.Contains(err.Error(), "exact tool name") {
|
||||
t.Fatalf("MCP tool-name glob = %v, want exact-name guidance", err)
|
||||
}
|
||||
got, err := config.LoadForEditReadOnlyStrict(config.UserConfigPath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Permissions.Deny) != 2 || got.Permissions.Deny[0] != "mcp__github__create_issue" || got.Permissions.Deny[1] != "mcp_connect__github" {
|
||||
t.Fatalf("saved MCP deny rules = %q", got.Permissions.Deny)
|
||||
}
|
||||
}
|
||||
Generated
+61
-61
@@ -68,8 +68,8 @@ importers:
|
||||
specifier: 1.7.5
|
||||
version: 1.7.5(@modelcontextprotocol/[email protected]([email protected]))([email protected]([email protected]))([email protected])([email protected])
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.14.10
|
||||
version: 3.14.11([email protected]([email protected]))([email protected])
|
||||
specifier: ^3.14.13
|
||||
version: 3.14.13([email protected]([email protected]))([email protected])
|
||||
'@xterm/addon-fit':
|
||||
specifier: 0.11.0
|
||||
version: 0.11.0
|
||||
@@ -89,8 +89,8 @@ importers:
|
||||
specifier: 3.0.1
|
||||
version: 3.0.1
|
||||
katex:
|
||||
specifier: ^0.18.6
|
||||
version: 0.18.7
|
||||
specifier: ^0.18.9
|
||||
version: 0.18.9
|
||||
lucide-react:
|
||||
specifier: ^1.41.0
|
||||
version: 1.43.0([email protected])
|
||||
@@ -156,11 +156,11 @@ importers:
|
||||
specifier: ^19.2.7
|
||||
version: 19.2.7(@types/[email protected])
|
||||
'@typescript-eslint/parser':
|
||||
specifier: ^8.69.0
|
||||
version: 8.70.0([email protected])([email protected])
|
||||
specifier: ^8.70.1
|
||||
version: 8.70.1([email protected])([email protected])
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.1.1
|
||||
version: 6.1.1([email protected](@types/[email protected])([email protected])([email protected])([email protected]3))
|
||||
version: 6.1.1([email protected](@types/[email protected])([email protected])([email protected])([email protected]5))
|
||||
decode-named-character-reference:
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0
|
||||
@@ -186,14 +186,14 @@ importers:
|
||||
specifier: ^5.51.2
|
||||
version: 5.51.2
|
||||
tsx:
|
||||
specifier: ^4.23.13
|
||||
version: 4.23.13
|
||||
specifier: ^4.23.15
|
||||
version: 4.23.15
|
||||
typescript:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: ^8.2.2
|
||||
version: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected]3)
|
||||
version: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected]5)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -776,14 +776,14 @@ packages:
|
||||
'@standard-schema/[email protected]':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@tanstack/[email protected]1':
|
||||
resolution: {integrity: sha512-SStWf8bYdTgAquYqG4Pi2+d1XimQKoCIJ94H3jsm0D6UA0yqffvWuvUzrrQ4idewFWq8BSPpahnmLVosJIAs6g==}
|
||||
'@tanstack/[email protected]3':
|
||||
resolution: {integrity: sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/[email protected].9':
|
||||
resolution: {integrity: sha512-M8Bzy7CCMvUjRiuoHVH9mRjyUVczRs8v8RcUEphLFGc445ZP4KQ15Y1sLqhQqJi/HgGJrxfCHnEnIX0x9mVrBg==}
|
||||
'@tanstack/[email protected].11':
|
||||
resolution: {integrity: sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
@@ -940,41 +940,41 @@ packages:
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
'@typescript-eslint/[email protected].0':
|
||||
resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==}
|
||||
'@typescript-eslint/[email protected].1':
|
||||
resolution: {integrity: sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/[email protected].0':
|
||||
resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==}
|
||||
'@typescript-eslint/[email protected].1':
|
||||
resolution: {integrity: sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/[email protected].0':
|
||||
resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==}
|
||||
'@typescript-eslint/[email protected].1':
|
||||
resolution: {integrity: sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/[email protected].0':
|
||||
resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==}
|
||||
'@typescript-eslint/[email protected].1':
|
||||
resolution: {integrity: sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/[email protected].0':
|
||||
resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==}
|
||||
'@typescript-eslint/[email protected].1':
|
||||
resolution: {integrity: sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/[email protected].0':
|
||||
resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==}
|
||||
'@typescript-eslint/[email protected].1':
|
||||
resolution: {integrity: sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/[email protected].0':
|
||||
resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==}
|
||||
'@typescript-eslint/[email protected].1':
|
||||
resolution: {integrity: sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@ungap/[email protected]':
|
||||
@@ -1823,8 +1823,8 @@ packages:
|
||||
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
|
||||
hasBin: true
|
||||
|
||||
[email protected].7:
|
||||
resolution: {integrity: sha512-h+UCwkZ+4Jz8WQ7MLGfj7UVFrRCizGb912fwF4luGdYsC5paYG1vx+jy+KRcC/XkpjGva/P7nAWuxNnPzRvzHw==}
|
||||
[email protected].9:
|
||||
resolution: {integrity: sha512-8ad9RyoKsb/g8/yLFE+KAlP+DhbCTRUNi/V9XGsxn0R+trJJltNwzcDNo0q/DEkOy5fQUQTAQyCXCYSE+OakTQ==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
@@ -2507,8 +2507,8 @@ packages:
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]3:
|
||||
resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
|
||||
[email protected]5:
|
||||
resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -3255,13 +3255,13 @@ snapshots:
|
||||
|
||||
'@standard-schema/[email protected]': {}
|
||||
|
||||
'@tanstack/[email protected]1([email protected]([email protected]))([email protected])':
|
||||
'@tanstack/[email protected]3([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.17.9
|
||||
'@tanstack/virtual-core': 3.17.11
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8([email protected])
|
||||
|
||||
'@tanstack/[email protected].9': {}
|
||||
'@tanstack/[email protected].11': {}
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
@@ -3444,44 +3444,44 @@ snapshots:
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
'@typescript-eslint/[email protected].0([email protected])([email protected])':
|
||||
'@typescript-eslint/[email protected].1([email protected])([email protected])':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.70.0
|
||||
'@typescript-eslint/types': 8.70.0
|
||||
'@typescript-eslint/typescript-estree': 8.70.0([email protected])
|
||||
'@typescript-eslint/visitor-keys': 8.70.0
|
||||
'@typescript-eslint/scope-manager': 8.70.1
|
||||
'@typescript-eslint/types': 8.70.1
|
||||
'@typescript-eslint/typescript-estree': 8.70.1([email protected])
|
||||
'@typescript-eslint/visitor-keys': 8.70.1
|
||||
debug: 4.4.3
|
||||
eslint: 10.10.0
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/[email protected].0([email protected])':
|
||||
'@typescript-eslint/[email protected].1([email protected])':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.70.0([email protected])
|
||||
'@typescript-eslint/types': 8.70.0
|
||||
'@typescript-eslint/tsconfig-utils': 8.70.1([email protected])
|
||||
'@typescript-eslint/types': 8.70.1
|
||||
debug: 4.4.3
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/[email protected].0':
|
||||
'@typescript-eslint/[email protected].1':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.70.0
|
||||
'@typescript-eslint/visitor-keys': 8.70.0
|
||||
'@typescript-eslint/types': 8.70.1
|
||||
'@typescript-eslint/visitor-keys': 8.70.1
|
||||
|
||||
'@typescript-eslint/[email protected].0([email protected])':
|
||||
'@typescript-eslint/[email protected].1([email protected])':
|
||||
dependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
'@typescript-eslint/[email protected].0': {}
|
||||
'@typescript-eslint/[email protected].1': {}
|
||||
|
||||
'@typescript-eslint/[email protected].0([email protected])':
|
||||
'@typescript-eslint/[email protected].1([email protected])':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.70.0([email protected])
|
||||
'@typescript-eslint/tsconfig-utils': 8.70.0([email protected])
|
||||
'@typescript-eslint/types': 8.70.0
|
||||
'@typescript-eslint/visitor-keys': 8.70.0
|
||||
'@typescript-eslint/project-service': 8.70.1([email protected])
|
||||
'@typescript-eslint/tsconfig-utils': 8.70.1([email protected])
|
||||
'@typescript-eslint/types': 8.70.1
|
||||
'@typescript-eslint/visitor-keys': 8.70.1
|
||||
debug: 4.4.3
|
||||
minimatch: 10.2.6
|
||||
semver: 7.8.5
|
||||
@@ -3491,9 +3491,9 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/[email protected].0':
|
||||
'@typescript-eslint/[email protected].1':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.70.0
|
||||
'@typescript-eslint/types': 8.70.1
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@ungap/[email protected]': {}
|
||||
@@ -3503,10 +3503,10 @@ snapshots:
|
||||
d3-selection: 3.0.0
|
||||
d3-transition: 3.0.1([email protected])
|
||||
|
||||
'@vitejs/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected]3))':
|
||||
'@vitejs/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected]5))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected]3)
|
||||
vite: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected]5)
|
||||
|
||||
'@xmldom/[email protected]': {}
|
||||
|
||||
@@ -4425,7 +4425,7 @@ snapshots:
|
||||
dependencies:
|
||||
commander: 8.3.0
|
||||
|
||||
[email protected].7:
|
||||
[email protected].9:
|
||||
dependencies:
|
||||
commander: 15.0.0
|
||||
|
||||
@@ -5374,7 +5374,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
[email protected]3:
|
||||
[email protected]5:
|
||||
dependencies:
|
||||
esbuild: 0.28.1
|
||||
optionalDependencies:
|
||||
@@ -5476,7 +5476,7 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
[email protected](@types/[email protected])([email protected])([email protected])([email protected]3):
|
||||
[email protected](@types/[email protected])([email protected])([email protected])([email protected]5):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.5
|
||||
@@ -5488,7 +5488,7 @@ snapshots:
|
||||
esbuild: 0.28.1
|
||||
fsevents: 2.3.3
|
||||
terser: 5.51.2
|
||||
tsx: 4.23.13
|
||||
tsx: 4.23.15
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/boot"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/plugin"
|
||||
)
|
||||
|
||||
// enableProjectMCPForTest records the user's enable decision for each server
|
||||
// root's project files declare with auto_start on.
|
||||
func enableProjectMCPForTest(t testing.TB, root string) {
|
||||
t.Helper()
|
||||
enableProjectMCPForWorkspace(t, root, root)
|
||||
}
|
||||
|
||||
// enableProjectMCPForWorkspace records the decision under workspace, which the
|
||||
// test app's global tab leaves empty while config still loads from root.
|
||||
func enableProjectMCPForWorkspace(t testing.TB, root, workspace string) {
|
||||
t.Helper()
|
||||
cfg, err := config.LoadForRootReadOnly(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range cfg.Plugins {
|
||||
if p.Source.ProjectScoped() && p.ShouldAutoStart() {
|
||||
if err := config.DefaultMCPActivationStore().SetServerEnabled(p, workspace, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Project configuration cannot choose MCP servers the desktop starts: without
|
||||
// the user's decision the shared host never connects to one.
|
||||
func TestDesktopSharedHostProjectMCPWaitsForTheUser(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
|
||||
srv := desktopMCPHTTPServer(t)
|
||||
defer srv.Close()
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), fmt.Appendf(nil, `
|
||||
[[plugins]]
|
||||
name = "h"
|
||||
type = "http"
|
||||
url = %q
|
||||
`, srv.URL), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
sharedHost := plugin.NewHost()
|
||||
defer sharedHost.Close()
|
||||
ctrl, err := boot.Build(ctx, boot.Options{
|
||||
WorkspaceRoot: dir,
|
||||
SessionDir: filepath.Join(dir, "sessions"),
|
||||
SharedHost: sharedHost,
|
||||
Stderr: io.Discard,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("boot.Build: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
|
||||
time.Sleep(time.Second)
|
||||
if sharedHost.HasClient("h") {
|
||||
t.Fatal("project MCP connected with no user decision")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[[plugins]]
|
||||
name = "playwright"
|
||||
command = "npx"
|
||||
args = ["-y", "@playwright/mcp"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer func() {
|
||||
if c := app.activeCtrl(); c != nil {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
if s.Name == "playwright" {
|
||||
if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
|
||||
t.Fatalf("default MCP view = %+v, want deferred automatic idle", s)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
|
||||
}
|
||||
@@ -1,19 +1,36 @@
|
||||
package main
|
||||
|
||||
import "errors"
|
||||
|
||||
// registerProjectRoot indexes workspaceRoot, realigns open tabs to its
|
||||
// canonical spelling, and discovers existing sessions once per process.
|
||||
func (a *App) registerProjectRoot(workspaceRoot string) {
|
||||
_ = addProject(workspaceRoot, "")
|
||||
func (a *App) registerProjectRoot(workspaceRoot string) error {
|
||||
if err := addProject(workspaceRoot, ""); errors.Is(err, errProjectStateCollisionAssignment) {
|
||||
return err
|
||||
}
|
||||
a.syncTabWorkspaceRootSpellings()
|
||||
root := normalizeProjectRoot(workspaceRoot)
|
||||
if root == "" {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
registrationKey := projectRootKey(root)
|
||||
if _, loaded := a.catalogRegisteredProjectRoots.LoadOrStore(registrationKey, struct{}{}); loaded {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if !a.requestSessionCatalogReconcile(desktopSessionDir(root)) {
|
||||
a.catalogRegisteredProjectRoots.Delete(registrationKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) beginRegisteredProjectRuntimeAdmission(tab *WorkspaceTab, scope, workspaceRoot string) (func(), error) {
|
||||
release, err := a.beginChangedProjectRuntimeAdmission(tab, scope, workspaceRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.registerProjectRoot(workspaceRoot); err != nil {
|
||||
release()
|
||||
return nil, err
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
var errProjectStateCollisionAssignment = errors.New("project state collision assignment")
|
||||
|
||||
// assignAddedProjectStateCollisions assigns a new root only when a recorded
|
||||
// project still uses the same legacy directory. Recorded roots and existing
|
||||
// assignments keep their current directory.
|
||||
func assignAddedProjectStateCollisions(previous []string, projects []desktopProject) error {
|
||||
known := append([]string(nil), previous...)
|
||||
userDir := config.MemoryUserDir()
|
||||
for _, project := range projects {
|
||||
root := normalizeProjectRoot(project.Root)
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
alreadyKnown := false
|
||||
collision := false
|
||||
for _, prior := range known {
|
||||
if sameProjectRoot(prior, root) {
|
||||
alreadyKnown = true
|
||||
break
|
||||
}
|
||||
if config.WorkspaceSlug(prior) == config.WorkspaceSlug(root) &&
|
||||
config.ProjectStateDir(userDir, prior) == config.ProjectStateDir(userDir, root) {
|
||||
collision = true
|
||||
}
|
||||
}
|
||||
if alreadyKnown {
|
||||
continue
|
||||
}
|
||||
if collision {
|
||||
if err := config.AssignProjectStateCollision(userDir, root); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
known = append(known, root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/memory"
|
||||
"reasonix/internal/sessioncatalog"
|
||||
)
|
||||
|
||||
func TestNewCollidingProjectGetsOwnStateWithoutMovingExistingProject(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
root := t.TempDir()
|
||||
first := filepath.Join(root, "front-end", "app")
|
||||
second := filepath.Join(root, "front", "end-app")
|
||||
plain := filepath.Join(root, "backend")
|
||||
for _, path := range []string{first, second, plain} {
|
||||
if err := os.MkdirAll(path, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := addProject(first, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstDir := config.ProjectSessionDir(first)
|
||||
if err := os.MkdirAll(firstDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldSession := filepath.Join(firstDir, "existing.jsonl")
|
||||
if err := os.WriteFile(oldSession, []byte("existing session\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := addProject(second, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondDir := config.ProjectSessionDir(second)
|
||||
if secondDir == firstDir {
|
||||
t.Fatalf("new project still shares %q", firstDir)
|
||||
}
|
||||
if got := config.ProjectSessionDir(first); got != firstDir {
|
||||
t.Fatalf("existing project moved to %q", got)
|
||||
}
|
||||
if got, err := os.ReadFile(oldSession); err != nil || string(got) != "existing session\n" {
|
||||
t.Fatalf("existing session changed: %q, %v", got, err)
|
||||
}
|
||||
if err := removeProject(first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := addProject(first, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := config.ProjectSessionDir(first); got != firstDir {
|
||||
t.Fatalf("re-added original project moved to %q, want %q", got, firstDir)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(secondDir, "existing.jsonl")); !os.IsNotExist(err) {
|
||||
t.Fatalf("new project sees existing session: %v", err)
|
||||
}
|
||||
if err := addProject(plain, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantPlain := filepath.Join(config.MemoryUserDir(), "projects", config.WorkspaceSlug(plain), "sessions")
|
||||
if got := config.ProjectSessionDir(plain); got != wantPlain {
|
||||
t.Fatalf("non-colliding project moved to %q, want %q", got, wantPlain)
|
||||
}
|
||||
if err := removeProject(second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := addProject(second, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := config.ProjectSessionDir(second); got != secondDir {
|
||||
t.Fatalf("re-added project moved to %q, want %q", got, secondDir)
|
||||
}
|
||||
if got := config.ProjectSessionStoreDir(first); got == config.ProjectSessionStoreDir(second) {
|
||||
t.Fatalf("canonical session stores overlap: %q", got)
|
||||
}
|
||||
if got := config.DesktopTopicStatePath(first); got == config.DesktopTopicStatePath(second) {
|
||||
t.Fatalf("topic stores overlap: %q", got)
|
||||
}
|
||||
if got := memory.StoreFor(config.MemoryUserDir(), first).Dir; got == memory.StoreFor(config.MemoryUserDir(), second).Dir {
|
||||
t.Fatalf("project memory stores overlap: %q", got)
|
||||
}
|
||||
if err := os.MkdirAll(secondDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstSession := writeTopicSession(t, firstDir, "first.jsonl", "first-topic", "First", first)
|
||||
secondSession := writeTopicSession(t, secondDir, "second.jsonl", "second-topic", "Second", second)
|
||||
app := NewApp()
|
||||
installSessionCatalogForTest(t, app, firstDir, "project", first)
|
||||
reconcileSessionCatalogForTest(t, app, secondDir, "project", second)
|
||||
for _, tc := range []struct{ root, dir, want, other string }{
|
||||
{first, firstDir, firstSession, secondSession},
|
||||
{second, secondDir, secondSession, firstSession},
|
||||
} {
|
||||
page, err := app.sessionCatalog.Load().ListSessions(context.Background(), sessioncatalog.SessionPageRequest{
|
||||
Scope: "project", WorkspaceRoot: tc.root, Directory: tc.dir, Limit: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, item := range page.Items {
|
||||
if item.Path == tc.other {
|
||||
t.Fatalf("project %q lists another project's session %q", tc.root, tc.other)
|
||||
}
|
||||
found = found || item.Path == tc.want
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("project %q did not list its session %q: %+v", tc.root, tc.want, page.Items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyProjectImportsKeepExistingCollisionState(t *testing.T) {
|
||||
for _, source := range []string{"workspace-list", "sidebar-recovery"} {
|
||||
t.Run(source, func(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
root := t.TempDir()
|
||||
first := filepath.Join(root, "front-end", "app")
|
||||
second := filepath.Join(root, "front", "end-app")
|
||||
for _, path := range []string{first, second} {
|
||||
if err := os.MkdirAll(path, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := addProject(first, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyDir := config.ProjectSessionDir(second)
|
||||
if err := os.MkdirAll(legacyDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldSession := filepath.Join(legacyDir, "legacy.jsonl")
|
||||
if err := os.WriteFile(oldSession, []byte("legacy session\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch source {
|
||||
case "workspace-list":
|
||||
rememberWorkspace(second)
|
||||
migrateLegacyWorkspacesIntoProjects()
|
||||
case "sidebar-recovery":
|
||||
if _, err := recoverLegacyProjectSidebarRoots(desktopTabsFile{Tabs: []desktopTabEntry{{Scope: "project", WorkspaceRoot: second}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := config.ProjectSessionDir(second); got != legacyDir {
|
||||
t.Fatalf("imported project moved to %q, want %q", got, legacyDir)
|
||||
}
|
||||
if got, err := os.ReadFile(oldSession); err != nil || string(got) != "legacy session\n" {
|
||||
t.Fatalf("imported session changed: %q, %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectRegistrationKeepsOpeningOnProjectListLockFailure(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
root := t.TempDir()
|
||||
lockPath := filepath.Join(desktopConfigDir(), desktopProjectsFile+".lock")
|
||||
if err := os.MkdirAll(lockPath, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := NewApp()
|
||||
if err := app.registerProjectRoot(root); err != nil {
|
||||
t.Fatalf("project list lock failure blocked opening: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollidingProjectRegistrationFailsBeforeUsingUnownedState(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
root := t.TempDir()
|
||||
first := filepath.Join(root, "front-end", "app")
|
||||
second := filepath.Join(root, "front", "end-app")
|
||||
if err := addProject(first, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := config.AssignProjectStateCollision(config.MemoryUserDir(), second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assigned := config.ProjectStateDir(config.MemoryUserDir(), second)
|
||||
if err := os.Remove(filepath.Join(assigned, ".workspace-root")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(assigned, "orphan"), []byte("keep"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := addProject(second, ""); err == nil {
|
||||
t.Fatal("registration accepted an unowned, nonempty assigned directory")
|
||||
}
|
||||
if len(loadProjectsFile().Projects) != 1 {
|
||||
t.Fatal("failed registration changed the saved project list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviouslyRecordedCollisionKeepsBothLegacyPaths(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
root := t.TempDir()
|
||||
first := filepath.Join(root, "front-end", "app")
|
||||
second := filepath.Join(root, "front", "end-app")
|
||||
if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: first}, {Root: second}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := config.ProjectSessionDir(first)
|
||||
for _, path := range []string{first, second} {
|
||||
if err := addProject(path, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := config.ProjectSessionDir(path); got != legacy {
|
||||
t.Fatalf("previously recorded project %q moved to %q", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// remoteContextSnapshot reads the selected remote session, then rejects a
|
||||
// response that raced with a reconnect or a switch to another session.
|
||||
func (a *App) remoteContextSnapshot(tabID string) (int, int, bool) {
|
||||
a.remoteTabMu.Lock()
|
||||
tab := a.remoteTabs[tabID]
|
||||
if tab == nil || tab.client == nil || tab.state != "ready" || tab.routing.rehydratingPath != "" {
|
||||
a.remoteTabMu.Unlock()
|
||||
return 0, 0, false
|
||||
}
|
||||
client, base := tab.client, tab.base
|
||||
path, generation, revision := tab.routing.currentPath, tab.gen, tab.routing.pathRevision
|
||||
a.remoteTabMu.Unlock()
|
||||
|
||||
ctx, cancel := commandContext(a)
|
||||
defer cancel()
|
||||
data, err := serveGet(ctx, client, serveURL(base, "/context"), path)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
var value struct {
|
||||
Used int `json:"used"`
|
||||
Window int `json:"window"`
|
||||
}
|
||||
if json.Unmarshal(data, &value) != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
a.remoteTabMu.Lock()
|
||||
current := a.remoteTabs[tabID]
|
||||
valid := current == tab && current.client == client && current.gen == generation &&
|
||||
current.routing.pathRevision == revision && current.routing.currentPath == path &&
|
||||
current.routing.rehydratingPath == "" && current.state == "ready"
|
||||
a.remoteTabMu.Unlock()
|
||||
return value.Used, value.Window, valid
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRemoteContextPanelReadsServeContext(t *testing.T) {
|
||||
fs := newFakeServe(t, "s3cret", nil)
|
||||
fs.newSessionPath = "/sessions/remote.jsonl"
|
||||
kernel := &fakeRemoteKernel{
|
||||
statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
|
||||
ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
|
||||
ensureToken: "s3cret",
|
||||
}
|
||||
seedBridgeTestHost(t, "box")
|
||||
a := &App{remoteRuntime: kernel}
|
||||
cleanupRemoteTabPumps(t, a)
|
||||
meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForTabState(t, a, meta.ID, "ready")
|
||||
if got := a.ContextPanel(meta.ID); got.UsedTokens != 10 || got.WindowTokens != 128 {
|
||||
t.Fatalf("remote panel context = %d/%d, want Serve /context value 10/128", got.UsedTokens, got.WindowTokens)
|
||||
}
|
||||
if got := a.ContextUsageForTab(meta.ID); got.Used != 10 || got.Window != 128 {
|
||||
t.Fatalf("remote usage context = %d/%d, want 10/128", got.Used, got.Window)
|
||||
}
|
||||
fs.mu.Lock()
|
||||
expected := fs.contextExpected
|
||||
fs.mu.Unlock()
|
||||
a.remoteTabMu.Lock()
|
||||
selected := a.remoteTabs[meta.ID].routing.currentPath
|
||||
a.remoteTabMu.Unlock()
|
||||
if expected != selected {
|
||||
t.Fatalf("remote context request targeted %q, want selected %q", expected, selected)
|
||||
}
|
||||
a.remoteTabMu.Lock()
|
||||
a.remoteTabs[meta.ID].routing.currentPath = remoteSessionIDRoutePrefix + "canonical-a"
|
||||
a.remoteTabs[meta.ID].routing.pathRevision++
|
||||
a.remoteTabMu.Unlock()
|
||||
if got := a.ContextPanel(meta.ID); got.UsedTokens != 10 {
|
||||
t.Fatalf("canonical remote panel used tokens = %d, want 10", got.UsedTokens)
|
||||
}
|
||||
fs.mu.Lock()
|
||||
expected = fs.contextExpected
|
||||
fs.mu.Unlock()
|
||||
if expected != remoteSessionIDRoutePrefix+"canonical-a" {
|
||||
t.Fatalf("canonical context header = %q", expected)
|
||||
}
|
||||
started, release := make(chan struct{}, 1), make(chan struct{})
|
||||
fs.mu.Lock()
|
||||
fs.contextStarted, fs.contextRelease = started, release
|
||||
fs.mu.Unlock()
|
||||
done := make(chan ContextPanelInfo, 1)
|
||||
go func() { done <- a.ContextPanel(meta.ID) }()
|
||||
<-started
|
||||
a.remoteTabMu.Lock()
|
||||
a.remoteTabs[meta.ID].routing.currentPath = remoteSessionIDRoutePrefix + "canonical-b"
|
||||
a.remoteTabs[meta.ID].routing.pathRevision++
|
||||
a.remoteTabMu.Unlock()
|
||||
close(release)
|
||||
if got := <-done; got.UsedTokens != 0 || got.WindowTokens != 0 {
|
||||
t.Fatalf("stale remote context reached the switched tab: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func TestRemoteRuntimeInboxReceiptSnapshotAndConsumptionOverHTTP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
})
|
||||
server := httptest.NewServer(serve.New(ctrl, nil, config.ServeConfig{}).Handler())
|
||||
server := httptest.NewServer(operatorServeHandler(serve.New(ctrl, nil, config.ServeConfig{})))
|
||||
defer server.Close()
|
||||
a, tab := remoteRuntimeTestApp(server.Client())
|
||||
tab.base, tab.routing.currentPath, tab.session.path = server.URL, path, path
|
||||
|
||||
@@ -110,6 +110,7 @@ func TestRemoteModelSourceRefreshesAutonomousHTTPRunAndRetiresOldRoute(t *testin
|
||||
request := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(`{"input":"autonomous next run"}`)).WithContext(ctx)
|
||||
request.Host = "127.0.0.1"
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+srv.AuthToken())
|
||||
response := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusAccepted {
|
||||
|
||||
@@ -53,6 +53,8 @@ type fakeServe struct {
|
||||
eventsCloseEarly bool // return immediately after the initial 200 frames
|
||||
statusPayload string
|
||||
statusAfterCancel string
|
||||
contextExpected string
|
||||
contextStarted, contextRelease chan struct{}
|
||||
}
|
||||
|
||||
func (fs *fakeServe) eventsCount() int { fs.mu.Lock(); defer fs.mu.Unlock(); return fs.eventsConns }
|
||||
@@ -305,6 +307,25 @@ func newFakeServe(t *testing.T, token string, sessions []serveSessionEntry) *fak
|
||||
mux.HandleFunc("GET "+path, func(w http.ResponseWriter, r *http.Request) {
|
||||
fs.record(r.Method, path, "")
|
||||
responsePayload := payload
|
||||
if path == "/context" {
|
||||
fs.mu.Lock()
|
||||
fs.contextExpected = r.Header.Get(expectedSessionPathHeader)
|
||||
if id := r.Header.Get(expectedSessionIDHeader); id != "" {
|
||||
fs.contextExpected = remoteSessionIDRoutePrefix + id
|
||||
}
|
||||
started, release := fs.contextStarted, fs.contextRelease
|
||||
fs.mu.Unlock()
|
||||
if started != nil {
|
||||
started <- struct{}{}
|
||||
}
|
||||
if release != nil {
|
||||
select {
|
||||
case <-release:
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if path == "/history" {
|
||||
fs.mu.Lock()
|
||||
fail := fs.failHistory
|
||||
@@ -333,7 +354,7 @@ func newFakeServe(t *testing.T, token string, sessions []serveSessionEntry) *fak
|
||||
})
|
||||
}
|
||||
snapshot("/history", `[{"role":"user","content":"hi"}]`)
|
||||
snapshot("/context", `{"used":10}`)
|
||||
snapshot("/context", `{"used":10,"window":128}`)
|
||||
snapshot("/todos", `[]`)
|
||||
snapshot("/checkpoints", `[{"turn":1}]`)
|
||||
snapshot("/models", `{"current":"remote/chat","label":"chat","models":[{"ref":"remote/chat","provider":"remote","model":"chat","active":true}]}`)
|
||||
|
||||
@@ -442,11 +442,18 @@ func (a *App) clearRemotePendingEvent(tabID, kind, callID string) {
|
||||
|
||||
// serveGet fetches a JSON member of the tab snapshot, returning the raw
|
||||
// payload for verbatim passthrough.
|
||||
func serveGet(ctx context.Context, client *http.Client, url string) (json.RawMessage, error) {
|
||||
func serveGet(ctx context.Context, client *http.Client, url string, expectedPath ...string) (json.RawMessage, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(expectedPath) != 0 && strings.TrimSpace(expectedPath[0]) != "" {
|
||||
if sessionID, ok := strings.CutPrefix(expectedPath[0], remoteSessionIDRoutePrefix); ok {
|
||||
req.Header.Set(expectedSessionIDHeader, sessionID)
|
||||
} else {
|
||||
req.Header.Set(expectedSessionPathHeader, expectedPath[0])
|
||||
}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/serve"
|
||||
"reasonix/internal/transcript"
|
||||
)
|
||||
|
||||
// A Serve whose foreground is a path-backed session (the controller POST
|
||||
// /resume installs for a historical .jsonl) still serves Follow v2 through the
|
||||
// legacy projection, so the handshake a desktop attaches with must say so.
|
||||
func TestRemoteFollowAttachesToServeWithPathBackedForeground(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
sess := agent.NewSession("system")
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "question"})
|
||||
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "answer"})
|
||||
if err := sess.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bc := serve.NewBroadcaster()
|
||||
ctrl := control.New(control.Options{Executor: agent.New(nil, nil, sess, agent.Options{}, bc), SessionDir: dir, SessionPath: path, Sink: bc})
|
||||
defer ctrl.Close()
|
||||
if ctrl.UsesExclusiveSession() {
|
||||
t.Fatal("fixture must put a path-backed session in the foreground")
|
||||
}
|
||||
server := httptest.NewServer(serve.New(ctrl, bc, config.ServeConfig{AuthMode: "token", Token: "fixture-token"}).Handler())
|
||||
defer server.Close()
|
||||
|
||||
client, err := newServeHTTPClient(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
capabilities, err := serveHandshakeCapabilities(ctx, client, server.URL, "fixture-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tab := &remoteTab{id: "remote", state: "ready", client: client, base: server.URL, gen: 1,
|
||||
routing: remoteTabSessionRouting{currentPath: path}, capabilities: map[string]bool{}}
|
||||
for _, capability := range capabilities {
|
||||
tab.capabilities[capability] = true
|
||||
}
|
||||
app := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
|
||||
|
||||
followed, err := app.RemoteTranscriptFollowForTab(tab.id, transcript.FollowRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("follow against a path-backed foreground (capabilities %v): %v", capabilities, err)
|
||||
}
|
||||
if followed.ProtocolVersion != transcript.FollowProtocolVersion || followed.Snapshot == nil || followed.Subscription == "" || followed.StorageBackend != "legacy" {
|
||||
t.Fatalf("follow = %+v", followed)
|
||||
}
|
||||
if len(followed.Snapshot.Records) != 2 {
|
||||
t.Fatalf("follow snapshot records = %d, want 2", len(followed.Snapshot.Records))
|
||||
}
|
||||
if _, err := app.RemoteTranscriptFollowForTab(tab.id, transcript.FollowRequest{Subscription: followed.Subscription, Close: true}); err != nil {
|
||||
t.Fatalf("close follow: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/sessioncatalog"
|
||||
)
|
||||
|
||||
// A save event indexes a transcript under the global fallback scope when its
|
||||
// sidecar names none, while archive assigns the owner from the project store
|
||||
// the file lives in. The row must leave the sidebar it was listed in anyway.
|
||||
func TestArchivedSourceOwnedByAnotherWorkspaceLeavesSidebar(t *testing.T) {
|
||||
for _, heads := range []int{1, 2} {
|
||||
name := "single-head"
|
||||
if heads == 2 {
|
||||
name = "branched"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
workspace := filepath.Join(t.TempDir(), "project")
|
||||
if err := os.MkdirAll(workspace, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: workspace}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := desktopSessionDir(workspace)
|
||||
path := filepath.Join(dir, "20260922-122527.100224400-deepseek-chat.jsonl")
|
||||
legacy := agent.NewSession("system")
|
||||
legacy.Add(provider.Message{ID: "q0", Role: provider.RoleUser, Content: "question"})
|
||||
legacy.Add(provider.Message{ID: "a0", Role: provider.RoleAssistant, Content: "answer"})
|
||||
if err := legacy.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if heads == 2 {
|
||||
if _, err := legacy.ForkHead(path, legacy.Snapshot()[1].ID, agent.HeadKindFork, "sibling"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy.Add(provider.Message{Role: provider.RoleAssistant, Content: "sibling answer"})
|
||||
if err := legacy.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := agent.SaveBranchMeta(path, agent.BranchMeta{TopicID: "topic_unscoped"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := NewApp()
|
||||
app.ctx = t.Context()
|
||||
pinDesktopSessionRoot(t, app)
|
||||
installNoopRuntimeEvents(app)
|
||||
t.Cleanup(app.closeSessionServices)
|
||||
catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true, MetadataOnly: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.sessionCatalog.Store(catalog)
|
||||
t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
|
||||
globalRows := func() []ProjectNode {
|
||||
t.Helper()
|
||||
// The desktop's persist observer indexes each save under this fallback.
|
||||
if err := catalog.IndexSessionPath(context.Background(), sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.ReleaseReadSnapshot(page.SnapshotID)
|
||||
return page.Items
|
||||
}
|
||||
rows := globalRows()
|
||||
if len(rows) != heads {
|
||||
t.Fatalf("global rows before archive = %+v, want %d", rows, heads)
|
||||
}
|
||||
for _, row := range rows {
|
||||
result, err := app.ArchiveSessionTarget(SessionSelector{Ref: row.Session, Source: row.Source, SessionPath: row.SessionPath})
|
||||
if err != nil || !result.Committed {
|
||||
t.Fatalf("archive %+v = %+v, %v", row.Source, result, err)
|
||||
}
|
||||
}
|
||||
if left := globalRows(); len(left) != 0 {
|
||||
var operationErr *SessionOperationError
|
||||
err := app.SetSessionPinned(SessionSelector{Source: left[0].Source, SessionPath: left[0].SessionPath}, true)
|
||||
if errors.As(err, &operationErr) {
|
||||
t.Fatalf("archived rows stay listed (%d) and refuse every action with %q", len(left), operationErr.Code)
|
||||
}
|
||||
t.Fatalf("archived rows stay listed: %+v", left)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -130,9 +130,11 @@ default = "model-a"
|
||||
if err := os.WriteFile(filepath.Join(rootA, "reasonix.toml"), []byte(`default_model = "local/model-a"`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, rootA)
|
||||
if err := os.WriteFile(filepath.Join(rootB, "reasonix.toml"), []byte(`default_model = "local/model-b"`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, rootB)
|
||||
draftA, err := a.seedPreviousDraftForTarget("project", rootA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -405,6 +407,8 @@ args = ["serve"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, root)
|
||||
enableProjectMCPForTest(t, root)
|
||||
draft, err := a.seedPreviousDraftForTarget("project", root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -59,7 +59,7 @@ func (a *App) listSessionsFromDir(dir, active string) []SessionMeta {
|
||||
Preview: node.Preview, Turns: node.Turns, TurnsState: node.TurnsState,
|
||||
CreatedAt: node.CreatedAt, LastActivityAt: node.LastActivityAt, ModTime: node.LastActivityAt})
|
||||
}
|
||||
adopted := adoptedSourceRows(state, "")
|
||||
adopted := adoptedSourceRows(state)
|
||||
catalog := a.sessionCatalog.Load()
|
||||
if catalog == nil {
|
||||
return v3
|
||||
|
||||
@@ -64,7 +64,9 @@ func (a *App) surfaceForCanonicalSession(ref session.SessionRef, workspace works
|
||||
defer releaseAdmission()
|
||||
if scope == "project" {
|
||||
saveWorkspace(workspaceRoot)
|
||||
a.registerProjectRoot(workspaceRoot)
|
||||
if err := a.registerProjectRoot(workspaceRoot); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
}
|
||||
model, toolApprovalMode := desktopNewSessionDefaults(scope, actualRoot)
|
||||
tab := &WorkspaceTab{
|
||||
|
||||
@@ -18,12 +18,10 @@ var sourceHeadRows sync.Map
|
||||
// A mapped DAG head must not return as an unidentified path placeholder while
|
||||
// its index is unavailable. This is a display filter, not an ownership alias:
|
||||
// independently identified siblings remain eligible for their own rows.
|
||||
func adoptedSourceRows(state workspacestate.State, workspaceID string) map[string]bool {
|
||||
// Adoption belongs to the source, not to the workspace a sidecar lists it in.
|
||||
func adoptedSourceRows(state workspacestate.State) map[string]bool {
|
||||
adopted := map[string]bool{}
|
||||
for _, mapping := range state.SourceMappings {
|
||||
if workspaceID != "" && mapping.WorkspaceID != workspaceID {
|
||||
continue
|
||||
}
|
||||
for _, key := range state.SourceKeys(mapping.SourceKey) {
|
||||
adopted["source\x00local\x00"+key] = true
|
||||
}
|
||||
|
||||
@@ -142,17 +142,18 @@ func (a *App) lazyProjectTopicSnapshot(req ProjectTopicPageRequest, reader works
|
||||
func lazyTopicSourceExclusions(state workspacestate.State, workspaceID string) ([]string, bool) {
|
||||
excluded := []string{}
|
||||
for _, mapping := range state.SourceMappings {
|
||||
if mapping.WorkspaceID != workspaceID {
|
||||
continue
|
||||
}
|
||||
// Canonical directories have one source identity even if an older
|
||||
// migration receipt retained their originating head ID.
|
||||
if mapping.Format != "canonical" && !sourceMappingHasPathAlias(mapping) {
|
||||
if mapping.WorkspaceID != workspaceID {
|
||||
continue
|
||||
}
|
||||
// The catalog may not yet know about a new sibling, or the head
|
||||
// index may be unavailable. Only the head-aware adapter can filter
|
||||
// adopted identities without inventing a new path-only source.
|
||||
return nil, false
|
||||
}
|
||||
// A sidecar can list a file outside the workspace that adopted it.
|
||||
excluded = append(excluded, mapping.Path)
|
||||
}
|
||||
return excluded, true
|
||||
|
||||
@@ -177,7 +177,7 @@ func (a *App) materializeProjectTopics(req ProjectTopicPageRequest, reader works
|
||||
}
|
||||
workspace.SessionIDs = admittedWorkspaceTopicMembers(req, state, workspace)
|
||||
infos, _ := listWorkspaceSessionInfo(a.bootContext(), reader, workspace.SessionIDs)
|
||||
adopted := adoptedSourceRows(state, workspaceID)
|
||||
adopted := adoptedSourceRows(state)
|
||||
adoptedTopics := state.AdoptedTopicIDs(workspaceID)
|
||||
all := req
|
||||
all.Cursor = ""
|
||||
|
||||
+27
-123
@@ -30,6 +30,7 @@ import (
|
||||
"reasonix/internal/netclient"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
// settings_app.go is the desktop Settings panel's command surface: it reads the
|
||||
@@ -1502,45 +1503,23 @@ func (a *App) loadDesktopUserConfigForEdit() (*config.Config, string, error) {
|
||||
return a.loadDesktopUserConfigForEditForRoot(a.activeWorkspaceRoot())
|
||||
}
|
||||
|
||||
func (a *App) loadDesktopUserConfigForEditForRoot(root string) (*config.Config, string, error) {
|
||||
// loadDesktopUserConfigForEditForRoot reads the user config alone. A
|
||||
// workspace's reasonix.toml is never adopted into it: that file arrives with a
|
||||
// checkout, and copying it would turn its sandbox, permission and [bot] values
|
||||
// into the user's own.
|
||||
func (a *App) loadDesktopUserConfigForEditForRoot(_ string) (*config.Config, string, error) {
|
||||
userPath := config.UserConfigPath()
|
||||
if userPath == "" {
|
||||
return nil, "", fmt.Errorf("cannot resolve user config directory")
|
||||
}
|
||||
if _, err := os.Stat(userPath); err == nil {
|
||||
cfg, err := config.LoadForEditReadOnlyStrict(userPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := normalizeLegacyDesktopProviderAccessForSettings(cfg, userPath); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := a.migrateLegacyBotConfigToUserForRoot(root, cfg, userPath); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return cfg, userPath, nil
|
||||
}
|
||||
cfg, err := config.LoadForEditReadOnlyStrict(userPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
legacyPath := config.SourcePathForRoot(root)
|
||||
if legacyPath == "" || sameConfigPath(legacyPath, userPath) {
|
||||
if err := normalizeLegacyDesktopProviderAccessForSettings(cfg, userPath); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return cfg, userPath, nil
|
||||
}
|
||||
legacyCfg, err := config.LoadForEditReadOnlyStrict(legacyPath)
|
||||
if err != nil {
|
||||
if err := normalizeLegacyDesktopProviderAccessForSettings(cfg, userPath); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
normalizeLegacyDesktopProviderAccessInMemory(legacyCfg, legacyPath)
|
||||
legacyCfg.ConfigVersion = config.Default().ConfigVersion
|
||||
if err := migrateLegacyBotConfigToUser(cfg, legacyCfg, userPath); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return legacyCfg, userPath, nil
|
||||
return cfg, userPath, nil
|
||||
}
|
||||
|
||||
// loadDesktopUserConfigForView loads the user config for read-only callers.
|
||||
@@ -1574,94 +1553,18 @@ func (a *App) loadDesktopUserConfigForViewWithCredentialsForRoot(root string) (*
|
||||
}
|
||||
|
||||
// loadDesktopUserConfigReadOnlyForRoot is the shared pure-read loader behind
|
||||
// the View variants: same shape as loadDesktopUserConfigForEdit, but every
|
||||
// legacy migration stays in memory (zero SaveTo) and resolves from root.
|
||||
func (a *App) loadDesktopUserConfigReadOnlyForRoot(root string, load func(string) (*config.Config, error)) (*config.Config, string, error) {
|
||||
// the View variants: the user config alone, never written to.
|
||||
func (a *App) loadDesktopUserConfigReadOnlyForRoot(_ string, load func(string) (*config.Config, error)) (*config.Config, string, error) {
|
||||
userPath := config.UserConfigPath()
|
||||
if userPath == "" {
|
||||
return nil, "", fmt.Errorf("cannot resolve user config directory")
|
||||
}
|
||||
if _, err := os.Stat(userPath); err == nil {
|
||||
cfg, err := load(userPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
normalizeLegacyDesktopProviderAccessInMemory(cfg, userPath)
|
||||
legacyPath := config.SourcePathForRoot(root)
|
||||
if legacyPath != "" && !sameConfigPath(legacyPath, userPath) {
|
||||
legacyCfg, err := load(legacyPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
mergeLegacyBotConfigInMemory(cfg, legacyCfg)
|
||||
}
|
||||
return cfg, userPath, nil
|
||||
}
|
||||
cfg, err := load(userPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
legacyPath := config.SourcePathForRoot(root)
|
||||
if legacyPath == "" || sameConfigPath(legacyPath, userPath) {
|
||||
normalizeLegacyDesktopProviderAccessInMemory(cfg, userPath)
|
||||
return cfg, userPath, nil
|
||||
}
|
||||
// The user config does not exist yet: serve the legacy config as the view.
|
||||
// It already carries any legacy bot config, so no merge is needed; the
|
||||
// write path creates the migrated user file later.
|
||||
legacyCfg, err := load(legacyPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
normalizeLegacyDesktopProviderAccessInMemory(legacyCfg, legacyPath)
|
||||
legacyCfg.ConfigVersion = config.Default().ConfigVersion
|
||||
return legacyCfg, userPath, nil
|
||||
}
|
||||
|
||||
// migrateLegacyBotConfigToUserForRoot is the write-path legacy bot-config
|
||||
// migration against an explicit workspace's legacy config file. Callers must
|
||||
// hold config.LockUserConfigEdits() (see loadDesktopUserConfigForEdit).
|
||||
func (a *App) migrateLegacyBotConfigToUserForRoot(root string, userCfg *config.Config, userPath string) error {
|
||||
if userCfg == nil {
|
||||
return nil
|
||||
}
|
||||
legacyPath := config.SourcePathForRoot(root)
|
||||
if legacyPath == "" || sameConfigPath(legacyPath, userPath) {
|
||||
return nil
|
||||
}
|
||||
legacyCfg, err := config.LoadForEditReadOnlyStrict(legacyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return migrateLegacyBotConfigToUser(userCfg, legacyCfg, userPath)
|
||||
}
|
||||
|
||||
// migrateLegacyBotConfigToUser is the write-path variant: it merges the legacy
|
||||
// bot config in memory and persists the result to userPath. Callers must hold
|
||||
// config.LockUserConfigEdits() (see loadDesktopUserConfigForEdit). Read paths
|
||||
// use mergeLegacyBotConfigInMemory instead.
|
||||
func migrateLegacyBotConfigToUser(userCfg, legacyCfg *config.Config, userPath string) error {
|
||||
if !mergeLegacyBotConfigInMemory(userCfg, legacyCfg) {
|
||||
return nil
|
||||
}
|
||||
if err := userCfg.SaveTo(userPath); err != nil {
|
||||
return fmt.Errorf("migrate legacy bot config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeLegacyBotConfigInMemory copies the legacy bot config onto userCfg when
|
||||
// the user config has none of its own. It never touches disk; it reports
|
||||
// whether userCfg changed (i.e. whether a write path should persist it).
|
||||
func mergeLegacyBotConfigInMemory(userCfg, legacyCfg *config.Config) bool {
|
||||
if userCfg == nil || legacyCfg == nil || desktopBotConfigConfigured(userCfg.Bot) {
|
||||
return false
|
||||
}
|
||||
if !desktopBotConfigConfigured(legacyCfg.Bot) {
|
||||
return false
|
||||
}
|
||||
userCfg.Bot = legacyCfg.Bot
|
||||
return true
|
||||
normalizeLegacyDesktopProviderAccessInMemory(cfg, userPath)
|
||||
return cfg, userPath, nil
|
||||
}
|
||||
|
||||
func desktopBotConfigConfigured(bot config.BotConfig) bool {
|
||||
@@ -1796,20 +1699,6 @@ func providerCredentialSourceNotice(apiKeyEnv, value string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func sameConfigPath(a, b string) bool {
|
||||
a = strings.TrimSpace(a)
|
||||
b = strings.TrimSpace(b)
|
||||
if a == "" || b == "" {
|
||||
return false
|
||||
}
|
||||
aAbs, aErr := filepath.Abs(a)
|
||||
bAbs, bErr := filepath.Abs(b)
|
||||
if aErr == nil && bErr == nil {
|
||||
return filepath.Clean(aAbs) == filepath.Clean(bAbs)
|
||||
}
|
||||
return filepath.Clean(a) == filepath.Clean(b)
|
||||
}
|
||||
|
||||
// rebuild builds a replacement controller from the (just-changed) config and
|
||||
// swaps it in only after the target session lease is available. The old
|
||||
// controller stays usable if the rebuild fails.
|
||||
@@ -2946,6 +2835,21 @@ func (a *App) SetPermissionMode(mode string) error {
|
||||
|
||||
// AddPermissionRule appends a rule to the allow/ask/deny list.
|
||||
func (a *App) AddPermissionRule(list, rule string) error {
|
||||
tools := tool.BuiltinContractEntries()
|
||||
if ctrl, ok := a.activeCtrl().(interface{ AllToolContractEntries() []tool.ContractEntry }); ok {
|
||||
tools = append(tools, ctrl.AllToolContractEntries()...)
|
||||
}
|
||||
cfg, err := config.LoadForRootWithoutCredentialsReadOnly(a.activeWorkspaceRoot())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
servers := make([]string, 0, len(cfg.Plugins))
|
||||
for _, entry := range cfg.Plugins {
|
||||
servers = append(servers, entry.Name)
|
||||
}
|
||||
if err := validateSavedPermissionRule(list, rule, tools, servers); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.applyConfigChange(func(c *config.Config) error { return c.AddPermissionRule(list, rule) })
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 675 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user