mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-09 10:09:47 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54c73168c3 | ||
|
|
36c1faba14 | ||
|
|
1808ff6d63 | ||
|
|
9c3123c7a3 | ||
|
|
aecf4e6960 | ||
|
|
d4e11410cb | ||
|
|
d2cb0aa894 | ||
|
|
3cb73b3bf4 | ||
|
|
ec866730e0 | ||
|
|
3065db7a49 | ||
|
|
ae2e9053f5 | ||
|
|
96297a2146 | ||
|
|
48b340a877 | ||
|
|
d67f2835ac | ||
|
|
6512f0db40 | ||
|
|
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 | ||
|
|
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:
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
+8
-10
@@ -7449,11 +7449,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 +8513,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 +8672,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 +8709,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 +9873,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()
|
||||
|
||||
+9
-33
@@ -6909,39 +6909,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 +6973,7 @@ url = %q
|
||||
`, srv.URL), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enableProjectMCPForTest(t, dir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -8434,6 +8402,7 @@ command = "reasonix-missing-mcp-binary"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -8961,6 +8930,7 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9022,6 +8992,7 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 +9027,7 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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"))
|
||||
@@ -9230,6 +9202,7 @@ tier = "background"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9280,6 +9253,7 @@ name = "codegraph"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(desktopFakeTool{name: "mcp__codegraph__connect"})
|
||||
@@ -9338,6 +9312,7 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9431,6 +9406,7 @@ tier = "eager"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+3
-2
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -1683,6 +1683,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",
|
||||
|
||||
@@ -1437,6 +1437,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"projectTree.colorPink": "粉色",
|
||||
"projectTree.emptyNoMatch": "沒有匹配的專案或會話",
|
||||
"projectTree.emptyNoProjects": "還沒有專案",
|
||||
"projectTree.loadingProjects": "正在讀取專案…",
|
||||
"projectTree.newTopicTooltip": "新建會話",
|
||||
"projectTree.addProjectTooltip": "新增專案",
|
||||
"projectTree.remoteOpenWindow": "在遠端視窗開啟",
|
||||
|
||||
@@ -1684,6 +1684,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"projectTree.colorPink": "粉色",
|
||||
"projectTree.emptyNoMatch": "没有匹配的项目或会话",
|
||||
"projectTree.emptyNoProjects": "还没有项目",
|
||||
"projectTree.loadingProjects": "正在读取项目…",
|
||||
"projectTree.newTopicTooltip": "新建会话",
|
||||
"projectTree.addProjectTooltip": "添加新项目",
|
||||
"projectTree.remoteOpenWindow": "在远程窗口打开",
|
||||
|
||||
@@ -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,29 @@ 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}
|
||||
for _, sibling := range recoveredLegacySiblings(source) {
|
||||
siblingID, siblingSource, err := a.historicalSourceForSelector(SessionSelector{Source: &SessionSourceRef{HostID: localDesktopHostID, Path: sibling}})
|
||||
if err != nil {
|
||||
slog.Warn("desktop: recovered 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: recovered 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 +71,41 @@ 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
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -405,6 +405,7 @@ args = ["serve"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/gitcmd"
|
||||
goaldomain "reasonix/internal/goal"
|
||||
"reasonix/internal/session"
|
||||
)
|
||||
@@ -75,6 +76,8 @@ type TabMeta struct {
|
||||
ModelSettingsPending bool `json:"modelSettingsPending,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
Cwd string `json:"cwd"`
|
||||
// repo is the tab session's git identity for WorkspaceRoot; never serialized.
|
||||
repo gitcmd.Repo
|
||||
}
|
||||
|
||||
// setAuthenticationMeta only exposes pending settings belonging to this runtime.
|
||||
|
||||
+5
-2
@@ -2138,7 +2138,7 @@ type wireEventTab struct {
|
||||
|
||||
func enrichTabMeta(meta TabMeta) TabMeta {
|
||||
if meta.Active {
|
||||
meta.GitBranch = workspaceGitBranchForMeta(meta.WorkspaceRoot)
|
||||
meta.GitBranch = workspaceGitBranchForMeta(meta.WorkspaceRoot, meta.repo)
|
||||
}
|
||||
return meta
|
||||
}
|
||||
@@ -2146,7 +2146,7 @@ func enrichTabMeta(meta TabMeta) TabMeta {
|
||||
func enrichTabMetas(metas []TabMeta) []TabMeta {
|
||||
for i := range metas {
|
||||
if metas[i].Active {
|
||||
metas[i].GitBranch = workspaceGitBranchForMeta(metas[i].WorkspaceRoot)
|
||||
metas[i].GitBranch = workspaceGitBranchForMeta(metas[i].WorkspaceRoot, metas[i].repo)
|
||||
}
|
||||
}
|
||||
return metas
|
||||
@@ -2192,6 +2192,9 @@ func (a *App) tabMeta(tab *WorkspaceTab, active bool) TabMeta {
|
||||
Cwd: tab.WorkspaceRoot,
|
||||
IsolatedWorktree: floor.isolated,
|
||||
}
|
||||
if repo, ok := sessionWorkspaceRepo(tab.Ctrl, tab.WorkspaceRoot); ok {
|
||||
m.repo = repo
|
||||
}
|
||||
if strings.TrimSpace(tab.SessionID) != "" {
|
||||
m.Session = &session.SessionRef{HostID: "local", SessionID: strings.TrimSpace(tab.SessionID)}
|
||||
}
|
||||
|
||||
@@ -480,7 +480,9 @@ func (a *App) refreshTabMetaExtras(tab *WorkspaceTab) {
|
||||
|
||||
gitBranch := ""
|
||||
if root != "" {
|
||||
gitBranch = workspaceGitBranch(root)
|
||||
if repo, err := workspaceRepo(root, ctrl); err == nil {
|
||||
gitBranch = workspaceGitBranch(repo)
|
||||
}
|
||||
}
|
||||
imageInputEnabled := false
|
||||
visionFallbackEnabled := false
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/transcript"
|
||||
)
|
||||
|
||||
// rendererFollowCursor mirrors TranscriptFollowClient.validate in
|
||||
// desktop/frontend/src/lib/transcriptFollowClient.ts (lines 174-220): any
|
||||
// error it returns is one that makes the renderer drop the subscription.
|
||||
type rendererFollowCursor struct {
|
||||
revision, coverage uint64
|
||||
indexes map[string]uint64
|
||||
attempts map[string]string
|
||||
results map[string]uint64
|
||||
resultOrder []string
|
||||
deltas map[string]string
|
||||
}
|
||||
|
||||
func newRendererFollowCursor(snapshot *transcript.Snapshot) *rendererFollowCursor {
|
||||
c := &rendererFollowCursor{revision: snapshot.ProjectionRevision, coverage: snapshot.CoveredThroughSeq,
|
||||
indexes: map[string]uint64{}, attempts: map[string]string{}, results: map[string]uint64{}, deltas: map[string]string{}}
|
||||
for _, attempt := range snapshot.ActiveAttempts {
|
||||
c.indexes[attempt.ID], c.attempts[attempt.ID] = attempt.NextIndex, attempt.MessageID
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *rendererFollowCursor) noteResult(messageID string, sequence uint64) {
|
||||
if _, ok := c.results[messageID]; !ok {
|
||||
c.resultOrder = append(c.resultOrder, messageID)
|
||||
}
|
||||
c.results[messageID] = sequence
|
||||
for len(c.resultOrder) > 192 {
|
||||
delete(c.results, c.resultOrder[0])
|
||||
c.resultOrder = c.resultOrder[1:]
|
||||
}
|
||||
}
|
||||
|
||||
func (c *rendererFollowCursor) accept(changes []transcript.Change) error {
|
||||
ordered := slices.Clone(changes)
|
||||
slices.SortFunc(ordered, func(a, b transcript.Change) int { return cmp.Compare(a.Revision, b.Revision) })
|
||||
for _, change := range ordered {
|
||||
if change.Revision <= c.revision {
|
||||
continue
|
||||
}
|
||||
if change.ResetRequired || change.Revision != c.revision+1 {
|
||||
return fmt.Errorf("revision_gap at revision %d", change.Revision)
|
||||
}
|
||||
if change.FirstSeq != 0 {
|
||||
if change.FirstSeq != c.coverage+1 || change.CommitSeq < change.FirstSeq {
|
||||
return fmt.Errorf("business_gap at revision %d: first=%d coverage=%d", change.Revision, change.FirstSeq, c.coverage)
|
||||
}
|
||||
c.coverage = change.CommitSeq
|
||||
} else if change.CommitSeq != c.coverage {
|
||||
return fmt.Errorf("frame_cut_mismatch at revision %d: commit=%d coverage=%d", change.Revision, change.CommitSeq, c.coverage)
|
||||
}
|
||||
e := change.Event
|
||||
for _, record := range change.Records {
|
||||
if record.MessageID != "" {
|
||||
c.noteResult(record.MessageID, change.CommitSeq)
|
||||
}
|
||||
}
|
||||
if e != nil && e.Kind == "stream_attempt" && e.StreamAttempt != nil && e.StreamAttempt.Action == "begin" {
|
||||
if e.MessageID == "" {
|
||||
return fmt.Errorf("sampling_identity_missing at revision %d", change.Revision)
|
||||
}
|
||||
c.indexes[e.StreamAttempt.ID], c.attempts[e.StreamAttempt.ID] = 0, e.MessageID
|
||||
}
|
||||
if change.AttemptID != "" && change.ResultSeq == 0 && (e == nil || e.Kind != "stream_attempt") {
|
||||
if c.indexes[change.AttemptID] != change.Index {
|
||||
return fmt.Errorf("sampling_gap at revision %d", change.Revision)
|
||||
}
|
||||
c.indexes[change.AttemptID] = change.Index + 1
|
||||
}
|
||||
if change.ResultSeq != 0 && (change.ResultSeq > c.coverage || (change.ResultKind != "message/complete" && change.ResultKind != "message/interrupted")) {
|
||||
return fmt.Errorf("settlement_not_committed at revision %d", change.Revision)
|
||||
}
|
||||
if change.ResultSeq != 0 {
|
||||
message := c.attempts[change.AttemptID]
|
||||
recorded, known := c.results[message]
|
||||
if message == "" || e == nil || message != e.MessageID || known && recorded != change.ResultSeq {
|
||||
return fmt.Errorf("settlement_identity_mismatch at revision %d", change.Revision)
|
||||
}
|
||||
}
|
||||
if e != nil && e.Kind == "stream_attempt" && e.StreamAttempt != nil && e.StreamAttempt.Action != "begin" {
|
||||
delete(c.indexes, e.StreamAttempt.ID)
|
||||
delete(c.attempts, e.StreamAttempt.ID)
|
||||
}
|
||||
if e != nil && (e.Kind == "text" || e.Kind == "reasoning") {
|
||||
c.deltas[e.Kind] += e.Text
|
||||
}
|
||||
c.revision = change.Revision
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// legacyStreamProvider answers each turn from a script: a streamed reply, a
|
||||
// tool-call-only round followed by a reply, or a stream that stalls until the
|
||||
// request is cancelled.
|
||||
type legacyStreamProvider struct {
|
||||
mu sync.Mutex
|
||||
mode string
|
||||
toolSent bool
|
||||
}
|
||||
|
||||
func (p *legacyStreamProvider) set(mode string) {
|
||||
p.mu.Lock()
|
||||
p.mode, p.toolSent = mode, false
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *legacyStreamProvider) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
p.mu.Lock()
|
||||
mode, toolRound := p.mode, p.mode == "tool" && !p.toolSent
|
||||
if toolRound {
|
||||
p.toolSent = true
|
||||
}
|
||||
p.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
flusher, _ := w.(http.Flusher)
|
||||
send := func(delta string) {
|
||||
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{`+delta+`}}]}`+"\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
switch {
|
||||
case toolRound:
|
||||
send(`"tool_calls":[{"index":0,"id":"call_read","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"note.txt\"}"}}]`)
|
||||
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}`+"\n\n")
|
||||
case mode == "stall":
|
||||
send(`"reasoning_content":"pondering"`)
|
||||
<-r.Context().Done()
|
||||
return
|
||||
default:
|
||||
for _, chunk := range []string{`"reasoning_content":"weigh "`, `"reasoning_content":"it"`, `"content":"stream"`, `"content":"ed"`} {
|
||||
send(chunk)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
_, _ = io.WriteString(w, "data: [DONE]\n\n")
|
||||
}
|
||||
|
||||
type legacyFollowTurn struct{ name, mode, reasoning, text string }
|
||||
|
||||
func TestLegacyTopicTranscriptFollowStreamsDeltasTheRendererAccepts(t *testing.T) {
|
||||
reply := legacyFollowTurn{"streamed reply", "stream", "weigh it", "streamed"}
|
||||
for _, first := range []legacyFollowTurn{
|
||||
reply,
|
||||
{"tool-call-only round", "tool", "weigh it", "streamed"},
|
||||
{"cancelled round", "stall", "pondering", ""},
|
||||
} {
|
||||
t.Run(first.name, func(t *testing.T) { followLegacyTopicTurns(t, first, reply) })
|
||||
}
|
||||
}
|
||||
|
||||
func followLegacyTopicTurns(t *testing.T, turns ...legacyFollowTurn) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
|
||||
script := &legacyStreamProvider{}
|
||||
stub := httptest.NewServer(script)
|
||||
t.Cleanup(stub.Close)
|
||||
cfg := config.Default()
|
||||
cfg.DefaultModel = "test/test-model"
|
||||
cfg.Desktop.ProviderAccess = []string{"test"}
|
||||
cfg.Providers = []config.ProviderEntry{{Name: "test", Kind: "openai", BaseURL: stub.URL, Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"}}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "note.txt"), []byte("note"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := addProject(root, "Project"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := writeTopicSession(t, dir, "legacy.jsonl", "legacy-topic", "Legacy", root)
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
app.readyHook = func() {}
|
||||
t.Cleanup(func() { app.shutdown(context.Background()) })
|
||||
events := newActivationEventRecorder(app)
|
||||
ticket, err := app.StartTopicActivation(TopicActivationRequest{Scope: "project", WorkspaceRoot: root, TopicID: "legacy-topic", SessionPath: path, RequestID: "open"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ev := events.waitFor(t, func(ev TopicActivationEvent) bool {
|
||||
return ev.RequestID == "open" && (ev.Phase == "ready" || ev.Phase == "failed")
|
||||
}); ev.Phase != "ready" {
|
||||
t.Fatalf("activation failed: %+v", ev)
|
||||
}
|
||||
|
||||
base, err := app.TranscriptFollowForTab(ticket.TabID, transcript.FollowRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = app.TranscriptFollowForTab(ticket.TabID, transcript.FollowRequest{Subscription: base.Subscription, Close: true})
|
||||
})
|
||||
if base.StorageBackend != "legacy" {
|
||||
t.Fatalf("fixture must follow a legacy-backed session, got %q", base.StorageBackend)
|
||||
}
|
||||
cursor := newRendererFollowCursor(base.Snapshot)
|
||||
for _, turn := range turns {
|
||||
script.set(turn.mode)
|
||||
clear(cursor.deltas)
|
||||
if err := app.SubmitToTab(ticket.TabID, turn.name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cancelled := false
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for done := false; !done; {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("%s did not finish", turn.name)
|
||||
}
|
||||
resp, err := app.TranscriptFollowForTab(ticket.TabID, transcript.FollowRequest{Subscription: base.Subscription, AfterRevision: cursor.revision})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.ResetRequired {
|
||||
t.Fatalf("%s: follower was reset", turn.name)
|
||||
}
|
||||
if err := cursor.accept(resp.Changes); err != nil {
|
||||
t.Fatalf("%s: renderer would discard the follow and re-baseline: %v", turn.name, err)
|
||||
}
|
||||
for _, change := range resp.Changes {
|
||||
done = done || change.Event != nil && change.Event.Kind == "turn_done"
|
||||
}
|
||||
if turn.mode == "stall" && !cancelled && cursor.deltas["reasoning"] != "" {
|
||||
app.CancelTab(ticket.TabID)
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
if cursor.deltas["reasoning"] != turn.reasoning || cursor.deltas["text"] != turn.text {
|
||||
t.Fatalf("%s: renderer received deltas %+v", turn.name, cursor.deltas)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,14 +68,15 @@ func (a *App) workspaceChanges(ctx context.Context, tabID string) WorkspaceChang
|
||||
return out
|
||||
}
|
||||
|
||||
base, err := workspaceBaseFromRoot(workspaceRoot)
|
||||
repo, err := workspaceRepo(workspaceRoot, ctrl)
|
||||
if err != nil {
|
||||
out.GitAvailable = false
|
||||
out.GitErr = err.Error()
|
||||
return out
|
||||
}
|
||||
base := repo.Dir
|
||||
|
||||
out.GitBranch, _ = workspaceGitBranchContext(ctx, base)
|
||||
out.GitBranch, _ = workspaceGitBranchContext(ctx, repo)
|
||||
|
||||
changes := map[string]*workspaceChangeAccumulator{}
|
||||
add := func(path string) *workspaceChangeAccumulator {
|
||||
@@ -108,7 +109,7 @@ func (a *App) workspaceChanges(ctx context.Context, tabID string) WorkspaceChang
|
||||
}
|
||||
}
|
||||
|
||||
gitEntries, gitErr := workspaceGitStatusContext(ctx, base)
|
||||
gitEntries, gitErr := workspaceGitStatusContext(ctx, repo)
|
||||
if gitErr != nil {
|
||||
out.GitAvailable = false
|
||||
out.GitErr = gitErr.Error()
|
||||
@@ -153,7 +154,7 @@ func (a *App) workspaceChanges(ctx context.Context, tabID string) WorkspaceChang
|
||||
return strings.ToLower(a.Path) < strings.ToLower(b.Path)
|
||||
})
|
||||
if out.GitAvailable {
|
||||
out.Added, out.Removed, out.Incomplete = workspaceGitDiffTally(ctx, base, untracked)
|
||||
out.Added, out.Removed, out.Incomplete = workspaceGitDiffTally(ctx, repo, untracked)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -173,13 +174,13 @@ func (a *App) workspaceChangesTarget(tabID string) (string, control.SessionAPI,
|
||||
return tab.WorkspaceRoot, tab.Ctrl, true
|
||||
}
|
||||
|
||||
func (a *App) workspaceBaseForTab(tabID string) (string, error) {
|
||||
func (a *App) workspaceRepoForTab(tabID string) (gitcmd.Repo, error) {
|
||||
tabID = strings.TrimSpace(tabID)
|
||||
workspaceRoot, _, ok := a.workspaceChangesTarget(tabID)
|
||||
workspaceRoot, ctrl, ok := a.workspaceChangesTarget(tabID)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("tab %q not found", tabID)
|
||||
return gitcmd.Repo{}, fmt.Errorf("tab %q not found", tabID)
|
||||
}
|
||||
return workspaceBaseFromRoot(workspaceRoot)
|
||||
return workspaceRepo(workspaceRoot, ctrl)
|
||||
}
|
||||
|
||||
// WorkspaceChangeDetail returns the current patch for one file in the
|
||||
@@ -191,10 +192,11 @@ func (a *App) WorkspaceChangeDetail(tabID, path string) (WorkspaceChangeDetailVi
|
||||
if !ok {
|
||||
return WorkspaceChangeDetailView{}, fmt.Errorf("tab %q not found", tabID)
|
||||
}
|
||||
base, err := workspaceBaseFromRoot(workspaceRoot)
|
||||
repo, err := workspaceRepo(workspaceRoot, ctrl)
|
||||
if err != nil {
|
||||
return WorkspaceChangeDetailView{}, err
|
||||
}
|
||||
base := repo.Dir
|
||||
rel := normalizeWorkspaceRelPath(base, path)
|
||||
if rel == "" {
|
||||
return WorkspaceChangeDetailView{}, os.ErrInvalid
|
||||
@@ -206,7 +208,7 @@ func (a *App) WorkspaceChangeDetail(tabID, path string) (WorkspaceChangeDetailVi
|
||||
return WorkspaceChangeDetailView{}, os.ErrInvalid
|
||||
}
|
||||
|
||||
if detail, found := workspaceGitChangeDetail(base, rel); found {
|
||||
if detail, found := workspaceGitChangeDetail(repo, rel); found {
|
||||
return detail, nil
|
||||
}
|
||||
if ctrl != nil {
|
||||
@@ -217,8 +219,9 @@ func (a *App) WorkspaceChangeDetail(tabID, path string) (WorkspaceChangeDetailVi
|
||||
return WorkspaceChangeDetailView{}, nil
|
||||
}
|
||||
|
||||
func workspaceGitChangeDetail(base, rel string) (WorkspaceChangeDetailView, bool) {
|
||||
entries, err := workspaceGitStatus(base)
|
||||
func workspaceGitChangeDetail(repo gitcmd.Repo, rel string) (WorkspaceChangeDetailView, bool) {
|
||||
base := repo.Dir
|
||||
entries, err := workspaceGitStatus(repo)
|
||||
if err != nil {
|
||||
return WorkspaceChangeDetailView{}, false
|
||||
}
|
||||
@@ -235,7 +238,7 @@ func workspaceGitChangeDetail(base, rel string) (WorkspaceChangeDetailView, bool
|
||||
|
||||
// Untracked files are omitted by git diff. In an unborn repository HEAD is
|
||||
// absent as well, so synthesize the same create/delete patch from disk.
|
||||
if entry.Status == "??" || !workspaceGitHasHead(base) {
|
||||
if entry.Status == "??" || !workspaceGitHasHead(repo) {
|
||||
detail, err := workspaceCheckpointChangeDetail(base, rel, nil)
|
||||
if err != nil {
|
||||
return WorkspaceChangeDetailView{}, false
|
||||
@@ -248,7 +251,7 @@ func workspaceGitChangeDetail(base, rel string) (WorkspaceChangeDetailView, bool
|
||||
if entry.OldPath != "" && entry.OldPath != rel {
|
||||
args = append(args, filepath.FromSlash(entry.OldPath))
|
||||
}
|
||||
raw, truncated, err := workspaceGitDiffOutput(args...)
|
||||
raw, truncated, err := workspaceGitDiffOutput(repo, args...)
|
||||
if err != nil {
|
||||
return WorkspaceChangeDetailView{}, false
|
||||
}
|
||||
@@ -264,12 +267,12 @@ func workspaceGitChangeDetail(base, rel string) (WorkspaceChangeDetailView, bool
|
||||
return WorkspaceChangeDetailView{Diff: &patch, Source: "git", Added: added, Removed: removed, Binary: binary}, true
|
||||
}
|
||||
|
||||
func workspaceGitHasHead(base string) bool {
|
||||
return workspaceGit("-C", base, "rev-parse", "--verify", "HEAD").Run() == nil
|
||||
func workspaceGitHasHead(repo gitcmd.Repo) bool {
|
||||
return workspaceGit(repo, "-C", repo.Dir, "rev-parse", "--verify", "HEAD").Run() == nil
|
||||
}
|
||||
|
||||
func workspaceGitDiffOutput(args ...string) ([]byte, bool, error) {
|
||||
cmd := workspaceGit(args...)
|
||||
func workspaceGitDiffOutput(repo gitcmd.Repo, args ...string) ([]byte, bool, error) {
|
||||
cmd := workspaceGit(repo, args...)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
@@ -381,43 +384,44 @@ func tallyUnifiedPatch(patch string) (added, removed int) {
|
||||
return added, removed
|
||||
}
|
||||
|
||||
// workspaceGit builds a console-hidden git probe. gitcmd supplies the shared
|
||||
// invocation baseline: CREATE_NO_WINDOW so git's own children inherit the
|
||||
// invisible console, and the config overrides that keep a probe from spawning a
|
||||
// background daemon that opens a console of its own (#3906).
|
||||
func workspaceGit(args ...string) *exec.Cmd {
|
||||
return workspaceGitCommand(context.Background(), args...)
|
||||
// workspaceGit builds a console-hidden git probe against repo's pinned
|
||||
// identity. gitcmd supplies the shared invocation baseline: CREATE_NO_WINDOW so
|
||||
// git's own children inherit the invisible console, and the config overrides
|
||||
// that keep a probe from spawning a background daemon (#3906).
|
||||
func workspaceGit(repo gitcmd.Repo, args ...string) *exec.Cmd {
|
||||
return workspaceGitCommand(context.Background(), repo, args...)
|
||||
}
|
||||
|
||||
func workspaceGitCommand(ctx context.Context, args ...string) *exec.Cmd {
|
||||
return gitcmd.Command(ctx, "", args...)
|
||||
func workspaceGitCommand(ctx context.Context, repo gitcmd.Repo, args ...string) *exec.Cmd {
|
||||
return repo.Command(ctx, args...)
|
||||
}
|
||||
|
||||
func workspaceGitOutputWithTimeout(timeout time.Duration, args ...string) ([]byte, error) {
|
||||
func workspaceGitOutputWithTimeout(timeout time.Duration, repo gitcmd.Repo, args ...string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return workspaceGitCommand(ctx, args...).Output()
|
||||
return workspaceGitCommand(ctx, repo, args...).Output()
|
||||
}
|
||||
|
||||
func workspaceGitStatus(base string) ([]gitStatusEntry, error) {
|
||||
func workspaceGitStatus(repo gitcmd.Repo) ([]gitStatusEntry, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
return workspaceGitStatusContext(ctx, base)
|
||||
return workspaceGitStatusContext(ctx, repo)
|
||||
}
|
||||
|
||||
func workspaceGitStatusContext(ctx context.Context, base string) ([]gitStatusEntry, error) {
|
||||
func workspaceGitStatusContext(ctx context.Context, repo gitcmd.Repo) ([]gitStatusEntry, error) {
|
||||
base := repo.Dir
|
||||
// Git's porcelain paths are repository-relative even when -C points at a
|
||||
// subdirectory. Derive the textual repository prefix from Git itself instead
|
||||
// of comparing absolute paths: Windows may spell the same directory once as
|
||||
// an 8.3 path and once as a long path, which makes filepath.Rel reject every
|
||||
// otherwise valid status entry.
|
||||
prefixCmd := workspaceGitCommand(ctx, "-C", base, "rev-parse", "--show-prefix")
|
||||
prefixCmd := workspaceGitCommand(ctx, repo, "-C", base, "rev-parse", "--show-prefix")
|
||||
prefixRaw, err := prefixCmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := strings.TrimSpace(string(prefixRaw))
|
||||
cmd := workspaceGitCommand(ctx, "-C", base, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ".")
|
||||
cmd := workspaceGitCommand(ctx, repo, "-C", base, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ".")
|
||||
raw, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -492,7 +496,7 @@ func workspaceRelPathFromGitPrefix(base, prefix, path string) string {
|
||||
// metadata is less harmful than blocking tab activation or hydration. Workflows
|
||||
// that need an immediate git read, such as WorkspaceChanges, should call
|
||||
// workspaceGitBranch directly.
|
||||
func workspaceGitBranchForMeta(base string) string {
|
||||
func workspaceGitBranchForMeta(base string, repo gitcmd.Repo) string {
|
||||
key := filepath.Clean(base)
|
||||
now := time.Now()
|
||||
|
||||
@@ -507,7 +511,7 @@ func workspaceGitBranchForMeta(base string) string {
|
||||
cached.request = new(int)
|
||||
workspaceGitBranchCache.entries[key] = cached
|
||||
workspaceGitBranchCache.Unlock()
|
||||
go refreshWorkspaceGitBranchForMeta(key, base, cached.request)
|
||||
go refreshWorkspaceGitBranchForMeta(key, base, repo, cached.request)
|
||||
return branch
|
||||
}
|
||||
|
||||
@@ -519,11 +523,13 @@ func workspaceGitBranchForMeta(base string) string {
|
||||
}
|
||||
workspaceGitBranchCache.Unlock()
|
||||
|
||||
go refreshWorkspaceGitBranchForMeta(key, base, request)
|
||||
go refreshWorkspaceGitBranchForMeta(key, base, repo, request)
|
||||
return ""
|
||||
}
|
||||
|
||||
func refreshWorkspaceGitBranchForMeta(key, base string, request *int) {
|
||||
// refreshWorkspaceGitBranchForMeta reads through repo, the tab session's
|
||||
// identity; a tab with no session yet resolves base as opening it would.
|
||||
func refreshWorkspaceGitBranchForMeta(key, base string, repo gitcmd.Repo, request *int) {
|
||||
branch := ""
|
||||
// Store via defer so the refreshing flag is always cleared, even when the
|
||||
// probe panics or exits the goroutine early; otherwise the entry would stay
|
||||
@@ -545,21 +551,24 @@ func refreshWorkspaceGitBranchForMeta(key, base string, request *int) {
|
||||
workspaceGitBranchCache.entries[key] = workspaceGitBranchCacheEntry{branch: branch, expires: storeNow.Add(workspaceGitBranchCacheTTL)}
|
||||
}()
|
||||
|
||||
branch = workspaceGitBranchForMetaProbe(base)
|
||||
if repo.Dir == "" {
|
||||
repo = openWorkspaceRepo(base)
|
||||
}
|
||||
branch = workspaceGitBranchForMetaProbe(repo)
|
||||
}
|
||||
|
||||
// workspaceGitBranch returns the current git branch name for the repo rooted
|
||||
// at base, or an empty string when base is not inside a git repository or when
|
||||
// git is unavailable.
|
||||
func workspaceGitBranch(base string) string {
|
||||
// workspaceGitBranch returns the current git branch name of repo, or an empty
|
||||
// string when it names no git repository or git is unavailable.
|
||||
func workspaceGitBranch(repo gitcmd.Repo) string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
branch, _ := workspaceGitBranchContext(ctx, base)
|
||||
branch, _ := workspaceGitBranchContext(ctx, repo)
|
||||
return branch
|
||||
}
|
||||
|
||||
func workspaceGitBranchContext(ctx context.Context, base string) (string, error) {
|
||||
raw, err := workspaceGitCommand(ctx, "-C", base, "branch", "--show-current").Output()
|
||||
func workspaceGitBranchContext(ctx context.Context, repo gitcmd.Repo) (string, error) {
|
||||
base := repo.Dir
|
||||
raw, err := workspaceGitCommand(ctx, repo, "-C", base, "branch", "--show-current").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -567,7 +576,7 @@ func workspaceGitBranchContext(ctx context.Context, base string) (string, error)
|
||||
return branch, nil
|
||||
}
|
||||
|
||||
raw, err = workspaceGitCommand(ctx, "-C", base, "rev-parse", "--short", "HEAD").Output()
|
||||
raw, err = workspaceGitCommand(ctx, repo, "-C", base, "rev-parse", "--short", "HEAD").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -580,19 +589,19 @@ func workspaceGitBranchContext(ctx context.Context, base string) (string, error)
|
||||
|
||||
// GitBranches returns all local git branches for the active workspace's repo.
|
||||
func (a *App) GitBranches() ([]string, error) {
|
||||
base, err := a.activeWorkspaceBase()
|
||||
repo, err := a.workspaceRepoForTab("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return workspaceLocalBranches(base)
|
||||
return workspaceLocalBranches(repo)
|
||||
}
|
||||
|
||||
func (a *App) GitCheckout(branch string) error {
|
||||
base, err := a.activeWorkspaceBase()
|
||||
repo, err := a.workspaceRepoForTab("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return workspaceCheckoutBranch(base, branch, false)
|
||||
return workspaceCheckoutBranch(repo, branch, false)
|
||||
}
|
||||
|
||||
const gitRefInvalidChars = " ~^:?*[\\" + "\t\n"
|
||||
@@ -607,11 +616,11 @@ func validGitBranchName(name string) bool {
|
||||
}
|
||||
|
||||
func (a *App) GitCreateBranch(name string) error {
|
||||
base, err := a.activeWorkspaceBase()
|
||||
repo, err := a.workspaceRepoForTab("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return workspaceCheckoutBranch(base, name, true)
|
||||
return workspaceCheckoutBranch(repo, name, true)
|
||||
}
|
||||
|
||||
type GitCommitView struct {
|
||||
@@ -627,17 +636,18 @@ type GitCommitDetailView struct {
|
||||
}
|
||||
|
||||
func (a *App) WorkspaceGitHistory(tabID string, path string) ([]GitCommitView, error) {
|
||||
base, err := a.workspaceBaseForTab(tabID)
|
||||
repo, err := a.workspaceRepoForTab(tabID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := repo.Dir
|
||||
|
||||
args := []string{"-C", base, "log", "--pretty=format:%H%x00%an%x00%ad%x00%s", "-z", "-n", "100"}
|
||||
if path != "" {
|
||||
args = append(args, "--", path)
|
||||
}
|
||||
|
||||
cmd := workspaceGit(args...)
|
||||
cmd := workspaceGit(repo, args...)
|
||||
raw, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -658,27 +668,28 @@ func (a *App) WorkspaceGitHistory(tabID string, path string) ([]GitCommitView, e
|
||||
}
|
||||
|
||||
func (a *App) WorkspaceGitCommitDetail(tabID string, hash string, path string) (GitCommitDetailView, error) {
|
||||
base, err := a.workspaceBaseForTab(tabID)
|
||||
repo, err := a.workspaceRepoForTab(tabID)
|
||||
if err != nil {
|
||||
return GitCommitDetailView{}, err
|
||||
}
|
||||
base := repo.Dir
|
||||
|
||||
if path != "" {
|
||||
// Single file diff
|
||||
cmd := workspaceGit("-C", base, "show", "--relative", "--pretty=format:", "--patch", hash, "--", path)
|
||||
cmd := workspaceGit(repo, "-C", base, "show", "--relative", "--pretty=format:", "--patch", hash, "--", path)
|
||||
raw, err := cmd.Output()
|
||||
if err != nil {
|
||||
return GitCommitDetailView{}, err
|
||||
return GitCommitDetailView{}, notLocalOr(repo, hash, err)
|
||||
}
|
||||
diffStr := strings.TrimSpace(string(raw))
|
||||
return GitCommitDetailView{Diff: &diffStr}, nil
|
||||
}
|
||||
|
||||
// Project level: list of files changed
|
||||
cmd := workspaceGit("-C", base, "diff-tree", "--relative", "--no-commit-id", "--name-only", "-z", "-r", hash)
|
||||
cmd := workspaceGit(repo, "-C", base, "diff-tree", "--relative", "--no-commit-id", "--name-only", "-z", "-r", hash)
|
||||
raw, err := cmd.Output()
|
||||
if err != nil {
|
||||
return GitCommitDetailView{}, err
|
||||
return GitCommitDetailView{}, notLocalOr(repo, hash, err)
|
||||
}
|
||||
|
||||
var files []string
|
||||
@@ -689,3 +700,14 @@ func (a *App) WorkspaceGitCommitDetail(tabID string, hash string, path string) (
|
||||
}
|
||||
return GitCommitDetailView{Files: files}, nil
|
||||
}
|
||||
|
||||
// notLocalOr names a failure caused by objects a partial clone never fetched —
|
||||
// host git does not fetch them — and passes any other failure through.
|
||||
func notLocalOr(repo gitcmd.Repo, hash string, err error) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if repo.ObjectsMissing(ctx, hash, hash+"^") {
|
||||
return fmt.Errorf("commit %s: %w", hash, gitcmd.ErrObjectNotLocal)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/gitcmd"
|
||||
)
|
||||
|
||||
// The probe must not spawn a background daemon that opens a console of its own
|
||||
@@ -17,14 +19,15 @@ import (
|
||||
// it a second time here only guaranteed this test would break whenever the
|
||||
// baseline gained an entry.
|
||||
func TestWorkspaceGitDisablesDaemonSpawns(t *testing.T) {
|
||||
cmd := workspaceGit("-C", "repo", "status", "--porcelain=v1")
|
||||
for _, want := range []string{"core.fsmonitor=false", "maintenance.auto=false"} {
|
||||
cmd := workspaceGit(gitcmd.Repo{}, "-C", "repo", "status", "--porcelain=v1")
|
||||
for _, want := range []string{"core.fsmonitor=", "maintenance.auto=false"} {
|
||||
if !hasGitConfigArg(cmd.Args, want) {
|
||||
t.Fatalf("args = %v, want -c %s", cmd.Args, want)
|
||||
}
|
||||
}
|
||||
if tail := cmd.Args[len(cmd.Args)-4:]; !slices.Equal(tail, []string{"-C", "repo", "status", "--porcelain=v1"}) {
|
||||
t.Fatalf("args = %v, want the caller's arguments last", cmd.Args)
|
||||
at := slices.Index(cmd.Args, "-C")
|
||||
if at < 0 || !slices.Equal(cmd.Args[at:at+3], []string{"-C", "repo", "status"}) || cmd.Args[len(cmd.Args)-1] != "--porcelain=v1" {
|
||||
t.Fatalf("args = %v, want the caller's arguments last and in order", cmd.Args)
|
||||
}
|
||||
if runtime.GOOS == "windows" && cmd.SysProcAttr == nil {
|
||||
t.Fatal("workspaceGit must hide the console window on Windows")
|
||||
@@ -61,7 +64,7 @@ func TestWorkspaceGitBranch(t *testing.T) {
|
||||
runGit(t, "init")
|
||||
runGit(t, "checkout", "-b", "feature/status")
|
||||
|
||||
if got := workspaceGitBranch(repo); got != "feature/status" {
|
||||
if got := workspaceGitBranch(openWorkspaceRepo(repo)); got != "feature/status" {
|
||||
t.Fatalf("branch = %q, want feature/status", got)
|
||||
}
|
||||
}
|
||||
@@ -87,11 +90,11 @@ func TestWorkspaceGitBranchReflectsImmediateCheckout(t *testing.T) {
|
||||
runGit(t, "init")
|
||||
runGit(t, "checkout", "-b", "feature/one")
|
||||
|
||||
if got := workspaceGitBranch(repo); got != "feature/one" {
|
||||
if got := workspaceGitBranch(openWorkspaceRepo(repo)); got != "feature/one" {
|
||||
t.Fatalf("branch before checkout = %q, want feature/one", got)
|
||||
}
|
||||
runGit(t, "checkout", "-b", "feature/two")
|
||||
if got := workspaceGitBranch(repo); got != "feature/two" {
|
||||
if got := workspaceGitBranch(openWorkspaceRepo(repo)); got != "feature/two" {
|
||||
t.Fatalf("branch after checkout = %q, want feature/two", got)
|
||||
}
|
||||
}
|
||||
@@ -125,13 +128,13 @@ func TestWorkspaceGitBranchDetachedHead(t *testing.T) {
|
||||
short := gitOutput(t, "rev-parse", "--short", "HEAD")
|
||||
runGit(t, "checkout", "--detach", "HEAD")
|
||||
|
||||
if got := workspaceGitBranch(repo); got != "@"+short {
|
||||
if got := workspaceGitBranch(openWorkspaceRepo(repo)); got != "@"+short {
|
||||
t.Fatalf("branch = %q, want @%s", got, short)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceGitBranchNonGitDirectory(t *testing.T) {
|
||||
if got := workspaceGitBranch(t.TempDir()); got != "" {
|
||||
if got := workspaceGitBranch(openWorkspaceRepo(t.TempDir())); got != "" {
|
||||
t.Fatalf("branch = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -143,7 +146,7 @@ func TestWorkspaceGitBranchForMetaDoesNotBlockOnColdProbe(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
releaseProbe := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
workspaceGitBranchForMetaProbe = func(string) string {
|
||||
workspaceGitBranchForMetaProbe = func(gitcmd.Repo) string {
|
||||
close(started)
|
||||
<-release
|
||||
return "feature/async"
|
||||
@@ -153,7 +156,7 @@ func TestWorkspaceGitBranchForMetaDoesNotBlockOnColdProbe(t *testing.T) {
|
||||
workspaceGitBranchForMetaProbe = origProbe
|
||||
}()
|
||||
|
||||
if got := workspaceGitBranchForMeta("/tmp/reasonix-cold-probe"); got != "" {
|
||||
if got := workspaceGitBranchForMeta("/tmp/reasonix-cold-probe", gitcmd.Repo{}); got != "" {
|
||||
t.Fatalf("cold branch = %q, want empty while async refresh runs", got)
|
||||
}
|
||||
// The probe is still blocked here, so returning is the deterministic proof
|
||||
@@ -182,7 +185,7 @@ func TestWorkspaceGitBranchForMetaReturnsStaleDuringRefresh(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
releaseProbe := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
workspaceGitBranchForMetaProbe = func(string) string {
|
||||
workspaceGitBranchForMetaProbe = func(gitcmd.Repo) string {
|
||||
close(started)
|
||||
<-release
|
||||
return "feature/fresh"
|
||||
@@ -192,7 +195,7 @@ func TestWorkspaceGitBranchForMetaReturnsStaleDuringRefresh(t *testing.T) {
|
||||
workspaceGitBranchForMetaProbe = origProbe
|
||||
}()
|
||||
|
||||
if got := workspaceGitBranchForMeta("/tmp/reasonix-stale-probe"); got != "feature/stale" {
|
||||
if got := workspaceGitBranchForMeta("/tmp/reasonix-stale-probe", gitcmd.Repo{}); got != "feature/stale" {
|
||||
t.Fatalf("stale branch = %q, want feature/stale", got)
|
||||
}
|
||||
// The probe is still blocked here, so returning the stale value proves that
|
||||
@@ -218,7 +221,7 @@ func eventuallyBranchForMeta(t *testing.T, base, want string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if got := workspaceGitBranchForMeta(base); got == want {
|
||||
if got := workspaceGitBranchForMeta(base, gitcmd.Repo{}); got == want {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
@@ -7,61 +7,63 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/gitcmd"
|
||||
)
|
||||
|
||||
func (a *App) gitWorkspaceBaseForTab(tabID, expectedRoot string) (string, error) {
|
||||
func (a *App) gitWorkspaceRepoForTab(tabID, expectedRoot string) (gitcmd.Repo, error) {
|
||||
tabID = strings.TrimSpace(tabID)
|
||||
if tabID == "" {
|
||||
return "", fmt.Errorf("workspace tab is required")
|
||||
return gitcmd.Repo{}, fmt.Errorf("workspace tab is required")
|
||||
}
|
||||
root, _, ok := a.workspaceChangesTarget(tabID)
|
||||
root, ctrl, ok := a.workspaceChangesTarget(tabID)
|
||||
if !ok || !filepath.IsAbs(root) {
|
||||
return "", fmt.Errorf("workspace tab %q is unavailable", tabID)
|
||||
return gitcmd.Repo{}, fmt.Errorf("workspace tab %q is unavailable", tabID)
|
||||
}
|
||||
if !filepath.IsAbs(expectedRoot) || !sameProjectRoot(root, expectedRoot) {
|
||||
return "", fmt.Errorf("workspace tab %q has changed project", tabID)
|
||||
return gitcmd.Repo{}, fmt.Errorf("workspace tab %q has changed project", tabID)
|
||||
}
|
||||
if info, err := os.Stat(root); err != nil || !info.IsDir() {
|
||||
return "", fmt.Errorf("workspace tab %q directory is unavailable", tabID)
|
||||
return gitcmd.Repo{}, fmt.Errorf("workspace tab %q directory is unavailable", tabID)
|
||||
}
|
||||
return workspaceBaseFromRoot(root)
|
||||
return workspaceRepo(root, ctrl)
|
||||
}
|
||||
|
||||
func (a *App) GitBranchesForTab(tabID, workspaceRoot string) ([]string, error) {
|
||||
base, err := a.gitWorkspaceBaseForTab(tabID, workspaceRoot)
|
||||
repo, err := a.gitWorkspaceRepoForTab(tabID, workspaceRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return workspaceLocalBranches(base)
|
||||
return workspaceLocalBranches(repo)
|
||||
}
|
||||
|
||||
func (a *App) GitCheckoutForTab(tabID, workspaceRoot, branch string) error {
|
||||
base, err := a.gitWorkspaceBaseForTab(tabID, workspaceRoot)
|
||||
repo, err := a.gitWorkspaceRepoForTab(tabID, workspaceRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return workspaceCheckoutBranch(base, branch, false)
|
||||
return workspaceCheckoutBranch(repo, branch, false)
|
||||
}
|
||||
|
||||
func (a *App) GitCreateBranchForTab(tabID, workspaceRoot, name string) error {
|
||||
base, err := a.gitWorkspaceBaseForTab(tabID, workspaceRoot)
|
||||
repo, err := a.gitWorkspaceRepoForTab(tabID, workspaceRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return workspaceCheckoutBranch(base, name, true)
|
||||
return workspaceCheckoutBranch(repo, name, true)
|
||||
}
|
||||
|
||||
// The launcher needs only Git totals, not session checkpoints or per-file views.
|
||||
func (a *App) WorkspaceGitStatsForTab(tabID, workspaceRoot string) (WorkspaceChangesView, error) {
|
||||
base, err := a.gitWorkspaceBaseForTab(tabID, workspaceRoot)
|
||||
repo, err := a.gitWorkspaceRepoForTab(tabID, workspaceRoot)
|
||||
if err != nil {
|
||||
return WorkspaceChangesView{}, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
out := WorkspaceChangesView{Files: []WorkspaceChangeView{}, GitAvailable: true}
|
||||
out.GitBranch, _ = workspaceGitBranchContext(ctx, base)
|
||||
entries, err := workspaceGitStatusContext(ctx, base)
|
||||
out.GitBranch, _ = workspaceGitBranchContext(ctx, repo)
|
||||
entries, err := workspaceGitStatusContext(ctx, repo)
|
||||
if err != nil {
|
||||
out.GitAvailable, out.Incomplete, out.GitErr = false, true, err.Error()
|
||||
return out, nil
|
||||
@@ -72,19 +74,22 @@ func (a *App) WorkspaceGitStatsForTab(tabID, workspaceRoot string) (WorkspaceCha
|
||||
untracked = append(untracked, entry.Path)
|
||||
}
|
||||
}
|
||||
out.Added, out.Removed, out.Incomplete = workspaceGitDiffTally(ctx, base, untracked)
|
||||
out.Added, out.Removed, out.Incomplete = workspaceGitDiffTally(ctx, repo, untracked)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func workspaceLocalBranches(base string) ([]string, error) {
|
||||
raw, err := workspaceGitOutputWithTimeout(3*time.Second, "-C", base, "branch", "--format=%(refname:short)")
|
||||
func workspaceLocalBranches(repo gitcmd.Repo) ([]string, error) {
|
||||
raw, err := workspaceGitOutputWithTimeout(3*time.Second, repo, "-C", repo.Dir, "branch", "--format=%(refname:short)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append([]string{}, strings.FieldsFunc(strings.TrimSpace(string(raw)), func(r rune) bool { return r == '\n' })...), nil
|
||||
}
|
||||
|
||||
func workspaceCheckoutBranch(base, name string, create bool) error {
|
||||
// workspaceCheckoutBranch switches repo's branch. Submodules are not updated
|
||||
// (gitcmd pins submodule.recurse off for checkout).
|
||||
func workspaceCheckoutBranch(repo gitcmd.Repo, name string, create bool) error {
|
||||
base := repo.Dir
|
||||
name = strings.TrimSpace(name)
|
||||
if !validGitBranchName(name) {
|
||||
return fmt.Errorf("invalid branch name %q", name)
|
||||
@@ -96,11 +101,11 @@ func workspaceCheckoutBranch(base, name string, create bool) error {
|
||||
args = append(args, name)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
out, err := workspaceGitCommand(ctx, args...).CombinedOutput()
|
||||
out, err := workspaceGitCommand(ctx, repo, args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git checkout: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
branch, _ := workspaceGitBranchContext(ctx, base)
|
||||
branch, _ := workspaceGitBranchContext(ctx, repo)
|
||||
workspaceGitBranchCache.Lock()
|
||||
if branch == "" {
|
||||
delete(workspaceGitBranchCache.entries, filepath.Clean(base))
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/gitcmd"
|
||||
)
|
||||
|
||||
func TestWorkspaceGitCheckoutInvalidatesInFlightBranchProbe(t *testing.T) {
|
||||
@@ -14,11 +16,12 @@ func TestWorkspaceGitCheckoutInvalidatesInFlightBranchProbe(t *testing.T) {
|
||||
workspaceGitBranchCache.Unlock()
|
||||
started, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
original := workspaceGitBranchForMetaProbe
|
||||
workspaceGitBranchForMetaProbe = func(string) string { close(started); <-release; return "main" }
|
||||
workspaceGitBranchForMetaProbe = func(gitcmd.Repo) string { close(started); <-release; return "main" }
|
||||
defer func() { workspaceGitBranchForMetaProbe = original }()
|
||||
go func() { refreshWorkspaceGitBranchForMeta(key, base, request); close(done) }()
|
||||
repo := openWorkspaceRepo(base)
|
||||
go func() { refreshWorkspaceGitBranchForMeta(key, base, repo, request); close(done) }()
|
||||
<-started
|
||||
err := workspaceCheckoutBranch(base, "shared", false)
|
||||
err := workspaceCheckoutBranch(repo, "shared", false)
|
||||
close(release)
|
||||
<-done
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/gitcmd"
|
||||
)
|
||||
|
||||
func gitScopeRepo(t *testing.T) string {
|
||||
@@ -17,7 +19,7 @@ func gitScopeRepo(t *testing.T) string {
|
||||
{"-c", "user.name=Test", "-c", "[email protected]", "commit", "--allow-empty", "-m", "init"},
|
||||
{"branch", "shared"},
|
||||
} {
|
||||
if out, err := workspaceGit(append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
|
||||
if out, err := gitcmd.Command(context.Background(), root, args...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("git fixture: %v: %s", err, out)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +36,7 @@ func TestWorkspaceGitBranchScope(t *testing.T) {
|
||||
if err != nil || !slices.Contains(list, "shared") {
|
||||
t.Fatalf("branches: %v, %v", list, err)
|
||||
}
|
||||
base, err := a.gitWorkspaceBaseForTab("a", rootA)
|
||||
base, err := a.gitWorkspaceRepoForTab("a", rootA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -45,13 +47,13 @@ func TestWorkspaceGitBranchScope(t *testing.T) {
|
||||
if err := workspaceCheckoutBranch(base, "shared", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if workspaceGitBranch(rootA) != "shared" || workspaceGitBranch(rootB) != "main" {
|
||||
if workspaceGitBranch(openWorkspaceRepo(rootA)) != "shared" || workspaceGitBranch(openWorkspaceRepo(rootB)) != "main" {
|
||||
t.Fatal("checkout changed the replacement workspace")
|
||||
}
|
||||
if err := a.GitCreateBranchForTab("b", rootB, "new-branch"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if workspaceGitBranch(rootA) != "shared" || workspaceGitBranch(rootB) != "new-branch" {
|
||||
if workspaceGitBranch(openWorkspaceRepo(rootA)) != "shared" || workspaceGitBranch(openWorkspaceRepo(rootB)) != "new-branch" {
|
||||
t.Fatal("create crossed project boundaries")
|
||||
}
|
||||
if err := a.GitCheckoutForTab("a", rootB, "--detach"); err == nil {
|
||||
@@ -117,7 +119,7 @@ func TestWorkspaceTallyBudgetAndFileKinds(t *testing.T) {
|
||||
for i := range paths {
|
||||
paths[i] = "large"
|
||||
}
|
||||
added, _, partial := workspaceGitDiffTally(context.Background(), base, paths)
|
||||
added, _, partial := workspaceGitDiffTally(context.Background(), openWorkspaceRepo(base), paths)
|
||||
if added != workspaceDiffTallyReadLimit/2 || !partial {
|
||||
t.Fatalf("aggregate budget: %d, %v", added, partial)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/gitcmd"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -15,11 +17,12 @@ const (
|
||||
workspaceDiffTallyReadLimit = 16 << 20
|
||||
)
|
||||
|
||||
func workspaceGitDiffTally(ctx context.Context, base string, untracked []string) (added, removed int, incomplete bool) {
|
||||
raw, err := workspaceGitCommand(ctx, "-C", base, "diff", "--numstat", "--no-textconv", "HEAD", "--", ".").Output()
|
||||
func workspaceGitDiffTally(ctx context.Context, repo gitcmd.Repo, untracked []string) (added, removed int, incomplete bool) {
|
||||
base := repo.Dir
|
||||
raw, err := workspaceGitCommand(ctx, repo, "-C", base, "diff", "--numstat", "--no-textconv", "HEAD", "--", ".").Output()
|
||||
if err != nil && ctx.Err() == nil {
|
||||
incomplete = true
|
||||
raw, err = workspaceGitCommand(ctx, "-C", base, "diff", "--numstat", "--no-textconv", "--", ".").Output()
|
||||
raw, err = workspaceGitCommand(ctx, repo, "-C", base, "diff", "--numstat", "--no-textconv", "--", ".").Output()
|
||||
}
|
||||
if err != nil {
|
||||
return 0, 0, true
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/gitcmd"
|
||||
)
|
||||
|
||||
// partialClone returns a blob-filtered clone of a three-commit repository, so
|
||||
// the blobs of every commit but the checked-out one are absent locally.
|
||||
func partialClone(t *testing.T) string {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
src := t.TempDir()
|
||||
run := func(dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
run(src, "init", "-q")
|
||||
for _, body := range []string{"1\n", "2\n", "3\n"} {
|
||||
if err := os.WriteFile(filepath.Join(src, "a.txt"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run(src, "add", "a.txt")
|
||||
run(src, "commit", "-q", "-m", body)
|
||||
}
|
||||
run(src, "config", "uploadpack.allowFilter", "true")
|
||||
clone := filepath.Join(t.TempDir(), "partial")
|
||||
run(src, "clone", "-q", "--filter=blob:none", "file://"+src, clone)
|
||||
return clone
|
||||
}
|
||||
|
||||
// A historical patch in a partial clone fails with an error naming the
|
||||
// unfetched objects, which the commit-detail view can tell from other failures.
|
||||
func TestCommitDetailInPartialCloneReportsObjectNotLocal(t *testing.T) {
|
||||
clone := partialClone(t)
|
||||
out, err := workspaceGit(openWorkspaceRepo(clone), "-C", clone, "show", "--pretty=format:", "--patch", "HEAD~1", "--", "a.txt").Output()
|
||||
if err == nil {
|
||||
t.Fatalf("show of an unfetched commit succeeded: %s", out)
|
||||
}
|
||||
if got := notLocalOr(openWorkspaceRepo(clone), "HEAD~1", err); !errors.Is(got, gitcmd.ErrObjectNotLocal) {
|
||||
t.Fatalf("notLocalOr = %v, want ErrObjectNotLocal", got)
|
||||
}
|
||||
other := errors.New("other")
|
||||
if got := notLocalOr(openWorkspaceRepo(clone), "no-such-rev", other); !errors.Is(got, other) {
|
||||
t.Fatalf("notLocalOr(no-such-rev) = %v, want the original error passed through", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/gitcmd"
|
||||
)
|
||||
|
||||
const workspaceRepoOpenLimit = 3 * time.Second
|
||||
|
||||
// sessionWorkspaceRepo is the git identity ctrl's session resolved for base
|
||||
// when it opened, and whether that session holds one for base at all.
|
||||
func sessionWorkspaceRepo(ctrl control.SessionAPI, base string) (gitcmd.Repo, bool) {
|
||||
c, ok := ctrl.(interface{ WorkspaceRepo() gitcmd.Repo })
|
||||
if !ok {
|
||||
return gitcmd.Repo{}, false
|
||||
}
|
||||
repo := c.WorkspaceRepo()
|
||||
return repo, repo.Dir != "" && filepath.Clean(repo.Dir) == filepath.Clean(base)
|
||||
}
|
||||
|
||||
// workspaceRepo is the identity host git reads root through: the one its
|
||||
// session opened with, or, for a root no session holds yet, the one it
|
||||
// resolves to now, as opening it would.
|
||||
func workspaceRepo(root string, ctrl control.SessionAPI) (gitcmd.Repo, error) {
|
||||
base, err := workspaceBaseFromRoot(root)
|
||||
if err != nil {
|
||||
return gitcmd.Repo{}, err
|
||||
}
|
||||
if repo, ok := sessionWorkspaceRepo(ctrl, base); ok {
|
||||
return repo, nil
|
||||
}
|
||||
return openWorkspaceRepo(base), nil
|
||||
}
|
||||
|
||||
func openWorkspaceRepo(base string) gitcmd.Repo {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), workspaceRepoOpenLimit)
|
||||
defer cancel()
|
||||
repo, err := gitcmd.Open(ctx, base)
|
||||
if err != nil {
|
||||
return gitcmd.Repo{Dir: base}
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
// workspaceRepoForRoot is root's identity as a tab session holding it opened
|
||||
// it, or as opening it now would.
|
||||
func (a *App) workspaceRepoForRoot(root string) (gitcmd.Repo, error) {
|
||||
var ctrl control.SessionAPI
|
||||
a.mu.RLock()
|
||||
for _, tab := range a.tabs {
|
||||
if tab.Ctrl != nil && sameProjectRoot(tab.WorkspaceRoot, root) {
|
||||
ctrl = tab.Ctrl
|
||||
break
|
||||
}
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
return workspaceRepo(root, ctrl)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/gitcmd"
|
||||
)
|
||||
|
||||
// The changes panel reads a tab through the identity its session resolved when
|
||||
// it opened, even after the workspace — a subdirectory of that repository —
|
||||
// gains its own .git whose local config names a filter.
|
||||
func TestWorkspaceChangesReadTheSessionRepository(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("payload script is POSIX shell")
|
||||
}
|
||||
mono := t.TempDir()
|
||||
pkg := filepath.Join(mono, "pkg")
|
||||
git := func(dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(pkg, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(pkg, "f.txt"), []byte("a\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
git(mono, "init", "-q")
|
||||
git(mono, "add", ".")
|
||||
git(mono, "commit", "-qm", "base")
|
||||
repo, err := gitcmd.Open(context.Background(), pkg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctrl := control.New(control.Options{Sink: event.Discard, WorkspaceRoot: pkg, WorkspaceRepo: repo})
|
||||
a := &App{tabs: map[string]*WorkspaceTab{"a": {WorkspaceRoot: pkg, Ctrl: ctrl}}}
|
||||
|
||||
marker := filepath.Join(t.TempDir(), "executed")
|
||||
payload := filepath.Join(t.TempDir(), "payload.sh")
|
||||
if err := os.WriteFile(payload, []byte("#!/bin/sh\necho ran >> '"+marker+"'\ncat\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
git(pkg, "init", "-q")
|
||||
git(pkg, "config", "filter.pwn.clean", payload)
|
||||
if err := os.WriteFile(filepath.Join(pkg, ".gitattributes"), []byte("f.txt filter=pwn\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(pkg, "f.txt"), []byte("b\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
view := a.WorkspaceChanges("a")
|
||||
var modified bool
|
||||
for _, f := range view.Files {
|
||||
modified = modified || (f.Path == "f.txt" && f.GitStatus == "M")
|
||||
}
|
||||
if !view.GitAvailable || !modified || view.Removed != 1 {
|
||||
t.Fatalf("changes = %+v, want f.txt modified against the enclosing repository", view)
|
||||
}
|
||||
if _, err := a.WorkspaceChangeDetail("a", "f.txt"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
t.Fatal("the changes panel ran the nested repository's filter")
|
||||
}
|
||||
}
|
||||
+18
-4
@@ -159,11 +159,25 @@ entirely of non-ASCII characters get a stable hash suffix such as
|
||||
generated environment variable remains valid; for example, `9router` becomes
|
||||
`CUSTOM_9ROUTER_API_KEY`.
|
||||
|
||||
The CLI custom-provider wizard uses this rule while building its draft. For example
|
||||
The CLI custom-provider wizard uses this rule for its draft name. For example
|
||||
`https://token.sensenova.cn/v1` creates provider name
|
||||
`custom-token-sensenova-cn`, whose default key env is
|
||||
`CUSTOM_TOKEN_SENSENOVA_CN_API_KEY`; commit then switches the saved connection
|
||||
to its newly allocated private slot.
|
||||
`custom-token-sensenova-cn`, whose draft key env is
|
||||
`CUSTOM_TOKEN_SENSENOVA_CN_API_KEY`. Pressing Enter at the variable-name
|
||||
prompt keeps that draft only until the key is saved; the saved connection then
|
||||
uses a newly allocated private slot.
|
||||
|
||||
A variable name you type at that prompt in `reasonix setup` is kept, so scripts
|
||||
can refer to a stable name, as long as saving under it changes nothing another
|
||||
connection reads: no other provider, bot or remote-host setting in the config
|
||||
reads it, the global `.env` holds no value (or cleared marker) for it, and the
|
||||
environment Reasonix runs in does not already set it. Otherwise the wizard says
|
||||
what holds the name and asks again; Enter falls back to a
|
||||
private slot. If the name is claimed between the prompt and saving, the save is
|
||||
refused and nothing is written.
|
||||
|
||||
Saving a new key later for a provider that uses a typed name moves that
|
||||
provider to a private slot; the old value stays under the typed name. Rotating
|
||||
in place is tracked in #11010.
|
||||
|
||||
Existing configs are not rewritten on upgrade. If an old custom provider already
|
||||
uses `CUSTOM_API_KEY`, it will keep working with that key. If several old custom
|
||||
|
||||
@@ -129,8 +129,18 @@ env 名,例如 `LOCAL_GATEWAY_API_KEY`;如果名称全部由中文等非 ASC
|
||||
都共用 `CUSTOM_API_KEY`。如果名称以数字开头,则会添加 `CUSTOM_` 前缀以保证生成的
|
||||
环境变量名合法;例如 `9router` 会生成 `CUSTOM_9ROUTER_API_KEY`。
|
||||
|
||||
CLI 的自定义 provider 向导在构建草稿时仍会先根据 base URL 生成 provider 名称,再套用
|
||||
同一规则;提交时会把保存后的连接切换到新分配的独立槽位。
|
||||
CLI 的自定义 provider 向导会先根据 base URL 生成 provider 名称,再套用同一规则得到草稿
|
||||
变量名。在变量名提示处直接回车时,这个草稿名只在保存 key 之前使用;保存后连接会切换到
|
||||
新分配的独立槽位。
|
||||
|
||||
在 `reasonix setup` 的这个提示处手动输入的变量名会被保留,方便脚本引用固定名称,前提是
|
||||
写入它不会改变其他读取方拿到的值:配置里没有其他 provider、机器人或远程主机设置读取它,
|
||||
全局 `.env` 里没有它的值(或清空标记),Reasonix 运行的环境变量里也没有设置它。否则向导
|
||||
会说明是什么占用了它,并请你重新输入;直接回车则改用独立槽位。如果在提示之后、保存之前这个名字被占用,保存会被拒绝,
|
||||
不写入任何内容。
|
||||
|
||||
之后再为使用手动变量名的 provider 保存新 key 时,该 provider 会切换到独立槽位,旧值仍留在
|
||||
手动输入的变量名下。原地轮换见 #11010。
|
||||
|
||||
升级时不会自动改写已有配置。旧配置中已经使用 `CUSTOM_API_KEY` 的自定义 provider 会继续
|
||||
读取这个 key。若多个旧自定义 provider 已经意外共用了 `CUSTOM_API_KEY`,重新保存每个
|
||||
|
||||
+95
-2
@@ -246,8 +246,30 @@ the terminal; stop it with Ctrl-C.
|
||||
An explicit `reasonix web --auth none` disables the default token and should be
|
||||
used only when the listener is intentionally trusted. `reasonix serve` keeps its
|
||||
backward-compatible, config-driven `auth_mode = "none"` default on
|
||||
`127.0.0.1:8787`. If you bind Serve outside loopback, expose it through a tunnel,
|
||||
or put it behind a reverse proxy, enable authentication before sharing the URL:
|
||||
`127.0.0.1:8787`.
|
||||
|
||||
Without authentication (`auth_mode = "none"`, the `serve` default) reads stay
|
||||
open on the listener, but every state-changing request, approvals included,
|
||||
needs the launch token:
|
||||
|
||||
- Serve writes the token to a 0600 file under `<Reasonix home>/remote/` and
|
||||
prints only its path next to an `approvals:` link; append
|
||||
`#token=<file contents>` to open it in a browser. A managed launch with
|
||||
`--token-file` names that file instead. Token mode prints its `share:` link
|
||||
the same way.
|
||||
- Send it as `Authorization: Bearer <token>`, or open the link once so the page
|
||||
sets its cookie. Without it the request answers 403 `launch_token_required`.
|
||||
- Prefer `--token-file` over `--token`: argv is visible to other processes,
|
||||
sandboxed ones included. A plaintext `[serve].token` in the global
|
||||
config is readable from inside the sandbox; keep the secret in a file.
|
||||
- On macOS and Linux the OS sandbox denies the remote state directory and any
|
||||
`--token-file`. Windows has no bash sandbox, so there nothing keeps an agent
|
||||
command from reading the file.
|
||||
- `[serve]` is read from the user config only; a project `reasonix.toml`
|
||||
cannot set it.
|
||||
|
||||
If you bind Serve outside loopback, expose it through a tunnel, or put it behind
|
||||
a reverse proxy, enable authentication before sharing the URL:
|
||||
|
||||
```bash
|
||||
reasonix serve --auth token
|
||||
@@ -723,6 +745,77 @@ it has no OS-level shell sandbox, and denying the current user would also deny
|
||||
the host settings process. Project `.env` files keep their existing
|
||||
workspace-scoped behavior.
|
||||
|
||||
**Git metadata is host-protected.** Inside the bash sandbox, the Git
|
||||
configuration and hooks of the workspace repository stay read-only, because the
|
||||
host's own git reads them.
|
||||
|
||||
The repository is the one git itself discovers from each writable root. A
|
||||
`.git` file is followed to the gitdir it names the way git resolves it,
|
||||
relative to the file with symlinks followed, so the protection lands on what
|
||||
git will read. Protected:
|
||||
|
||||
- `.git` itself, the gitdir, the common dir and every symlink on the way to
|
||||
them: none can be removed, renamed or replaced by a symlink.
|
||||
- `config`, `config.worktree`, `commondir` and `hooks/` of the gitdir and of
|
||||
the common dir.
|
||||
- `config`, `config.worktree` and `commondir` of each existing `worktrees/*`
|
||||
entry, and `config` and `config.worktree` of entries created later.
|
||||
- `config`, `config.worktree`, `commondir` and `hooks/` of each submodule
|
||||
gitdir under `modules/`, existing or created later.
|
||||
|
||||
Everything else under `.git` (objects, refs, index, logs, lock files) stays
|
||||
writable, so add, commit, branch, checkout, merge, rebase, stash, tag and
|
||||
worktree creation keep working. These operations change:
|
||||
|
||||
| Operation | In the sandbox |
|
||||
| --- | --- |
|
||||
| `git config` without `--global`, `git remote add` / `set-url`, `git branch -m`, `git submodule init`, `git submodule update --init`, `git sparse-checkout init`, `git maintenance register`, `git init` in an existing repository | Fails |
|
||||
| Installing a hook into `.git/hooks` | Fails |
|
||||
| `git worktree remove` / `prune` of a linked worktree that existed before the command | Fails; one added in the same command can be removed |
|
||||
| Cloning a new submodule (`git submodule add`, `git submodule update` for one not yet cloned) | Fails on macOS; on Linux the clone lands but its config entry does not |
|
||||
| `git branch --set-upstream-to`, `git checkout --track`, `git push -u` | Reports the refused write but exits 0; no upstream is recorded |
|
||||
|
||||
To add or initialise submodules, run it outside the sandbox (in a terminal, or through an approved danger-full-access retry); updating submodules that are
|
||||
already cloned works inside it.
|
||||
|
||||
When a command's output names one of these paths, the bash result says so with
|
||||
`sandbox.git_metadata_protected`, also when git exited 0. `additional_write_dirs` cannot grant it.
|
||||
|
||||
A protected file that already has another hard link refuses every confined
|
||||
command with `sandbox.git_metadata_linked`, because a write through the other
|
||||
name would reach it; the user removes that link outside the sandbox.
|
||||
|
||||
Limits:
|
||||
|
||||
- On Linux, bubblewrap can only mount over paths that exist and cannot pin a
|
||||
symlink. Creating an absent `commondir`, `config.worktree` or hooks
|
||||
directory, or swapping a symlink on the `gitdir:` path, is not stopped there.
|
||||
- Closing that on Linux is the host's side: its own git has to pin its git and
|
||||
common directories instead of rediscovering them.
|
||||
- Existing worktree and submodule gitdirs get exact rules, up to 128 each on
|
||||
macOS and 512 on Linux.
|
||||
- On macOS, patterns cover the rest and gitdirs created later. They skip
|
||||
`refs/` and `logs/`, so a branch or tag named `config` or `hooks` stays
|
||||
writable, but a new submodule named `hooks` is protected whole.
|
||||
- On macOS with more than 128 worktrees, `git worktree add` fails; past 128
|
||||
submodules each command starts about 0.1 s slower.
|
||||
- On Linux, past 512 gitdirs the whole `worktrees/` or `modules/` directory is
|
||||
mounted read-only, and a command can cause that by planting `HEAD` files.
|
||||
A command can likewise plant a submodule gitdir with a hard-linked config,
|
||||
after which every confined command is refused until the user removes it.
|
||||
- A repository created by a sandboxed command is protected from the next
|
||||
command on.
|
||||
- A `.git` made unrecognisable to git (for example a corrupted `HEAD`) sends
|
||||
git's discovery further up.
|
||||
- A repository nested in the workspace that is not a submodule gitdir under
|
||||
`modules/` is not protected, including one a command creates and records as
|
||||
a gitlink.
|
||||
- The host can run such a repository's configuration through its gitlink
|
||||
unless the host's own git excludes it.
|
||||
- Hooks that `core.hooksPath` points outside `.git`, and files `include.path`
|
||||
names, are ordinary workspace files.
|
||||
- Windows has no shell sandbox, so none of this is enforced there.
|
||||
|
||||
**Session-private temporary directory.** Within one logical chat session, Bash
|
||||
commands share a private temporary directory so consecutive calls can exchange
|
||||
files through `$TMPDIR` (and, on Linux under bubblewrap, through literal
|
||||
|
||||
+65
-2
@@ -220,8 +220,17 @@ reasonix web
|
||||
|
||||
显式传入 `reasonix web --auth none` 可以关闭默认 Token,只应在监听地址确定可信时使用。
|
||||
`reasonix serve` 则保持向后兼容:默认监听 `127.0.0.1:8787`,认证模式仍由配置决定,空配置为
|
||||
`auth_mode = "none"`。如果要绑定到非 loopback 地址、通过 tunnel 暴露,或放到反向代理后面,
|
||||
请先开启认证再分享 URL:
|
||||
`auth_mode = "none"`。
|
||||
|
||||
未开启认证时读取接口保持开放,但所有会改变状态的请求(包括审批)都需要本次启动的令牌:
|
||||
|
||||
- serve 把令牌写进 `<Reasonix home>/remote/` 下权限 0600 的文件,终端只打印文件路径和 `approvals:` 链接;在链接后拼上 `#token=<文件内容>` 用浏览器打开。带 `--token-file` 的托管启动则打印该文件路径。token 模式的 `share:` 链接同样处理。
|
||||
- 以 `Authorization: Bearer <token>` 发送,或打开一次链接让页面写入 Cookie;否则返回 403 `launch_token_required`。
|
||||
- 优先用 `--token-file` 而不是 `--token`:命令行参数对其他进程可见,沙盒内的进程也能看到。全局配置里明文的 `[serve].token` 在沙盒内可读,密钥请放文件。
|
||||
- macOS 和 Linux 的系统沙盒会拒绝读取该状态目录和 `--token-file`;Windows 没有 bash 沙盒,agent 命令可以读到该文件。
|
||||
- `[serve]` 只从用户配置读取,项目里的 `reasonix.toml` 不能设置它。
|
||||
|
||||
如果要绑定到非 loopback 地址、通过 tunnel 暴露,或放到反向代理后面,请先开启认证再分享 URL:
|
||||
|
||||
```bash
|
||||
reasonix serve --auth token
|
||||
@@ -580,6 +589,60 @@ Reasonix 始终会从工具子进程环境中移除已保存的 provider 与 bot
|
||||
因为 Windows 没有 OS 级 Shell 沙箱,而拒绝当前用户也会拒绝宿主设置进程。项目
|
||||
`.env` 仍保持现有的 workspace 范围行为。
|
||||
|
||||
**Git 元数据由宿主保护。**Bash 沙盒内,工作区仓库的 Git 配置和钩子保持只读,
|
||||
因为宿主自己的 git 会读取它们。
|
||||
|
||||
受保护的仓库是 git 自身从每个可写根发现的那个。`.git` 是文件时,按 git 的方式解析
|
||||
它指向的 gitdir(相对该文件、跟随符号链接),保护落在 git 实际读取的位置。受保护:
|
||||
|
||||
- `.git` 本身、gitdir、公共目录及通往它们的每个符号链接:都不能被删除、改名或替换成符号链接。
|
||||
- gitdir 与公共目录中的 `config`、`config.worktree`、`commondir` 和 `hooks/`。
|
||||
- 已有 `worktrees/*` 条目的 `config`、`config.worktree`、`commondir`,以及之后新建条目的
|
||||
`config` 和 `config.worktree`。
|
||||
- `modules/` 下每个子模块 gitdir(已有的和之后新建的)的 `config`、`config.worktree`、
|
||||
`commondir` 和 `hooks/`。
|
||||
|
||||
`.git` 下其余内容(objects、refs、index、logs、锁文件)仍可写,因此 add、commit、
|
||||
branch、checkout、merge、rebase、stash、tag 和创建 worktree 照常工作。以下操作的
|
||||
行为会变:
|
||||
|
||||
| 操作 | 沙盒内 |
|
||||
| --- | --- |
|
||||
| 不带 `--global` 的 `git config`、`git remote add` / `set-url`、`git branch -m`、`git submodule init`、`git submodule update --init`、`git sparse-checkout init`、`git maintenance register`、对已有仓库执行 `git init` | 失败 |
|
||||
| 向 `.git/hooks` 安装钩子 | 失败 |
|
||||
| 对命令开始前已存在的 linked worktree 执行 `git worktree remove` / `prune` | 失败;同一条命令里新建的可以删除 |
|
||||
| 克隆新的子模块(`git submodule add`,或对尚未克隆的子模块执行 `git submodule update`) | macOS 上失败;Linux 上克隆成功但配置条目写不进去 |
|
||||
| `git branch --set-upstream-to`、`git checkout --track`、`git push -u` | 报告写入被拒但退出码为 0;不会记录上游 |
|
||||
|
||||
需要添加或初始化子模块时,在沙盒外运行(终端里,或经用户批准的 danger-full-access 重试);已克隆的子模块在沙盒内仍可更新。
|
||||
|
||||
命令输出里出现这些路径时,bash 结果会用 `sandbox.git_metadata_protected` 指明,git
|
||||
退出码为 0 时也一样。`additional_write_dirs` 无法授权。
|
||||
|
||||
受保护文件如果已有另一个硬链接,所有沙盒命令都会以 `sandbox.git_metadata_linked` 被拒,
|
||||
因为经另一个名字的写入会改到它;需要用户在沙盒外删掉那个链接。
|
||||
|
||||
限制:
|
||||
|
||||
- Linux 上 bubblewrap 只能挂载已存在的路径,也钉不住符号链接:新建尚不存在的
|
||||
`commondir`、`config.worktree` 或钩子目录、替换 `gitdir:` 路径上的符号链接,在那里都拦不住。
|
||||
- 在 Linux 上补这一点要靠宿主一侧:宿主自己的 git 固定其 git 目录与公共目录,而不是重新发现。
|
||||
- 已有的 worktree 与子模块 gitdir 用精确规则,macOS 上各至多 128 个,Linux 上至多 512 个。
|
||||
- macOS 上其余的以及之后新建的由模式覆盖。模式跳过 `refs/` 和 `logs/`,名为 `config` 或
|
||||
`hooks` 的分支、标签仍可写,但新建的名为 `hooks` 的子模块会整个被保护。
|
||||
- macOS 上 worktree 超过 128 个时 `git worktree add` 会失败;子模块超过 128 个时每条命令
|
||||
启动约慢 0.1 秒。
|
||||
- Linux 上超过 512 个 gitdir 时整个 `worktrees/` 或 `modules/` 以只读挂载,命令可以通过伪造
|
||||
`HEAD` 文件触发这一点。命令也可以伪造一个配置带硬链接的子模块 gitdir,之后所有沙盒命令都会
|
||||
被拒,直到用户删掉它。
|
||||
- 沙盒命令新建的仓库从下一条命令起受保护。
|
||||
- 被弄成 git 不认识的 `.git`(例如损坏的 `HEAD`)会让 git 继续向上查找。
|
||||
- 工作区里嵌套的、不是 `modules/` 下子模块 gitdir 的仓库不受保护,包括命令自己建出来并
|
||||
记录成 gitlink 的。
|
||||
- 除非宿主自己的 git 排除它,宿主可能经由该 gitlink 执行它的配置。
|
||||
- `core.hooksPath` 指向 `.git` 之外的钩子、`include.path` 引用的文件,都是普通工作区文件。
|
||||
- Windows 没有 Bash 沙盒,以上都不生效。
|
||||
|
||||
**会话私有标准临时目录。**同一逻辑会话内的多条 Bash 命令共享一个私有临时目录,
|
||||
因此连续调用可以通过 `$TMPDIR` 交换文件(在 Linux bubblewrap 下还可以通过字面
|
||||
`/tmp`)。用户不需要设置:Reasonix 会自动为 Bash 和客户端托管的 ACP 终端注入
|
||||
|
||||
+36
-28
@@ -8,7 +8,26 @@ record, checks its provenance and bytes, and requires one `release` approval
|
||||
before creating the three tags and publishing. `recover` reuses the same sealed
|
||||
files; it must not rebuild or sign them.
|
||||
|
||||
## Public-site recovery / 官网独立恢复
|
||||
Rendering the notes credits people: each `refs` number that is a pull request
|
||||
gains `by @author`; an issue gains `fixed in #M by @author` for each merged pull
|
||||
request GitHub links as closing it. A closing Contributors list names those
|
||||
humans once, in first-appearance order. Issues fixed by a direct push and bot
|
||||
authors are left plain. The lookup needs a GitHub token (`GH_TOKEN`, else
|
||||
`gh auth token` locally) and `issues: read` plus `pull-requests: read`.
|
||||
Transient failures (5xx, rate limits, dropped connections) are retried with
|
||||
backoff; one that persists, or an authorisation failure, fails the render with
|
||||
a `release_credits.*` code instead of publishing notes without that credit. A
|
||||
number that names a discussion or nothing stays plain with a warning.
|
||||
|
||||
The seal job renders the notes once into `evidence/release-notes.md` inside the
|
||||
candidate payload and records it as `notes.renderedPath`, bound to
|
||||
`notes.renderedSha256`. Promotion verifies the payload and publishes those
|
||||
bytes without calling GitHub, so an author renamed, an issue newly linked to a
|
||||
fix or an API outage after sealing cannot change or block what ships. A
|
||||
candidate sealed before this has no `renderedPath` and is rendered offline from
|
||||
its own source, as before.
|
||||
|
||||
## Public checks / 公开渠道检查
|
||||
|
||||
Candidate preparation and publication execute offline identity, resolver-output,
|
||||
archive-integrity, atomic-tag, and publication-ledger contracts before expensive
|
||||
@@ -20,34 +39,23 @@ a new release, but rejects HTTP errors, browser challenges, and malformed JSON.
|
||||
另从 runner 以无凭据请求检查公开 Stable 清单;发新版前允许清单仍为旧版,但 HTTP 错误、浏览器挑战及
|
||||
无效 JSON 必须失败。离线契约测试与在线可访问性检查相互独立。
|
||||
|
||||
For an already-published release whose website deployment or public verification
|
||||
failed, dispatch **Recover release site** (`release-site-recovery.yml`) on
|
||||
protected `main-v2` with `version=X.Y.Z`. It resolves all existing tags on protected
|
||||
history, re-reads public package identities, and requires one `release` approval.
|
||||
After approval it rechecks the public state, requires an existing matching release
|
||||
event, and uses the same site-sync owner as full publication. No candidate artifact
|
||||
is needed, so this remains usable after candidate payload expiry. It does not
|
||||
build, sign, create tags, upload release assets, publish packages, or move pointers.
|
||||
Missing packages or a missing release event require full publication recovery.
|
||||
After every publisher succeeds, postflight verifies the tags, the CLI and
|
||||
Desktop GitHub Releases, npm, the Desktop Stable manifest on `dl.reasonix.io`
|
||||
and the Homebrew cask, then attaches `release-event.json`. Publication is
|
||||
complete when those product surfaces are verified. A 90-day ledger records the
|
||||
source/control SHA, observed surfaces, and the exact failed stage. On recovery
|
||||
a proven newer Stable pointer is preserved, never a failed HTTP observation.
|
||||
|
||||
已发布版本若官网部署或公开验收失败,可在受保护 `main-v2` 上运行 **Recover release site**,输入
|
||||
`version=X.Y.Z`。它校验三个已有标签属于受保护历史,重新读取公开包身份,并要求一次 `release`
|
||||
审批。审批后再次核验公开状态及已有的匹配发布事件,复用完整发布的官网同步脚本。不依赖候选产物,
|
||||
因此候选 payload 过期后仍可使用;不构建、不签名、不建标签、不上传安装包、不发布 npm、不移动指针。
|
||||
缺少包或发布事件时,应使用完整发布恢复。
|
||||
发布任务全部成功后,postflight 核验三个标签、CLI 与桌面端 GitHub Release、npm、`dl.reasonix.io`
|
||||
上的桌面端 Stable 清单和 Homebrew cask,再附上 `release-event.json`;这些产品渠道全部核验通过即发布完成。
|
||||
90 天台账保留产品/控制 SHA、已验证渠道及失败阶段。恢复时只有确证存在更新的 Stable 指针才予以保留,
|
||||
HTTP 失败不会被当作“新版已发布”。
|
||||
|
||||
The workflow shares the global publication lock. A proven newer Stable pointer
|
||||
preserves the newer site. Each Pages dispatch has a unique run/attempt correlation
|
||||
ID, and Pages rechecks ownership immediately before deploying. The final verifier
|
||||
checks the hydrated download page. A 90-day ledger records the source/control SHA,
|
||||
Pages run, observed surfaces, and exact failed stage; a failed HTTP observation
|
||||
never becomes a successful newer-pointer result. Recovery success is not a claim
|
||||
that unavailable services were verified.
|
||||
|
||||
流程共用全局发布锁;只有确证存在更新 Stable 版本才保留新官网。Pages 调度带当前 run/attempt 的
|
||||
唯一关联 ID,部署前再次检查版本归属,最后验证浏览器渲染后的下载页。90 天台账保留产品/控制 SHA、
|
||||
Pages run、已验证渠道及失败阶段;HTTP 失败不会再被当作“新版已部署”。外部访问未恢复时不会报告验收完成。
|
||||
The website is not part of this pipeline. It is built and deployed from the
|
||||
`website` branch, and its changelog renders from that branch's own release
|
||||
catalog, so a new version's page is released there, not here.
|
||||
|
||||
官网不在此流程内:它由 `website` 分支独立构建和部署,更新日志由该分支自己的发布目录渲染,新版本页面在那里发布。
|
||||
|
||||
For qualification without publication, dispatch `Prepare release candidate` on
|
||||
protected `main-v2` with `version` and `rehearsal=true`. An already reviewed
|
||||
@@ -56,7 +64,7 @@ version may be used for this isolated run. It uses separate
|
||||
cannot pass the normal publish resolver or payload verifier. The Desktop child
|
||||
accepts the existing version tag only in this non-publishing mode. The run must
|
||||
still complete source CI, signing, and native acceptance. It creates no tags,
|
||||
GitHub Releases, npm packages, Homebrew updates, R2 pointers, or site changes.
|
||||
GitHub Releases, npm packages, Homebrew updates, or R2 pointers.
|
||||
|
||||
After sealing, run `Verify release candidate rehearsal` on `main-v2` with its
|
||||
candidate ID. This independent workflow downloads the exact record and payload
|
||||
@@ -69,7 +77,7 @@ not a publication or a substitute for a later formal release's public checks.
|
||||
The candidate payload lasts 30 days and its record/evidence 90 days. If the
|
||||
payload expires before publication, prepare a new candidate. The release
|
||||
skill's public postflight remains the authority for tags, npm, Desktop
|
||||
updates, Homebrew, and the hydrated website after an authorized publication.
|
||||
updates, and Homebrew after an authorized publication.
|
||||
|
||||
## Tag publisher identity / 标签发布身份
|
||||
|
||||
|
||||
@@ -6,6 +6,19 @@ Desktop 文件、执行最终安装包的原生验收并封存记录。`Publish
|
||||
只接受该记录,验证来源与文件字节,经过一次 `release` 批准后创建三个标签并发布。
|
||||
`recover` 复用相同的封存文件,不重新构建或签名。
|
||||
|
||||
渲染发布说明时会为贡献者署名:`refs` 中属于合并请求的编号后面加上
|
||||
`by @作者`;属于 issue 的编号,对 GitHub 记录为关闭它的每个已合并请求加上
|
||||
`fixed in #M by @作者`。结尾的「贡献者」列表按首次出现顺序列出这些真人作者,
|
||||
每人一次。直接推送修复的 issue 和机器人作者保持原样。查询需要 GitHub 令牌(`GH_TOKEN`,
|
||||
本地没有时用 `gh auth token`),以及 `issues: read` 和 `pull-requests: read` 权限。
|
||||
暂时性失败(5xx、限流、连接中断)会退避重试;重试后仍失败或鉴权失败时,渲染以
|
||||
`release_credits.*` 错误码失败,而不是发布缺少署名的说明。指向讨论或不存在的编号保持原样并给出警告。
|
||||
|
||||
封存任务只渲染一次,写进候选包的 `evidence/release-notes.md`,记录为
|
||||
`notes.renderedPath` 并与 `notes.renderedSha256` 绑定。发布时校验候选包后直接发布这份
|
||||
内容,不再访问 GitHub,因此封存之后作者改名、issue 新关联了修复或接口故障都不会改变或
|
||||
阻塞发布内容。本改动之前封存的候选没有 `renderedPath`,仍按原方式从自身源码离线渲染。
|
||||
|
||||
需要验收但不发布时,在受保护的 `main-v2` 上调度 `Prepare release candidate`,
|
||||
填写 `version` 和 `rehearsal=true`。可使用已有审核完成的版本进行隔离演练。
|
||||
它使用独立的 `release-candidate-rehearsal-*` 产物,记录
|
||||
|
||||
+4
-1
@@ -1134,7 +1134,10 @@ cursor behavior.
|
||||
`[serve]` controls the HTTP browser frontend used by `reasonix serve`. The
|
||||
default `auth_mode = "none"` is intended for the loopback default
|
||||
`127.0.0.1:8787`; deployments reachable from another machine must use `token` or
|
||||
`password`. Password mode requires either a startup `--password` or a stored
|
||||
`password`. In `none` mode every state-changing request still needs the launch
|
||||
token, which Serve writes to a 0600 file under `<Reasonix home>/remote/` rather
|
||||
than to the terminal, and otherwise refuses with `launch_token_required`.
|
||||
`[serve]` is read from the user config only. Password mode requires either a startup `--password` or a stored
|
||||
bcrypt `password_hash`. `behind_proxy` must stay false unless the server is
|
||||
behind a trusted proxy that owns the `X-Forwarded-For` and `X-Forwarded-Proto`
|
||||
headers.
|
||||
|
||||
+1
-1
@@ -453,7 +453,7 @@ auth_mode = "none"
|
||||
|
||||
权限预设直接选择强制沙盒边界。交互会话可以用「扩展写入范围」授权(仅本次 / 本会话 / 拒绝)按需扩大可写根;文件工具会申请目标父目录,Bash 必须声明 `additional_write_dirs` 和 `justification`。无头 `reasonix run` 缺少目录时失败关闭。file writer 默认限制在 workspace root、会话私有临时目录和显式授权根;`forbid_read` 可阻止读取敏感路径。macOS 使用 Seatbelt,Linux 使用 bubblewrap;受限 backend 不可用时拒绝执行,不无约束回退。Windows 没有 OS 级 Shell 沙箱:受限预设仍约束文件工具,Shell 命令以当前系统账户运行。
|
||||
|
||||
`[serve]` 控制 `reasonix serve` 的 browser frontend。默认 `auth_mode = "none"` 仅适合 loopback;暴露到其他机器时必须使用 token 或 password。只有位于可信 reverse proxy 后方时才能启用 `behind_proxy`。
|
||||
`[serve]` 控制 `reasonix serve` 的 browser frontend。默认 `auth_mode = "none"` 仅适合 loopback;暴露到其他机器时必须使用 token 或 password。`none` 模式下所有会改变状态的请求仍需启动令牌(serve 把它写进 `<Reasonix home>/remote/` 下 0600 文件而非终端),否则以 `launch_token_required` 拒绝;`[serve]` 只从用户配置读取。只有位于可信 reverse proxy 后方时才能启用 `behind_proxy`。
|
||||
|
||||
项目根目录的 `.mcp.json` 可使用 Claude Code 的 `mcpServers` schema;与 `reasonix.toml` 同名时,以后者为准。
|
||||
|
||||
|
||||
@@ -323,9 +323,9 @@ func TestResolveSlashPromptFallsThroughToExtensionAction(t *testing.T) {
|
||||
sess := &acpSession{id: "sess-1", ctrl: newExtActionController(t, client)}
|
||||
svc := &service{}
|
||||
|
||||
got := svc.resolveSlashPrompt(context.Background(), sess, "/alpha:act1 k=v extra")
|
||||
if got != "rerun scheduled" {
|
||||
t.Fatalf("resolveSlashPrompt = %q, want the action result", got)
|
||||
got, invoked := svc.resolveSlashPrompt(context.Background(), sess, "/alpha:act1 k=v extra")
|
||||
if got != "rerun scheduled" || invoked != "" {
|
||||
t.Fatalf("resolveSlashPrompt = (%q, %q), want the action result without a skill", got, invoked)
|
||||
}
|
||||
if client.got == nil || client.got.ActionID != "act1" ||
|
||||
client.got.Args["k"] != "v" || client.got.Args["arg1"] != "extra" {
|
||||
@@ -333,10 +333,10 @@ func TestResolveSlashPromptFallsThroughToExtensionAction(t *testing.T) {
|
||||
}
|
||||
|
||||
// Undeclared actions and non-action lines pass through untouched.
|
||||
if got := svc.resolveSlashPrompt(context.Background(), sess, "/alpha:other"); got != "/alpha:other" {
|
||||
if got, _ := svc.resolveSlashPrompt(context.Background(), sess, "/alpha:other"); got != "/alpha:other" {
|
||||
t.Fatalf("undeclared action rewrote to %q", got)
|
||||
}
|
||||
if got := svc.resolveSlashPrompt(context.Background(), sess, "/plain"); got != "/plain" {
|
||||
if got, _ := svc.resolveSlashPrompt(context.Background(), sess, "/plain"); got != "/plain" {
|
||||
t.Fatalf("plain slash rewrote to %q", got)
|
||||
}
|
||||
|
||||
@@ -345,7 +345,7 @@ func TestResolveSlashPromptFallsThroughToExtensionAction(t *testing.T) {
|
||||
failing := &acpSession{id: "sess-1", ctrl: newExtActionController(t, &extActionClient{
|
||||
result: protocol.UIActionResult{Accepted: false, Message: "nope"},
|
||||
})}
|
||||
if got := svc.resolveSlashPrompt(context.Background(), failing, "/alpha:act1"); got != "/alpha:act1" {
|
||||
if got, _ := svc.resolveSlashPrompt(context.Background(), failing, "/alpha:act1"); got != "/alpha:act1" {
|
||||
t.Fatalf("failed action rewrote to %q", got)
|
||||
}
|
||||
}
|
||||
+15
-11
@@ -263,6 +263,8 @@ func (s *service) bindClientIO(p *SessionParams, sessionID string) {
|
||||
type acpController interface {
|
||||
control.Lifecycle
|
||||
control.TurnControl
|
||||
RunTurnWithRaw(ctx context.Context, input, raw, invokedSkill string) error
|
||||
RunSkillWithName(input string) (sent, name string, found bool)
|
||||
RunFinalReadinessRecoveryWithAdmission(ctx context.Context, input string, onAdmitted func()) error
|
||||
TrySteer(text string) bool
|
||||
control.Approvals
|
||||
@@ -1158,6 +1160,8 @@ func (s *service) sessionPrompt(ctx context.Context, raw json.RawMessage) (any,
|
||||
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/prompt: unknown session " + p.SessionID}
|
||||
}
|
||||
text := FlattenPrompt(p.Prompt)
|
||||
rawText := text
|
||||
invokedSkill := ""
|
||||
if text == "" && p.Action != control.ProtocolRecoveryAction {
|
||||
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/prompt: empty prompt"}
|
||||
}
|
||||
@@ -1178,7 +1182,7 @@ func (s *service) sessionPrompt(ctx context.Context, raw json.RawMessage) (any,
|
||||
recovery = true
|
||||
text = prompt
|
||||
} else {
|
||||
text = s.resolveSlashPrompt(ctx, sess, text)
|
||||
text, invokedSkill = s.resolveSlashPrompt(ctx, sess, text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1217,7 +1221,7 @@ func (s *service) sessionPrompt(ctx context.Context, raw json.RawMessage) (any,
|
||||
runErr = sess.ctrl.RunFinalReadinessRecoveryWithAdmission(runCtx, text, beginTurn)
|
||||
} else {
|
||||
beginTurn()
|
||||
runErr = sess.ctrl.RunTurn(runCtx, text)
|
||||
runErr = sess.ctrl.RunTurnWithRaw(runCtx, text, rawText, invokedSkill)
|
||||
}
|
||||
if errors.Is(runErr, agent.ErrProtocolRecoveryUnavailable) && !statusStarted {
|
||||
return nil, &RPCError{Code: ErrInvalidRequest, Message: "session/prompt: protocol recovery is unavailable or stale"}
|
||||
@@ -2588,28 +2592,28 @@ func availableCommandsFor(ctrl acpController) []AvailableCommand {
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *service) resolveSlashPrompt(ctx context.Context, sess *acpSession, text string) string {
|
||||
func (s *service) resolveSlashPrompt(ctx context.Context, sess *acpSession, text string) (string, string) {
|
||||
line := strings.TrimSpace(text)
|
||||
if sess == nil || !strings.HasPrefix(line, "/") {
|
||||
return text
|
||||
return text, ""
|
||||
}
|
||||
ctrl := sess.currentCtrl()
|
||||
if ctrl == nil {
|
||||
return text
|
||||
return text, ""
|
||||
}
|
||||
if sent, ok := ctrl.CustomCommand(line); ok {
|
||||
return sent
|
||||
return sent, ""
|
||||
}
|
||||
if sent, ok := ctrl.RunSkill(line); ok {
|
||||
return sent
|
||||
if sent, name, ok := ctrl.RunSkillWithName(line); ok {
|
||||
return sent, name
|
||||
}
|
||||
if sent, ok, err := ctrl.MCPPrompt(ctx, line); err == nil && ok {
|
||||
return sent
|
||||
return sent, ""
|
||||
}
|
||||
if sent, ok := invokeExtensionAction(ctx, ctrl, line); ok {
|
||||
return sent
|
||||
return sent, ""
|
||||
}
|
||||
return text
|
||||
return text, ""
|
||||
}
|
||||
|
||||
// invokeExtensionAction resolves a "/<plugin>:<action> args…" line against the
|
||||
|
||||
@@ -424,7 +424,7 @@ func TestACPCtrlReadPathsDoNotRaceWithRebuild(t *testing.T) {
|
||||
}
|
||||
_ = svc.sessionDir()
|
||||
svc.sendAvailableCommands(sess)
|
||||
if got := svc.resolveSlashPrompt(context.Background(), sess, "/no-such-command args"); got != "/no-such-command args" {
|
||||
if got, _ := svc.resolveSlashPrompt(context.Background(), sess, "/no-such-command args"); got != "/no-such-command args" {
|
||||
t.Fatalf("resolveSlashPrompt rewrote unknown command to %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/agent/testutil"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/skill"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
type skillRawFactory struct {
|
||||
provider *testutil.MockProvider
|
||||
executor chan *agent.Agent
|
||||
dir string
|
||||
}
|
||||
|
||||
func (f *skillRawFactory) SessionDir() string { return f.dir }
|
||||
|
||||
func (f *skillRawFactory) NewSession(_ context.Context, p SessionParams) (*control.Controller, error) {
|
||||
executor := agent.New(f.provider, tool.NewRegistry(), agent.NewSession("test agent"),
|
||||
agent.Options{MaxSteps: 2}, p.Sink)
|
||||
ctrl := control.New(control.Options{
|
||||
Runner: executor, Executor: executor, Sink: p.Sink,
|
||||
SessionDir: f.dir,
|
||||
Skills: []skill.Skill{
|
||||
{Name: "probe", Description: "probe skill", Body: "PROBE BODY\nthen /other", Triggers: []string{"probe", "tidy"}, AutoUse: "require"},
|
||||
{Name: "other", Description: "other skill", Body: "OTHER BODY", Triggers: []string{"other"}, AutoUse: "require"},
|
||||
},
|
||||
})
|
||||
f.executor <- executor
|
||||
return ctrl, nil
|
||||
}
|
||||
|
||||
func TestACPSkillPromptKeepsTypedTextAsRawInput(t *testing.T) {
|
||||
factory := &skillRawFactory{
|
||||
provider: testutil.NewMock("test", testutil.Turn{Text: "done"}),
|
||||
executor: make(chan *agent.Agent, 1),
|
||||
dir: t.TempDir(),
|
||||
}
|
||||
client, stop := startServer(t, factory)
|
||||
defer stop()
|
||||
sid := openSession(t, client)
|
||||
executor := <-factory.executor
|
||||
|
||||
const typed = "/probe tidy the notes"
|
||||
prompt := client.callAsync("session/prompt", SessionPromptParams{
|
||||
SessionID: sid,
|
||||
Prompt: []ContentBlock{{Type: "text", Text: typed}},
|
||||
})
|
||||
_, resp := drainPrompt(t, client, prompt)
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("session/prompt: %v", resp.Error)
|
||||
}
|
||||
|
||||
req := factory.provider.LastRequest()
|
||||
if req == nil {
|
||||
t.Fatal("no provider request")
|
||||
}
|
||||
var delivered string
|
||||
for _, msg := range req.Messages {
|
||||
if msg.Role == provider.RoleUser {
|
||||
delivered = msg.Content
|
||||
}
|
||||
}
|
||||
if !strings.Contains(delivered, "PROBE BODY") || !strings.Contains(delivered, "Arguments: tidy the notes") {
|
||||
t.Fatalf("provider did not receive the rendered skill invocation: %q", delivered)
|
||||
}
|
||||
if strings.Contains(delivered, "<capability-route") {
|
||||
t.Fatalf("already-pinned skill was routed again: %q", delivered)
|
||||
}
|
||||
for _, msg := range executor.Session().Snapshot() {
|
||||
if msg.Role == provider.RoleUser && strings.Contains(msg.Content, "PROBE BODY") {
|
||||
if msg.RawContent != typed {
|
||||
t.Fatalf("raw user input = %q, want %q", msg.RawContent, typed)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("no user turn in history")
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/plugin"
|
||||
)
|
||||
|
||||
func (r *MCPCapabilityRuntime) serverRegistered(server string) bool {
|
||||
@@ -24,12 +27,32 @@ func mcpServerDisabledMessage(server string) string {
|
||||
return fmt.Sprintf("MCP server %q is disabled in this session", server)
|
||||
}
|
||||
|
||||
// mcpServerOffMessage carries the host's reason a server is off. One the
|
||||
// project declared waits for the user, and only the user can change that.
|
||||
func mcpServerOffMessage(server string, decision config.MCPDecision) string {
|
||||
if !decision.AwaitsUser() {
|
||||
return mcpServerDisabledMessage(server)
|
||||
}
|
||||
return fmt.Sprintf("MCP server %q is off [%s]: this project declares it, and it starts only after the user reviews and enables it (reasonix mcp enable %s, /mcp connect, or the MCP panel). Tell the user if it is needed.", server, decision.Code(), server)
|
||||
}
|
||||
|
||||
// serverDecision resolves why entry is off; an enabled server is simply on.
|
||||
func serverDecision(entry config.PluginEntry, spec plugin.Spec, enabled bool) config.MCPDecision {
|
||||
if enabled {
|
||||
return config.MCPDecisionOn
|
||||
}
|
||||
if d := config.MCPServerDecision(entry, spec.WorkspaceRoot); d.AwaitsUser() {
|
||||
return d
|
||||
}
|
||||
return config.MCPDecisionOff
|
||||
}
|
||||
|
||||
func mcpServerUnregisteredError(server string) error {
|
||||
return errors.New(mcpServerUnregisteredMessage(server))
|
||||
}
|
||||
|
||||
func mcpServerDisabledError(server string) error {
|
||||
return errors.New(mcpServerDisabledMessage(server))
|
||||
func mcpServerOffError(server string, decision config.MCPDecision) error {
|
||||
return errors.New(mcpServerOffMessage(server, decision))
|
||||
}
|
||||
|
||||
func (t *UseCapabilityTool) serverRegistered(server string) bool {
|
||||
@@ -46,5 +69,11 @@ func (t *UseCapabilityTool) serverUnavailableReason(server string) string {
|
||||
if !t.serverRegistered(server) {
|
||||
return mcpServerUnregisteredMessage(server)
|
||||
}
|
||||
if t.runtime != nil {
|
||||
t.runtime.mu.RLock()
|
||||
configured := t.runtime.servers[strings.TrimSpace(server)]
|
||||
t.runtime.mu.RUnlock()
|
||||
return mcpServerOffMessage(server, configured.decision)
|
||||
}
|
||||
return mcpServerDisabledMessage(server)
|
||||
}
|
||||
@@ -46,6 +46,7 @@ type mcpRuntimeServer struct {
|
||||
entry config.PluginEntry
|
||||
spec plugin.Spec
|
||||
enabled bool
|
||||
decision config.MCPDecision // why a disabled server is off
|
||||
cached []plugin.CachedTool
|
||||
cacheKeyOK bool
|
||||
}
|
||||
@@ -107,7 +108,7 @@ func (r *MCPCapabilityRuntime) ConfigureServers(entries []config.PluginEntry, sp
|
||||
for _, entry := range entries {
|
||||
name := strings.TrimSpace(entry.Name)
|
||||
if name != "" {
|
||||
byName[name] = runtimePluginEntry(entry)
|
||||
byName[name] = entry
|
||||
}
|
||||
}
|
||||
next := make(map[string]mcpRuntimeServer, len(specs))
|
||||
@@ -125,11 +126,16 @@ func (r *MCPCapabilityRuntime) ConfigureServers(entries []config.PluginEntry, sp
|
||||
if enabled != nil {
|
||||
isEnabled = enabled[name]
|
||||
}
|
||||
decision := config.MCPDecisionOn
|
||||
if ok {
|
||||
decision = serverDecision(entry, spec, isEnabled)
|
||||
}
|
||||
cached, keyOK := cachedToolsForSpec(spec, r.hostProfile())
|
||||
next[name] = mcpRuntimeServer{
|
||||
entry: entry,
|
||||
entry: runtimePluginEntry(entry),
|
||||
spec: spec,
|
||||
enabled: isEnabled,
|
||||
decision: decision,
|
||||
cached: cached,
|
||||
cacheKeyOK: keyOK,
|
||||
}
|
||||
@@ -155,6 +161,7 @@ func (r *MCPCapabilityRuntime) UpsertServer(entry config.PluginEntry, raw plugin
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
decision := serverDecision(entry, spec, enabled)
|
||||
entry = runtimePluginEntry(entry)
|
||||
if strings.TrimSpace(entry.Name) == "" {
|
||||
entry.Name = name
|
||||
@@ -165,6 +172,7 @@ func (r *MCPCapabilityRuntime) UpsertServer(entry config.PluginEntry, raw plugin
|
||||
entry: entry,
|
||||
spec: spec,
|
||||
enabled: enabled,
|
||||
decision: decision,
|
||||
cached: cached,
|
||||
cacheKeyOK: keyOK,
|
||||
}
|
||||
@@ -189,6 +197,10 @@ func (r *MCPCapabilityRuntime) SetServerEnabled(name string, enabled bool) bool
|
||||
server, ok := r.servers[name]
|
||||
if ok {
|
||||
server.enabled = enabled
|
||||
server.decision = config.MCPDecisionOff
|
||||
if enabled {
|
||||
server.decision = config.MCPDecisionOn
|
||||
}
|
||||
r.servers[name] = server
|
||||
}
|
||||
r.mu.Unlock()
|
||||
@@ -1201,7 +1213,7 @@ func (t *UseCapabilityTool) lockAuthorizedRuntimeServer(ctx context.Context, ser
|
||||
}
|
||||
if !configured.enabled {
|
||||
unlock()
|
||||
return plugin.Spec{}, func() {}, mcpServerDisabledError(server)
|
||||
return plugin.Spec{}, func() {}, mcpServerOffError(server, configured.decision)
|
||||
}
|
||||
spec := plugin.ResolveStoredAuthorization(ctx, cloneMCPSpec(configured.spec))
|
||||
if !spec.ServerAuthorized() {
|
||||
|
||||
@@ -136,7 +136,7 @@ func (t *UseCapabilityTool) listServers() (string, error) {
|
||||
connected := server.enabled && resolved.ServerAuthorized() && t.host != nil && t.host.HasClientForSpec(resolved)
|
||||
status := "configured"
|
||||
if !server.enabled {
|
||||
status = "disabled"
|
||||
status = server.decision.Code()
|
||||
} else if connected {
|
||||
status = "ready"
|
||||
} else if t.host != nil {
|
||||
|
||||
@@ -21,8 +21,8 @@ func TestUseCapabilityListSummarizesMCPWithoutExpandingCachedDirectories(t *test
|
||||
{Name: "enabled", Type: "stdio", Command: "enabled-mcp", Authorized: true},
|
||||
}
|
||||
entries := []config.PluginEntry{
|
||||
{Name: "disabled", Type: "stdio", Command: "disabled-mcp"},
|
||||
{Name: "enabled", Type: "stdio", Command: "enabled-mcp"},
|
||||
{Name: "disabled", Type: "stdio", Command: "disabled-mcp", Source: config.MCPSourceUserConfig},
|
||||
{Name: "enabled", Type: "stdio", Command: "enabled-mcp", Source: config.MCPSourceUserConfig},
|
||||
}
|
||||
for _, spec := range specs {
|
||||
cached := make([]plugin.CachedTool, 64)
|
||||
|
||||
@@ -23,9 +23,9 @@ func TestAdditionalDirsResolveAgainstExplicitWorkspaceRoot(t *testing.T) {
|
||||
}
|
||||
|
||||
// An explicit workspace root (from --dir) pins resolution to proj, not the git root.
|
||||
root := resolveWorkspaceRoot(proj)
|
||||
root := ResolveWorkspaceRoot(proj)
|
||||
if root != proj {
|
||||
t.Fatalf("resolveWorkspaceRoot(%q) = %q, want the explicit --dir", proj, root)
|
||||
t.Fatalf("ResolveWorkspaceRoot(%q) = %q, want the explicit --dir", proj, root)
|
||||
}
|
||||
|
||||
// A relative --add-dir resolves against that explicit root (proj/shared), not
|
||||
|
||||
+13
-3
@@ -40,6 +40,7 @@ import (
|
||||
"reasonix/internal/extension/providerext"
|
||||
"reasonix/internal/extension/sidecar"
|
||||
"reasonix/internal/extension/uihub"
|
||||
"reasonix/internal/gitcmd"
|
||||
"reasonix/internal/guardian"
|
||||
"reasonix/internal/history"
|
||||
"reasonix/internal/hook"
|
||||
@@ -234,6 +235,7 @@ type Options struct {
|
||||
WorkspaceOnly bool
|
||||
PinnedContextLoader control.PinnedContextLoader
|
||||
SessionTemp *sessiontemp.Manager // session-private temp manager; Rebuild reuses old's
|
||||
WorkspaceRepo gitcmd.Repo // git identity the session opened with; Rebuild reuses old's
|
||||
PersistentShell *persistentshell.Manager
|
||||
RuntimeReload
|
||||
// deferPublish keeps a replacement generation private until migration and
|
||||
@@ -256,7 +258,8 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
|
||||
if stderr == nil {
|
||||
stderr = os.Stderr
|
||||
}
|
||||
root := resolveWorkspaceRoot(opts.WorkspaceRoot)
|
||||
root := ResolveWorkspaceRoot(opts.WorkspaceRoot)
|
||||
repo := workspaceRepo(ctx, opts.WorkspaceRepo, root)
|
||||
additionalDirs, err := normalizeAdditionalDirs(root, opts.AdditionalDirs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -787,6 +790,7 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
|
||||
OAuthHTTPClient: balanceClient,
|
||||
}
|
||||
autoStartEntries := cfg.EnabledPlugins(root, config.DefaultMCPActivationStore())
|
||||
emitProjectMCPDecisionNotice(sink, cfg, root)
|
||||
enabledMCPNames := make(map[string]bool, len(autoStartEntries))
|
||||
for _, enabled := range autoStartEntries {
|
||||
if name := strings.TrimSpace(enabled.Name); name != "" {
|
||||
@@ -1478,7 +1482,9 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
|
||||
slashEntries = append(slashEntries, command.SlashEntry{
|
||||
Name: sk.SlashName(),
|
||||
Description: sk.Description,
|
||||
Render: func(args []string) string { return skillStore.Render(sk, strings.Join(args, " ")) },
|
||||
Render: func(args []string) string {
|
||||
return skill.RenderInvocation(skillStore.Prepare(sk), strings.Join(args, " "))
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1884,6 +1890,7 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
|
||||
},
|
||||
CapabilityRuntime: capRuntime,
|
||||
WorkspaceRoot: root,
|
||||
WorkspaceRepo: repo,
|
||||
ExternalFolderToolRefs: readPathResolver,
|
||||
ResponseLanguage: cfg.ResponseLanguage(),
|
||||
ReasoningLanguage: config.ReasoningLanguageForEntry(entry, cfg.ReasoningLanguage()),
|
||||
@@ -2287,7 +2294,9 @@ func currentWorkspacePromptLine(root string) string {
|
||||
return "Current workspace: " + strconv.Quote(root)
|
||||
}
|
||||
|
||||
func resolveWorkspaceRoot(explicit string) string {
|
||||
// ResolveWorkspaceRoot is the workspace a session in the current directory uses:
|
||||
// explicit, else the nearest git root, else the working directory.
|
||||
func ResolveWorkspaceRoot(explicit string) string {
|
||||
if explicit != "" {
|
||||
return explicit
|
||||
}
|
||||
@@ -2393,6 +2402,7 @@ func runtimeForbidReadRootsForGOOS(cfg *config.Config, root, goos string) []stri
|
||||
if goos == "windows" {
|
||||
return append([]string(nil), base...)
|
||||
}
|
||||
base = appendUniquePaths(base, config.HostSecretReadRoots()...)
|
||||
credentialPath := strings.TrimSpace(config.UserCredentialsPath())
|
||||
if credentialPath == "" {
|
||||
return append([]string(nil), base...)
|
||||
|
||||
@@ -3936,6 +3936,7 @@ command = "reasonix-missing-legacy-eager-mcp"
|
||||
tier = "eager"
|
||||
`)
|
||||
|
||||
enableProjectMCPForTest(t, dir)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
ctrl, err := Build(ctx, Options{})
|
||||
@@ -3981,6 +3982,7 @@ command = "reasonix-missing-legacy-lazy-mcp"
|
||||
tier = "lazy"
|
||||
`)
|
||||
|
||||
enableProjectMCPForTest(t, dir)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
ctrl, err := Build(ctx, Options{})
|
||||
@@ -4082,7 +4084,7 @@ func TestRuntimeForbidReadRootsAddsGlobalCredentialFileExceptOnWindows(t *testin
|
||||
cfg := config.Default()
|
||||
cfg.Sandbox.ForbidRead = []string{configured}
|
||||
withoutCredentials := RuntimeForbidReadRoots(cfg, ".")
|
||||
if !reflect.DeepEqual(withoutCredentials, []string{configured}) {
|
||||
if want := appendUniquePaths([]string{configured}, config.HostSecretReadRoots()...); runtime.GOOS != "windows" && !reflect.DeepEqual(withoutCredentials, want) {
|
||||
t.Fatalf("roots without global credentials = %v", withoutCredentials)
|
||||
}
|
||||
credentialPath := config.UserCredentialsPath()
|
||||
@@ -4302,6 +4304,7 @@ tier = "eager"
|
||||
`)
|
||||
|
||||
var notices []event.Event
|
||||
enableProjectMCPForTest(t, dir)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
ctrl, err := Build(ctx, Options{
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// A user-typed inline skill invocation must reach the provider in the same
|
||||
// pinned form run_skill returns, on both the string and the structured submit path.
|
||||
func TestEffectInlineSkillInvocationReachesProviderPinned(t *testing.T) {
|
||||
const pinOpen = "<skill-pin name=\"probe\">\n# Skill: probe"
|
||||
cases := []struct {
|
||||
name string
|
||||
submit func(*control.Controller)
|
||||
tail string
|
||||
raw string
|
||||
}{
|
||||
{"slash bare", func(c *control.Controller) { c.Submit("/probe") }, "PROBE BODY\nthen run /other\n</skill-pin>", "/probe"},
|
||||
{"slash with task", func(c *control.Controller) { c.Submit("/probe tidy the notes") }, "PROBE BODY\nthen run /other\n\nArguments: tidy the notes\n</skill-pin>", "/probe tidy the notes"},
|
||||
{"chip bare", func(c *control.Controller) {
|
||||
c.SubmitInvocationDisplay("/probe", "", []control.InvocationRequest{{Name: "probe", Kind: "skill"}})
|
||||
}, "PROBE BODY\nthen run /other\n</skill-pin>", ""},
|
||||
{"chip with task", func(c *control.Controller) {
|
||||
c.SubmitInvocationDisplay("/probe tidy the notes", "tidy the notes", []control.InvocationRequest{{Name: "probe", Kind: "skill"}})
|
||||
}, "PROBE BODY\nthen run /other\n\nArguments: tidy the notes\n</skill-pin>", "tidy the notes"},
|
||||
}
|
||||
var rec *effectRecordingProvider
|
||||
provider.Register("boot-effect-skill-invocation", func(provider.Config) (provider.Provider, error) { return rec, nil })
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
rec = &effectRecordingProvider{}
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
[environment]
|
||||
enabled = false
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "boot-effect-skill-invocation"
|
||||
model = "x"
|
||||
`)
|
||||
writeFile(t, dir, ".reasonix/skills/probe/SKILL.md", "---\ndescription: probe skill\ntriggers: probe, tidy\nauto-use: require\n---\nPROBE BODY\nthen run /other")
|
||||
writeFile(t, dir, ".reasonix/skills/other/SKILL.md", "---\ndescription: other skill\ntriggers: other\nauto-use: require\n---\nOTHER BODY")
|
||||
ctrl, err := Build(context.Background(), Options{Sink: event.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
|
||||
tc.submit(ctrl)
|
||||
if done, running := ctrl.TurnIdleDone(); running {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("turn did not finish")
|
||||
}
|
||||
}
|
||||
reqs := rec.requests()
|
||||
if len(reqs) == 0 {
|
||||
t.Fatal("no request reached the provider boundary")
|
||||
}
|
||||
req := reqs[len(reqs)-1]
|
||||
if raw := rec.rawUserInputs(); len(raw) == 0 || raw[len(raw)-1] != tc.raw {
|
||||
t.Fatalf("provider raw user input = %q, want %q", raw, tc.raw)
|
||||
}
|
||||
var user string
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == provider.RoleUser {
|
||||
user = m.Content
|
||||
}
|
||||
}
|
||||
if !strings.Contains(user, pinOpen) || !strings.Contains(user, tc.tail) {
|
||||
t.Fatalf("user message is not the pinned invocation (want tail %q):\n%s", tc.tail, user)
|
||||
}
|
||||
if strings.Count(user, "tidy the notes") > 1 {
|
||||
t.Fatalf("task delivered twice:\n%s", user)
|
||||
}
|
||||
if strings.Contains(systemMessage(req.Messages), "PROBE BODY") {
|
||||
t.Fatal("invocation body leaked into the cache-stable prefix")
|
||||
}
|
||||
if strings.Contains(user, "skill:probe require") || strings.Contains(user, "skill:other require") {
|
||||
t.Fatalf("inline skill already supplied to the model must not require another skill call:\n%s", user)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -20,15 +20,17 @@ import (
|
||||
)
|
||||
|
||||
type effectRecordingProvider struct {
|
||||
mu sync.Mutex
|
||||
reqs []provider.Request
|
||||
mu sync.Mutex
|
||||
reqs []provider.Request
|
||||
rawInputs []string
|
||||
}
|
||||
|
||||
func (p *effectRecordingProvider) Name() string { return "boot-effect-test" }
|
||||
|
||||
func (p *effectRecordingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
func (p *effectRecordingProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.mu.Lock()
|
||||
p.reqs = append(p.reqs, req)
|
||||
p.rawInputs = append(p.rawInputs, agent.RawUserInput(ctx, ""))
|
||||
p.mu.Unlock()
|
||||
chunks := []provider.Chunk{
|
||||
{Type: provider.ChunkText, Text: "ok"},
|
||||
@@ -42,6 +44,12 @@ func (p *effectRecordingProvider) Stream(_ context.Context, req provider.Request
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (p *effectRecordingProvider) rawUserInputs() []string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return append([]string(nil), p.rawInputs...)
|
||||
}
|
||||
|
||||
func (p *effectRecordingProvider) requests() []provider.Request {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/sandbox"
|
||||
)
|
||||
|
||||
func TestRuntimeForbidReadRootsCoverServeLaunchTokens(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", filepath.Join(isolateConfigHome(t), "reasonix-home"))
|
||||
tokenFile := filepath.Join(t.TempDir(), "serve.token")
|
||||
config.RegisterHostSecretPath(tokenFile)
|
||||
|
||||
got := runtimeForbidReadRootsForGOOS(config.Default(), ".", "darwin")
|
||||
for _, want := range []string{config.RemoteStateDir(), tokenFile} {
|
||||
if !pathListContains(got, want) {
|
||||
t.Fatalf("runtime forbid roots = %v, missing %s", got, want)
|
||||
}
|
||||
}
|
||||
if windows := runtimeForbidReadRootsForGOOS(config.Default(), ".", "windows"); pathListContains(windows, tokenFile) {
|
||||
t.Fatalf("Windows runtime forbid roots = %v; the host ACL model cannot deny them", windows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxedShellCannotReadServeLaunchTokens(t *testing.T) {
|
||||
if runtime.GOOS == "windows" || !sandbox.Available() {
|
||||
t.Skip("OS sandbox unavailable")
|
||||
}
|
||||
isolateConfigHome(t)
|
||||
// Every file sits inside the write root: Linux masks /tmp with a tmpfs, so a
|
||||
// file anywhere else under it would read as missing whether denied or not.
|
||||
work := robustTempDir(t)
|
||||
t.Setenv("REASONIX_HOME", filepath.Join(work, "reasonix-home"))
|
||||
remoteDir := config.RemoteStateDir()
|
||||
if err := os.MkdirAll(remoteDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const secret = "launch-token-sentinel"
|
||||
remoteToken := filepath.Join(remoteDir, "serve-demo.token")
|
||||
flagToken := filepath.Join(work, "serve.token")
|
||||
visible := filepath.Join(work, "visible.txt")
|
||||
for _, path := range []string{remoteToken, flagToken, visible} {
|
||||
if err := os.WriteFile(path, []byte(secret), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
config.RegisterHostSecretPath(flagToken)
|
||||
|
||||
spec := sandbox.Spec{Mode: "enforce", WriteRoots: []string{work}, ForbidReadRoots: RuntimeForbidReadRoots(config.Default(), work), Network: true}
|
||||
read := func(path string) (string, error) {
|
||||
argv, wrapped := sandbox.Command(spec, sandbox.Shell{Kind: sandbox.ShellBash, Path: "bash"}, "cat "+path)
|
||||
if !wrapped {
|
||||
t.Fatal("sandbox did not wrap the command")
|
||||
}
|
||||
out, err := exec.Command(argv[0], argv[1:]...).CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
if out, err := read(visible); err != nil || !strings.Contains(out, secret) {
|
||||
t.Fatalf("control read = %q, %v; the sandbox must still read ordinary files", out, err)
|
||||
}
|
||||
for _, path := range []string{remoteToken, flagToken} {
|
||||
if out, err := read(path); err == nil || strings.Contains(out, secret) {
|
||||
t.Fatalf("sandboxed read of %s = %q, %v; want denied", path, out, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent/testutil"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
func countingMCPStub(t *testing.T, name string) (*httptest.Server, *atomic.Int32) {
|
||||
t.Helper()
|
||||
inner := mcpHostSessionStub(t, name, nil)
|
||||
handler := inner.Config.Handler
|
||||
inner.Close()
|
||||
hits := &atomic.Int32{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
handler.ServeHTTP(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, hits
|
||||
}
|
||||
|
||||
// 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()
|
||||
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, root, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForHit(hits *atomic.Int32, within time.Duration) bool {
|
||||
deadline := time.Now().Add(within)
|
||||
for time.Now().Before(deadline) {
|
||||
if hits.Load() > 0 {
|
||||
return true
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
return hits.Load() > 0
|
||||
}
|
||||
|
||||
// A project's configuration cannot choose MCP servers the host starts: a
|
||||
// repository-declared server stays idle until the user enables it, while a
|
||||
// user-level server keeps starting as before.
|
||||
func TestEffectProjectDeclaredMCPDoesNotStartThroughRealBuild(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
|
||||
userSrv, userHits := countingMCPStub(t, "user")
|
||||
tomlSrv, tomlHits := countingMCPStub(t, "repo-toml")
|
||||
jsonSrv, jsonHits := countingMCPStub(t, "repo-json")
|
||||
|
||||
userConfig := config.UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(userConfig), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(userConfig, []byte("[[plugins]]\nname = \"user-http\"\ntype = \"http\"\nurl = \""+userSrv.URL+"\"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker := filepath.Join(dir, "stdio-started")
|
||||
stdioPlugin := ""
|
||||
if runtime.GOOS != "windows" {
|
||||
stdioPlugin = "\n[[plugins]]\nname = \"repo-stdio\"\ncommand = \"sh\"\nargs = [\"-c\", \"echo started > " + marker + "\"]\n"
|
||||
}
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "openai"
|
||||
base_url = "https://example.invalid"
|
||||
model = "x"
|
||||
api_key_env = "REASONIX_TEST_KEY_UNSET"
|
||||
|
||||
[[plugins]]
|
||||
name = "repo-toml-http"
|
||||
type = "http"
|
||||
url = "`+tomlSrv.URL+`"
|
||||
auto_start = true
|
||||
`+stdioPlugin)
|
||||
writeFile(t, dir, ".mcp.json", `{"mcpServers":{"repo-json-http":{"type":"http","url":"`+jsonSrv.URL+`"}}}`)
|
||||
|
||||
build := func() {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
ctrl, err := Build(ctx, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
t.Cleanup(ctrl.Close)
|
||||
}
|
||||
|
||||
build()
|
||||
if !waitForHit(userHits, 5*time.Second) {
|
||||
t.Fatal("user-level MCP server was never contacted; the probe cannot tell idle from broken")
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if n := tomlHits.Load(); n != 0 {
|
||||
t.Fatalf("project reasonix.toml MCP server received %d requests with no user decision", n)
|
||||
}
|
||||
if n := jsonHits.Load(); n != 0 {
|
||||
t.Fatalf("project .mcp.json MCP server received %d requests with no user decision", n)
|
||||
}
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
t.Fatal("project stdio MCP command ran with no user decision")
|
||||
}
|
||||
|
||||
cfg, err := config.LoadForRootReadOnly(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range cfg.Plugins {
|
||||
if p.Name == "repo-toml-http" {
|
||||
if err := config.DefaultMCPActivationStore().SetServerEnabled(p, dir, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
build()
|
||||
if !waitForHit(tomlHits, 5*time.Second) {
|
||||
t.Fatal("project MCP server stayed idle after the user enabled it")
|
||||
}
|
||||
if n := jsonHits.Load(); n != 0 {
|
||||
t.Fatalf("enabling one project server started another: .mcp.json server got %d requests", n)
|
||||
}
|
||||
}
|
||||
|
||||
// An enable decision holds for the declaration the user approved; rewriting
|
||||
// the command afterwards leaves the server off until they approve again.
|
||||
func TestEffectRewrittenProjectMCPCommandDoesNotRunThroughRealBuild(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("marker command is POSIX shell")
|
||||
}
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
base := `
|
||||
default_model = "test-model"
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "openai"
|
||||
base_url = "https://example.invalid"
|
||||
model = "x"
|
||||
api_key_env = "REASONIX_TEST_KEY_UNSET"
|
||||
`
|
||||
approved := filepath.Join(dir, "approved-ran")
|
||||
rewritten := filepath.Join(dir, "rewritten-ran")
|
||||
writeFile(t, dir, "reasonix.toml", base+"\n[[plugins]]\nname = \"repo-stdio\"\ncommand = \"sh\"\nargs = [\"-c\", \"echo ok > "+approved+"\"]\n")
|
||||
enableProjectMCPForTest(t, dir)
|
||||
writeFile(t, dir, "reasonix.toml", base+"\n[[plugins]]\nname = \"repo-stdio\"\ncommand = \"sh\"\nargs = [\"-c\", \"echo changed > "+rewritten+"\"]\n")
|
||||
|
||||
var notices []event.Event
|
||||
var mu sync.Mutex
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
ctrl, err := Build(ctx, Options{Sink: event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.Notice {
|
||||
mu.Lock()
|
||||
notices = append(notices, e)
|
||||
mu.Unlock()
|
||||
}
|
||||
})})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
t.Cleanup(ctrl.Close)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(rewritten); err == nil {
|
||||
t.Fatal("rewritten command of an enabled project server ran with no new decision")
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, n := range notices {
|
||||
if strings.Contains(n.Detail, "repo-stdio [changed_since_enabled]") && strings.Contains(n.Detail, "echo changed > "+rewritten) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("no notice named the changed server and its new command; notices=%+v", notices)
|
||||
}
|
||||
|
||||
// The model learns why a project server is off, as a typed cause it can report,
|
||||
// rather than a generic disabled state it might try to route around.
|
||||
func TestEffectModelSeesProjectMCPAwaitingUserDecision(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
srv, hits := countingMCPStub(t, "repo")
|
||||
writeFile(t, dir, "reasonix.toml", mcpCapabilityTestProviderConfig+`
|
||||
[[plugins]]
|
||||
name = "repo-http"
|
||||
type = "http"
|
||||
url = "`+srv.URL+`"
|
||||
`)
|
||||
out := runUseCapabilityCalls(t, Options{Sink: event.Discard}, "mcp-server:repo-http", "mcp-tool:repo-http/ping")
|
||||
if !strings.Contains(out, "[awaiting_user_decision]") {
|
||||
t.Fatalf("model-visible refusal lacks the typed cause:\n%s", out)
|
||||
}
|
||||
if n := hits.Load(); n != 0 {
|
||||
t.Fatalf("pending project server received %d requests during the turn", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Connecting a pending project server by hand is the user's approval, so the
|
||||
// next session starts it without asking again.
|
||||
func TestConnectingPendingProjectMCPRecordsTheDecision(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
srv, hits := countingMCPStub(t, "repo")
|
||||
writeFile(t, dir, "reasonix.toml", mcpCapabilityTestProviderConfig+`
|
||||
[[plugins]]
|
||||
name = "repo-http"
|
||||
type = "http"
|
||||
url = "`+srv.URL+`"
|
||||
`)
|
||||
registerBootTokenProfileTestProvider()
|
||||
setBootTokenProfileTestProvider(t, testutil.NewMock("connect", testutil.Turn{Text: "done"}))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
ctrl, err := Build(ctx, Options{Sink: event.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
t.Cleanup(ctrl.Close)
|
||||
if _, err := ctrl.ConnectConfiguredMCPServer("repo-http"); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
if hits.Load() == 0 {
|
||||
t.Fatal("explicit connect never reached the server")
|
||||
}
|
||||
cfg, err := config.LoadForRootReadOnly(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range cfg.Plugins {
|
||||
if p.Name == "repo-http" {
|
||||
if d := config.MCPServerDecision(p, ctrl.WorkspaceRoot()); d != config.MCPDecisionOn {
|
||||
t.Fatalf("decision after explicit connect = %s, want enabled", d.Code())
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("repo-http not loaded")
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
// projectMCPNoticesShown keeps the notice to once per process for each
|
||||
// declaration, so rebuilding a controller does not repeat it.
|
||||
var projectMCPNoticesShown sync.Map
|
||||
|
||||
// emitProjectMCPDecisionNotice tells the user which project-declared servers are
|
||||
// off pending their decision, with what each would run.
|
||||
func emitProjectMCPDecisionNotice(sink event.Sink, cfg *config.Config, root string) {
|
||||
if sink == nil || cfg == nil {
|
||||
return
|
||||
}
|
||||
var lines []string
|
||||
for _, p := range cfg.Plugins {
|
||||
d := config.MCPServerDecision(p, root)
|
||||
if !d.AwaitsUser() {
|
||||
continue
|
||||
}
|
||||
key := strings.Join([]string{config.ReasonixHomeDir(), root, p.Name, d.Code(), config.MCPLaunchLine(p)}, "\x00")
|
||||
if _, seen := projectMCPNoticesShown.LoadOrStore(key, true); seen {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" %s [%s]: %s", p.Name, d.Code(), config.MCPLaunchLine(p)))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
sink.Emit(event.Event{
|
||||
Kind: event.Notice, Level: event.LevelWarn,
|
||||
Text: "This project declares MCP servers that stay off until you enable them.",
|
||||
Detail: strings.Join(lines, "\n") +
|
||||
"\nReview each one (full declaration: `reasonix mcp get <name>`), then enable it with `reasonix mcp enable <name>`, /mcp connect, or the MCP panel.",
|
||||
})
|
||||
}
|
||||
@@ -113,6 +113,9 @@ func rebuildWithPrevious(ctx context.Context, old *control.Controller, previous
|
||||
if opts.PersistentShell == nil {
|
||||
opts.PersistentShell = old.PersistentShell()
|
||||
}
|
||||
if opts.WorkspaceRepo.Dir == "" {
|
||||
opts.WorkspaceRepo = old.WorkspaceRepo()
|
||||
}
|
||||
|
||||
home := config.ReasonixHomeDir()
|
||||
// fromGraph must be the PREVIOUS generation's graph when available.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/gitcmd"
|
||||
)
|
||||
|
||||
// workspaceRepoTimeout bounds the one git call a session open makes.
|
||||
const workspaceRepoTimeout = 10 * time.Second
|
||||
|
||||
// workspaceRepo is root's git identity for the session being built, settled
|
||||
// before the session runs any command. A rebuild of the same workspace keeps
|
||||
// the identity its session opened with instead of rediscovering it from files
|
||||
// that session could since have written.
|
||||
func workspaceRepo(ctx context.Context, carried gitcmd.Repo, root string) gitcmd.Repo {
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return gitcmd.Repo{Dir: root}
|
||||
}
|
||||
if carried.Dir != "" && filepath.Clean(carried.Dir) == abs {
|
||||
return carried
|
||||
}
|
||||
if !underGitMarker(abs) {
|
||||
return gitcmd.Repo{Dir: abs}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, workspaceRepoTimeout)
|
||||
defer cancel()
|
||||
repo, err := gitcmd.Open(ctx, abs)
|
||||
if err != nil {
|
||||
return gitcmd.Repo{Dir: abs}
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
// underGitMarker reports whether dir or an ancestor holds a .git entry, so a
|
||||
// workspace outside any repository never starts git (on macOS a missing git is
|
||||
// an installer prompt, not an error).
|
||||
func underGitMarker(dir string) bool {
|
||||
for {
|
||||
if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil {
|
||||
return true
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return false
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/gitcmd"
|
||||
)
|
||||
|
||||
// A session resolves its workspace's repository when it opens; a rebuild of
|
||||
// the same workspace keeps that identity, and another workspace resolves its own.
|
||||
func TestWorkspaceRepoResolvedAtOpenAndCarriedAcrossRebuilds(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
root := t.TempDir()
|
||||
if out, err := exec.Command("git", "-C", root, "init", "-q").CombinedOutput(); err != nil {
|
||||
t.Fatalf("git init: %v: %s", err, out)
|
||||
}
|
||||
ctx := context.Background()
|
||||
repo := workspaceRepo(ctx, gitcmd.Repo{}, root)
|
||||
if !repo.Valid() {
|
||||
t.Fatalf("workspaceRepo(%s) = %+v, want a resolved repository", root, repo)
|
||||
}
|
||||
carried := gitcmd.Repo{Dir: repo.Dir, GitDir: "/pinned", CommonDir: "/pinned", WorkTree: "/pinned"}
|
||||
if got := workspaceRepo(ctx, carried, root); got != carried {
|
||||
t.Fatalf("rebuild of the same workspace = %+v, want the identity it opened with", got)
|
||||
}
|
||||
other := t.TempDir()
|
||||
if got := workspaceRepo(ctx, carried, other); got.Valid() || got.Dir != filepath.Clean(other) {
|
||||
t.Fatalf("another workspace = %+v, want it resolved on its own (no repository)", got)
|
||||
}
|
||||
}
|
||||
@@ -20,22 +20,22 @@ func TestResolveWorkspaceRootExplicitAndGitFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
// Explicit --dir pins the workspace root, not the git root of the repo.
|
||||
if got := resolveWorkspaceRoot(sub); got != sub {
|
||||
t.Fatalf("resolveWorkspaceRoot(%q) = %q, want the explicit dir", sub, got)
|
||||
if got := ResolveWorkspaceRoot(sub); got != sub {
|
||||
t.Fatalf("ResolveWorkspaceRoot(%q) = %q, want the explicit dir", sub, got)
|
||||
}
|
||||
|
||||
// No explicit root: fall back to the nearest git root from the CWD.
|
||||
t.Chdir(sub)
|
||||
got := resolveWorkspaceRoot("")
|
||||
got := ResolveWorkspaceRoot("")
|
||||
wantRoot, err := filepath.EvalSymlinks(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotResolved, err := filepath.EvalSymlinks(got)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveWorkspaceRoot(%q) returned unusable path %q: %v", "", got, err)
|
||||
t.Fatalf("ResolveWorkspaceRoot(%q) returned unusable path %q: %v", "", got, err)
|
||||
}
|
||||
if gotResolved != wantRoot {
|
||||
t.Fatalf("resolveWorkspaceRoot(\"\") = %q, want git root %q", got, repo)
|
||||
t.Fatalf("ResolveWorkspaceRoot(\"\") = %q, want git root %q", got, repo)
|
||||
}
|
||||
}
|
||||
@@ -293,9 +293,6 @@ func RenderTransientBlock(d RouteDecision) string {
|
||||
|
||||
func routeEntry(text string, e Entry) (AutoUse, string, bool) {
|
||||
if e.Kind == KindSkill {
|
||||
if explicitSkill(text, e.Name) {
|
||||
return AutoUseRequire, "the user explicitly referenced this skill", true
|
||||
}
|
||||
if e.AutoUse == AutoUseOff {
|
||||
return "", "", false
|
||||
}
|
||||
@@ -312,17 +309,6 @@ func routeEntry(text string, e Entry) (AutoUse, string, bool) {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func explicitSkill(text, name string) bool {
|
||||
n := normalize(name)
|
||||
return strings.Contains(text, "/"+n) ||
|
||||
strings.Contains(text, "use "+n+" skill") ||
|
||||
strings.Contains(text, "using "+n+" skill") ||
|
||||
strings.Contains(text, "使用 "+n+" skill") ||
|
||||
strings.Contains(text, "用 "+n+" skill") ||
|
||||
strings.Contains(text, "使用"+n+"技能") ||
|
||||
strings.Contains(text, "用"+n+"技能")
|
||||
}
|
||||
|
||||
// namesMCPTool matches the identifier the host mints for the tool. A server
|
||||
// name or a topic word selects every tool the server exposes, so it is left to
|
||||
// the semantic router, which reads the whole request.
|
||||
|
||||
@@ -26,19 +26,18 @@ func TestRoutePrefersReviewSkillForReviewRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteRequiresExplicitSkill(t *testing.T) {
|
||||
func TestRouteDoesNotTreatTextMentionAsInvocation(t *testing.T) {
|
||||
entries := SkillEntries([]skill.Skill{{
|
||||
Name: "audit",
|
||||
Description: "audit something",
|
||||
Scope: skill.ScopeProject,
|
||||
}}, []tool.ContractEntry{{Name: "run_skill"}})
|
||||
|
||||
decision := Route("请使用 audit skill 检查一下", entries)
|
||||
if len(decision.Candidates) == 0 {
|
||||
t.Fatal("Route returned no candidates")
|
||||
}
|
||||
if got := decision.Candidates[0].Policy; got != AutoUseRequire {
|
||||
t.Fatalf("policy = %s, want require", got)
|
||||
for _, text := range []string{"/audit 检查一下", "then run /audit", "请使用 audit skill 检查一下"} {
|
||||
decision := Route(text, entries)
|
||||
if len(decision.Candidates) != 0 {
|
||||
t.Errorf("%q must not create a structural skill invocation: %+v", text, decision.Candidates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -461,6 +461,7 @@ func collectMCP(cfg *config.Config, root, home, reasonixHome string, disp func(s
|
||||
if cfg == nil {
|
||||
return rep, issues
|
||||
}
|
||||
enabled := enabledMCPNames(cfg, root, reasonixHome)
|
||||
// Stable order by name.
|
||||
entries := append([]config.PluginEntry(nil), cfg.Plugins...)
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
@@ -471,13 +472,10 @@ func collectMCP(cfg *config.Config, root, home, reasonixHome string, disp func(s
|
||||
Name: p.Name,
|
||||
Effective: true,
|
||||
Transport: transportOf(p.Type),
|
||||
StartIntent: "automatic",
|
||||
StartIntent: startIntent(enabled[p.Name]),
|
||||
EnvKeys: sortedKeys(p.Env),
|
||||
HeaderKeys: sortedKeys(p.Headers),
|
||||
}
|
||||
if !p.ShouldAutoStart() {
|
||||
info.StartIntent = "off"
|
||||
}
|
||||
if owner, ok := cfg.PluginPackageOwner(p.Name); ok {
|
||||
info.PackageOwner = owner
|
||||
info.Source = "plugin_package"
|
||||
|
||||
@@ -35,10 +35,12 @@ func probeLiveMCP(rep *MCPReport, cfg *config.Config, root, home, reasonixHome s
|
||||
timeout = MaxLiveTimeout
|
||||
}
|
||||
|
||||
// Only probe automatic start intent servers.
|
||||
// Only probe servers that would start on their own; a project-declared one
|
||||
// the user has not enabled must not start here either.
|
||||
enabled := enabledMCPNames(cfg, root, reasonixHome)
|
||||
var auto []config.PluginEntry
|
||||
for _, p := range cfg.Plugins {
|
||||
if p.ShouldAutoStart() {
|
||||
if enabled[p.Name] {
|
||||
auto = append(auto, p)
|
||||
} else {
|
||||
for i := range rep.Servers {
|
||||
@@ -155,3 +157,24 @@ They may access the network and receive configured environment variables and hea
|
||||
Tools are not registered into the agent registry. Startup stats/schema cache writes are disabled.
|
||||
Host is always closed after the probe.`)
|
||||
}
|
||||
|
||||
// enabledMCPNames resolves which servers start on their own, reading the
|
||||
// activation store under reasonixHome.
|
||||
func enabledMCPNames(cfg *config.Config, root, reasonixHome string) map[string]bool {
|
||||
store := config.DefaultMCPActivationStore()
|
||||
if strings.TrimSpace(reasonixHome) != "" {
|
||||
store = config.NewMCPActivationStore(reasonixHome)
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, p := range cfg.EnabledPlugins(root, store) {
|
||||
out[p.Name] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func startIntent(enabled bool) string {
|
||||
if enabled {
|
||||
return "automatic"
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/gitcmd"
|
||||
"reasonix/internal/hook"
|
||||
"reasonix/internal/i18n"
|
||||
"reasonix/internal/memory"
|
||||
@@ -572,9 +573,16 @@ func runStatuslineCmd(cmd, stdinPayload string) string {
|
||||
}
|
||||
|
||||
func runStatuslineCmdWithTimeout(cmd, stdinPayload string, timeout time.Duration) string {
|
||||
res := hook.DefaultSpawner(context.Background(), hook.SpawnInput{
|
||||
return runStatuslineCmdWith(hook.DefaultSpawner, cmd, stdinPayload, timeout)
|
||||
}
|
||||
|
||||
// runStatuslineCmdWith spawns from the workspace, which the user does not
|
||||
// author, so a bare command name resolves through PATH alone.
|
||||
func runStatuslineCmdWith(spawn hook.Spawner, cmd, stdinPayload string, timeout time.Duration) string {
|
||||
res := spawn(context.Background(), hook.SpawnInput{
|
||||
Command: cmd,
|
||||
Stdin: stdinPayload + "\n",
|
||||
Env: map[string]string{hook.NoCwdCommandSearchEnv: "1"},
|
||||
Timeout: timeout,
|
||||
})
|
||||
out := strings.TrimSpace(res.Stdout)
|
||||
@@ -588,7 +596,16 @@ func (m chatTUI) refreshGitStatus() tea.Cmd {
|
||||
if m.statuslineCmd != "" {
|
||||
return nil
|
||||
}
|
||||
return fetchGitStatus()
|
||||
return fetchGitStatus(sessionWorkspaceRepo(m.ctrl))
|
||||
}
|
||||
|
||||
// sessionWorkspaceRepo is the git identity ctrl's session opened with; a
|
||||
// controller that carries none has no status line to read.
|
||||
func sessionWorkspaceRepo(ctrl control.SessionAPI) gitcmd.Repo {
|
||||
if c, ok := ctrl.(interface{ WorkspaceRepo() gitcmd.Repo }); ok {
|
||||
return c.WorkspaceRepo()
|
||||
}
|
||||
return gitcmd.Repo{}
|
||||
}
|
||||
|
||||
// modelSwitchMsg carries the result of an async /model switch. A nil err means
|
||||
|
||||
+31
-20
@@ -792,7 +792,7 @@ func runServeWithOptions(args []string, opts serveRunOptions) int {
|
||||
if opts.command == "web" {
|
||||
sessionID = fs.String("session-id", "", "bind a fresh Web session identity (used by /web handoff)")
|
||||
}
|
||||
authHelp := "auth mode: none, token, or password (default: config/none)"
|
||||
authHelp := "auth mode: none, token, or password (default: config/none; none still requires the launch token for changes)"
|
||||
if opts.command == "web" {
|
||||
authHelp = "auth mode: none, token, or password (default: generated token)"
|
||||
}
|
||||
@@ -867,6 +867,7 @@ func runServeWithOptions(args []string, opts serveRunOptions) int {
|
||||
return 1
|
||||
}
|
||||
serveCfg.Token = tok
|
||||
config.RegisterHostSecretPath(*tokenFile)
|
||||
}
|
||||
if *behindProxy {
|
||||
serveCfg.BehindProxy = true
|
||||
@@ -1851,11 +1852,16 @@ func repairInvalidProviderKeyEnvs(providers []config.ProviderEntry) ([]config.Pr
|
||||
return providers, repairs
|
||||
}
|
||||
|
||||
func promptAPIKeyEnvName(in *bufio.Scanner, w io.Writer, label, def string) string {
|
||||
// promptAPIKeyEnvName reports explicit=false when the user pressed Enter: def
|
||||
// is then only a draft name, and a saved key goes to a private slot instead.
|
||||
func promptAPIKeyEnvName(in *bufio.Scanner, w io.Writer, label, def string) (keyEnv string, explicit bool) {
|
||||
for {
|
||||
keyEnv := ask(in, w, label, def)
|
||||
keyEnv = ask(in, w, label, "")
|
||||
if keyEnv == "" {
|
||||
return def, false
|
||||
}
|
||||
if config.IsValidCredentialKey(keyEnv) {
|
||||
return keyEnv
|
||||
return keyEnv, true
|
||||
}
|
||||
fmt.Fprintf(w, i18n.M.InvalidAPIKeyEnvFmt+"\n", keyEnv)
|
||||
}
|
||||
@@ -1891,10 +1897,13 @@ func familyOf(name string) providerFamily {
|
||||
type providerPromptResult struct {
|
||||
entries []config.ProviderEntry
|
||||
credentials map[string]string
|
||||
// keyEnvTyped: the user typed the entries' api_key_env rather than accepting keyEnvDraft.
|
||||
keyEnvTyped bool
|
||||
keyEnvDraft string
|
||||
}
|
||||
|
||||
func newProviderPromptResult(entries []config.ProviderEntry, key, value string) providerPromptResult {
|
||||
result := providerPromptResult{entries: entries}
|
||||
func newProviderPromptResult(entries []config.ProviderEntry, key, value string, typed bool, draft string) providerPromptResult {
|
||||
result := providerPromptResult{entries: entries, keyEnvTyped: typed, keyEnvDraft: draft}
|
||||
if key != "" && value != "" {
|
||||
result.credentials = map[string]string{key: value}
|
||||
}
|
||||
@@ -1918,7 +1927,7 @@ func promptCustomProvider(proxy netclient.ProxySpec) (providerPromptResult, erro
|
||||
|
||||
// promptCustomProviderManual handles manual model entry.
|
||||
func promptCustomProviderManual() (providerPromptResult, error) {
|
||||
return promptCustomProviderManualWith(bufio.NewScanner(os.Stdin), "", "", "")
|
||||
return promptCustomProviderManualWith(bufio.NewScanner(os.Stdin), "", "", false, "")
|
||||
}
|
||||
|
||||
// promptCustomProviderManualWith is the shared backend for manual entry.
|
||||
@@ -1926,7 +1935,7 @@ func promptCustomProviderManual() (providerPromptResult, error) {
|
||||
// so the URL-fetch flow can fall through to manual entry without re-asking
|
||||
// the user for information they've already typed. An empty apiKey is allowed
|
||||
// — the key step happens later in the wizard and Reasonix's global .env is updated then.
|
||||
func promptCustomProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKey string) (providerPromptResult, error) {
|
||||
func promptCustomProviderManualWith(in *bufio.Scanner, baseURL, keyEnv string, keyEnvTyped bool, apiKey string) (providerPromptResult, error) {
|
||||
fmt.Println()
|
||||
if baseURL == "" {
|
||||
baseURL = ask(in, os.Stdout, i18n.M.CustomPromptBaseURL, "")
|
||||
@@ -1939,8 +1948,9 @@ func promptCustomProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKey s
|
||||
if modelName == "" {
|
||||
return providerPromptResult{}, fmt.Errorf("model name is required")
|
||||
}
|
||||
draft := apiKeyEnvFromProviderName(providerName)
|
||||
if keyEnv == "" {
|
||||
keyEnv = promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, apiKeyEnvFromProviderName(providerName))
|
||||
keyEnv, keyEnvTyped = promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, draft)
|
||||
} else if !config.IsValidCredentialKey(keyEnv) {
|
||||
return providerPromptResult{}, fmt.Errorf("invalid API key variable name %q", keyEnv)
|
||||
}
|
||||
@@ -1952,7 +1962,7 @@ func promptCustomProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKey s
|
||||
Model: modelName, APIKeyEnv: keyEnv, ContextWindow: askContextWindow(in, os.Stdout),
|
||||
}
|
||||
fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.CustomAddedFmt, entry.Name+"/"+modelName)))
|
||||
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil
|
||||
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey, keyEnvTyped, draft), nil
|
||||
}
|
||||
|
||||
// promptCustomProviderFromURL tries the OpenAI-compatible GET /models
|
||||
@@ -1968,7 +1978,8 @@ func promptCustomProviderFromURL(proxy netclient.ProxySpec) (providerPromptResul
|
||||
return providerPromptResult{}, fmt.Errorf("base URL is required")
|
||||
}
|
||||
providerName := providerSlug("custom", baseURL)
|
||||
keyEnv := promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, apiKeyEnvFromProviderName(providerName))
|
||||
draft := apiKeyEnvFromProviderName(providerName)
|
||||
keyEnv, keyEnvTyped := promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, draft)
|
||||
apiKey := askSecret(in, os.Stdout, i18n.M.CustomPromptAPIKey)
|
||||
|
||||
fmt.Printf(" %s\n", dim(fmt.Sprintf(i18n.M.FetchingModelsFmt, "custom")))
|
||||
@@ -1981,7 +1992,7 @@ func promptCustomProviderFromURL(proxy netclient.ProxySpec) (providerPromptResul
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, " %s\n", dim(i18n.M.CustomFetchEmpty))
|
||||
}
|
||||
return promptCustomProviderManualWith(in, baseURL, keyEnv, apiKey)
|
||||
return promptCustomProviderManualWith(in, baseURL, keyEnv, keyEnvTyped, apiKey)
|
||||
}
|
||||
fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.FetchModelsSuccessFmt, len(models), "custom")))
|
||||
|
||||
@@ -2002,7 +2013,7 @@ func promptCustomProviderFromURL(proxy netclient.ProxySpec) (providerPromptResul
|
||||
Models: selected, Model: selected[0], APIKeyEnv: keyEnv, ContextWindow: askContextWindow(in, os.Stdout),
|
||||
}
|
||||
fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.CustomAddedFmt, entry.Name+"/"+selected[0])))
|
||||
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil
|
||||
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey, keyEnvTyped, draft), nil
|
||||
}
|
||||
|
||||
// promptAnthropicProvider handles the Anthropic compatible provider entry flow.
|
||||
@@ -2022,14 +2033,14 @@ func promptAnthropicProvider(proxy netclient.ProxySpec) (providerPromptResult, e
|
||||
|
||||
// promptAnthropicProviderManual handles manual model entry.
|
||||
func promptAnthropicProviderManual() (providerPromptResult, error) {
|
||||
return promptAnthropicProviderManualWith(bufio.NewScanner(os.Stdin), "", "", "")
|
||||
return promptAnthropicProviderManualWith(bufio.NewScanner(os.Stdin), "", "", false, "")
|
||||
}
|
||||
|
||||
// promptAnthropicProviderManualWith is the shared backend for manual entry
|
||||
// of an Anthropic-compatible custom provider. Pre-filled values (baseURL,
|
||||
// keyEnv, apiKey) are reused as-is when non-empty so the URL-fetch flow
|
||||
// can fall through to manual entry without re-asking the user.
|
||||
func promptAnthropicProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKey string) (providerPromptResult, error) {
|
||||
func promptAnthropicProviderManualWith(in *bufio.Scanner, baseURL, keyEnv string, keyEnvTyped bool, apiKey string) (providerPromptResult, error) {
|
||||
fmt.Println()
|
||||
if baseURL == "" {
|
||||
baseURL = ask(in, os.Stdout, i18n.M.AnthropicPromptBaseURL, "")
|
||||
@@ -2042,7 +2053,7 @@ func promptAnthropicProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKe
|
||||
return providerPromptResult{}, fmt.Errorf("model name is required")
|
||||
}
|
||||
if keyEnv == "" {
|
||||
keyEnv = promptAPIKeyEnvName(in, os.Stdout, i18n.M.AnthropicPromptKeyEnv, "ANTHROPIC_API_KEY")
|
||||
keyEnv, keyEnvTyped = promptAPIKeyEnvName(in, os.Stdout, i18n.M.AnthropicPromptKeyEnv, "ANTHROPIC_API_KEY")
|
||||
} else if !config.IsValidCredentialKey(keyEnv) {
|
||||
return providerPromptResult{}, fmt.Errorf("invalid API key variable name %q", keyEnv)
|
||||
}
|
||||
@@ -2054,7 +2065,7 @@ func promptAnthropicProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKe
|
||||
Model: modelName, APIKeyEnv: keyEnv, ContextWindow: askContextWindow(in, os.Stdout),
|
||||
}
|
||||
fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.AnthropicAddedFmt, entry.Name+"/"+modelName)))
|
||||
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil
|
||||
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey, keyEnvTyped, "ANTHROPIC_API_KEY"), nil
|
||||
}
|
||||
|
||||
// promptAnthropicProviderFromURL tries the OpenAI-compatible GET /models
|
||||
@@ -2070,7 +2081,7 @@ func promptAnthropicProviderFromURL(proxy netclient.ProxySpec) (providerPromptRe
|
||||
if baseURL == "" {
|
||||
return providerPromptResult{}, fmt.Errorf("base URL is required")
|
||||
}
|
||||
keyEnv := promptAPIKeyEnvName(in, os.Stdout, i18n.M.AnthropicPromptKeyEnv, "ANTHROPIC_API_KEY")
|
||||
keyEnv, keyEnvTyped := promptAPIKeyEnvName(in, os.Stdout, i18n.M.AnthropicPromptKeyEnv, "ANTHROPIC_API_KEY")
|
||||
apiKey := askSecret(in, os.Stdout, i18n.M.AnthropicPromptAPIKey)
|
||||
|
||||
fmt.Printf(" %s\n", dim(fmt.Sprintf(i18n.M.AnthropicFetchingModelsFmt, "anthropic")))
|
||||
@@ -2083,7 +2094,7 @@ func promptAnthropicProviderFromURL(proxy netclient.ProxySpec) (providerPromptRe
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, " %s\n", dim(i18n.M.AnthropicFetchEmpty))
|
||||
}
|
||||
return promptAnthropicProviderManualWith(in, baseURL, keyEnv, apiKey)
|
||||
return promptAnthropicProviderManualWith(in, baseURL, keyEnv, keyEnvTyped, apiKey)
|
||||
}
|
||||
fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.AnthropicFetchModelsSuccessFmt, len(models), "anthropic")))
|
||||
|
||||
@@ -2104,7 +2115,7 @@ func promptAnthropicProviderFromURL(proxy netclient.ProxySpec) (providerPromptRe
|
||||
Models: selected, Model: selected[0], APIKeyEnv: keyEnv, ContextWindow: askContextWindow(in, os.Stdout),
|
||||
}
|
||||
fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.AnthropicAddedFmt, entry.Name+"/"+selected[0])))
|
||||
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil
|
||||
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey, keyEnvTyped, "ANTHROPIC_API_KEY"), nil
|
||||
}
|
||||
|
||||
func groupByFamily(providers []config.ProviderEntry) ([]string, map[string][]int, map[string]providerFamily) {
|
||||
|
||||
@@ -1838,6 +1838,7 @@ func TestPromptCustomProviderManualDefaultsKeyEnvFromBaseURL(t *testing.T) {
|
||||
bufio.NewScanner(strings.NewReader("sensenova-chat\n\n\n")),
|
||||
"https://token.sensenova.cn/v1",
|
||||
"",
|
||||
false,
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -1857,6 +1858,7 @@ func TestPromptCustomProviderManualPreservesExplicitKeyEnv(t *testing.T) {
|
||||
bufio.NewScanner(strings.NewReader("manual-chat\n\n")),
|
||||
"https://token.sensenova.cn/v1",
|
||||
"CUSTOM_API_KEY",
|
||||
true,
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -1874,14 +1876,14 @@ func TestPromptCustomProviderManualPreservesExplicitKeyEnv(t *testing.T) {
|
||||
func TestPromptAPIKeyEnvNameRejectsModelName(t *testing.T) {
|
||||
i18n.DetectLanguage("en")
|
||||
var out bytes.Buffer
|
||||
got := promptAPIKeyEnvName(
|
||||
got, typed := promptAPIKeyEnvName(
|
||||
bufio.NewScanner(strings.NewReader("grok-4.5\n\n")),
|
||||
&out,
|
||||
i18n.M.CustomPromptKeyEnv,
|
||||
"CUSTOM_API_YAIROUTER_COM_API_KEY",
|
||||
)
|
||||
if got != "CUSTOM_API_YAIROUTER_COM_API_KEY" {
|
||||
t.Fatalf("key env = %q, want generated default", got)
|
||||
if got != "CUSTOM_API_YAIROUTER_COM_API_KEY" || typed {
|
||||
t.Fatalf("key env = %q (typed %v), want the untyped generated default", got, typed)
|
||||
}
|
||||
if text := out.String(); !strings.Contains(text, "not a valid API Key variable name") || !strings.Contains(text, "do not enter a model name") {
|
||||
t.Fatalf("validation guidance missing from prompt output: %q", text)
|
||||
@@ -1893,6 +1895,7 @@ func TestPromptCustomProviderManualAsksForModelBeforeCredentialName(t *testing.T
|
||||
bufio.NewScanner(strings.NewReader("grok-4.5\ngrok-4.5\n\n\n")),
|
||||
"https://api.example.com/v1",
|
||||
"",
|
||||
false,
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -1914,6 +1917,7 @@ func TestPromptCustomProviderStagesExplicitKeyEvenWhenProcessEnvMatches(t *testi
|
||||
bufio.NewScanner(strings.NewReader("grok-4.5\n")),
|
||||
"https://api.example.com/v1",
|
||||
key,
|
||||
true,
|
||||
"same-secret",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -1929,6 +1933,7 @@ func TestPromptCustomProviderStagesExplicitKeyEvenWhenProcessEnvMatches(t *testi
|
||||
bufio.NewScanner(strings.NewReader("grok-4.5\n")),
|
||||
"https://api.example.com/v1",
|
||||
key,
|
||||
true,
|
||||
"new-secret",
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
Loaded 100 of 460 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user