mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-08 01:48:26 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7574f8b4c9 | ||
|
|
d945284cec | ||
|
|
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
|
||||
@@ -1825,4 +1829,4 @@ jobs:
|
||||
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
|
||||
run: gh workflow run pages.yml --ref website
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -306,4 +312,4 @@ jobs:
|
||||
- name: Refresh public changelog
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh workflow run pages.yml --ref main-v2
|
||||
run: gh workflow run pages.yml --ref website
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+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`,重新保存每个
|
||||
|
||||
@@ -8,6 +8,25 @@ 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.
|
||||
|
||||
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-site recovery / 官网独立恢复
|
||||
|
||||
Candidate preparation and publication execute offline identity, resolver-output,
|
||||
|
||||
@@ -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-*` 产物,记录
|
||||
|
||||
@@ -1478,7 +1478,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, " "))
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-19
@@ -1851,11 +1851,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 +1896,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 +1926,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 +1934,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 +1947,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 +1961,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 +1977,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 +1991,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 +2012,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 +2032,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 +2052,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 +2064,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 +2080,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 +2093,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 +2114,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 {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/i18n"
|
||||
)
|
||||
|
||||
func (s *providerSetupSession) addPrompted(in *bufio.Scanner, w io.Writer, result providerPromptResult) bool {
|
||||
if result.keyEnvTyped && len(result.credentials) > 0 && len(result.entries) > 0 {
|
||||
names := make([]string, 0, len(result.entries))
|
||||
for _, entry := range result.entries {
|
||||
names = append(names, entry.Name)
|
||||
}
|
||||
from := result.entries[0].APIKeyEnv
|
||||
to, typed := s.settleTypedKeyEnv(in, w, keyEnvPromptLabel(result.entries[0].Kind), names, from, result.keyEnvDraft)
|
||||
result.renameKeyEnv(from, to)
|
||||
result.keyEnvTyped = typed
|
||||
}
|
||||
for _, entry := range result.entries {
|
||||
if !confirmSharedCredential(s.cfg, entry, "") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if err := s.add(result.entries); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return false
|
||||
}
|
||||
s.addProviderAccess(result.entries)
|
||||
for key, value := range result.credentials {
|
||||
var names []string
|
||||
for _, entry := range result.entries {
|
||||
if entry.APIKeyEnv == key {
|
||||
names = append(names, entry.Name)
|
||||
}
|
||||
}
|
||||
if err := s.setCredentialForProviders(names, key, value); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return false
|
||||
}
|
||||
if result.keyEnvTyped {
|
||||
for _, name := range names {
|
||||
s.typedKeyEnv[name] = key
|
||||
}
|
||||
}
|
||||
}
|
||||
// After the new keys are staged, so usability sees them.
|
||||
s.promoteDefaultToNewProviders(result.entries)
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *providerPromptResult) renameKeyEnv(from, to string) {
|
||||
if from == to {
|
||||
return
|
||||
}
|
||||
for i := range r.entries {
|
||||
if r.entries[i].APIKeyEnv == from {
|
||||
r.entries[i].APIKeyEnv = to
|
||||
}
|
||||
}
|
||||
if value, ok := r.credentials[from]; ok {
|
||||
delete(r.credentials, from)
|
||||
r.credentials[to] = value
|
||||
}
|
||||
}
|
||||
|
||||
// settleTypedKeyEnv asks again while providers may not claim key, saying who
|
||||
// holds it. Pressing Enter returns draft with typed=false: a private slot.
|
||||
func (s *providerSetupSession) settleTypedKeyEnv(in *bufio.Scanner, w io.Writer, label string, providers []string, key, draft string) (string, bool) {
|
||||
for {
|
||||
var held error
|
||||
for _, name := range providers {
|
||||
if held = s.cfg.CredentialKeyClaimable(key, name); held != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if held == nil {
|
||||
return key, true
|
||||
}
|
||||
fmt.Fprintln(w, keyEnvInUseText(held))
|
||||
fmt.Fprintln(w, i18n.M.SetupKeyEnvRetry)
|
||||
var typed bool
|
||||
if key, typed = promptAPIKeyEnvName(in, w, label, draft); !typed {
|
||||
return key, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keyEnvPromptLabel(kind string) string {
|
||||
if kind == "anthropic" {
|
||||
return i18n.M.AnthropicPromptKeyEnv
|
||||
}
|
||||
return i18n.M.CustomPromptKeyEnv
|
||||
}
|
||||
|
||||
func keyEnvInUseText(err error) string {
|
||||
var inUse *config.CredentialKeyInUseError
|
||||
if !errors.As(err, &inUse) {
|
||||
return err.Error()
|
||||
}
|
||||
switch inUse.Holder {
|
||||
case config.CredentialKeyHeldByProvider:
|
||||
return fmt.Sprintf(i18n.M.SetupKeyEnvTakenFmt, inUse.Key, inUse.Provider)
|
||||
case config.CredentialKeyHeldBySetting:
|
||||
return fmt.Sprintf(i18n.M.SetupKeyEnvSettingFmt, inUse.Key)
|
||||
case config.CredentialKeyHeldByEnvironment:
|
||||
return fmt.Sprintf(i18n.M.SetupKeyEnvShellFmt, inUse.Key)
|
||||
default:
|
||||
return fmt.Sprintf(i18n.M.SetupKeyEnvStoredFmt, inUse.Key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
const opencodeBaseURL = "https://opencode.ai/zen/go/v1"
|
||||
|
||||
func newKeyEnvTestSession(t *testing.T, fixture func(*config.Config)) (*providerSetupSession, string) {
|
||||
t.Helper()
|
||||
isolateUserConfig(t)
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
for _, key := range []string{"CUSTOM_OPENCODE_AI_API_KEY", "OPENCODE_KEY", "ORPHAN_KEY", "SHARED_RELAY_KEY"} {
|
||||
t.Setenv(key, "") // registers the restore; saving pins the key into the process
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
path := config.UserConfigPath()
|
||||
c := config.Default()
|
||||
if fixture != nil {
|
||||
fixture(c)
|
||||
}
|
||||
if err := c.SaveTo(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := config.LoadForEditReadOnlyStrict(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return newProviderSetupSessionForPath(c, path), path
|
||||
}
|
||||
|
||||
func addOpencodeThroughWizard(t *testing.T, s *providerSetupSession, input string, out *bytes.Buffer) bool {
|
||||
t.Helper()
|
||||
in := bufio.NewScanner(strings.NewReader(input))
|
||||
result, err := promptCustomProviderManualWith(in, opencodeBaseURL, "", false, "sk-opencode")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s.addPrompted(in, out, result)
|
||||
}
|
||||
|
||||
func savedKeyEnv(t *testing.T, path, provider string) string {
|
||||
t.Helper()
|
||||
saved, err := config.LoadForEditReadOnlyStrict(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, ok := saved.Provider(provider)
|
||||
if !ok {
|
||||
t.Fatalf("provider %q was not saved", provider)
|
||||
}
|
||||
return entry.APIKeyEnv
|
||||
}
|
||||
|
||||
func TestSetupHonoursATypedKeyEnvName(t *testing.T) {
|
||||
s, path := newKeyEnvTestSession(t, nil)
|
||||
var out bytes.Buffer
|
||||
if !addOpencodeThroughWizard(t, s, "chat\nCUSTOM_OPENCODE_AI_API_KEY\n\n", &out) {
|
||||
t.Fatalf("wizard refused the provider: %s", out.String())
|
||||
}
|
||||
if _, err := commitProviderSetupSession(s, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := savedKeyEnv(t, path, "custom-opencode-ai"); got != "CUSTOM_OPENCODE_AI_API_KEY" {
|
||||
t.Fatalf("api_key_env = %q, want the typed CUSTOM_OPENCODE_AI_API_KEY", got)
|
||||
}
|
||||
if res := config.ResolveCredentialForRootGlobalFirst(".", "CUSTOM_OPENCODE_AI_API_KEY"); res.Value != "sk-opencode" {
|
||||
t.Fatalf("typed variable holds %q, want the entered key", res.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupEnterForDefaultKeepsAPrivateSlot(t *testing.T) {
|
||||
s, path := newKeyEnvTestSession(t, nil)
|
||||
var out bytes.Buffer
|
||||
if !addOpencodeThroughWizard(t, s, "chat\n\n\n", &out) {
|
||||
t.Fatalf("wizard refused the provider: %s", out.String())
|
||||
}
|
||||
if _, err := commitProviderSetupSession(s, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := savedKeyEnv(t, path, "custom-opencode-ai"); !strings.HasPrefix(got, "REASONIX_CONNECTION_") {
|
||||
t.Fatalf("api_key_env = %q, want a private REASONIX_CONNECTION_ slot", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupRefusesATypedNameAnotherProviderHolds(t *testing.T) {
|
||||
other := config.ProviderEntry{Name: "relay", Kind: "openai", BaseURL: "https://relay.invalid/v1", Model: "chat", APIKeyEnv: "SHARED_RELAY_KEY"}
|
||||
s, path := newKeyEnvTestSession(t, func(c *config.Config) { c.Providers = append(c.Providers, other) })
|
||||
var out bytes.Buffer
|
||||
if !addOpencodeThroughWizard(t, s, "chat\nSHARED_RELAY_KEY\n\n\n", &out) {
|
||||
t.Fatalf("wizard refused the provider: %s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "SHARED_RELAY_KEY is already used by provider relay") {
|
||||
t.Fatalf("wizard did not say why the typed name was refused: %q", out.String())
|
||||
}
|
||||
if _, err := commitProviderSetupSession(s, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := savedKeyEnv(t, path, "custom-opencode-ai"); !strings.HasPrefix(got, "REASONIX_CONNECTION_") {
|
||||
t.Fatalf("api_key_env = %q, want a private slot after Enter", got)
|
||||
}
|
||||
if config.CredentialStored("SHARED_RELAY_KEY") {
|
||||
t.Fatal("the new provider's key was written into relay's variable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupRefusesATypedNameThatAlreadyHoldsAKey(t *testing.T) {
|
||||
s, path := newKeyEnvTestSession(t, nil)
|
||||
if _, err := config.SetCredential("ORPHAN_KEY", "sk-someone-else"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if !addOpencodeThroughWizard(t, s, "chat\nORPHAN_KEY\n\nOPENCODE_KEY\n", &out) {
|
||||
t.Fatalf("wizard refused the provider: %s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "ORPHAN_KEY already holds a saved credential") {
|
||||
t.Fatalf("wizard did not say why the typed name was refused: %q", out.String())
|
||||
}
|
||||
if _, err := commitProviderSetupSession(s, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := savedKeyEnv(t, path, "custom-opencode-ai"); got != "OPENCODE_KEY" {
|
||||
t.Fatalf("api_key_env = %q, want the second typed name", got)
|
||||
}
|
||||
if res := config.ResolveCredentialForRootGlobalFirst(".", "ORPHAN_KEY"); res.Value != "sk-someone-else" {
|
||||
t.Fatalf("existing credential became %q", res.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupSaveRefusesATypedNameClaimedAfterTheWizard(t *testing.T) {
|
||||
// A declared access list keeps the concurrent write from also changing which providers count as configured.
|
||||
s, path := newKeyEnvTestSession(t, func(c *config.Config) { c.Desktop.ProviderAccess = []string{"deepseek"} })
|
||||
var out bytes.Buffer
|
||||
if !addOpencodeThroughWizard(t, s, "chat\nCUSTOM_OPENCODE_AI_API_KEY\n\n", &out) {
|
||||
t.Fatalf("wizard refused the provider: %s", out.String())
|
||||
}
|
||||
if _, err := config.SetCredential("CUSTOM_OPENCODE_AI_API_KEY", "sk-written-meanwhile"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := commitProviderSetupSession(s, path)
|
||||
var inUse *config.CredentialKeyInUseError
|
||||
if !errors.As(err, &inUse) || inUse.Holder != config.CredentialKeyHeldByStore {
|
||||
t.Fatalf("commit error = %v, want CredentialKeyInUseError held by the store", err)
|
||||
}
|
||||
if res := config.ResolveCredentialForRootGlobalFirst(".", "CUSTOM_OPENCODE_AI_API_KEY"); res.Value != "sk-written-meanwhile" {
|
||||
t.Fatalf("commit overwrote the concurrent credential with %q", res.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupRefusesATypedNameABotSettingReads(t *testing.T) {
|
||||
s, path := newKeyEnvTestSession(t, nil)
|
||||
secret := s.cfg.Bot.QQ.AppSecretEnv
|
||||
var out bytes.Buffer
|
||||
if !addOpencodeThroughWizard(t, s, "chat\n"+secret+"\n\n\n", &out) {
|
||||
t.Fatalf("wizard refused the provider: %s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), secret+" is read by a bot or remote-host setting") {
|
||||
t.Fatalf("wizard did not say why %s was refused: %q", secret, out.String())
|
||||
}
|
||||
if _, err := commitProviderSetupSession(s, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.CredentialStored(secret) {
|
||||
t.Fatalf("the provider key was written into the bot secret %s", secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupRefusesATypedNameTheEnvironmentSets(t *testing.T) {
|
||||
for _, key := range []string{"PATH", "OPENAI_API_KEY"} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
s, path := newKeyEnvTestSession(t, nil)
|
||||
before := "shell-value"
|
||||
if key == "PATH" {
|
||||
before = os.Getenv("PATH")
|
||||
}
|
||||
t.Setenv(key, before)
|
||||
var out bytes.Buffer
|
||||
if !addOpencodeThroughWizard(t, s, "chat\n"+key+"\n\n\n", &out) {
|
||||
t.Fatalf("wizard refused the provider: %s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), key+" is already set in the environment") {
|
||||
t.Fatalf("wizard did not say why %s was refused: %q", key, out.String())
|
||||
}
|
||||
if _, err := commitProviderSetupSession(s, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := os.Getenv(key); got != before {
|
||||
t.Fatalf("%s changed to %q", key, got)
|
||||
}
|
||||
if config.CredentialStored(key) {
|
||||
t.Fatalf("%s was written to the credential store", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ type providerSetupSession struct {
|
||||
// Credentials are owned by selected connections, never by their previous
|
||||
// shared environment variable. The legacy map above is summary-only.
|
||||
pendingConnectionCredentials map[string]string
|
||||
typedKeyEnv map[string]string // provider -> api_key_env the user typed; its pending key is saved there
|
||||
removed map[string]bool
|
||||
accessDeclared bool
|
||||
projectScoped bool
|
||||
@@ -97,6 +98,7 @@ func newProviderSetupSession(cfg *config.Config) *providerSetupSession {
|
||||
originalDefault: cfg.DefaultModel,
|
||||
pendingCredentials: map[string]string{},
|
||||
pendingConnectionCredentials: map[string]string{},
|
||||
typedKeyEnv: map[string]string{},
|
||||
removed: map[string]bool{},
|
||||
}
|
||||
for _, p := range cfg.Providers {
|
||||
@@ -231,6 +233,7 @@ func (s *providerSetupSession) remove(name string) error {
|
||||
return err
|
||||
}
|
||||
s.recordProviderMutation(name, before, nil)
|
||||
delete(s.typedKeyEnv, name)
|
||||
s.removeProviderAccess(name)
|
||||
if _, existed := s.originalProviders[name]; existed {
|
||||
s.removed[name] = true
|
||||
@@ -578,31 +581,7 @@ func addProviderToSession(s *providerSetupSession, anthropic bool) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, entry := range result.entries {
|
||||
if !confirmSharedCredential(s.cfg, entry, "") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if err := s.add(result.entries); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return false
|
||||
}
|
||||
s.addProviderAccess(result.entries)
|
||||
for key, value := range result.credentials {
|
||||
var names []string
|
||||
for _, entry := range result.entries {
|
||||
if entry.APIKeyEnv == key {
|
||||
names = append(names, entry.Name)
|
||||
}
|
||||
}
|
||||
if err := s.setCredentialForProviders(names, key, value); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
// After the new keys are staged, so usability sees them.
|
||||
s.promoteDefaultToNewProviders(result.entries)
|
||||
return true
|
||||
return s.addPrompted(bufio.NewScanner(os.Stdin), os.Stdout, result)
|
||||
}
|
||||
|
||||
func manageProvider(s *providerSetupSession, providerIndex int) {
|
||||
@@ -698,9 +677,12 @@ func confirmSharedCredential(cfg *config.Config, candidate config.ProviderEntry,
|
||||
|
||||
func updateProviderKey(s *providerSetupSession, p config.ProviderEntry) {
|
||||
in := bufio.NewScanner(os.Stdin)
|
||||
keyEnvChanged := false
|
||||
keyEnvChanged, typed := false, false
|
||||
if p.APIKeyEnv == "" {
|
||||
p.APIKeyEnv = promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, apiKeyEnvFromProviderName(p.Name))
|
||||
draft := apiKeyEnvFromProviderName(p.Name)
|
||||
if p.APIKeyEnv, typed = promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, draft); typed {
|
||||
p.APIKeyEnv, typed = s.settleTypedKeyEnv(in, os.Stdout, i18n.M.CustomPromptKeyEnv, []string{p.Name}, p.APIKeyEnv, draft)
|
||||
}
|
||||
keyEnvChanged = true
|
||||
}
|
||||
if !confirmSharedCredential(s.cfg, p, p.Name) {
|
||||
@@ -718,6 +700,10 @@ func updateProviderKey(s *providerSetupSession, p config.ProviderEntry) {
|
||||
}
|
||||
if err := s.setCredentialForProviders([]string{p.Name}, p.APIKeyEnv, value); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return
|
||||
}
|
||||
if typed {
|
||||
s.typedKeyEnv[p.Name] = p.APIKeyEnv
|
||||
}
|
||||
}
|
||||
|
||||
@@ -928,20 +914,23 @@ func commitProviderSetupSession(s *providerSetupSession, configPath string) (boo
|
||||
return false, err
|
||||
}
|
||||
for name, value := range s.pendingConnectionCredentials {
|
||||
slot, err := fresh.StageModelCredentialLocked(value)
|
||||
entry, ok := fresh.Provider(name)
|
||||
if !ok {
|
||||
return false, &providerSetupConflictError{field: fmt.Sprintf("provider %q", name)}
|
||||
}
|
||||
var slot string
|
||||
if typed := s.typedKeyEnv[name]; typed != "" && typed == entry.APIKeyEnv {
|
||||
slot, err = fresh.StageNamedModelCredentialLocked(typed, name, value)
|
||||
} else {
|
||||
slot, err = fresh.StageModelCredentialLocked(value)
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
{
|
||||
entry, ok := fresh.Provider(name)
|
||||
if !ok {
|
||||
return false, &providerSetupConflictError{field: fmt.Sprintf("provider %q", name)}
|
||||
}
|
||||
updated := *entry
|
||||
updated.APIKeyEnv = slot
|
||||
if err := fresh.UpsertProvider(updated); err != nil {
|
||||
return false, err
|
||||
}
|
||||
updated := *entry
|
||||
updated.APIKeyEnv = slot
|
||||
if err := fresh.UpsertProvider(updated); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
current, err := readProviderSetupFileSnapshot(configPath)
|
||||
@@ -983,6 +972,8 @@ func saveProviderSetupSession(s *providerSetupSession, configPath, envPath strin
|
||||
var conflict *providerSetupConflictError
|
||||
if errors.As(err, &conflict) {
|
||||
fmt.Fprintf(os.Stderr, i18n.M.SetupConcurrentChangeFmt+"\n", conflict.field)
|
||||
} else if errors.Is(err, config.ErrCredentialKeyInUse) {
|
||||
fmt.Fprintln(os.Stderr, i18n.M.WriteConfigErr, keyEnvInUseText(err))
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, i18n.M.WriteConfigErr, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrCredentialKeyInUse marks a named credential variable that a connection
|
||||
// may not claim; CredentialKeyInUseError says who holds it.
|
||||
var ErrCredentialKeyInUse = errors.New("credential variable already in use")
|
||||
|
||||
// CredentialKeyHolder names why a variable is unavailable.
|
||||
type CredentialKeyHolder uint8
|
||||
|
||||
const (
|
||||
// CredentialKeyHeldByProvider: another provider entry references the variable.
|
||||
CredentialKeyHeldByProvider CredentialKeyHolder = iota + 1
|
||||
// CredentialKeyHeldBySetting: a bot or remote-host setting reads the variable.
|
||||
CredentialKeyHeldBySetting
|
||||
// CredentialKeyHeldByStore: the credential store already has a value or tombstone for it.
|
||||
CredentialKeyHeldByStore
|
||||
// CredentialKeyHeldByEnvironment: the process environment sets it from outside the store.
|
||||
CredentialKeyHeldByEnvironment
|
||||
)
|
||||
|
||||
type CredentialKeyInUseError struct {
|
||||
Key string
|
||||
Holder CredentialKeyHolder
|
||||
Provider string // set when Holder is CredentialKeyHeldByProvider
|
||||
}
|
||||
|
||||
func (e *CredentialKeyInUseError) Error() string {
|
||||
switch e.Holder {
|
||||
case CredentialKeyHeldByProvider:
|
||||
return fmt.Sprintf("%s is referenced by provider %q", e.Key, e.Provider)
|
||||
case CredentialKeyHeldBySetting:
|
||||
return fmt.Sprintf("%s is read by a bot or remote-host setting", e.Key)
|
||||
case CredentialKeyHeldByEnvironment:
|
||||
return fmt.Sprintf("%s is already set in the environment", e.Key)
|
||||
default:
|
||||
return fmt.Sprintf("%s already has a stored credential", e.Key)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *CredentialKeyInUseError) Unwrap() error { return ErrCredentialKeyInUse }
|
||||
|
||||
// sameCredentialName compares variable names the way the OS environment does.
|
||||
func sameCredentialName(a, b string) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.EqualFold(a, b)
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
func environmentHasName(key string) bool {
|
||||
for _, kv := range os.Environ() {
|
||||
name, _, _ := strings.Cut(kv, "=")
|
||||
if name != "" && sameCredentialName(name, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CredentialKeyClaimable reports whether provider may write its key under key
|
||||
// without changing what any other reader of that variable resolves. A stored
|
||||
// value or tombstone may belong to a config this one cannot see.
|
||||
func (c *Config) CredentialKeyClaimable(key, provider string) error {
|
||||
key = strings.TrimSpace(key)
|
||||
if !isCredentialKey(key) {
|
||||
return fmt.Errorf("invalid credential key %q", key)
|
||||
}
|
||||
for _, p := range c.Providers {
|
||||
if p.Name != provider && sameCredentialName(strings.TrimSpace(p.APIKeyEnv), key) {
|
||||
return &CredentialKeyInUseError{Key: key, Holder: CredentialKeyHeldByProvider, Provider: p.Name}
|
||||
}
|
||||
}
|
||||
others := *c
|
||||
others.Providers = nil
|
||||
for _, name := range credentialEnvNamesFromConfig(&others) {
|
||||
if sameCredentialName(name, key) {
|
||||
return &CredentialKeyInUseError{Key: key, Holder: CredentialKeyHeldBySetting}
|
||||
}
|
||||
}
|
||||
if credentialCurrentStoreHasKey(key) || credentialCurrentStoreClearedKey(key) {
|
||||
return &CredentialKeyInUseError{Key: key, Holder: CredentialKeyHeldByStore}
|
||||
}
|
||||
if environmentHasName(key) {
|
||||
return &CredentialKeyInUseError{Key: key, Holder: CredentialKeyHeldByEnvironment}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StageNamedModelCredentialLocked is StageModelCredentialLocked for a name the
|
||||
// user chose. It refuses a name CredentialKeyClaimable rejects, so the no-
|
||||
// overwrite and rollback guarantees of a fresh slot still hold.
|
||||
func (c *Config) StageNamedModelCredentialLocked(key, provider, value string) (string, error) {
|
||||
if err := c.CredentialKeyClaimable(key, provider); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return c.stageModelCredentialLocked(strings.TrimSpace(key), value)
|
||||
}
|
||||
|
||||
// configReferencesCredential reports whether the config at path reads key.
|
||||
// A config that cannot be parsed counts as referencing it: removal needs proof.
|
||||
func configReferencesCredential(path, key string) bool {
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
cfg, err := LoadForEditWithoutCredentialsReadOnlyStrict(path)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
for _, name := range credentialEnvNamesFromConfig(cfg) {
|
||||
if sameCredentialName(name, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stagedCredentialDigest(value string) (string, error) {
|
||||
return ModelSettingsRequestDigest([]byte(strings.TrimSpace(value)))
|
||||
}
|
||||
|
||||
// stagedValueUnchanged reports whether slot still holds what this edit staged.
|
||||
// No recorded digest means a journal from before digests existed.
|
||||
func stagedValueUnchanged(slot, digest string) bool {
|
||||
if digest == "" {
|
||||
return true
|
||||
}
|
||||
value, ok := envFileValue(UserCredentialsPath(), slot)
|
||||
if !ok {
|
||||
return !envFileHasClearedKey(UserCredentialsPath(), slot)
|
||||
}
|
||||
got, err := stagedCredentialDigest(value)
|
||||
return err == nil && got == digest
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func unsetForTest(t *testing.T, key string) {
|
||||
t.Helper()
|
||||
t.Setenv(key, "") // registers the restore; staging pins the key into the process
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
|
||||
func stageNamedAndStop(t *testing.T, path, key, value string) *Config {
|
||||
t.Helper()
|
||||
unsetForTest(t, key)
|
||||
unlockConfig := LockUserConfigEdits()
|
||||
defer unlockConfig()
|
||||
unlockCredentials, err := LockUserCredentialEdits()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer unlockCredentials()
|
||||
cfg, err := LoadForEditReadOnlyStrict(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.BeginModelCredentialCommitLocked(path, "cli-setup"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := cfg.StageNamedModelCredentialLocked(key, "p", value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func recoverLocked(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
unlockConfig := LockUserConfigEdits()
|
||||
defer unlockConfig()
|
||||
unlockCredentials, err := LockUserCredentialEdits()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer unlockCredentials()
|
||||
if err := RecoverModelCredentialCommitsLocked(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func journalCount(t *testing.T) int {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(modelCredentialTransactionDir())
|
||||
if os.IsNotExist(err) {
|
||||
return 0
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := 0
|
||||
for _, e := range entries {
|
||||
if filepath.Ext(e.Name()) == ".json" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestRecoveryKeepsANamedSlotAnotherWriterTookOver(t *testing.T) {
|
||||
isolateUserConfigHome(t)
|
||||
path := UserConfigPath()
|
||||
if err := Default().SaveTo(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stageNamedAndStop(t, path, "TEAM_KEY", "sk-mine")
|
||||
if _, err := SetCredential("TEAM_KEY", "sk-team-owned-elsewhere"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recoverLocked(t, path)
|
||||
if value, _ := envFileValue(UserCredentialsPath(), "TEAM_KEY"); value != "sk-team-owned-elsewhere" {
|
||||
t.Fatalf("recovery removed a value it did not stage: TEAM_KEY=%q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryStillRemovesAnUnpublishedNamedSlot(t *testing.T) {
|
||||
isolateUserConfigHome(t)
|
||||
path := UserConfigPath()
|
||||
if err := Default().SaveTo(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stageNamedAndStop(t, path, "TEAM_KEY", "sk-mine")
|
||||
recoverLocked(t, path)
|
||||
if CredentialStored("TEAM_KEY") || journalCount(t) != 0 {
|
||||
t.Fatalf("unpublished slot stored=%v journals=%d, want both gone", CredentialStored("TEAM_KEY"), journalCount(t))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupReadsReferencesStructurallyNotBySubstring(t *testing.T) {
|
||||
isolateUserConfigHome(t)
|
||||
path := UserConfigPath()
|
||||
cfg := Default()
|
||||
cfg.Providers = []ProviderEntry{{Name: "deep", Kind: "openai", BaseURL: "https://deep.invalid/v1", Model: "chat", APIKeyEnv: "DEEPSEEK_API_KEY"}}
|
||||
if err := cfg.SaveTo(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
staged := stageNamedAndStop(t, path, "API_KEY", "sk-short")
|
||||
func() {
|
||||
unlockConfig := LockUserConfigEdits()
|
||||
defer unlockConfig()
|
||||
unlockCredentials, err := LockUserCredentialEdits()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer unlockCredentials()
|
||||
staged.CleanupStagedModelCredentialsLocked(path)
|
||||
}()
|
||||
if CredentialStored("API_KEY") || journalCount(t) != 0 {
|
||||
t.Fatalf("API_KEY stored=%v journals=%d: a substring of DEEPSEEK_API_KEY kept the orphan", CredentialStored("API_KEY"), journalCount(t))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimableRefusesNamesOtherReadersHold(t *testing.T) {
|
||||
isolateUserConfigHome(t)
|
||||
t.Setenv("OPENAI_API_KEY", "sk-shell")
|
||||
cfg := Default()
|
||||
cfg.Remote.Hosts = append(cfg.Remote.Hosts, RemoteHostEntry{PasswordEnv: "BOX_PASSWORD"})
|
||||
cases := map[string]CredentialKeyHolder{
|
||||
cfg.Bot.QQ.AppSecretEnv: CredentialKeyHeldBySetting,
|
||||
"BOX_PASSWORD": CredentialKeyHeldBySetting,
|
||||
"PATH": CredentialKeyHeldByEnvironment,
|
||||
"OPENAI_API_KEY": CredentialKeyHeldByEnvironment,
|
||||
}
|
||||
for key, want := range cases {
|
||||
err := cfg.CredentialKeyClaimable(key, "p")
|
||||
var inUse *CredentialKeyInUseError
|
||||
if !errors.As(err, &inUse) || inUse.Holder != want {
|
||||
t.Errorf("CredentialKeyClaimable(%q) = %v, want holder %d", key, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
@@ -10,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -28,9 +28,11 @@ type modelCredentialCommitJournal struct {
|
||||
AfterRevision string `json:"afterRevision,omitempty"`
|
||||
ResultRevision string `json:"resultRevision,omitempty"`
|
||||
Slots []string `json:"slots"`
|
||||
Phase string `json:"phase"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
journalPath string
|
||||
// SlotDigests: slot -> keyed digest of the staged value; removal needs the store to still hold it.
|
||||
SlotDigests map[string]string `json:"slotDigests,omitempty"`
|
||||
Phase string `json:"phase"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
journalPath string
|
||||
}
|
||||
|
||||
// ModelSettingsReceipt is durable evidence that a request crossed the config
|
||||
@@ -229,16 +231,28 @@ func (c *Config) StageModelCredentialLocked(value string) (string, error) {
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := fmt.Sprintf("REASONIX_CONNECTION_%X_KEY", id)
|
||||
return c.stageModelCredentialLocked(fmt.Sprintf("REASONIX_CONNECTION_%X_KEY", id), value)
|
||||
}
|
||||
|
||||
func (c *Config) stageModelCredentialLocked(key, value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.ContainsAny(value, "\r\n") {
|
||||
return "", fmt.Errorf("credential value contains a newline")
|
||||
}
|
||||
if c.modelCredentialCommit != nil {
|
||||
c.modelCredentialCommit.Slots = append(c.modelCredentialCommit.Slots, key)
|
||||
c.modelCredentialCommit.Phase = "prepared"
|
||||
if err := writeModelCredentialJournal(c.modelCredentialCommit); err != nil {
|
||||
c.modelCredentialCommit.Slots = c.modelCredentialCommit.Slots[:len(c.modelCredentialCommit.Slots)-1]
|
||||
if j := c.modelCredentialCommit; j != nil {
|
||||
digest, err := stagedCredentialDigest(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if j.SlotDigests == nil {
|
||||
j.SlotDigests = map[string]string{}
|
||||
}
|
||||
j.Slots = append(j.Slots, key)
|
||||
j.SlotDigests[key] = digest
|
||||
j.Phase = "prepared"
|
||||
if err := writeModelCredentialJournal(j); err != nil {
|
||||
j.Slots = j.Slots[:len(j.Slots)-1]
|
||||
delete(j.SlotDigests, key)
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
@@ -312,15 +326,16 @@ func (c *Config) CleanupStagedModelCredentialsLocked(path string) {
|
||||
}
|
||||
return
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return
|
||||
}
|
||||
referenced := false
|
||||
for _, key := range c.stagedModelCredentials {
|
||||
// Check the entire document to retain references in unknown fields too.
|
||||
if bytes.Contains(raw, []byte(key)) {
|
||||
digest := ""
|
||||
if c.modelCredentialCommit != nil {
|
||||
digest = c.modelCredentialCommit.SlotDigests[key]
|
||||
}
|
||||
if configReferencesCredential(path, key) {
|
||||
referenced = true
|
||||
} else if !stagedValueUnchanged(key, digest) {
|
||||
continue // Another writer owns the slot now.
|
||||
} else {
|
||||
if err := removeCredentialFromFile(UserCredentialsPath(), key); err == nil {
|
||||
_ = os.Unsetenv(key)
|
||||
@@ -474,17 +489,10 @@ func RecoverModelCredentialCommitsLocked(configPath string) error {
|
||||
if json.Unmarshal(raw, &j) != nil || j.Schema != modelCredentialCommitSchema || filepath.Clean(j.ConfigPath) != configPath {
|
||||
continue
|
||||
}
|
||||
configRaw, configErr := os.ReadFile(configPath)
|
||||
if configErr != nil && !os.IsNotExist(configErr) {
|
||||
if _, statErr := os.Stat(configPath); statErr != nil && !os.IsNotExist(statErr) {
|
||||
continue
|
||||
}
|
||||
anyReferenced := false
|
||||
for _, slot := range j.Slots {
|
||||
if bytes.Contains(configRaw, []byte(slot)) {
|
||||
anyReferenced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
anyReferenced := slices.ContainsFunc(j.Slots, func(slot string) bool { return configReferencesCredential(configPath, slot) })
|
||||
committed := false
|
||||
if anyReferenced || j.AfterRevision != "" {
|
||||
var committedErr error
|
||||
@@ -516,11 +524,8 @@ func RecoverModelCredentialCommitsLocked(configPath string) error {
|
||||
if fileContentRevision(configPath) != j.BeforeRevision {
|
||||
continue
|
||||
}
|
||||
for _, slot := range j.Slots {
|
||||
if err := removeCredentialFromFile(UserCredentialsPath(), slot); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Unsetenv(slot)
|
||||
if err := removeUnpublishedSlots(&j); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(journalPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
@@ -528,3 +533,18 @@ func RecoverModelCredentialCommitsLocked(configPath string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeUnpublishedSlots skips a slot whose value is no longer the staged one:
|
||||
// another writer owns it now.
|
||||
func removeUnpublishedSlots(j *modelCredentialCommitJournal) error {
|
||||
for _, slot := range j.Slots {
|
||||
if !stagedValueUnchanged(slot, j.SlotDigests[slot]) {
|
||||
continue
|
||||
}
|
||||
if err := removeCredentialFromFile(UserCredentialsPath(), slot); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Unsetenv(slot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -10,15 +10,35 @@ import (
|
||||
"reasonix/internal/plugin"
|
||||
)
|
||||
|
||||
type invokedSkillsKey struct{}
|
||||
|
||||
func withInvokedSkills(ctx context.Context, names []string) context.Context {
|
||||
if len(names) == 0 {
|
||||
return ctx
|
||||
}
|
||||
invoked := make(map[string]bool, len(names))
|
||||
for _, name := range names {
|
||||
invoked[strings.ToLower(name)] = true
|
||||
}
|
||||
return context.WithValue(ctx, invokedSkillsKey{}, invoked)
|
||||
}
|
||||
|
||||
func invokedSkills(ctx context.Context) map[string]bool {
|
||||
invoked, _ := ctx.Value(invokedSkillsKey{}).(map[string]bool)
|
||||
return invoked
|
||||
}
|
||||
|
||||
func (c *Controller) withCapabilityRoute(ctx context.Context, composed, routeInput string) string {
|
||||
if c == nil {
|
||||
return composed
|
||||
}
|
||||
routeInput = strings.TrimSpace(agent.StripTransientUserBlocks(routeInput))
|
||||
if routeInput == "" {
|
||||
// A resolved inline invocation already supplies the skill body. With no
|
||||
// typed task, routing on that body would invent a second skill request.
|
||||
if routeInput == "" && len(invokedSkills(ctx)) == 0 {
|
||||
routeInput = strings.TrimSpace(agent.StripTransientUserBlocks(composed))
|
||||
}
|
||||
if routeInput == "" {
|
||||
if routeInput == "" && len(invokedSkills(ctx)) == 0 {
|
||||
return composed
|
||||
}
|
||||
decision := c.routeCapabilities(ctx, routeInput)
|
||||
@@ -93,6 +113,15 @@ func (c *Controller) routeCapabilities(ctx context.Context, routeInput string) c
|
||||
}
|
||||
}
|
||||
catalog := capability.BuildCatalog(opts)
|
||||
if invoked := invokedSkills(ctx); len(invoked) > 0 {
|
||||
entries := make([]capability.Entry, 0, len(catalog.Entries))
|
||||
for _, entry := range catalog.Entries {
|
||||
if entry.Kind != capability.KindSkill || !invoked[strings.ToLower(entry.Name)] {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
catalog.Entries = entries
|
||||
}
|
||||
decision := capability.Route(routeInput, catalog.Entries)
|
||||
if c.capabilityProxy {
|
||||
decision.CapabilityProxy = true
|
||||
|
||||
@@ -1375,8 +1375,9 @@ func (c *Controller) submitInvocationsLocked(input, display string, requests []I
|
||||
}
|
||||
|
||||
type preparedInvocationTurn struct {
|
||||
composed string
|
||||
subagents []skill.Skill
|
||||
composed string
|
||||
subagents []skill.Skill
|
||||
inlineSkillNames []string
|
||||
}
|
||||
|
||||
func (c *Controller) prepareInvocationTurn(input string, requests []InvocationRequest) (preparedInvocationTurn, error) {
|
||||
@@ -1403,20 +1404,25 @@ func (c *Controller) prepareInvocationTurn(input string, requests []InvocationRe
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(input) == "" && len(subagents) > 0 {
|
||||
return preparedInvocationTurn{}, fmt.Errorf("subagent invocation requires a task")
|
||||
}
|
||||
inlineSkillNames := make([]string, 0, len(inline))
|
||||
for _, sk := range inline {
|
||||
inlineSkillNames = append(inlineSkillNames, sk.Name)
|
||||
}
|
||||
// A lone inline skill takes the typed text as its arguments, as "/name task" does.
|
||||
if len(inline) == 1 && len(subagents) == 0 {
|
||||
return preparedInvocationTurn{composed: c.skills.renderInvocation(inline[0], strings.TrimSpace(input)), inlineSkillNames: inlineSkillNames}, nil
|
||||
}
|
||||
parts := make([]string, 0, len(inline)+1)
|
||||
for _, sk := range inline {
|
||||
parts = append(parts, c.skills.render(sk, ""))
|
||||
parts = append(parts, c.skills.renderInvocation(sk, ""))
|
||||
}
|
||||
if strings.TrimSpace(input) != "" {
|
||||
parts = append(parts, input)
|
||||
}
|
||||
composed := strings.Join(parts, "\n\n")
|
||||
if strings.TrimSpace(input) == "" {
|
||||
if len(subagents) > 0 {
|
||||
return preparedInvocationTurn{}, fmt.Errorf("subagent invocation requires a task")
|
||||
}
|
||||
}
|
||||
return preparedInvocationTurn{composed: composed, subagents: subagents}, nil
|
||||
return preparedInvocationTurn{composed: strings.Join(parts, "\n\n"), subagents: subagents, inlineSkillNames: inlineSkillNames}, nil
|
||||
}
|
||||
|
||||
func (c *Controller) runPreparedInvocationTurn(
|
||||
@@ -1425,6 +1431,7 @@ func (c *Controller) runPreparedInvocationTurn(
|
||||
input, raw, display string,
|
||||
frozenImages []string,
|
||||
) error {
|
||||
ctx = withInvokedSkills(ctx, prepared.inlineSkillNames)
|
||||
if len(prepared.subagents) == 0 {
|
||||
return c.runGoalLoopWithFrozenImagesRawDisplay(ctx, prepared.composed, raw, display, frozenImages)
|
||||
}
|
||||
@@ -1693,9 +1700,9 @@ func (c *Controller) submitCommandOrTurnReady(trimmed, input, display string, sc
|
||||
c.runSubagentSkillSlash(sk, task, trimmed, display, admission)
|
||||
return
|
||||
}
|
||||
sent := c.skills.render(sk, task)
|
||||
sent := c.skills.renderInvocation(sk, task)
|
||||
c.runGuardedWithAdmission(func(ctx context.Context) error {
|
||||
return runGoalLoop(ctx, sent, sent, display)
|
||||
return runGoalLoop(withInvokedSkills(ctx, []string{sk.Name}), sent, input, display)
|
||||
}, admission)
|
||||
return
|
||||
}
|
||||
@@ -4354,7 +4361,7 @@ func (c *Controller) ReloadCommands(ctx context.Context) error {
|
||||
entries = append(entries, command.SlashEntry{
|
||||
Name: sk.SlashName(),
|
||||
Description: sk.Description,
|
||||
Render: func(args []string) string { return c.skills.render(sk, strings.Join(args, " ")) },
|
||||
Render: func(args []string) string { return c.skills.renderInvocation(sk, strings.Join(args, " ")) },
|
||||
})
|
||||
}
|
||||
for _, cmd := range cmds {
|
||||
|
||||
@@ -497,7 +497,7 @@ func (c *Controller) RunSkill(input string) (sent string, found bool) {
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return c.skills.render(sk, task), true
|
||||
return c.skills.renderInvocation(sk, task), true
|
||||
}
|
||||
|
||||
// MCPPrompt resolves a "/mcp__server__prompt args…" line: it maps the positional
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/skill"
|
||||
)
|
||||
|
||||
func TestSubmitInvocationDisplayKeepsSharedTextAcrossSeveralInlineSkills(t *testing.T) {
|
||||
runner := &fakeTurnRunner{}
|
||||
c := newOwnedTestController(t, Options{
|
||||
Runner: runner,
|
||||
Skills: []skill.Skill{
|
||||
{Name: "one", Body: "ONE_BODY", RunAs: skill.RunInline, Scope: skill.ScopeGlobal},
|
||||
{Name: "two", Body: "TWO_BODY", RunAs: skill.RunInline, Scope: skill.ScopeGlobal},
|
||||
},
|
||||
})
|
||||
c.SubmitInvocationDisplay("/one /two shared task", "shared task", []InvocationRequest{
|
||||
{Name: "two", Kind: "skill", Offset: 5},
|
||||
{Name: "one", Kind: "skill", Offset: 0},
|
||||
})
|
||||
waitIdle(t, c)
|
||||
if len(runner.inputs) != 1 {
|
||||
t.Fatalf("inputs = %q", runner.inputs)
|
||||
}
|
||||
got := runner.inputs[0]
|
||||
one, two := strings.Index(got, "<skill-pin name=\"one\">"), strings.Index(got, "<skill-pin name=\"two\">")
|
||||
if one < 0 || two < one || strings.Contains(got, "Arguments:") || !strings.HasSuffix(got, "</skill-pin>\n\nshared task") {
|
||||
t.Fatalf("several inline skills should each be pinned with the text appended once:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -78,11 +78,8 @@ func (s *skillSet) prepare(sk skill.Skill) skill.Skill {
|
||||
return sk
|
||||
}
|
||||
|
||||
func (s *skillSet) render(sk skill.Skill, args string) string {
|
||||
if s.store != nil {
|
||||
return s.store.Render(sk, args)
|
||||
}
|
||||
return skill.Render(sk, args)
|
||||
func (s *skillSet) renderInvocation(sk skill.Skill, args string) string {
|
||||
return skill.RenderInvocation(s.prepare(sk), args)
|
||||
}
|
||||
|
||||
// writer returns the live store to use for authoring (create/delete), preferring
|
||||
|
||||
@@ -511,6 +511,11 @@ type Messages struct {
|
||||
SetupBack string
|
||||
SetupPromptModels string
|
||||
SetupSharedKeyWarningFmt string
|
||||
SetupKeyEnvTakenFmt string
|
||||
SetupKeyEnvStoredFmt string
|
||||
SetupKeyEnvSettingFmt string
|
||||
SetupKeyEnvShellFmt string
|
||||
SetupKeyEnvRetry string
|
||||
SetupPromptAPIKeyFmt string
|
||||
SetupSelectDefaultModel string
|
||||
SetupConfirmRemoveFmt string
|
||||
|
||||
@@ -474,6 +474,11 @@ var English = Messages{
|
||||
SetupBack: "Back",
|
||||
SetupPromptModels: "Models (comma-separated)",
|
||||
SetupSharedKeyWarningFmt: "%s is also used by %s at %s. Share this credential?",
|
||||
SetupKeyEnvTakenFmt: "%s is already used by provider %s; saving this key under it would replace that provider's key.",
|
||||
SetupKeyEnvStoredFmt: "%s already holds a saved credential; saving this key under it would overwrite that value.",
|
||||
SetupKeyEnvSettingFmt: "%s is read by a bot or remote-host setting; saving this key under it would hand that setting your key.",
|
||||
SetupKeyEnvShellFmt: "%s is already set in the environment Reasonix runs in; saving this key under it would override that value.",
|
||||
SetupKeyEnvRetry: "Enter another name, or press Enter to let Reasonix choose a private variable.",
|
||||
SetupPromptAPIKeyFmt: "Enter new value for %s",
|
||||
SetupSelectDefaultModel: "Select default model",
|
||||
SetupConfirmRemoveFmt: "Remove provider %s?",
|
||||
@@ -507,7 +512,7 @@ var English = Messages{
|
||||
CustomMethodURL: "Fetch models from URL",
|
||||
CustomPromptModel: "Enter model name",
|
||||
CustomPromptBaseURL: "Enter Base URL",
|
||||
CustomPromptKeyEnv: "API Key variable name (press Enter to use the default; not the model name)",
|
||||
CustomPromptKeyEnv: "API Key variable name (press Enter to let Reasonix choose; not the model name)",
|
||||
CustomPromptAPIKey: "Enter API Key",
|
||||
CustomPromptWindow: "Context window in tokens (a value below the model's real window makes compaction fire early)",
|
||||
CustomAddedFmt: "Added custom model: %s",
|
||||
@@ -519,7 +524,7 @@ var English = Messages{
|
||||
AnthropicMethodURL: "Fetch models from URL",
|
||||
AnthropicPromptModel: "Enter model name",
|
||||
AnthropicPromptBaseURL: "Enter Base URL",
|
||||
AnthropicPromptKeyEnv: "API Key variable name (press Enter to use the default; not the model name)",
|
||||
AnthropicPromptKeyEnv: "API Key variable name (press Enter to let Reasonix choose; not the model name)",
|
||||
AnthropicPromptAPIKey: "Enter API Key",
|
||||
AnthropicAddedFmt: "Added Anthropic compatible model: %s",
|
||||
AnthropicFetchingModelsFmt: "Fetching models for %s...",
|
||||
|
||||
@@ -475,6 +475,11 @@ var Chinese = Messages{
|
||||
SetupBack: "返回",
|
||||
SetupPromptModels: "模型列表(用逗号分隔)",
|
||||
SetupSharedKeyWarningFmt: "%s 已被 %s(%s)使用。确认共享这项凭据?",
|
||||
SetupKeyEnvTakenFmt: "%s 已被 provider %s 使用;把这个 key 存进去会替换那个 provider 的 key。",
|
||||
SetupKeyEnvStoredFmt: "%s 已经保存了一项凭据;把这个 key 存进去会覆盖原来的值。",
|
||||
SetupKeyEnvSettingFmt: "%s 已被机器人或远程主机设置读取;把这个 key 存进去会让那项设置拿到你的 key。",
|
||||
SetupKeyEnvShellFmt: "%s 已在 Reasonix 运行的环境变量中设置;把这个 key 存进去会覆盖那个值。",
|
||||
SetupKeyEnvRetry: "请换一个变量名,或直接回车由 Reasonix 分配独立变量。",
|
||||
SetupPromptAPIKeyFmt: "输入 %s 的新值",
|
||||
SetupSelectDefaultModel: "选择默认模型",
|
||||
SetupConfirmRemoveFmt: "确认删除供应商 %s?",
|
||||
@@ -508,7 +513,7 @@ var Chinese = Messages{
|
||||
CustomMethodURL: "从 URL 获取模型列表",
|
||||
CustomPromptModel: "请输入模型名称",
|
||||
CustomPromptBaseURL: "请输入 Base URL",
|
||||
CustomPromptKeyEnv: "API Key 变量名(直接回车使用默认值,不是模型名)",
|
||||
CustomPromptKeyEnv: "API Key 变量名(直接回车由 Reasonix 自动分配,不是模型名)",
|
||||
CustomPromptAPIKey: "请输入 API Key",
|
||||
CustomPromptWindow: "上下文窗口(tokens,填得比模型真实窗口小会导致过早压缩)",
|
||||
CustomAddedFmt: "已添加自定义模型: %s",
|
||||
@@ -520,7 +525,7 @@ var Chinese = Messages{
|
||||
AnthropicMethodURL: "从 URL 获取模型列表",
|
||||
AnthropicPromptModel: "请输入模型名称",
|
||||
AnthropicPromptBaseURL: "请输入 Base URL",
|
||||
AnthropicPromptKeyEnv: "API Key 变量名(直接回车使用默认值,不是模型名)",
|
||||
AnthropicPromptKeyEnv: "API Key 变量名(直接回车由 Reasonix 自动分配,不是模型名)",
|
||||
AnthropicPromptAPIKey: "请输入 API Key",
|
||||
AnthropicAddedFmt: "已添加 Anthropic 兼容模型: %s",
|
||||
AnthropicFetchingModelsFmt: "正在获取 %s 的模型列表...",
|
||||
|
||||
@@ -448,6 +448,11 @@ var ChineseTraditional = Messages{
|
||||
SetupBack: "返回",
|
||||
SetupPromptModels: "模型列表(用逗號分隔)",
|
||||
SetupSharedKeyWarningFmt: "%s 已被 %s(%s)使用。確認共用這項憑據?",
|
||||
SetupKeyEnvTakenFmt: "%s 已被 provider %s 使用;把這個 key 存進去會取代那個 provider 的 key。",
|
||||
SetupKeyEnvStoredFmt: "%s 已經儲存了一項憑據;把這個 key 存進去會覆寫原本的值。",
|
||||
SetupKeyEnvSettingFmt: "%s 已被機器人或遠端主機設定讀取;把這個 key 存進去會讓那項設定拿到你的 key。",
|
||||
SetupKeyEnvShellFmt: "%s 已在 Reasonix 執行的環境變數中設定;把這個 key 存進去會覆寫那個值。",
|
||||
SetupKeyEnvRetry: "請換一個變數名稱,或直接按 Enter 由 Reasonix 分配獨立變數。",
|
||||
SetupPromptAPIKeyFmt: "輸入 %s 的新值",
|
||||
SetupSelectDefaultModel: "選擇預設模型",
|
||||
SetupConfirmRemoveFmt: "確認刪除供應商 %s?",
|
||||
@@ -481,7 +486,7 @@ var ChineseTraditional = Messages{
|
||||
CustomMethodURL: "從 URL 取得模型列表",
|
||||
CustomPromptModel: "請輸入模型名稱",
|
||||
CustomPromptBaseURL: "請輸入 Base URL",
|
||||
CustomPromptKeyEnv: "API Key 變數名稱(直接按 Enter 使用預設值,不是模型名稱)",
|
||||
CustomPromptKeyEnv: "API Key 變數名稱(直接按 Enter 由 Reasonix 自動分配,不是模型名稱)",
|
||||
CustomPromptAPIKey: "請輸入 API Key",
|
||||
CustomPromptWindow: "上下文視窗(tokens,填得比模型真實視窗小會導致過早壓縮)",
|
||||
CustomAddedFmt: "已新增自訂模型: %s",
|
||||
@@ -493,7 +498,7 @@ var ChineseTraditional = Messages{
|
||||
AnthropicMethodURL: "從 URL 取得模型列表",
|
||||
AnthropicPromptModel: "請輸入模型名稱",
|
||||
AnthropicPromptBaseURL: "請輸入 Base URL",
|
||||
AnthropicPromptKeyEnv: "API Key 變數名稱(直接按 Enter 使用預設值,不是模型名稱)",
|
||||
AnthropicPromptKeyEnv: "API Key 變數名稱(直接按 Enter 由 Reasonix 自動分配,不是模型名稱)",
|
||||
AnthropicPromptAPIKey: "請輸入 API Key",
|
||||
AnthropicAddedFmt: "已新增 Anthropic 相容模型: %s",
|
||||
AnthropicFetchingModelsFmt: "正在取得 %s 的模型列表...",
|
||||
|
||||
@@ -68,11 +68,8 @@ func loadOrBuildSparseIndex(ctx context.Context, dir, cacheDir string) (sparseIn
|
||||
if err != nil {
|
||||
return sparseIndex{}, err
|
||||
}
|
||||
if data, readErr := os.ReadFile(sparseIndexPath(cacheDir)); readErr == nil {
|
||||
var cached sparseIndex
|
||||
if json.Unmarshal(data, &cached) == nil && cached.validFor(info, identity) {
|
||||
return cached, nil
|
||||
}
|
||||
if cached, ok := readValidSparseIndex(cacheDir, info, identity); ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
rebuilt := sparseIndex{
|
||||
@@ -110,6 +107,18 @@ func loadOrBuildSparseIndex(ctx context.Context, dir, cacheDir string) (sparseIn
|
||||
return rebuilt, nil
|
||||
}
|
||||
|
||||
func readValidSparseIndex(cacheDir string, info os.FileInfo, identity string) (sparseIndex, bool) {
|
||||
data, err := os.ReadFile(sparseIndexPath(cacheDir))
|
||||
if err != nil {
|
||||
return sparseIndex{}, false
|
||||
}
|
||||
var cached sparseIndex
|
||||
if json.Unmarshal(data, &cached) != nil || !cached.validFor(info, identity) {
|
||||
return sparseIndex{}, false
|
||||
}
|
||||
return cached, true
|
||||
}
|
||||
|
||||
func (idx sparseIndex) validFor(info os.FileInfo, identity string) bool {
|
||||
if idx.Codec != sparseIndexCodec || idx.LogSize != info.Size() || idx.LogModTimeNS != info.ModTime().UnixNano() || idx.LogIdentity != identity {
|
||||
return false
|
||||
@@ -188,6 +197,35 @@ func (s *Store) recordPersistedIndex(file *os.File, start int64, commits []Commi
|
||||
writeSparseIndex(s.dir, index)
|
||||
}
|
||||
|
||||
// A reopened writer knows the durable sequence but not the older checkpoints.
|
||||
// Adopt an already validated cache before appending, so the new commits can
|
||||
// extend it without scanning history or leaving the disk index stale.
|
||||
func (s *Store) adoptPersistedIndex(file *os.File, start int64) {
|
||||
s.indexMu.Lock()
|
||||
partial, lastSequence := s.index.partial, s.index.LastSequence
|
||||
s.indexMu.Unlock()
|
||||
if !partial {
|
||||
return
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil || info.Size() != start {
|
||||
return
|
||||
}
|
||||
identity, err := sparseLogIdentity(file, info)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cached, ok := readValidSparseIndex(s.dir, info, identity)
|
||||
if !ok || cached.LastSequence != lastSequence {
|
||||
return
|
||||
}
|
||||
s.indexMu.Lock()
|
||||
if s.index.partial && s.index.LastSequence == lastSequence {
|
||||
s.index = cached
|
||||
}
|
||||
s.indexMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Store) rebuildWriterIndex(_ *os.File) error {
|
||||
index, err := loadOrBuildSparseIndex(context.Background(), s.dir, s.dir)
|
||||
if err != nil {
|
||||
|
||||
@@ -80,3 +80,50 @@ func TestCorruptSparseIndexIsRebuiltFromValidatedLog(t *testing.T) {
|
||||
t.Fatalf("rebuilt=%+v err=%v", rebuilt, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReopenedWriterKeepsSparseIndexCurrentAcrossAppends(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "reopened")
|
||||
writer, err := Open(dir, "reopened")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendEvent := func(operation string) {
|
||||
t.Helper()
|
||||
if _, err := writer.Append(t.Context(), Batch{OperationID: operation, Events: []Event{{Kind: "diagnostic/test", Optional: true}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := writer.Flush(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
appendEvent("initial")
|
||||
if err := writer.Close(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer, err = Open(dir, "reopened")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer writer.Close(t.Context())
|
||||
for i := range 2 {
|
||||
appendEvent("after-reopen-" + strconv.Itoa(i))
|
||||
before, err := os.Stat(sparseIndexPath(dir))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := writer.Read(t.Context(), 0, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Commits) != i+2 {
|
||||
t.Fatalf("read %d: got %d commits", i, len(page.Commits))
|
||||
}
|
||||
after, err := os.Stat(sparseIndexPath(dir))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !os.SameFile(before, after) {
|
||||
t.Fatalf("read %d rebuilt the sparse index after append", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -772,9 +772,8 @@ func readStoredManifest(path string) (Manifest, error) {
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
// Append writes already-committed batches in order. Sequence allocation,
|
||||
// validation, and idempotency belong to Session; this method is only the
|
||||
// physical hand-off and reports uncertainty rather than guessing.
|
||||
// Append writes committed batches in order. Session owns sequence allocation,
|
||||
// validation, and idempotency; this method reports physical write uncertainty.
|
||||
func (s *Store) Append(ctx context.Context, commits []Commit) error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("session: nil store")
|
||||
@@ -851,6 +850,7 @@ func (s *Store) persist(ctx context.Context, file *os.File, commits []Commit) er
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.adoptPersistedIndex(file, start)
|
||||
if err := copyStagedAppend(ctx, staged, file, s.writeFn); err != nil {
|
||||
end, statErr := file.Seek(0, io.SeekEnd)
|
||||
if statErr == nil && end == start {
|
||||
|
||||
@@ -70,5 +70,5 @@ func (t *readSkillTool) Execute(_ context.Context, args json.RawMessage) (string
|
||||
}
|
||||
return renderEmbeddedReference(sk, reference)
|
||||
}
|
||||
return renderInline(sk, strings.TrimSpace(p.Arguments)), nil
|
||||
return RenderInvocation(sk, strings.TrimSpace(p.Arguments)), nil
|
||||
}
|
||||
@@ -337,9 +337,6 @@ func (s *Store) Prepare(sk Skill) Skill {
|
||||
return sk
|
||||
}
|
||||
|
||||
// Render prepares and renders a skill for a direct slash invocation.
|
||||
func (s *Store) Render(sk Skill, args string) string { return Render(s.Prepare(sk), args) }
|
||||
|
||||
func bindAllowedTools(refs []string, bindings []tool.MCPBinding) []string {
|
||||
if len(refs) == 0 {
|
||||
return refs
|
||||
|
||||
+9
-11
@@ -185,7 +185,7 @@ func (t *runSkillTool) Execute(ctx context.Context, args json.RawMessage) (strin
|
||||
if opts.ContinueFrom != "" || opts.ForkFrom != "" {
|
||||
return "", fmt.Errorf("run_skill: subagent continuation is only valid for runAs=subagent skills")
|
||||
}
|
||||
return renderInline(sk, rawArgs), nil
|
||||
return RenderInvocation(sk, rawArgs), nil
|
||||
}
|
||||
|
||||
func (t *runSkillTool) ResolveProfile(args json.RawMessage) *event.Profile {
|
||||
@@ -334,7 +334,7 @@ func (t *readOnlySkillTool) Execute(ctx context.Context, args json.RawMessage) (
|
||||
}
|
||||
return tool.GuardSubagentHostDecisionText(out), nil
|
||||
}
|
||||
return renderInline(sk, rawArgs), nil
|
||||
return RenderInvocation(sk, rawArgs), nil
|
||||
}
|
||||
|
||||
func (t *readOnlySkillTool) ResolveProfile(args json.RawMessage) *event.Profile {
|
||||
@@ -681,11 +681,9 @@ func RenderSkillFile(opts SkillFileOptions) string {
|
||||
|
||||
// shared helpers
|
||||
|
||||
// Render builds a skill's invocation text: a header (name, description, source)
|
||||
// followed by the body and any arguments. Used directly when a user invokes a
|
||||
// skill via "/<name>" (sent as a turn); the run_skill tool wraps the same text
|
||||
// in a skill-pin sentinel (see renderInline).
|
||||
func Render(sk Skill, args string) string {
|
||||
// render builds a skill's text: a header (name, description, source) followed
|
||||
// by the body and any arguments.
|
||||
func render(sk Skill, args string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Skill: " + sk.Name)
|
||||
if sk.Description != "" {
|
||||
@@ -699,10 +697,10 @@ func Render(sk Skill, args string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderInline wraps Render's output in a skill-pin sentinel so context
|
||||
// compaction preserves the body verbatim instead of paraphrasing it.
|
||||
func renderInline(sk Skill, args string) string {
|
||||
return "<skill-pin name=" + strconv.Quote(sk.Name) + ">\n" + Render(sk, args) + "\n</skill-pin>"
|
||||
// RenderInvocation is the single form every skill invocation reaches the model
|
||||
// in, whether the user or the model invoked it.
|
||||
func RenderInvocation(sk Skill, args string) string {
|
||||
return "<skill-pin name=" + strconv.Quote(sk.Name) + ">\n" + render(sk, args) + "\n</skill-pin>"
|
||||
}
|
||||
|
||||
var bracketTagRe = regexp.MustCompile(`\[[^\]]*\]`)
|
||||
|
||||
@@ -57,7 +57,7 @@ func (b bash) startBackground(ctx context.Context, p bashParams, sh sandbox.Shel
|
||||
runErr = normalizeBashRunError(jobCtx, runErr, p.PreserveBackgroundProcesses)
|
||||
execution, classifiedErr := classifyBackgroundShellExecution(jobCtx, sh, argv, capture.String(), runErr, started)
|
||||
if classifiedErr != nil && execution.FailurePhase == tool.ShellPhaseExecution && wrapped {
|
||||
writeBackgroundSandboxHint(out, capture.String(), classifiedErr, p, jobSpec, permissionPreset)
|
||||
writeBackgroundSandboxHint(out, capture.String(), classifiedErr, p, jobSpec, permissionPreset, b.workDir)
|
||||
}
|
||||
jobs.SetExecution(jobCtx, execution)
|
||||
return "", classifiedErr
|
||||
@@ -80,8 +80,8 @@ func (b bash) startBackground(ctx context.Context, p bashParams, sh sandbox.Shel
|
||||
}, nil
|
||||
}
|
||||
|
||||
func writeBackgroundSandboxHint(out io.Writer, captured string, runErr error, p bashParams, spec sandbox.Spec, permissionPreset string) {
|
||||
hinted := appendSandboxWriteHint(captured, runErr, p, spec, permissionPreset)
|
||||
func writeBackgroundSandboxHint(out io.Writer, captured string, runErr error, p bashParams, spec sandbox.Spec, permissionPreset, workDir string) {
|
||||
hinted := appendSandboxWriteHint(captured, runErr, p, spec, permissionPreset, workDir)
|
||||
if hint := strings.TrimPrefix(hinted, captured); hint != "" {
|
||||
_, _ = io.WriteString(out, hint+"\n")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -97,7 +99,7 @@ func bashLaunchFailure(ex *tool.ShellExecution, start time.Time, err error) (too
|
||||
func (b bash) appendWriteHints(ctx context.Context, out string, err error, p bashParams, wrapped bool) string {
|
||||
out = appendSessionDataHint(out, b.guard.CommandHint(b.workDir, p.Command))
|
||||
if wrapped {
|
||||
out = appendSandboxWriteHint(out, err, p, b.specForCall(ctx), string(sandbox.PermissionPresetFrom(ctx)))
|
||||
out = appendSandboxWriteHint(out, err, p, b.specForCall(ctx), string(sandbox.PermissionPresetFrom(ctx)), b.workDir)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -176,12 +178,17 @@ func looksLikeSandboxWriteDenial(out string, err error) bool {
|
||||
var localFilePermissionDenied = regexp.MustCompile(`(?m)^(?:bash|zsh|sh|dash|fish|mkdir|touch|cp|mv|rm|ln|install|tee|cat|chmod|chown):[^\n]*permission denied\b`)
|
||||
var windowsChildProcessDenied = regexp.MustCompile(`\b(?:spawn(?:sync)?|exec(?:file|sync)?)\s+eperm\b`)
|
||||
|
||||
func appendSandboxWriteHint(out string, err error, p bashParams, spec sandbox.Spec, preset string) string {
|
||||
func appendSandboxWriteHint(out string, err error, p bashParams, spec sandbox.Spec, preset, workDir string) string {
|
||||
if !spec.Enforce() || strings.TrimSpace(preset) == string(permissionpreset.DangerFullAccess) || !looksLikeSandboxWriteDenial(out, err) {
|
||||
return out
|
||||
}
|
||||
hint := bashWriteDeniedHint()
|
||||
if len(p.AdditionalWriteDirs) > 0 || windowsChildProcessDenied.MatchString(strings.ToLower(out+"\n"+err.Error())) {
|
||||
if windowsChildProcessDenied.MatchString(strings.ToLower(out + "\n" + err.Error())) {
|
||||
hint = "The command encountered a permission denial under the OS sandbox. Additional writable directories may not resolve a child-process or named-object denial."
|
||||
} else if dirs := gitWorktreeWriteDirs(workDir, out, spec.WriteRoots); len(dirs) > 0 {
|
||||
paths, _ := json.Marshal(dirs)
|
||||
hint = "Git worktree metadata is outside the writable workspace. Retry this command with additional_write_dirs: " + string(paths) + " and a justification; the host will request approval for these directories."
|
||||
} else if len(p.AdditionalWriteDirs) > 0 {
|
||||
hint = "The command encountered a permission denial under the OS sandbox. Additional writable directories may not resolve a child-process or named-object denial."
|
||||
}
|
||||
if denialID := sandbox.IssueDenial(p.Command, preset); denialID != "" {
|
||||
@@ -189,3 +196,122 @@ func appendSandboxWriteHint(out string, err error, p bashParams, spec sandbox.Sp
|
||||
}
|
||||
return appendSessionDataHint(out, hint)
|
||||
}
|
||||
|
||||
func gitWorktreeWriteDirs(workDir, output string, writeRoots []string) []string {
|
||||
gitDir, commonDir := linkedGitMetadataDirs(workDir)
|
||||
if gitDir == "" {
|
||||
return nil
|
||||
}
|
||||
match := gitWriteDeniedPath.FindStringSubmatch(output)
|
||||
if len(match) != 2 || !filepath.IsAbs(match[1]) {
|
||||
return nil
|
||||
}
|
||||
denied, err := sandbox.ResolveAbsPath(match[1])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
objects := filepath.Join(commonDir, "objects")
|
||||
refs := filepath.Join(commonDir, "refs")
|
||||
logs := filepath.Join(commonDir, "logs")
|
||||
if !sandbox.PathWithin(gitDir, denied) && !sandbox.PathWithin(objects, denied) && !sandbox.PathWithin(refs, denied) && !sandbox.PathWithin(logs, denied) {
|
||||
return nil
|
||||
}
|
||||
paths := []string{gitDir, objects}
|
||||
if sandbox.PathWithin(refs, denied) {
|
||||
paths = append(paths, refs)
|
||||
}
|
||||
if sandbox.PathWithin(logs, denied) {
|
||||
paths = append(paths, logs)
|
||||
}
|
||||
var missing []string
|
||||
for _, path := range paths {
|
||||
covered := false
|
||||
for _, root := range writeRoots {
|
||||
if sandbox.PathWithin(root, path) {
|
||||
covered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !covered {
|
||||
missing = append(missing, path)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
var gitWriteDeniedPath = regexp.MustCompile(`(?im)['"]([^'"\n]+)['"]:[ \t]*(?:operation not permitted|read-only file system|permission denied)`)
|
||||
|
||||
func linkedGitMetadataDirs(workDir string) (string, string) {
|
||||
for dir := filepath.Clean(workDir); filepath.IsAbs(dir); dir = filepath.Dir(dir) {
|
||||
gitPath := filepath.Join(dir, ".git")
|
||||
pointer, err := readSmallGitFile(gitPath)
|
||||
if err == nil {
|
||||
if !strings.HasPrefix(pointer, "gitdir: ") {
|
||||
return "", ""
|
||||
}
|
||||
gitDir := strings.TrimSpace(strings.TrimPrefix(pointer, "gitdir: "))
|
||||
if !filepath.IsAbs(gitDir) {
|
||||
gitDir = filepath.Join(dir, gitDir)
|
||||
}
|
||||
gitDir, err = filepath.EvalSymlinks(gitDir)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
common, err := readSmallGitFile(filepath.Join(gitDir, "commondir"))
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
commonDir := common
|
||||
if !filepath.IsAbs(commonDir) {
|
||||
commonDir = filepath.Join(gitDir, commonDir)
|
||||
}
|
||||
commonDir, err = filepath.EvalSymlinks(commonDir)
|
||||
if err != nil || filepath.Dir(gitDir) != filepath.Join(commonDir, "worktrees") {
|
||||
return "", ""
|
||||
}
|
||||
backlink, err := readSmallGitFile(filepath.Join(gitDir, "gitdir"))
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
if !filepath.IsAbs(backlink) {
|
||||
backlink = filepath.Join(gitDir, backlink)
|
||||
}
|
||||
backlink, err = sandbox.ResolveAbsPath(backlink)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
actualGitPath, err := sandbox.ResolveAbsPath(gitPath)
|
||||
if err != nil || backlink != actualGitPath {
|
||||
return "", ""
|
||||
}
|
||||
for _, path := range []string{"objects", "refs"} {
|
||||
info, err := os.Lstat(filepath.Join(commonDir, path))
|
||||
if err != nil || !info.IsDir() {
|
||||
return "", ""
|
||||
}
|
||||
}
|
||||
if info, err := os.Lstat(filepath.Join(commonDir, "HEAD")); err != nil || !info.Mode().IsRegular() {
|
||||
return "", ""
|
||||
}
|
||||
return gitDir, commonDir
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", ""
|
||||
}
|
||||
if filepath.Dir(dir) == dir {
|
||||
break
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func readSmallGitFile(path string) (string, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() > 4096 {
|
||||
return "", fmt.Errorf("not a small regular Git metadata file: %s", path)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
return strings.TrimSpace(string(data)), err
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
package builtin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/sandbox"
|
||||
)
|
||||
|
||||
func TestSandboxWriteDenialClassifierRejectsOrdinaryFailures(t *testing.T) {
|
||||
@@ -37,3 +46,120 @@ func TestSandboxWriteDenialClassifierAcceptsFilesystemFailures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxWriteHintNamesGitWorktreeMetadata(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git is unavailable")
|
||||
}
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
main := filepath.Join(root, "main")
|
||||
worktree := filepath.Join(root, "linked")
|
||||
if err := os.Mkdir(main, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, args := range [][]string{{"init", main}, {"-C", main, "-c", "user.name=Test", "-c", "[email protected]", "commit", "--allow-empty", "-m", "initial"}, {"-C", main, "worktree", "add", "-b", "linked", worktree}} {
|
||||
if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
}
|
||||
subdir := filepath.Join(worktree, "subdir")
|
||||
if err := os.Mkdir(subdir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gitDir := filepath.Join(main, ".git", "worktrees", "linked")
|
||||
objects := filepath.Join(main, ".git", "objects")
|
||||
denial := "fatal: Unable to create '" + filepath.Join(gitDir, "index.lock") + "': Operation not permitted"
|
||||
hint := appendSandboxWriteHint(denial, errors.New("exit status 128"), bashParams{Command: "git add ."}, sandbox.Spec{Mode: "enforce", WriteRoots: []string{worktree}}, "", subdir)
|
||||
wantDirs, err := json.Marshal([]string{gitDir, objects})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(hint, string(wantDirs)) || !strings.Contains(hint, "additional_write_dirs") {
|
||||
t.Fatalf("missing actionable worktree metadata hint: %s", hint)
|
||||
}
|
||||
if got := gitWorktreeWriteDirs(subdir, "touch: /outside: Operation not permitted", []string{worktree}); len(got) != 0 {
|
||||
t.Fatalf("unrelated denial must not suggest Git metadata: %v", got)
|
||||
}
|
||||
if got := gitWorktreeWriteDirs(subdir, "note: "+gitDir+"\ntouch: '/outside/file': Operation not permitted", []string{worktree}); len(got) != 0 {
|
||||
t.Fatalf("metadata mention outside the denied path must not trigger a hint: %v", got)
|
||||
}
|
||||
if got := gitWorktreeWriteDirs(subdir, "note: '"+filepath.Join(gitDir, "index.lock")+"':\nOperation not permitted", []string{worktree}); len(got) != 0 {
|
||||
t.Fatalf("denial must share the diagnostic line with the metadata path: %v", got)
|
||||
}
|
||||
alias := filepath.Join(root, "metadata-alias")
|
||||
if err := os.Symlink(gitDir, alias); err == nil {
|
||||
aliasDenial := "fatal: Unable to create '" + filepath.Join(alias, "index.lock") + "': Operation not permitted"
|
||||
if got := gitWorktreeWriteDirs(subdir, aliasDenial, []string{worktree}); len(got) != 2 || got[0] != gitDir || got[1] != objects {
|
||||
t.Fatalf("symlinked diagnostic path must resolve to Git metadata: %v", got)
|
||||
}
|
||||
}
|
||||
if got := gitWorktreeWriteDirs(subdir, denial, []string{worktree, filepath.Join(main, ".git")}); len(got) != 0 {
|
||||
t.Fatalf("already writable metadata must not be suggested: %v", got)
|
||||
}
|
||||
commonDenial := "fatal: cannot lock ref 'refs/heads/linked': Unable to create '" + filepath.Join(main, ".git", "refs", "heads", "linked.lock") + "': Operation not permitted"
|
||||
if got := gitWorktreeWriteDirs(subdir, commonDenial, []string{worktree, gitDir}); len(got) != 2 || got[0] != objects || got[1] != filepath.Join(main, ".git", "refs") {
|
||||
t.Fatalf("ref denial must suggest only needed metadata subdirectories: %v", got)
|
||||
}
|
||||
commonPath := filepath.Join(gitDir, "commondir")
|
||||
originalCommon, err := os.ReadFile(commonPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
private := filepath.Join(root, "private")
|
||||
for _, path := range []string{private, filepath.Join(private, "objects"), filepath.Join(private, "refs")} {
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(private, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(commonPath, []byte(private+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forgedDenial := "fatal: Unable to create '" + filepath.Join(private, "refs", "heads", "main.lock") + "': Operation not permitted"
|
||||
if got := gitWorktreeWriteDirs(subdir, forgedDenial, []string{worktree}); len(got) != 0 {
|
||||
t.Fatalf("forged commondir must not be suggested: %v", got)
|
||||
}
|
||||
if err := os.WriteFile(commonPath, originalCommon, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backlinkPath := filepath.Join(gitDir, "gitdir")
|
||||
originalBacklink, err := os.ReadFile(backlinkPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(backlinkPath, []byte(filepath.Join(private, ".git")+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := gitWorktreeWriteDirs(subdir, denial, []string{worktree}); len(got) != 0 {
|
||||
t.Fatalf("invalid worktree backlink must not be suggested: %v", got)
|
||||
}
|
||||
if err := os.WriteFile(backlinkPath, originalBacklink, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtime.GOOS != "darwin" {
|
||||
return
|
||||
}
|
||||
if _, err := exec.LookPath("sandbox-exec"); err != nil {
|
||||
t.Skip("macOS Seatbelt is unavailable")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(worktree, "note.txt"), []byte("change\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := fmt.Sprintf("(version 1) (allow default) (deny file-write*) (allow file-write* (literal \"/dev/null\") (subpath %q))", worktree)
|
||||
blocked, err := exec.Command("sandbox-exec", "-p", profile, "git", "-C", worktree, "add", "note.txt").CombinedOutput()
|
||||
if err == nil || !strings.Contains(string(blocked), "index.lock") {
|
||||
t.Fatalf("git add must fail at external worktree metadata: %v: %s", err, blocked)
|
||||
}
|
||||
if hint := appendSandboxWriteHint(string(blocked), err, bashParams{Command: "git add note.txt"}, sandbox.Spec{Mode: "enforce", WriteRoots: []string{worktree}}, "", worktree); !strings.Contains(hint, gitDir) || !strings.Contains(hint, objects) {
|
||||
t.Fatalf("actual Seatbelt denial did not name the needed directory: %s", hint)
|
||||
}
|
||||
profile = fmt.Sprintf("(version 1) (allow default) (deny file-write*) (allow file-write* (literal \"/dev/null\") (subpath %q) (subpath %q) (subpath %q))", worktree, gitDir, objects)
|
||||
if out, err := exec.Command("sandbox-exec", "-p", profile, "git", "-C", worktree, "add", "note.txt").CombinedOutput(); err != nil {
|
||||
t.Fatalf("approved metadata must permit git add: %v: %s", err, out)
|
||||
}
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func (p *Projection) Apply(envelope turnevent.Envelope) error {
|
||||
if envelope.Sequence != p.covered+1 {
|
||||
return errors.New("transcript projection sequence gap")
|
||||
}
|
||||
return p.applyLocked(envelope, envelope.Sequence)
|
||||
return p.applyLocked(envelope, envelope.Sequence, envelope.Sequence)
|
||||
}
|
||||
|
||||
// ApplyFrame uses an independent display revision. The caller supplies the
|
||||
@@ -186,14 +186,16 @@ func (p *Projection) ApplyFrame(envelope turnevent.Envelope, covered uint64) err
|
||||
if covered < p.covered {
|
||||
return errors.New("transcript business coverage regression")
|
||||
}
|
||||
if err := p.applyLocked(envelope, covered); err != nil {
|
||||
if err := p.applyLocked(envelope, covered, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
p.trimSettledLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Projection) applyLocked(envelope turnevent.Envelope, covered uint64) error {
|
||||
// first is the business sequence the envelope itself occupies, or zero for a
|
||||
// display frame that rides an existing cut.
|
||||
func (p *Projection) applyLocked(envelope turnevent.Envelope, covered, first uint64) error {
|
||||
// Detach pointer payloads before retaining them. Event publication cannot
|
||||
// mutate a previously committed snapshot through an aliased tool slice.
|
||||
encoded, err := json.Marshal(envelope)
|
||||
@@ -225,8 +227,14 @@ func (p *Projection) applyLocked(envelope turnevent.Envelope, covered uint64) er
|
||||
p.revision++
|
||||
// Legacy ledger numbering is never a chat coverage cursor.
|
||||
w.Sequence = 0
|
||||
if first != 0 && owned.Kind == "message" && w.MessageID != "" {
|
||||
if p.results == nil {
|
||||
p.results = make(map[string]uint64)
|
||||
}
|
||||
p.results[w.MessageID] = first
|
||||
}
|
||||
state := p.runtime
|
||||
change := Change{Event: &w, Runtime: &state}
|
||||
change := Change{Event: &w, Runtime: &state, FirstSeq: first}
|
||||
if owned.Kind == "text" || owned.Kind == "reasoning" || owned.Kind == "tool_call_delta" || (owned.Kind == "tool_dispatch" && w.Tool != nil && w.Tool.Partial) {
|
||||
if attempt, ok := p.attempts[w.AttemptID]; ok {
|
||||
change.AttemptID, change.Index = attempt.ID, attempt.NextIndex
|
||||
@@ -236,6 +244,14 @@ func (p *Projection) applyLocked(envelope turnevent.Envelope, covered uint64) er
|
||||
}
|
||||
if owned.Kind == "stream_attempt" && w.StreamAttempt != nil && w.StreamAttempt.Action == "commit" {
|
||||
change.AttemptID, change.ResultSeq = w.StreamAttempt.ID, p.results[w.MessageID]
|
||||
if first != 0 {
|
||||
// A tool-only or interrupted round publishes no message event; its
|
||||
// commit follows the durable write, so its own sequence is committed.
|
||||
if change.ResultSeq == 0 {
|
||||
change.ResultSeq = first
|
||||
}
|
||||
delete(p.results, w.MessageID)
|
||||
}
|
||||
change.ResultKind = "message/complete"
|
||||
if w.StreamAttempt.Reason == "interrupted" {
|
||||
change.ResultKind = "message/interrupted"
|
||||
|
||||
@@ -1,6 +1,509 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "1.39.2",
|
||||
"date": "2026-09-27",
|
||||
"channel": "stable",
|
||||
"targetingVersion": 1,
|
||||
"title": {
|
||||
"en": "Reasonix 1.39.2",
|
||||
"zh": "Reasonix 1.39.2"
|
||||
},
|
||||
"summary": {
|
||||
"en": "Fixes for desktop and CLI: sessions that could not be opened or archived, live streaming after switching sessions, typed skill invocations, the setup API key name, and readable output on Chinese Windows.",
|
||||
"zh": "桌面端与 CLI 修复:打不开或归档不掉的会话、切换会话后的实时流式显示、手动调用技能、设置向导的 API Key 变量名,以及中文 Windows 上的输出乱码。"
|
||||
},
|
||||
"surfaces": [
|
||||
"desktop",
|
||||
"cli"
|
||||
],
|
||||
"guides": [],
|
||||
"highlights": [
|
||||
{
|
||||
"kind": "fixed",
|
||||
"targets": [
|
||||
"desktop"
|
||||
],
|
||||
"title": {
|
||||
"en": "Sessions that could not be opened or trashed work again",
|
||||
"zh": "打不开、也删不掉的会话恢复正常"
|
||||
},
|
||||
"body": {
|
||||
"en": "A session whose history recorded the same message twice failed to open and could not be moved to the Trash, showing only a generic error. It now opens (the first copy of the message wins; the file on disk is not rewritten), a damaged session can always be moved to the Trash with a clear message, and new writes can no longer record a duplicate.",
|
||||
"zh": "历史里同一条消息被记录两次的会话会打不开,也无法移到废纸篓,只提示一个笼统的错误。现在这类会话可以正常打开(以第一条为准,磁盘上的文件不会被改写);文件损坏的会话也总能移到废纸篓,并给出明确提示;新的写入也不会再产生重复消息。"
|
||||
},
|
||||
"refs": [
|
||||
10893
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "fixed",
|
||||
"targets": [
|
||||
"desktop"
|
||||
],
|
||||
"title": {
|
||||
"en": "Old sessions you archive leave the sidebar",
|
||||
"zh": "归档的旧会话会从侧栏消失"
|
||||
},
|
||||
"body": {
|
||||
"en": "Archiving a recovered 1.38 conversation now archives every recovered copy at once instead of the next copy taking its place, and a legacy row listed under the wrong workspace (branch-badged rows, or rows titled with a raw file name) no longer stays stuck in the sidebar refusing pin, rename and open. Everything archived stays restorable from the archive list.",
|
||||
"zh": "归档 1.38 留下的「已恢复」旧会话时,会一次归档它的所有恢复副本,不会再归档一条又冒出下一条;被列在错误工作区下的旧会话(带分支标记的、或以原始文件名为标题的)也不会再卡在侧栏、既删不掉又不能置顶/重命名/打开。归档的内容都可以在归档列表里恢复。"
|
||||
},
|
||||
"refs": [
|
||||
10883,
|
||||
11022
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "fixed",
|
||||
"targets": [
|
||||
"desktop"
|
||||
],
|
||||
"title": {
|
||||
"en": "Replies stream live again after switching sessions",
|
||||
"zh": "切换会话后回复重新实时流式显示"
|
||||
},
|
||||
"body": {
|
||||
"en": "A session opened from an older topic path, for example by switching to it in the sidebar, showed no thinking or answer until the turn ended. Output now streams as it arrives, as it does in the CLI.",
|
||||
"zh": "从旧话题路径打开的会话(例如在侧栏切换过去)在整轮结束前看不到任何思考或回答。现在会像 CLI 一样边生成边显示。"
|
||||
},
|
||||
"refs": [
|
||||
10973
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "fixed",
|
||||
"targets": [
|
||||
"desktop"
|
||||
],
|
||||
"title": {
|
||||
"en": "Earlier history loads after the cache is cleared",
|
||||
"zh": "缓存清理后仍能加载更早的历史"
|
||||
},
|
||||
"body": {
|
||||
"en": "Scrolling up for older messages in a long session no longer fails, with retry stuck, after the session cache was evicted and the session had changed since the tab opened.",
|
||||
"zh": "长会话里向上翻加载更早消息时,如果会话缓存已被清理且会话在打开后有过变化,不会再加载失败、重试也无效。"
|
||||
},
|
||||
"refs": [
|
||||
10997
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "fixed",
|
||||
"targets": [
|
||||
"desktop",
|
||||
"cli"
|
||||
],
|
||||
"title": {
|
||||
"en": "Typed skill invocations reach the model the same way every time",
|
||||
"zh": "手动调用技能时,模型收到的内容与工具调用一致"
|
||||
},
|
||||
"body": {
|
||||
"en": "A message that is just /name (or a lone skill chip in the desktop composer) now sends the skill exactly as run_skill does, with your arguments labelled. Routing reads what you typed rather than the skill's body, so a skill that mentions another one no longer makes the model load both, and wording such as \"use X skill\" in the middle of a sentence no longer forces a skill.",
|
||||
"zh": "只包含 /技能名 的消息(或桌面端输入框里单独的技能标签)现在与 run_skill 以同一种形式发给模型,你输入的参数也会单独标出。技能路由按你实际输入的内容判断,而不是技能正文:一个技能正文里提到另一个技能时,不会再让模型把两个都加载;句子中间出现「用 X 技能」之类的说法也不会再强制调用技能。"
|
||||
},
|
||||
"refs": [
|
||||
11007,
|
||||
11023
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "fixed",
|
||||
"targets": [
|
||||
"cli"
|
||||
],
|
||||
"title": {
|
||||
"en": "Setup keeps the API key variable name you type",
|
||||
"zh": "reasonix setup 保留你输入的 API Key 变量名"
|
||||
},
|
||||
"body": {
|
||||
"en": "The setup wizard asked for an API key variable name but always saved the key under a generated REASONIX_CONNECTION_…_KEY name. The name you type is now used. If another provider, setting or your environment already uses that variable, setup says what holds it and asks again; pressing Enter still lets Reasonix choose a private name.",
|
||||
"zh": "设置向导会询问 API Key 变量名,但此前总是把密钥存到自动生成的 REASONIX_CONNECTION_…_KEY 里。现在会使用你输入的名字;如果该变量已被其他提供方、配置项或环境占用,向导会说明被谁占用并请你重新输入;直接回车仍由 Reasonix 自动选择一个私有名字。"
|
||||
},
|
||||
"refs": [
|
||||
11005
|
||||
]
|
||||
}
|
||||
],
|
||||
"changes": {
|
||||
"new": [],
|
||||
"improved": [
|
||||
{
|
||||
"targets": [
|
||||
"desktop"
|
||||
],
|
||||
"title": {
|
||||
"en": "The context panel's runtime keeps counting during long tool runs",
|
||||
"zh": "上下文面板的运行时间在长时间工具执行中持续计时"
|
||||
},
|
||||
"body": {
|
||||
"en": "Runtime in the context panel and the context ring no longer freezes between model requests, so long sessions show their real running time.",
|
||||
"zh": "上下文面板和上下文环里的运行时间不再在两次模型请求之间停住,长会话显示的是真实运行时长。"
|
||||
},
|
||||
"refs": [
|
||||
10925
|
||||
]
|
||||
},
|
||||
{
|
||||
"targets": [
|
||||
"desktop"
|
||||
],
|
||||
"title": {
|
||||
"en": "History search finds text in tool output",
|
||||
"zh": "历史搜索能找到工具输出里的内容"
|
||||
},
|
||||
"body": {
|
||||
"en": "Content search in the history panel now matches tool results, such as a failed MCP server listing, and reads each session once instead of once per hit.",
|
||||
"zh": "历史面板的内容搜索现在也会匹配工具结果(例如 MCP 服务器列表失败的输出),并且每个会话只读取一次。"
|
||||
},
|
||||
"refs": [
|
||||
10938
|
||||
]
|
||||
},
|
||||
{
|
||||
"targets": [
|
||||
"desktop"
|
||||
],
|
||||
"title": {
|
||||
"en": "The sidebar says it is reading projects on a slow start",
|
||||
"zh": "启动较慢时侧栏会提示正在读取项目"
|
||||
},
|
||||
"body": {
|
||||
"en": "Instead of briefly showing \"No projects yet\" before the project list arrives, the sidebar shows a loading state.",
|
||||
"zh": "项目列表返回之前,侧栏显示加载状态,而不是先闪一下「还没有项目」。"
|
||||
},
|
||||
"refs": [
|
||||
10979
|
||||
]
|
||||
},
|
||||
{
|
||||
"targets": [
|
||||
"desktop"
|
||||
],
|
||||
"title": {
|
||||
"en": "Symlinks inside the workspace appear in the file panel",
|
||||
"zh": "工作区内的符号链接会显示在文件面板中"
|
||||
},
|
||||
"body": {
|
||||
"en": "The file panel and the @ menu list a link whose target stays inside the workspace; links that dangle or point outside it are left out and cannot be previewed.",
|
||||
"zh": "文件面板和 @ 菜单会列出目标仍在工作区内的符号链接;悬空或指向工作区外的链接不显示,也无法预览。"
|
||||
},
|
||||
"refs": [
|
||||
10888
|
||||
]
|
||||
},
|
||||
{
|
||||
"targets": [
|
||||
"desktop",
|
||||
"cli"
|
||||
],
|
||||
"title": {
|
||||
"en": "Reopened sessions stay fast to read",
|
||||
"zh": "重新打开的会话读取保持快速"
|
||||
},
|
||||
"body": {
|
||||
"en": "After a session is reopened, new messages keep its on-disk index current, so later reads no longer rebuild it from the full log.",
|
||||
"zh": "会话重新打开后,新消息会同步更新磁盘上的索引,之后读取不再从完整日志重建。"
|
||||
},
|
||||
"refs": [
|
||||
10977
|
||||
]
|
||||
},
|
||||
{
|
||||
"targets": [
|
||||
"desktop",
|
||||
"cli"
|
||||
],
|
||||
"title": {
|
||||
"en": "Sandbox denials in a Git worktree say which folders to allow",
|
||||
"zh": "Git worktree 中的沙箱拒绝会提示该放行哪些目录"
|
||||
},
|
||||
"body": {
|
||||
"en": "When git add fails in a linked worktree because the sandbox blocks the main repository's Git metadata, bash now suggests the narrow folders to approve, never the whole .git directory.",
|
||||
"zh": "在关联的 Git worktree 中,git add 因沙箱拦截主仓库的 Git 元数据而失败时,bash 会提示需要放行的最小目录,绝不会建议放行整个 .git 目录。"
|
||||
},
|
||||
"refs": [
|
||||
11003
|
||||
]
|
||||
},
|
||||
{
|
||||
"targets": [
|
||||
"desktop",
|
||||
"cli"
|
||||
],
|
||||
"title": {
|
||||
"en": "The environment note tells the tool shell from your login shell",
|
||||
"zh": "环境说明区分工具 shell 与你的登录 shell"
|
||||
},
|
||||
"body": {
|
||||
"en": "When your login shell is fish or zsh, the model is told that its bash tool still runs bash, so shell advice matches where commands actually run.",
|
||||
"zh": "当你的登录 shell 是 fish 或 zsh 时,模型会知道它的 bash 工具仍然运行在 bash 中,给出的 shell 建议与实际执行环境一致。"
|
||||
},
|
||||
"refs": [
|
||||
10902
|
||||
]
|
||||
}
|
||||
],
|
||||
"fixed": [
|
||||
{
|
||||
"targets": [
|
||||
"desktop"
|
||||
],
|
||||
"title": {
|
||||
"en": "A remote Ask card shows once and can be answered",
|
||||
"zh": "远程 Ask 卡片只显示一次且可以提交"
|
||||
},
|
||||
"body": {
|
||||
"en": "In an SSH remote workspace, a question from the agent appeared twice and neither card could be submitted. It now appears once and submits.",
|
||||
"zh": "在 SSH 远程工作区里,智能体的提问会出现两张卡片且都无法提交。现在只显示一张,并能正常提交。"
|
||||
},
|
||||
"refs": [
|
||||
10914
|
||||
]
|
||||
},
|
||||
{
|
||||
"targets": [
|
||||
"desktop"
|
||||
],
|
||||
"title": {
|
||||
"en": "SSH projects stay out of the composer's local folder menu",
|
||||
"zh": "SSH 项目不再出现在输入框上方的本地文件夹菜单里"
|
||||
},
|
||||
"body": {
|
||||
"en": "Pinned SSH projects were listed as local folders and failed when picked. They remain reachable from the sidebar's remote group.",
|
||||
"zh": "已固定的 SSH 项目曾被当作本地文件夹列出,选中就报错。现在它们只在侧栏的远程分组中出现。"
|
||||
},
|
||||
"refs": [
|
||||
10955
|
||||
]
|
||||
},
|
||||
{
|
||||
"targets": [
|
||||
"desktop"
|
||||
],
|
||||
"title": {
|
||||
"en": "A waiting approval or question notifies once",
|
||||
"zh": "等待中的审批或提问只通知一次"
|
||||
},
|
||||
"body": {
|
||||
"en": "System notifications and bot-watched chats no longer repeat the same approval or question each time the desktop re-syncs a tab.",
|
||||
"zh": "桌面端每次重新同步标签页时,系统通知和机器人监听的聊天不会再重复推送同一个审批或提问。"
|
||||
},
|
||||
"refs": [
|
||||
10934,
|
||||
10942
|
||||
]
|
||||
},
|
||||
{
|
||||
"targets": [
|
||||
"desktop"
|
||||
],
|
||||
"title": {
|
||||
"en": "Writes to the session's own temp folder no longer ask for scope",
|
||||
"zh": "写入会话自己的临时目录不再请求扩大范围"
|
||||
},
|
||||
"body": {
|
||||
"en": "write_file to the app-owned session temp directory stops prompting on every call; other directories still go through the normal approval.",
|
||||
"zh": "write_file 写入应用自有的会话临时目录时不再每次都弹出扩大写入范围的请求;其他目录仍走正常审批。"
|
||||
},
|
||||
"refs": [
|
||||
10890
|
||||
]
|
||||
},
|
||||
{
|
||||
"targets": [
|
||||
"desktop"
|
||||
],
|
||||
"title": {
|
||||
"en": "A single ~ is no longer strikethrough",
|
||||
"zh": "单个 ~ 不再被渲染成删除线"
|
||||
},
|
||||
"body": {
|
||||
"en": "Ranges such as 500~1000 render as written; ~~text~~ still strikes through.",
|
||||
"zh": "像 500~1000 这样的范围按原样显示;~~文字~~ 仍是删除线。"
|
||||
},
|
||||
"refs": [
|
||||
10886
|
||||
]
|
||||
},
|
||||
{
|
||||
"targets": [
|
||||
"desktop",
|
||||
"cli"
|
||||
],
|
||||
"title": {
|
||||
"en": "File edits keep untouched bytes in UTF-8 files with CJK text",
|
||||
"zh": "编辑含中日韩文字的 UTF-8 文件不再改动未编辑的字节"
|
||||
},
|
||||
"body": {
|
||||
"en": "edit_file and related tools could rewrite characters far from the edit when a file had a stray invalid byte. A file is now treated as GB18030 or GBK only when that reading round-trips exactly.",
|
||||
"zh": "文件中有个别无效字节时,edit_file 等工具可能改写远离编辑处的字符。现在只有能原样往返时才把文件当作 GB18030 或 GBK 处理。"
|
||||
},
|
||||
"refs": [
|
||||
10892
|
||||
]
|
||||
},
|
||||
{
|
||||
"targets": [
|
||||
"desktop",
|
||||
"cli"
|
||||
],
|
||||
"title": {
|
||||
"en": "Command and MCP output on Chinese Windows is readable",
|
||||
"zh": "中文 Windows 上的命令输出和 MCP 报错可以正常阅读"
|
||||
},
|
||||
"body": {
|
||||
"en": "Output from bash tool commands and MCP server startup errors written in the console code page is decoded instead of turning into mojibake, and output cut in the middle of a UTF-8 character no longer reads as garbled text.",
|
||||
"zh": "bash 工具命令的输出和 MCP 服务器启动报错按控制台代码页正确解码,不再是乱码;在 UTF-8 字符中间被截断的输出也不会再整体显示成乱码。"
|
||||
},
|
||||
"refs": [
|
||||
10918,
|
||||
10912,
|
||||
10948
|
||||
]
|
||||
},
|
||||
{
|
||||
"targets": [
|
||||
"desktop",
|
||||
"cli"
|
||||
],
|
||||
"title": {
|
||||
"en": "Tool calls through Responses relays run",
|
||||
"zh": "经 Responses 中转的工具调用能正常执行"
|
||||
},
|
||||
"body": {
|
||||
"en": "When a relay drops the per-call done events, the tool calls listed in the completed response are now run instead of the turn ending early.",
|
||||
"zh": "中转服务丢掉单个工具调用的完成事件时,现在会按完成响应里列出的调用继续执行,而不是提前结束这一轮。"
|
||||
},
|
||||
"refs": [
|
||||
10924
|
||||
]
|
||||
},
|
||||
{
|
||||
"targets": [
|
||||
"desktop",
|
||||
"cli"
|
||||
],
|
||||
"title": {
|
||||
"en": "StepFun presets know their context window",
|
||||
"zh": "StepFun 预设带上上下文窗口"
|
||||
},
|
||||
"body": {
|
||||
"en": "StepFun models now show the context gauge and compact automatically at their 262144-token limit; existing StepFun entries without a window are updated.",
|
||||
"zh": "StepFun 模型现在会显示上下文用量,并按 262144 token 的上限自动压缩;已安装但没有设置窗口的 StepFun 配置会自动补上。"
|
||||
},
|
||||
"refs": [
|
||||
10930
|
||||
]
|
||||
},
|
||||
{
|
||||
"targets": [
|
||||
"desktop",
|
||||
"cli"
|
||||
],
|
||||
"title": {
|
||||
"en": "Subagents stay on the provider you picked",
|
||||
"zh": "子代理留在你选择的提供方上"
|
||||
},
|
||||
"body": {
|
||||
"en": "A subagent model named without a provider now runs on the parent session's provider when it serves that model, instead of the first provider that lists it.",
|
||||
"zh": "只写模型名、不带提供方的子代理,现在会在父会话的提供方支持该模型时留在同一提供方,而不是跑到第一个列出它的提供方。"
|
||||
},
|
||||
"refs": [
|
||||
10929
|
||||
]
|
||||
},
|
||||
{
|
||||
"targets": [
|
||||
"desktop",
|
||||
"cli"
|
||||
],
|
||||
"title": {
|
||||
"en": "OpenCode Go minimax-m2.7 uses the route it is served on",
|
||||
"zh": "OpenCode Go 的 minimax-m2.7 走正确的接口"
|
||||
},
|
||||
"body": {
|
||||
"en": "minimax-m2.7 is offered only on the Anthropic route, where OpenCode Go serves it.",
|
||||
"zh": "minimax-m2.7 只在 OpenCode Go 实际提供它的 Anthropic 接口上出现。"
|
||||
},
|
||||
"refs": [
|
||||
10961
|
||||
]
|
||||
},
|
||||
{
|
||||
"targets": [
|
||||
"desktop",
|
||||
"cli"
|
||||
],
|
||||
"title": {
|
||||
"en": "web_fetch works with fake-ip proxies",
|
||||
"zh": "web_fetch 在 fake-ip 代理下可用"
|
||||
},
|
||||
"body": {
|
||||
"en": "With Clash or mihomo in fake-ip mode, web_fetch no longer refuses every public site; it connects to an allowed address and still refuses hosts that resolve only to internal ones.",
|
||||
"zh": "在 Clash 或 mihomo 的 fake-ip 模式下,web_fetch 不再拒绝所有公网站点;它会连接允许的地址,只解析到内网地址的主机仍会被拒绝。"
|
||||
},
|
||||
"refs": [
|
||||
10909
|
||||
]
|
||||
},
|
||||
{
|
||||
"targets": [
|
||||
"desktop",
|
||||
"cli"
|
||||
],
|
||||
"title": {
|
||||
"en": "Projects under a cloud-drive folder load their config",
|
||||
"zh": "云盘目录下的项目能正常加载配置"
|
||||
},
|
||||
"body": {
|
||||
"en": "On Windows, a workspace inside a Box Drive or similar mount no longer fails every config load with a path error.",
|
||||
"zh": "在 Windows 上,位于 Box Drive 等云盘挂载目录中的工作区不再每次加载配置都报路径错误。"
|
||||
},
|
||||
"refs": [
|
||||
10904
|
||||
]
|
||||
},
|
||||
{
|
||||
"targets": [
|
||||
"cli"
|
||||
],
|
||||
"title": {
|
||||
"en": "Quoted text wraps within the terminal",
|
||||
"zh": "引用文字在终端宽度内换行"
|
||||
},
|
||||
"body": {
|
||||
"en": "Long blockquotes in the CLI chat, especially CJK text, no longer overflow the terminal and leave a stray rail on the next row.",
|
||||
"zh": "CLI 对话里的长引用(尤其是中文)不再超出终端宽度、在下一行留下多余的竖线。"
|
||||
},
|
||||
"refs": [
|
||||
10877
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"upgrade": [],
|
||||
"risks": [],
|
||||
"contributors": [
|
||||
"esengine",
|
||||
"KHG420"
|
||||
],
|
||||
"links": {
|
||||
"github": "https://github.com/esengine/DeepSeek-Reasonix/releases/tag/desktop-v1.39.2",
|
||||
"compare": "https://github.com/esengine/DeepSeek-Reasonix/compare/desktop-v1.39.1...desktop-v1.39.2",
|
||||
"download": "https://reasonix.io/?download=desktop&channel=stable#start"
|
||||
},
|
||||
"releaseId": "1.39.2",
|
||||
"baseVersion": "1.39.2",
|
||||
"status": "reviewed",
|
||||
"previousRelease": "1.39.1",
|
||||
"builds": {
|
||||
"cli": "v1.39.2",
|
||||
"desktop": "v1.39.2",
|
||||
"npm": "1.39.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "1.39.1",
|
||||
"date": "2026-09-26",
|
||||
|
||||
@@ -14,7 +14,7 @@ const FRONTEND = /^desktop\/frontend\//;
|
||||
const DESKTOP_MANIFEST = /^(?:desktop\/(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|\.npmrc)|desktop\/frontend\/(?:package\.json|pnpm-lock\.yaml|vite\.config\.[cm]?[jt]s|tsconfig[^/]*\.json))$/;
|
||||
const ELECTRON = /^(?:desktop\/electron\/|desktop\/(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)$)/;
|
||||
const PACKAGING = /^(?:desktop\/(?:packaging\/|build\/|internal\/upgradefixture\/|cmd\/windows-upgrade-fixture\/)|scripts\/(?:desktop-build|package-windows-desktop|install-nsis|check-windows-uninstaller|finalize-windows-signed-candidate|test-windows-(?:installer-startup|startup-recovery|upgrade-startup)|windows-(?:upgrade-ui-evidence|acceptance-environment))\b)/;
|
||||
const RELEASE_CONTROL = /^(?:\.github\/workflows\/(?:release[^/]*|prepare-release-notes|pages)\.yml|scripts\/(?:release|resolve-release-candidate|validate-release-candidate|build-release-cli-candidate|publish-homebrew-cask|desktop-release-artifacts|finalize-windows-signed-candidate|verify-release-artifact-archive|verify-release-tag-identity|verify-stable-release-artifacts)[^/]*|npm\/publish(?:-candidate)?(?:\.test)?\.mjs)$/;
|
||||
const RELEASE_CONTROL = /^(?:\.github\/workflows\/release[^/]*\.yml|scripts\/(?:release|resolve-release-candidate|validate-release-candidate|build-release-cli-candidate|publish-homebrew-cask|desktop-release-artifacts|finalize-windows-signed-candidate|verify-release-artifact-archive|verify-release-tag-identity|verify-stable-release-artifacts)[^/]*|npm\/publish(?:-candidate)?(?:\.test)?\.mjs)$/;
|
||||
const DESKTOP_GO = /^(?:desktop\/(?:[^/]+\.go|go\.(?:mod|sum)|cmd\/|internal\/)|internal\/|cmd\/|go\.(?:mod|sum)$)/;
|
||||
const SDK = /^(?:sdk\/|internal\/extension\/)/;
|
||||
const WINDOWS_BUILTIN = /^(?:internal\/(?:tool\/builtin\/|tool\/tool\.go$|sandbox\/|permission\/|permissionpreset\/)|scripts\/windows-pr-contract-tests(?:\.test)?\.mjs$)/;
|
||||
@@ -60,10 +60,6 @@ export function classifyPaths(input, { full = false } = {}) {
|
||||
if (releaseControl) {
|
||||
flags.release_control = true;
|
||||
setReason(reasons, "release_control", path, "release control plane");
|
||||
if (path === ".github/workflows/pages.yml") {
|
||||
flags.site = true;
|
||||
setReason(reasons, "site", path, "site deployment control");
|
||||
}
|
||||
}
|
||||
if (SDK.test(path)) {
|
||||
flags.sdk = true;
|
||||
|
||||
@@ -202,7 +202,6 @@ test("release control changes run focused contracts without selecting product su
|
||||
".github/workflows/release-candidate.yml",
|
||||
".github/workflows/release-candidate-verify.yml",
|
||||
".github/workflows/release-promote.yml",
|
||||
".github/workflows/pages.yml",
|
||||
"scripts/release-candidate.mjs",
|
||||
"scripts/sync-release-site.sh",
|
||||
"scripts/sync-release-site.test.mjs",
|
||||
|
||||
@@ -93,7 +93,7 @@ test("release tag mutation follows approval with an explicit identity and read-o
|
||||
test("cancelled CI releases workers, aggregates, and metrics without hiding live failures", () => {
|
||||
for (const name of ["test", "windows-control", "windows-isolated", "race", "sdk", "desktop-prepare",
|
||||
"desktop-frontend", "desktop-browser-group", "desktop-go", "desktop-go-race", "desktop-macos",
|
||||
"desktop-windows", "desktop-windows-go-group", "desktop-windows-package", "lint-code", "site", "coverage", "prune-go-cache"]) {
|
||||
"desktop-windows", "desktop-windows-go-group", "desktop-windows-package", "lint-code", "coverage", "prune-go-cache"]) {
|
||||
assert.equal(condition(job(ci, name), { cancelled: () => true }), false, name);
|
||||
}
|
||||
for (const name of ["root", "lint", "desktop", "desktop-browser", "desktop-windows-go", "ci-metrics"]) {
|
||||
@@ -321,19 +321,19 @@ test("required lint aggregates code lint and the deduplicated frontend suite", (
|
||||
test("required root aggregate covers the jobs the per-OS test legs do not", () => {
|
||||
const body = job(ci, "root");
|
||||
const script = shellStep(body, "Verify root validation jobs");
|
||||
const success = { CHANGES_RESULT: "success", CODE_REQUIRED: "true", SITE_REQUIRED: "true", COVERAGE_REQUIRED: "true",
|
||||
CONTROL_RESULT: "success", ISOLATED_RESULT: "success", SDK_RESULT: "success", SITE_RESULT: "success", COVERAGE_RESULT: "success" };
|
||||
const success = { CHANGES_RESULT: "success", CODE_REQUIRED: "true", COVERAGE_REQUIRED: "true",
|
||||
CONTROL_RESULT: "success", ISOLATED_RESULT: "success", SDK_RESULT: "success", COVERAGE_RESULT: "success" };
|
||||
const run = env => spawnSync("bash", ["-e", "-c", script], { env: { ...process.env, ...env } }).status;
|
||||
assert.equal(run(success), 0);
|
||||
for (const key of ["CHANGES_RESULT", "CONTROL_RESULT", "ISOLATED_RESULT", "SDK_RESULT", "SITE_RESULT", "COVERAGE_RESULT"])
|
||||
for (const key of ["CHANGES_RESULT", "CONTROL_RESULT", "ISOLATED_RESULT", "SDK_RESULT", "COVERAGE_RESULT"])
|
||||
for (const value of ["failure", "cancelled", "skipped", ""]) assert.notEqual(run({ ...success, [key]: value }), 0, `${key}=${value}`);
|
||||
// A pull request unrelated to code or site: the internally-gated jobs still
|
||||
// A pull request unrelated to code: the internally-gated jobs still
|
||||
// report success, the skippable ones must actually be skipped.
|
||||
assert.equal(run({ ...success, CODE_REQUIRED: "false", SITE_REQUIRED: "false", COVERAGE_REQUIRED: "false",
|
||||
ISOLATED_RESULT: "skipped", SITE_RESULT: "skipped", COVERAGE_RESULT: "skipped" }), 0);
|
||||
assert.equal(run({ ...success, CODE_REQUIRED: "false", COVERAGE_REQUIRED: "false",
|
||||
ISOLATED_RESULT: "skipped", COVERAGE_RESULT: "skipped" }), 0);
|
||||
// Coverage is push-only; a pull request that ran it is a routing defect.
|
||||
assert.notEqual(run({ ...success, COVERAGE_REQUIRED: "false", COVERAGE_RESULT: "success" }), 0);
|
||||
assert.match(body, /needs: \[changes, windows-control, windows-isolated, sdk, site, coverage\]/);
|
||||
assert.match(body, /needs: \[changes, windows-control, windows-isolated, sdk, coverage\]/);
|
||||
// govulncheck sets continue-on-error, so needs.*.result is success even when
|
||||
// it fails; aggregating it would be a tautology that reads like coverage.
|
||||
assert.match(job(ci, "govulncheck"), /continue-on-error: true/);
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname } from "node:path";
|
||||
import { loadCatalog, upsertRelease, validateCatalog } from "./release-notes.mjs";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const apiBase = process.env.DEEPSEEK_API_BASE || "https://api.deepseek.com";
|
||||
const model = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro";
|
||||
const releaseTargetOrder = ["desktop", "cli", "site", "service"];
|
||||
|
||||
export const releaseOutputBudgets = Object.freeze({
|
||||
standard: Object.freeze({ highlights: 6, changes: 15, guides: 6, upgrade: 4, risks: 4, bodyChars: 280 }),
|
||||
compact: Object.freeze({ highlights: 4, changes: 9, guides: 4, upgrade: 2, risks: 2, bodyChars: 220 }),
|
||||
});
|
||||
|
||||
export function editorialLimitInstruction(compact = false) {
|
||||
const budget = compact ? releaseOutputBudgets.compact : releaseOutputBudgets.standard;
|
||||
return `Editorial budget: at most ${budget.highlights} highlights, ${budget.changes} total change items across new/improved/fixed, ${budget.guides} guides, ${budget.upgrade} upgrade notes, and ${budget.risks} risks. Keep every English and Chinese body within ${budget.bodyChars} characters. Select the most important user outcomes across the supplied PRs, combine related work, and avoid repeating the same outcome in highlights and changes. Always return a complete JSON object within this budget.`;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const values = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (!arg.startsWith("--")) throw new Error(`unexpected argument ${arg}`);
|
||||
values[arg.slice(2)] = argv[++index];
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function runGit(args) {
|
||||
return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function normalizeVersion(version) {
|
||||
return String(version || "").replace(/^(?:desktop-|npm-)?v/, "");
|
||||
}
|
||||
|
||||
function repositoryName() {
|
||||
if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY;
|
||||
const remote = runGit(["remote", "get-url", "origin"]);
|
||||
const match = remote.match(/github\.com[/:]([^/]+\/[^/.]+)(?:\.git)?$/);
|
||||
if (!match) throw new Error("cannot determine GitHub repository; set GITHUB_REPOSITORY");
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function commitRange(from, to) {
|
||||
return runGit(["log", "--first-parent", "--format=%H%x09%s%x09%b%x00", `${from}..${to}`])
|
||||
.split("\0")
|
||||
.map((record) => record.trim())
|
||||
.filter(Boolean)
|
||||
.map((record) => {
|
||||
const [sha, subject, ...body] = record.split("\t");
|
||||
return { sha, subject, body: body.join("\t").trim() };
|
||||
});
|
||||
}
|
||||
|
||||
function prNumbersFromCommits(commits) {
|
||||
const refs = new Set();
|
||||
for (const commit of commits) {
|
||||
for (const match of `${commit.subject}\n${commit.body}`.matchAll(/#(\d+)/g)) refs.add(Number(match[1]));
|
||||
}
|
||||
return [...refs];
|
||||
}
|
||||
|
||||
async function githubJson(path, { allowMissing = false } = {}) {
|
||||
const headers = { Accept: "application/vnd.github+json", "User-Agent": "reasonix-release-notes" };
|
||||
if (process.env.GITHUB_TOKEN || process.env.GH_TOKEN) {
|
||||
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN || process.env.GH_TOKEN}`;
|
||||
}
|
||||
const response = await fetch(`https://api.github.com${path}`, { headers, signal: AbortSignal.timeout(30_000) });
|
||||
if (allowMissing && response.status === 404) return null;
|
||||
if (!response.ok) throw new Error(`GitHub API ${path} failed: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function githubList(path) {
|
||||
const items = [];
|
||||
for (let page = 1; ; page += 1) {
|
||||
const separator = path.includes("?") ? "&" : "?";
|
||||
const batch = await githubJson(`${path}${separator}per_page=100&page=${page}`);
|
||||
items.push(...batch);
|
||||
if (batch.length < 100) return items;
|
||||
}
|
||||
}
|
||||
|
||||
export function inferPullTargetHints(labels, files) {
|
||||
const labelSet = new Set(labels);
|
||||
const targets = new Set();
|
||||
const add = (target) => targets.add(target);
|
||||
const hasPath = (pattern) => files.some((path) => pattern.test(path));
|
||||
|
||||
const explicitlyDesktop = labelSet.has("desktop");
|
||||
const explicitlyCLI = labelSet.has("tui") || labelSet.has("cli");
|
||||
if (explicitlyDesktop) add("desktop");
|
||||
if (explicitlyCLI) add("cli");
|
||||
if (hasPath(/^desktop\//)) add("desktop");
|
||||
if (hasPath(/^(?:internal\/cli\/|cmd\/)/)) add("cli");
|
||||
if (hasPath(/^site\//)) add("site");
|
||||
if (hasPath(/^(?:workers\/|\.github\/|scripts\/|release-notes\/)/)) add("service");
|
||||
|
||||
const hasSharedProductCode = files.some((path) =>
|
||||
/^(?:internal\/|sdk\/|npm\/)/.test(path) &&
|
||||
!/^internal\/cli\//.test(path) &&
|
||||
!/^internal\/telemetry\//.test(path),
|
||||
);
|
||||
if (!explicitlyDesktop && !explicitlyCLI && hasSharedProductCode) {
|
||||
add("desktop");
|
||||
add("cli");
|
||||
}
|
||||
if (!targets.size) {
|
||||
add("desktop");
|
||||
add("cli");
|
||||
}
|
||||
return releaseTargetOrder.filter((target) => targets.has(target));
|
||||
}
|
||||
|
||||
async function collectPullRequests(repository, commits) {
|
||||
const numbers = new Set(prNumbersFromCommits(commits));
|
||||
const associated = await Promise.all(
|
||||
commits.map((commit) => githubJson(`/repos/${repository}/commits/${commit.sha}/pulls`, { allowMissing: true })),
|
||||
);
|
||||
for (const pulls of associated) for (const pull of pulls || []) numbers.add(pull.number);
|
||||
const pulls = await Promise.all([...numbers].map((number) => githubJson(`/repos/${repository}/pulls/${number}`, { allowMissing: true })));
|
||||
return Promise.all(pulls.filter(Boolean).map(async (pull) => {
|
||||
const labels = (pull.labels || []).map((label) => label.name);
|
||||
const files = (await githubList(`/repos/${repository}/pulls/${pull.number}/files`)).map((file) => file.filename);
|
||||
return {
|
||||
number: pull.number,
|
||||
title: pull.title,
|
||||
body: String(pull.body || "").slice(0, 2000),
|
||||
author: pull.user?.login || "",
|
||||
labels,
|
||||
changedFileCount: files.length,
|
||||
files: files.slice(0, 200),
|
||||
targetHints: inferPullTargetHints(labels, files),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
function releaseItems(release) {
|
||||
return [
|
||||
...(release.highlights || []),
|
||||
...["new", "improved", "fixed"].flatMap((kind) => release.changes?.[kind] || []),
|
||||
...(release.upgrade || []),
|
||||
...(release.risks || []),
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeReleaseTargets(release) {
|
||||
for (const item of releaseItems(release)) {
|
||||
if (!Array.isArray(item.targets)) continue;
|
||||
item.targets.sort((a, b) => releaseTargetOrder.indexOf(a) - releaseTargetOrder.indexOf(b));
|
||||
}
|
||||
const targets = new Set(releaseItems(release).flatMap((item) => item.targets || []));
|
||||
release.surfaces = [
|
||||
...releaseTargetOrder.filter((target) => targets.has(target)),
|
||||
...[...targets].filter((target) => !releaseTargetOrder.includes(target)).sort(),
|
||||
];
|
||||
}
|
||||
|
||||
function collectDocLinks(from, repository, to) {
|
||||
const linkRef = runGit(["rev-parse", to]);
|
||||
const paths = runGit(["diff", "--name-only", `${from}..${to}`])
|
||||
.split("\n")
|
||||
.filter((path) => /^(?:docs|README)[/\w.-]*\.(?:md|mdx)$/i.test(path));
|
||||
return paths.map((path) => `https://github.com/${repository}/blob/${linkRef}/${path}`);
|
||||
}
|
||||
|
||||
function assertGroundedRefs(value, allowedRefs, path = "release") {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => assertGroundedRefs(item, allowedRefs, `${path}[${index}]`));
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object") return;
|
||||
if (Array.isArray(value.refs)) {
|
||||
for (const ref of value.refs) {
|
||||
if (!allowedRefs.has(ref)) throw new Error(`${path}.refs contains PR #${ref}, which is outside the release range`);
|
||||
}
|
||||
}
|
||||
for (const [key, child] of Object.entries(value)) assertGroundedRefs(child, allowedRefs, `${path}.${key}`);
|
||||
}
|
||||
|
||||
function extractJson(content) {
|
||||
if (!content?.trim()) throw new Error("DeepSeek returned empty content");
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed.release || parsed;
|
||||
}
|
||||
|
||||
async function askDeepSeek(payload, attempt = 0) {
|
||||
const key = process.env.DEEPSEEK_API_KEY;
|
||||
if (!key) throw new Error("DEEPSEEK_API_KEY is required");
|
||||
const compact = attempt > 0;
|
||||
const response = await fetch(`${apiBase.replace(/\/$/, "")}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
// Release notes need deterministic structured output, not hidden chain of
|
||||
// thought. Thinking tokens share the generation budget with content and
|
||||
// can otherwise leave response_format JSON empty or truncated.
|
||||
thinking: { type: "disabled" },
|
||||
temperature: 0,
|
||||
max_tokens: 8000,
|
||||
response_format: { type: "json_object" },
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are Reasonix's release editor. Return one JSON object with a \"release\" property. Write factual, user-facing product release notes in equivalent English and Simplified Chinese. Group changes by user outcome, not by commit. Never invent capabilities, migrations, risks, PR numbers, contributors, URLs, or metrics. Every highlight and change must cite one or more supplied PR numbers. Use this exact release shape:
|
||||
{
|
||||
\"version\": \"semver\", \"date\": \"YYYY-MM-DD\", \"channel\": \"stable|prerelease\", \"targetingVersion\": 1,
|
||||
\"title\": {\"en\":\"\",\"zh\":\"\"}, \"summary\": {\"en\":\"\",\"zh\":\"\"},
|
||||
\"surfaces\": [\"desktop\",\"cli\"],
|
||||
\"guides\": [{\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"href\":\"https://...\"}],
|
||||
\"highlights\": [{\"kind\":\"new|improved|fixed|security\",\"targets\":[\"desktop\",\"cli\"],\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"refs\":[123]}],
|
||||
\"changes\": {\"new\":[],\"improved\":[],\"fixed\":[]},
|
||||
\"upgrade\": [{\"level\":\"info|warning\",\"targets\":[\"desktop\"],\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"refs\":[123]}],
|
||||
\"risks\": [{\"targets\":[\"cli\"],\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"refs\":[123]}],
|
||||
\"contributors\": [], \"links\": {\"github\":\"https://...\",\"compare\":\"https://...\",\"download\":\"https://...\"}
|
||||
}
|
||||
Every highlight, change, upgrade note, and risk must have a non-empty \"targets\" array using only this canonical order: desktop, cli, site, service. Use each PR's targetHints as deterministic candidates, then choose the user-visible delivery target supported by its labels, changed files, title, and body. Shared product-core behavior belongs to both desktop and cli. Website-only work belongs to site; hosted workers and release infrastructure belong to service and must not be marked as a client update merely because a client file accompanies the server change. Set top-level surfaces to the canonical union of all item targets. Return guides only for supplied documentation URLs. Mention upgrade action or risk only when explicitly supported; otherwise use empty arrays. ${editorialLimitInstruction(compact)} Output JSON only.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `${compact ? "The previous response was invalid or truncated. Produce a smaller complete record and obey the compact editorial budget.\n" : ""}Create the release record from these public GitHub sources:\n${JSON.stringify(payload)}`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`DeepSeek API failed: ${response.status} ${await response.text()}`);
|
||||
const data = await response.json();
|
||||
const choice = data.choices?.[0];
|
||||
if (choice?.finish_reason === "length") {
|
||||
if (compact) throw new Error("DeepSeek response exceeded the compact editorial budget (finish_reason=length)");
|
||||
return askDeepSeek(payload, attempt + 1);
|
||||
}
|
||||
try {
|
||||
return extractJson(choice?.message?.content);
|
||||
} catch (error) {
|
||||
if (compact) {
|
||||
throw new Error(`${error.message} (finish_reason=${choice?.finish_reason || "unknown"})`);
|
||||
}
|
||||
return askDeepSeek(payload, attempt + 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.version) throw new Error("--version is required");
|
||||
const version = normalizeVersion(args.version);
|
||||
const catalog = await loadCatalog();
|
||||
const channel = version.includes("-") ? "prerelease" : "stable";
|
||||
const baseVersion = version.split("-")[0];
|
||||
const previousRecord = channel === "stable"
|
||||
? catalog.releases.find((release) => release.version !== version && release.channel === "stable")
|
||||
: catalog.releases.find(
|
||||
(release) =>
|
||||
release.version !== version &&
|
||||
release.channel === "prerelease" &&
|
||||
release.baseVersion === baseVersion,
|
||||
) || catalog.releases.find((release) => release.channel === "stable");
|
||||
const previous = args.from || previousRecord?.version;
|
||||
if (!previous) throw new Error("--from is required when no previous release exists");
|
||||
const previousVersion = normalizeVersion(previous);
|
||||
const previousIsPreview = previousVersion.includes("-");
|
||||
const from = previous.match(/^(?:desktop-|npm-)?v/)
|
||||
? previous
|
||||
: previousIsPreview
|
||||
? `v${previousVersion}`
|
||||
: `desktop-v${previousVersion}`;
|
||||
const to = args.to || "HEAD";
|
||||
const repository = repositoryName();
|
||||
const commits = commitRange(from, to);
|
||||
if (!commits.length) throw new Error(`no commits found in ${from}..${to}`);
|
||||
const pulls = await collectPullRequests(repository, commits);
|
||||
if (!pulls.length) throw new Error(`no pull requests found in ${from}..${to}`);
|
||||
const docLinks = collectDocLinks(from, repository, to);
|
||||
const date = args.date || new Date().toISOString().slice(0, 10);
|
||||
const tag = args.tag || (channel === "prerelease" ? `v${version}` : `desktop-v${version}`);
|
||||
const source = {
|
||||
version,
|
||||
date,
|
||||
channel,
|
||||
range: `${from}..${to}`,
|
||||
pullRequests: pulls,
|
||||
documentationUrls: docLinks,
|
||||
};
|
||||
const release = await askDeepSeek(source);
|
||||
release.targetingVersion = 1;
|
||||
release.version = version;
|
||||
release.releaseId = version;
|
||||
release.baseVersion = baseVersion;
|
||||
release.date = date;
|
||||
release.channel = source.channel;
|
||||
release.status = "reviewed";
|
||||
release.previousRelease = previousVersion;
|
||||
const previewOrdinal = version.match(/-preview\.([1-9][0-9]*)$/)?.[1];
|
||||
if (channel === "prerelease") {
|
||||
if (!previewOrdinal) throw new Error("Preview release version must use MAJOR.MINOR.PATCH-preview.N");
|
||||
release.builds = {
|
||||
cli: `v${version}`,
|
||||
desktop: `v${baseVersion}-preview.${previewOrdinal}`,
|
||||
npm: `${baseVersion}-canary.${previewOrdinal}`,
|
||||
};
|
||||
} else {
|
||||
release.builds = {
|
||||
cli: `v${version}`,
|
||||
desktop: `v${version}`,
|
||||
npm: version,
|
||||
};
|
||||
}
|
||||
release.contributors = [...new Set(pulls.map((pull) => pull.author).filter(Boolean))];
|
||||
release.links = {
|
||||
github: `https://github.com/${repository}/releases/tag/${tag}`,
|
||||
compare: `https://github.com/${repository}/compare/${from}...${tag}`,
|
||||
download: channel === "prerelease"
|
||||
? "https://reasonix.io/?download=desktop&channel=preview#start"
|
||||
: "https://reasonix.io/?download=desktop&channel=stable#start",
|
||||
};
|
||||
release.guides = (release.guides || []).filter((guide) => docLinks.includes(guide.href));
|
||||
normalizeReleaseTargets(release);
|
||||
assertGroundedRefs(release, new Set(pulls.map((pull) => pull.number)));
|
||||
validateCatalog({ schemaVersion: 1, releases: [release] });
|
||||
await upsertRelease(release);
|
||||
console.log(`Generated bilingual release notes for v${version} from ${pulls.length} pull request(s).`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -150,6 +150,16 @@ function requireAcceptanceReceipt(payloadRoot, files, metadata, item) {
|
||||
return { ...item, evidenceSha256: evidence.sha256, artifactSha256: receipt.sha256 };
|
||||
}
|
||||
|
||||
// The notes are rendered once, at seal, because rendering reads GitHub: a later
|
||||
// render can differ (a renamed author, a newly linked fix) or fail outright.
|
||||
export const sealedNotesPath = "evidence/release-notes.md";
|
||||
|
||||
function requireSealedNotes(files, renderedSha256) {
|
||||
const notes = files.find(file => file.path === sealedNotesPath);
|
||||
if (!notes) throw new Error(`candidate is missing sealed release notes: ${sealedNotesPath}`);
|
||||
if (notes.sha256 !== renderedSha256) throw new Error("sealed release notes do not match renderedSha256");
|
||||
}
|
||||
|
||||
export function sealCandidate(payloadRoot, metadata) {
|
||||
requireIdentity(metadata);
|
||||
const files = walk(payloadRoot);
|
||||
@@ -162,6 +172,7 @@ export function sealCandidate(payloadRoot, metadata) {
|
||||
throw new Error("invalid candidate validity window");
|
||||
}
|
||||
const acceptance = metadata.acceptance.map(item => requireAcceptanceReceipt(payloadRoot, files, metadata, item));
|
||||
requireSealedNotes(files, metadata.renderedNotesSha256);
|
||||
return {
|
||||
schema: 1,
|
||||
purpose: metadata.purpose ?? "release",
|
||||
@@ -174,6 +185,7 @@ export function sealCandidate(payloadRoot, metadata) {
|
||||
sourceSHA: metadata.notesSourceSHA,
|
||||
catalogSha256: metadata.catalogSha256,
|
||||
renderedSha256: metadata.renderedNotesSha256,
|
||||
renderedPath: sealedNotesPath,
|
||||
},
|
||||
source: {
|
||||
repository: metadata.repository,
|
||||
@@ -224,6 +236,10 @@ export function verifyCandidate(payloadRoot, record, now = new Date(), purpose =
|
||||
const actual = walk(payloadRoot);
|
||||
requirePayloadLayout(payloadRoot, actual);
|
||||
if (JSON.stringify(actual) !== JSON.stringify(record.files)) throw new Error("candidate payload digest mismatch");
|
||||
if (record.notes.renderedPath !== undefined) {
|
||||
if (record.notes.renderedPath !== sealedNotesPath) throw new Error("invalid sealed release notes path");
|
||||
requireSealedNotes(actual, record.notes.renderedSha256);
|
||||
}
|
||||
requireDesktopIdentities(payloadRoot, {
|
||||
version: record.version,
|
||||
sourceSHA: record.sourceSHA,
|
||||
|
||||
@@ -6,7 +6,15 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
import { artifactNamespace, candidateId, desktopPlatforms, npmPackages, sealCandidate, verifyCandidate } from "./release-candidate.mjs";
|
||||
import {
|
||||
artifactNamespace,
|
||||
candidateId,
|
||||
desktopPlatforms,
|
||||
npmPackages,
|
||||
sealCandidate,
|
||||
sealedNotesPath,
|
||||
verifyCandidate,
|
||||
} from "./release-candidate.mjs";
|
||||
|
||||
function fixture(t) {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "reasonix-candidate-"));
|
||||
@@ -50,13 +58,16 @@ function fixture(t) {
|
||||
sha256: createHash("sha256").update(platform).digest("hex"),
|
||||
}));
|
||||
}
|
||||
writeFileSync(path.join(evidence, "release-notes.md"), sealedNotes);
|
||||
return root;
|
||||
}
|
||||
|
||||
const sealedNotes = "notes by @alice\n";
|
||||
|
||||
const metadata = {
|
||||
version: "1.2.3", sourceSHA: "a".repeat(40), buildControlSHA: "b".repeat(40),
|
||||
acceptanceControlSHA: "c".repeat(40), notesSourceSHA: "a".repeat(40),
|
||||
catalogSha256: "d".repeat(64), renderedNotesSha256: "e".repeat(64),
|
||||
catalogSha256: "d".repeat(64), renderedNotesSha256: createHash("sha256").update("notes by @alice\n").digest("hex"),
|
||||
repository: "esengine/DeepSeek-Reasonix", workflow: ".github/workflows/release-candidate.yml",
|
||||
runId: "123", runAttempt: "1", desktopPrefix: "desktop-123-1-preflight",
|
||||
payloadArtifactId: "456", payloadArtifactName: `release-candidate-payload-${candidateId("1.2.3", "a".repeat(40), "d".repeat(64))}`,
|
||||
@@ -73,6 +84,30 @@ test("seals and verifies a complete immutable candidate", t => {
|
||||
assert.doesNotThrow(() => verifyCandidate(root, record, new Date("2026-01-02T00:00:00Z")));
|
||||
});
|
||||
|
||||
test("the rendered notes are sealed into the payload and bound to their hash", t => {
|
||||
const root = fixture(t);
|
||||
const record = sealCandidate(root, metadata);
|
||||
assert.equal(record.notes.renderedPath, sealedNotesPath);
|
||||
assert.equal(readFileSync(path.join(root, record.notes.renderedPath), "utf8"), sealedNotes);
|
||||
assert.throws(() => sealCandidate(root, { ...metadata, renderedNotesSha256: "e".repeat(64) }), /do not match renderedSha256/);
|
||||
writeFileSync(path.join(root, sealedNotesPath), "notes by @mallory\n");
|
||||
assert.throws(() => verifyCandidate(root, record, new Date("2026-01-02T00:00:00Z")), /digest mismatch/);
|
||||
rmSync(path.join(root, sealedNotesPath));
|
||||
assert.throws(() => sealCandidate(root, metadata), /missing sealed release notes/);
|
||||
});
|
||||
|
||||
test("a candidate sealed before notes were sealed still verifies", t => {
|
||||
const root = fixture(t);
|
||||
const record = sealCandidate(root, metadata);
|
||||
rmSync(path.join(root, sealedNotesPath));
|
||||
const legacy = structuredClone(record);
|
||||
delete legacy.notes.renderedPath;
|
||||
legacy.files = legacy.files.filter(file => file.path !== sealedNotesPath);
|
||||
assert.doesNotThrow(() => verifyCandidate(root, legacy, new Date("2026-01-02T00:00:00Z")));
|
||||
const forged = { ...legacy, notes: { ...legacy.notes, renderedPath: "evidence/other.md" } };
|
||||
assert.throws(() => verifyCandidate(root, forged, new Date("2026-01-02T00:00:00Z")), /sealed release notes path/);
|
||||
});
|
||||
|
||||
test("rehearsal bytes remain isolated from the publication identity", t => {
|
||||
const root = fixture(t);
|
||||
const id = candidateId(metadata.version, metadata.sourceSHA, metadata.catalogSha256);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
// Resolves the #N references in release notes to the people they credit: a
|
||||
// pull request's author, or for an issue the authors of the merged pull
|
||||
// requests that closed it. A number that names a discussion or nothing stays
|
||||
// plain with a warning; a lookup GitHub could not answer fails by code.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
export const defaultRepository = "esengine/DeepSeek-Reasonix";
|
||||
|
||||
export const CreditErrorCode = Object.freeze({
|
||||
noToken: "release_credits.no_token",
|
||||
unreachable: "release_credits.api_unreachable",
|
||||
status: "release_credits.api_status",
|
||||
notFound: "release_credits.ref_not_found",
|
||||
discussion: "release_credits.ref_is_discussion",
|
||||
unresolved: "release_credits.unresolved",
|
||||
});
|
||||
|
||||
export class CreditError extends Error {
|
||||
constructor(code, message, { ref, failures } = {}) {
|
||||
super(message);
|
||||
this.name = "CreditError";
|
||||
this.code = code;
|
||||
if (ref !== undefined) this.ref = ref;
|
||||
if (failures !== undefined) this.failures = failures;
|
||||
}
|
||||
}
|
||||
|
||||
const botLogins = new Set(["github-actions", "dependabot", "dependabot-preview"]);
|
||||
|
||||
export function isBotAccount(login, type) {
|
||||
if (type === "Bot") return true;
|
||||
const name = String(login).toLowerCase();
|
||||
return name.endsWith("[bot]") || botLogins.has(name);
|
||||
}
|
||||
|
||||
export function tokenFromEnvironment(env = process.env, runGh = defaultRunGh) {
|
||||
const token = env.GH_TOKEN || env.GITHUB_TOKEN;
|
||||
if (token) return token;
|
||||
try {
|
||||
const fromGh = runGh().trim();
|
||||
if (fromGh) return fromGh;
|
||||
} catch {
|
||||
// gh missing or signed out; reported below as no_token.
|
||||
}
|
||||
throw new CreditError(
|
||||
CreditErrorCode.noToken,
|
||||
"no GitHub token: set GH_TOKEN (in CI: ${{ github.token }}), or sign in with `gh auth login`",
|
||||
);
|
||||
}
|
||||
|
||||
function defaultRunGh() {
|
||||
return execFileSync("gh", ["auth", "token"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
||||
}
|
||||
|
||||
const refQuery = `query($owner: String!, $name: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
discussion(number: $number) { number }
|
||||
issueOrPullRequest(number: $number) {
|
||||
__typename
|
||||
... on PullRequest { author { login __typename } }
|
||||
... on Issue {
|
||||
closedByPullRequestsReferences(first: 20, includeClosedPrs: true) {
|
||||
nodes { number merged author { login __typename } }
|
||||
}
|
||||
timelineItems(itemTypes: [CLOSED_EVENT], last: 1) {
|
||||
nodes { ... on ClosedEvent { closer { __typename ... on PullRequest { number merged author { login __typename } } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
function person(author) {
|
||||
if (!author) return { login: null, bot: true };
|
||||
return { login: author.login, bot: isBotAccount(author.login, author.__typename) };
|
||||
}
|
||||
|
||||
// An issue's fixers are the merged pull requests GitHub links to it as closing
|
||||
// it: the one that closed it, and any linked by a closing keyword or by hand.
|
||||
// A mere mention is not a fix, so cross-references are not read.
|
||||
function issueFixes(issue) {
|
||||
const closer = issue.timelineItems?.nodes?.[0]?.closer;
|
||||
const candidates = [...(issue.closedByPullRequestsReferences?.nodes || [])];
|
||||
if (closer?.__typename === "PullRequest") candidates.push(closer);
|
||||
const fixes = new Map();
|
||||
for (const pull of candidates) {
|
||||
if (pull?.merged && !fixes.has(pull.number)) fixes.set(pull.number, { number: pull.number, ...person(pull.author) });
|
||||
}
|
||||
return [...fixes.values()].sort((a, b) => a.number - b.number);
|
||||
}
|
||||
|
||||
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// A 5xx, a 429, a 403 that carries rate-limit headers, a GraphQL RATE_LIMITED
|
||||
// error or a dropped connection is transient; anything else is final.
|
||||
function retryDelay(response, payload, attempt) {
|
||||
const backoff = 1000 * 2 ** attempt;
|
||||
if (!response) return backoff;
|
||||
const header = (name) => response.headers?.get?.(name) ?? null;
|
||||
const after = header("retry-after") === null ? null : Number(header("retry-after"));
|
||||
const limited =
|
||||
response.status === 429 ||
|
||||
(response.status === 403 && (after !== null || header("x-ratelimit-remaining") === "0")) ||
|
||||
payload?.errors?.some((error) => error.type === "RATE_LIMITED");
|
||||
if (!limited && response.status < 500) return null;
|
||||
return after > 0 ? Math.min(after * 1000, 60_000) : backoff;
|
||||
}
|
||||
|
||||
// Returns an async lookup: number -> { kind: "pull", login, bot } |
|
||||
// { kind: "issue", fixes: [{ number, login, bot }] } | { kind: "discussion" } |
|
||||
// { kind: "missing" }.
|
||||
export function githubRefLookup({
|
||||
repository = defaultRepository,
|
||||
token,
|
||||
fetchImpl = fetch,
|
||||
sleep = defaultSleep,
|
||||
attempts = 4,
|
||||
} = {}) {
|
||||
const [owner, name] = repository.split("/");
|
||||
const headers = { "Content-Type": "application/json", "User-Agent": "reasonix-release-credits" };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const body = (ref) => JSON.stringify({ query: refQuery, variables: { owner, name, number: ref } });
|
||||
|
||||
async function query(ref) {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
let response = null;
|
||||
let payload = null;
|
||||
let failure;
|
||||
try {
|
||||
response = await fetchImpl("https://api.github.com/graphql", { method: "POST", headers, body: body(ref) });
|
||||
if (response.ok) payload = await response.json();
|
||||
if (response.ok && !payload.errors?.some((error) => error.type === "RATE_LIMITED")) return payload;
|
||||
const why = response.ok ? "GraphQL RATE_LIMITED" : `status ${response.status}`;
|
||||
failure = new CreditError(CreditErrorCode.status, `#${ref}: GitHub API answered ${why}`, { ref });
|
||||
} catch (error) {
|
||||
if (error instanceof CreditError) throw error;
|
||||
response = null;
|
||||
failure = new CreditError(CreditErrorCode.unreachable, `#${ref}: GitHub API unreachable (${error.message})`, { ref });
|
||||
}
|
||||
const delay = retryDelay(response, payload, attempt);
|
||||
if (delay === null || attempt + 1 >= attempts) throw failure;
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
return async (ref) => {
|
||||
const payload = await query(ref);
|
||||
const repositoryNode = payload.data?.repository;
|
||||
const node = repositoryNode?.issueOrPullRequest;
|
||||
if (node?.__typename === "PullRequest") return { kind: "pull", ...person(node.author) };
|
||||
if (node?.__typename === "Issue") return { kind: "issue", fixes: issueFixes(node) };
|
||||
const errors = payload.errors || [];
|
||||
if (repositoryNode && errors.every((error) => error.type === "NOT_FOUND")) {
|
||||
return { kind: repositoryNode.discussion ? "discussion" : "missing" };
|
||||
}
|
||||
const types = errors.map((error) => error.type || "unknown").join(", ") || "empty response";
|
||||
throw new CreditError(CreditErrorCode.status, `#${ref}: GitHub GraphQL returned no node (${types})`, { ref });
|
||||
};
|
||||
}
|
||||
|
||||
// Looks each distinct ref up once. Failures are collected, not thrown, so the
|
||||
// caller can still render every ref it has and then fail with all of them. A
|
||||
// discussion or a number that names nothing is a warning, not a failure.
|
||||
export async function resolveCredits(refs, lookup, { concurrency = 6 } = {}) {
|
||||
const distinct = [...new Set(refs)];
|
||||
const outcomes = new Array(distinct.length);
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
while (next < distinct.length) {
|
||||
const index = next++;
|
||||
try {
|
||||
outcomes[index] = { credit: await lookup(distinct[index]) };
|
||||
} catch (error) {
|
||||
if (!(error instanceof CreditError)) throw error;
|
||||
outcomes[index] = { failure: { ref: distinct[index], code: error.code, message: error.message } };
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, distinct.length) }, worker));
|
||||
const credits = new Map();
|
||||
const failures = [];
|
||||
const warnings = [];
|
||||
distinct.forEach((ref, index) => {
|
||||
const { credit, failure } = outcomes[index];
|
||||
if (failure) return failures.push(failure);
|
||||
credits.set(ref, credit);
|
||||
if (credit.kind === "discussion") {
|
||||
warnings.push({ ref, code: CreditErrorCode.discussion, message: `#${ref} is a discussion; left uncredited` });
|
||||
} else if (credit.kind === "missing") {
|
||||
warnings.push({ ref, code: CreditErrorCode.notFound, message: `#${ref} names no issue, pull request or discussion; left uncredited` });
|
||||
}
|
||||
});
|
||||
return { credits, failures, warnings };
|
||||
}
|
||||
|
||||
export function reportWarnings(warnings, { env = process.env, write = (line) => console.error(line) } = {}) {
|
||||
const prefix = env.GITHUB_ACTIONS === "true" ? "::warning::" : "warning: ";
|
||||
for (const warning of warnings) write(`${prefix}${warning.code} ${warning.message}`);
|
||||
}
|
||||
|
||||
export function unresolvedError(failures) {
|
||||
const lines = failures.map((failure) => ` ${failure.code} ${failure.message}`);
|
||||
return new CreditError(
|
||||
CreditErrorCode.unresolved,
|
||||
`${failures.length} reference(s) could not be credited:\n${lines.join("\n")}`,
|
||||
{ failures },
|
||||
);
|
||||
}
|
||||
|
||||
function humanLogin(person) {
|
||||
return person.login && !person.bot ? person.login : null;
|
||||
}
|
||||
|
||||
export function creditedLogins(credit) {
|
||||
if (credit?.kind === "pull") return [humanLogin(credit)].filter(Boolean);
|
||||
if (credit?.kind === "issue") return credit.fixes.map(humanLogin).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
// " by @a" for a pull request, " fixed in #2 by @a and #3" for an issue.
|
||||
export function creditSuffix(credit) {
|
||||
const by = (person) => (humanLogin(person) ? ` by @${person.login}` : "");
|
||||
if (credit?.kind === "pull") return by(credit);
|
||||
if (credit?.kind !== "issue" || !credit.fixes.length) return "";
|
||||
return ` fixed in ${credit.fixes.map((fix) => `#${fix.number}${by(fix)}`).join(" and ")}`;
|
||||
}
|
||||
|
||||
// Unique human logins in the order their refs first appear.
|
||||
export function contributorLogins(refs, credits) {
|
||||
const logins = [];
|
||||
const seen = new Set();
|
||||
for (const ref of refs) {
|
||||
for (const login of creditedLogins(credits.get(ref))) {
|
||||
if (isBotAccount(login) || seen.has(login.toLowerCase())) continue;
|
||||
seen.add(login.toLowerCase());
|
||||
logins.push(login);
|
||||
}
|
||||
}
|
||||
return logins;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
contributorLogins,
|
||||
CreditError,
|
||||
CreditErrorCode,
|
||||
creditSuffix,
|
||||
githubRefLookup,
|
||||
isBotAccount,
|
||||
reportWarnings,
|
||||
resolveCredits,
|
||||
tokenFromEnvironment,
|
||||
unresolvedError,
|
||||
} from "./release-credits.mjs";
|
||||
|
||||
function jsonResponse(status, body, headers = {}) {
|
||||
return {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
headers: { get: (name) => headers[name] ?? null },
|
||||
json: async () => body,
|
||||
};
|
||||
}
|
||||
|
||||
// routes: number -> node | "discussion" | Error | [status, body, headers] |
|
||||
// an array of those, answered in turn; a missing number is NOT_FOUND.
|
||||
function fakeGitHub(routes) {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, init) => {
|
||||
const { variables } = JSON.parse(init.body);
|
||||
calls.push({ url, init, variables });
|
||||
let route = routes[variables.number];
|
||||
if (route?.sequence) route = route.sequence.shift();
|
||||
if (route instanceof Error) throw route;
|
||||
if (Array.isArray(route)) return jsonResponse(...route);
|
||||
if (route && route !== "discussion") {
|
||||
return jsonResponse(200, { data: { repository: { discussion: null, issueOrPullRequest: route } } });
|
||||
}
|
||||
const errors = [{ type: "NOT_FOUND", message: "Could not resolve" }];
|
||||
if (!route) errors.push({ type: "NOT_FOUND", message: "Could not resolve" });
|
||||
return jsonResponse(200, {
|
||||
data: { repository: { discussion: route ? { number: variables.number } : null, issueOrPullRequest: null } },
|
||||
errors,
|
||||
});
|
||||
};
|
||||
return { calls, fetchImpl };
|
||||
}
|
||||
|
||||
const noSleep = { sleep: async () => {} };
|
||||
|
||||
const user = (login, type = "User") => ({ login, __typename: type });
|
||||
const pull = (login, type) => ({ __typename: "PullRequest", author: user(login, type) });
|
||||
const linked = (number, login, merged = true, type) => ({ number, merged, author: user(login, type) });
|
||||
const issue = (references = [], closer = null) => ({
|
||||
__typename: "Issue",
|
||||
closedByPullRequestsReferences: { nodes: references },
|
||||
timelineItems: { nodes: closer ? [{ closer }] : [] },
|
||||
});
|
||||
|
||||
test("a pull request credits its author", async () => {
|
||||
const { calls, fetchImpl } = fakeGitHub({ 1: pull("alice") });
|
||||
const lookup = githubRefLookup({ repository: "o/r", token: "t", fetchImpl });
|
||||
assert.deepEqual(await lookup(1), { kind: "pull", login: "alice", bot: false });
|
||||
assert.equal(calls[0].url, "https://api.github.com/graphql");
|
||||
assert.equal(calls[0].init.headers.Authorization, "Bearer t");
|
||||
assert.deepEqual(calls[0].variables, { owner: "o", name: "r", number: 1 });
|
||||
});
|
||||
|
||||
test("an issue credits the merged pull requests that closed it, and nothing else", async () => {
|
||||
const { fetchImpl } = fakeGitHub({
|
||||
2: issue([linked(12, "bob"), linked(11, "carol", false)], { __typename: "PullRequest", ...linked(10, "alice") }),
|
||||
3: issue([], { __typename: "Commit" }),
|
||||
4: issue([linked(14, "bob"), linked(14, "bob")], { __typename: "PullRequest", ...linked(14, "bob") }),
|
||||
});
|
||||
const lookup = githubRefLookup({ repository: "o/r", fetchImpl });
|
||||
assert.deepEqual(await lookup(2), {
|
||||
kind: "issue",
|
||||
fixes: [
|
||||
{ number: 10, login: "alice", bot: false },
|
||||
{ number: 12, login: "bob", bot: false },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(await lookup(3), { kind: "issue", fixes: [] });
|
||||
assert.deepEqual(await lookup(4), { kind: "issue", fixes: [{ number: 14, login: "bob", bot: false }] });
|
||||
});
|
||||
|
||||
test("bot and deleted authors are never credited", async () => {
|
||||
const { fetchImpl } = fakeGitHub({
|
||||
1: pull("github-actions", "Bot"),
|
||||
2: pull("dependabot"),
|
||||
3: pull("renovate[bot]"),
|
||||
4: { __typename: "PullRequest", author: null },
|
||||
5: issue([linked(15, "dependabot[bot]")]),
|
||||
});
|
||||
const lookup = githubRefLookup({ repository: "o/r", fetchImpl });
|
||||
for (const ref of [1, 2, 3, 4]) assert.equal((await lookup(ref)).bot, true);
|
||||
const fixed = await lookup(5);
|
||||
assert.equal(fixed.fixes[0].bot, true);
|
||||
assert.equal(creditSuffix(fixed), " fixed in #15");
|
||||
assert.equal(isBotAccount("github-actions"), true);
|
||||
assert.equal(isBotAccount("esengine"), false);
|
||||
});
|
||||
|
||||
test("a discussion or a number naming nothing resolves, and warns instead of failing", async () => {
|
||||
const { fetchImpl } = fakeGitHub({ 8: "discussion", 1: pull("alice") });
|
||||
const lookup = githubRefLookup({ repository: "o/r", fetchImpl });
|
||||
assert.deepEqual(await lookup(8), { kind: "discussion" });
|
||||
assert.deepEqual(await lookup(9), { kind: "missing" });
|
||||
const { credits, failures, warnings } = await resolveCredits([8, 1, 9], lookup);
|
||||
assert.deepEqual(failures, []);
|
||||
assert.equal(creditSuffix(credits.get(8)), "");
|
||||
assert.deepEqual(
|
||||
warnings.map((warning) => [warning.ref, warning.code]),
|
||||
[
|
||||
[8, CreditErrorCode.discussion],
|
||||
[9, CreditErrorCode.notFound],
|
||||
],
|
||||
);
|
||||
const lines = [];
|
||||
reportWarnings(warnings, { env: { GITHUB_ACTIONS: "true" }, write: (line) => lines.push(line) });
|
||||
assert.ok(lines.every((line) => line.startsWith("::warning::release_credits.")));
|
||||
});
|
||||
|
||||
test("transient failures are retried with backoff; the last one is reported by code", async () => {
|
||||
const slept = [];
|
||||
const sleep = async (ms) => slept.push(ms);
|
||||
const { calls, fetchImpl } = fakeGitHub({
|
||||
1: { sequence: [[502, {}], new TypeError("socket hang up"), [429, {}, { "retry-after": "7" }], pull("alice")] },
|
||||
2: [503, {}],
|
||||
3: new TypeError("fetch failed"),
|
||||
4: { sequence: [[200, { data: null, errors: [{ type: "RATE_LIMITED" }] }], pull("bob")] },
|
||||
5: [403, {}, { "x-ratelimit-remaining": "0" }],
|
||||
});
|
||||
const lookup = githubRefLookup({ repository: "o/r", fetchImpl, sleep });
|
||||
assert.equal((await lookup(1)).login, "alice");
|
||||
assert.deepEqual(slept, [1000, 2000, 7000]);
|
||||
await assert.rejects(lookup(2), { code: CreditErrorCode.status, ref: 2 });
|
||||
await assert.rejects(lookup(3), { code: CreditErrorCode.unreachable, ref: 3 });
|
||||
assert.equal((await lookup(4)).login, "bob");
|
||||
await assert.rejects(lookup(5), { code: CreditErrorCode.status, ref: 5 });
|
||||
const tries = (ref) => calls.filter((call) => call.variables.number === ref).length;
|
||||
assert.deepEqual([1, 2, 3, 4, 5].map(tries), [4, 4, 4, 2, 4]);
|
||||
});
|
||||
|
||||
test("an authorisation failure is final and never retried", async () => {
|
||||
const { calls, fetchImpl } = fakeGitHub({ 1: [401, {}], 2: [403, {}], 3: [200, { data: null, errors: [{ type: "FORBIDDEN" }] }] });
|
||||
const lookup = githubRefLookup({ repository: "o/r", fetchImpl, ...noSleep });
|
||||
await assert.rejects(lookup(1), { code: CreditErrorCode.status });
|
||||
await assert.rejects(lookup(2), { code: CreditErrorCode.status });
|
||||
await assert.rejects(lookup(3), { code: CreditErrorCode.status });
|
||||
assert.equal(calls.length, 3);
|
||||
});
|
||||
|
||||
test("suffixes use one wording for pull requests and fixed issues", () => {
|
||||
assert.equal(creditSuffix({ kind: "pull", login: "alice", bot: false }), " by @alice");
|
||||
assert.equal(creditSuffix({ kind: "pull", login: "x[bot]", bot: true }), "");
|
||||
assert.equal(creditSuffix({ kind: "issue", fixes: [] }), "");
|
||||
assert.equal(
|
||||
creditSuffix({
|
||||
kind: "issue",
|
||||
fixes: [
|
||||
{ number: 2, login: "alice", bot: false },
|
||||
{ number: 3, login: "bob", bot: false },
|
||||
],
|
||||
}),
|
||||
" fixed in #2 by @alice and #3 by @bob",
|
||||
);
|
||||
});
|
||||
|
||||
test("each distinct ref is looked up once and failures are collected in ref order", async () => {
|
||||
const seen = [];
|
||||
const lookup = async (ref) => {
|
||||
seen.push(ref);
|
||||
if (ref === 3 || ref === 1) throw new CreditError(CreditErrorCode.notFound, `#${ref}`, { ref });
|
||||
return { kind: "pull", login: `u${ref}`, bot: false };
|
||||
};
|
||||
const { credits, failures } = await resolveCredits([3, 2, 3, 1, 2], lookup, { concurrency: 2 });
|
||||
assert.deepEqual(seen.sort(), [1, 2, 3]);
|
||||
assert.deepEqual([...credits.keys()], [2]);
|
||||
assert.deepEqual(failures.map((failure) => failure.ref), [3, 1]);
|
||||
const error = unresolvedError(failures);
|
||||
assert.equal(error.code, CreditErrorCode.unresolved);
|
||||
assert.deepEqual(error.failures.map((failure) => failure.code), [CreditErrorCode.notFound, CreditErrorCode.notFound]);
|
||||
});
|
||||
|
||||
test("an unexpected lookup error is not swallowed", async () => {
|
||||
await assert.rejects(
|
||||
resolveCredits([1], async () => {
|
||||
throw new RangeError("bug");
|
||||
}),
|
||||
RangeError,
|
||||
);
|
||||
});
|
||||
|
||||
test("contributors are unique humans in first-appearance order, fixers included", () => {
|
||||
const credits = new Map([
|
||||
[1, { kind: "pull", login: "bob", bot: false }],
|
||||
[2, { kind: "pull", login: "alice", bot: false }],
|
||||
[3, { kind: "issue", fixes: [{ number: 30, login: "carol", bot: false }] }],
|
||||
[4, { kind: "pull", login: "github-actions[bot]", bot: true }],
|
||||
[5, { kind: "pull", login: "Bob", bot: false }],
|
||||
[6, { kind: "issue", fixes: [] }],
|
||||
]);
|
||||
assert.deepEqual(contributorLogins([3, 1, 4, 2, 1, 5, 6, 9], credits), ["carol", "bob", "alice"]);
|
||||
});
|
||||
|
||||
test("the token comes from the environment, then gh, else a coded error", () => {
|
||||
assert.equal(tokenFromEnvironment({ GH_TOKEN: "a", GITHUB_TOKEN: "b" }, () => "c"), "a");
|
||||
assert.equal(tokenFromEnvironment({ GITHUB_TOKEN: "b" }, () => "c"), "b");
|
||||
assert.equal(tokenFromEnvironment({}, () => "c\n"), "c");
|
||||
assert.throws(
|
||||
() =>
|
||||
tokenFromEnvironment({}, () => {
|
||||
throw new Error("gh: not logged in");
|
||||
}),
|
||||
{ code: CreditErrorCode.noToken },
|
||||
);
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start CI on a Notes PR that the Actions bot opened. Events raised with
|
||||
# GITHUB_TOKEN never start workflows, so such a PR shows action_required with
|
||||
# zero jobs until a human-authenticated close/reopen re-emits pull_request.
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 1 ] || [[ ! "$1" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||||
echo "usage: scripts/release-notes-pr-kick.sh MAJOR.MINOR.PATCH" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
version="$1"
|
||||
repository="${RELEASE_REPOSITORY:-esengine/DeepSeek-Reasonix}"
|
||||
branch="release-notes/v$version"
|
||||
wait_seconds="${RELEASE_NOTES_KICK_WAIT_SECONDS:-120}"
|
||||
|
||||
for command in gh jq; do
|
||||
command -v "$command" >/dev/null || {
|
||||
echo "required command is unavailable: $command" >&2
|
||||
exit 2
|
||||
}
|
||||
done
|
||||
|
||||
prs="$(gh pr list --repo "$repository" --head "$branch" --state open --json number,headRefOid --limit 2)"
|
||||
count="$(jq 'length' <<<"$prs")"
|
||||
if [ "$count" -ne 1 ]; then
|
||||
echo "expected exactly one open PR for $branch, found $count" >&2
|
||||
exit 1
|
||||
fi
|
||||
number="$(jq -r '.[0].number' <<<"$prs")"
|
||||
head="$(jq -r '.[0].headRefOid' <<<"$prs")"
|
||||
|
||||
check_runs() {
|
||||
gh api "repos/$repository/commits/$head/check-runs" --jq '.total_count'
|
||||
}
|
||||
|
||||
if [ "$(check_runs)" -gt 0 ]; then
|
||||
echo "PR #$number already has check runs on $head; nothing to kick"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only the recorded-but-never-started shape is safe to kick. Anything else
|
||||
# with zero check runs needs a look, not a reopen.
|
||||
stalled="$(gh api "repos/$repository/actions/runs?event=pull_request&head_sha=$head&per_page=20" \
|
||||
--jq "[.workflow_runs[] | select(.head_sha == \"$head\" and .conclusion == \"action_required\")] | length")"
|
||||
if [ "$stalled" -eq 0 ]; then
|
||||
echo "PR #$number has no check runs and no action_required run on $head; inspect before kicking" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PR #$number: $stalled recorded-but-unstarted run(s) on $head; closing and reopening"
|
||||
gh pr close "$number" --repo "$repository"
|
||||
gh pr reopen "$number" --repo "$repository"
|
||||
|
||||
deadline=$((SECONDS + wait_seconds))
|
||||
while [ "$(check_runs)" -eq 0 ]; do
|
||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||
echo "no check runs appeared on $head within ${wait_seconds}s" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "CI started on PR #$number ($head)"
|
||||
+55
-31
@@ -3,6 +3,15 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
contributorLogins,
|
||||
creditSuffix,
|
||||
githubRefLookup,
|
||||
reportWarnings,
|
||||
resolveCredits,
|
||||
tokenFromEnvironment,
|
||||
unresolvedError,
|
||||
} from "./release-credits.mjs";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
export const defaultCatalogPath = resolve(repoRoot, "release-notes/releases.json");
|
||||
@@ -237,9 +246,11 @@ function localized(value, lang) {
|
||||
return value[lang] || value.en;
|
||||
}
|
||||
|
||||
function refsSuffix(refs = []) {
|
||||
function refsSuffix(refs = [], credits) {
|
||||
if (!refs.length) return "";
|
||||
return ` (${refs.map((ref) => `[#${ref}](https://github.com/esengine/DeepSeek-Reasonix/pull/${ref})`).join(", ")})`;
|
||||
return ` (${refs
|
||||
.map((ref) => `[#${ref}](https://github.com/esengine/DeepSeek-Reasonix/pull/${ref})${creditSuffix(credits.get(ref))}`)
|
||||
.join(", ")})`;
|
||||
}
|
||||
|
||||
function targetLabel(target) {
|
||||
@@ -251,9 +262,12 @@ function targetsPrefix(item) {
|
||||
return `**[${item.targets.map(targetLabel).join(" · ")}]** `;
|
||||
}
|
||||
|
||||
function renderItems(items, lang) {
|
||||
function renderItems(items, lang, credits) {
|
||||
return items
|
||||
.map((item) => `- ${targetsPrefix(item)}**${localized(item.title, lang)}** — ${localized(item.body, lang)}${refsSuffix(item.refs)}`)
|
||||
.map(
|
||||
(item) =>
|
||||
`- ${targetsPrefix(item)}**${localized(item.title, lang)}** — ${localized(item.body, lang)}${refsSuffix(item.refs, credits)}`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
@@ -265,7 +279,7 @@ function targetItemCount(release, target) {
|
||||
return releaseItems(release).filter((item) => hasTarget(item, target)).length;
|
||||
}
|
||||
|
||||
function appendTargetSection(lines, release, target, lang) {
|
||||
function appendTargetSection(lines, release, target, lang, credits) {
|
||||
const isZh = lang === "zh";
|
||||
const title = target === "desktop"
|
||||
? (isZh ? "桌面端更新" : "Desktop updates")
|
||||
@@ -274,7 +288,7 @@ function appendTargetSection(lines, release, target, lang) {
|
||||
|
||||
const highlights = release.highlights.filter((item) => hasTarget(item, target));
|
||||
if (highlights.length) {
|
||||
lines.push(`### ${isZh ? "重点内容" : "Highlights"}`, "", renderItems(highlights, lang), "");
|
||||
lines.push(`### ${isZh ? "重点内容" : "Highlights"}`, "", renderItems(highlights, lang, credits), "");
|
||||
}
|
||||
const headings = {
|
||||
new: isZh ? "新增" : "New",
|
||||
@@ -283,7 +297,7 @@ function appendTargetSection(lines, release, target, lang) {
|
||||
};
|
||||
for (const kind of changeKinds) {
|
||||
const items = release.changes[kind].filter((item) => hasTarget(item, target));
|
||||
if (items.length) lines.push(`### ${headings[kind]}`, "", renderItems(items, lang), "");
|
||||
if (items.length) lines.push(`### ${headings[kind]}`, "", renderItems(items, lang, credits), "");
|
||||
}
|
||||
|
||||
if (!highlights.length && !changeKinds.some((kind) => release.changes[kind].some((item) => hasTarget(item, target)))) {
|
||||
@@ -297,7 +311,7 @@ function appendTargetSection(lines, release, target, lang) {
|
||||
}
|
||||
}
|
||||
|
||||
function appendOtherTargetSection(lines, release, lang) {
|
||||
function appendOtherTargetSection(lines, release, lang, credits) {
|
||||
const isZh = lang === "zh";
|
||||
const isOtherOnly = (item) => item.targets?.length && !hasTarget(item, "desktop") && !hasTarget(item, "cli");
|
||||
const highlights = release.highlights.filter(isOtherOnly);
|
||||
@@ -305,13 +319,18 @@ function appendOtherTargetSection(lines, release, lang) {
|
||||
if (!highlights.length && !changeKinds.some((kind) => changes[kind].length)) return;
|
||||
|
||||
lines.push(`## ${isZh ? "其他项目更新" : "Other project updates"}`, "");
|
||||
if (highlights.length) lines.push(renderItems(highlights, lang), "");
|
||||
if (highlights.length) lines.push(renderItems(highlights, lang, credits), "");
|
||||
for (const kind of changeKinds) {
|
||||
if (changes[kind].length) lines.push(renderItems(changes[kind], lang), "");
|
||||
if (changes[kind].length) lines.push(renderItems(changes[kind], lang, credits), "");
|
||||
}
|
||||
}
|
||||
|
||||
export function renderGitHubRelease(release, lang = "zh") {
|
||||
export function releaseRefs(release) {
|
||||
return releaseItems(release).flatMap((item) => item.refs || []);
|
||||
}
|
||||
|
||||
// credits maps a ref to its lookup result; a ref absent from it renders plain.
|
||||
export function renderGitHubRelease(release, lang = "zh", credits = new Map()) {
|
||||
const isZh = lang === "zh";
|
||||
const isPreview = release.channel === "prerelease";
|
||||
const channelLabel = isPreview ? (isZh ? "预览版" : "Preview") : (isZh ? "稳定版" : "Stable");
|
||||
@@ -350,11 +369,11 @@ export function renderGitHubRelease(release, lang = "zh") {
|
||||
`**Desktop ${targetItemCount(release, "desktop")} · CLI ${targetItemCount(release, "cli")}**`,
|
||||
"",
|
||||
);
|
||||
appendTargetSection(lines, release, "desktop", lang);
|
||||
appendTargetSection(lines, release, "cli", lang);
|
||||
appendOtherTargetSection(lines, release, lang);
|
||||
appendTargetSection(lines, release, "desktop", lang, credits);
|
||||
appendTargetSection(lines, release, "cli", lang, credits);
|
||||
appendOtherTargetSection(lines, release, lang, credits);
|
||||
} else {
|
||||
lines.push(`## ${isZh ? "重点内容" : "Highlights"}`, "", renderItems(release.highlights, lang), "");
|
||||
lines.push(`## ${isZh ? "重点内容" : "Highlights"}`, "", renderItems(release.highlights, lang, credits), "");
|
||||
const headings = {
|
||||
new: isZh ? "新功能" : "New",
|
||||
improved: isZh ? "改进" : "Improved",
|
||||
@@ -363,31 +382,20 @@ export function renderGitHubRelease(release, lang = "zh") {
|
||||
for (const kind of changeKinds) {
|
||||
const items = release.changes[kind];
|
||||
if (!items.length) continue;
|
||||
lines.push(`## ${headings[kind]}`, "", renderItems(items, lang), "");
|
||||
lines.push(`## ${headings[kind]}`, "", renderItems(items, lang, credits), "");
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(`## ${isZh ? "升级提醒" : "Upgrade notes"}`, "");
|
||||
if (release.upgrade.length) lines.push(renderItems(release.upgrade, lang));
|
||||
if (release.upgrade.length) lines.push(renderItems(release.upgrade, lang, credits));
|
||||
else lines.push(isZh ? "本版本无需手动迁移。" : "No manual migration is required.");
|
||||
lines.push("");
|
||||
|
||||
lines.push(`## ${isZh ? "风险提示" : "Risk notes"}`, "");
|
||||
if (release.risks.length) lines.push(renderItems(release.risks, lang));
|
||||
if (release.risks.length) lines.push(renderItems(release.risks, lang, credits));
|
||||
else lines.push(isZh ? "当前没有需要额外操作的已知风险。" : "There are no known risks requiring extra action.");
|
||||
lines.push("");
|
||||
|
||||
if (release.contributors.length) {
|
||||
lines.push(
|
||||
`## ${isZh ? "致谢" : "Thanks"}`,
|
||||
"",
|
||||
`${isZh ? "感谢本版本的贡献者" : "Thanks to the contributors in this release"}:${release.contributors
|
||||
.map((name) => `[@${name}](https://github.com/${name})`)
|
||||
.join("、")}`,
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`## ${isZh ? "下载与安装" : "Download and install"}`,
|
||||
"",
|
||||
@@ -395,6 +403,18 @@ export function renderGitHubRelease(release, lang = "zh") {
|
||||
`- [${isZh ? "查看完整差异" : "Full comparison"}](${release.links.compare})`,
|
||||
"",
|
||||
);
|
||||
|
||||
const contributors = contributorLogins(releaseRefs(release), credits);
|
||||
if (contributors.length) {
|
||||
lines.push(
|
||||
`## ${isZh ? "贡献者" : "Contributors"}`,
|
||||
"",
|
||||
`${isZh ? "感谢本版本的贡献者:" : "Thanks to the contributors in this release: "}${contributors
|
||||
.map((login) => `@${login}`)
|
||||
.join(isZh ? "、" : ", ")}`,
|
||||
"",
|
||||
);
|
||||
}
|
||||
return `${lines.join("\n").trim()}\n`;
|
||||
}
|
||||
|
||||
@@ -434,14 +454,18 @@ async function main() {
|
||||
invariant(args.version, "render requires --version");
|
||||
invariant(args.output, "render requires --output");
|
||||
const release = releaseForVersion(catalog, args.version);
|
||||
const lookup = githubRefLookup({ token: tokenFromEnvironment() });
|
||||
const { credits, failures, warnings } = await resolveCredits(releaseRefs(release), lookup);
|
||||
reportWarnings(warnings);
|
||||
const output = resolve(args.output);
|
||||
await writeFile(output, renderGitHubRelease(release, args.lang === "en" ? "en" : "zh"));
|
||||
await writeFile(output, renderGitHubRelease(release, args.lang === "en" ? "en" : "zh", credits));
|
||||
if (failures.length) throw unresolvedError(failures);
|
||||
console.log(`Rendered v${release.version} release notes to ${output}`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
console.error(error.code ? `${error.code}: ${error.message}` : error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
editorialLimitInstruction,
|
||||
inferPullTargetHints,
|
||||
releaseOutputBudgets,
|
||||
} from "./generate-release-notes.mjs";
|
||||
import { loadCatalog, releaseForVersion, renderGitHubRelease, validateCatalog } from "./release-notes.mjs";
|
||||
import { loadCatalog, releaseForVersion, releaseRefs, renderGitHubRelease, validateCatalog } from "./release-notes.mjs";
|
||||
import { validateReleaseEvent } from "./release-event.mjs";
|
||||
|
||||
test("the committed release catalog is valid and newest first", async () => {
|
||||
@@ -28,11 +23,43 @@ test("GitHub rendering keeps product sections and source PR links", async () =>
|
||||
assert.match(markdown, /## 重点内容/);
|
||||
assert.match(markdown, /## 升级提醒/);
|
||||
assert.match(markdown, /## 风险提示/);
|
||||
assert.match(markdown, /## 致谢/);
|
||||
assert.match(markdown, /\/pull\/6460/);
|
||||
assert.match(markdown, /reasonix\.io\/changelog\/v1\.17\.13/);
|
||||
});
|
||||
|
||||
test("rendering credits authors and fixers and lists them once, in order", async () => {
|
||||
const catalog = await loadCatalog();
|
||||
const release = structuredClone(releaseForVersion(catalog, "1.39.1"));
|
||||
release.contributors = ["late-comer"];
|
||||
const refs = releaseRefs(release);
|
||||
const special = {
|
||||
10853: { kind: "pull", login: "github-actions[bot]", bot: true },
|
||||
10858: { kind: "issue", fixes: [{ number: 20000, login: "fixer", bot: false }] },
|
||||
10495: { kind: "issue", fixes: [] },
|
||||
};
|
||||
const credits = new Map(
|
||||
refs.map((ref) => [ref, special[ref] || { kind: "pull", login: ref % 2 ? "KHG420" : "esengine", bot: false }]),
|
||||
);
|
||||
const zh = renderGitHubRelease(release, "zh", credits);
|
||||
assert.match(zh, /\[#10763\]\([^)]+\/pull\/10763\) by @KHG420/);
|
||||
assert.match(zh, /\[#10858\]\([^)]+\/pull\/10858\) fixed in #20000 by @fixer, /);
|
||||
assert.match(zh, /\[#10495\]\([^)]+\/pull\/10495\), /);
|
||||
assert.doesNotMatch(zh, /by @github-actions/);
|
||||
assert.match(zh, /## 贡献者\n\n感谢本版本的贡献者:@KHG420、@fixer、@esengine\n$/);
|
||||
const en = renderGitHubRelease(release, "en", credits);
|
||||
assert.match(en, /## Contributors\n\nThanks to the contributors in this release: @KHG420, @fixer, @esengine\n$/);
|
||||
assert.doesNotMatch(en, /late-comer/);
|
||||
});
|
||||
|
||||
test("an unresolved ref renders as the plain link it always was", async () => {
|
||||
const catalog = await loadCatalog();
|
||||
const release = releaseForVersion(catalog, "1.39.0");
|
||||
const markdown = renderGitHubRelease(release, "zh", new Map());
|
||||
assert.doesNotMatch(markdown, / by @/);
|
||||
assert.match(markdown, /\(\[#10731\]\(https:\/\/github\.com\/esengine\/DeepSeek-Reasonix\/pull\/10731\)\)/);
|
||||
assert.doesNotMatch(markdown, /## 贡献者/);
|
||||
});
|
||||
|
||||
test("targeted releases render Desktop and CLI sections from one shared item", async () => {
|
||||
const catalog = await loadCatalog();
|
||||
const markdown = renderGitHubRelease(releaseForVersion(catalog, "1.31.4"), "zh");
|
||||
@@ -126,22 +153,6 @@ test("targeting adoption boundary preserves historical releases and rejects futu
|
||||
}
|
||||
});
|
||||
|
||||
test("release target hints prefer explicit product labels and identify shared or service paths", () => {
|
||||
assert.deepEqual(inferPullTargetHints(["desktop"], ["internal/sessioncatalog/catalog.go"]), ["desktop"]);
|
||||
assert.deepEqual(inferPullTargetHints(["desktop", "tui"], ["desktop/app.go", "internal/cli/cli.go"]), ["desktop", "cli"]);
|
||||
assert.deepEqual(inferPullTargetHints([], ["internal/agent/agent.go"]), ["desktop", "cli"]);
|
||||
assert.deepEqual(inferPullTargetHints([], ["workers/crash-report/src/index.ts"]), ["service"]);
|
||||
});
|
||||
|
||||
test("release generation uses bounded output and a tighter truncation retry", () => {
|
||||
assert.ok(releaseOutputBudgets.standard.highlights > releaseOutputBudgets.compact.highlights);
|
||||
assert.ok(releaseOutputBudgets.standard.changes > releaseOutputBudgets.compact.changes);
|
||||
assert.ok(releaseOutputBudgets.standard.bodyChars > releaseOutputBudgets.compact.bodyChars);
|
||||
assert.match(editorialLimitInstruction(), /Always return a complete JSON object/);
|
||||
assert.match(editorialLimitInstruction(true), /at most 4 highlights/);
|
||||
assert.match(editorialLimitInstruction(true), /9 total change items/);
|
||||
});
|
||||
|
||||
test("validation rejects bilingual drift", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
|
||||
@@ -158,14 +158,6 @@ for retired in release-preview.yml release-cli-trigger.yml release-desktop-trigg
|
||||
done
|
||||
npm_events="$(sed -n '/^on:/,/^permissions:/p' "$repo_root/.github/workflows/release-npm.yml")"
|
||||
! grep -Eq 'push:|npm-v\*-\*' <<<"$npm_events"
|
||||
grep -Eq '^name: Prepare release$' "$repo_root/.github/workflows/prepare-release-notes.yml"
|
||||
[ "$(grep -Fc 'git merge --no-edit origin/main-v2' "$repo_root/.github/workflows/prepare-release-notes.yml")" = "0" ]
|
||||
grep -Fq 'RELEASE_NOTES_SOURCE_SHA=$source_sha' "$repo_root/.github/workflows/prepare-release-notes.yml"
|
||||
grep -Fq -- '--to "$RELEASE_NOTES_SOURCE_SHA"' "$repo_root/.github/workflows/prepare-release-notes.yml"
|
||||
notes_dispatch="$(sed -n '/workflow_dispatch:/,/permissions:/p' "$repo_root/.github/workflows/prepare-release-notes.yml")"
|
||||
[ "$(grep -Ec '^ [a-z_]+:$' <<<"$notes_dispatch")" = "1" ]
|
||||
grep -Fq 'GitHub Actions could not open the PR; the reviewed branch is preserved.' \
|
||||
"$repo_root/.github/workflows/prepare-release-notes.yml"
|
||||
if grep -Eq '^ push:$' "$repo_root/.github/workflows/release-stable.yml" ||
|
||||
grep -Eq '^ push:$' "$repo_root/.github/workflows/release.yml" ||
|
||||
grep -Eq '^ push:$' "$repo_root/.github/workflows/release-desktop.yml"; then
|
||||
@@ -1338,21 +1330,6 @@ expect_invalid_desktop_manifest "a Preview manifest as Stable" stable "$desktop_
|
||||
expect_invalid_desktop_manifest "a non-official asset base" preview "$desktop_preview_version" \
|
||||
"https://cdn.invalid/desktop-${desktop_preview_version}/" "$desktop_preview_manifest"
|
||||
|
||||
# Release notes use one deterministic branch per official version. Failure to
|
||||
# open the PR must preserve that branch and print an exact manual handoff.
|
||||
prepare_notes="$repo_root/.github/workflows/prepare-release-notes.yml"
|
||||
generate_notes="$repo_root/scripts/generate-release-notes.mjs"
|
||||
if grep -Eq '^ (target_pr|from_tag):$' "$prepare_notes"; then
|
||||
echo "Prepare release must expose only the official version input" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq 'branch="release-notes/v${VERSION}"' "$prepare_notes"
|
||||
grep -Fq 'GitHub Actions could not open the PR; the reviewed branch is preserved.' "$prepare_notes"
|
||||
grep -Fq 'gh pr create --repo ${{ github.repository }} --base main-v2 --head $RELEASE_NOTES_BRANCH --fill' "$prepare_notes"
|
||||
grep -Eq 'GITHUB_STEP_SUMMARY' "$prepare_notes"
|
||||
grep -Fq 'node scripts/generate-release-notes.mjs --version "$VERSION" --to "$RELEASE_NOTES_SOURCE_SHA"' "$prepare_notes"
|
||||
grep -Fq 'thinking: { type: "disabled" }' "$generate_notes"
|
||||
|
||||
desktop_candidate_resolver="$repo_root/scripts/resolve-desktop-candidate.sh"
|
||||
test -x "$desktop_candidate_resolver"
|
||||
|
||||
|
||||
@@ -67,12 +67,12 @@ test "$update" = true
|
||||
stage=pages-dispatch
|
||||
request="release-${GITHUB_RUN_ID:?}-${GITHUB_RUN_ATTEMPT:?}"
|
||||
title="Deploy site v$version [$request]"
|
||||
payload="$(jq -cn --arg version "$version" --arg request "$request" '{ref:"main-v2",inputs:{release_version:$version,release_request:$request}}')"
|
||||
payload="$(jq -cn --arg version "$version" --arg request "$request" '{ref:"website",inputs:{release_version:$version,release_request:$request}}')"
|
||||
# Dispatch once. On an uncertain response, recovery observes public state again;
|
||||
# never blindly repeat this write within the same run.
|
||||
gh api -X POST "repos/$repository/actions/workflows/pages.yml/dispatches" --input - <<< "$payload"
|
||||
for _ in $(seq 1 30); do
|
||||
runs="$(gh run list --repo "$repository" --workflow pages.yml --event workflow_dispatch --branch main-v2 --limit 100 --json databaseId,displayTitle)"
|
||||
runs="$(gh run list --repo "$repository" --workflow pages.yml --event workflow_dispatch --branch website --limit 100 --json databaseId,displayTitle)"
|
||||
pages_run="$(jq -er --arg title "$title" '[.[] | select(.displayTitle == $title)] | if length == 0 then "" elif length == 1 then .[0].databaseId else error("ambiguous owned Pages deployment") end' <<< "$runs")"
|
||||
[ -z "$pages_run" ] || break
|
||||
sleep 2
|
||||
|
||||
@@ -42,7 +42,7 @@ const args=process.argv.slice(2), root=process.env.FIXTURE;
|
||||
fs.appendFileSync(path.join(root,'calls'),JSON.stringify(args)+'\\n');
|
||||
if(args[0]==='api' && args.includes('POST')) {
|
||||
const body=JSON.parse(fs.readFileSync(0,'utf8'));
|
||||
if(body.ref!=='main-v2' || body.inputs.release_version!=='1.2.3' || body.inputs.release_request!=='release-123-2') process.exit(3);
|
||||
if(body.ref!=='website' || body.inputs.release_version!=='1.2.3' || body.inputs.release_request!=='release-123-2') process.exit(3);
|
||||
fs.writeFileSync(path.join(root,'request'),body.inputs.release_request);
|
||||
} else if(args[0]==='api') console.log('2026-01-01T00:00:00Z');
|
||||
else if(args[0]==='release' && args[1]==='view') console.log(process.env.MISSING_EVENT==='true'?'0':'1');
|
||||
@@ -132,19 +132,13 @@ test("real public verifier rejects tag drift before inspecting any packages", t
|
||||
assert.equal(f.calls(), "");
|
||||
});
|
||||
|
||||
test("recovery and publication share one owner and recovery cannot enter a publisher", () => {
|
||||
const recovery = readFileSync(".github/workflows/release-site-recovery.yml", "utf8");
|
||||
test("publication delegates website deployment to the website branch", () => {
|
||||
const promote = readFileSync(".github/workflows/release-promote.yml", "utf8");
|
||||
const sync = readFileSync("scripts/sync-release-site.sh", "utf8");
|
||||
assert.match(readFileSync(".github/workflows/release-stable.yml", "utf8"), /group: stable-release-publication/);
|
||||
for (const workflow of [recovery, promote]) {
|
||||
assert.match(workflow, /group: stable-release-publication/);
|
||||
assert.match(workflow, /bash scripts\/sync-release-site.sh/);
|
||||
assert.match(workflow, /retention-days: 90/);
|
||||
assert.match(workflow, /bash scripts\/check-release-public-access.sh/);
|
||||
}
|
||||
assert.match(recovery, /RELEASE_SITE_RECOVERY_ONLY: "true"/);
|
||||
assert.doesNotMatch(recovery, /secrets: inherit|release-candidate-tags.sh activate|contents: write|uses: .*release-(desktop|npm)\.yml/);
|
||||
assert.equal((recovery.match(/environment: release/g) || []).length, 1);
|
||||
const pages = readFileSync(".github/workflows/pages.yml", "utf8");
|
||||
assert.ok(pages.indexOf("Recheck release ownership") < pages.indexOf("uses: actions/deploy-pages"));
|
||||
assert.match(promote, /group: stable-release-publication/);
|
||||
assert.match(promote, /bash scripts\/sync-release-site.sh/);
|
||||
assert.match(promote, /retention-days: 90/);
|
||||
assert.match(sync, /\{ref:"website"/);
|
||||
assert.match(sync, /--branch website/);
|
||||
});
|
||||
@@ -7,7 +7,6 @@ required=(
|
||||
.github/workflows/release-candidate.yml
|
||||
.github/workflows/release-candidate-verify.yml
|
||||
.github/workflows/release-promote.yml
|
||||
.github/workflows/release-site-recovery.yml
|
||||
.github/workflows/release-desktop.yml
|
||||
.signpath/contracts/release-signing.yml
|
||||
npm/publish-candidate.mjs
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
|
||||
// Served from the custom domain reasonix.io at the site root.
|
||||
export default defineConfig({
|
||||
site: 'https://reasonix.io',
|
||||
// Documentation code samples intentionally use source newlines as content.
|
||||
// Astro 7's JSX-style compression removes those newlines unless disabled.
|
||||
compressHTML: false,
|
||||
build: { assets: 'static' },
|
||||
integrations: [sitemap({
|
||||
filter: (page) => !/\/changelog\/(?:stable|preview)\/?$/.test(page) &&
|
||||
!/\/changelog\/v\d+\.\d+\.\d+-/.test(page) &&
|
||||
!/\/(?:login|register|forgot|reset|account|device|u)\/?$/.test(page),
|
||||
})],
|
||||
});
|
||||
Generated
-4248
File diff suppressed because it is too large.
Load diff
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "reasonix-site",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"prebuild": "node scripts/fetch-community.mjs && node scripts/fetch-publications.mjs",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"test": "node --test src/scripts/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "^3.7.4",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"astro": "^7.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "0.28.1"
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_1" data-name=" 图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #0153e5;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="cls-2" y="0" width="64" height="64" rx="20.22" ry="20.22"/>
|
||||
<path class="cls-1" d="M40.53,37.67h0c2.26-.67,4.24-1.86,5.83-3.59h0c1.73-1.94,2.82-4.29,3.2-6.87.86-5.64-1.94-10.79-7.21-12.97-1.85-.74-3.76-1.06-5.78-1.04h-19.6v37.56h9.64v-12.68c1.05.34,2.16.52,3.29.52l3.15,4.35,5.73,7.83,11.44-.02-9.7-13.1h0ZM40.14,35.94c-1.31.43-2.72.37-4.06-.02-2.3-1.74-3.55-3.6-5.52-5.37-2.52-2.27-5.84-3.84-9.25-3.69.27-.74.66-1.45,1.17-2.11,1.76-2.2,4.16-2.9,6.92-2.51,1.02.14,2.11-1.05,4.18-.82.14.02.28.12.29.2.05.28-.75.44-.75,1.21,0,.32.15.7.45.92,1.06.74,1.97,1.57,2.93,2.43.47.42,2.04,1.59,2.44.64.24-.56.39-1.14.54-1.74.08-.29-.09-.47-.32-.61-1.63-.96-2.24-2.97-1.6-4.75.06-.17.22-.25.36-.26.55-.02.25.99,1.61,1.4,1.28.4,1.32,1.46,1.94,1.04,1.45-.99,1.97-.18,3.32-1.45.14-.13.38-.14.52-.04.09.06.17.22.16.41-.04,1.07-.46,2.1-1.19,2.88-1.39,1.49-2.55.77-2.62,1.82-.22,3.06-1.22,6.11-3.39,8.33-.07.07-.11.17-.1.24.01.07.1.14.19.17l1.83.62c.26.09.45.32.41.57-.03.22-.2.41-.45.49h0Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,21 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_1" data-name=" 图层 1" xmlns="http://www.w3.org/2000/svg" width="1151.55" height="217.07" viewBox="0 0 1151.55 217.07">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #0153e5;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<circle class="cls-1" cx="972.16" cy="33.55" r="23.88"/>
|
||||
<g>
|
||||
<path class="cls-1" d="M488.72,211.37c-12.37,1.71-24.96.46-38.13.63l-1.23-16.27c-12.87,14.9-29.84,21.97-49.07,19.87-24.58-2.69-44.41-19.38-53.2-42.6-7.32-19.32-6.99-40.86.81-60.12,9.5-23.46,30.72-40.21,55.73-42.02,18.41-1.33,34.33,4.84,45.96,18.54l1.13-15.37,37.63-.22.36,137.56h.01ZM451.41,143.22c0-19.82-16.06-35.88-35.88-35.88s-35.88,16.06-35.88,35.88,16.06,35.88,35.88,35.88,35.88-16.06,35.88-35.88Z"/>
|
||||
<path class="cls-1" d="M294.69,165.71c11.08,4.99,20.6,11.87,31.28,18.31-14.54,22.26-39.3,32.78-65.14,31.91-42.29-1.42-73.12-35.5-70.7-77.31,2.1-36.29,28.66-64.56,65.17-67.92,26.31-2.42,52.01,8.19,66.02,30.99,9.52,15.5,11.55,33.39,9.76,50.92l-101,.13c1.4,14.77,13.51,25.01,25.92,27.74,14.72,3.24,29.02-.82,38.69-14.77h0ZM294.02,127.16c-4.12-16.52-17.68-24.4-32.5-24.13-13.74.25-27.5,9.07-30.5,24.11l63,.02h0Z"/>
|
||||
<path class="cls-1" d="M780.3,143.31c0,40.74-33.02,73.76-73.76,73.76s-73.76-33.02-73.76-73.76,33.02-73.76,73.76-73.76,73.76,33.02,73.76,73.76ZM742.63,143.26c0-19.97-16.19-36.16-36.16-36.16s-36.16,16.19-36.16,36.16,16.19,36.16,36.16,36.16,36.16-16.19,36.16-36.16Z"/>
|
||||
<path class="cls-1" d="M865.82,106.67c-16.4-.48-30.2,10.42-30.32,26.41l-.59,78.88-38.91-.18.06-137.83,38.15.35c.41,5.66-1.03,9.97,1.45,15.75,19.54-26.51,63.34-25.07,82.95-1.16,9.2,11.23,13.46,25.62,13.45,40.38l-.06,82.62-38.94.14-.69-79.28c-.13-14.92-10.82-25.64-26.54-26.1v.02h-.01Z"/>
|
||||
<path class="cls-1" d="M1151.55,211.51l-44.84.51-29.9-41.82-30.52,41.81-43.29-.52,50.71-70.03-48.12-67.06c16.29-1.81,30.31-.14,45.42-.02l26.06,38.81,27.14-39.29c14.85-.38,28.79-.61,44.5.69l-47.96,66.65,50.79,70.28h0Z"/>
|
||||
<path class="cls-1" d="M595.11,210.55c-30.01,11.52-78.69,6.96-94.37-27.63l29.88-14.4c4.49,1.96,5.5,6.8,9.44,9.49,16.43,11.17,43.99,9.75,44.36-4.61.12-4.72-3.46-8.89-8.58-10.29l-36.51-9.95c-11.77-3.21-22.59-9.81-28.13-21.2-10.25-21.06.12-45.53,21.63-54.96,29.01-12.72,77.53-8.27,89.7,25.13l-29.06,12.62c-10.47-12.8-27.66-16.84-42.64-10.42-3.51,1.5-5.54,6.03-5.28,8.81.35,3.7,3.39,7.71,7.59,8.86l40.46,11.09c9.77,2.68,19.02,7.82,24.29,16.77,13.52,22.96,2.31,51.05-22.78,60.69h0Z"/>
|
||||
<rect class="cls-1" x="902.92" y="123.55" width="138.32" height="38.82" transform="translate(828.4464 1114.9374) rotate(-89.96)"/>
|
||||
<path class="cls-1" d="M134.57,139.75l.02-.02c12.9-3.8,24.24-10.63,33.31-20.48v-.03c9.89-11.07,16.1-24.46,18.25-39.19,4.93-32.21-11.06-61.62-41.17-74.07C134.39,1.71,123.51-.11,111.97,0L0,.03v214.57l55.04-.04v-72.42c6,1.93,12.33,2.97,18.82,2.94l18.02,24.87,32.75,44.72,65.37-.09-55.43-74.84h0ZM132.36,129.86c-7.5,2.48-15.55,2.11-23.21-.12-13.17-9.95-20.3-20.57-31.51-30.7-14.37-12.97-33.39-21.96-52.86-21.08,1.55-4.25,3.78-8.31,6.68-12.03,10.05-12.55,23.77-16.55,39.52-14.34,5.82.82,12.03-6.02,23.86-4.7.79.09,1.6.68,1.67,1.16.27,1.61-4.3,2.49-4.3,6.9,0,1.86.83,4.01,2.59,5.24,6.03,4.23,11.26,8.99,16.72,13.9,2.66,2.39,11.66,9.1,13.95,3.64,1.34-3.19,2.22-6.53,3.11-9.93.44-1.68-.49-2.68-1.85-3.48-9.29-5.51-12.79-16.96-9.16-27.12.35-.98,1.26-1.46,2.03-1.49,3.12-.13,1.46,5.65,9.18,8.03,7.32,2.26,7.54,8.34,11.1,5.92,8.29-5.65,11.28-1.02,18.98-8.27.79-.74,2.15-.8,2.97-.22.51.35.95,1.26.91,2.33-.22,6.12-2.61,11.97-6.8,16.46-7.91,8.49-14.56,4.43-14.99,10.38-1.26,17.46-6.97,34.9-19.37,47.58-.4.41-.63.99-.57,1.38.07.41.56.82,1.1.99l10.44,3.55c1.47.5,2.55,1.83,2.35,3.24-.17,1.23-1.15,2.32-2.55,2.79h.01Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 77 KiB |
@@ -1,4 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://reasonix.io/sitemap-index.xml
|
||||
@@ -1,63 +0,0 @@
|
||||
// Prebuild: fetch live community stats + bake contributor avatars into public/av/.
|
||||
// Never fails the build — falls back to the committed snapshot on any error.
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const repo = 'esengine/DeepSeek-Reasonix';
|
||||
const api = `https://api.github.com/repos/${repo}`;
|
||||
const headers = { 'User-Agent': 'reasonix-site', Accept: 'application/vnd.github+json' };
|
||||
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
||||
|
||||
const fallback = JSON.parse(await readFile('src/data/contributors.json', 'utf8'));
|
||||
let { stars, mergedPrs, contributors, list } = fallback;
|
||||
|
||||
try {
|
||||
const r = await fetch(`${api}/contributors?per_page=100`, { headers });
|
||||
if (r.ok) {
|
||||
const humans = (await r.json())
|
||||
.filter((c) => c.type === 'User')
|
||||
.map((c) => ({ login: c.login, avatar: String(c.avatar_url).split('?')[0], url: c.html_url, c: c.contributions }));
|
||||
if (humans.length) { list = humans; contributors = humans.length; }
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const r = await fetch(api, { headers });
|
||||
if (r.ok) { const d = await r.json(); if (typeof d.stargazers_count === 'number') stars = d.stargazers_count; }
|
||||
} catch {}
|
||||
try {
|
||||
const r = await fetch(`https://api.github.com/search/issues?q=repo:${repo}+is:pr+is:merged&per_page=1`, { headers });
|
||||
if (r.ok) { const d = await r.json(); if (typeof d.total_count === 'number') mergedPrs = d.total_count; }
|
||||
} catch {}
|
||||
|
||||
// 64px is the largest rendered avatar; bake at 2x. GitHub honours ?s= for its
|
||||
// own CDN but serves some custom avatars unscaled and in whatever format was
|
||||
// uploaded, so re-encode rather than trusting the response.
|
||||
const AVATAR_PX = 128;
|
||||
|
||||
await rm('public/av', { recursive: true, force: true });
|
||||
await mkdir('public/av', { recursive: true });
|
||||
let baked = 0;
|
||||
let bakedBytes = 0;
|
||||
const out = await Promise.all(list.map(async (c) => {
|
||||
const remote = `${c.avatar}?s=${AVATAR_PX}`;
|
||||
try {
|
||||
const r = await fetch(remote);
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
const webp = await sharp(Buffer.from(await r.arrayBuffer()))
|
||||
.resize(AVATAR_PX, AVATAR_PX, { fit: 'cover' })
|
||||
.webp({ quality: 80 })
|
||||
.toBuffer();
|
||||
await writeFile(`public/av/${c.login}.webp`, webp);
|
||||
baked++;
|
||||
bakedBytes += webp.byteLength;
|
||||
return { ...c, avatar: `/av/${c.login}.webp` };
|
||||
} catch {
|
||||
return { ...c, avatar: remote };
|
||||
}
|
||||
}));
|
||||
|
||||
await writeFile('src/data/community.json', JSON.stringify({ stars, mergedPrs, contributors, list: out }, null, 2) + '\n');
|
||||
console.log(
|
||||
`community: ${contributors} contributors · ${stars} stars · ${mergedPrs} merged PRs · ` +
|
||||
`${baked}/${out.length} avatars baked (${(bakedBytes / 1024).toFixed(0)} KB)`,
|
||||
);
|
||||
@@ -1,50 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { validateReleaseEvent } from "../../scripts/release-event.mjs";
|
||||
|
||||
const siteRoot = resolve(import.meta.dirname, "..");
|
||||
const repoRoot = resolve(siteRoot, "..");
|
||||
const catalog = JSON.parse(await readFile(resolve(repoRoot, "release-notes/releases.json"), "utf8"));
|
||||
const output = resolve(siteRoot, ".generated/publications.json");
|
||||
const headers = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "reasonix-site-publications",
|
||||
};
|
||||
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
||||
|
||||
const publications = [];
|
||||
for (const release of catalog.releases) {
|
||||
// Legacy entries predate publication markers and remain public. Every new
|
||||
// reviewed entry fails closed until its exact CLI release carries the marker
|
||||
// uploaded by the all-surface postflight.
|
||||
if (!release.status || release.status === "published") {
|
||||
publications.push(release.version);
|
||||
continue;
|
||||
}
|
||||
const tag = `v${release.version}`;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/esengine/DeepSeek-Reasonix/releases/tags/${encodeURIComponent(tag)}`,
|
||||
{ headers, signal: AbortSignal.timeout(20_000) },
|
||||
);
|
||||
if (!response.ok) continue;
|
||||
const githubRelease = await response.json();
|
||||
const marker = githubRelease.assets?.find((asset) => asset.name === "release-event.json" && asset.size > 0);
|
||||
if (!marker?.browser_download_url) continue;
|
||||
const markerResponse = await fetch(marker.browser_download_url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
if (!markerResponse.ok) continue;
|
||||
const event = await markerResponse.json();
|
||||
validateReleaseEvent(event, release);
|
||||
publications.push(release.version);
|
||||
} catch {
|
||||
// A reviewed release must never become public because an upstream lookup
|
||||
// failed. The next Pages build retries after the release postflight.
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir(resolve(siteRoot, ".generated"), { recursive: true });
|
||||
await writeFile(output, `${JSON.stringify({ schemaVersion: 1, publications }, null, 2)}\n`);
|
||||
console.log(`Resolved ${publications.length} public release note(s).`);
|
||||
@@ -1,411 +0,0 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import SiteHeader from './SiteHeader.astro';
|
||||
import type { ReleaseItem, ReleaseRecord, ReleaseTarget } from '../data/releases';
|
||||
import { releasePath, releaseTargetCount } from '../data/releases';
|
||||
import '../styles/changelog.css';
|
||||
|
||||
const { release, releases, canonical } = Astro.props as {
|
||||
release: ReleaseRecord;
|
||||
releases: ReleaseRecord[];
|
||||
canonical?: string;
|
||||
};
|
||||
const base = import.meta.env.BASE_URL.replace(/\/$/, '');
|
||||
const repo = 'https://github.com/esengine/DeepSeek-Reasonix';
|
||||
const prHref = (ref: number) => `${repo}/pull/${ref}`;
|
||||
|
||||
const isPreview = release.channel === 'prerelease';
|
||||
const latestForChannel = releases[0];
|
||||
const downloadHref = isPreview ? 'https://reasonix.io/?download=desktop#start' : release.links.download;
|
||||
|
||||
const kindLabel = (kind: string) => ({
|
||||
new: { en: 'New', zh: '新增' },
|
||||
improved: { en: 'Improved', zh: '改进' },
|
||||
fixed: { en: 'Fixed', zh: '修复' },
|
||||
security: { en: 'Security', zh: '安全' },
|
||||
}[kind] ?? { en: kind, zh: kind });
|
||||
|
||||
const surfaceLabel = (surface: string) => ({
|
||||
agent: { en: 'Agent', zh: 'Agent' },
|
||||
desktop: { en: 'Desktop', zh: '桌面端' },
|
||||
cli: { en: 'CLI', zh: 'CLI' },
|
||||
mcp: { en: 'MCP', zh: 'MCP' },
|
||||
security: { en: 'Security', zh: '安全' },
|
||||
provider: { en: 'Provider', zh: '模型服务' },
|
||||
plugin: { en: 'Plugins', zh: '插件' },
|
||||
site: { en: 'Site', zh: '网站' },
|
||||
service: { en: 'Service', zh: '服务端' },
|
||||
}[surface] ?? { en: surface, zh: surface });
|
||||
|
||||
const targetingEnabled = release.targetingVersion === 1;
|
||||
const changeKinds = ['new', 'improved', 'fixed'] as const;
|
||||
const clientTargets: { id: ReleaseTarget; en: string; zh: string }[] = [
|
||||
{ id: 'desktop', en: 'Desktop updates', zh: '桌面端更新' },
|
||||
{ id: 'cli', en: 'CLI updates', zh: 'CLI 端更新' },
|
||||
];
|
||||
const hasTarget = (item: ReleaseItem, target: ReleaseTarget) => item.targets?.includes(target) ?? false;
|
||||
const clientHighlights = (target: ReleaseTarget) => release.highlights.filter((item) => hasTarget(item, target));
|
||||
const clientChanges = (target: ReleaseTarget, kind: typeof changeKinds[number]) =>
|
||||
release.changes[kind].filter((item) => hasTarget(item, target));
|
||||
const hasClientChangeContent = (target: ReleaseTarget) =>
|
||||
clientHighlights(target).length > 0 || changeKinds.some((kind) => clientChanges(target, kind).length > 0);
|
||||
const isOtherOnly = (item: ReleaseItem) =>
|
||||
Boolean(item.targets?.length) && !hasTarget(item, 'desktop') && !hasTarget(item, 'cli');
|
||||
const otherHighlights = release.highlights.filter(isOtherOnly);
|
||||
const otherChanges = Object.fromEntries(
|
||||
changeKinds.map((kind) => [kind, release.changes[kind].filter(isOtherOnly)]),
|
||||
) as Record<typeof changeKinds[number], ReleaseItem[]>;
|
||||
const hasOtherChanges = otherHighlights.length > 0 || changeKinds.some((kind) => otherChanges[kind].length > 0);
|
||||
|
||||
/* Sections render only when they have content; numbering is sequential. */
|
||||
const sectionDefs = [
|
||||
...(release.guides.length ? [{ id: 'guides', en: 'Guides', zh: '使用攻略' }] : []),
|
||||
...(targetingEnabled
|
||||
? [
|
||||
...clientTargets,
|
||||
...(hasOtherChanges ? [{ id: 'other', en: 'Other project updates', zh: '其他项目更新' }] : []),
|
||||
]
|
||||
: [
|
||||
{ id: 'highlights', en: 'Highlights', zh: '重点内容' },
|
||||
...changeKinds
|
||||
.filter((kind) => release.changes[kind].length)
|
||||
.map((kind) => ({ id: kind, ...kindLabel(kind) })),
|
||||
]),
|
||||
...(release.upgrade.length ? [{ id: 'upgrade', en: 'Upgrade notes', zh: '升级提醒' }] : []),
|
||||
...(release.risks.length ? [{ id: 'risks', en: 'Risk notes', zh: '风险提示' }] : []),
|
||||
...(release.contributors.length ? [{ id: 'thanks', en: 'Thanks', zh: '致谢' }] : []),
|
||||
];
|
||||
const sectionNum = new Map(sectionDefs.map((s, i) => [s.id, String(i + 1).padStart(2, '0')]));
|
||||
const tocItems = [...sectionDefs, { id: 'download', en: 'Download', zh: '下载与安装' }];
|
||||
|
||||
/* Version rail: group by minor, show the latest few patches per group,
|
||||
older ones behind a native <details> expander. */
|
||||
const minorKey = (v: string) => v.split('.').slice(0, 2).join('.');
|
||||
const railGroups: { key: string; items: ReleaseRecord[] }[] = [];
|
||||
for (const item of releases) {
|
||||
const key = minorKey(item.version);
|
||||
const last = railGroups[railGroups.length - 1];
|
||||
if (last && last.key === key) last.items.push(item);
|
||||
else railGroups.push({ key, items: [item] });
|
||||
}
|
||||
const RAIL_VISIBLE = 3;
|
||||
|
||||
const titleEn = `Reasonix v${release.version} release notes`;
|
||||
const titleZh = `Reasonix v${release.version} 更新日志`;
|
||||
const description = release.summary.en;
|
||||
---
|
||||
|
||||
<Base title={titleEn} titleEn={titleEn} titleZh={titleZh} description={description} canonical={canonical} noindex={isPreview}>
|
||||
<SiteHeader active="changelog" scrolled />
|
||||
|
||||
<main class="release-stage">
|
||||
<div class="release-mobile-switcher">
|
||||
<label for="release-version"><span class="l-en">Version</span><span class="l-zh">版本</span></label>
|
||||
<select id="release-version" data-release-version>
|
||||
{releases.map((item) => <option value={item.version} selected={item.version === release.version}>v{item.version}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="release-grid">
|
||||
<aside class="release-rail" aria-label="Release versions">
|
||||
<div class="release-rail__eyebrow"><span class="release-rail__mark"></span><span class="l-en">Release ledger</span><span class="l-zh">版本档案</span></div>
|
||||
<nav>
|
||||
{railGroups.map((group) => {
|
||||
const channelItems = group.items;
|
||||
if (!channelItems.length) return null;
|
||||
const visible = channelItems.slice(0, RAIL_VISIBLE);
|
||||
const overflow = channelItems.slice(RAIL_VISIBLE);
|
||||
return (
|
||||
<div class="release-rail__group">
|
||||
<p class="release-rail__minor">{group.key}.x</p>
|
||||
{visible.map((item) => (
|
||||
<a class:list={["release-version-link", { active: item.version === release.version }]} href={`${base}${releasePath(item.version)}`}>
|
||||
<span class="release-version-link__line"><strong>v{item.version}</strong>{item === latestForChannel && <em><span class="l-en">Latest</span><span class="l-zh">最新</span></em>}</span>
|
||||
<small>{item.date}</small>
|
||||
</a>
|
||||
))}
|
||||
{overflow.length > 0 && (
|
||||
<details class="release-rail__more" open={overflow.some((item) => item.version === release.version)}>
|
||||
<summary>
|
||||
<span class="when-closed"><span class="l-en">+{overflow.length} more</span><span class="l-zh">还有 {overflow.length} 个</span></span>
|
||||
<span class="when-open"><span class="l-en">Show less</span><span class="l-zh">收起</span></span>
|
||||
</summary>
|
||||
{overflow.map((item) => (
|
||||
<a class:list={["release-version-link", { active: item.version === release.version }]} href={`${base}${releasePath(item.version)}`}>
|
||||
<span class="release-version-link__line"><strong>v{item.version}</strong>{item === latestForChannel && <em><span class="l-en">Latest</span><span class="l-zh">最新</span></em>}</span>
|
||||
<small>{item.date}</small>
|
||||
</a>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<a class="release-rail__all" href={`${repo}/releases`}><span class="l-en">All GitHub releases ↗</span><span class="l-zh">全部 GitHub Releases ↗</span></a>
|
||||
</aside>
|
||||
|
||||
<article class="release-paper">
|
||||
<header class="release-hero">
|
||||
<div class="release-hero__meta">
|
||||
{isPreview && <span class="release-channel release-channel--preview"><span class="release-channel__dot"></span><span class="l-en">Historical archive</span><span class="l-zh">历史归档</span></span>}
|
||||
<time datetime={release.date}>{release.date}</time>
|
||||
</div>
|
||||
<h1>Reasonix v{release.version}</h1>
|
||||
<div class="release-surfaces">
|
||||
{release.surfaces.map((surface) => {
|
||||
const label = surfaceLabel(surface);
|
||||
return <span><span class="l-en">{label.en}</span><span class="l-zh">{label.zh}</span></span>;
|
||||
})}
|
||||
</div>
|
||||
{release.builds && (
|
||||
<div class="release-builds" aria-label="Published builds">
|
||||
<span><strong>CLI</strong> {release.builds.cli}</span>
|
||||
<span><strong>Desktop</strong> {release.builds.desktop}</span>
|
||||
<span><strong>npm</strong> {release.builds.npm}</span>
|
||||
</div>
|
||||
)}
|
||||
{targetingEnabled && (
|
||||
<div class="release-target-summary" aria-label="Updates by product">
|
||||
<span><strong>Desktop</strong> {releaseTargetCount(release, 'desktop')}</span>
|
||||
<span><strong>CLI</strong> {releaseTargetCount(release, 'cli')}</span>
|
||||
</div>
|
||||
)}
|
||||
<p class="release-lede"><span class="l-en">{release.summary.en}</span><span class="l-zh">{release.summary.zh}</span></p>
|
||||
</header>
|
||||
|
||||
{release.guides.length > 0 && (
|
||||
<section id="guides" class="release-section release-guides">
|
||||
<div class="release-section__head"><span>{sectionNum.get('guides')}</span><h2><span class="l-en">Start here</span><span class="l-zh">使用攻略</span></h2></div>
|
||||
<div class="release-guide-grid">
|
||||
{release.guides.map((guide) => (
|
||||
<a class="release-guide" href={guide.href}>
|
||||
<span class="release-guide__arrow">↗</span>
|
||||
<h3><span class="l-en">{guide.title.en}</span><span class="l-zh">{guide.title.zh}</span></h3>
|
||||
<p><span class="l-en">{guide.body.en}</span><span class="l-zh">{guide.body.zh}</span></p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{targetingEnabled ? (
|
||||
<>
|
||||
{clientTargets.map((target) => {
|
||||
const highlights = clientHighlights(target.id);
|
||||
const total = releaseTargetCount(release, target.id);
|
||||
return (
|
||||
<section id={target.id} class="release-section release-target-section" data-target={target.id}>
|
||||
<div class="release-section__head"><span>{sectionNum.get(target.id)}</span><h2><span class="l-en">{target.en}</span><span class="l-zh">{target.zh}</span></h2></div>
|
||||
<p class="release-target-section__summary">
|
||||
<span class="l-en">{total} update{total === 1 ? '' : 's'} relevant to {target.id === 'desktop' ? 'Desktop' : 'CLI'}.</span>
|
||||
<span class="l-zh">与{target.id === 'desktop' ? '桌面端' : ' CLI'}相关的更新共 {total} 项。</span>
|
||||
</p>
|
||||
|
||||
{highlights.length > 0 && (
|
||||
<div class="release-target-group">
|
||||
<h3 class="release-target-group__title"><span class="l-en">Highlights</span><span class="l-zh">重点内容</span></h3>
|
||||
<div class="release-highlight-list">
|
||||
{highlights.map((item, index) => {
|
||||
const kind = kindLabel(item.kind);
|
||||
return (
|
||||
<article class="release-highlight" data-kind={item.kind}>
|
||||
<div class="release-highlight__index">{String(index + 1).padStart(2, '0')}</div>
|
||||
<div>
|
||||
<div class="release-item-meta">
|
||||
<div class="release-highlight__kind"><span class="l-en">{kind.en}</span><span class="l-zh">{kind.zh}</span></div>
|
||||
<div class="release-item-targets">{item.targets?.map((itemTarget) => {
|
||||
const label = surfaceLabel(itemTarget);
|
||||
return <span><span class="l-en">{label.en}</span><span class="l-zh">{label.zh}</span></span>;
|
||||
})}</div>
|
||||
</div>
|
||||
<h3><span class="l-en">{item.title.en}</span><span class="l-zh">{item.title.zh}</span></h3>
|
||||
<p><span class="l-en">{item.body.en}</span><span class="l-zh">{item.body.zh}</span></p>
|
||||
{item.refs?.length && <div class="release-refs">{item.refs.map((ref) => <a href={prHref(ref)}>#{ref}</a>)}</div>}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{changeKinds.map((kind) => {
|
||||
const items = clientChanges(target.id, kind);
|
||||
if (!items.length) return null;
|
||||
const label = kindLabel(kind);
|
||||
return (
|
||||
<div class="release-target-group">
|
||||
<h3 class="release-target-group__title"><span class="l-en">{label.en}</span><span class="l-zh">{label.zh}</span></h3>
|
||||
<div class="release-change-list">
|
||||
{items.map((item: ReleaseItem) => (
|
||||
<article>
|
||||
<div class="release-item-targets">{item.targets?.map((itemTarget) => {
|
||||
const targetLabel = surfaceLabel(itemTarget);
|
||||
return <span><span class="l-en">{targetLabel.en}</span><span class="l-zh">{targetLabel.zh}</span></span>;
|
||||
})}</div>
|
||||
<h3><span class="l-en">{item.title.en}</span><span class="l-zh">{item.title.zh}</span></h3>
|
||||
<p><span class="l-en">{item.body.en}</span><span class="l-zh">{item.body.zh}</span></p>
|
||||
{item.refs?.length && <div class="release-refs">{item.refs.map((ref) => <a href={prHref(ref)}>#{ref}</a>)}</div>}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!hasClientChangeContent(target.id) && (
|
||||
<div class="release-target-empty">
|
||||
{target.id === 'cli' && total === 0 ? (
|
||||
<><strong><span class="l-en">No CLI changes in this release.</span><span class="l-zh">本版本没有 CLI 相关改动。</span></strong><p><span class="l-en">CLI users may skip this version if preferred.</span><span class="l-zh">CLI 用户可按需跳过这个版本。</span></p></>
|
||||
) : (
|
||||
<><strong><span class="l-en">No feature or fix entries in this section.</span><span class="l-zh">此板块没有功能或修复条目。</span></strong><p><span class="l-en">See the upgrade and risk notes below for relevant guidance.</span><span class="l-zh">相关说明请查看下方升级提醒和风险提示。</span></p></>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasOtherChanges && (
|
||||
<section id="other" class="release-section release-target-section" data-target="other">
|
||||
<div class="release-section__head"><span>{sectionNum.get('other')}</span><h2><span class="l-en">Other project updates</span><span class="l-zh">其他项目更新</span></h2></div>
|
||||
<p class="release-target-section__summary"><span class="l-en">Website and service changes that do not require a Desktop or CLI update.</span><span class="l-zh">不要求更新桌面端或 CLI 的网站与服务端变更。</span></p>
|
||||
<div class="release-change-list">
|
||||
{[...otherHighlights, ...changeKinds.flatMap((kind) => otherChanges[kind])].map((item) => (
|
||||
<article>
|
||||
<div class="release-item-targets">{item.targets?.map((itemTarget) => {
|
||||
const label = surfaceLabel(itemTarget);
|
||||
return <span><span class="l-en">{label.en}</span><span class="l-zh">{label.zh}</span></span>;
|
||||
})}</div>
|
||||
<h3><span class="l-en">{item.title.en}</span><span class="l-zh">{item.title.zh}</span></h3>
|
||||
<p><span class="l-en">{item.body.en}</span><span class="l-zh">{item.body.zh}</span></p>
|
||||
{item.refs?.length && <div class="release-refs">{item.refs.map((ref) => <a href={prHref(ref)}>#{ref}</a>)}</div>}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<section id="highlights" class="release-section">
|
||||
<div class="release-section__head"><span>{sectionNum.get('highlights')}</span><h2><span class="l-en">Highlights</span><span class="l-zh">重点内容</span></h2></div>
|
||||
<div class="release-highlight-list">
|
||||
{release.highlights.map((item, index) => {
|
||||
const kind = kindLabel(item.kind);
|
||||
return (
|
||||
<article class="release-highlight" data-kind={item.kind}>
|
||||
<div class="release-highlight__index">{String(index + 1).padStart(2, '0')}</div>
|
||||
<div>
|
||||
<div class="release-highlight__kind"><span class="l-en">{kind.en}</span><span class="l-zh">{kind.zh}</span></div>
|
||||
<h3><span class="l-en">{item.title.en}</span><span class="l-zh">{item.title.zh}</span></h3>
|
||||
<p><span class="l-en">{item.body.en}</span><span class="l-zh">{item.body.zh}</span></p>
|
||||
{item.refs?.length && <div class="release-refs">{item.refs.map((ref) => <a href={prHref(ref)}>#{ref}</a>)}</div>}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{changeKinds.map((kind) => {
|
||||
const items = release.changes[kind];
|
||||
if (!items.length) return null;
|
||||
const label = kindLabel(kind);
|
||||
return (
|
||||
<section id={kind} class="release-section release-change-section">
|
||||
<div class="release-section__head"><span>{sectionNum.get(kind)}</span><h2><span class="l-en">{label.en}</span><span class="l-zh">{label.zh}</span></h2></div>
|
||||
<div class="release-change-list">
|
||||
{items.map((item: ReleaseItem) => (
|
||||
<article>
|
||||
<h3><span class="l-en">{item.title.en}</span><span class="l-zh">{item.title.zh}</span></h3>
|
||||
<p><span class="l-en">{item.body.en}</span><span class="l-zh">{item.body.zh}</span></p>
|
||||
{item.refs?.length && <div class="release-refs">{item.refs.map((ref) => <a href={prHref(ref)}>#{ref}</a>)}</div>}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{release.upgrade.length > 0 && (
|
||||
<section id="upgrade" class="release-section">
|
||||
<div class="release-section__head"><span>{sectionNum.get('upgrade')}</span><h2><span class="l-en">Upgrade notes</span><span class="l-zh">升级提醒</span></h2></div>
|
||||
<div class="release-notice-list">
|
||||
{release.upgrade.map((item) => (
|
||||
<article class:list={["release-notice", `release-notice--${item.level}`]}>
|
||||
<div class="release-notice__icon">{item.level === 'warning' ? '!' : 'i'}</div>
|
||||
<div>
|
||||
{item.targets?.length && <div class="release-item-targets">{item.targets.map((itemTarget) => {
|
||||
const label = surfaceLabel(itemTarget);
|
||||
return <span><span class="l-en">{label.en}</span><span class="l-zh">{label.zh}</span></span>;
|
||||
})}</div>}
|
||||
<h3><span class="l-en">{item.title.en}</span><span class="l-zh">{item.title.zh}</span></h3>
|
||||
<p><span class="l-en">{item.body.en}</span><span class="l-zh">{item.body.zh}</span></p>
|
||||
{item.refs?.length && <div class="release-refs">{item.refs.map((ref) => <a href={prHref(ref)}>#{ref}</a>)}</div>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{release.risks.length > 0 && (
|
||||
<section id="risks" class="release-section">
|
||||
<div class="release-section__head"><span>{sectionNum.get('risks')}</span><h2><span class="l-en">Risk notes</span><span class="l-zh">风险提示</span></h2></div>
|
||||
<div class="release-change-list">
|
||||
{release.risks.map((item) => (
|
||||
<article>
|
||||
{item.targets?.length && <div class="release-item-targets">{item.targets.map((itemTarget) => {
|
||||
const label = surfaceLabel(itemTarget);
|
||||
return <span><span class="l-en">{label.en}</span><span class="l-zh">{label.zh}</span></span>;
|
||||
})}</div>}
|
||||
<h3><span class="l-en">{item.title.en}</span><span class="l-zh">{item.title.zh}</span></h3>
|
||||
<p><span class="l-en">{item.body.en}</span><span class="l-zh">{item.body.zh}</span></p>
|
||||
{item.refs?.length && <div class="release-refs">{item.refs.map((ref) => <a href={prHref(ref)}>#{ref}</a>)}</div>}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{release.contributors.length > 0 && (
|
||||
<section id="thanks" class="release-section release-thanks">
|
||||
<div class="release-section__head"><span>{sectionNum.get('thanks')}</span><h2><span class="l-en">Thanks</span><span class="l-zh">致谢</span></h2></div>
|
||||
<p><span class="l-en">This release was shaped by:</span><span class="l-zh">感谢本版本的贡献者:</span></p>
|
||||
<div>{release.contributors.map((name) => <a href={`https://github.com/${name}`}>@{name}</a>)}</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section id="download" class="release-section release-download">
|
||||
<div>
|
||||
<p class="release-download__kicker"><span class="l-en">Ready when you are</span><span class="l-zh">准备好即可升级</span></p>
|
||||
<h2><span class="l-en">Ship with a safer Reasonix.</span><span class="l-zh">升级到更可靠的 Reasonix。</span></h2>
|
||||
</div>
|
||||
<div class="release-download__actions">
|
||||
<a class="btn btn-dark" href={downloadHref}><span class="l-en">Download</span><span class="l-zh">下载</span></a>
|
||||
<a class="release-compare" href={release.links.compare}><span class="l-en">Full comparison ↗</span><span class="l-zh">完整差异 ↗</span></a>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<aside class="release-toc">
|
||||
<p><span class="l-en">In v{release.version}</span><span class="l-zh">在 v{release.version} 中</span></p>
|
||||
<nav>{tocItems.map((item) => <a href={`#${item.id}`}><span class="l-en">{item.en}</span><span class="l-zh">{item.zh}</span></a>)}</nav>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="release-footer">
|
||||
<a class="brand" href={`${base}/`}><img src={`${base}/logo.svg`} alt="" /><span>Reasonix</span></a>
|
||||
<span>© 2026 Reasonix · MIT</span>
|
||||
<a href={repo}>GitHub ↗</a>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
import '../scripts/changelog.js';
|
||||
</script>
|
||||
</Base>
|
||||
@@ -1,130 +0,0 @@
|
||||
---
|
||||
type ActiveItem = 'features' | 'surfaces' | 'skills' | 'community' | 'docs' | 'changelog';
|
||||
|
||||
const {
|
||||
active,
|
||||
scrolled = false,
|
||||
accountSlot = false,
|
||||
} = Astro.props as {
|
||||
active?: ActiveItem;
|
||||
scrolled?: boolean;
|
||||
accountSlot?: boolean;
|
||||
};
|
||||
|
||||
const base = import.meta.env.BASE_URL.replace(/\/$/, '');
|
||||
const repo = 'https://github.com/esengine/DeepSeek-Reasonix';
|
||||
const items: Array<{
|
||||
id: ActiveItem;
|
||||
href: string;
|
||||
en: string;
|
||||
zh: string;
|
||||
secondary?: boolean;
|
||||
}> = [
|
||||
{ id: 'features', href: `${base}/#features`, en: 'Features', zh: '特性' },
|
||||
{ id: 'surfaces', href: `${base}/#surfaces`, en: 'Surfaces', zh: '使用形态', secondary: true },
|
||||
{ id: 'skills', href: `${base}/skills/`, en: 'Skills', zh: '技能市场' },
|
||||
{ id: 'community', href: `${base}/#community`, en: 'Community', zh: '社区', secondary: true },
|
||||
{ id: 'docs', href: `${base}/docs/`, en: 'Docs', zh: '文档' },
|
||||
{ id: 'changelog', href: `${base}/changelog/`, en: 'Changelog', zh: '更新日志' },
|
||||
];
|
||||
---
|
||||
|
||||
<header class:list={['nav', { scrolled }]}>
|
||||
<div class="nav-inner">
|
||||
<a class="brand" href={`${base}/`}>
|
||||
<picture class="brand-picture">
|
||||
<source media="(max-width: 400px)" srcset={`${base}/favicon.svg`} />
|
||||
<img src={`${base}/logo.svg`} alt="Reasonix logo" />
|
||||
</picture>
|
||||
</a>
|
||||
<nav class="nav-links" aria-label="Primary navigation">
|
||||
{items.map((item) => (
|
||||
<a
|
||||
class:list={[{ here: active === item.id, 'nav-link--secondary': item.secondary }]}
|
||||
href={item.href}
|
||||
aria-current={active === item.id ? 'page' : undefined}
|
||||
>
|
||||
<span class="l-en">{item.en}</span>
|
||||
<span class="l-zh">{item.zh}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<a
|
||||
class="nav-github"
|
||||
href={repo}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Reasonix GitHub repository"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12 .7a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2.3c-3.3.7-4-1.4-4-1.4-.5-1.4-1.3-1.8-1.3-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.8-1.6-2.6-.3-5.4-1.3-5.4-5.9 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.6.3 2.8.1 3.1.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.4 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .7Z" />
|
||||
</svg>
|
||||
<span class="nav-github-label">GitHub</span>
|
||||
</a>
|
||||
<div class="lang-switch" role="group" aria-label="Language">
|
||||
<button type="button" data-lang="en" class="active">EN</button>
|
||||
<button type="button" data-lang="zh">中文</button>
|
||||
</div>
|
||||
<div class="theme-switch" role="group" aria-label="Theme">
|
||||
<button type="button" data-theme="system" class="active" aria-label="System theme"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></button>
|
||||
<button type="button" data-theme="light" aria-label="Light theme"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="12" y1="1" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="23"/><line x1="4.2" y1="4.2" x2="6.3" y2="6.3"/><line x1="17.7" y1="17.7" x2="19.8" y2="19.8"/><line x1="1" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="23" y2="12"/><line x1="4.2" y1="19.8" x2="6.3" y2="17.7"/><line x1="17.7" y1="6.3" x2="19.8" y2="4.2"/></svg></button>
|
||||
<button type="button" data-theme="dark" aria-label="Dark theme"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></button>
|
||||
</div>
|
||||
{accountSlot ? (
|
||||
<span class="nav-acct" data-account-slot></span>
|
||||
) : (
|
||||
<a class="btn btn-ghost nav-sign-in" href={`${base}/login/`}>
|
||||
<span class="l-en">Sign in</span>
|
||||
<span class="l-zh">登录</span>
|
||||
</a>
|
||||
)}
|
||||
<a class="btn btn-dark nav-install" href={`${base}/#start`}>
|
||||
<span class="l-en">Install</span>
|
||||
<span class="l-zh">安装</span>
|
||||
</a>
|
||||
<button
|
||||
class="nav-menu-toggle"
|
||||
type="button"
|
||||
data-mobile-nav-toggle
|
||||
aria-controls="site-mobile-nav"
|
||||
aria-expanded="false"
|
||||
aria-label="Open navigation"
|
||||
title="Open navigation"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mobile-nav" id="site-mobile-nav" data-mobile-nav hidden>
|
||||
<button class="mobile-nav-backdrop" type="button" data-mobile-nav-close aria-label="Close navigation"></button>
|
||||
<aside class="mobile-nav-panel" role="dialog" aria-modal="true" aria-labelledby="site-mobile-nav-title">
|
||||
<div class="mobile-nav-head">
|
||||
<span id="site-mobile-nav-title"><span class="l-en">Navigation</span><span class="l-zh">导航</span></span>
|
||||
<button class="mobile-nav-close" type="button" data-mobile-nav-close aria-label="Close navigation" title="Close navigation">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="mobile-nav-links" aria-label="Mobile primary navigation">
|
||||
{items.map((item) => (
|
||||
<a class:list={[{ here: active === item.id }]} href={item.href} aria-current={active === item.id ? 'page' : undefined}>
|
||||
<span class="l-en">{item.en}</span>
|
||||
<span class="l-zh">{item.zh}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<div class="mobile-nav-actions">
|
||||
<a class="mobile-nav-github" href={repo} target="_blank" rel="noreferrer">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 .7a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2.3c-3.3.7-4-1.4-4-1.4-.5-1.4-1.3-1.8-1.3-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.8-1.6-2.6-.3-5.4-1.3-5.4-5.9 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.6.3 2.8.1 3.1.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.4 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .7Z" /></svg>
|
||||
GitHub
|
||||
</a>
|
||||
{accountSlot ? (
|
||||
<span class="nav-acct" data-account-slot></span>
|
||||
) : (
|
||||
<a class="btn btn-ghost" href={`${base}/login/`}><span class="l-en">Sign in</span><span class="l-zh">登录</span></a>
|
||||
)}
|
||||
<a class="btn btn-dark" href={`${base}/#start`}><span class="l-en">Install</span><span class="l-zh">安装</span></a>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</header>
|
||||
Loaded 100 of 287 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user