mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-09 17:11:47 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
164cfef2dd | ||
|
|
2ce60e12f0 | ||
|
|
136bd5fcd1 | ||
|
|
78395619f1 | ||
|
|
4115cc702e | ||
|
|
28b3300cc5 | ||
|
|
369f55cbf5 | ||
|
|
b0b7cfa34c | ||
|
|
77eab9e8e2 | ||
|
|
f3452e3a82 | ||
|
|
a311dd464e | ||
|
|
9e32035f32 | ||
|
|
b124e2a5fa | ||
|
|
7dcfb7fa46 | ||
|
|
9a165ea6dc | ||
|
|
5a7fd42d6a | ||
|
|
7da1197efc | ||
|
|
9aad6d8b4c | ||
|
|
3da4a00263 | ||
|
|
a9cd939c1f | ||
|
|
ddf6ca6820 | ||
|
|
c2d5560aac | ||
|
|
51a3ed710c | ||
|
|
dd7625ca58 | ||
|
|
20134210be | ||
|
|
f41512ed00 | ||
|
|
df6b737fba | ||
|
|
d0572a4f5a | ||
|
|
ee72c66af6 | ||
|
|
a0df39a7a1 | ||
|
|
43dc1778d2 | ||
|
|
923370645c | ||
|
|
aad51d1970 | ||
|
|
3575809b3e | ||
|
|
b63d4151a2 | ||
|
|
9b6437fe06 | ||
|
|
9eab98f5e7 | ||
|
|
425c59b2c5 | ||
|
|
00b14555bd | ||
|
|
c92312d04d | ||
|
|
c9e9da581c | ||
|
|
2b6128ff27 | ||
|
|
2370aa5aad | ||
|
|
1a1ccbef33 | ||
|
|
7cefa9c194 | ||
|
|
522001b7be | ||
|
|
213b8fa2db | ||
|
|
8580ea084b | ||
|
|
14ed15ba5e | ||
|
|
a42067d245 | ||
|
|
de2c07c46f | ||
|
|
1ad0716a37 | ||
|
|
e6b520c39b | ||
|
|
f733345587 | ||
|
|
4f1637e05f | ||
|
|
8f7e4a0748 | ||
|
|
3770cb3856 | ||
|
|
2835c52d2b | ||
|
|
64638bccd1 | ||
|
|
ae31f7388e | ||
|
|
ade8df6c1f | ||
|
|
420718b717 | ||
|
|
4e8dc79184 | ||
|
|
97f5a265de | ||
|
|
b76822bc19 | ||
|
|
c2974487dd | ||
|
|
1d75becebc | ||
|
|
d39c0ff888 | ||
|
|
24dc82f313 | ||
|
|
b73f1174b2 | ||
|
|
d62e151fcb | ||
|
|
d5c4e196a6 | ||
|
|
e8e7af5996 | ||
|
|
7e45e66110 | ||
|
|
bf979b7831 | ||
|
|
1405c2a043 | ||
|
|
ca4fbe80aa | ||
|
|
9c3579256d | ||
|
|
5d88b179b2 | ||
|
|
1132215f4a | ||
|
|
d9ba90ed04 | ||
|
|
4a09d5546b | ||
|
|
4a5f86a0d1 | ||
|
|
555b96966c | ||
|
|
790c6be08e | ||
|
|
52f465e4f5 | ||
|
|
3d129f0d6f | ||
|
|
e66c50c76b | ||
|
|
b474d12fdd | ||
|
|
b66fdb30f6 | ||
|
|
092ab722cc | ||
|
|
390f996103 | ||
|
|
46dd766262 | ||
|
|
4cf81e89fe | ||
|
|
63582bc7c8 | ||
|
|
54c73168c3 | ||
|
|
36c1faba14 | ||
|
|
1808ff6d63 | ||
|
|
9c3123c7a3 | ||
|
|
aecf4e6960 | ||
|
|
d4e11410cb | ||
|
|
2f7809c10d | ||
|
|
d2cb0aa894 | ||
|
|
3cb73b3bf4 | ||
|
|
ec866730e0 | ||
|
|
3065db7a49 | ||
|
|
ae2e9053f5 | ||
|
|
28e37a1c0a | ||
|
|
3822d577eb | ||
|
|
88751263bf | ||
|
|
c2d10fb7e0 | ||
|
|
053136c488 | ||
|
|
49939444ff | ||
|
|
3a946c048d | ||
|
|
e30096d678 | ||
|
|
54f4898b2c | ||
|
|
03254360ee | ||
|
|
35fc8b746a | ||
|
|
befb5e258c | ||
|
|
8e9f7cfed0 | ||
|
|
96297a2146 | ||
|
|
48b340a877 | ||
|
|
d67f2835ac | ||
|
|
6512f0db40 | ||
|
|
a0ac937c65 | ||
|
|
15a1f262fb | ||
|
|
7574f8b4c9 | ||
|
|
d945284cec | ||
|
|
c55c6bee78 | ||
|
|
2a3855f16a | ||
|
|
6d0c756228 | ||
|
|
dd9b441f99 | ||
|
|
acf3bfdcbc | ||
|
|
96b296cd90 | ||
|
|
b18f84ee22 | ||
|
|
29c8349c3a | ||
|
|
51fe2e9255 | ||
|
|
8cf5b64418 | ||
|
|
463840813f | ||
|
|
c04f7dd247 | ||
|
|
a44319982f | ||
|
|
a8bea84e68 | ||
|
|
f457bbbd61 | ||
|
|
3007c94084 | ||
|
|
c09dbf56b9 | ||
|
|
26984605be | ||
|
|
85759d074c | ||
|
|
e47c32200c | ||
|
|
861a7b8d12 | ||
|
|
32107d7bb6 | ||
|
|
d5c9f2d739 | ||
|
|
1a74c7450f | ||
|
|
947ce21896 | ||
|
|
872ad88677 | ||
|
|
4512a84cbb | ||
|
|
9424d402fb | ||
|
|
2dfa1ca44c | ||
|
|
e5795e5e0f | ||
|
|
86db9b4df3 | ||
|
|
8829515d5e | ||
|
|
8b2504fb6b | ||
|
|
42503a4d36 | ||
|
|
598ecbf1b2 | ||
|
|
d730d8c8f8 |
No files matched your search
@@ -10,9 +10,7 @@ on:
|
||||
# conservative: when in doubt, run.
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'site/**'
|
||||
- 'release-notes/**'
|
||||
- 'workers/**'
|
||||
- 'benchmarks/**'
|
||||
- 'npm/**'
|
||||
- '*.md'
|
||||
|
||||
@@ -1299,7 +1299,7 @@ jobs:
|
||||
if: ${{ !cancelled() && needs.desktop-prepare.result == 'success' && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.native != 'false') }}
|
||||
runs-on: windows-latest
|
||||
# Independent runners prevent valid groups from consuming each other's
|
||||
# step deadline. Go retains its default per-package ten-minute timeout.
|
||||
# step deadline. Each group runs with go -timeout=25m (driver testArgs).
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -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 \
|
||||
@@ -1655,6 +1652,7 @@ jobs:
|
||||
scripts/verify-release-artifact-archive.test.mjs \
|
||||
scripts/build-release-cli-candidate.test.mjs scripts/publish-homebrew-cask.test.mjs \
|
||||
scripts/desktop-release-artifacts.test.mjs scripts/release-publication-ledger.test.mjs \
|
||||
scripts/release-cli-freeze.test.mjs \
|
||||
scripts/ci-paths.test.mjs scripts/ci-workflow.test.mjs scripts/macos-go-tests.test.mjs \
|
||||
scripts/desktop-windows-go-tests.test.mjs \
|
||||
npm/publish.test.mjs
|
||||
@@ -1669,14 +1667,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 +1682,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 +1693,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 +1726,6 @@ jobs:
|
||||
test "$FRONTEND_RESULT" = skipped
|
||||
fi
|
||||
|
||||
# The site/ auth client has security-sensitive redirect-validation logic
|
||||
# (safeNext) covered by node:test unit tests. Those tests use only Node
|
||||
# builtins, so no `npm install` is needed — run them directly on every PR so
|
||||
# a regression in redirect validation fails the build instead of shipping.
|
||||
# The notes_only term matches every other gated job here. Without it a
|
||||
# release-notes-only push runs this job while the root aggregate expects the
|
||||
# house-style skip, which would leave main-v2 permanently red. A pure
|
||||
# release-notes/** diff cannot touch site/, so skipping it loses no coverage.
|
||||
site:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.site != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: site
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: test
|
||||
run: npm test
|
||||
|
||||
govulncheck:
|
||||
needs: changes
|
||||
if: (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true')
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
name: Deploy accounts worker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-accounts-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: workers/accounts/pnpm-lock.yaml
|
||||
- name: Deploy
|
||||
working-directory: workers/accounts
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm test
|
||||
pnpm typecheck
|
||||
npx wrangler deploy
|
||||
- name: Sync RESEND_API_KEY to the worker
|
||||
working-directory: workers/accounts
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||
run: |
|
||||
if [ -n "$RESEND_API_KEY" ]; then
|
||||
printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY
|
||||
else
|
||||
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
|
||||
fi
|
||||
@@ -1,168 +0,0 @@
|
||||
name: Deploy crash worker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
firebase_data_action:
|
||||
description: Firebase crash history operation (migration actions do not deploy the Worker)
|
||||
required: true
|
||||
default: none
|
||||
type: choice
|
||||
options:
|
||||
- none
|
||||
- dry-run
|
||||
- apply
|
||||
- verify-only
|
||||
confirmation:
|
||||
description: Enter APPLY_FIREBASE_CRASH_DATA when selecting apply
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-crash-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.event_name == 'push' || inputs.firebase_data_action == 'none'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: workers/crash-report/package-lock.json
|
||||
- name: Validate
|
||||
working-directory: workers/crash-report
|
||||
run: |
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
- name: Apply and verify diagnostics v2 D1 migration
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:diagnostics-v2
|
||||
- name: Apply and verify Firebase crash D1 migration
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:firebase-crash
|
||||
- name: Apply dashboard performance indexes
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:dashboard-indexes
|
||||
- name: Apply registry performance indexes and install rollup
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:registry-indexes
|
||||
- name: Sync Firebase crash secrets
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
|
||||
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
|
||||
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
|
||||
run: |
|
||||
configured=0
|
||||
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
|
||||
if [ "$configured" -ne 0 ] && [ "$configured" -ne 3 ]; then
|
||||
echo "Firebase crash secrets must be either all configured or all absent."
|
||||
exit 1
|
||||
fi
|
||||
node -e 'process.stdout.write(JSON.stringify({FIREBASE_DATABASE_URL:process.env.FIREBASE_DATABASE_URL||null,FIREBASE_CLIENT_EMAIL:process.env.FIREBASE_CLIENT_EMAIL||null,FIREBASE_PRIVATE_KEY:process.env.FIREBASE_PRIVATE_KEY||null}))' | npx wrangler secret bulk
|
||||
- name: Deploy
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
npx wrangler deploy
|
||||
# Mirrors the ALERT_WEBHOOK repo secret into the worker so the ingest
|
||||
# sentinel can push alerts (see runIngestSentinel). Managed here so
|
||||
# nobody needs local Cloudflare credentials; when the repo secret is
|
||||
# unset the sentinel stays log-only.
|
||||
- name: Sync alert webhook secret
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
ALERT_WEBHOOK: ${{ secrets.ALERT_WEBHOOK }}
|
||||
run: |
|
||||
if [ -z "$ALERT_WEBHOOK" ]; then
|
||||
echo "ALERT_WEBHOOK repo secret not set; removing any existing worker secret."
|
||||
printf '{"ALERT_WEBHOOK":null}' | npx wrangler secret bulk
|
||||
exit 0
|
||||
fi
|
||||
printf '%s' "$ALERT_WEBHOOK" | npx wrangler secret put ALERT_WEBHOOK
|
||||
|
||||
migrate-firebase-data:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.firebase_data_action != 'none'
|
||||
runs-on: ubuntu-latest
|
||||
environment: canary
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- name: Require the protected production branch
|
||||
run: |
|
||||
if [ "$GITHUB_REF" != "refs/heads/main-v2" ]; then
|
||||
echo "Firebase crash data migration must run from main-v2."
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: workers/crash-report/package-lock.json
|
||||
- name: Validate
|
||||
working-directory: workers/crash-report
|
||||
run: |
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
- name: Run Firebase crash history operation
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
|
||||
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
|
||||
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
|
||||
FIREBASE_DATA_ACTION: ${{ inputs.firebase_data_action }}
|
||||
FIREBASE_DATA_CONFIRMATION: ${{ inputs.confirmation }}
|
||||
run: |
|
||||
configured=0
|
||||
[ -n "$CLOUDFLARE_API_TOKEN" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
|
||||
if [ "$configured" -ne 4 ]; then
|
||||
echo "Cloudflare and Firebase migration secrets must all be configured."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$FIREBASE_DATA_ACTION" in
|
||||
dry-run)
|
||||
npm run migrate:firebase-data
|
||||
;;
|
||||
apply)
|
||||
if [ "$FIREBASE_DATA_CONFIRMATION" != "APPLY_FIREBASE_CRASH_DATA" ]; then
|
||||
echo "Apply requires the exact confirmation APPLY_FIREBASE_CRASH_DATA."
|
||||
exit 1
|
||||
fi
|
||||
npm run migrate:firebase-data -- --apply
|
||||
npm run migrate:firebase-data -- --verify-only
|
||||
;;
|
||||
verify-only)
|
||||
npm run migrate:firebase-data -- --verify-only
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported Firebase crash data action."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,36 +0,0 @@
|
||||
name: Deploy forum worker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-forum-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: workers/forum/pnpm-lock.yaml
|
||||
- name: Deploy
|
||||
working-directory: workers/forum
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm test
|
||||
pnpm typecheck
|
||||
npx wrangler d1 execute reasonix-forum --remote --file=migrate-performance-indexes.sql
|
||||
npx wrangler deploy
|
||||
@@ -1,76 +0,0 @@
|
||||
name: Deploy site
|
||||
run-name: Deploy site ${{ inputs.release_version && format('v{0}', inputs.release_version) || github.sha }}${{ inputs.release_request && format(' [{0}]', inputs.release_request) || '' }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_version:
|
||||
description: "Optional release version that owns this deployment"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
release_request:
|
||||
description: "Release workflow correlation ID (optional)"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.ref == 'refs/heads/main-v2' && github.ref_protected
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: site/package-lock.json
|
||||
- name: Build
|
||||
working-directory: site
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
|
||||
with:
|
||||
path: site/dist
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deploy.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: inputs.release_version != ''
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
if: inputs.release_version != ''
|
||||
with:
|
||||
node-version: "22"
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
if: inputs.release_version != ''
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Recheck release ownership immediately before deployment
|
||||
if: inputs.release_version != ''
|
||||
env:
|
||||
VERSION: ${{ inputs.release_version }}
|
||||
run: test "$(bash scripts/observe-release-site.sh "$VERSION" recover)" = true
|
||||
- id: deploy
|
||||
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5
|
||||
@@ -1,106 +0,0 @@
|
||||
name: Prepare release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Official version without a tag prefix (for example 1.20.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
draft:
|
||||
name: generate reviewable bilingual draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Select the review branch
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git fetch origin main-v2
|
||||
|
||||
if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||||
echo "::error::version must be MAJOR.MINOR.PATCH"
|
||||
exit 1
|
||||
fi
|
||||
branch="release-notes/v${VERSION}"
|
||||
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
|
||||
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
|
||||
git checkout -b "$branch" --track "origin/$branch"
|
||||
source_sha="$(git merge-base origin/main-v2 HEAD)"
|
||||
else
|
||||
git checkout -b "$branch"
|
||||
source_sha="$(git rev-parse HEAD)"
|
||||
fi
|
||||
git merge-base --is-ancestor "$source_sha" origin/main-v2
|
||||
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_NOTES_SOURCE_SHA=$source_sha" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate and validate release notes
|
||||
env:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
DEEPSEEK_MODEL: deepseek-v4-pro
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The first preparation fixes the product range. Reruns update the
|
||||
# draft without silently absorbing unrelated main-v2 merges.
|
||||
node scripts/generate-release-notes.mjs --version "$VERSION" --to "$RELEASE_NOTES_SOURCE_SHA"
|
||||
node scripts/release-notes.mjs validate
|
||||
node --test scripts/release-notes.test.mjs
|
||||
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
|
||||
{
|
||||
echo "## Review draft for v${VERSION#v}"
|
||||
echo
|
||||
cat /tmp/release-notes.md
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Open or update the review PR
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add release-notes/releases.json
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "docs(release): prepare v${VERSION#v} notes" \
|
||||
-m "Summary:
|
||||
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.
|
||||
|
||||
Verification:
|
||||
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing."
|
||||
fi
|
||||
git push -u origin "$RELEASE_NOTES_BRANCH"
|
||||
if ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
|
||||
if ! gh pr create \
|
||||
--base main-v2 \
|
||||
--head "$RELEASE_NOTES_BRANCH" \
|
||||
--title "docs(release): 准备 v${VERSION#v} 中英更新日志" \
|
||||
--body-file /tmp/release-notes.md; then
|
||||
echo "::warning::GitHub Actions could not open the PR; the reviewed branch is preserved."
|
||||
{
|
||||
echo "## Manual PR handoff"
|
||||
echo
|
||||
echo '```sh'
|
||||
echo "gh pr create --repo ${{ github.repository }} --base main-v2 --head $RELEASE_NOTES_BRANCH --fill"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
fi
|
||||
@@ -194,6 +194,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -210,6 +212,7 @@ jobs:
|
||||
env:
|
||||
DOCS_BUILD_VERSION: v${{ needs.resolve.outputs.version }}
|
||||
DOCS_SOURCE_REVISION: ${{ github.sha }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
node scripts/release-notes.mjs validate
|
||||
node scripts/release-notes.mjs render --version "$DOCS_BUILD_VERSION" --output /tmp/release-notes.md
|
||||
@@ -259,6 +262,8 @@ jobs:
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/release-desktop.yml
|
||||
with:
|
||||
channel: stable
|
||||
@@ -285,6 +290,8 @@ jobs:
|
||||
attestations: write
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -338,6 +345,16 @@ jobs:
|
||||
test -s payload/evidence/windows-amd64.json
|
||||
test -s payload/evidence/windows-arm64.json
|
||||
test -s payload/evidence/macos-universal-intel.json
|
||||
# Rendered once and sealed: promotion publishes these bytes and never
|
||||
# reads GitHub again, so a later rename or outage cannot change them.
|
||||
- name: Render reviewed notes into the sealed payload
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
|
||||
git worktree add --detach "$worktree" "${{ github.sha }}"
|
||||
node "$worktree/scripts/release-notes.mjs" render --version "v${{ needs.resolve.outputs.version }}" --output payload/evidence/release-notes.md
|
||||
git worktree remove "$worktree"
|
||||
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||
with:
|
||||
subject-path: payload/**/*
|
||||
@@ -359,13 +376,6 @@ jobs:
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 90
|
||||
- name: Render reviewed notes from the immutable product
|
||||
run: |
|
||||
git show "${{ github.sha }}:release-notes/releases.json" > /tmp/releases.json
|
||||
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
|
||||
git worktree add --detach "$worktree" "${{ github.sha }}"
|
||||
node "$worktree/scripts/release-notes.mjs" render --version "v${{ needs.resolve.outputs.version }}" --output /tmp/release-notes.md
|
||||
git worktree remove "$worktree"
|
||||
- name: Seal candidate record
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
@@ -385,7 +395,7 @@ jobs:
|
||||
RELEASE_ACCEPTANCE_JSON: >-
|
||||
[{"kind":"windows-amd64","status":"passed","evidencePath":"evidence/windows-amd64.json"},{"kind":"windows-arm64","status":"passed","evidencePath":"evidence/windows-arm64.json"},{"kind":"macos-universal-intel","status":"passed","evidencePath":"evidence/macos-universal-intel.json"}]
|
||||
run: |
|
||||
RELEASE_RENDERED_NOTES_SHA256="$(sha256sum /tmp/release-notes.md | awk '{print $1}')"
|
||||
RELEASE_RENDERED_NOTES_SHA256="$(sha256sum payload/evidence/release-notes.md | awk '{print $1}')"
|
||||
RELEASE_CREATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
RELEASE_EXPIRES_AT="$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)"
|
||||
export RELEASE_RENDERED_NOTES_SHA256 RELEASE_CREATED_AT RELEASE_EXPIRES_AT
|
||||
|
||||
@@ -1084,6 +1084,8 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
# Approved orchestrators have already passed the matching GitHub environment.
|
||||
# Direct prereleases and manual Stable recovery pass release-gate above. The
|
||||
# Certum signing completes before either platform bundle reaches publication.
|
||||
@@ -1200,6 +1202,8 @@ jobs:
|
||||
# GitHub release as usual.
|
||||
- name: Render reviewed release notes
|
||||
if: ${{ !inputs.orchestrated && needs.resolve.outputs.channel != 'preview' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node scripts/release-notes.mjs render --version "${{ needs.resolve.outputs.notes_version }}" --output /tmp/release-notes.md
|
||||
|
||||
- name: Revalidate approved release ref
|
||||
@@ -1292,7 +1296,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # gh release download + compatibility manifest upload
|
||||
actions: write # dispatch pages.yml to re-bake the site version
|
||||
# Stable keeps GitHub as a fallback when R2 is unavailable. Preview has no
|
||||
# GitHub release, so it must fail closed before attempting publication.
|
||||
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.publish.result == 'success' && github.repository_owner == 'esengine' }}
|
||||
@@ -1818,11 +1821,3 @@ jobs:
|
||||
else
|
||||
echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
|
||||
fi
|
||||
|
||||
# Stable release moved R2 latest/ — rebuild the site so its build-time baked
|
||||
# version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
|
||||
- name: Refresh site to the new version
|
||||
if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel != 'preview' && needs.resolve.outputs.prerelease != 'true' && steps.mirror_r2.outputs.pointer_moved == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh workflow run pages.yml --ref main-v2
|
||||
@@ -4,16 +4,9 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "release-notes/**"
|
||||
- "scripts/generate-release-notes.mjs"
|
||||
- "scripts/release-notes*.mjs"
|
||||
- "scripts/release-credits*.mjs"
|
||||
- "scripts/release-event.mjs"
|
||||
- "site/src/pages/changelog/**"
|
||||
- "site/src/components/ChangelogPage.astro"
|
||||
- "site/src/data/releases.ts"
|
||||
- "site/src/styles/changelog.css"
|
||||
- "desktop/frontend/src/App.tsx"
|
||||
- "desktop/frontend/src/components/SettingsPanel.tsx"
|
||||
- "desktop/frontend/src/components/UpdateBanner.tsx"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -27,4 +20,4 @@ jobs:
|
||||
with:
|
||||
node-version: "22"
|
||||
- run: node scripts/release-notes.mjs validate
|
||||
- run: node --test scripts/release-notes.test.mjs
|
||||
- run: node --test scripts/release-notes.test.mjs scripts/release-credits.test.mjs
|
||||
@@ -221,6 +221,7 @@ jobs:
|
||||
if: ${{ inputs.candidate_artifact_name == '' }}
|
||||
env:
|
||||
VERSION_ARG: ${{ steps.ver.outputs.arg }}
|
||||
NPM_STABLE_DIST_TAG: ${{ vars.CLI_PUBLISH_FROZEN == 'true' && 'legacy-v1' || 'latest' }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: node npm/build.mjs "$VERSION_ARG" --publish
|
||||
- name: Publish or recover prepared npm packages
|
||||
@@ -228,10 +229,11 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ inputs.base_version }}
|
||||
APPROVED_SHA: ${{ inputs.approved_sha }}
|
||||
NPM_STABLE_DIST_TAG: ${{ vars.CLI_PUBLISH_FROZEN == 'true' && 'legacy-v1' || 'latest' }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: node npm/publish-candidate.mjs "$RUNNER_TEMP/release-candidate/npm" "$VERSION" "$APPROVED_SHA"
|
||||
- name: Align legacy aliases with the official release
|
||||
if: ${{ inputs.channel == 'stable' }}
|
||||
if: ${{ inputs.channel == 'stable' && vars.CLI_PUBLISH_FROZEN != 'true' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.base_version }}
|
||||
APPROVED_SHA: ${{ inputs.approved_sha }}
|
||||
|
||||
@@ -23,6 +23,8 @@ permissions:
|
||||
actions: write
|
||||
attestations: read
|
||||
contents: write
|
||||
issues: read # granted to the nested release workflows, which can render notes
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
@@ -146,12 +148,19 @@ jobs:
|
||||
echo "- Payload files: \`$(jq '.files | length' record/record.json)\`"
|
||||
echo "- Native acceptance: \`$(jq -r '[.acceptance[] | select(.status == "passed") | .kind] | join(", ")' record/record.json)\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Render and bind reviewed release notes
|
||||
# A candidate sealed with its notes publishes those bytes without touching
|
||||
# the network. One sealed before that renders offline from its own source.
|
||||
- name: Bind reviewed release notes
|
||||
run: |
|
||||
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
|
||||
git worktree add --detach "$worktree" "${{ steps.record.outputs.source_sha }}"
|
||||
node "$worktree/scripts/release-notes.mjs" render --version "v${{ steps.record.outputs.version }}" --output release-notes.md
|
||||
git worktree remove "$worktree"
|
||||
sealed="$(jq -r '.notes.renderedPath // empty' record/record.json)"
|
||||
if [ -n "$sealed" ]; then
|
||||
cp "payload/$sealed" release-notes.md
|
||||
else
|
||||
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
|
||||
git worktree add --detach "$worktree" "${{ steps.record.outputs.source_sha }}"
|
||||
node "$worktree/scripts/release-notes.mjs" render --version "v${{ steps.record.outputs.version }}" --output release-notes.md
|
||||
git worktree remove "$worktree"
|
||||
fi
|
||||
test "$(sha256sum release-notes.md | awk '{print $1}')" = "$(jq -r .notes.renderedSha256 record/record.json)"
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
@@ -306,7 +315,6 @@ jobs:
|
||||
if: ${{ always() && !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Require every publisher
|
||||
@@ -326,7 +334,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Reconcile public artifacts, owned Pages deployment, and durable ledger
|
||||
- name: Reconcile public artifacts, release event, and durable ledger
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
@@ -334,7 +342,8 @@ jobs:
|
||||
RELEASE_OPERATION: ${{ inputs.operation }}
|
||||
RELEASE_EXPECTED_SHA: ${{ needs.preflight.outputs.source_sha }}
|
||||
RELEASE_LEDGER_OUTPUT: /tmp/publication-ledger.json
|
||||
run: bash scripts/sync-release-site.sh
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
|
||||
run: bash scripts/reconcile-release-publication.sh
|
||||
- name: Upload publication ledger
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
name: Recover release site
|
||||
run-name: Recover site v${{ inputs.version }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Already-published stable version; no builds or package publication"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: stable-release-publication
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
if: github.repository == 'esengine/DeepSeek-Reasonix' && github.ref == 'refs/heads/main-v2' && github.ref_protected
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
source_sha: ${{ steps.identity.outputs.source_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Validate executable contracts and public access before approval
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
bash scripts/validate-release-control-plane.sh
|
||||
bash scripts/check-release-public-access.sh "$VERSION"
|
||||
- name: Bind existing public tags to protected history
|
||||
id: identity
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
|
||||
source_sha="$(git rev-parse "refs/tags/v$VERSION^{commit}")"
|
||||
git merge-base --is-ancestor "$source_sha" origin/main-v2
|
||||
bash scripts/release-candidate-tags.sh check recover "$VERSION" "$source_sha"
|
||||
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
|
||||
- name: Re-read public package identities before approval
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
RELEASE_OPERATION: recover
|
||||
RELEASE_EXPECTED_SHA: ${{ steps.identity.outputs.source_sha }}
|
||||
CLI_TAG: v${{ inputs.version }}
|
||||
DESKTOP_TAG: desktop-v${{ inputs.version }}
|
||||
RELEASE_LEDGER_OUTPUT: ${{ runner.temp }}/site-recovery-preflight.json
|
||||
run: bash scripts/verify-stable-release-artifacts.sh
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: site-recovery-preflight-${{ inputs.version }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/site-recovery-preflight.json
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
|
||||
sync:
|
||||
needs: preflight
|
||||
environment: release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Recover only the website and verify public state again
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
RELEASE_OPERATION: recover
|
||||
RELEASE_EXPECTED_SHA: ${{ needs.preflight.outputs.source_sha }}
|
||||
RELEASE_SITE_RECOVERY_ONLY: "true"
|
||||
RELEASE_LEDGER_OUTPUT: ${{ runner.temp }}/publication-ledger.json
|
||||
run: bash scripts/sync-release-site.sh
|
||||
- name: Preserve successful or incomplete recovery evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: release-site-recovery-ledger-${{ inputs.version }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/publication-ledger.json
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
@@ -55,6 +55,8 @@ concurrency:
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
@@ -63,6 +65,8 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
cli_tag: ${{ steps.release.outputs.cli_tag }}
|
||||
@@ -111,6 +115,8 @@ jobs:
|
||||
bash scripts/validate-stable-candidate.sh "$RELEASE_VERSION" "$RELEASE_SHA"
|
||||
bash scripts/verify-release-push-ci.sh "$RELEASE_SHA"
|
||||
- name: Validate reviewed release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node scripts/release-notes.mjs render --version "${{ steps.release.outputs.cli_tag }}" --output /tmp/release-notes.md
|
||||
# Recovery builds deliberately check out the immutable tagged candidate,
|
||||
# which can predate its reviewed release-note entry. Carry the exact file
|
||||
@@ -226,7 +232,6 @@ jobs:
|
||||
if: ${{ always() && !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Require every publisher to succeed
|
||||
@@ -273,6 +278,7 @@ jobs:
|
||||
CLI_TAG: ${{ needs.authorize.outputs.cli_tag }}
|
||||
DESKTOP_TAG: ${{ needs.authorize.outputs.desktop_tag }}
|
||||
DESKTOP_MANUAL_ONLY: ${{ inputs.desktop_manual_only || false }}
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
|
||||
run: bash scripts/verify-stable-release-artifacts.sh
|
||||
- name: Publish exact Stable release record
|
||||
env:
|
||||
@@ -303,7 +309,3 @@ jobs:
|
||||
else
|
||||
gh release upload "$CLI_TAG" /tmp/release-event.json
|
||||
fi
|
||||
- name: Refresh public changelog
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh workflow run pages.yml --ref main-v2
|
||||
@@ -35,5 +35,6 @@ jobs:
|
||||
RELEASE_OPERATION: recover
|
||||
CLI_TAG: v${{ inputs.version }}
|
||||
DESKTOP_TAG: desktop-v${{ inputs.version }}
|
||||
VERIFY_HOMEPAGE: "true"
|
||||
VERIFY_PUBLIC_POINTERS: "true"
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
|
||||
run: bash scripts/verify-stable-release-artifacts.sh
|
||||
@@ -212,6 +212,10 @@ jobs:
|
||||
needs: [resolve, cache-guard]
|
||||
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
# The Stable caller has already passed the single GitHub release approval.
|
||||
# Official standalone recovery passes release-gate above. This job therefore
|
||||
# must not add a second GitHub environment approval.
|
||||
@@ -274,6 +278,7 @@ jobs:
|
||||
if: ${{ !inputs.orchestrated }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.resolve.outputs.notes_version }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node scripts/release-notes.mjs render --version "$RELEASE_TAG" --output /tmp/release-notes.md
|
||||
- name: Revalidate approved release ref
|
||||
env:
|
||||
@@ -328,10 +333,10 @@ jobs:
|
||||
if: ${{ steps.publication.outputs.decision == 'publish' && inputs.candidate_artifact_name == '' }}
|
||||
with:
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
args: release --clean${{ vars.CLI_PUBLISH_FROZEN == 'true' && ' --skip=homebrew' || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_TOKEN: ${{ needs.resolve.outputs.channel == 'stable' && secrets.HOMEBREW_TAP_TOKEN || '' }}
|
||||
HOMEBREW_TAP_TOKEN: ${{ needs.resolve.outputs.channel == 'stable' && vars.CLI_PUBLISH_FROZEN != 'true' && secrets.HOMEBREW_TAP_TOKEN || '' }}
|
||||
# workflow_dispatch recovery runs have a branch-shaped GITHUB_REF even
|
||||
# though checkout is on the release tag. Pin GoReleaser explicitly, and
|
||||
# avoid ambiguity from the three release tags sharing one commit.
|
||||
@@ -354,7 +359,7 @@ jobs:
|
||||
--title "Reasonix CLI $TAG" --notes-file /tmp/release-notes.md --latest=false
|
||||
|
||||
- name: Publish prepared Homebrew cask
|
||||
if: ${{ inputs.candidate_artifact_name != '' && needs.resolve.outputs.channel == 'stable' }}
|
||||
if: ${{ inputs.candidate_artifact_name != '' && needs.resolve.outputs.channel == 'stable' && vars.CLI_PUBLISH_FROZEN != 'true' }}
|
||||
env:
|
||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
run: node release-control/scripts/publish-homebrew-cask.mjs "$RUNNER_TEMP/release-candidate/cli/reasonix.rb"
|
||||
@@ -370,6 +375,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
NOTES_TAG: ${{ needs.resolve.outputs.notes_version }}
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
@@ -473,6 +479,11 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${CLI_PUBLISH_FROZEN:-}" = "true" ]; then
|
||||
echo "CLI publication is frozen; the $channel pointer stays where it is and $TAG keeps only its immutable record"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
current_tag=""
|
||||
pointer_error="$(mktemp)"
|
||||
if aws s3 cp "s3://${R2_BUCKET}/cli/${channel}/latest.json" /tmp/cli-release.pointer.json \
|
||||
@@ -607,6 +618,7 @@ jobs:
|
||||
- name: Check npm latest dist-tag freshness
|
||||
env:
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TAG" in
|
||||
@@ -620,6 +632,10 @@ jobs:
|
||||
echo "::error::the npm-v$version tag was never pushed — the npm channel is being left behind and 'npm update -g' users will be downgraded to the old 'latest'. Push it: git tag npm-v$version ${TAG} && git push origin npm-v$version (or 'npm dist-tag add reasonix@$version latest' for an already-published version)."
|
||||
exit 1
|
||||
fi
|
||||
if [ "${CLI_PUBLISH_FROZEN:-}" = "true" ]; then
|
||||
echo "CLI publication is frozen; npm latest belongs to another line, only the npm-v$version tag is required"
|
||||
exit 0
|
||||
fi
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
got="$(npm view reasonix dist-tags.latest 2>/dev/null || true)"
|
||||
if [ -n "$got" ]; then
|
||||
|
||||
@@ -52,14 +52,16 @@ lint-cross:
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
# The desktop suite needs ~25m locally, past go's default 10m test alarm
|
||||
# (the TaggedHistory migrations alone are ~389s), so these targets pin a wider one.
|
||||
desktop-test:
|
||||
cd desktop && go test .
|
||||
cd desktop && go test -timeout=25m .
|
||||
|
||||
desktop-test-short:
|
||||
cd desktop && go test -short .
|
||||
cd desktop && go test -short -timeout=25m .
|
||||
|
||||
desktop-test-times:
|
||||
cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
|
||||
cd desktop && go test -count=1 -timeout=25m -json . | python3 ../scripts/desktop-test-times.py
|
||||
|
||||
sdk-test:
|
||||
cd sdk/go && go test ./...
|
||||
|
||||
@@ -100,8 +100,7 @@ for the latest desktop build.
|
||||
| Windows | Installer `.exe` or portable `.zip` | x64 / ARM64 |
|
||||
| Linux | `.deb` or `.tar.gz` | x64 |
|
||||
|
||||
Windows installers are code-signed through [SignPath.io](https://signpath.io/)
|
||||
with a free certificate provided by the [SignPath Foundation](https://signpath.org/).
|
||||
Windows installers are code-signed with a Certum code-signing certificate.
|
||||
|
||||
### Path C: VS Code extension
|
||||
|
||||
|
||||
+1
-2
@@ -92,8 +92,7 @@ brew install esengine/reasonix/reasonix # macOS
|
||||
| Windows | 安装器 `.exe` 或便携 `.zip` | x64 / ARM64 |
|
||||
| Linux | `.deb` 或 `.tar.gz` | x64 |
|
||||
|
||||
Windows 安装器通过 [SignPath.io](https://signpath.io/) 完成代码签名,证书由
|
||||
[SignPath 基金会](https://signpath.org/) 免费提供。
|
||||
Windows 安装器使用 Certum 代码签名证书签名。
|
||||
|
||||
### 路径 C:VS Code 扩展
|
||||
|
||||
|
||||
+53
-6
@@ -56,9 +56,9 @@ Supported boundaries include:
|
||||
- Sandbox behavior for built-in shell execution where the platform supports it.
|
||||
- Secret handling for provider keys, bot credentials, OAuth tokens, plugin
|
||||
headers, and credential-store fallback files.
|
||||
- HTTP `serve` protections for the unauthenticated local server, including
|
||||
localhost binding assumptions, JSON-only state-changing requests, and CORS
|
||||
restrictions.
|
||||
- HTTP `serve` protections, including the launch token every state-changing
|
||||
request requires when authentication is off, localhost binding assumptions,
|
||||
JSON-only state-changing requests, and CORS restrictions.
|
||||
- Desktop and bot session isolation, including per-workspace session metadata
|
||||
and configured bot allowlists.
|
||||
- Updater, install, and release verification paths.
|
||||
@@ -67,10 +67,10 @@ The following are normally treated as trusted local/operator-controlled inputs
|
||||
unless another bug lets an untrusted actor supply them:
|
||||
|
||||
- CLI arguments and text typed directly by the local user.
|
||||
- Project configuration files intentionally loaded from the current workspace.
|
||||
- The user's own configuration under the Reasonix home directory.
|
||||
- Explicit `@path` references supplied by the local user to attach local files.
|
||||
- MCP servers, language servers, hooks, and slash commands installed or enabled
|
||||
by the local user.
|
||||
- MCP servers, language servers, hooks, and slash commands installed, enabled,
|
||||
or approved by the local user.
|
||||
- Provider base URLs and model names configured by the local user.
|
||||
|
||||
The following can be security issues when reachable by an untrusted actor or
|
||||
@@ -89,6 +89,53 @@ when they bypass the intended boundary:
|
||||
- Trusting unverified update artifacts, plugin definitions, or downloaded
|
||||
binaries.
|
||||
|
||||
## Project Configuration
|
||||
|
||||
Files that arrive with a workspace are untrusted input: `reasonix.toml`,
|
||||
`.reasonix/settings.json`, `.mcp.json` and the project `.env`. Cloning a
|
||||
repository is not a decision to trust its author, so these files may narrow
|
||||
what the user configured and never widen it.
|
||||
|
||||
| Setting | What a workspace file may do |
|
||||
| --- | --- |
|
||||
| `sandbox.bash` | Move `off` to `enforce`, never back |
|
||||
| `sandbox.network` | Move `true` to `false`, never back |
|
||||
| `sandbox.forbid_read` | Add to the user's list |
|
||||
| `sandbox.allow_write`, `sandbox.workspace_root` | Apply only when they resolve inside the workspace, symlinks followed; `${VAR}` in any sandbox path expands from the process environment only |
|
||||
| `permissions.deny`, `permissions.ask` | Add to the user's rules |
|
||||
| `permissions.mode`, `permissions.allow`, `permissions.allow_dynamic_bash` | Nothing |
|
||||
| `desktop.default_tool_approval_mode` | Nothing |
|
||||
| `[network]` proxy settings, `[bot]` | Nothing |
|
||||
| `[[providers]]` | Declare them; they stay out of use until approved, and model settings naming them fall back to the user's |
|
||||
|
||||
An "always allow" answer, or extra write access, granted in a workspace is
|
||||
stored under the Reasonix home, not in the workspace.
|
||||
|
||||
Programs a workspace names for the host to run stay off until the user
|
||||
approves them for that workspace:
|
||||
|
||||
- Hooks, language server commands, `tools.search.rg_path`,
|
||||
`tools.shell.path` and `[browser]` launch settings are approved with
|
||||
`reasonix trust`, or for hooks from the desktop hooks settings.
|
||||
- An approval is stored under the Reasonix home with a digest of the
|
||||
declaration and of the workspace files it names; any change needs approval
|
||||
again. Approvals and grants belong to one workspace folder.
|
||||
- Hooks and language servers are checked against their approval again before
|
||||
each start, and refused if a file they name changed, appeared or now
|
||||
resolves elsewhere.
|
||||
- A single program path (`tools.shell.path`, `tools.search.rg_path`,
|
||||
`browser.chrome_path`) may not point into the workspace, an `allow_write`
|
||||
root, a temporary directory or a toolchain cache the bash jail leaves
|
||||
writable, and may not climb with `..`; its approval covers the program
|
||||
file's content.
|
||||
- Without an interactive frontend they stay off.
|
||||
- MCP servers a workspace declares stay off until the user enables that
|
||||
declaration for that workspace.
|
||||
|
||||
A project value that is not applied is reported as a load warning with the
|
||||
reason. A way for workspace files to widen any of the above without the
|
||||
user's approval is a security issue.
|
||||
|
||||
## `@` File References
|
||||
|
||||
Reasonix supports `@path` references so users can include local files and images
|
||||
|
||||
@@ -20,6 +20,7 @@ func writeTaskVerify(t *testing.T, taskDir, script string) {
|
||||
}
|
||||
|
||||
func TestGradeCheckpointsFindsEarliestCorrectState(t *testing.T) {
|
||||
requireRealBash(t)
|
||||
taskDir := filepath.Join(t.TempDir(), "task")
|
||||
writeTaskVerify(t, taskDir, "#!/usr/bin/env bash\ngrep -q done answer.txt\n")
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const corpusDir = "../../benchmarks/e2e"
|
||||
@@ -56,6 +58,24 @@ func gradeSeed(t *testing.T, work string) error {
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// requireRealBash skips hosts whose bash cannot actually run commands. On
|
||||
// Windows exec.LookPath("bash") happily resolves to the System32 WSL launcher,
|
||||
// so a LookPath guard passes while every grade fails inside a distro that may
|
||||
// not even carry /bin/bash. The graders are POSIX fixtures; probing mirrors
|
||||
// what sandbox's own shell resolution does before trusting a bash. Timeout
|
||||
// bounded in case the stub blocks on an install prompt.
|
||||
func requireRealBash(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("bash"); err != nil {
|
||||
t.Skip("bash unavailable; the graders are POSIX shell fixtures")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "bash", "-c", "true").Run(); err != nil {
|
||||
t.Skipf("bash cannot run commands (%v); the graders need a working POSIX shell", err)
|
||||
}
|
||||
}
|
||||
|
||||
// forbiddenArtifact names, per task, a file whose mere existence is the
|
||||
// documented way to fake that task's missing piece. Probing it keeps the
|
||||
// absence checks honest; inferring intent from a missing manifest does not,
|
||||
@@ -157,10 +177,9 @@ func TestNoSolutionCorpusGradesTheInverseContract(t *testing.T) {
|
||||
// The graders are POSIX shell and python3 fixtures, and the suite they
|
||||
// belong to only ever runs on POSIX CI. Where either is missing this
|
||||
// checks the host, not the corpus.
|
||||
for _, bin := range []string{"bash", "python3"} {
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
t.Skipf("%s unavailable; the no-solution graders need a POSIX shell and python3", bin)
|
||||
}
|
||||
requireRealBash(t)
|
||||
if _, err := exec.LookPath("python3"); err != nil {
|
||||
t.Skip("python3 unavailable; the no-solution graders need a POSIX shell and python3")
|
||||
}
|
||||
tasks, err := loadTasks(corpusDir)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
// produced. For an exploration task the grader's "want X, got Y" line is the
|
||||
// entire finding — a bare false would leave every failure indistinguishable.
|
||||
func TestGradeVerboseKeepsWhatTheGraderSaid(t *testing.T) {
|
||||
requireRealBash(t)
|
||||
taskDir := t.TempDir()
|
||||
verify := "#!/usr/bin/env bash\necho \"answer.txt normalized to 'askrigg', want 'gorsefen'\" >&2\nexit 1\n"
|
||||
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte(verify), 0o755); err != nil {
|
||||
@@ -27,6 +28,7 @@ func TestGradeVerboseKeepsWhatTheGraderSaid(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGradeVerboseStaysQuietWhenTheTaskPasses(t *testing.T) {
|
||||
requireRealBash(t)
|
||||
taskDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte("#!/usr/bin/env bash\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -190,8 +190,8 @@ func migrateWithRelaunch(installRoot, activeVersion string, relaunch bool) error
|
||||
}
|
||||
}
|
||||
shellMembers, err := installlayout.ShellMembers(installRoot, runtime.GOOS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate shell: %w", err)
|
||||
if err != nil || len(shellMembers) == 0 {
|
||||
return refuseShellMissing(installRoot, activeVersion, relaunch, err)
|
||||
}
|
||||
for _, member := range shellMembers {
|
||||
members = append(members, member)
|
||||
@@ -431,25 +431,35 @@ func ensureLauncherEntry(installRoot string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
|
||||
// readLegacyPendingUpdate returns the transaction an old file updater left for
|
||||
// this install and version, or nil when there is none.
|
||||
func readLegacyPendingUpdate(installRoot, activeVersion string) (*repair.UpdateTransaction, error) {
|
||||
tx, err := repair.ReadPendingUpdate()
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate: read legacy pending update: %w", err)
|
||||
return nil, fmt.Errorf("migrate: read legacy pending update: %w", err)
|
||||
}
|
||||
if tx.TargetKind != "file" {
|
||||
return fmt.Errorf("migrate: legacy pending update target kind %q is not supported", tx.TargetKind)
|
||||
return nil, fmt.Errorf("migrate: legacy pending update target kind %q is not supported", tx.TargetKind)
|
||||
}
|
||||
if strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(activeVersion) {
|
||||
return fmt.Errorf("migrate: pending update targets %s, not %s", tx.ToVersion, activeVersion)
|
||||
return nil, fmt.Errorf("migrate: pending update targets %s, not %s", tx.ToVersion, activeVersion)
|
||||
}
|
||||
for _, target := range append([]repair.UpdateTransactionFile{{TargetPath: tx.TargetPath}}, tx.Files...) {
|
||||
if !pathWithinInstallRoot(installRoot, target.TargetPath) {
|
||||
return fmt.Errorf("migrate: pending update target escapes install root")
|
||||
return nil, fmt.Errorf("migrate: pending update target escapes install root")
|
||||
}
|
||||
}
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
|
||||
tx, err := readLegacyPendingUpdate(installRoot, activeVersion)
|
||||
if err != nil || tx == nil {
|
||||
return err
|
||||
}
|
||||
id := repair.UpdateTransactionID(tx)
|
||||
if err := repair.MarkUpdateHealthyExact(activeVersion, tx.CreatedAt, id); err != nil {
|
||||
return fmt.Errorf("migrate: commit legacy pending update: %w", err)
|
||||
|
||||
@@ -11,6 +11,10 @@ import (
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// A legacy-updater child keeps the home its parent test chose.
|
||||
if mode := os.Getenv(legacyChildModeEnv); mode != "" {
|
||||
os.Exit(runLegacyChild(mode))
|
||||
}
|
||||
testenv.RunWithIsolatedUserState(m)
|
||||
}
|
||||
|
||||
@@ -69,6 +73,7 @@ func writeInstallerStaging(t *testing.T, root, label string, includeLauncher boo
|
||||
func TestMigrateFlatInstallToVersioned(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFlatUnit(t, root, "flat")
|
||||
writeShellTree(t, root, "flat")
|
||||
// Thin launcher entry must already exist (packaging places it). Use a
|
||||
// non-executable marker so startLauncher fails closed without hanging.
|
||||
_ = os.WriteFile(filepath.Join(root, "reasonix-launcher"), []byte("launcher"), 0o644)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/installlayout"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
// errShellMissing identifies a flat release unit whose desktop cannot start:
|
||||
// the desktop needs the Electron app/ tree beside it, and the 1.18–1.19.2 file
|
||||
// updaters publish only the three binaries they know by name.
|
||||
var errShellMissing = errors.New("migrate: flat release unit has no Electron desktop shell")
|
||||
|
||||
const migrateLogName = "legacy-migrate.log"
|
||||
|
||||
// refuseShellMissing commits nothing for an absent or incomplete shell: no
|
||||
// version directory, no current.json, no health mark and no flat cleanup.
|
||||
// Where an old updater's transaction is still pending it restores the release
|
||||
// unit that transaction replaced.
|
||||
func refuseShellMissing(installRoot, activeVersion string, relaunch bool, cause error) error {
|
||||
refusal := fmt.Errorf("%w: %s has no %s/ tree", errShellMissing, installRoot, installlayout.AppShellDirName)
|
||||
if cause != nil {
|
||||
refusal = fmt.Errorf("%w: %s/ tree is unusable: %w", errShellMissing, installlayout.AppShellDirName, cause)
|
||||
}
|
||||
result, rollbackErr := rollbackLegacyPendingUpdate(installRoot, activeVersion)
|
||||
switch {
|
||||
case rollbackErr != nil:
|
||||
refusal = fmt.Errorf("%w; restore the previous release: %w", refusal, rollbackErr)
|
||||
case result.RolledBack:
|
||||
refusal = fmt.Errorf("%w; restored %s", refusal, result.FromVersion)
|
||||
if relaunch {
|
||||
if err := startRestoredRelease(installRoot); err != nil {
|
||||
refusal = fmt.Errorf("%w; start the restored release: %w", refusal, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
logMigrateRefusal(installRoot, result.RolledBack, refusal)
|
||||
return refusal
|
||||
}
|
||||
|
||||
func rollbackLegacyPendingUpdate(installRoot, activeVersion string) (repair.UpdateRollbackResult, error) {
|
||||
// Windows cannot replace the running migrator image, and its installer
|
||||
// carries the shell into update mode, so this state is Unix-only.
|
||||
if runtime.GOOS == "windows" {
|
||||
return repair.UpdateRollbackResult{}, nil
|
||||
}
|
||||
tx, err := readLegacyPendingUpdate(installRoot, activeVersion)
|
||||
if err != nil || tx == nil {
|
||||
return repair.UpdateRollbackResult{}, err
|
||||
}
|
||||
result, err := repair.RollbackPendingUpdateExact(tx)
|
||||
if err == nil && result.MixedInstall {
|
||||
err = fmt.Errorf("rollback left a mix of two releases")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// startRestoredRelease relaunches the way the legacy updater itself does:
|
||||
// through its Guard when the restored unit has one, else the desktop directly.
|
||||
func startRestoredRelease(installRoot string) error {
|
||||
if guard := optionalRegular(filepath.Join(installRoot, "reasonix-guard")); guard != "" {
|
||||
return startDetached(installRoot, guard, "launch", "--detach")
|
||||
}
|
||||
if desktop := optionalRegular(filepath.Join(installRoot, installlayout.DesktopBinaryName())); desktop != "" {
|
||||
return startDetached(installRoot, desktop)
|
||||
}
|
||||
return fmt.Errorf("restored release has no entry point")
|
||||
}
|
||||
|
||||
func startDetached(dir, path string, args ...string) error {
|
||||
cmd := exec.Command(path, args...)
|
||||
cmd.Dir = dir
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// logMigrateRefusal persists the refusal: a migrator relaunched by an old
|
||||
// updater has no terminal, so stderr alone reaches nobody.
|
||||
func logMigrateRefusal(installRoot string, rolledBack bool, refusal error) {
|
||||
home := config.ReasonixHomeDir()
|
||||
if home == "" {
|
||||
return
|
||||
}
|
||||
path := filepath.Join(home, "desktop-shell", "logs", migrateLogName)
|
||||
if os.MkdirAll(filepath.Dir(path), 0o700) != nil {
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
_, _ = fmt.Fprintf(f, "%s event=legacy_migrate_refused code=shell_missing version=%s rolled_back=%t root=%q detail=%q\n",
|
||||
time.Now().UTC().Format(time.RFC3339), version, rolledBack, installRoot, refusal.Error())
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/installlayout"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
const (
|
||||
legacyChildModeEnv = "REASONIX_LEGACY_MIGRATOR_TEST_CHILD"
|
||||
legacyChildRootEnv = "REASONIX_LEGACY_MIGRATOR_TEST_ROOT"
|
||||
legacyFromVersion = "v1.19.2"
|
||||
legacyToVersion = "v1.39.4"
|
||||
childShellMissing = 3
|
||||
)
|
||||
|
||||
func writeShellTree(t *testing.T, root, label string) {
|
||||
t.Helper()
|
||||
for _, name := range installlayout.ShellRequiredNames(runtime.GOOS) {
|
||||
p := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(label+"-"+name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotFlat(t *testing.T, root string) map[string]string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, entry := range entries {
|
||||
if !entry.Type().IsRegular() {
|
||||
out[entry.Name()] = "<dir>"
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, entry.Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out[entry.Name()] = string(data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertNothingCommitted(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
if _, err := os.Lstat(filepath.Join(root, installlayout.CurrentFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("current.json written for a release unit without a shell: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(root, "versions")); !os.IsNotExist(err) {
|
||||
t.Fatalf("versions/ created for a release unit without a shell: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertRefusalLogged(t *testing.T, want ...string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(config.ReasonixHomeDir(), "desktop-shell", "logs", migrateLogName))
|
||||
if err != nil {
|
||||
t.Fatalf("refusal was not logged: %v", err)
|
||||
}
|
||||
for _, w := range append([]string{"code=shell_missing"}, want...) {
|
||||
if !strings.Contains(string(data), w) {
|
||||
t.Fatalf("refusal log %q lacks %q", data, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateRefusesFlatUnitWithoutShell(t *testing.T) {
|
||||
cases := map[string]func(t *testing.T, root string){
|
||||
"absent": func(*testing.T, string) {},
|
||||
"incomplete": func(t *testing.T, root string) {
|
||||
p := filepath.Join(root, installlayout.AppShellDirName, "resources", "app.asar")
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("asar"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
for name, shell := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
root := t.TempDir()
|
||||
writeFlatUnit(t, root, "new")
|
||||
if err := os.WriteFile(filepath.Join(root, "reasonix-guard"), []byte("migrator"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shell(t, root)
|
||||
before := snapshotFlat(t, root)
|
||||
|
||||
err := migrateWithRelaunch(root, legacyToVersion, false)
|
||||
if !errors.Is(err, errShellMissing) {
|
||||
t.Fatalf("migrate = %v, want errShellMissing", err)
|
||||
}
|
||||
assertNothingCommitted(t, root)
|
||||
if after := snapshotFlat(t, root); fmt.Sprint(after) != fmt.Sprint(before) {
|
||||
t.Fatalf("install root changed on refusal:\nbefore %v\nafter %v", before, after)
|
||||
}
|
||||
assertRefusalLogged(t, "rolled_back=false")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// legacyUpdatedInstall reproduces what a 1.18–1.19.2 updater leaves behind: it
|
||||
// runs the updater's own transaction steps from inside the install, publishing
|
||||
// only desktop, CLI and the migrator under the Guard name.
|
||||
func legacyUpdatedInstall(t *testing.T, withShell bool) (root string, oldDesktop []byte) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("the file-by-file legacy updater is the Unix portable path")
|
||||
}
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldDesktop, err = os.ReadFile(self)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root = t.TempDir()
|
||||
for name, body := range map[string][]byte{
|
||||
"reasonix-desktop": oldDesktop,
|
||||
"reasonix-guard": []byte("old-guard"),
|
||||
"reasonix": []byte("old-cli"),
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(root, name), body, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if withShell {
|
||||
writeShellTree(t, root, "new")
|
||||
}
|
||||
if code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-desktop"), "legacy-update", root); code != 0 {
|
||||
t.Fatalf("legacy updater child exited %d: %s", code, out)
|
||||
}
|
||||
if got, _ := os.ReadFile(filepath.Join(root, "reasonix-desktop")); string(got) != "new-desktop" {
|
||||
t.Fatalf("legacy updater did not publish the new desktop: %q", got)
|
||||
}
|
||||
return root, oldDesktop
|
||||
}
|
||||
|
||||
func runLegacyChildProcess(t *testing.T, exe, mode, root string) (int, string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(exe, "-test.run=^$")
|
||||
cmd.Env = append(os.Environ(), legacyChildModeEnv+"="+mode, legacyChildRootEnv+"="+root)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &out
|
||||
err := cmd.Run()
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
return exit.ExitCode(), out.String()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("run %s child: %v", mode, err)
|
||||
}
|
||||
return 0, out.String()
|
||||
}
|
||||
|
||||
func TestLegacyUpdaterWithoutShellRestoresPreviousRelease(t *testing.T) {
|
||||
root, oldDesktop := legacyUpdatedInstall(t, false)
|
||||
code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-guard"), "migrate", root)
|
||||
if code != childShellMissing {
|
||||
t.Fatalf("migrator child exited %d, want errShellMissing: %s", code, out)
|
||||
}
|
||||
assertNothingCommitted(t, root)
|
||||
for name, want := range map[string][]byte{
|
||||
"reasonix-desktop": oldDesktop,
|
||||
"reasonix-guard": []byte("old-guard"),
|
||||
"reasonix": []byte("old-cli"),
|
||||
} {
|
||||
if got, err := os.ReadFile(filepath.Join(root, name)); err != nil || !bytes.Equal(got, want) {
|
||||
t.Fatalf("%s was not restored to the previous release (err %v)", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(root, installlayout.LauncherBinaryName())); !os.IsNotExist(err) {
|
||||
t.Fatalf("thin launcher installed beside a restored legacy release: %v", err)
|
||||
}
|
||||
if repair.PendingUpdateExists() {
|
||||
t.Fatal("legacy transaction still pending after rollback")
|
||||
}
|
||||
assertRefusalLogged(t, "rolled_back=true")
|
||||
}
|
||||
|
||||
func TestLegacyUpdaterWithShellStillCommitsTransaction(t *testing.T) {
|
||||
root, _ := legacyUpdatedInstall(t, true)
|
||||
if code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-guard"), "migrate", root); code != 0 {
|
||||
t.Fatalf("migrator child exited %d: %s", code, out)
|
||||
}
|
||||
ptr, err := installlayout.ReadCurrent(root)
|
||||
if err != nil || ptr.ActiveVersion != legacyToVersion {
|
||||
t.Fatalf("pointer=%+v err=%v", ptr, err)
|
||||
}
|
||||
desktop, err := installlayout.ActiveDesktopPath(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := os.ReadFile(desktop); string(got) != "new-desktop" {
|
||||
t.Fatalf("active desktop = %q", got)
|
||||
}
|
||||
if !installlayout.HasActiveShell(root) {
|
||||
t.Fatal("active version lost its shell")
|
||||
}
|
||||
if repair.PendingUpdateExists() {
|
||||
t.Fatal("legacy transaction was not committed")
|
||||
}
|
||||
}
|
||||
|
||||
func runLegacyChild(mode string) int {
|
||||
root := os.Getenv(legacyChildRootEnv)
|
||||
var err error
|
||||
switch mode {
|
||||
case "legacy-update":
|
||||
err = applyLegacyUpdate(root)
|
||||
case "migrate":
|
||||
err = migrateWithRelaunch(root, legacyToVersion, false)
|
||||
if errors.Is(err, errShellMissing) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return childShellMissing
|
||||
}
|
||||
default:
|
||||
err = fmt.Errorf("unknown child mode %q", mode)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// applyLegacyUpdate follows applyLinux from v1.19.2 desktop/updater.go step
|
||||
// for step, with the running desktop as the updater.
|
||||
func applyLegacyUpdate(root string) error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
migrator, err := os.ReadFile(exe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
desktop := filepath.Join(root, "reasonix-desktop")
|
||||
guard := filepath.Join(root, "reasonix-guard")
|
||||
cli := filepath.Join(root, "reasonix")
|
||||
tx, err := repair.PrepareFileUpdate(legacyFromVersion, legacyToVersion, desktop, guard, cli)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare: %w", err)
|
||||
}
|
||||
claimed, release, err := repair.ClaimPendingFileUpdateExact(tx.ToVersion, tx.CreatedAt, repair.UpdateTransactionID(tx), desktop, []string{desktop, guard, cli}, 2*time.Minute)
|
||||
if err != nil {
|
||||
return fmt.Errorf("claim: %w", err)
|
||||
}
|
||||
defer release()
|
||||
if err := repair.MarkUpdateApplyFailedExact(claimed, "Linux update publish did not complete"); err != nil {
|
||||
return err
|
||||
}
|
||||
var receipts []repair.FileUpdateInstallReceipt
|
||||
for _, member := range []struct {
|
||||
path string
|
||||
body []byte
|
||||
}{{cli, []byte("new-cli")}, {guard, migrator}, {desktop, []byte("new-desktop")}} {
|
||||
receipt, err := repair.PublishClaimedFileUpdateMemberExact(claimed, member.path, member.body, 0o700)
|
||||
if err != nil {
|
||||
return fmt.Errorf("publish %s: %w", filepath.Base(member.path), err)
|
||||
}
|
||||
receipts = append(receipts, receipt)
|
||||
}
|
||||
if _, err := repair.RecordClaimedFileUpdateInstalled(claimed, receipts...); err != nil {
|
||||
return fmt.Errorf("record installed: %w", err)
|
||||
}
|
||||
return repair.ClearUpdateApplyFailureExact(claimed)
|
||||
}
|
||||
+2
-2
@@ -193,8 +193,8 @@ not depend on homepage badge semantics. Self-update behavior by platform:
|
||||
|
||||
### Code signing — first launch
|
||||
|
||||
- **Windows** — stable builds carry an Authenticode signature (SignPath, approved
|
||||
per release; `release-desktop.yml` verifies every payload binary through
|
||||
- **Windows** — stable builds carry an Authenticode signature (Certum certificate;
|
||||
`release-desktop.yml` verifies every payload binary through
|
||||
`scripts/verify-windows-authenticode.ps1` and fails the release otherwise). A
|
||||
brand-new version can still show SmartScreen until the signature accumulates
|
||||
reputation: *More info → Run anyway*.
|
||||
|
||||
+16
-11
@@ -4820,7 +4820,7 @@ func migrateLegacyWorkspacesIntoProjects() {
|
||||
if len(legacy) == 0 {
|
||||
return
|
||||
}
|
||||
_ = updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
_ = updateProjectsFilePreservingLegacyState(func(f *desktopProjectFile) (bool, error) {
|
||||
seen := make(map[string]bool, len(f.Projects)+len(legacy))
|
||||
for _, p := range f.Projects {
|
||||
seen[p.Root] = true
|
||||
@@ -6148,6 +6148,13 @@ func firstNonEmpty(values ...string) string {
|
||||
}
|
||||
|
||||
func (a *App) ContextUsageForTab(tabID string) ContextInfo {
|
||||
if a.isRemoteTab(tabID) {
|
||||
used, window, ok := a.remoteContextSnapshot(tabID)
|
||||
if ok {
|
||||
return ContextInfo{Used: used, Window: window}
|
||||
}
|
||||
return ContextInfo{}
|
||||
}
|
||||
read := a.captureContextRead(tabID)
|
||||
ctrl := read.ctrl
|
||||
var info ContextInfo
|
||||
@@ -7449,11 +7456,7 @@ func (a *App) mcpServersView() []ServerView {
|
||||
}
|
||||
|
||||
func mcpEntryEnabled(p config.PluginEntry, workspace string) bool {
|
||||
enabled, err := config.DefaultMCPActivationStore().IsEnabled(p, workspace)
|
||||
if err != nil {
|
||||
return p.ShouldAutoStart()
|
||||
}
|
||||
return enabled
|
||||
return config.MCPServerEnabled(p, workspace)
|
||||
}
|
||||
|
||||
func mcpRuntimeState(status string) string {
|
||||
@@ -8517,6 +8520,9 @@ func (a *App) ReconnectMCPServer(name string) error {
|
||||
if !found {
|
||||
return fmt.Errorf("no configured MCP server named %q", name)
|
||||
}
|
||||
if err := config.RecordExplicitStart(entry, root); err != nil {
|
||||
return err
|
||||
}
|
||||
controllers := a.mcpControllersSharingHost(host, name, ctrl)
|
||||
for i := range controllers {
|
||||
if controllers[i].ctrl == ctrl {
|
||||
@@ -8673,6 +8679,9 @@ func (a *App) SetMCPServerTier(name, tier string) error {
|
||||
}
|
||||
a.bumpExtensionGeneration()
|
||||
if tab != nil && ctrl != nil && !mcpConnected(ctrl, name) {
|
||||
if err := config.RecordExplicitStart(updated, root); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := ctrl.ConnectMCPServer(updated); err != nil {
|
||||
recordMCPFailure(ctrl, updated, err)
|
||||
return nil
|
||||
@@ -8707,7 +8716,7 @@ func (a *App) saveDesktopMCPServer(root string, entry config.PluginEntry) error
|
||||
if err := ensureMCPServerDirectlyWritable(root, entry.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := config.UpsertPluginInSourceForRoot(root, entry)
|
||||
_, err := config.UpsertPluginKeepingDecision(root, entry)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9871,10 +9880,6 @@ func previewMediaKind(path string) (kind string, mime string) {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func (a *App) activeWorkspaceBase() (string, error) {
|
||||
return workspaceBaseFromRoot(a.activeWorkspaceRoot())
|
||||
}
|
||||
|
||||
func (a *App) workspaceTargetForTab(tabID string) (string, control.SessionAPI, bool) {
|
||||
tabID = strings.TrimSpace(tabID)
|
||||
a.mu.RLock()
|
||||
|
||||
+42
-71
@@ -1103,6 +1103,7 @@ status_bar_items = ["cost", "balance"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
approveWorkspace(t, project)
|
||||
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if err := userCfg.SetDesktopLanguage("en"); err != nil {
|
||||
@@ -1345,54 +1346,26 @@ func TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T)
|
||||
t.Fatalf("settings provider missing from settings: %+v", got.Providers)
|
||||
}
|
||||
|
||||
func TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {
|
||||
// With no user config yet, Settings start from the defaults, not from the
|
||||
// checkout's reasonix.toml, and the first edit writes only the user's choice.
|
||||
func TestSettingsNeverSeedTheUserConfigFromTheProjectFile(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
default_model = "legacy-provider/legacy-model"
|
||||
|
||||
[desktop]
|
||||
language = "zh"
|
||||
layout_style = "workbench"
|
||||
theme = "light"
|
||||
theme_style = "glacier"
|
||||
close_behavior = "quit"
|
||||
status_bar_style = "text"
|
||||
status_bar_items = ["model", "cache", "balance"]
|
||||
`), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("[desktop]\ntheme = \"light\"\n\n[permissions]\nmode = \"allow\"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
approveWorkspace(t, project)
|
||||
t.Chdir(project)
|
||||
app := NewApp()
|
||||
got := app.Settings()
|
||||
if got.ConfigPath != config.UserConfigPath() {
|
||||
t.Fatalf("Settings configPath = %q, want user config %q", got.ConfigPath, config.UserConfigPath())
|
||||
}
|
||||
if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopLayoutStyle != "workbench" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" || got.StatusBarStyle != "icon" {
|
||||
t.Fatalf("Settings did not seed from legacy project config: %+v", got)
|
||||
}
|
||||
if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(got.StatusBarItems, want) {
|
||||
t.Fatalf("Settings did not seed status bar items from legacy project config: got %v want %v", got.StatusBarItems, want)
|
||||
}
|
||||
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("Settings() should not write user config before an edit, stat err = %v", err)
|
||||
if got := app.Settings(); got.ConfigPath != config.UserConfigPath() || got.DesktopTheme == "light" {
|
||||
t.Fatalf("Settings = path %q theme %q, want the user config's defaults", got.ConfigPath, got.DesktopTheme)
|
||||
}
|
||||
if err := app.SetDesktopLanguage("en"); err != nil {
|
||||
t.Fatalf("SetDesktopLanguage: %v", err)
|
||||
}
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if userCfg.DesktopLanguage() != "en" || userCfg.DesktopLayoutStyle() != "workbench" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" || userCfg.DesktopStatusBarStyle() != "icon" {
|
||||
t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())
|
||||
}
|
||||
if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {
|
||||
t.Fatalf("saved user config did not preserve seeded status bar items: got %v want %v", userCfg.DesktopStatusBarItems(), want)
|
||||
if userCfg.DesktopLanguage() != "en" || userCfg.DesktopTheme() == "light" || userCfg.Permissions.Mode == "allow" {
|
||||
t.Fatalf("user config took the checkout's values: theme %q mode %q", userCfg.DesktopTheme(), userCfg.Permissions.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3227,6 +3200,7 @@ api_key_env = "DEEPSEEK_API_KEY"
|
||||
if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte(projectConfig), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
approveWorkspace(t, projectRoot)
|
||||
|
||||
app := NewApp()
|
||||
tab := &WorkspaceTab{ID: "project", WorkspaceRoot: projectRoot, Ready: true}
|
||||
@@ -3954,6 +3928,7 @@ default_effort = "max"
|
||||
if err := os.WriteFile(filepath.Join(projectA, "reasonix.toml"), []byte(ownerConfig), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, projectA)
|
||||
staleConfig := `default_model = "stale/stale-model"
|
||||
[[providers]]
|
||||
name = "stale"
|
||||
@@ -3966,6 +3941,7 @@ reasoning_protocol = "none"
|
||||
if err := os.WriteFile(filepath.Join(projectB, "reasonix.toml"), []byte(staleConfig), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, projectB)
|
||||
|
||||
topicID := "topic_effort_owner"
|
||||
topicTitle := "Effort owner"
|
||||
@@ -6909,39 +6885,6 @@ func (r *appendingDesktopRunner) Run(_ context.Context, input string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[[plugins]]
|
||||
name = "playwright"
|
||||
command = "npx"
|
||||
args = ["-y", "@playwright/mcp"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer func() {
|
||||
if c := app.activeCtrl(); c != nil {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
if s.Name == "playwright" {
|
||||
if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
|
||||
t.Fatalf("default MCP view = %+v, want deferred automatic idle", s)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
|
||||
}
|
||||
|
||||
func TestCapabilitiesIncludesInstalledPlugins(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
reasonixHome := config.ReasonixHomeDir()
|
||||
@@ -7006,6 +6949,8 @@ url = %q
|
||||
`, srv.URL), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForTest(t, dir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -7093,6 +7038,7 @@ args = ["-y", "@playwright/mcp"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -7116,6 +7062,7 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -7164,6 +7111,7 @@ url = "http://127.0.0.1:1/mcp"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
host := plugin.NewHost()
|
||||
defer host.Close()
|
||||
@@ -7202,6 +7150,7 @@ url = %q
|
||||
`, srv.URL), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -7352,6 +7301,7 @@ url = %q
|
||||
`, projectServer.URL), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
host := plugin.NewHost()
|
||||
t.Cleanup(host.Close)
|
||||
@@ -7505,6 +7455,7 @@ network = true
|
||||
`, exe, singleInstanceAddr, gateConfig), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
entry := config.PluginEntry{
|
||||
Name: "h", Command: exe, Args: helperArgs,
|
||||
@@ -8323,6 +8274,7 @@ args = ["serve"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -8434,6 +8386,8 @@ command = "reasonix-missing-mcp-binary"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -8961,6 +8915,8 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -8992,6 +8948,7 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9022,6 +8979,8 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
host := plugin.NewHost()
|
||||
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
|
||||
@@ -9056,6 +9015,8 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
host := plugin.NewHost()
|
||||
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
|
||||
@@ -9115,6 +9076,7 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9192,6 +9154,7 @@ args = ["-y", "@playwright/mcp"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9230,6 +9193,8 @@ tier = "background"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9280,6 +9245,8 @@ name = "codegraph"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(desktopFakeTool{name: "mcp__codegraph__connect"})
|
||||
@@ -9338,6 +9305,8 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9431,6 +9400,8 @@ tier = "eager"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
|
||||
@@ -90,7 +90,12 @@ func TestRetryAuthenticationPublishesAuthoritativeTabRefresh(t *testing.T) {
|
||||
}
|
||||
for _, item := range app.ListTabs() {
|
||||
if item.ID == tab.ID && item.Authentication != nil && item.Authentication.Ready() {
|
||||
refreshed <- true
|
||||
// 非阻塞发送:该回调跑在共享的 asyncRuntimeEmitter.run 上,
|
||||
// 阻塞发送会把后续所有 runtime 事件永久卡死。
|
||||
select {
|
||||
case refreshed <- true:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -103,4 +108,23 @@ func TestRetryAuthenticationPublishesAuthoritativeTabRefresh(t *testing.T) {
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("retry did not publish a refresh of authoritative authentication state")
|
||||
}
|
||||
|
||||
// 回归:缓冲已满时再次触发同一事件,emitter 泵必须继续排空而不是停在
|
||||
// tabs.go 的 emit 调用点上(曾实证 chan send 卡死 7 分钟)。
|
||||
for range 3 {
|
||||
app.runtimeEvents.Emit(app.ctx, tabMetaRefreshEventChannel)
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
app.runtimeEvents.mu.Lock()
|
||||
busy := app.runtimeEvents.running
|
||||
app.runtimeEvents.mu.Unlock()
|
||||
if !busy {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("asyncRuntimeEmitter pump stayed blocked after repeated refresh events")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
+17
-2
@@ -64,6 +64,10 @@ type botBridgeHub struct {
|
||||
lastPersistSeq uint64
|
||||
|
||||
queue chan desktopBridgeNotification
|
||||
// closed 终止 run worker;queue 本身不能 close——observe 仍可能在
|
||||
// controller 事件 goroutine 上并发 enqueue,向已关闭 channel 发送会 panic。
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
type desktopPendingPrompt struct {
|
||||
@@ -135,11 +139,17 @@ func newBotBridgeHub(deps botBridgeDeps) *botBridgeHub {
|
||||
takeovers: make(map[string]bot.DesktopWatchRoute),
|
||||
takeoverTabs: make(map[string]string),
|
||||
queue: make(chan desktopBridgeNotification, botBridgeQueueSize),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
go h.run()
|
||||
return h
|
||||
}
|
||||
|
||||
// Close 停掉通知 worker goroutine。幂等;App 关停时调用。
|
||||
func (h *botBridgeHub) Close() {
|
||||
h.closeOnce.Do(func() { close(h.closed) })
|
||||
}
|
||||
|
||||
// observe 接收某个桌面会话的一条事件。在 controller 事件 goroutine 上运行,
|
||||
// 只做内存记账和入队,不做任何阻塞调用。
|
||||
func (h *botBridgeHub) observe(tabID string, e event.Event) {
|
||||
@@ -209,8 +219,13 @@ func (h *botBridgeHub) enqueue(n desktopBridgeNotification) {
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) run() {
|
||||
for n := range h.queue {
|
||||
h.deliver(n)
|
||||
for {
|
||||
select {
|
||||
case <-h.closed:
|
||||
return
|
||||
case n := <-h.queue:
|
||||
h.deliver(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,12 @@ func (a *App) newBotBridge() *botBridgeHub {
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) stopBotBridge() {
|
||||
if a.botBridge != nil {
|
||||
a.botBridge.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// bridgeSessions 枚举所有 live 会话:可见 tab 用完整 TabMeta,后台 detached
|
||||
// 会话补一份轻量快照(controller 仍存活,审批/问答仍可路由)。
|
||||
func (a *App) bridgeSessions() []bot.DesktopSessionInfo {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -129,6 +130,28 @@ func testGroupRoute() bot.DesktopWatchRoute {
|
||||
return r
|
||||
}
|
||||
|
||||
// newBotBridgeHub 启动一个 run worker;Close 必须让它退出,否则每个 App 实例
|
||||
// 都泄漏一个 goroutine(转储曾观测到上千个)。enqueue 在 Close 后仍不得 panic:
|
||||
// observe 可能在 controller 事件 goroutine 上与关停并发。
|
||||
func TestBridgeCloseStopsRunGoroutine(t *testing.T) {
|
||||
before := runtime.NumGoroutine()
|
||||
env := newBridgeTestEnv(nil)
|
||||
env.hub.SetWatch(testWatchRoute(), true)
|
||||
env.hub.Close()
|
||||
env.hub.Close() // 幂等
|
||||
// 有订阅者时 observe 会真正 enqueue;Close 后不得 panic。
|
||||
env.hub.observe("tab-1", event.Event{Kind: event.TurnDone})
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if runtime.NumGoroutine() <= before {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("hub run goroutine still alive after Close: %d goroutines > baseline %d", runtime.NumGoroutine(), before)
|
||||
}
|
||||
|
||||
func TestBridgeTakeoverRejectsGroupChat(t *testing.T) {
|
||||
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一", Ready: true}})
|
||||
if _, err := env.hub.Takeover(testGroupRoute(), "tab-1"); err == nil {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/botruntime"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/session"
|
||||
)
|
||||
|
||||
type BotRuntimeStatusView struct {
|
||||
@@ -123,7 +124,7 @@ func (a *App) refreshBotRuntime() {
|
||||
a.botBridge.seedWatchers(bridgeRoutesFromConfig(cfg.Bot.DesktopWatchers), watcherVersion)
|
||||
bridge = a.botBridge
|
||||
}
|
||||
_ = a.botRuntime.apply(a.bootContext(), cfg, globalTabWorkspaceRoot(), a.persistRemoteBotToolApprovalMode, bridge)
|
||||
_ = a.botRuntime.apply(a.bootContext(), cfg, globalTabWorkspaceRoot(), a.persistRemoteBotToolApprovalMode, bridge, a.historicalSessionService)
|
||||
}
|
||||
|
||||
func (a *App) loadDesktopBotConfig() (*config.Config, error) {
|
||||
@@ -151,7 +152,7 @@ func (a *App) BotRuntimeStatus() BotRuntimeStatusView {
|
||||
return a.botRuntime.snapshot()
|
||||
}
|
||||
|
||||
func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, workspaceRoot string, onToolApprovalModeChange func(bot.InboundMessage, string) error, bridge bot.DesktopBridge) error {
|
||||
func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, workspaceRoot string, onToolApprovalModeChange func(bot.InboundMessage, string) error, bridge bot.DesktopBridge, sessionServiceForRoot func(string) (*session.Service, error)) error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -234,6 +235,7 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
OnSessionReady: botruntime.NewSessionRemembererWithWorkspace(logger, workspaceRoot),
|
||||
OnToolApprovalModeChange: onToolApprovalModeChange,
|
||||
Desktop: bridge,
|
||||
SessionServiceForRoot: sessionServiceForRoot,
|
||||
}
|
||||
bindings := botruntime.AdapterBindings(cfg, plan.Enabled, nil, logger)
|
||||
if len(bindings) == 0 {
|
||||
|
||||
+30
-155
@@ -265,19 +265,18 @@ func TestDesktopBotRuntimeConfigLoadsAllSavedCredentialsAfterRestart(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimeMigratesLegacyProjectBotSettings(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
userCfg := config.Default()
|
||||
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
|
||||
t.Fatalf("set desktop appearance: %v", err)
|
||||
}
|
||||
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save user config: %v", err)
|
||||
}
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
// A checkout's [bot] never reaches the bot runtime or the user's config, with
|
||||
// or without a user config of its own.
|
||||
func TestDesktopBotRuntimeIgnoresProjectBotSettings(t *testing.T) {
|
||||
for _, withUser := range []bool{false, true} {
|
||||
isolateDesktopUserDirs(t)
|
||||
if withUser {
|
||||
if err := config.Default().SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save user config: %v", err)
|
||||
}
|
||||
}
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
[bot]
|
||||
enabled = true
|
||||
|
||||
@@ -289,154 +288,30 @@ feishu_users = ["ou-legacy"]
|
||||
id = "feishu-lark"
|
||||
provider = "feishu"
|
||||
domain = "lark"
|
||||
label = "Lark"
|
||||
enabled = true
|
||||
status = "connected"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
|
||||
}
|
||||
|
||||
// The bot-runtime load is a pure read: the merge above stays in memory and
|
||||
// the user config file is not rewritten.
|
||||
preWrite := config.LoadForEdit(config.UserConfigPath())
|
||||
if preWrite.Bot.Enabled || len(preWrite.Bot.Connections) != 0 {
|
||||
t.Fatalf("read path persisted bot config = %+v, want disk untouched until a locked write", preWrite.Bot)
|
||||
}
|
||||
|
||||
// The first locked write path performs the on-disk migration.
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
|
||||
}
|
||||
if persisted.DesktopTheme() != "dark" {
|
||||
t.Fatalf("desktop theme = %q, want preserved user preference", persisted.DesktopTheme())
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
t.Chdir(project)
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
if got.Bot.Enabled || len(got.Bot.Connections) != 0 {
|
||||
t.Fatalf("desktop bot config = %+v, want the checkout's [bot] ignored", got.Bot)
|
||||
}
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
if persisted := config.LoadForEdit(config.UserConfigPath()); persisted.Bot.Enabled || len(persisted.Bot.Connections) != 0 {
|
||||
t.Fatalf("user config took the checkout's [bot]: %+v", persisted.Bot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimePersistsLegacyProjectBotWhenUserConfigMissing(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
[desktop]
|
||||
theme = "dark"
|
||||
|
||||
[bot]
|
||||
enabled = true
|
||||
|
||||
[bot.allowlist]
|
||||
enabled = true
|
||||
feishu_users = ["ou-legacy"]
|
||||
|
||||
[[bot.connections]]
|
||||
id = "feishu-lark"
|
||||
provider = "feishu"
|
||||
domain = "lark"
|
||||
label = "Lark"
|
||||
enabled = true
|
||||
status = "connected"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
|
||||
}
|
||||
|
||||
// The bot-runtime load is a pure read: it serves the legacy config from
|
||||
// memory and must not create the user config file.
|
||||
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("read path must not create the user config, stat err = %v", err)
|
||||
}
|
||||
|
||||
// The first locked write path creates the user config with the migrated
|
||||
// bot settings (adopting the legacy config, ConfigVersion-bumped).
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
[desktop]
|
||||
theme = "dark"
|
||||
close_behavior = "quit"
|
||||
|
||||
[bot]
|
||||
enabled = true
|
||||
|
||||
[bot.allowlist]
|
||||
enabled = true
|
||||
feishu_users = ["ou-legacy"]
|
||||
|
||||
[[bot.connections]]
|
||||
id = "feishu-lark"
|
||||
provider = "feishu"
|
||||
domain = "lark"
|
||||
label = "Lark"
|
||||
enabled = true
|
||||
status = "connected"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
settings := NewApp().Settings()
|
||||
if !settings.Bot.Enabled || len(settings.Bot.Connections) != 1 || settings.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("settings bot = %+v, want migrated legacy Lark connection", settings.Bot)
|
||||
}
|
||||
if settings.DesktopTheme != "dark" || settings.CloseBehavior != "quit" {
|
||||
t.Fatalf("settings desktop prefs = theme:%q close:%q, want legacy seed visible before first edit", settings.DesktopTheme, settings.CloseBehavior)
|
||||
}
|
||||
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if persisted.DesktopTheme() == "dark" || persisted.DesktopCloseBehavior() == "quit" {
|
||||
t.Fatalf("persisted desktop prefs = theme:%q close:%q, want bot-only migration", persisted.DesktopTheme(), persisted.DesktopCloseBehavior())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimeMigrationDoesNotOverwriteUserBotSettings(t *testing.T) {
|
||||
func TestDesktopBotRuntimeKeepsUserBotSettingsBesideAProjectBot(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
userCfg := config.Default()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/session"
|
||||
)
|
||||
|
||||
const embeddedBotSessionPrefix = "bot-session:local:"
|
||||
|
||||
// Embedded bot conversations keep their existing project-local store. Resolve
|
||||
// the sidebar's stable session reference there before creating a surface; a
|
||||
// forged reference must not open a session belonging to another workspace.
|
||||
func (a *App) embeddedBotSessionPath(scope, workspaceRoot, route string) (string, error) {
|
||||
id := strings.TrimPrefix(route, embeddedBotSessionPrefix)
|
||||
if err := session.ValidateSessionID(id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
root := globalTabWorkspaceRoot()
|
||||
if scope == "project" {
|
||||
root = normalizeProjectRoot(workspaceRoot)
|
||||
if root == "" {
|
||||
return "", fmt.Errorf("workspaceRoot is required")
|
||||
}
|
||||
}
|
||||
storeRoot := session.RootForLegacyDir(config.ProjectSessionDir(root))
|
||||
service, err := a.historicalSessionService(storeRoot)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := service.Query().Stat(a.bootContext(), session.SessionRef{HostID: localDesktopHostID, SessionID: id})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
same, err := sameDesktopPathStrict(info.CWD, root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !same {
|
||||
return "", errSessionWorkspaceConflict
|
||||
}
|
||||
path := filepath.Join(storeRoot, id)
|
||||
if !nativeStoredDirectory(path) {
|
||||
return "", session.ErrSessionNotFound
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -46,7 +46,11 @@ type DeliveryWorktreeOpenResult = IsolatedWorktreeOpenResult
|
||||
// optional Git isolation path. A false result never disables writing itself;
|
||||
// the cross-platform workspace writer lease remains the no-Git fallback.
|
||||
func (a *App) IsolatedWorktreeAvailability(workspaceRoot string) worktree.Availability {
|
||||
return inspectDeliveryWorktree(a.bootContext(), workspaceRoot)
|
||||
repo, err := a.workspaceRepoForRoot(workspaceRoot)
|
||||
if err != nil {
|
||||
return worktree.Availability{Reason: err.Error()}
|
||||
}
|
||||
return inspectDeliveryWorktree(a.bootContext(), repo)
|
||||
}
|
||||
|
||||
// CreateIsolatedWorktree creates a durable branch-backed worktree and opens it
|
||||
@@ -61,7 +65,11 @@ func (a *App) CreateIsolatedWorktree(workspaceRoot string) (IsolatedWorktreeOpen
|
||||
return worktree.Result{}, err
|
||||
}
|
||||
defer releaseAdmission()
|
||||
return createDeliveryWorktree(a.bootContext(), workspaceRoot, config.DeliveryWorktreeDir())
|
||||
repo, err := a.workspaceRepoForRoot(workspaceRoot)
|
||||
if err != nil {
|
||||
return worktree.Result{}, err
|
||||
}
|
||||
return createDeliveryWorktree(a.bootContext(), repo, config.DeliveryWorktreeDir())
|
||||
}()
|
||||
if err != nil {
|
||||
return IsolatedWorktreeOpenResult{}, err
|
||||
|
||||
@@ -7,13 +7,15 @@ import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/gitcmd"
|
||||
"reasonix/internal/worktree"
|
||||
)
|
||||
|
||||
func TestDeliveryWorktreeAvailabilityDelegatesWithoutRequiringGit(t *testing.T) {
|
||||
original := inspectDeliveryWorktree
|
||||
t.Cleanup(func() { inspectDeliveryWorktree = original })
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := filepath.Base(repo.Dir)
|
||||
return worktree.Availability{Available: false, Reason: "Git is not installed", RepoRoot: root}
|
||||
}
|
||||
got := NewApp().DeliveryWorktreeAvailability("project")
|
||||
@@ -35,7 +37,8 @@ func TestCreateDeliveryWorktreeRegistersAndOpensManagedProject(t *testing.T) {
|
||||
|
||||
original := createDeliveryWorktree
|
||||
t.Cleanup(func() { createDeliveryWorktree = original })
|
||||
createDeliveryWorktree = func(_ context.Context, source, gotManaged string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(_ context.Context, sourceRepo gitcmd.Repo, gotManaged string) (worktree.Result, error) {
|
||||
source := filepath.Base(sourceRepo.Dir)
|
||||
if source != "source-project" {
|
||||
t.Fatalf("source = %q", source)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -23,8 +24,12 @@ func acquireDesktopProjectsFileLock() (func(), error) {
|
||||
|
||||
// updateProjectsFileCrossProcessLocked requires desktopProjectsFileMu and the
|
||||
// desktop-projects cross-process file lock.
|
||||
func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (bool, error)) error {
|
||||
func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (bool, error), assignCollisions bool) error {
|
||||
f := loadProjectsFile()
|
||||
previous := make([]string, 0, len(f.Projects))
|
||||
for _, project := range f.Projects {
|
||||
previous = append(previous, project.Root)
|
||||
}
|
||||
changed, err := mutator(&f)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -32,5 +37,10 @@ func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (boo
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
if assignCollisions {
|
||||
if err := assignAddedProjectStateCollisions(previous, f.Projects); err != nil {
|
||||
return fmt.Errorf("%w: %w", errProjectStateCollisionAssignment, err)
|
||||
}
|
||||
}
|
||||
return saveProjectsFile(f)
|
||||
}
|
||||
+6
-3
@@ -66,7 +66,8 @@ func (a *App) forkForTabWithOptions(tabID string, turn int, isolateWorkspace boo
|
||||
if scope != "project" || strings.TrimSpace(srcRoot) == "" {
|
||||
result.FallbackToShared = true
|
||||
} else {
|
||||
avail := inspectDeliveryWorktree(a.bootContext(), srcRoot)
|
||||
srcRepo, _ := workspaceRepo(srcRoot, ctrl)
|
||||
avail := inspectDeliveryWorktree(a.bootContext(), srcRepo)
|
||||
if !avail.Available {
|
||||
result.FallbackToShared = true
|
||||
} else if avail.SourceDirty {
|
||||
@@ -80,7 +81,7 @@ func (a *App) forkForTabWithOptions(tabID string, turn int, isolateWorkspace boo
|
||||
return worktree.Result{}, err
|
||||
}
|
||||
defer releaseAdmission()
|
||||
return createDeliveryWorktree(a.bootContext(), srcRoot, config.DeliveryWorktreeDir())
|
||||
return createDeliveryWorktree(a.bootContext(), srcRepo, config.DeliveryWorktreeDir())
|
||||
}()
|
||||
if createErr != nil {
|
||||
return ForkWorktreeResultView{}, fmt.Errorf("create isolated fork worktree: %w", createErr)
|
||||
@@ -280,7 +281,9 @@ func (a *App) openForkedSessionTabWithWorkspace(sourceTab *WorkspaceTab, locator
|
||||
if err := prependTopicInProjectsFile(workspaceRoot, topicID, true); err != nil {
|
||||
slog.Warn("desktop: persist isolated fork topic", "workspace", workspaceRoot, "topic", topicID, "err", err)
|
||||
}
|
||||
a.registerProjectRoot(workspaceRoot)
|
||||
if err := a.registerProjectRoot(workspaceRoot); err != nil {
|
||||
return forkedSessionTabOpen{}, err
|
||||
}
|
||||
}
|
||||
if hook := forkTabBeforePublishHookForTest.Load(); hook != nil {
|
||||
(*hook)()
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/gitcmd"
|
||||
"reasonix/internal/worktree"
|
||||
)
|
||||
|
||||
@@ -28,10 +29,12 @@ func TestForkWorktreeForTabCreatesIsolatedWorkspace(t *testing.T) {
|
||||
rollbackDeliveryWorktree = origRollback
|
||||
})
|
||||
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := repo.Dir
|
||||
return worktree.Availability{Available: true, RepoRoot: root, Branch: "main"}
|
||||
}
|
||||
createDeliveryWorktree = func(_ context.Context, source, gotManaged string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(_ context.Context, sourceRepo gitcmd.Repo, gotManaged string) (worktree.Result, error) {
|
||||
source := sourceRepo.Dir
|
||||
return worktree.Result{
|
||||
WorkspaceRoot: isolatedRoot,
|
||||
WorktreeRoot: filepath.Dir(isolatedRoot),
|
||||
@@ -90,11 +93,12 @@ func TestForkWorktreeForTabRefusesDirtySourceWithoutMutation(t *testing.T) {
|
||||
inspectDeliveryWorktree = origInspect
|
||||
createDeliveryWorktree = origCreate
|
||||
})
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := repo.Dir
|
||||
return worktree.Availability{Available: true, RepoRoot: root, SourceDirty: true}
|
||||
}
|
||||
createCalls := 0
|
||||
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
|
||||
createCalls++
|
||||
return worktree.Result{}, nil
|
||||
}
|
||||
@@ -125,10 +129,10 @@ func TestForkWorktreeForTabFallsBackToSharedFork(t *testing.T) {
|
||||
inspectDeliveryWorktree = origInspect
|
||||
createDeliveryWorktree = origCreate
|
||||
})
|
||||
inspectDeliveryWorktree = func(context.Context, string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(context.Context, gitcmd.Repo) worktree.Availability {
|
||||
return worktree.Availability{Reason: "not a repository"}
|
||||
}
|
||||
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
|
||||
t.Fatal("fallback must not create a worktree")
|
||||
return worktree.Result{}, nil
|
||||
}
|
||||
@@ -164,7 +168,8 @@ func TestForkWorktreeForTabRollsBackUnusedCreation(t *testing.T) {
|
||||
createDeliveryWorktree = origCreate
|
||||
rollbackDeliveryWorktree = origRollback
|
||||
})
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := repo.Dir
|
||||
return worktree.Availability{Available: true, RepoRoot: root}
|
||||
}
|
||||
created := worktree.Result{
|
||||
@@ -174,7 +179,7 @@ func TestForkWorktreeForTabRollsBackUnusedCreation(t *testing.T) {
|
||||
Branch: "reasonix/delivery-test",
|
||||
Head: "deadbeef",
|
||||
}
|
||||
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
|
||||
return created, nil
|
||||
}
|
||||
rollbackCalls := 0
|
||||
@@ -211,7 +216,8 @@ func TestForkWorktreeForTabPreservesReferencedWorkspaceWhenSourceCloses(t *testi
|
||||
rollbackDeliveryWorktree = origRollback
|
||||
forkTabBeforePublishHookForTest.Store(nil)
|
||||
})
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := repo.Dir
|
||||
return worktree.Availability{Available: true, RepoRoot: root}
|
||||
}
|
||||
worktreeRoot := filepath.Join(t.TempDir(), "worktree")
|
||||
@@ -226,7 +232,7 @@ func TestForkWorktreeForTabPreservesReferencedWorkspaceWhenSourceCloses(t *testi
|
||||
Branch: "reasonix/delivery-preserved",
|
||||
Head: "deadbeef",
|
||||
}
|
||||
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
|
||||
return created, nil
|
||||
}
|
||||
rollbackCalls := 0
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"test:code-browser": "node bench/code-theme.mjs",
|
||||
"test:dock-responsive-browser": "node bench/responsive-dock.mjs",
|
||||
"test:diagnostics": "tsx src/__tests__/diagnostics-settings.test.tsx",
|
||||
"test:transcript": "tsx src/__tests__/canonical-user-display.test.ts && tsx src/__tests__/steer-transcript.test.ts && tsx src/__tests__/chat-tool-presentation.test.ts && tsx src/__tests__/chat-view-source.test.ts && tsx src/__tests__/maintenance-lifecycle.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/maintenance-card.test.tsx && tsx src/__tests__/presented-files.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/presented-files-ui.test.tsx && tsx src/__tests__/chat-content-loader.test.ts && tsx src/__tests__/chat-details-race.test.tsx && tsx src/__tests__/tool-payload-preview.test.ts && tsx src/__tests__/markdown-natural-flow.test.tsx && tsx src/__tests__/chat-natural-flow.test.tsx && tsx src/__tests__/chat-scroll-controller.test.ts && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/markdown-worker-client.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-memory-pressure.test.ts && tsx src/__tests__/transcript-tool-association.test.ts && tsx src/__tests__/transcript-follow-client.test.ts && tsx src/__tests__/transcript-session-follower.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/session-export-routing.test.tsx && tsx src/__tests__/session-export-operation.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/transcript-outline-store.test.ts && tsx src/__tests__/chat-turn-jump.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/chat-turn-outline-jump.test.tsx && tsx src/__tests__/native-transcript-history.test.ts && tsx src/__tests__/history-read-binding.test.ts",
|
||||
"test:transcript": "tsx src/__tests__/canonical-user-display.test.ts && tsx src/__tests__/steer-transcript.test.ts && tsx src/__tests__/chat-tool-presentation.test.ts && tsx src/__tests__/chat-view-source.test.ts && tsx src/__tests__/maintenance-lifecycle.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/maintenance-card.test.tsx && tsx src/__tests__/presented-files.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/presented-files-ui.test.tsx && tsx src/__tests__/chat-content-loader.test.ts && tsx src/__tests__/chat-details-race.test.tsx && tsx src/__tests__/tool-payload-preview.test.ts && tsx src/__tests__/markdown-natural-flow.test.tsx && tsx src/__tests__/chat-natural-flow.test.tsx && tsx src/__tests__/chat-scroll-controller.test.ts && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/markdown-worker-client.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-memory-pressure.test.ts && tsx src/__tests__/transcript-tool-association.test.ts && tsx src/__tests__/transcript-follow-client.test.ts && tsx src/__tests__/transcript-session-follower.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/session-export-routing.test.tsx && tsx src/__tests__/session-export-operation.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/transcript-outline-store.test.ts && tsx src/__tests__/chat-turn-jump.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/chat-turn-outline-jump.test.tsx && tsx src/__tests__/native-transcript-history.test.ts && tsx src/__tests__/history-read-binding.test.ts && tsx src/__tests__/history-reload-identity.test.ts",
|
||||
"test:session-export-browser": "node bench/session-export.mjs",
|
||||
"test:session-loading-browser": "node bench/session-loading.mjs && node bench/draft-recovery.mjs && node bench/manual-creation.mjs && node bench/manual-creation-recovery.mjs",
|
||||
"test:draft-recovery-browser": "node bench/draft-recovery.mjs",
|
||||
@@ -72,14 +72,14 @@
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@modelcontextprotocol/ext-apps": "1.7.5",
|
||||
"@tanstack/react-virtual": "^3.14.10",
|
||||
"@tanstack/react-virtual": "^3.14.13",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"anser": "^2.3.5",
|
||||
"hast-util-to-jsx-runtime": "2.3.6",
|
||||
"highlight.js": "^11.12.0",
|
||||
"html-url-attributes": "3.0.1",
|
||||
"katex": "^0.18.6",
|
||||
"katex": "^0.18.9",
|
||||
"lucide-react": "^1.41.0",
|
||||
"mermaid": "^11.17.2",
|
||||
"qrcode.react": "^4.2.0",
|
||||
@@ -103,7 +103,7 @@
|
||||
"@types/node": "^26.4.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@typescript-eslint/parser": "^8.69.0",
|
||||
"@typescript-eslint/parser": "^8.70.1",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"decode-named-character-reference": "^1.3.0",
|
||||
"eslint": "^10.10.0",
|
||||
@@ -113,7 +113,7 @@
|
||||
"mdast-util-math": "^3.0.0",
|
||||
"playwright": "^1.63.0",
|
||||
"terser": "^5.51.2",
|
||||
"tsx": "^4.23.13",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.2.2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createTranscriptHarness } from "./transcript-dom-harness";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
const harness = await createTranscriptHarness({ deterministic: true });
|
||||
const { ChatSource } = await harness.loadModule<typeof import("../lib/chatViewSource")>("/src/lib/chatViewSource.ts");
|
||||
const { ChatNodeList } = await harness.loadModule<typeof import("../components/ChatNodes")>("/src/components/ChatNodes.tsx");
|
||||
const { ChatMountedOrder } = await harness.loadModule<typeof import("../lib/chatMountedOrder")>("/src/lib/chatMountedOrder.ts");
|
||||
const { ChatContentLoader } = await harness.loadModule<typeof import("../lib/chatContentLoader")>("/src/lib/chatContentLoader.ts");
|
||||
const { ChatScrollController } = await harness.loadModule<typeof import("../lib/chatScrollController")>("/src/lib/chatScrollController.ts");
|
||||
const { LocaleProvider } = await harness.loadModule<typeof import("../lib/i18n")>("/src/lib/i18n.tsx");
|
||||
|
||||
const source = new ChatSource("reasoning-load-full");
|
||||
let fail = false;
|
||||
let release: (() => void) | undefined;
|
||||
const loads: string[] = [];
|
||||
const loader = new ChatContentLoader(undefined, (item, field) => {
|
||||
if (field !== "reasoning") return Promise.resolve("");
|
||||
loads.push(item.id);
|
||||
if (fail) return Promise.reject(new Error("unavailable"));
|
||||
return new Promise<string>(resolve => { release = () => resolve((item as { reasoning: string }).reasoning); });
|
||||
});
|
||||
const scroll = new ChatScrollController("reasoning-load-full");
|
||||
const mounts = new ChatMountedOrder();
|
||||
await harness.unmount();
|
||||
const root = createRoot(harness.container);
|
||||
const question: Item = { kind: "user", id: "q", text: "think" };
|
||||
const live = (reasoning: string, streaming = true): Item => ({ kind: "assistant", id: "a", text: "", reasoning, streaming });
|
||||
const publish = async (items: Item[], running: boolean) => {
|
||||
await act(async () => source.update({ items, running, hydrating: false, hasOlder: false, loadingOlder: false }));
|
||||
await harness.settle();
|
||||
};
|
||||
const body = () => harness.container.querySelector(".chat-reasoning__body")?.textContent ?? "";
|
||||
const loadButton = () => [...harness.container.querySelectorAll<HTMLButtonElement>(".chat-reasoning__body > .btn")][0];
|
||||
|
||||
try {
|
||||
const first = "a".repeat(9000);
|
||||
await publish([question, live(first)], true);
|
||||
await act(async () => root.render(createElement(LocaleProvider, null,
|
||||
createElement(ChatNodeList, { source, mounts, loader, scroll, actions: { openDetails: () => {}, recover: () => {} } }))));
|
||||
await harness.settle();
|
||||
await act(async () => harness.container.querySelector<HTMLElement>(".chat-reasoning [data-disclosure-row]")!.click());
|
||||
assert.ok(loadButton(), "long streaming reasoning offers a full-text button");
|
||||
await act(async () => loadButton()!.click());
|
||||
await harness.settle();
|
||||
assert.ok(body().length >= first.length, "clicking load shows the whole reasoning while it streams");
|
||||
assert.equal(loadButton(), undefined, "the button is gone once the full text is shown");
|
||||
|
||||
const grown = first + "b".repeat(500);
|
||||
await publish([question, live(grown)], true);
|
||||
assert.ok(body().length >= grown.length, "the loaded view follows the growing stream instead of snapping back");
|
||||
assert.equal(loadButton(), undefined, "streaming chunks do not bring the button back");
|
||||
|
||||
const final = grown + "c".repeat(500);
|
||||
await publish([question, live(final, false)], false);
|
||||
assert.ok(body().length >= final.length, "the body never shrinks while the settled load is pending");
|
||||
assert.equal(loadButton(), undefined, "no button while the settled load is pending");
|
||||
await act(async () => { release?.(); });
|
||||
await harness.settle();
|
||||
assert.ok(body().length >= final.length, "the loaded view survives the settled load");
|
||||
assert.equal(loadButton(), undefined, "settling does not bring the button back");
|
||||
assert.equal(loads.length, 1, "the settled load runs once");
|
||||
|
||||
const copies = loads.length;
|
||||
await publish([question, { ...live("d".repeat(9000)), id: "b" }], true);
|
||||
await act(async () => harness.container.querySelectorAll<HTMLButtonElement>(".chat-reasoning .copybtn")[1]?.click());
|
||||
assert.equal(loads.length, copies, "copying while streaming does not start a load");
|
||||
|
||||
const start = loads.length;
|
||||
await publish([question, { ...live("e".repeat(9000)), id: "c" }], true);
|
||||
await act(async () => harness.container.querySelector<HTMLElement>(".chat-reasoning [data-disclosure-row]")!.click());
|
||||
await act(async () => loadButton()!.click());
|
||||
const idle = loadButton() ? "" : "gone";
|
||||
fail = true;
|
||||
const failedFinal = "e".repeat(9500);
|
||||
await publish([question, { ...live(failedFinal, false), id: "c" }], false);
|
||||
await harness.settle();
|
||||
assert.equal(loads.length, start + 1, "the failing load ran once");
|
||||
assert.ok(body().length >= failedFinal.length, "a failed load does not shrink the body");
|
||||
const retry = loadButton();
|
||||
assert.ok(retry, `a failed load keeps a retry button (${idle})`);
|
||||
assert.equal(retry!.disabled, false, "the retry button is enabled");
|
||||
const failedLabel = retry!.textContent;
|
||||
fail = false;
|
||||
await act(async () => retry!.click());
|
||||
await act(async () => { release?.(); });
|
||||
await harness.settle();
|
||||
assert.equal(loads.length, start + 2, "retry loads again");
|
||||
assert.equal(loadButton(), undefined, "a successful retry removes the button");
|
||||
assert.ok(failedLabel, "the failed state is labelled");
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
source.dispose(); mounts.dispose(); loader.dispose(); scroll.dispose();
|
||||
await harness.unmount(); await harness.close();
|
||||
}
|
||||
console.log("reasoning load full: stays loaded across streaming chunks and settlement");
|
||||
@@ -48,12 +48,12 @@ const warning: Item = { kind: "notice", id: "warning", level: "warn", text: "int
|
||||
source.update({ ...input, items: [...settled, warning], running: false });
|
||||
await Promise.resolve();
|
||||
const failed = source.getNodeSnapshot("u2:process");
|
||||
assert.ok(failed?.kind === "process" && !failed.foldable);
|
||||
assert.ok(failed?.kind === "process" && failed.foldable && !failed.collapsed, "an interrupted turn stays open but can be folded");
|
||||
assert.ok(source.getOrderSnapshot().includes("warning"));
|
||||
source.update({ ...input, items: [settled[1]], running: false });
|
||||
await Promise.resolve();
|
||||
const incomplete = source.getNodeSnapshot("history-head:process");
|
||||
assert.ok(incomplete?.kind === "process" && !incomplete.foldable);
|
||||
assert.ok(incomplete?.kind === "process" && !incomplete.foldable, "a group without its user message has no fold seat");
|
||||
source.update(input);
|
||||
source.dispose();
|
||||
const notified = active;
|
||||
@@ -120,7 +120,8 @@ const restored = new ChatSource("recovered-weather");
|
||||
restored.update({ ...input, items: weather, running: false });
|
||||
assert.equal((restored.getNodeSnapshot("weather-user:process") as typeof compact).collapsed, false, "manual open survives session revisit");
|
||||
restored.update({ ...input, items: [...weather, { kind: "notice", id: "stopped", level: "info", text: "Stopped" }], running: false });
|
||||
assert.equal((restored.getNodeSnapshot("weather-user:process") as typeof compact).foldable, false, "terminal interruption is not hidden by a partial answer");
|
||||
const interrupted = restored.getNodeSnapshot("weather-user:process") as typeof compact;
|
||||
assert.ok(interrupted.foldable && !interrupted.collapsed, "terminal interruption stays open and keeps the manual choice");
|
||||
restored.dispose();
|
||||
|
||||
const deliverables = new ChatSource("deliverables");
|
||||
@@ -199,3 +200,34 @@ const maintenanceProcess = maintenanceSource.getNodeSnapshot("compact-user:proce
|
||||
assert.ok(maintenanceProcess?.kind === "process" && !maintenanceProcess.members.includes("maintenance:op"), "maintenance card is independent from the previous process fold");
|
||||
assert.ok(maintenanceSource.getOrderSnapshot().includes("maintenance:op"), "pending maintenance remains mounted");
|
||||
maintenanceSource.dispose();
|
||||
|
||||
const endings: Record<string, Item[]> = {
|
||||
clean: [{ kind: "assistant", id: "clean-answer", text: "done", reasoning: "", streaming: false, turnFinal: true }],
|
||||
stopped: [{ kind: "notice", id: "stopped-note", level: "info", text: "Stopped" }],
|
||||
errored: [{ kind: "notice", id: "errored-note", level: "warn", text: "provider error" }],
|
||||
unfinishedTool: [{ kind: "tool", id: "hung", name: "fleet", args: "{}", readOnly: true, status: "stopped" }],
|
||||
streamingLeftover: [{ kind: "assistant", id: "leftover", text: "partial", reasoning: "", streaming: true }],
|
||||
};
|
||||
for (const [ending, tail] of Object.entries(endings)) {
|
||||
const source = new ChatSource(`ending-${ending}`);
|
||||
const turn: Item[] = [
|
||||
{ kind: "user", id: "q", text: "go", checkpointTurn: 1 },
|
||||
{ kind: "tool", id: "t1", name: "bash", args: "{}", readOnly: true, status: "done", output: "ok" },
|
||||
...tail,
|
||||
];
|
||||
source.update({ ...input, items: turn, running: false });
|
||||
await Promise.resolve();
|
||||
const seat = () => source.getNodeSnapshot("q:process") as Extract<NonNullable<ReturnType<typeof source.getNodeSnapshot>>, { kind: "process" }>;
|
||||
assert.ok(seat().foldable, `${ending}: every ended turn has a fold control`);
|
||||
const startsFolded = seat().collapsed;
|
||||
assert.equal(startsFolded, ending === "clean", `${ending}: only a settled turn starts folded`);
|
||||
source.toggleProcess("q");
|
||||
assert.equal(seat().collapsed, !startsFolded, `${ending}: the control flips the turn`);
|
||||
source.update({ ...input, items: [...turn], running: false });
|
||||
await Promise.resolve();
|
||||
assert.equal(seat().collapsed, !startsFolded, `${ending}: the manual choice survives republication`);
|
||||
source.toggleProcess("q");
|
||||
assert.equal(seat().collapsed, startsFolded, `${ending}: the control flips back`);
|
||||
source.dispose();
|
||||
}
|
||||
console.log("chat view: every ended turn is foldable by hand");
|
||||
@@ -25,7 +25,7 @@ const background = [{ id: "local-runtime" }] as unknown as BackgroundRuntimeView
|
||||
const view = projectConversation({ local, remote, tab, activeTabId: tab.id, backgroundRuntimes: background, connectingLabel: "connecting" });
|
||||
assert.equal(view.runtime, remote.transcript, "projection shares the canonical state and message arrays");
|
||||
assert.equal(view.context.items, remote.transcript.items);
|
||||
assert.equal(view.context.tabId, undefined, "remote context cannot fetch local telemetry");
|
||||
assert.equal(view.context.tabId, tab.id, "remote context reads the selected remote tab");
|
||||
assert.equal(view.composer.modelLabel, "remote-model");
|
||||
assert.equal(view.composer.cwd, "remote-cwd");
|
||||
assert.equal(view.composer.turnTokens, 7);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Run: tsx src/__tests__/decision-surface-remote.test.ts
|
||||
import assert from "node:assert/strict";
|
||||
import { projectDecisionSurface } from "../app-runtime/decisionSurfaceProjection";
|
||||
|
||||
const base = { approval: undefined, ask: undefined, mcpInteraction: undefined, extensionForm: undefined, workspaceConflict: null, pendingClose: null, clearContextPending: false } as unknown as Parameters<typeof projectDecisionSurface>[0];
|
||||
const ask = { id: "1", questions: [{ id: "q1", prompt: "?", options: [{ label: "a" }, { label: "b" }] }] } as unknown as Parameters<typeof projectDecisionSurface>[0]["ask"];
|
||||
const approval = { id: "call-1", tool: "bash" } as unknown as Parameters<typeof projectDecisionSurface>[0]["approval"];
|
||||
|
||||
assert.equal(projectDecisionSurface({ ...base, ask }), "ask", "a local ask docks in the footer");
|
||||
assert.equal(projectDecisionSurface({ ...base, ask, remote: true }), null, "a remote ask belongs to the remote surface, not a second footer card");
|
||||
assert.equal(projectDecisionSurface({ ...base, approval, remote: true }), null, "a remote approval is not duplicated in the footer");
|
||||
assert.equal(projectDecisionSurface({ ...base, ask, remote: true, clearContextPending: true }), "clear_context", "shell decisions still reach the footer on a remote tab");
|
||||
console.log("decision-surface-remote: 4 passed");
|
||||
@@ -23,6 +23,7 @@ const acceptedTopics: number[] = [];
|
||||
let intent = 0;
|
||||
let registration: ReturnType<typeof deferred<string>> | undefined;
|
||||
let preparationReads = 0;
|
||||
let lastTopicPath = "";
|
||||
let api!: ReturnType<typeof useDesktopNavigation>;
|
||||
const activate = (id: string) => { calls.push(`open:${id}`); const request = deferred<TabMeta>(); pending.set(id, request); return request.promise; };
|
||||
const ports: Parameters<typeof useDesktopNavigation>[0]["ports"] = {
|
||||
@@ -30,7 +31,7 @@ const ports: Parameters<typeof useDesktopNavigation>[0]["ports"] = {
|
||||
registeredNavigationIntent: async seq => registration ? registration.promise : String(seq),
|
||||
openRemoteProject: async (_host, workspace) => activate(`remote:${workspace}`),
|
||||
switchRemoteTab: async (meta, seq) => { calls.push(`remote-switch:${meta.id}:${seq}`); },
|
||||
activateTopic: async (_scope, _workspace, id) => activate(id),
|
||||
activateTopic: async (_scope, _workspace, id, path) => { lastTopicPath = path; return activate(id); },
|
||||
openCanonicalSession: async (ref) => { await activate(`canonical:${ref.sessionId}`); },
|
||||
openTopicSession: async (_scope, _workspace, id) => { calls.push("tab-session"); return activate(id); },
|
||||
openGlobalTab: async id => { calls.push("tab-global"); return activate(id); },
|
||||
@@ -104,6 +105,14 @@ try {
|
||||
await finish("blank:im", validIM);
|
||||
assert.ok(calls.includes("channel:blank:im:channel.jsonl"));
|
||||
|
||||
calls.length = 0;
|
||||
const botConnection = { ...connection, sessionId: "session:local:bot-conversation" };
|
||||
const botNavigation = api.enqueueNavigation({ kind: "sidebar-im", connection: botConnection });
|
||||
await finish("", botNavigation);
|
||||
assert.equal(lastTopicPath, "bot-session:local:bot-conversation");
|
||||
assert.ok(!calls.some(value => value.startsWith("open:blank:") || value.startsWith("channel:")),
|
||||
"canonical bot sessions open through the shared runtime surface, not a new blank tab");
|
||||
|
||||
calls.length = 0;
|
||||
const isolated = api.enqueueNavigation({ kind: "isolated-worktree", workspaceRoot: "dirty" });
|
||||
await paint(); // Normal commits do not change the request epoch.
|
||||
|
||||
@@ -16,6 +16,7 @@ for (const locale of ["en", "zh", "zh-TW"] as const) {
|
||||
const translate: Translator = key => dict[key];
|
||||
for (const [error, key] of [
|
||||
["dial tcp: connection refused", "error.connection"],
|
||||
[{ code: "transport_protocol", message: "connection error: PROTOCOL_ERROR" }, "error.transportProtocol"],
|
||||
["lookup host: no such host", "error.dns"],
|
||||
["deepseek: status 503: invalid api key mentioned in upstream diagnostics", "error.service"],
|
||||
["HTTP 429: too many requests", "error.rateLimit"],
|
||||
@@ -36,6 +37,10 @@ for (const locale of ["en", "zh", "zh-TW"] as const) {
|
||||
] as const) {
|
||||
assert.equal(presentError(error, translate, locale).summary, dict[key], `${locale}: ${JSON.stringify(error)}`);
|
||||
}
|
||||
assert.deepEqual(presentError("opaque failure", translate, locale, { kind: "transport_protocol", transportCode: "PROTOCOL_ERROR" }), {
|
||||
summary: dict["error.transportProtocol"], detail: "opaque failure\nHTTP/2: PROTOCOL_ERROR",
|
||||
});
|
||||
assert.notEqual(presentError("connection error: PROTOCOL_ERROR", translate, locale).summary, dict["error.transportProtocol"], "untyped wording is not transport evidence");
|
||||
if (locale !== "en") {
|
||||
for (const raw of ["unknown backend failure", "read /tmp/timeout/404.txt: unsupported format", "read /tmp/中文文件: unsupported format", "<img src=x onerror=alert(1)>"]) {
|
||||
assert.deepEqual(presentError(raw, translate, locale), { summary: dict["error.unknown"], detail: raw });
|
||||
@@ -59,10 +64,11 @@ await Promise.all([preloadLocale("zh"), preloadLocale("zh-TW")]);
|
||||
let changeLocale: (locale: Locale) => void;
|
||||
let toast: ReturnType<typeof useToast>["showToast"];
|
||||
const raw = "unknown library failure <img src=x onerror=alert(1)>";
|
||||
const protocolError = "connection error: PROTOCOL_ERROR <img src=x onerror=alert(1)>";
|
||||
function Probe() {
|
||||
changeLocale = useI18n().setPref;
|
||||
toast = useToast().showToast;
|
||||
return <><p id="inline-error"><ErrorMessage error={raw} /></p><p id="summary-only"><ErrorMessage error={undefined} summary="仅有说明" /></p></>;
|
||||
return <><p id="inline-error"><ErrorMessage error={raw} /></p><p id="transport-error"><ErrorMessage error={protocolError} diagnostic={{ kind: "transport_protocol", transportCode: "PROTOCOL_ERROR" }} /></p><p id="summary-only"><ErrorMessage error={undefined} summary="仅有说明" /></p></>;
|
||||
}
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
await act(async () => root.render(<LocaleProvider><ToastProvider><Probe /></ToastProvider></LocaleProvider>));
|
||||
@@ -76,9 +82,14 @@ assert.equal(toggle.getAttribute("aria-expanded"), "true");
|
||||
assert.equal(document.querySelector(".user-error__detail")?.textContent, raw);
|
||||
assert.equal(document.querySelector("#inline-error img"), null, "diagnostics are text, never executable markup");
|
||||
await act(async () => changeLocale("zh-TW"));
|
||||
assert.equal(document.querySelector("#transport-error .user-error__summary")?.textContent, zhTW["error.transportProtocol"]);
|
||||
assert.equal(document.querySelector("#inline-error .user-error__summary")?.textContent, zhTW["error.unknown"]);
|
||||
assert.equal(document.querySelector(".user-error__detail")?.textContent, raw, "language switching preserves diagnostics");
|
||||
await act(async () => changeLocale("en"));
|
||||
assert.equal(document.querySelector("#transport-error .user-error__summary")?.textContent, en["error.transportProtocol"]);
|
||||
await act(async () => document.querySelector<HTMLButtonElement>("#transport-error .user-error__toggle")!.click());
|
||||
assert.equal(document.querySelector("#transport-error .user-error__detail")?.textContent, `${protocolError}\nHTTP/2: PROTOCOL_ERROR`);
|
||||
assert.equal(document.querySelector("#transport-error img"), null);
|
||||
assert.equal(document.querySelector("#inline-error .user-error__summary")?.textContent, raw, "English errors follow the active English UI locale");
|
||||
await act(async () => changeLocale("zh-TW"));
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ let navigationEpoch = 0;
|
||||
let cancelled = 0;
|
||||
const opened: string[] = [];
|
||||
const preparedSelectors: unknown[] = [];
|
||||
let preparedSource: SessionPreparationView | undefined;
|
||||
const source = { hostId: "local", sourceKey: "legacy", path: "/fixture/legacy.jsonl" };
|
||||
const host = installDesktopHostStub({
|
||||
CheckHistoricalSourceUpdate: async () => ({ sourceKey: "legacy", status: "available", version: "v2", source, retryable: false }),
|
||||
@@ -33,6 +34,7 @@ const host = installDesktopHostStub({
|
||||
},
|
||||
PrepareSession: async (selector: unknown) => {
|
||||
preparedSelectors.push(selector);
|
||||
if (preparedSource) return preparedSource;
|
||||
return { operationId: "prepare-source", sourceKey: "legacy", status: "ready", revision: 8,
|
||||
target: { hostId: "local", sessionId: "imported-source" }, retryable: false };
|
||||
},
|
||||
@@ -133,6 +135,29 @@ const [canonicalTab] = seedActiveTabMetaList([{ ...baseProps.tab, historicalSour
|
||||
assert.equal(canonicalTab.historicalSource, undefined, "canonical metadata omission clears the old preparation state");
|
||||
await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners {...baseProps} tab={canonicalTab} /></LocaleProvider>));
|
||||
assert.equal(document.getElementById("reasonix-prepare-restored-session"), null, "successful activation removes the preparation action");
|
||||
const failingSourceTab = { ...baseProps.tab, sessionId: undefined, ready: false, historicalSource: source };
|
||||
let recoveryOpened = 0;
|
||||
const openFailures: unknown[] = [];
|
||||
preparedSource = { operationId: "prepare-conflict", sourceKey: "legacy", status: "failed", revision: 9, errorCode: "workspace_conflict",
|
||||
errorDetail: "session workspace identity is inconsistent; the session files were left unchanged", retryable: true };
|
||||
await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners tab={failingSourceTab}
|
||||
navigate={async intent => { openFailures.push(intent); throw new Error("open refused: workspace registry busy"); }}
|
||||
openRecoveryDetails={() => { recoveryOpened++; }} /></LocaleProvider>));
|
||||
assert.equal(document.body.textContent?.includes("Review recovery details"), false, "recovery details appear only with a failure");
|
||||
await act(async () => document.getElementById("reasonix-prepare-restored-session")!.click());
|
||||
const failureAlert = () => document.querySelector('[role="alert"]');
|
||||
assert.ok(failureAlert()?.textContent?.includes("records a different project folder"), "the banner names the failure class the host reported");
|
||||
await act(async () => failureAlert()!.querySelector<HTMLButtonElement>(".user-error__toggle")!.click());
|
||||
assert.ok(failureAlert()?.textContent?.includes("session workspace identity is inconsistent"), "the host's failure detail is reachable from the banner");
|
||||
await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Review recovery details")!.click());
|
||||
assert.equal(recoveryOpened, 1, "the promised recovery details open");
|
||||
assert.deepEqual(openFailures, [], "a failed preparation never navigates");
|
||||
preparedSource = { operationId: "prepare-ready", sourceKey: "legacy", status: "ready", revision: 10,
|
||||
target: { hostId: "local", sessionId: "imported-source" }, retryable: false };
|
||||
await act(async () => document.getElementById("reasonix-prepare-restored-session")!.click());
|
||||
assert.ok(failureAlert()?.textContent?.includes("Imported, but the conversation could not be opened"), "an open failure is not reported as an import failure");
|
||||
await act(async () => failureAlert()!.querySelector<HTMLButtonElement>(".user-error__toggle")!.click());
|
||||
assert.ok(failureAlert()?.textContent?.includes("open refused: workspace registry busy"), "the open failure keeps its cause");
|
||||
await act(async () => root.unmount());
|
||||
host.uninstall();
|
||||
dom.window.close();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Run: tsx src/__tests__/hydrate-failure-attribution.test.ts
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { FollowRequest, TranscriptFollowResponse } from "../generated/desktopContract.generated";
|
||||
import { hydrateFailureDetail } from "../lib/hydrateErrorState";
|
||||
import { projectSessionAvailability } from "../lib/sessionAvailability";
|
||||
import { TranscriptFollowClient } from "../lib/transcriptFollowClient";
|
||||
import { initialState, reducer } from "../lib/useController";
|
||||
|
||||
const SUMMARY = "Failed to load conversation history.";
|
||||
|
||||
async function localFollowFailure(cause: unknown): Promise<unknown> {
|
||||
const client = new TranscriptFollowClient((request: FollowRequest) =>
|
||||
request.close ? Promise.resolve({ protocolVersion: 2, subscription: "", changes: [], resetRequired: false } as TranscriptFollowResponse)
|
||||
: Promise.reject(cause), { transport: "local" });
|
||||
try {
|
||||
await client.start({ install: () => {}, changes: () => {}, connection: () => {} });
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
assert.fail("follow start resolved although the host rejected the baseline read");
|
||||
}
|
||||
|
||||
test("a failed local follow keeps its stage, reason and host cause in the recovery detail", async () => {
|
||||
const failure = await localFollowFailure(new Error("session store: database is locked"));
|
||||
const detail = hydrateFailureDetail(SUMMARY, failure);
|
||||
assert.ok(detail.startsWith(SUMMARY), detail);
|
||||
assert.match(detail, /baseline_read\.transport_rejected/);
|
||||
assert.match(detail, /session store: database is locked/);
|
||||
|
||||
const failed = reducer({ ...initialState, hydrating: true }, { type: "hydrate_error", reason: "startup", error: detail });
|
||||
const availability = projectSessionAvailability({ local: failed });
|
||||
assert.deepEqual({ kind: availability.kind, source: availability.source }, { kind: "error", source: "history" });
|
||||
assert.equal(availability.detail, detail, "the banner's details show the cause, not only the summary");
|
||||
});
|
||||
|
||||
test("causes without a follow identity keep their message; an absent cause keeps the summary alone", () => {
|
||||
assert.equal(hydrateFailureDetail(SUMMARY, new Error("resident snapshot unreadable")), `${SUMMARY}\nresident snapshot unreadable`);
|
||||
assert.equal(hydrateFailureDetail(SUMMARY, "plain rejection"), `${SUMMARY}\nplain rejection`);
|
||||
assert.equal(hydrateFailureDetail(SUMMARY, undefined), SUMMARY);
|
||||
assert.equal(hydrateFailureDetail(SUMMARY, new Error(" ")), SUMMARY);
|
||||
});
|
||||
|
||||
test("every local history-read failure hands its cause to the recovery detail", () => {
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const controller = readFileSync(join(root, "lib/useController.ts"), "utf8");
|
||||
assert.doesNotMatch(controller, /type: "hydrate_error", reason, error: t\("history\.failedLoadHistory"\) \}/,
|
||||
"a history-read failure must not replace its cause with the generic summary");
|
||||
assert.doesNotMatch(controller, /const error = t\("history\.failedLoadHistory"\);/,
|
||||
"the follow failure must not be replaced by the generic summary");
|
||||
assert.equal(controller.match(/hydrateFailureDetail\(t\("history\.failedLoadHistory"\), /g)?.length, 2,
|
||||
"both the follow path and the readable-baseline path attribute their failure");
|
||||
});
|
||||
@@ -51,7 +51,7 @@ type ControllerState = Parameters<typeof reducer>[0];
|
||||
const appSource = readFileSync(new URL("../AppRuntime.tsx", import.meta.url), "utf8");
|
||||
const sessionCompositionSource = readFileSync(new URL("../app-runtime/useAppSessionComposition.ts", import.meta.url), "utf8");
|
||||
ok(
|
||||
/\[clearContextPending, pendingClose, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource),
|
||||
/\[clearContextPending, pendingClose, remoteSurfaceActive, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource),
|
||||
"App decision surface recomputes when an MCP interaction arrives",
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// Run: tsx src/__tests__/project-tree-organization-group-drop.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { useProjectTreeOrganization } from "../components/ProjectTreeOrganization";
|
||||
import type { ProjectNode, ProjectTreeOrganizationBindings, SessionGroup } from "../lib/types";
|
||||
import type { SessionOrganizationMutation, SessionOrganizationSnapshot } from "../generated/desktopContract.generated";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
|
||||
if (value) passed += 1; else failed += 1;
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
return dom;
|
||||
}
|
||||
|
||||
function topic(id: string): ProjectNode {
|
||||
return { key: `topic-${id}`, kind: "topic", label: id, root: "/repo", topicId: id, session: { hostId: "local", sessionId: id } } as ProjectNode;
|
||||
}
|
||||
|
||||
const inside = topic("inside");
|
||||
const sibling = topic("sibling");
|
||||
const outside = topic("outside");
|
||||
const loose = topic("loose");
|
||||
const folder: ProjectNode = { key: "project-/repo", kind: "project", label: "Repo", root: "/repo", children: [inside, sibling, outside, loose] };
|
||||
const groups: SessionGroup[] = [{ id: "g1", title: "Group", sessionKeys: ["ref\0local\0inside", "ref\0local\0sibling"] }];
|
||||
|
||||
function Harness({ bindings }: { bindings: ProjectTreeOrganizationBindings }) {
|
||||
const organization = useProjectTreeOrganization({ tree: [folder], refresh: async () => {}, sortMode: "created", bindings });
|
||||
const loaded = organization.groupsFor(folder).length > 0;
|
||||
return <>
|
||||
<output id="loaded">{loaded ? "yes" : "no"}</output>
|
||||
{folder.children!.map((node) => {
|
||||
const row = organization.topicRow(node, false);
|
||||
return <div key={node.key} id={`row-${node.topicId}`} {...row.props}>{node.label}</div>;
|
||||
})}
|
||||
</>;
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function fireDrag(type: string, target: Element, data: Map<string, string>) {
|
||||
const event = new window.Event(type, { bubbles: true, cancelable: true });
|
||||
const dataTransfer = {
|
||||
setData: (format: string, value: string) => { data.set(format, value); },
|
||||
getData: (format: string) => data.get(format) ?? "",
|
||||
effectAllowed: "all",
|
||||
dropEffect: "none",
|
||||
};
|
||||
Object.defineProperty(event, "dataTransfer", { value: dataTransfer });
|
||||
Object.defineProperty(event, "clientY", { value: 10 });
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
async function dropOnto(draggedID: string, targetID: string): Promise<SessionOrganizationMutation[]> {
|
||||
const dom = installDom();
|
||||
const mutations: SessionOrganizationMutation[] = [];
|
||||
const snapshot = (): SessionOrganizationSnapshot => ({ groups: structuredClone(groups), revision: 1, applied: true, order: [], manualOrderEnabled: false });
|
||||
const bindings: ProjectTreeOrganizationBindings = {
|
||||
ReorderTopics: async () => {},
|
||||
ListProjectGroups: async () => [],
|
||||
SaveSessionGroups: async () => {},
|
||||
GetSessionOrganization: async () => snapshot(),
|
||||
UpdateSessionOrganization: async (_workspace, _revision, mutation) => { mutations.push(mutation); return snapshot(); },
|
||||
};
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
await act(async () => {
|
||||
root.render(<LocaleProvider><ToastProvider><Harness bindings={bindings} /></ToastProvider></LocaleProvider>);
|
||||
await flush();
|
||||
});
|
||||
for (let attempt = 0; attempt < 30 && document.getElementById("loaded")?.textContent !== "yes"; attempt += 1) await act(flush);
|
||||
const data = new Map<string, string>();
|
||||
await act(async () => { fireDrag("dragstart", document.getElementById(`row-${draggedID}`)!, data); await flush(); });
|
||||
await act(async () => { fireDrag("dragover", document.getElementById(`row-${targetID}`)!, data); await flush(); });
|
||||
await act(async () => { fireDrag("drop", document.getElementById(`row-${targetID}`)!, data); await flush(); });
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) await act(flush);
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
return mutations;
|
||||
}
|
||||
|
||||
console.log("\nproject tree drop onto a group member row");
|
||||
|
||||
{
|
||||
const mutations = await dropOnto("outside", "inside");
|
||||
ok(mutations.length === 1 && mutations[0].kind === "set-group" && mutations[0].groupId === "g1",
|
||||
`dropping a non-member on a member row adds it to that group (got ${JSON.stringify(mutations.map((m) => [m.kind, m.groupId ?? ""]))})`);
|
||||
}
|
||||
|
||||
{
|
||||
const mutations = await dropOnto("sibling", "inside");
|
||||
ok(mutations.length === 1 && mutations[0].kind === "move",
|
||||
`dropping a member on another member of its group reorders (got ${JSON.stringify(mutations.map((m) => m.kind))})`);
|
||||
}
|
||||
|
||||
{
|
||||
const mutations = await dropOnto("outside", "loose");
|
||||
ok(mutations.length === 1 && mutations[0].kind === "move",
|
||||
`dropping on an ungrouped row reorders (got ${JSON.stringify(mutations.map((m) => m.kind))})`);
|
||||
}
|
||||
|
||||
{
|
||||
const mutations = await dropOnto("outside", "loose");
|
||||
ok(mutations.length === 1 && mutations[0].sortMode === "created",
|
||||
`a move carries the sort order on screen, so a first manual move starts from it (got ${JSON.stringify(mutations.map((m) => m.sortMode ?? ""))})`);
|
||||
}
|
||||
|
||||
process.stdout.write(`\nproject-tree-organization-group-drop: ${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,52 @@
|
||||
// Run: pnpm exec tsx src/__tests__/project-tree-organization-reset.test.tsx
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { useProjectTreeOrganization } from "../components/ProjectTreeOrganization";
|
||||
import type { ProjectNode, ProjectTreeOrganizationBindings } from "../lib/types";
|
||||
import type { SessionOrganizationMutation, SessionOrganizationSnapshot } from "../generated/desktopContract.generated";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import { LocaleProvider, t } from "../lib/i18n";
|
||||
|
||||
const dom = new JSDOM('<html><body><div id="root"></div></body></html>', { url: "http://localhost/" });
|
||||
Object.assign(globalThis, { window: dom.window, document: dom.window.document, Node: dom.window.Node, Element: dom.window.Element,
|
||||
HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const folder: ProjectNode = { key: "project-/repo", kind: "project", label: "Repo", root: "/repo", children: [] };
|
||||
const initialOrder = ["ref\0local\0older", "ref\0local\0newer"];
|
||||
let snapshot: SessionOrganizationSnapshot = { revision: 1, applied: true, manualOrderEnabled: true, order: initialOrder, groups: [] };
|
||||
const mutations: SessionOrganizationMutation[] = [];
|
||||
let refreshes = 0;
|
||||
const bindings: ProjectTreeOrganizationBindings = {
|
||||
GetSessionOrganization: async () => snapshot,
|
||||
UpdateSessionOrganization: async (_workspace, revision, mutation) => {
|
||||
assert.equal(revision, snapshot.revision);
|
||||
mutations.push(mutation);
|
||||
snapshot = { ...snapshot, revision: revision + 1, manualOrderEnabled: false };
|
||||
return snapshot;
|
||||
},
|
||||
};
|
||||
|
||||
function Harness() {
|
||||
const organization = useProjectTreeOrganization({ tree: [folder], refresh: async () => { refreshes += 1; }, bindings });
|
||||
const resetItem = organization.resetOrderMenuItems(folder, t, () => {});
|
||||
return <><output id="order">{organization.orderFor?.(folder).join(",")}</output>
|
||||
{resetItem.map(item => <button key={item.key} id="reset" onClick={item.onSelect}>Reset</button>)}</>;
|
||||
}
|
||||
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
const flush = async () => { await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); };
|
||||
try {
|
||||
await act(async () => root.render(<LocaleProvider><ToastProvider><Harness /></ToastProvider></LocaleProvider>));
|
||||
await flush();
|
||||
assert.equal(document.getElementById("order")?.textContent, initialOrder.join(","));
|
||||
await act(async () => { document.getElementById("reset")!.click(); });
|
||||
await flush();
|
||||
assert.deepEqual(mutations.map(mutation => mutation.kind), ["reset-order"]);
|
||||
assert.equal(document.getElementById("order")?.textContent, "");
|
||||
assert.equal(refreshes, 1);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
}
|
||||
@@ -25,8 +25,9 @@ const { ToastProvider } = await import("../lib/toast");
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
let reject!: (reason: Error) => void;
|
||||
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const pending: ReturnType<typeof deferred<ProjectTreeSnapshot>>[] = [];
|
||||
@@ -78,9 +79,21 @@ try {
|
||||
try {
|
||||
await render(0);
|
||||
assert.equal(pending.length, 1, "mount starts one shell read");
|
||||
assert.match(container.querySelector(".project-tree__empty-state")?.textContent ?? "", /读取|讀取|Reading/,
|
||||
"an unanswered shell read must say it is loading");
|
||||
assert.doesNotMatch(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/,
|
||||
"pending shell read must not claim there are no projects");
|
||||
if (scenario.name === "addition") {
|
||||
assert.equal(container.querySelector(".project-tree__empty-state .project-tree__skeleton"), null,
|
||||
"fast shell reads must not flash a skeleton");
|
||||
await act(async () => mock.timers.tick(250));
|
||||
assert.ok(container.querySelector(".project-tree__empty-state .project-tree__skeleton"),
|
||||
"a delayed shell read shows a skeleton");
|
||||
}
|
||||
await act(async () => pending[0].resolve(snapshot(scenario.before)));
|
||||
await flush();
|
||||
assert.deepEqual(labels(), scenario.before.map(item => item.label));
|
||||
assert.equal(container.querySelector(".project-tree__empty-state .project-tree__skeleton"), null);
|
||||
|
||||
// A read started before the mutation retains the old shell contents.
|
||||
// Its scalar revision may equal the post-mutation read: this fixture
|
||||
@@ -114,6 +127,44 @@ try {
|
||||
await act(async () => root.unmount());
|
||||
}
|
||||
}
|
||||
|
||||
pending.length = 0;
|
||||
localStorage.clear();
|
||||
const emptyRoot = createRoot(container);
|
||||
try {
|
||||
await act(async () => emptyRoot.render(<LocaleProvider><ToastProvider><ProjectTree
|
||||
onOpenTopic={noop} onAddProject={addProject}
|
||||
/></ToastProvider></LocaleProvider>));
|
||||
await flush();
|
||||
assert.doesNotMatch(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/);
|
||||
await act(async () => pending[0].resolve(snapshot([])));
|
||||
await flush();
|
||||
assert.match(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/,
|
||||
"an empty answer restores the true empty state");
|
||||
} finally {
|
||||
await act(async () => emptyRoot.unmount());
|
||||
}
|
||||
|
||||
pending.length = 0;
|
||||
localStorage.clear();
|
||||
const failedRoot = createRoot(container);
|
||||
try {
|
||||
await act(async () => failedRoot.render(<LocaleProvider><ToastProvider><ProjectTree
|
||||
onOpenTopic={noop} onAddProject={addProject}
|
||||
/></ToastProvider></LocaleProvider>));
|
||||
await flush();
|
||||
assert.match(container.querySelector(".project-tree__empty-state")?.textContent ?? "", /读取|讀取|Reading/);
|
||||
await act(async () => pending[0].reject(new Error("snapshot unavailable")));
|
||||
await flush();
|
||||
assert.doesNotMatch(container.textContent ?? "", /读取项目|讀取專案|Reading projects/,
|
||||
"a failed first shell read must settle the loading state");
|
||||
assert.match(container.textContent ?? "", /Add new project|添加新项目|新增專案/,
|
||||
"the empty-state project action must remain available after a failed read");
|
||||
assert.match(container.textContent ?? "", /Remote connection|远程连接|遠端連線/,
|
||||
"the empty-state remote action must remain available after a failed read");
|
||||
} finally {
|
||||
await act(async () => failedRoot.unmount());
|
||||
}
|
||||
} finally {
|
||||
mock.timers.reset();
|
||||
dom.window.close();
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { initialState, reducer } from "../lib/useController";
|
||||
import { historyMessagesToItems } from "../lib/historyItems";
|
||||
import { historyNoticeItems } from "../lib/controllerNotices";
|
||||
import { presentError } from "../lib/errorPresentation";
|
||||
import { en } from "../locales/en";
|
||||
import type { HistoryMessage } from "../lib/types";
|
||||
|
||||
const started = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
|
||||
const failed = reducer(started, {
|
||||
@@ -25,3 +30,17 @@ assert.equal(
|
||||
"Connection ID: deepseek-anthropic\nRequest path: /anthropic/v1/chat/completions",
|
||||
"stable provider id and sanitized path stay in live diagnostic details",
|
||||
);
|
||||
|
||||
const diagnostic = { kind: "transport_protocol", transportCode: "PROTOCOL_ERROR" };
|
||||
const history: HistoryMessage = { role: "notice", content: "opaque provider failure", level: "warn", diagnostic };
|
||||
const live = reducer(started, { type: "event", e: { kind: "turn_done", err: history.content, diagnostic } });
|
||||
const interrupted = reducer(started, { type: "event", e: { kind: "turn_done", status: "interrupted", err: history.content, diagnostic } });
|
||||
const liveNotice = reducer(started, { type: "event", e: { kind: "notice", level: "warn", text: history.content, diagnostic } });
|
||||
for (const items of [live.items, interrupted.items, liveNotice.items, historyMessagesToItems([history], "full").items, historyNoticeItems(history, "windowed")]) {
|
||||
const item = items.find(item => item.kind === "notice" && item.level === "warn");
|
||||
assert.ok(item?.kind === "notice");
|
||||
assert.deepEqual(item.diagnostic, diagnostic, "live and both history projections retain structured evidence");
|
||||
const presentation = presentError(item.text, key => en[key], "en", item.diagnostic);
|
||||
assert.equal(presentation.summary, en["error.transportProtocol"]);
|
||||
assert.match(presentation.detail, /PROTOCOL_ERROR/);
|
||||
}
|
||||
@@ -391,6 +391,22 @@ await act(async () => {
|
||||
ok(Boolean(document.querySelector(".prompt-shelf--ask")) && document.body.textContent?.includes("remote answer failed") === true && document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.disabled === false, "a failed remote Ask answer preserves the card, surfaces the error, and re-enables retry"); failAnswer = false; await act(async () => { document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.click(); await flush(); }); ok(tape.filter((entry) => entry.startsWith("answer:tab-remote-1:ask-7:")).length === 2 && !document.querySelector(".prompt-shelf--ask"), "a successful remote Ask retry resubmits the complete answer and clears the card");
|
||||
}
|
||||
|
||||
// A CLI serve never binds desktop routing metadata, so its prompts carry no
|
||||
// runtimeEpoch; the serve fences on the epoch only when it has one.
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", turnId: "turn-main", ask: { id: "ask-serve", questions: [{ id: "q1", prompt: "Ship it?", options: [{ label: "ship" }, { label: "wait" }] }] } });
|
||||
await flush();
|
||||
});
|
||||
{
|
||||
ok(Boolean(document.querySelector(".prompt-shelf--ask")) && !document.body.textContent?.includes("exact request identity is unavailable"), "an ask without a runtime epoch is answerable, not refused as unidentified");
|
||||
await act(async () => {
|
||||
[...document.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent?.trim() === "ship")?.click();
|
||||
await flush();
|
||||
});
|
||||
await act(async () => { document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.click(); await flush(); });
|
||||
ok(tape.some((entry) => entry.startsWith("answer:tab-remote-1:ask-serve:")) && !document.querySelector(".prompt-shelf--ask"), "the answer reaches the serve and the card clears");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", turnId: "turn-main", runtimeEpoch: "runtime-main", ask: { id: "ask-custom", questions: [{ id: "q-custom", prompt: "Where?", options: [{ label: "staging" }] }] } });
|
||||
await flush();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { setAttentionPreference, setSuccessPreference } from "../lib/sound";
|
||||
import { createRuntimeNotifications } from "../lib/runtimeNotifications";
|
||||
import { t } from "../lib/i18n";
|
||||
|
||||
const stored = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem(key: string) { return stored.get(key) ?? null; },
|
||||
setItem(key: string, value: string) { stored.set(key, value); },
|
||||
removeItem(key: string) { stored.delete(key); },
|
||||
clear() { stored.clear(); },
|
||||
},
|
||||
});
|
||||
let contexts = 0;
|
||||
class FakeAudioContext {
|
||||
destination = {};
|
||||
constructor() { contexts++; }
|
||||
createOscillator() { return { type: "sine", frequency: { setValueAtTime() {} }, connect() {}, start() {}, stop() {} }; }
|
||||
createGain() {
|
||||
return { gain: { setValueAtTime() {}, linearRampToValueAtTime() {}, exponentialRampToValueAtTime() {} }, connect() {} };
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
Object.defineProperty(globalThis, "AudioContext", { configurable: true, value: FakeAudioContext });
|
||||
|
||||
const owner = createRuntimeNotifications(() => ({ activeTabId: "A", t, showToast() {} }));
|
||||
|
||||
function chimesFor(event: { err?: string; outcome?: string }, only: "success" | "attention"): number {
|
||||
setSuccessPreference(only === "success" ? "synth" : "off");
|
||||
setAttentionPreference(only === "attention" ? "synth" : "off");
|
||||
const before = contexts;
|
||||
owner.accept({ event: { kind: "turn_done", tabId: "A", ...event } });
|
||||
return contexts - before;
|
||||
}
|
||||
|
||||
const readiness = { err: "delivery checks are incomplete", outcome: "final_readiness" };
|
||||
assert.equal(chimesFor(readiness, "attention"), 1, "a turn that stopped for delivery checks must alert the user");
|
||||
assert.equal(chimesFor(readiness, "success"), 0, "a turn that stopped for delivery checks is not a success");
|
||||
for (const outcome of ["recovery_paused", "incomplete_read", "completion_uncertain"]) {
|
||||
assert.equal(chimesFor({ err: "paused", outcome }, "attention"), 1, `${outcome} waits on the user`);
|
||||
}
|
||||
assert.equal(chimesFor({}, "success"), 1, "a clean finish keeps the success chime");
|
||||
assert.equal(chimesFor({}, "attention"), 0, "a clean finish does not ring the attention chime");
|
||||
assert.equal(chimesFor({ err: "provider failed" }, "success") + chimesFor({ err: "provider failed" }, "attention"), 0,
|
||||
"an ordinary failure stays silent");
|
||||
owner.dispose();
|
||||
console.log("runtime notifications: paused turn outcomes ring the attention chime");
|
||||
@@ -35,7 +35,9 @@ try {
|
||||
assert.deepEqual([...document.querySelector("header")!.children].map(node => node.className),
|
||||
["topicbar__brand", "btn btn--small", "tooltip-trigger", "topicbar__identity", "topicbar__spacer", "topicbar__actions"],
|
||||
"the Windows bar leads with the app mark");
|
||||
assert.equal(document.querySelector(".topicbar__brand-logo")!.tagName, "IMG", "the leading mark is the app logo asset");
|
||||
const logo = document.querySelector(".topicbar__brand-logo")!;
|
||||
assert.equal(logo.getAttribute("role"), "img", "the leading mark is the app logo");
|
||||
assert.equal(logo.getAttribute("aria-label"), "Reasonix");
|
||||
await paint();
|
||||
const action = document.querySelector(".topicbar__actions button");
|
||||
assert.equal(document.querySelectorAll(".topicbar__subtitle .worktree-badge").length, 1);
|
||||
|
||||
@@ -87,7 +87,7 @@ export function projectConversation({ local, remote, tab, activeTabId, backgroun
|
||||
cacheMissTokens: runtime.usage?.cacheMissTokens, balance: runtime.balance,
|
||||
},
|
||||
context: {
|
||||
tabId: remote ? undefined : activeTabId,
|
||||
tabId: activeTabId,
|
||||
items: runtime.items, context: runtime.context, usage: runtime.usage,
|
||||
sessionTokens: runtime.sessionTokens, sessionCost: runtime.sessionCost,
|
||||
sessionCurrency: runtime.sessionCurrency, turnTokens: runtime.turnTotalTokens,
|
||||
|
||||
@@ -19,6 +19,26 @@ export function projectDecisionSurface(input: {
|
||||
workspaceConflict: WorkspaceConflictView | null;
|
||||
pendingClose: PendingClose;
|
||||
clearContextPending: boolean;
|
||||
remote?: boolean;
|
||||
}): AppDecisionSurfaceKind | null {
|
||||
// A remote session's prompts and forms are answered by RemoteSessionSurface,
|
||||
// the one owner of the serve's exact prompt route; a footer copy would be a
|
||||
// second card whose answer has no local session to reach.
|
||||
if (!input.remote) {
|
||||
const prompt = projectPromptSurface(input);
|
||||
if (prompt) return prompt;
|
||||
}
|
||||
if (input.workspaceConflict) return "workspace_conflict";
|
||||
if (input.pendingClose) return "close_active";
|
||||
if (input.clearContextPending) return "clear_context";
|
||||
return null;
|
||||
}
|
||||
|
||||
function projectPromptSurface(input: {
|
||||
approval: State["approval"];
|
||||
ask: State["ask"];
|
||||
mcpInteraction: State["mcpInteraction"];
|
||||
extensionForm: State["extensionForm"];
|
||||
}): AppDecisionSurfaceKind | null {
|
||||
if (input.approval) {
|
||||
return input.approval.tool === "exit_plan_mode" ? "plan_approval" : "tool_approval";
|
||||
@@ -26,8 +46,5 @@ export function projectDecisionSurface(input: {
|
||||
if (input.ask) return "ask";
|
||||
if (input.mcpInteraction) return "mcp_interaction";
|
||||
if (input.extensionForm) return "extension_form";
|
||||
if (input.workspaceConflict) return "workspace_conflict";
|
||||
if (input.pendingClose) return "close_active";
|
||||
if (input.clearContextPending) return "clear_context";
|
||||
return null;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export type HistoricalPreparationSurface = {
|
||||
operationId: string;
|
||||
status: SessionPreparationView["status"];
|
||||
errorCode?: string;
|
||||
errorDetail?: string;
|
||||
retryable: boolean;
|
||||
revision?: number;
|
||||
isCurrent?: () => boolean;
|
||||
@@ -59,7 +60,7 @@ export function reconcileHistoricalPreparation(expected: HistoricalPreparationSu
|
||||
if (!current || current.operationId !== expected.operationId || current.session !== expected.session
|
||||
|| expected.isCurrent?.() === false || view.operationId !== expected.operationId
|
||||
|| view.revision < (current.revision ?? 0) || view.status === "ready") return;
|
||||
setHistoricalPreparation({ ...current, status: view.status, errorCode: view.errorCode,
|
||||
setHistoricalPreparation({ ...current, status: view.status, errorCode: view.errorCode, errorDetail: view.errorDetail,
|
||||
retryable: view.retryable, revision: view.revision });
|
||||
}
|
||||
export type NavigationNotice = {
|
||||
@@ -148,6 +149,8 @@ export async function executeDesktopNavigation(input: DesktopNavigationCapture,
|
||||
checkpoint();
|
||||
if (connection.sessionSource === "auto") await ports.openChannelSession(target.value, tab.id, seq);
|
||||
else await ports.resumeSession(target.value, tab.id, seq);
|
||||
} else if (target.kind === "bot-session") {
|
||||
tab = await openTopic(connection.scope, connection.workspaceRoot, "", `bot-session:local:${target.value}`);
|
||||
} else tab = await openTopic(connection.scope, connection.workspaceRoot, target.value);
|
||||
checkpoint(); ports.seedTab(tab);
|
||||
await refresh(); checkpoint(); ports.reveal(); ports.projectChanged(); return;
|
||||
|
||||
@@ -216,7 +216,7 @@ export function sidebarImConnectionsFromBot(
|
||||
return qqConnection ? [qqConnection, ...connectionItems] : connectionItems;
|
||||
}
|
||||
|
||||
function mappedSessionTarget(sessionId: string): { kind: "path" | "topic"; value: string } | null {
|
||||
function mappedSessionTarget(sessionId: string): { kind: "path" | "topic" | "bot-session"; value: string } | null {
|
||||
const trimmed = sessionId.trim();
|
||||
if (!trimmed) return null;
|
||||
const lower = trimmed.toLowerCase();
|
||||
@@ -224,6 +224,10 @@ function mappedSessionTarget(sessionId: string): { kind: "path" | "topic"; value
|
||||
const value = trimmed.slice(5).trim();
|
||||
return value ? { kind: "path", value } : null;
|
||||
}
|
||||
if (lower.startsWith("session:local:")) {
|
||||
const value = trimmed.slice("session:local:".length).trim();
|
||||
return value ? { kind: "bot-session", value } : null;
|
||||
}
|
||||
if (lower.startsWith("topic:")) {
|
||||
const value = trimmed.slice(6).trim();
|
||||
return value ? { kind: "topic", value } : null;
|
||||
@@ -240,7 +244,7 @@ export function taskSessionIDFromPath(path: string): string {
|
||||
return extension > 0 ? base.slice(0, extension) : base;
|
||||
}
|
||||
|
||||
export function sidebarImSessionTarget(connection: SidebarImConnection): { kind: "path" | "topic"; value: string } | null {
|
||||
export function sidebarImSessionTarget(connection: SidebarImConnection): { kind: "path" | "topic" | "bot-session"; value: string } | null {
|
||||
return mappedSessionTarget(connection.sessionId);
|
||||
}
|
||||
|
||||
|
||||
@@ -345,8 +345,8 @@ export function useAppSessionComposition(input: AppSessionCompositionInput) {
|
||||
|
||||
const decisionSurface = useMemo((): AppDecisionSurfaceKind | null => projectDecisionSurface({
|
||||
approval: state.approval, ask: state.ask, mcpInteraction: state.mcpInteraction, extensionForm: state.extensionForm,
|
||||
workspaceConflict, pendingClose, clearContextPending,
|
||||
}), [clearContextPending, pendingClose, state.approval, state.ask, state.extensionForm, state.mcpInteraction, workspaceConflict]);
|
||||
workspaceConflict, pendingClose, clearContextPending, remote: remoteSurfaceActive,
|
||||
}), [clearContextPending, pendingClose, remoteSurfaceActive, state.approval, state.ask, state.extensionForm, state.mcpInteraction, workspaceConflict]);
|
||||
const visibleDecisionSurface = decisionSurface;
|
||||
// Navigation used to hide the entire composer until the controller/runtime
|
||||
// activation ticket and the transcript paint ticket both settled. That made
|
||||
|
||||
@@ -31,7 +31,7 @@ export function useRuntimeNotifications(activeTabId: string | undefined) {
|
||||
pending.current = [];
|
||||
};
|
||||
}, [readPorts]);
|
||||
const handleNotification = useCommittedCommand((event: AttentionChimeEvent & { err?: string }) => {
|
||||
const handleNotification = useCommittedCommand((event: AttentionChimeEvent & { err?: string; outcome?: string }) => {
|
||||
if (event.kind === "ask_request" || event.kind === "approval_request" || event.kind === "turn_done") accept({ event });
|
||||
});
|
||||
const resetLegacyAttention = useCommittedCommand((resetTabId?: string) => accept({ resetTabId }));
|
||||
|
||||
@@ -310,6 +310,7 @@ export function AppRuntimeView(props: AppRuntimeViewProps) {
|
||||
})} startupError={navigationCommands.manualCreation && navigationCommands.manualCreation.operation?.phase !== "ready" ? undefined : state.meta?.startupErr}
|
||||
historical={core.remoteSurfaceActive ? undefined : { tab: activeTab, navigate: session.desktopNavigation.enqueueNavigation,
|
||||
captureNavigation: () => { const intent = runtime.navigation.currentNavigationIntent(); return () => runtime.navigation.isNavigationIntentCurrent(intent); },
|
||||
openRecoveryDetails: () => chromeCommands.openSidebarSettings("storage"),
|
||||
}} />
|
||||
|
||||
<ChatPaneRegion
|
||||
|
||||
@@ -45,7 +45,9 @@ export function SidebarRegion(props: SidebarRegionProps) {
|
||||
</button>
|
||||
</div>
|
||||
<section className="sidebar__section sidebar__section--projects">
|
||||
<Suspense fallback={null}><ProjectTree {...props.projectTree} /></Suspense>
|
||||
<Suspense fallback={<div className="project-tree__empty project-tree__empty--subtle" role="status">{t("projectTree.loadingProjects")}</div>}>
|
||||
<ProjectTree {...props.projectTree} />
|
||||
</Suspense>
|
||||
</section>
|
||||
<nav className="sidebar__nav sidebar__nav--footer">
|
||||
<div className="sidebar__utility-row" aria-label={t("sidebar.utilityActions")}>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
import logoSymbol from "../assets/logo-symbol.svg";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { WorktreeBadge } from "../components/WorktreeBadge";
|
||||
|
||||
@@ -36,7 +35,7 @@ export function TopicbarRegion({ view, commands, children }: {
|
||||
const { sidebar, title, subtitle } = view;
|
||||
return <header className="topicbar">
|
||||
{view.brand && <div className="topicbar__brand">
|
||||
<img src={logoSymbol} alt="Reasonix" className="topicbar__brand-logo" draggable={false} />
|
||||
<span role="img" aria-label="Reasonix" className="topicbar__brand-logo" />
|
||||
</div>}
|
||||
{view.automationReturn && <button className="btn btn--small" type="button" onClick={event => {
|
||||
event.currentTarget.focus({ preventScroll: true });
|
||||
|
||||
@@ -144,7 +144,7 @@ function ChatNotice({ node, actions, scroll }: { node: Extract<ChatNode, { kind:
|
||||
// Empty delivery accounting is not a chat result. Keep meaningful records in details.
|
||||
if (summary && !summary.mutations && !summary.changed_files && !summary.checks_passed && !summary.checks_failed) return null;
|
||||
if (item.level === "warn" || item.action === "recover_context") return <div className="chat-notice" role="status" data-level={item.level}>
|
||||
{item.title && <strong>{item.title} </strong>}<ErrorMessage error={item.text} />
|
||||
{item.title && <strong>{item.title} </strong>}<ErrorMessage error={item.text} diagnostic={item.diagnostic} />
|
||||
{summary && <details className="chat-notice__details"><summary>{t("chat.details")}</summary><pre>{JSON.stringify(summary, null, 2)}</pre></details>}
|
||||
{item.detail && <ChatDisclosure label={t("chat.details")}><pre>{item.detail}</pre></ChatDisclosure>}
|
||||
{item.action === "recover_context" && item.recoveryId && <button className="btn" onClick={() => actions.recover(item.recoveryId!)}>{t("notice.protocolRecoveryAction")}</button>}
|
||||
@@ -232,30 +232,36 @@ function ChatAnswer({ node, loader, source, tabId, hostId }: { node: Extract<Cha
|
||||
function ChatReasoning({ node, loader, source, scroll }: { node: Extract<ChatNode, { kind: "reasoning" }>; loader: ChatContentLoader; source: ChatSource; scroll: ChatScrollController }) {
|
||||
const t = useT();
|
||||
const [result, setResult] = useState<{ item: unknown; text: string }>();
|
||||
const [following, setFollowing] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const epoch = useRef(0);
|
||||
useEffect(() => { return () => { epoch.current++; }; }, []);
|
||||
const text = node.item.reasoning;
|
||||
const full = result && (result.item === node.item || result.text === node.item.reasoning) ? result.text : undefined;
|
||||
if (!text) return null;
|
||||
const needsFull = full === undefined && (text.length > 8000 || loader.needsFullContent(node.item, "reasoning"));
|
||||
const streaming = Boolean(node.item.streaming);
|
||||
const settled = result && (result.item === node.item || result.text === node.item.reasoning) ? result.text : undefined;
|
||||
const full = settled ?? (following ? text : undefined);
|
||||
const load = async () => {
|
||||
const ticket = ++epoch.current; setBusy(true); setError(false);
|
||||
try {
|
||||
const value = await loader.load(node.item, "reasoning");
|
||||
const current = source.getNodeSnapshot(node.key);
|
||||
if (ticket !== epoch.current || current?.kind !== "reasoning" || (current.item !== node.item && current.item.reasoning !== value)) throw new Error("Reasoning content changed; retry");
|
||||
setResult({ item: current.item, text: value }); return value;
|
||||
setResult({ item: current.item, text: value }); setFollowing(false); return value;
|
||||
}
|
||||
catch (error) { if (ticket === epoch.current) setError(true); throw error; }
|
||||
finally { if (ticket === epoch.current) setBusy(false); }
|
||||
};
|
||||
useEffect(() => {
|
||||
if (following && !streaming) void load().catch(() => {});
|
||||
}, [following, streaming]);
|
||||
if (!text) return null;
|
||||
const needsFull = error || full === undefined && (text.length > 8000 || loader.needsFullContent(node.item, "reasoning"));
|
||||
return <div className="chat-reasoning"><ReasoningRow text={text} running={node.item.streaming} t={t} beforeToggle={scroll.beforeChange}
|
||||
duration={node.item.reasoningDurationMs != null ? `${(node.item.reasoningDurationMs / 1000).toFixed(1)}s` : undefined}>
|
||||
<div className="chat-reasoning__body">{full ?? text.slice(0, 8000)}
|
||||
{needsFull && <button className="btn" disabled={busy} onClick={() => void load().catch(() => {})}>{t(error ? "chat.loadFailed" : busy ? "chat.loading" : "chat.loadFull")}</button>}
|
||||
<CopyButton getText={() => full === undefined ? load() : full} label={t("chat.copyFull")} />
|
||||
{needsFull && <button className="btn" disabled={busy} onClick={() => streaming ? setFollowing(true) : void load().catch(() => {})}>{t(error ? "chat.loadFailed" : busy ? "chat.loading" : "chat.loadFull")}</button>}
|
||||
<CopyButton getText={() => full ?? (streaming ? text : load())} label={t("chat.copyFull")} />
|
||||
</div></ReasoningRow></div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { presentError } from "../lib/errorPresentation";
|
||||
import type { WireEvent } from "../lib/types";
|
||||
|
||||
/** Inline-safe error surface, including inside paragraphs and existing banners. */
|
||||
export function ErrorMessage({ error, summary, onInspect }: { error: unknown; summary?: string; onInspect?: () => void }) {
|
||||
export function ErrorMessage({ error, summary, diagnostic, onInspect }: { error: unknown; summary?: string; diagnostic?: WireEvent["diagnostic"]; onInspect?: () => void }) {
|
||||
const { t, locale } = useI18n();
|
||||
const id = useId();
|
||||
const [expandedError, setExpandedError] = useState<{ error: unknown } | null>(null);
|
||||
const presentation = presentError(error, t, locale);
|
||||
const presentation = presentError(error, t, locale, diagnostic);
|
||||
const expanded = expandedError !== null && expandedError.error === error;
|
||||
const title = summary || presentation.summary;
|
||||
const detail = error == null || error === "" ? "" : presentation.detail || (summary ? presentation.summary : "");
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useT } from "../lib/i18n";
|
||||
import type { HistoricalImportStatus, HistoricalSessionView, HistoricalSourceUpdateView } from "../generated/desktopContract.generated";
|
||||
import type { SessionRef } from "../lib/sessionRef";
|
||||
import { desktopHost } from "../lib/desktopHost";
|
||||
import { useManagementT, type ManagementKey } from "../lib/managementLocale";
|
||||
import { historicalFailureKey, useManagementT, type ManagementKey } from "../lib/managementLocale";
|
||||
|
||||
const emptyHistoricalStatus: HistoricalImportStatus = { items: [], running: false, paused: false, remaining: 0, completed: 0, blocked: 0, failed: 0 };
|
||||
const historicalStatusKeys: Record<string, ManagementKey> = {
|
||||
@@ -97,7 +97,7 @@ export function HistoricalImportList({ active, onOpenSession }: { active: boolea
|
||||
<div className="archived-sessions__row" key={item.id}>
|
||||
<span>{item.title}</span><small>{item.format}</small>
|
||||
<span>{m(historicalStatusKeys[item.status] ?? "historicalNeedsAttention")}</span>
|
||||
{item.errorCode && <small>{m(item.errorCode === "source_busy" ? "historicalSourceBusy" : "historicalImportFailed")}</small>}
|
||||
{item.errorCode && item.errorCode !== "cancelled" && <small><ErrorMessage error={item.errorDetail ?? ""} summary={m(historicalFailureKey(item.errorCode))} /></small>}
|
||||
<button className="btn btn--small" disabled={!!busy || item.status === "importing" || item.status === "queued" || item.status === "archived" || item.status === "deleted"}
|
||||
onClick={() => void open(item)}>{item.session && onOpenSession ? t("history.openRestored") : m("historicalImportOpen")}</button>
|
||||
{item.session && item.source && <button className="btn btn--small" disabled={!!busy} onClick={() => void checkUpdate(item)}>{t("common.retry")} · {m("historicalTitle")}</button>}
|
||||
|
||||
@@ -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;
|
||||
@@ -1018,7 +1024,7 @@ export function ProjectTree({
|
||||
}
|
||||
}, [onTopicsChanged, refresh, tree]);
|
||||
|
||||
const organization = useProjectTreeOrganization({ tree, refresh, onTopicsChanged, organizationRevision });
|
||||
const organization = useProjectTreeOrganization({ tree, refresh, onTopicsChanged, organizationRevision, sortMode: creationTopics ? "updated" : workbenchSortMode });
|
||||
|
||||
const clearProjectDrag = useCallback(() => {
|
||||
setDragProjectRoot(null);
|
||||
@@ -1588,7 +1594,7 @@ export function ProjectTree({
|
||||
label: t("projectTree.newGroup"),
|
||||
onSelect: () => organization.createGroup(node, t("projectTree.newGroup")),
|
||||
},
|
||||
newSessionMenuItem,
|
||||
newSessionMenuItem, ...organization.resetOrderMenuItems(node, t, closeMenu),
|
||||
...isolatedWorkspaceItems,
|
||||
{
|
||||
key: "rename",
|
||||
@@ -1642,7 +1648,7 @@ export function ProjectTree({
|
||||
: []),
|
||||
];
|
||||
const workbenchProjectMenuItems: ContextMenuItem[] = [
|
||||
newSessionMenuItem,
|
||||
newSessionMenuItem, ...organization.resetOrderMenuItems(node, t, closeMenu),
|
||||
...(scope === "project"
|
||||
? [
|
||||
{
|
||||
@@ -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">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState, lazy, Suspense, type DragEvent, type HTMLAttributes, type ReactNode } from "react";
|
||||
import { Archive, FolderMinus, Pencil } from "lucide-react";
|
||||
import { Archive, Clock, FolderMinus, Pencil } from "lucide-react";
|
||||
import { app } from "../lib/bridge";
|
||||
import { asArray } from "../lib/array";
|
||||
import type { Translator } from "../lib/i18n";
|
||||
@@ -144,6 +144,7 @@ type TopicRowDragProps = Pick<HTMLAttributes<HTMLDivElement>, "draggable" | "onD
|
||||
|
||||
export interface ProjectTreeOrganizationController {
|
||||
orderFor?(folder: ProjectNode): readonly string[];
|
||||
resetOrderMenuItems(folder: ProjectNode, t: Translator, closeMenu: () => void): ContextMenuItem[];
|
||||
topicRow(node: ProjectNode, disabled: boolean): { className: string; props: TopicRowDragProps };
|
||||
topicMenuItems(node: ProjectNode, t: Translator): ContextMenuItem[];
|
||||
createGroup(folder: ProjectNode, title: string): void;
|
||||
@@ -161,15 +162,20 @@ export function useProjectTreeOrganization({
|
||||
refresh,
|
||||
onTopicsChanged,
|
||||
organizationRevision = 0,
|
||||
sortMode,
|
||||
bindings = app,
|
||||
}: {
|
||||
tree: ProjectNode[];
|
||||
refresh: ProjectTreeRefresh;
|
||||
onTopicsChanged?: () => Promise<void> | void;
|
||||
organizationRevision?: number;
|
||||
// The activity order on screen; a first manual move starts from it.
|
||||
sortMode?: string;
|
||||
bindings?: ProjectTreeOrganizationBindings;
|
||||
}): ProjectTreeOrganizationController {
|
||||
const { showToast } = useToast();
|
||||
const sortModeRef = useRef(sortMode);
|
||||
sortModeRef.current = sortMode;
|
||||
const [ordersByKey, setOrdersByKey] = useState<Record<string, string[]>>({});
|
||||
const [dragTopicID, setDragTopicID] = useState<string | null>(null);
|
||||
const [dropTopic, setDropTopic] = useState<{ topicID: string; position: ProjectDropPosition } | null>(null);
|
||||
@@ -313,9 +319,12 @@ export function useProjectTreeOrganization({
|
||||
const position = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
|
||||
const folder = tree.find(folder => projectTreeOrganizationKey(folder) === key);
|
||||
const dragged = folder?.children?.find(row => projectSessionIdentity(row) === draggedID);
|
||||
if (bindings.UpdateSessionOrganization && dragged) {
|
||||
const targetGroup = (groupsRef.current[key] ?? []).find((group) => projectTreeGroupContainsNode(group, node));
|
||||
if (dragged && targetGroup && !projectTreeGroupContainsNode(targetGroup, dragged)) {
|
||||
mutateGroups(key, (groups) => moveNodeToGroup(groups, dragged, targetGroup.id), { kind: "set-group", target: projectNodeSelector(dragged), groupId: targetGroup.id });
|
||||
} else if (bindings.UpdateSessionOrganization && dragged) {
|
||||
void mutateSessionOrganization(bindings, { scope: context.scope, workspaceRoot: context.root, hostId: splitOrganizationKey(key).hostId },
|
||||
{ kind: "move", target: projectNodeSelector(dragged), anchor: projectNodeSelector(node), position })
|
||||
{ kind: "move", target: projectNodeSelector(dragged), anchor: projectNodeSelector(node), position, sortMode: sortModeRef.current })
|
||||
.then(saved => { setOrdersByKey(current => ({ ...current, [key]: saved.order })); return refresh({ reloadAllTopics: true }); })
|
||||
.then(() => onTopicsChanged?.()).catch(error => { showToast(String(error), "error"); loadGroups(key, true); return refresh({ reloadAllTopics: true }); });
|
||||
} else {
|
||||
@@ -326,7 +335,7 @@ export function useProjectTreeOrganization({
|
||||
};
|
||||
props.onDragEnd = clearTopicDrag;
|
||||
return { className, props };
|
||||
}, [bindings, clearTopicDrag, dragTopicID, dropTopic, loadGroups, onTopicsChanged, refresh, showToast, tree]);
|
||||
}, [bindings, clearTopicDrag, dragTopicID, dropTopic, loadGroups, mutateGroups, onTopicsChanged, refresh, showToast, tree]);
|
||||
|
||||
const removeTopicFromGroups = useCallback((node: ProjectNode) => {
|
||||
const key = projectTreeOrganizationKey(node);
|
||||
@@ -340,6 +349,13 @@ export function useProjectTreeOrganization({
|
||||
|
||||
return {
|
||||
orderFor(folder) { return ordersByKey[projectTreeOrganizationKey(folder)] ?? []; },
|
||||
resetOrderMenuItems(folder, t, closeMenu) {
|
||||
const key = projectTreeOrganizationKey(folder);
|
||||
return (ordersByKey[key]?.length ?? 0) > 0 ? [{
|
||||
key: "reset-manual-order", icon: <Clock size={13} />, label: t("projectTree.resetManualOrder"),
|
||||
onSelect: () => { closeMenu(); mutateGroups(key, (groups) => groups, { kind: "reset-order" }); },
|
||||
}] : [];
|
||||
},
|
||||
topicRow,
|
||||
topicMenuItems(node, t) {
|
||||
if (!(groupsRef.current[projectTreeOrganizationKey(node)] ?? []).some((group) => projectTreeGroupContainsNode(group, node))) return [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { app } from "../lib/bridge";
|
||||
import type { SessionTakeoverView, TabMeta } from "../lib/types";
|
||||
import type { HistoricalSourceUpdateView, SessionPreparationView } from "../generated/desktopContract.generated";
|
||||
import { historicalPreparationSnapshot, reconcileHistoricalPreparation, subscribeHistoricalPreparation, type DesktopNavigationIntent } from "../app-runtime/desktopNavigationOwner";
|
||||
import { useManagementT } from "../lib/managementLocale";
|
||||
import { historicalFailureKey, useManagementT } from "../lib/managementLocale";
|
||||
|
||||
/**
|
||||
* SessionTakeoverDialog confirms taking a lease-blocked session over from the
|
||||
@@ -132,15 +132,17 @@ export type HistoricalSessionBannerProps = {
|
||||
tab?: TabMeta;
|
||||
navigate(intent: DesktopNavigationIntent): Promise<void>;
|
||||
captureNavigation?(): () => boolean;
|
||||
openRecoveryDetails?(): void;
|
||||
};
|
||||
export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: HistoricalSessionBannerProps) {
|
||||
type HistoricalFailure = { summary: string; detail?: unknown };
|
||||
export function HistoricalSessionBanners({ tab, navigate, captureNavigation, openRecoveryDetails }: HistoricalSessionBannerProps) {
|
||||
const t = useT();
|
||||
const m = useManagementT();
|
||||
const activeRef = tab?.session ?? (tab?.sessionId ? { hostId: "local", sessionId: tab.sessionId } : undefined);
|
||||
const preparation = useSyncExternalStore(subscribeHistoricalPreparation, historicalPreparationSnapshot);
|
||||
const [update, setUpdate] = useState<HistoricalSourceUpdateView | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [updateError, setUpdateError] = useState("");
|
||||
const [updateError, setUpdateError] = useState<HistoricalFailure | null>(null);
|
||||
const updateOperation = useRef(0);
|
||||
const [cancellingOperationId, setCancellingOperationId] = useState("");
|
||||
const cancellingOperationRef = useRef("");
|
||||
@@ -158,7 +160,7 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
|
||||
let current = true;
|
||||
updateOperation.current++;
|
||||
setUpdate(null);
|
||||
setUpdateError("");
|
||||
setUpdateError(null);
|
||||
setBusy(false);
|
||||
if (!activeSessionId || activeHostId !== "local" || !app.CheckHistoricalSourceUpdate) return () => { current = false; };
|
||||
const ref = { hostId: activeHostId, sessionId: activeSessionId };
|
||||
@@ -200,7 +202,7 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
|
||||
const navigationCurrent = captureNavigation?.() ?? (() => activeKeyRef.current === expectedActive);
|
||||
const current = () => mounted.current && operation === updateOperation.current && activeKeyRef.current === expectedActive && navigationCurrent();
|
||||
setBusy(true);
|
||||
setUpdateError("");
|
||||
setUpdateError(null);
|
||||
try {
|
||||
let view = await start();
|
||||
while (current() && !terminalPreparation.has(view.status)) {
|
||||
@@ -209,13 +211,17 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
|
||||
view = await app.GetSessionPreparation(view.operationId);
|
||||
}
|
||||
if (!current()) return;
|
||||
if (view.status === "ready" && view.target) {
|
||||
await navigate({ kind: "canonical-session", ref: view.target });
|
||||
} else {
|
||||
setUpdateError(m(view.errorCode === "source_busy" ? "historicalSourceBusy" : "historicalImportFailed"));
|
||||
if (view.status !== "ready" || !view.target) {
|
||||
setUpdateError({ summary: m(historicalFailureKey(view.errorCode)), detail: view.errorDetail });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
if (current()) setUpdateError(m("historicalImportFailed"));
|
||||
try {
|
||||
await navigate({ kind: "canonical-session", ref: view.target });
|
||||
} catch (error) {
|
||||
if (current()) setUpdateError({ summary: m("historicalOpenFailed"), detail: error });
|
||||
}
|
||||
} catch (error) {
|
||||
if (current()) setUpdateError({ summary: m("historicalImportFailed"), detail: error });
|
||||
}
|
||||
finally { if (mounted.current && operation === updateOperation.current) setBusy(false); }
|
||||
};
|
||||
@@ -234,27 +240,34 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
|
||||
}
|
||||
};
|
||||
|
||||
const failureNotice = (failure: HistoricalFailure) => <ErrorMessage error={failure.detail ?? ""} summary={failure.summary} />;
|
||||
const recoveryDetails = openRecoveryDetails
|
||||
? <button type="button" className="btn btn--small btn--ghost" onClick={openRecoveryDetails}>{m("historicalRecoveryDetails")}</button> : null;
|
||||
if (preparation) {
|
||||
const waiting = preparation.status === "queued" || preparation.status === "preparing";
|
||||
return <div className={`banner ${waiting ? "banner--warning" : "banner--error"} banner--actionable`} role="status">
|
||||
<span className="banner__msg">{m("historicalImporting")}: {preparation.session.title || preparation.session.topicId || m("historicalTitle")}</span>
|
||||
<span className="banner__hint">{m(preparation.status === "queued" ? "historicalQueued" : preparation.status === "preparing" ? "historicalImporting" : "historicalImportFailed")}</span>
|
||||
<span className="banner__hint">{waiting ? m(preparation.status === "queued" ? "historicalQueued" : "historicalImporting")
|
||||
: failureNotice({ summary: m(historicalFailureKey(preparation.errorCode)), detail: preparation.errorDetail })}</span>
|
||||
<span className="banner__spacer" />
|
||||
{waiting && <button type="button" className="btn btn--small" disabled={cancellingOperationId === preparation.operationId} onClick={() => void cancelPreparation()}>{t("common.cancel")}</button>}
|
||||
{!waiting && recoveryDetails}
|
||||
{!waiting && preparation.retryable && <button type="button" className="btn btn--small" onClick={() => void navigate({ kind: "resume-session", session: preparation.session })}>{t("common.retry")}</button>}
|
||||
</div>;
|
||||
}
|
||||
if (historicalSource) return <div className={`banner ${updateError ? "banner--error" : "banner--warning"} banner--actionable`} role={updateError ? "alert" : "status"}>
|
||||
<span className="banner__msg">{tab?.topicTitle || m("historicalTitle")} · {m("historicalAvailable")}</span>
|
||||
<span className="banner__hint">{updateError ? <ErrorMessage error={updateError} /> : m(busy ? "historicalImporting" : "historicalImportToSend")}</span>
|
||||
<span className="banner__hint">{updateError ? failureNotice(updateError) : m(busy ? "historicalImporting" : "historicalImportToSend")}</span>
|
||||
<span className="banner__spacer" />
|
||||
{updateError && recoveryDetails}
|
||||
<button id="reasonix-prepare-restored-session" type="button" className="btn btn--small" disabled={busy} onClick={() => void importSource()}>{m("historicalImportOpen")}</button>
|
||||
</div>;
|
||||
if (!update) return null;
|
||||
return <div className={`banner ${updateError ? "banner--error" : "banner--warning"} banner--actionable`} role={updateError ? "alert" : "status"}>
|
||||
<span className="banner__msg">{m("historicalSourceUpdated")}</span>
|
||||
{(updateError || busy) && <span className="banner__hint">{updateError ? <ErrorMessage error={updateError} /> : m("historicalImporting")}</span>}
|
||||
{(updateError || busy) && <span className="banner__hint">{updateError ? failureNotice(updateError) : m("historicalImporting")}</span>}
|
||||
<span className="banner__spacer" />
|
||||
{updateError && recoveryDetails}
|
||||
<button type="button" className="btn btn--small" disabled={busy} onClick={() => void importUpdate()}>{m("historicalImportOpen")} · {m("branch")}</button>
|
||||
<button type="button" className="btn btn--small" disabled={busy} onClick={dismissUpdate}>{t("updater.dismiss")}</button>
|
||||
</div>;
|
||||
|
||||
@@ -6652,6 +6652,19 @@ function HooksSection({ onChanged }: { onChanged: (settings?: SettingsView | nul
|
||||
setPathMessage(t("settings.hooksJsonClipboardUnavailable"));
|
||||
}
|
||||
};
|
||||
const approve = async () => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await app.TrustProjectHooksForRoot(view?.projectRoot?.trim() ?? "");
|
||||
await load(scope);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setErr(String((e as Error)?.message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
@@ -6694,6 +6707,12 @@ function HooksSection({ onChanged }: { onChanged: (settings?: SettingsView | nul
|
||||
title={t("settings.hooks")}
|
||||
description={scope === "project" ? t("settings.hooksProjectHint") : t("settings.hooksGlobalHint")}
|
||||
>
|
||||
{view && scope === "project" && !view.trusted && view.hooks.length > 0 && (
|
||||
<div className="banner banner--warning" role="status">
|
||||
<span>{t("settings.hooksAwaitingApproval")}</span>
|
||||
<button className="btn btn--small" disabled={busy} onClick={() => void approve()}>{t("settings.hooksApprove")}</button>
|
||||
</div>
|
||||
)}
|
||||
{view && (
|
||||
<div className="hooks-json-panel">
|
||||
<div className="hooks-json-panel__head">
|
||||
|
||||
@@ -31177,6 +31177,13 @@
|
||||
"kind": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "errorDetail",
|
||||
"optional": true,
|
||||
"type": {
|
||||
"kind": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "session",
|
||||
"optional": true,
|
||||
@@ -40676,6 +40683,13 @@
|
||||
"type": {
|
||||
"kind": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sortMode",
|
||||
"optional": true,
|
||||
"type": {
|
||||
"kind": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -40788,6 +40802,13 @@
|
||||
"kind": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "errorDetail",
|
||||
"optional": true,
|
||||
"type": {
|
||||
"kind": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "retryable",
|
||||
"type": {
|
||||
@@ -45032,6 +45053,13 @@
|
||||
"type": {
|
||||
"kind": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "transportCode",
|
||||
"optional": true,
|
||||
"type": {
|
||||
"kind": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
export const DESKTOP_PROTOCOL_VERSION = 11;
|
||||
|
||||
export const DESKTOP_CONTRACT_DIGEST = "sha256:857039e0868d57e1e367b080f009abd2d21baa856ae5d57e55fc8c3b1cef5734";
|
||||
export const DESKTOP_CONTRACT_DIGEST = "sha256:210f66b66e33ec4c371de07c74cfd69d22a831b23cbf6ef5dd44f2b156689241";
|
||||
|
||||
export const DESKTOP_COMMANDS = [
|
||||
"AIRenameSession",
|
||||
@@ -2595,6 +2595,7 @@ export interface HistoricalSessionView {
|
||||
format: string;
|
||||
status: string;
|
||||
errorCode?: string;
|
||||
errorDetail?: string;
|
||||
session?: SessionRef | null;
|
||||
source?: SessionSourceRef | null;
|
||||
}
|
||||
@@ -4370,6 +4371,7 @@ export interface SessionOrganizationMutation {
|
||||
position?: string;
|
||||
groupId?: string;
|
||||
title?: string;
|
||||
sortMode?: string;
|
||||
}
|
||||
|
||||
export interface SessionOrganizationSnapshot {
|
||||
@@ -4393,6 +4395,7 @@ export interface SessionPreparationView {
|
||||
revision: number;
|
||||
target?: SessionRef | null;
|
||||
errorCode?: string;
|
||||
errorDetail?: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
@@ -5202,6 +5205,7 @@ export interface FailureDiagnostic {
|
||||
providerDisplayName?: string;
|
||||
protocol?: string;
|
||||
requestPath?: string;
|
||||
transportCode?: string;
|
||||
}
|
||||
|
||||
export interface MCPAppPresentation {
|
||||
|
||||
@@ -4672,10 +4672,10 @@ function makeMockApp(): MockAppBindings {
|
||||
hookSettings[key].hooks = JSON.parse(JSON.stringify(hooks)) as HookConfigView[];
|
||||
},
|
||||
async TrustProjectHooks() {
|
||||
// Compatibility no-op: project hooks are enabled automatically.
|
||||
hookSettings.project.trusted = true;
|
||||
},
|
||||
async TrustProjectHooksForRoot(_projectRoot: string) {
|
||||
// Compatibility no-op: project hooks are enabled automatically.
|
||||
hookSettings.project.trusted = true;
|
||||
},
|
||||
async SetDefaultModel(ref: string) {
|
||||
settings.defaultModel = ref;
|
||||
|
||||
@@ -36,7 +36,7 @@ const sameReferences = (a: readonly unknown[] = [], b: readonly unknown[] = [])
|
||||
a.length === b.length && a.every((item, index) => item === b[index]);
|
||||
const shallowSame = (a: object, b: object) => Object.keys(a).length === Object.keys(b).length
|
||||
&& Object.entries(a).every(([key, value]) => value === (b as Record<string, unknown>)[key]);
|
||||
const foldViews = new Map<string, Set<string>>();
|
||||
const foldViews = new Map<string, Map<string, boolean>>();
|
||||
const emptyChildren: readonly Extract<ChatNode, { kind: "tool" }>[] = [];
|
||||
function proxyAuditCall(item: Item): string | undefined {
|
||||
if (item.kind !== "notice" || item.code !== "capability_proxy_audit") return undefined;
|
||||
@@ -109,11 +109,11 @@ export class ChatSource implements ChatViewSource {
|
||||
private epoch = 0;
|
||||
private input?: ChatInput;
|
||||
private status: ChatStatus = { running: false, hydrating: true, hasOlder: false, loadingOlder: false };
|
||||
private opened = new Set<string>();
|
||||
private choices = new Map<string, boolean>();
|
||||
private displayKeyBySubmission = new Map<string, string>();
|
||||
private displayMessageBySubmission = new Map<string, string>();
|
||||
private displayKeyByMessage = new Map<string, string>();
|
||||
constructor(readonly sessionKey: string) { this.opened = new Set(foldViews.get(sessionKey)); }
|
||||
constructor(readonly sessionKey: string) { this.choices = new Map(foldViews.get(sessionKey)); }
|
||||
getOrderSnapshot = () => this.order;
|
||||
getNodeSnapshot = (key: string) => this.nodes.get(key);
|
||||
getStatusSnapshot = () => this.status;
|
||||
@@ -207,11 +207,13 @@ export class ChatSource implements ChatViewSource {
|
||||
const hasProcess = current.items.some(item => item.kind === "tool" || item.kind === "phase" ||
|
||||
item.kind === "assistant" && (item !== answer && item.text.trim() || item.reasoning.trim()));
|
||||
const hasTrailingWork = current.items.slice(answerIndex + 1).some(item => item.kind === "tool" || item.kind === "phase" || item.kind === "assistant");
|
||||
const foldable = Boolean(hasProcess && current.user && answer && !hasTrailingWork && !active && !failed && !current.user.failed);
|
||||
// Every ended turn can fold by hand; only a settled one starts folded.
|
||||
const settled = Boolean(answer && !hasTrailingWork && !failed && !current.user?.failed);
|
||||
const foldable = Boolean(hasProcess && current.user && !active);
|
||||
const allCalls = current.items.filter((item): item is Extract<Item, { kind: "tool" }> => item.kind === "tool");
|
||||
const calls = allCalls.filter(item => !item.parentId);
|
||||
const subagentCount = calls.filter(item => ["task", "read_only_task", "parallel_tasks", "fleet", "subagent"].includes(item.name)).length;
|
||||
add({ kind: "process", key: processKey, turnKey, members: stableMembers, foldable, collapsed: foldable && !this.opened.has(turnKey),
|
||||
add({ kind: "process", key: processKey, turnKey, members: stableMembers, foldable, collapsed: foldable && !(this.choices.get(turnKey) ?? !settled),
|
||||
toolCallCount: calls.length - subagentCount, subagentCount,
|
||||
messageCount: current.items.filter(item => item.kind === "assistant" && item !== answer && item.text.trim()).length,
|
||||
failureCount: calls.filter(item => item.status === "error" || item.error).length });
|
||||
@@ -264,7 +266,7 @@ export class ChatSource implements ChatViewSource {
|
||||
this.children.set(key, next); this.dirty.add(`${key}:children`);
|
||||
}
|
||||
}
|
||||
for (const key of this.opened) if (!groupKeys.has(key)) this.opened.delete(key);
|
||||
for (const key of this.choices.keys()) if (!groupKeys.has(key)) this.choices.delete(key);
|
||||
if (!sameKeys(this.order, order)) { this.order = order; this.orderDirty = true; }
|
||||
recordFrontendDiagnostic("transcript", "presentation", this.presentationStats());
|
||||
}
|
||||
@@ -308,10 +310,12 @@ export class ChatSource implements ChatViewSource {
|
||||
toggleProcess(turnKey: string) {
|
||||
const node = this.nodes.get(`${turnKey}:process`);
|
||||
if (node?.kind !== "process") return;
|
||||
if (node.collapsed) this.opened.add(turnKey); else this.opened.delete(turnKey);
|
||||
foldViews.delete(this.sessionKey); foldViews.set(this.sessionKey, new Set(this.opened));
|
||||
if (!node.foldable) return;
|
||||
const open = node.collapsed;
|
||||
this.choices.set(turnKey, open);
|
||||
foldViews.delete(this.sessionKey); foldViews.set(this.sessionKey, new Map(this.choices));
|
||||
if (foldViews.size > 100) foldViews.delete(foldViews.keys().next().value!);
|
||||
this.put({ ...node, collapsed: node.foldable && !this.opened.has(turnKey) });
|
||||
this.put({ ...node, collapsed: !open });
|
||||
this.flush();
|
||||
}
|
||||
toolChildren(id: string) { return this.children.get(id) ?? emptyChildren; }
|
||||
|
||||
@@ -4,12 +4,12 @@ import type { HistoryMessage, WireFinalReadiness, WireDecisionReceipt } from "./
|
||||
import type { Item } from "./useController";
|
||||
import { readPauseItem } from "./readPause";
|
||||
|
||||
export function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt): { items: Item[]; seq: number } {
|
||||
export function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt, diagnostic?: HistoryMessage["diagnostic"]): { items: Item[]; seq: number } {
|
||||
if (quietTranscriptNoticeKey(rawText, code)) return { items, seq };
|
||||
const text = localizedNoticeText(rawText, code);
|
||||
if (quietTranscriptNoticeKey(text, code)) return { items, seq };
|
||||
const trimmedDetail = detail?.trim();
|
||||
return { items: [...items, { kind: "notice", id, level, text, ...(trimmedDetail ? { detail: trimmedDetail } : {}), ...(code ? { code } : {}), ...(decisionReceipt ? { decisionReceipt } : {}) }], seq: seq + 1 };
|
||||
return { items: [...items, { kind: "notice", id, level, text, ...(diagnostic ? { diagnostic } : {}), ...(trimmedDetail ? { detail: trimmedDetail } : {}), ...(code ? { code } : {}), ...(decisionReceipt ? { decisionReceipt } : {}) }], seq: seq + 1 };
|
||||
}
|
||||
|
||||
export function errorMessage(err: unknown): string {
|
||||
@@ -190,5 +190,5 @@ export function historyNoticeItems(m: HistoryMessage, id: string): Item[] {
|
||||
if (quietTranscriptNoticeKey(text, m.code)) return [];
|
||||
const detail = m.detail?.trim();
|
||||
return [{ kind: "notice", id, level: m.level === "warn" ? "warn" : "info", text,
|
||||
...(detail ? { detail } : {}), ...(m.decisionReceipt ? { decisionReceipt: m.decisionReceipt } : {}) }];
|
||||
...(m.diagnostic ? { diagnostic: m.diagnostic } : {}), ...(detail ? { detail } : {}), ...(m.decisionReceipt ? { decisionReceipt: m.decisionReceipt } : {}) }];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t, getLocale, type DictKey, type Translator, type Locale } from "./i18n";
|
||||
import type { WireEvent } from "./types";
|
||||
|
||||
export interface ErrorPresentation { summary: string; detail: string }
|
||||
|
||||
@@ -24,6 +25,7 @@ const codeKeys: Record<string, DictKey> = {
|
||||
invalid_api_key: "error.auth", insufficient_quota: "error.quota", rate_limit_exceeded: "error.rateLimit",
|
||||
stream_interrupted: "error.interrupted", empty_response: "error.empty", stale_generation: "error.conflict",
|
||||
provider_connection: "error.connection", cancelled: "error.cancelled",
|
||||
transport_protocol: "error.transportProtocol",
|
||||
};
|
||||
|
||||
// Narrow legacy matches bridge errors from older services and OS libraries.
|
||||
@@ -61,8 +63,11 @@ function statusKey(status: number): DictKey | undefined {
|
||||
if (status === 400 || status === 422) return "error.invalidInput";
|
||||
}
|
||||
|
||||
export function presentError(error: unknown, translate: Translator = t, locale: Locale = getLocale()): ErrorPresentation {
|
||||
export function presentError(error: unknown, translate: Translator = t, locale: Locale = getLocale(), diagnostic?: WireEvent["diagnostic"]): ErrorPresentation {
|
||||
const detail = errorDetail(error).trim();
|
||||
if (diagnostic?.kind === "transport_protocol") {
|
||||
return { summary: translate("error.transportProtocol"), detail: [detail, diagnostic.transportCode ? `HTTP/2: ${diagnostic.transportCode}` : ""].filter(Boolean).join("\n") };
|
||||
}
|
||||
const structured = error && typeof error === "object" ? error as { code?: unknown; status?: unknown } : undefined;
|
||||
const code = typeof structured?.code === "string" ? structured.code : detail;
|
||||
const explicitStatus = typeof structured?.status === "number" ? structured.status : 0;
|
||||
|
||||
@@ -102,7 +102,7 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
|
||||
continue;
|
||||
}
|
||||
if (m.content.trim() !== "" || m.decisionReceipt) {
|
||||
const next = appendNoticeItem(items, seq, recordItemId, m.level === "warn" ? "warn" : "info", m.content, m.detail, m.code, m.decisionReceipt);
|
||||
const next = appendNoticeItem(items, seq, recordItemId, m.level === "warn" ? "warn" : "info", m.content, m.detail, m.code, m.decisionReceipt, m.diagnostic);
|
||||
items = next.items;
|
||||
seq = next.seq;
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export async function loadHistoryWindow(input: LoadInput): Promise<HistoryWindow
|
||||
return "empty";
|
||||
}
|
||||
if (!result) { input.dispatch({ type: "history_older_error", error: "history page unavailable" }); return "empty"; }
|
||||
if (!fingerprintMatches(expectedRevision, result.revisionKnown ? result.revision : undefined) || !digestMatches(expectedDigest, result.digest)) {
|
||||
if (result.kind !== "reload" && (!fingerprintMatches(expectedRevision, result.revisionKnown ? result.revision : undefined) || !digestMatches(expectedDigest, result.digest))) {
|
||||
input.dispatch({ type: "history_older_error", error: "history identity changed" });
|
||||
return "empty";
|
||||
}
|
||||
|
||||
@@ -27,6 +27,16 @@ export function applyHydrateErrorState<
|
||||
};
|
||||
}
|
||||
|
||||
/** The recovery detail for a failed history read: the summary, then the reader's own identity and cause. */
|
||||
export function hydrateFailureDetail(summary: string, cause: unknown): string {
|
||||
const message = (cause instanceof Error ? cause.message : typeof cause === "string" ? cause : "").trim();
|
||||
const failure = cause as { stage?: unknown; reason?: unknown } | undefined;
|
||||
const identity = cause instanceof Error && typeof failure?.stage === "string" && typeof failure.reason === "string"
|
||||
? `${failure.stage}.${failure.reason}` : "";
|
||||
const line = [identity, message].filter(Boolean).join(": ");
|
||||
return line ? `${summary}\n${line}` : summary;
|
||||
}
|
||||
|
||||
export function hydratePlaceholderItems<TItem>(
|
||||
optionsItems: TItem[] | undefined,
|
||||
): TItem[] | undefined {
|
||||
|
||||
@@ -49,6 +49,14 @@ const messages = {
|
||||
historicalSourceBusy: ["其他实例正在使用此会话,关闭后可重试。", "其他實例正在使用此對話,關閉後可重試。", "In use by another instance. Close it and retry."],
|
||||
historicalImportFailed: ["导入失败,原件已保留。可重试或查看恢复详情。", "匯入失敗,原件已保留。可重試或檢視恢復詳情。", "Import failed. Original files are preserved; retry or review recovery details."],
|
||||
historicalAvailable: ["尚未导入", "尚未匯入", "Not imported"],
|
||||
historicalFailureWorkspaceConflict: ["会话记录的项目路径与当前项目不一致,已停止导入以免放错项目。原件已保留。", "對話記錄的專案路徑與目前專案不一致,已停止匯入以免放錯專案。原件已保留。", "The conversation records a different project folder than this project, so it was not imported. Original files are preserved."],
|
||||
historicalFailureTooLarge: ["会话历史超过安全回放上限,无法导入。原件已保留。", "對話歷史超過安全重播上限,無法匯入。原件已保留。", "The conversation history exceeds the safe replay limit and cannot be imported. Original files are preserved."],
|
||||
historicalFailureDamaged: ["会话历史已损坏或不完整,无法导入。原件已保留。", "對話歷史已損壞或不完整,無法匯入。原件已保留。", "The conversation history is damaged or incomplete and cannot be imported. Original files are preserved."],
|
||||
historicalFailureUnsupported: ["会话由不支持的存储格式写入,无法导入。原件已保留。", "對話由不支援的儲存格式寫入,無法匯入。原件已保留。", "The conversation was written in an unsupported storage format. Original files are preserved."],
|
||||
historicalFailureStateConflict: ["导入记录与已登记的会话冲突,已停止以免覆盖。原件已保留。", "匯入記錄與已登記的對話衝突,已停止以免覆寫。原件已保留。", "The import conflicts with an already registered conversation and was stopped. Original files are preserved."],
|
||||
historicalFailureSourceChanged: ["导入期间原始会话发生了变化,请重试。", "匯入期間原始對話發生了變化,請重試。", "The original conversation changed during import. Retry."],
|
||||
historicalOpenFailed: ["已导入,但打开会话失败。可重试。", "已匯入,但開啟對話失敗。可重試。", "Imported, but the conversation could not be opened. Retry."],
|
||||
historicalRecoveryDetails: ["查看恢复详情", "檢視恢復詳情", "Review recovery details"],
|
||||
historicalImportToSend: ["导入后才能继续发送消息,原始文件会保留。", "匯入後才能繼續傳送訊息,原始檔案會保留。", "Import this conversation to keep sending messages. The original file is kept."],
|
||||
historicalSourceUpdated: ["旧来源有更新,可导入为独立分支", "舊來源有更新,可匯入為獨立分支", "Historical source changed. Import as a separate branch."],
|
||||
historicalQueued: ["排队中", "排隊中", "Queued"],
|
||||
@@ -62,6 +70,18 @@ const messages = {
|
||||
branch: ["分支", "分支", "Branch"],
|
||||
} as const;
|
||||
export type ManagementKey = keyof typeof messages;
|
||||
const historicalFailureKeys: Record<string, ManagementKey> = {
|
||||
source_busy: "historicalSourceBusy",
|
||||
workspace_conflict: "historicalFailureWorkspaceConflict",
|
||||
history_too_large: "historicalFailureTooLarge",
|
||||
history_damaged: "historicalFailureDamaged",
|
||||
unsupported_version: "historicalFailureUnsupported",
|
||||
state_conflict: "historicalFailureStateConflict",
|
||||
target_changed: "historicalFailureSourceChanged",
|
||||
};
|
||||
export function historicalFailureKey(code?: string): ManagementKey {
|
||||
return (code && historicalFailureKeys[code]) || "historicalImportFailed";
|
||||
}
|
||||
export function useManagementT() {
|
||||
const { locale } = useI18n();
|
||||
return useCallback((key: ManagementKey, vars?: Record<string, string | number>) => {
|
||||
|
||||
@@ -3,7 +3,11 @@ import { attentionChimeEventKey, clearAttentionChimeKeys, playAttentionChime, pl
|
||||
import type { Translator } from "./i18n";
|
||||
import type { ToastContextValue } from "./toast";
|
||||
|
||||
export type NotificationOperation = { event: AttentionChimeEvent & { err?: string } } | { resetTabId?: string };
|
||||
export type NotificationOperation = { event: AttentionChimeEvent & { err?: string; outcome?: string } } | { resetTabId?: string };
|
||||
|
||||
// Outcomes where the turn stopped to wait on the user; the host records them as
|
||||
// paused or awaiting_delivery (desktop/topic_status.go), never as a finish.
|
||||
const PAUSED_TURN_OUTCOMES = new Set(["final_readiness", "recovery_paused", "incomplete_read", "completion_uncertain"]);
|
||||
type NotificationPorts = { activeTabId: string | undefined; t: Translator; showToast: ToastContextValue["showToast"] };
|
||||
|
||||
/** One owner deduplicates view events and authoritative background snapshots. */
|
||||
@@ -72,7 +76,9 @@ export function createRuntimeNotifications(readPorts: () => NotificationPorts |
|
||||
clearAttentionChimeKeys(seen, operation.resetTabId);
|
||||
clearAttentionChimeKeys(pendingSeen, operation.resetTabId);
|
||||
} else if (operation.event.kind === "turn_done") {
|
||||
if (readPorts() && !operation.event.err) playSuccessChime();
|
||||
if (!readPorts()) return;
|
||||
if (PAUSED_TURN_OUTCOMES.has(operation.event.outcome ?? "")) playAttentionChime();
|
||||
else if (!operation.event.err) playSuccessChime();
|
||||
} else handleAttention(operation.event);
|
||||
},
|
||||
start() { stop = runtimeStateStore.subscribe(handleSnapshot); handleSnapshot(); },
|
||||
|
||||
@@ -449,7 +449,7 @@ export interface WireEvent extends RecoveryEventFields {
|
||||
outcome?: "completed" | "partial" | "blocked" | "final_readiness" | "recovery_paused" | "completion_uncertain" | "incomplete_read";
|
||||
readiness?: WireFinalReadiness;
|
||||
protocolRecovery?: { id: string };
|
||||
diagnostic?: { kind: string; status?: number; traceId?: string; providerId?: string; providerDisplayName?: string; protocol?: string; requestPath?: string };
|
||||
diagnostic?: { kind: string; status?: number; traceId?: string; providerId?: string; providerDisplayName?: string; protocol?: string; requestPath?: string; transportCode?: string };
|
||||
/** Optional: "headers" | "stream". Older clients ignore unknown fields. */
|
||||
retryScope?: "headers" | "stream" | "protocol";
|
||||
streamAttempt?: WireStreamAttempt;
|
||||
@@ -856,7 +856,7 @@ export interface HistoryMessage extends TranscriptTurnMetadata {
|
||||
decisionReceipt?: WireDecisionReceipt;
|
||||
readiness?: WireFinalReadiness;
|
||||
protocolRecovery?: { id: string };
|
||||
diagnostic?: { kind: string; status?: number; traceId?: string; providerId?: string; providerDisplayName?: string; protocol?: string; requestPath?: string };
|
||||
diagnostic?: { kind: string; status?: number; traceId?: string; providerId?: string; providerDisplayName?: string; protocol?: string; requestPath?: string; transportCode?: string };
|
||||
serverSearch?: HistoryServerSearch[];
|
||||
attachments?: Array<{ kind?: string; digest?: string; name?: string; mime?: string; width?: number; height?: number; bytes?: number }>;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ import {
|
||||
} from "./controllerNotices";
|
||||
import { applyReadStatusEvent, type ReadStatusHost } from "./readStatus";
|
||||
import { upsertReadPause } from "./readPause";
|
||||
import { applyHydrateErrorState, hydratePlaceholderItems as resolveHydratePlaceholders } from "./hydrateErrorState";
|
||||
import { applyHydrateErrorState, hydrateFailureDetail, hydratePlaceholderItems as resolveHydratePlaceholders } from "./hydrateErrorState";
|
||||
import { canAdoptUnboundLiveSurface, hasCachedLiveTurn, hasReusableCachedTranscript, sameSessionHydrateIdentity, sameSessionPlaceholderItems, type HydrateSurfacePolicy } from "./hydrateHistoryApply";
|
||||
import { useSessionCatalogActions } from "./useSessionCatalogActions";
|
||||
import { hydrateIdentityCurrent, sessionIdentityFields, sessionIdentityRoute, sessionIdentityStableKey, type SessionHydrationOptions } from "./sessionIdentity";
|
||||
@@ -340,7 +340,7 @@ export type Item = { turnId?: string } & (
|
||||
| { kind: "user"; id: string; messageId?: string; submissionId?: string; submissionState?: "sending" | "confirmed" | "failed" | "unknown"; text: string; submitText?: string; failed?: boolean; createdAt?: number; checkpointTurn?: number; historyTurn?: number }
|
||||
| { kind: "assistant"; id: string; text: string; reasoning: string; streaming: boolean; turnFinal?: boolean; samplingCount?: number; toolCount?: number; wasStreamed?: true; reasoningComplete?: boolean; reasoningDurationMs?: number; workDurationMs?: number; turnDurationMs?: number; turnUsage?: TurnUsage; tokensPerSecond?: number; createdAt?: number; memoryCitations?: MemoryCitation[]; searchSources?: SearchSource[] }
|
||||
| { kind: "phase"; id: string; text: string }
|
||||
| { kind: "notice"; id: string; local?: boolean; level: "info" | "warn"; text: string; detail?: string; code?: string; title?: string; variant?: "delivery" | "completion"; action?: "continue_delivery" | "open_changes" | "recover_context"; recoveryId?: string; completionSummary?: WireCompletionSummary; decisionReceipt?: WireDecisionReceipt; missing?: string[]; inboxItemId?: string }
|
||||
| { kind: "notice"; id: string; local?: boolean; level: "info" | "warn"; text: string; detail?: string; diagnostic?: WireEvent["diagnostic"]; code?: string; title?: string; variant?: "delivery" | "completion"; action?: "continue_delivery" | "open_changes" | "recover_context"; recoveryId?: string; completionSummary?: WireCompletionSummary; decisionReceipt?: WireDecisionReceipt; missing?: string[]; inboxItemId?: string }
|
||||
| {
|
||||
kind: "compaction";
|
||||
id: string;
|
||||
@@ -1646,7 +1646,7 @@ function applyEvent(s: State, e: WireEvent, preserveToolPayloads = false): State
|
||||
case "notice": {
|
||||
const noticeId = e.code === "unapplied_steer" && e.messageId ? `he:m:${e.messageId}` : undefined;
|
||||
if (noticeId && s.items.some(item => item.id === noticeId)) return s;
|
||||
const next = appendNoticeToState(s, e.level ?? "info", e.text ?? "", e.detail, e.code, e.decisionReceipt, noticeId);
|
||||
const next = appendNoticeToState(s, e.level ?? "info", e.text ?? "", e.detail, e.code, e.decisionReceipt, noticeId, e.diagnostic);
|
||||
return e.code?.startsWith("stream_interrupted_") ? { ...next, streamInterruptNoticeShown: true } : next;
|
||||
}
|
||||
case "context_maintenance": {
|
||||
@@ -1815,11 +1815,11 @@ function applyEvent(s: State, e: WireEvent, preserveToolPayloads = false): State
|
||||
});
|
||||
}
|
||||
if (e.err && e.diagnostic && e.diagnostic.kind !== "cancelled" && !s.streamInterruptNoticeShown) {
|
||||
interruptItems.push({ kind: "notice", id: `e${s.seq + interruptItems.length}`, level: "warn", text: e.err, detail: e.detail });
|
||||
interruptItems.push({ kind: "notice", id: `e${s.seq + interruptItems.length}`, level: "warn", text: e.err, detail: e.detail, ...(e.diagnostic ? { diagnostic: e.diagnostic } : {}) });
|
||||
}
|
||||
items = [...finalized, ...interruptItems];
|
||||
} else if (e.err && !s.streamInterruptNoticeShown) {
|
||||
items = [...finalized, { kind: "notice", id: `e${s.seq}`, level: "warn", text: e.err, detail: e.detail }];
|
||||
items = [...finalized, { kind: "notice", id: `e${s.seq}`, level: "warn", text: e.err, detail: e.detail, ...(e.diagnostic ? { diagnostic: e.diagnostic } : {}) }];
|
||||
}
|
||||
if (e.protocolRecovery?.id && e.status !== "interrupted" && !s.cancelRequested) {
|
||||
items = items.map(item => item.kind==="notice" && item.action==="recover_context" ? {...item,action:undefined} : item);
|
||||
@@ -2318,8 +2318,8 @@ function getOrCreateState(states: TabStates, tabId: string): State {
|
||||
return states.get(tabId)!;
|
||||
}
|
||||
|
||||
function appendNoticeToState(s: State, level: "info" | "warn", text: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt, id?: string): State {
|
||||
const next = appendNoticeItem(s.items, s.seq, id ?? `n${s.seq}`, level, text, detail, code, decisionReceipt);
|
||||
function appendNoticeToState(s: State, level: "info" | "warn", text: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt, id?: string, diagnostic?: WireEvent["diagnostic"]): State {
|
||||
const next = appendNoticeItem(s.items, s.seq, id ?? `n${s.seq}`, level, text, detail, code, decisionReceipt, diagnostic);
|
||||
return { ...s, running: s.turnActive ? s.running : false, seq: next.seq, items: next.items };
|
||||
}
|
||||
|
||||
@@ -2758,13 +2758,12 @@ export function useController() {
|
||||
};
|
||||
|
||||
const modern = !skipHistory;
|
||||
const snapshotLoaded = modern ? await loadTimed("transcript follow", async () => {
|
||||
await startTranscriptFollow(tabId, sessionPath);
|
||||
return true;
|
||||
}) : false;
|
||||
let followFailure: unknown;
|
||||
const snapshotLoaded = modern ? await loadTimed("transcript follow", () => startTranscriptFollow(tabId, sessionPath)
|
||||
.then(() => true, (err: unknown) => { followFailure = err; throw err; })) : false;
|
||||
if (!stillCurrent()) return;
|
||||
if (!skipHistory && snapshotLoaded !== true) {
|
||||
const error = t("history.failedLoadHistory");
|
||||
const error = hydrateFailureDetail(t("history.failedLoadHistory"), followFailure);
|
||||
dispatchTo(tabId, { type: "hydrate_error", reason, error });
|
||||
// SessionRecoveryBanner owns recovery; chat notices survive successful snapshots.
|
||||
return;
|
||||
@@ -2918,7 +2917,7 @@ export function useController() {
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
if (!stillCurrent()) return "miss";
|
||||
dispatchTo(tabId, { type: "hydrate_error", reason, error: t("history.failedLoadHistory") });
|
||||
dispatchTo(tabId, { type: "hydrate_error", reason, error: hydrateFailureDetail(t("history.failedLoadHistory"), error) });
|
||||
return "failed";
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
export const en = {
|
||||
"error.unknown": "An error occurred. Open details to view the reported cause.",
|
||||
"error.connection": "Could not connect. Check the network, proxy and service address.",
|
||||
"error.transportProtocol": "The HTTP/2 connection failed with a protocol error. Check the service or proxy compatibility; expand the details for error information.",
|
||||
"error.dns": "The server address could not be resolved. Check the address and DNS settings.",
|
||||
"error.interrupted": "The connection was interrupted. Check the network or service before trying again.",
|
||||
"error.timeout": "The request timed out. Check the service and connection before trying again.",
|
||||
@@ -1683,6 +1684,7 @@ export const en = {
|
||||
"projectTree.colorPink": "Pink",
|
||||
"projectTree.emptyNoMatch": "No matching projects or sessions",
|
||||
"projectTree.emptyNoProjects": "No projects yet",
|
||||
"projectTree.loadingProjects": "Reading projects…",
|
||||
"projectTree.newTopicTooltip": "New session",
|
||||
"projectTree.addProjectTooltip": "Add new project",
|
||||
"projectTree.remoteOpenWindow": "Open in remote window",
|
||||
@@ -1697,6 +1699,7 @@ export const en = {
|
||||
"projectTree.sortCriteria": "Sort criteria",
|
||||
"projectTree.sortByCreatedAt": "Created time",
|
||||
"projectTree.sortByUpdatedAt": "Updated time",
|
||||
"projectTree.resetManualOrder": "Reset manual order",
|
||||
"projectTree.createBlankProject": "New blank project",
|
||||
"projectTree.useExistingFolder": "Use existing folder",
|
||||
"projectTree.collapseAllTooltip": "Collapse all projects",
|
||||
@@ -3020,6 +3023,8 @@ export const en = {
|
||||
"settings.hooksPathUnavailable": "No project settings file is available",
|
||||
"settings.hooksGlobalHint": "Saved globally. Restart Reasonix to load new hooks.",
|
||||
"settings.hooksProjectHint": "Saved in this workspace. Restart Reasonix to load new hooks.",
|
||||
"settings.hooksAwaitingApproval": "These hooks came with this workspace and stay off until you approve them as they are now. Any later change needs approval again.",
|
||||
"settings.hooksApprove": "Approve",
|
||||
"settings.hooksJsonTitle": "JSON config",
|
||||
"settings.hooksJsonHint": "Edit the {\"hooks\": ...} block from settings.json directly. Arrays with event fields are also accepted; saving validates and formats the JSON.",
|
||||
"settings.hooksJsonCopy": "Copy JSON",
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { DictKey } from "./en";
|
||||
export const zhTW: Record<DictKey, string> = {
|
||||
"error.unknown": "操作發生問題,請展開詳情查看原始錯誤。",
|
||||
"error.connection": "無法連線至服務,請檢查網路、代理和服務位址。",
|
||||
"error.transportProtocol": "HTTP/2 連線發生協定錯誤,請檢查服務端或代理的相容性,展開詳情可查看錯誤資訊。",
|
||||
"error.dns": "無法解析伺服器位址,請檢查位址和 DNS 設定。",
|
||||
"error.interrupted": "連線意外中斷,請檢查網路或服務狀態後再試。",
|
||||
"error.timeout": "請求逾時,請檢查服務和網路連線後再試。",
|
||||
@@ -1437,6 +1438,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"projectTree.colorPink": "粉色",
|
||||
"projectTree.emptyNoMatch": "沒有匹配的專案或會話",
|
||||
"projectTree.emptyNoProjects": "還沒有專案",
|
||||
"projectTree.loadingProjects": "正在讀取專案…",
|
||||
"projectTree.newTopicTooltip": "新建會話",
|
||||
"projectTree.addProjectTooltip": "新增專案",
|
||||
"projectTree.remoteOpenWindow": "在遠端視窗開啟",
|
||||
@@ -1451,6 +1453,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"projectTree.sortCriteria": "排序條件",
|
||||
"projectTree.sortByCreatedAt": "建立時間",
|
||||
"projectTree.sortByUpdatedAt": "更新時間",
|
||||
"projectTree.resetManualOrder": "重設手動排序",
|
||||
"projectTree.createBlankProject": "新建空白專案",
|
||||
"projectTree.useExistingFolder": "使用現有資料夾",
|
||||
|
||||
@@ -3507,6 +3510,8 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.hooksPathUnavailable": "沒有可用的專案設定檔案",
|
||||
"settings.hooksGlobalHint": "儲存為全域性配置。重啟 Reasonix 後載入新的 hooks。",
|
||||
"settings.hooksProjectHint": "儲存在當前工作區。重啟 Reasonix 後載入新的 hooks。",
|
||||
"settings.hooksAwaitingApproval": "這些 hooks 隨工作區而來,在你核准目前內容之前不會執行;之後有任何變動都需要重新核准。",
|
||||
"settings.hooksApprove": "核准",
|
||||
"settings.hooksJsonTitle": "JSON 配置",
|
||||
"settings.hooksJsonHint": "直接編輯 settings.json 中的 {\"hooks\": ...}。也支援貼上帶 event 欄位的 hook 陣列;儲存前會校驗並格式化。",
|
||||
"settings.hooksJsonCopy": "複製 JSON",
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { DictKey } from "./en";
|
||||
export const zh: Record<DictKey, string> = {
|
||||
"error.unknown": "操作出现问题,请展开详情查看原始报错。",
|
||||
"error.connection": "无法连接服务,请检查网络、代理和服务地址。",
|
||||
"error.transportProtocol": "HTTP/2 连接发生协议错误,请检查服务端或代理的兼容性,展开详情可查看错误信息。",
|
||||
"error.dns": "无法解析服务器地址,请检查地址和 DNS 设置。",
|
||||
"error.interrupted": "连接意外中断,请检查网络或服务状态后再试。",
|
||||
"error.timeout": "请求超时,请检查服务和网络连接后再试。",
|
||||
@@ -1684,6 +1685,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"projectTree.colorPink": "粉色",
|
||||
"projectTree.emptyNoMatch": "没有匹配的项目或会话",
|
||||
"projectTree.emptyNoProjects": "还没有项目",
|
||||
"projectTree.loadingProjects": "正在读取项目…",
|
||||
"projectTree.newTopicTooltip": "新建会话",
|
||||
"projectTree.addProjectTooltip": "添加新项目",
|
||||
"projectTree.remoteOpenWindow": "在远程窗口打开",
|
||||
@@ -1698,6 +1700,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"projectTree.sortCriteria": "排序条件",
|
||||
"projectTree.sortByCreatedAt": "创建时间",
|
||||
"projectTree.sortByUpdatedAt": "更新时间",
|
||||
"projectTree.resetManualOrder": "重置手动排序",
|
||||
"projectTree.createBlankProject": "新建空白项目",
|
||||
"projectTree.useExistingFolder": "使用现有文件夹",
|
||||
"projectTree.collapseAllTooltip": "收起所有项目",
|
||||
@@ -3023,6 +3026,8 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.hooksPathUnavailable": "没有可用的项目设置文件",
|
||||
"settings.hooksGlobalHint": "保存为全局配置。重启 Reasonix 后加载新的 hooks。",
|
||||
"settings.hooksProjectHint": "保存在当前工作区。重启 Reasonix 后加载新的 hooks。",
|
||||
"settings.hooksAwaitingApproval": "这些 hooks 随工作区而来,在你批准当前内容之前不会运行;之后有任何改动都需要重新批准。",
|
||||
"settings.hooksApprove": "批准",
|
||||
"settings.hooksJsonTitle": "JSON 配置",
|
||||
"settings.hooksJsonHint": "直接编辑 settings.json 中的 {\"hooks\": ...}。也支持粘贴带 event 字段的 hook 数组;保存前会校验并格式化。",
|
||||
"settings.hooksJsonCopy": "复制 JSON",
|
||||
|
||||
@@ -22277,7 +22277,9 @@ body > .mermaid-diagram--fullscreen {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
background: var(--accent);
|
||||
-webkit-mask: url("./assets/logo-symbol.svg") no-repeat center / contain;
|
||||
mask: url("./assets/logo-symbol.svg") no-repeat center / contain;
|
||||
}
|
||||
.app--windows-frameless .sidebar__head,
|
||||
.app--windows-frameless .sidebar__brand,
|
||||
|
||||
+5
-5
@@ -35,7 +35,7 @@ require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.2 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
@@ -45,13 +45,13 @@ require (
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/klauspost/compress v1.18.7 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.11.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-pointer v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.29 // indirect
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0-pre.2 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.30 // indirect
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||
github.com/pkg/sftp v1.13.11 // indirect
|
||||
@@ -77,7 +77,7 @@ require (
|
||||
modernc.org/libc v1.75.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
mvdan.cc/sh/v3 v3.14.0 // indirect
|
||||
mvdan.cc/sh/v3 v3.14.1 // indirect
|
||||
)
|
||||
|
||||
replace reasonix => ../
|
||||
|
||||
+10
-10
@@ -10,8 +10,8 @@ github.com/UserExistsError/conpty v0.1.4 h1:+3FhJhiqhyEJa+K5qaK3/w6w+sN3Nh9O9VbJ
|
||||
github.com/UserExistsError/conpty v0.1.4/go.mod h1:PDglKIkX3O/2xVk0MV9a6bCWxRmPVfxqZoTG/5sSd9I=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.2 h1:eF7W7HWKg3z9NrWV9pTLnNeoXaqq3Tq9DNKXVMfoCnw=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
@@ -56,8 +56,8 @@ github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRg
|
||||
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
|
||||
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -70,10 +70,10 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
|
||||
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
|
||||
github.com/mattn/go-runewidth v0.0.29 h1:3oGF3R/S2N9DQ3ptftzVIvg2eicmojCzlwBEmqEPDfQ=
|
||||
github.com/mattn/go-runewidth v0.0.29/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0-pre.2 h1:Sxti6bD15KmHiTqcJ54nt9G6885m8DVwAxZX6Iu4Kjc=
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0-pre.2/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
|
||||
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
@@ -228,5 +228,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
mvdan.cc/sh/v3 v3.14.0 h1:wsPjcfrSXLJomdxwhVZqZ5j5Cue68l549Aj/DCqzbpc=
|
||||
mvdan.cc/sh/v3 v3.14.0/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo=
|
||||
mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI=
|
||||
mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo=
|
||||
@@ -5,11 +5,14 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/desktop/internal/workspacestate"
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/session"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
// Explicit source archives stage content as archived from the beginning. They
|
||||
@@ -38,11 +41,40 @@ func (a *App) archiveHistoricalSource(selector SessionSelector) (SessionMutation
|
||||
slog.Warn("desktop: historical archive failed", "source_key", id, "operation", operationID, "err", err)
|
||||
return SessionMutationResult{}, sessionOperationErrorForTarget(err, id, operationID)
|
||||
}
|
||||
archivedPaths := []string{source.path}
|
||||
siblings := legacyHeadVersions(source)
|
||||
for _, path := range recoveredLegacySiblings(source) {
|
||||
siblings = append(siblings, SessionSourceRef{HostID: localDesktopHostID, Path: path})
|
||||
}
|
||||
state, err := a.workspaceRegistry().Load(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("desktop: sibling lifecycle unavailable", "source_key", id, "err", err)
|
||||
}
|
||||
for _, sibling := range siblings {
|
||||
siblingID, siblingSource, err := a.historicalSourceForSelector(SessionSelector{Source: &sibling})
|
||||
if err != nil {
|
||||
slog.Warn("desktop: sibling archive skipped", "source_key", id, "err", err)
|
||||
continue
|
||||
}
|
||||
if historicalSourceMapped(state, siblingID) {
|
||||
continue
|
||||
}
|
||||
siblingResult, err := a.archiveHistoricalSourceWithOperation(ctx, siblingID, siblingSource,
|
||||
"archive-source-"+strings.TrimPrefix(newTabID(), "tab_"))
|
||||
if err != nil {
|
||||
slog.Warn("desktop: sibling archive failed", "source_key", siblingID, "err", err)
|
||||
continue
|
||||
}
|
||||
result.IdentityAliases = append(result.IdentityAliases, siblingResult.IdentityAliases...)
|
||||
archivedPaths = append(archivedPaths, siblingSource.path)
|
||||
}
|
||||
c := &a.historicalImports
|
||||
c.mu.Lock()
|
||||
for i := range c.catalog {
|
||||
if c.catalog[i].node.Source != nil && sameDesktopPath(c.catalog[i].node.Source.Path, source.path) {
|
||||
c.catalog[i].sourceChanged = false
|
||||
for _, path := range archivedPaths {
|
||||
if c.catalog[i].node.Source != nil && sameDesktopPath(c.catalog[i].node.Source.Path, path) {
|
||||
c.catalog[i].sourceChanged = false
|
||||
}
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
@@ -50,6 +82,95 @@ 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
|
||||
}
|
||||
|
||||
// A sibling with a durable mapping already has its own lifecycle. Archiving a
|
||||
// different version must not retire an active session or copy an old version
|
||||
// again after another head changes the shared legacy transcript.
|
||||
func historicalSourceMapped(state workspacestate.State, id string) bool {
|
||||
_, mapped, err := state.ResolveSource(id)
|
||||
return err == nil && mapped
|
||||
}
|
||||
|
||||
// headStartsConversation reports a head the user split off under a name. A
|
||||
// rewind, an unnamed fork ("fork from this turn") and a second writer's
|
||||
// concurrent head all continue their parent head's conversation.
|
||||
func headStartsConversation(head agent.SessionHead) bool {
|
||||
return head.ParentHead == "" || head.Kind == agent.HeadKindFork && strings.TrimSpace(head.Name) != ""
|
||||
}
|
||||
|
||||
// legacyHeadVersions lists the other live heads of a legacy transcript that are
|
||||
// versions of the selected head's conversation. The sidebar lists every version
|
||||
// as its own row with the same title; named forks stay.
|
||||
func legacyHeadVersions(source historicalSource) []SessionSourceRef {
|
||||
if source.format != "legacy" || source.head == "" {
|
||||
return nil
|
||||
}
|
||||
heads, err := agent.ListSessionHeads(source.path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
byID := make(map[string]agent.SessionHead, len(heads))
|
||||
for _, head := range heads {
|
||||
byID[head.ID] = head
|
||||
}
|
||||
origin := func(id string) string {
|
||||
for range heads {
|
||||
head, ok := byID[id]
|
||||
if !ok || headStartsConversation(head) {
|
||||
break
|
||||
}
|
||||
id = head.ParentHead
|
||||
}
|
||||
return id
|
||||
}
|
||||
if _, ok := byID[source.head]; !ok {
|
||||
return nil
|
||||
}
|
||||
want := origin(source.head)
|
||||
var versions []SessionSourceRef
|
||||
for _, head := range heads {
|
||||
if head.ID == source.head || head.Retired || head.Kind == agent.HeadKindConcurrent || origin(head.ID) != want {
|
||||
continue
|
||||
}
|
||||
versions = append(versions, SessionSourceRef{HostID: localDesktopHostID, Path: source.path, HeadID: head.ID})
|
||||
}
|
||||
return versions
|
||||
}
|
||||
|
||||
func (a *App) archiveHistoricalSourceWithOperation(ctx context.Context, id string, source historicalSource, operationID string) (SessionMutationResult, error) {
|
||||
release, err := acquireHistoricalSource(ctx, id, source)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/sessioncatalog"
|
||||
)
|
||||
|
||||
// A write conflict leaves one recovery copy per writer, each listed as its own
|
||||
// row of the same conversation, so archiving any of them archives them all.
|
||||
func TestArchivingRecoveredLegacyRowArchivesEveryCopy(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
parent := filepath.Join(dir, "20260826-101500.000000000-fake-model.jsonl")
|
||||
p := agent.NewSession("system")
|
||||
p.Add(provider.Message{ID: "q0", Role: provider.RoleUser, Content: "shops from my search"})
|
||||
if err := p.Save(parent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := agent.BranchMeta{Scope: "global", TopicID: "topic_jd", TopicTitle: "shops from my search"}
|
||||
for _, answer := range []string{"first writer", "second writer", "third writer"} {
|
||||
s := agent.NewSession("system")
|
||||
s.Add(provider.Message{ID: "q0", Role: provider.RoleUser, Content: "shops from my search"})
|
||||
s.Add(provider.Message{ID: "a-" + answer, Role: provider.RoleAssistant, Content: answer})
|
||||
if _, err := s.SaveConflictRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: parent, Reason: "conflict", BranchMeta: meta}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), "20260826-101500.000000000-fake-model.") {
|
||||
_ = os.Remove(filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
// An unrelated recovered conversation must survive.
|
||||
other := filepath.Join(dir, "20260826-120000.000000000-fake-model.jsonl")
|
||||
o := agent.NewSession("system")
|
||||
o.Add(provider.Message{ID: "q0", Role: provider.RoleUser, Content: "another conversation"})
|
||||
if err := o.Save(other); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := o.SaveConflictRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: other, Reason: "conflict",
|
||||
BranchMeta: agent.BranchMeta{Scope: "global", TopicID: "topic_other", TopicTitle: "another conversation"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = t.Context()
|
||||
pinDesktopSessionRoot(t, app)
|
||||
installNoopRuntimeEvents(app)
|
||||
t.Cleanup(app.closeSessionServices)
|
||||
catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true, MetadataOnly: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.sessionCatalog.Store(catalog)
|
||||
t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
|
||||
recoveredRows := func(topic string) []ProjectNode {
|
||||
t.Helper()
|
||||
if err := catalog.ReconcileDirectory(context.Background(), sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.ReleaseReadSnapshot(page.SnapshotID)
|
||||
var rows []ProjectNode
|
||||
for _, n := range page.Items {
|
||||
if n.Recovered && n.TopicID == topic {
|
||||
rows = append(rows, n)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
rows := recoveredRows("topic_jd")
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("recovered rows before archive = %d, want the 3 copies", len(rows))
|
||||
}
|
||||
target := rows[0]
|
||||
if _, err := app.ArchiveSessionTarget(SessionSelector{Ref: target.Session, Source: target.Source, SessionPath: target.SessionPath}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if left := recoveredRows("topic_jd"); len(left) != 0 {
|
||||
t.Fatalf("archiving one recovered row left %d copies of the same conversation listed", len(left))
|
||||
}
|
||||
if kept := recoveredRows("topic_other"); len(kept) != 1 {
|
||||
t.Fatalf("an unrelated recovered conversation was archived too: %d rows left, want 1", len(kept))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/desktop/internal/workspacestate"
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/session"
|
||||
)
|
||||
|
||||
func forkLegacyVersion(t *testing.T, legacy *agent.Session, path, kind, name, answer string) string {
|
||||
t.Helper()
|
||||
head, err := legacy.ForkHead(path, "question", kind, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy.Add(provider.Message{Role: provider.RoleAssistant, Content: answer})
|
||||
if err := legacy.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return head
|
||||
}
|
||||
|
||||
func legacyVersionApp(t *testing.T, path string) (*App, string, func() map[string]bool) {
|
||||
t.Helper()
|
||||
app := NewApp()
|
||||
app.ctx = t.Context()
|
||||
root := pinDesktopSessionRoot(t, app)
|
||||
installNoopRuntimeEvents(app)
|
||||
t.Cleanup(app.closeSessionServices)
|
||||
installSessionCatalogForTest(t, app, filepath.Dir(path), "global", "")
|
||||
return app, root, func() map[string]bool {
|
||||
t.Helper()
|
||||
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
heads := map[string]bool{}
|
||||
for _, row := range page.Items {
|
||||
if row.Source == nil {
|
||||
t.Fatalf("unexpected materialized row: %+v", row)
|
||||
}
|
||||
heads[row.Source.HeadID] = true
|
||||
}
|
||||
return heads
|
||||
}
|
||||
}
|
||||
|
||||
func countSessionDirs(t *testing.T, root string) int {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dirs := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
dirs++
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// Before rewinds became their own head kind, "fork from this turn" wrote an
|
||||
// unnamed fork head and a second writer's save a concurrent one; both keep the
|
||||
// conversation. Only a named fork is one the user split off.
|
||||
func TestArchivingLegacyVersionRowCoversUnnamedForkAndConcurrentVersions(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
path, legacy, mainHead := migrationSingleDAGFixture(t)
|
||||
legacy.Add(provider.Message{ID: "answer", Role: provider.RoleAssistant, Content: "first answer"})
|
||||
if err := legacy.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := forkLegacyVersion(t, legacy, path, agent.HeadKindFork, "", "second answer")
|
||||
second := forkLegacyVersion(t, legacy, path, agent.HeadKindFork, "", "third answer")
|
||||
other, err := agent.LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy.Add(provider.Message{Role: provider.RoleUser, Content: "follow-up here"})
|
||||
if err := legacy.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other.Add(provider.Message{Role: provider.RoleUser, Content: "follow-up elsewhere"})
|
||||
if err := other.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
concurrent, _ := other.Head()
|
||||
rewound := forkLegacyVersion(t, other, path, agent.HeadKindRewind, "", "answer after the concurrent write")
|
||||
named := forkLegacyVersion(t, legacy, path, agent.HeadKindFork, "separate idea", "forked answer")
|
||||
heads, err := agent.ListSessionHeads(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, head := range heads {
|
||||
if head.ID == concurrent.HeadID && head.Kind != agent.HeadKindConcurrent {
|
||||
t.Fatalf("fixture head %s is %q, want a concurrent head", head.ID, head.Kind)
|
||||
}
|
||||
}
|
||||
versions := []string{mainHead, first, second, rewound}
|
||||
|
||||
app, root, rowHeads := legacyVersionApp(t, path)
|
||||
if got := rowHeads(); len(got) != len(versions)+1 || !got[named] {
|
||||
t.Fatalf("fixture rows = %v; want %d versions and the named fork", got, len(versions))
|
||||
}
|
||||
selector := SessionSelector{Source: &SessionSourceRef{HostID: localDesktopHostID, Path: path, HeadID: second}}
|
||||
dirs := 0
|
||||
for attempt := range 2 {
|
||||
result, err := app.ArchiveSessionTarget(selector)
|
||||
if err != nil || !result.Committed {
|
||||
t.Fatalf("archive %d = %+v, %v", attempt, result, err)
|
||||
}
|
||||
if got := rowHeads(); len(got) != 1 || !got[named] {
|
||||
t.Fatalf("archive %d left rows %v; want only the named fork", attempt, got)
|
||||
}
|
||||
state, err := app.workspaceRegistry().Load(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mapped := map[string]string{}
|
||||
for _, mapping := range state.SourceMappings {
|
||||
if previous, dup := mapped[mapping.HeadID]; dup {
|
||||
t.Fatalf("head %s materialized twice: %s and %s", mapping.HeadID, previous, mapping.SessionID)
|
||||
}
|
||||
mapped[mapping.HeadID] = mapping.SessionID
|
||||
if lifecycle := state.SessionStates[mapping.SessionID].Lifecycle; lifecycle != workspacestate.Archived {
|
||||
t.Fatalf("version %s is %v, not archived", mapping.HeadID, lifecycle)
|
||||
}
|
||||
}
|
||||
if len(mapped) != len(versions) {
|
||||
t.Fatalf("archive %d mapped %v; want exactly the versions %v", attempt, mapped, versions)
|
||||
}
|
||||
for _, head := range versions {
|
||||
if _, ok := mapped[head]; !ok {
|
||||
t.Fatalf("version %s was not archived: %v", head, mapped)
|
||||
}
|
||||
}
|
||||
if attempt == 0 {
|
||||
dirs = countSessionDirs(t, root)
|
||||
} else if got := countSessionDirs(t, root); got != dirs {
|
||||
t.Fatalf("archive %d wrote new session storage: %d entries, was %d", attempt, got, dirs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Installs that archived versions one row at a time keep those records. When
|
||||
// the transcript has changed since, a later archive of the conversation must
|
||||
// retire the remaining rows without materializing the archived ones again.
|
||||
func TestArchivingLegacyConversationDoesNotRematerializeArchivedVersions(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
path, legacy, mainHead := migrationSingleDAGFixture(t)
|
||||
legacy.Add(provider.Message{ID: "answer", Role: provider.RoleAssistant, Content: "first answer"})
|
||||
if err := legacy.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions := []string{mainHead}
|
||||
for _, retry := range []string{"second answer", "third answer", "fourth answer"} {
|
||||
versions = append(versions, forkLegacyVersion(t, legacy, path, agent.HeadKindRewind, "", retry))
|
||||
}
|
||||
named := forkLegacyVersion(t, legacy, path, agent.HeadKindFork, "separate idea", "forked answer")
|
||||
app, root, rowHeads := legacyVersionApp(t, path)
|
||||
|
||||
for _, head := range versions[:2] {
|
||||
ctx, done, err := app.beginHistoricalRecovery()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, source, err := app.historicalSourceForSelector(SessionSelector{Source: &SessionSourceRef{HostID: localDesktopHostID, Path: path, HeadID: head}})
|
||||
if err != nil {
|
||||
done()
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = app.archiveHistoricalSourceWithOperation(ctx, id, source, "archive-source-single-"+head)
|
||||
done()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
legacy.Add(provider.Message{Role: provider.RoleUser, Content: "the named fork continued"})
|
||||
if err := legacy.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := countSessionDirs(t, root)
|
||||
|
||||
result, err := app.ArchiveSessionTarget(SessionSelector{Source: &SessionSourceRef{HostID: localDesktopHostID, Path: path, HeadID: versions[3]}})
|
||||
if err != nil || !result.Committed {
|
||||
t.Fatalf("archive = %+v, %v", result, err)
|
||||
}
|
||||
if got := rowHeads(); len(got) != 1 || !got[named] {
|
||||
t.Fatalf("rows %v; want only the named fork", got)
|
||||
}
|
||||
if got := countSessionDirs(t, root); got != before+2 {
|
||||
t.Fatalf("archive wrote %d session directories; want 2, one per unarchived version", got-before)
|
||||
}
|
||||
state, err := app.workspaceRegistry().Load(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
perHead := map[string]int{}
|
||||
for _, mapping := range state.SourceMappings {
|
||||
perHead[mapping.HeadID]++
|
||||
}
|
||||
for _, head := range versions {
|
||||
if perHead[head] != 1 {
|
||||
t.Fatalf("version %s has %d mappings; want 1 (%v)", head, perHead[head], perHead)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchivingLegacyVersionKeepsAnotherOpenVersionActive(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
path, legacy, mainHead := migrationSingleDAGFixture(t)
|
||||
legacy.Add(provider.Message{ID: "answer", Role: provider.RoleAssistant, Content: "first answer"})
|
||||
if err := legacy.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherHead := forkLegacyVersion(t, legacy, path, agent.HeadKindRewind, "", "second answer")
|
||||
app, root, _ := legacyVersionApp(t, path)
|
||||
|
||||
prepared, err := app.PrepareSession(SessionSelector{Source: &SessionSourceRef{HostID: localDesktopHostID, Path: path, HeadID: mainHead}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.historicalImports.mu.Lock()
|
||||
call := app.historicalImports.operations[prepared.OperationID]
|
||||
app.historicalImports.mu.Unlock()
|
||||
imported, err := waitHistoricalImport(call)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active := session.SessionRef{HostID: localDesktopHostID, SessionID: imported.Session.SessionID}
|
||||
if _, err := app.OpenSession(active); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy.Add(provider.Message{Role: provider.RoleUser, Content: "the other legacy version continued"})
|
||||
if err := legacy.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sourceBefore, err := desktopSourceFingerprint(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := countSessionDirs(t, root)
|
||||
|
||||
result, err := app.ArchiveSessionTarget(SessionSelector{Source: &SessionSourceRef{HostID: localDesktopHostID, Path: path, HeadID: otherHead}})
|
||||
if err != nil || !result.Committed {
|
||||
t.Fatalf("archive other version = %+v, %v", result, err)
|
||||
}
|
||||
state, err := app.workspaceRegistry().Load(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := state.SessionStates[active.SessionID].Lifecycle; got != workspacestate.Active {
|
||||
t.Fatalf("open version lifecycle = %q, want active", got)
|
||||
}
|
||||
mainMappings := 0
|
||||
for _, mapping := range state.SourceMappings {
|
||||
if mapping.HeadID == mainHead {
|
||||
mainMappings++
|
||||
if mapping.SessionID != active.SessionID {
|
||||
t.Fatalf("open version remapped from %s to %s", active.SessionID, mapping.SessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if mainMappings != 1 {
|
||||
t.Fatalf("open version has %d mappings, want one", mainMappings)
|
||||
}
|
||||
if got := countSessionDirs(t, root); got != before+1 {
|
||||
t.Fatalf("archive wrote %d session directories, want one for the other version", got-before)
|
||||
}
|
||||
if sourceAfter, err := desktopSourceFingerprint(path); err != nil || sourceAfter != sourceBefore {
|
||||
t.Fatalf("archive changed the legacy source: before=%s after=%s err=%v", sourceBefore, sourceAfter, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/desktop/internal/workspacestate"
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// Every rewind keeps the head it left as a version, and the sidebar lists each
|
||||
// version of a legacy transcript as its own row with the same title. Archiving
|
||||
// one of them archives the conversation, once, however often it is repeated.
|
||||
func TestArchivingLegacyVersionRowArchivesEveryVersionOnce(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
path, legacy, mainHead := migrationSingleDAGFixture(t)
|
||||
legacy.Add(provider.Message{ID: "answer", Role: provider.RoleAssistant, Content: "first answer"})
|
||||
if err := legacy.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions := []string{mainHead}
|
||||
for _, retry := range []string{"second answer", "third answer", "fourth answer"} {
|
||||
head, err := legacy.ForkHead(path, "question", agent.HeadKindRewind, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy.Add(provider.Message{Role: provider.RoleAssistant, Content: retry})
|
||||
if err := legacy.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions = append(versions, head)
|
||||
}
|
||||
fork, err := legacy.ForkHead(path, "question", agent.HeadKindFork, "separate idea")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy.Add(provider.Message{Role: provider.RoleAssistant, Content: "forked answer"})
|
||||
if err := legacy.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := desktopSourceFingerprint(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = t.Context()
|
||||
root := pinDesktopSessionRoot(t, app)
|
||||
installNoopRuntimeEvents(app)
|
||||
t.Cleanup(app.closeSessionServices)
|
||||
installSessionCatalogForTest(t, app, filepath.Dir(path), "global", "")
|
||||
rowHeads := func() map[string]bool {
|
||||
t.Helper()
|
||||
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
heads := map[string]bool{}
|
||||
for _, row := range page.Items {
|
||||
if row.Source == nil {
|
||||
t.Fatalf("unexpected materialized row: %+v", row)
|
||||
}
|
||||
heads[row.Source.HeadID] = true
|
||||
}
|
||||
return heads
|
||||
}
|
||||
if got := rowHeads(); len(got) != len(versions)+1 || !got[fork] {
|
||||
t.Fatalf("fixture rows = %v; want %d versions and the fork", got, len(versions))
|
||||
}
|
||||
|
||||
selector := SessionSelector{Source: &SessionSourceRef{HostID: localDesktopHostID, Path: path, HeadID: versions[2]}}
|
||||
var dirs int
|
||||
for attempt := range 3 {
|
||||
result, err := app.ArchiveSessionTarget(selector)
|
||||
if err != nil || !result.Committed {
|
||||
t.Fatalf("archive %d = %+v, %v", attempt, result, err)
|
||||
}
|
||||
if got := rowHeads(); len(got) != 1 || !got[fork] {
|
||||
t.Fatalf("archive %d left rows %v; want only the fork", attempt, got)
|
||||
}
|
||||
state, err := app.workspaceRegistry().Load(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(state.SourceMappings) != len(versions) || len(state.SessionStates) != len(versions) {
|
||||
t.Fatalf("archive %d: %d mappings, %d sessions; want one per version (%d)",
|
||||
attempt, len(state.SourceMappings), len(state.SessionStates), len(versions))
|
||||
}
|
||||
mapped := map[string]string{}
|
||||
for _, mapping := range state.SourceMappings {
|
||||
if !sameDesktopPath(mapping.Path, path) {
|
||||
t.Fatalf("mapping for another source: %+v", mapping)
|
||||
}
|
||||
if previous, dup := mapped[mapping.HeadID]; dup {
|
||||
t.Fatalf("head %s materialized twice: %s and %s", mapping.HeadID, previous, mapping.SessionID)
|
||||
}
|
||||
mapped[mapping.HeadID] = mapping.SessionID
|
||||
if lifecycle := state.SessionStates[mapping.SessionID].Lifecycle; lifecycle != workspacestate.Archived {
|
||||
t.Fatalf("version %s is %v, not archived", mapping.HeadID, lifecycle)
|
||||
}
|
||||
}
|
||||
for _, head := range versions {
|
||||
if _, ok := mapped[head]; !ok {
|
||||
t.Fatalf("version %s was not archived: %v", head, mapped)
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempt == 0 {
|
||||
dirs = len(entries)
|
||||
} else if len(entries) != dirs {
|
||||
t.Fatalf("archive %d wrote new session storage: %d entries, was %d", attempt, len(entries), dirs)
|
||||
}
|
||||
}
|
||||
if after, err := desktopSourceFingerprint(path); err != nil || after != before {
|
||||
t.Fatalf("archive changed the legacy source: %v", err)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 784 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user