Compare commits

...
35 Commits
Author SHA1 Message Date
YHH 7574f8b4c9 Merge pull request #11031 from esengine/docs/v1.39.2-notes
docs(release): prepare v1.39.2 notes
2026-09-27 04:39:01 -07:00
esengine d945284cec docs(release): prepare v1.39.2 notes
Bilingual Stable notes for everything merged to main-v2 since v1.39.1: unopenable and unarchivable sessions, live streaming on legacy-backed sessions, typed skill invocations, the setup API key variable name, and code-page output on Chinese Windows. CI-only changes (#10863, #10870, #10983) are left out.
2026-09-27 04:37:09 -07:00
YHH 2a3855f16a Merge pull request #11023 from KHG420/codex/1x-inline-skill-raw
fix(skill): route 1.x inline skills from raw user input
2026-09-27 04:20:36 -07:00
YHH 6d0c756228 Merge pull request #11022 from esengine/fix/v1-archived-row-stays-listed
fix(desktop): hide a historical row once any workspace has adopted its source
2026-09-27 03:41:03 -07:00
esengine dd9b441f99 fix(desktop): hide a historical row once any workspace has adopted its source
The sidebar lists a legacy transcript under the scope its sidecar names, and
the persist observer indexes a save under the global fallback when the sidecar
names none. Archive and import assign the owning workspace from the store the
file physically lives in. When the two disagree, the adopted source was only
filtered from the workspace that owns the mapping, so the row stayed listed in
the other one: archiving it "succeeded" again and again, and pin, rename and
open resolved to the archived conversation and were refused with
"Restore this session before ...".

Adoption is a property of the source identity, so the sidebar's adopted-row
filter and the lazy page's path exclusions now consider every mapping, as the
history listing already did. A head-keyed mapping owned by another workspace
that cannot prove a path alias still does not disable the lazy adapter here.
2026-09-27 03:21:47 -07:00
YHH acf3bfdcbc Merge pull request #11003 from KHG420/codex/1x-worktree-git-write-hint
fix(sandbox): guide worktree Git metadata write approval
2026-09-27 03:00:13 -07:00
xiexian 96b296cd90 test(skill): reject slash mentions as implicit invocations 2026-09-27 17:55:14 +08:00
xiexian b18f84ee22 fix(skill): route inline invocations from raw user input 2026-09-27 17:38:47 +08:00
YHH 29c8349c3a Merge pull request #11007 from esengine/fix/v1-11004-inline-skill-pin
fix(skill): send a user-typed inline skill as the pinned invocation run_skill returns
2026-09-27 02:26:16 -07:00
YHH 51fe2e9255 Merge pull request #11005 from esengine/fix/v1-11002-api-key-env-name
fix(setup): keep an API key variable name the user types
2026-09-27 02:17:10 -07:00
xiexian 8cf5b64418 test(sandbox): compare JSON-escaped worktree hints on Windows 2026-09-27 17:16:21 +08:00
esengine 463840813f fix(skill): send a user-typed inline skill as the pinned invocation run_skill returns
A bare "/name" line or a lone skill chip reached the provider as the raw
rendered skill body in plain user prose: no <skill-pin> sentinel, and on the
structured (composer) path the typed text trailed as an unlabeled paragraph
instead of an Arguments line. The model-invoked path (run_skill,
read_only_skill, read_skill) already sent the pinned form, so one skill had
two payload shapes depending on who invoked it.

Every invocation site now renders through skill.RenderInvocation: the
string submit path, the structured path, RunSkill (ACP prompts) and the
slash_command tool. A lone inline chip takes the typed text as its
arguments, matching "/name task"; several inline chips, or chips mixed with
subagents, keep appending the shared text once. Nothing moves into the
cache-stable prefix. Store.Render is removed and Render is unexported, so the
pinned form is the only one an invocation can take.

An effect test through the real Build asserts the pinned payload at the
provider boundary for the bare and argument-carrying forms of both paths.

Fixes #11004
2026-09-27 02:09:43 -07:00
esengine c04f7dd247 fix(setup): keep an API key variable name the user types
`reasonix setup` asked for the API key variable name, then saved every
new key under a freshly allocated REASONIX_CONNECTION_*_KEY slot, so a
typed name was dropped from config.toml and .env without a word.

A typed name is now kept when saving under it changes nothing another
reader resolves: no other provider, bot or remote-host setting reads it,
the credential store holds no value or tombstone for it, and the process
environment does not already set it (compared case-insensitively on
Windows). The wizard checks that as soon as the name is typed, says what
holds it, and asks again; Enter still means a private slot. The commit
repeats the check under the edit locks, so a name claimed in between
refuses the save with config.ErrCredentialKeyInUse instead of
overwriting it.

The staging journal records a keyed digest of each staged value, and
cleanup and crash recovery remove a slot only while the store still holds
that value, so a named slot another writer took over survives recovery.
Whether the config still references a slot is read from the parsed
config instead of a substring match, so API_KEY is no longer "referenced"
by DEEPSEEK_API_KEY and left behind as an orphan.

The prompt no longer shows a bracketed default it would not use.

Fixes #11002
2026-09-27 01:59:35 -07:00
xiexian a44319982f fix(sandbox): validate and narrow worktree metadata hints 2026-09-27 16:56:29 +08:00
YHH a8bea84e68 Merge pull request #10914 from esengine/fix/remote-prompt-card
fix(desktop): a remote Ask shows once and can be answered
2026-09-27 01:30:14 -07:00
YHH f457bbbd61 Merge pull request #10883 from esengine/fix/archive-recovered-copies
fix(desktop): archiving a recovered legacy conversation archives all its copies
2026-09-27 01:21:53 -07:00
YHH 3007c94084 Merge pull request #10997 from KHG420/codex/1x-history-reload-identity
fix(desktop): accept refreshed history after cache eviction
2026-09-27 01:12:16 -07:00
xiexian c09dbf56b9 fix(sandbox): include common Git metadata in retry hint 2026-09-27 16:08:19 +08:00
xiexian 26984605be fix(sandbox): guide worktree Git metadata write approval 2026-09-27 16:04:14 +08:00
esengine 85759d074c fix(desktop): archiving a recovered legacy conversation archives all its copies
A 1.38 write conflict saved one recovery copy per writer, each with the
recovered flag and the same parent in its metadata. The desktop catalog
runs metadata-only, which never folds them, so every copy is its own
sidebar row with the same title and the "legacy" and "recovered" badges.
Archiving a row archived exactly that copy and the next one took its
place, which reads as "cannot archive".

Archiving a recovered legacy source now also archives the other recovered
copies in its directory whose metadata names the same parent, each through
the same source archive path. Unrelated conversations are untouched, and
everything lands in the archive where it can be restored.

Reproduced on Windows with 1.39.1 and two copies written by
SaveConflictRecoveryBranch: one archive left the second row; with the fix
both rows go and both appear under Archived.
2026-09-27 00:59:53 -07:00
xiexian e47c32200c test(desktop): type history reload fixture for CI 2026-09-27 15:06:52 +08:00
xiexian 861a7b8d12 fix(desktop): accept refreshed older history after cache eviction 2026-09-27 14:47:40 +08:00
YHH 32107d7bb6 Merge pull request #10983 from esengine/fix/v1-release-notes-authors
feat(release-notes): credit pull request authors and issue fixers in rendered notes
2026-09-26 23:37:37 -07:00
esengine d5c9f2d739 feat(release-notes): credit pull request authors and issue fixers in rendered notes
Rendering now resolves every refs number through the GitHub GraphQL API.
A pull request gains "by @author". An issue gains "fixed in #M by @author"
for each merged pull request GitHub links as closing it (the ClosedEvent
closer and closedByPullRequestsReferences; mentions are not fixes); an
issue fixed by a direct push stays plain. Bot authors are never credited.
A number that names a discussion or nothing stays plain with a warning.

The closing Contributors / 贡献者 section lists exactly the credited
humans, once each, in first-appearance order. It replaces the old Thanks
section, which linked the catalog's contributors (github-actions[bot]
included) without mentioning them; that field no longer feeds the notes.

Lookups are cached per run, bounded in concurrency, and retried with
backoff on 5xx, rate limits and dropped connections. A failure that
persists, or an authorisation failure, renders the ref as its plain link
and fails the command with a release_credits.* code.

Because rendering now reads GitHub, the seal job renders the notes once
into the candidate payload (evidence/release-notes.md, recorded as
notes.renderedPath and bound to renderedSha256). Promotion publishes
those sealed bytes with no network call, so a rename, a newly linked fix
or an outage after sealing cannot change or block the release. Candidates
sealed before this carry no renderedPath and render offline as before.

Every job that renders, and every caller of a reusable workflow that
does, gains issues: read and pull-requests: read.
2026-09-26 23:24:50 -07:00
YHH 1a74c7450f Merge pull request #10979 from KHG420/codex/fix-1x-sidebar-loading
fix(desktop): show initial project loading state on 1.x
2026-09-26 22:59:20 -07:00
xiexian 947ce21896 chore(desktop): stay within project tree line budget 2026-09-27 13:30:40 +08:00
xiexian 872ad88677 fix(desktop): settle project shell loading after read failure 2026-09-27 13:21:07 +08:00
YHH 4512a84cbb Merge pull request #10977 from KHG420/codex/fix-reopened-session-index
fix(session): extend cached index after reopening writer
2026-09-26 22:17:13 -07:00
YHH 9424d402fb Merge pull request #10973 from esengine/fix/v1-10968-stream-generation
fix(transcript): stream live deltas for sessions on the legacy projection
2026-09-26 22:00:21 -07:00
esengine 2dfa1ca44c fix(transcript): stream live deltas for sessions on the legacy projection
A session opened from a legacy topic path follows its transcript through
the legacy projection (storageBackend "legacy"). There every turn event,
text and reasoning deltas included, takes the next ledger sequence, so
each follow change advanced commitSeq by one but carried no firstSeq.
The desktop follow client treats a change without firstSeq as a display
frame that must ride the current cut, rejected the very first change of
the turn as "transcript frame cut mismatch", dropped the subscription
and re-baselined once a second. No delta reached the reducer, so neither
thinking nor answer streamed and the reply appeared whole at turn end.

The stream_attempt commit on that path also asked for a reset, because
the legacy path never recorded a result sequence. Recording the message
event's sequence covers rounds with text or reasoning, but a tool-call-
only round and an interrupted round publish no message event at all.

Projection.Apply now publishes the sequence each envelope occupies as
the change's firstSeq. A commit settles on the recorded message sequence
or, when the round had no message event, on its own sequence: the agent
emits the commit only after the round's record is durably appended, so
that sequence is already covered. The entry is dropped at the commit so
the map stays bounded. ApplyFrame (the exclusive runtime path) is
unchanged.

Test: TestLegacyTopicTranscriptFollowStreamsDeltasTheRendererAccepts
opens a legacy topic through StartTopicActivation on a real App with a
scripted streaming provider, follows through TranscriptFollowForTab and
applies the renderer's acceptance rules (mirroring
transcriptFollowClient.ts validate: coverage, sampling, resultKind,
records-based result identity with its 192 cap). Subtests run a
streamed reply, a tool-call-only round and a cancelled round, each
followed by a streamed reply.
- main-v2: all three fail with "frame_cut_mismatch at revision 3".
- message-sequence fix only: tool-call-only and cancelled rounds fail
  with "revision_gap" at the commit.
- this change: all pass, every delta delivered with no re-baseline.
Replaying captured follow responses through the real
TranscriptFollowClient accepted 0 deltas before and all of them after.

Fixes #10968
2026-09-26 21:32:08 -07:00
YHH e5795e5e0f chore: extract website and platform from product tree
Move the website and Cloudflare platform services to their dedicated protected branches. Keep main-v2 focused on the product and retain only the product-side release integration contract.
2026-09-27 04:29:09 +00:00
xiexian 86db9b4df3 fix(session): extend cached index after reopening writer 2026-09-27 12:22:14 +08:00
xiexian 8829515d5e style(desktop): keep project tree within lint budget 2026-09-27 12:19:16 +08:00
xiexian 8b2504fb6b fix(desktop): show loading state until first project tree snapshot 2026-09-27 12:04:12 +08:00
esengine d730d8c8f8 fix(desktop): a remote Ask shows once and can be answered
#10865: in an SSH remote workspace an Ask showed twice and neither card
answered. The remote surface's card was disabled with "This card's exact
request identity is unavailable", and the footer's copy looked live but
its Submit went nowhere.

- RemoteSessionSurface required a runtimeEpoch on every prompt. A CLI
  serve never binds desktop routing metadata, so its prompts carry none;
  the serve and the kernel fence on the epoch only when they have one,
  and the desktop bridge forwards it unchanged. The check now stops at
  the host, session, generation and turn it can actually expect.
- The shell's decision footer also docked the remote prompt from shared
  state, but its answer path targets a local session a remote tab does
  not have, so it was cancelled as not ready. On a remote tab the footer
  leaves approvals, asks, MCP interactions and extension forms to the
  remote surface; shell decisions (clear context, close, conflicts) stay.

Reproduced on Windows with 1.39.1 against a Linux container over SSH
(same two cards, same error); with this build one card appears, both
questions answer and the turn continues.
2026-09-26 10:38:56 -07:00
287 changed files with 3284 additions and 35782 deletions

No files matched your search

-2
View File
@@ -10,9 +10,7 @@ on:
# conservative: when in doubt, run.
paths-ignore:
- 'docs/**'
- 'site/**'
- 'release-notes/**'
- 'workers/**'
- 'benchmarks/**'
- 'npm/**'
- '*.md'
+2 -33
View File
@@ -1636,16 +1636,13 @@ jobs:
-ignore 'label "windows-11-arm" is unknown' \
-ignore 'label "macos-15-intel" is unknown' \
.github/workflows/release-stable.yml \
.github/workflows/prepare-release-notes.yml \
.github/workflows/release-candidate.yml \
.github/workflows/release-candidate-verify.yml \
.github/workflows/release-promote.yml \
.github/workflows/release-verify.yml \
.github/workflows/release-site-recovery.yml \
.github/workflows/release.yml \
.github/workflows/release-npm.yml \
.github/workflows/release-desktop.yml \
.github/workflows/pages.yml \
.github/workflows/apple-notary-log.yml \
.github/workflows/macos-signing-check.yml \
.github/workflows/release-verify-issues.yml \
@@ -1669,14 +1666,14 @@ jobs:
node scripts/check-single-release-public-contract.mjs
# `lint` is a protected check name. Keep it as the fail-closed aggregate so
# Required check for the root-module and site jobs the per-OS `test` legs do
# required checks for root-module jobs the per-OS `test` legs do
# not cover. windows-control and sdk gate internally through RUN_STEPS and so
# always report a result: `success` here means "did not fail", not "validated
# this diff" — the same trade-off the required `test` legs already make. Do
# not add govulncheck: it sets continue-on-error, so needs.*.result is
# `success` even when its steps fail and the assertion would be a tautology.
root:
needs: [changes, windows-control, windows-isolated, sdk, site, coverage]
needs: [changes, windows-control, windows-isolated, sdk, coverage]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
@@ -1684,12 +1681,10 @@ jobs:
env:
CHANGES_RESULT: ${{ needs.changes.result }}
CODE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.code != 'false' }}
SITE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.site != 'false' }}
COVERAGE_REQUIRED: ${{ github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true' }}
CONTROL_RESULT: ${{ needs.windows-control.result }}
ISOLATED_RESULT: ${{ needs.windows-isolated.result }}
SDK_RESULT: ${{ needs.sdk.result }}
SITE_RESULT: ${{ needs.site.result }}
COVERAGE_RESULT: ${{ needs.coverage.result }}
run: |
test "$CHANGES_RESULT" = success
@@ -1697,7 +1692,6 @@ jobs:
test "$CONTROL_RESULT" = success
test "$SDK_RESULT" = success
test "$ISOLATED_RESULT" = "$(expected "$CODE_REQUIRED")"
test "$SITE_RESULT" = "$(expected "$SITE_REQUIRED")"
test "$COVERAGE_RESULT" = "$(expected "$COVERAGE_REQUIRED")"
# frontend unit coverage can run once in desktop-frontend without weakening
@@ -1731,31 +1725,6 @@ jobs:
test "$FRONTEND_RESULT" = skipped
fi
# The site/ auth client has security-sensitive redirect-validation logic
# (safeNext) covered by node:test unit tests. Those tests use only Node
# builtins, so no `npm install` is needed — run them directly on every PR so
# a regression in redirect validation fails the build instead of shipping.
# The notes_only term matches every other gated job here. Without it a
# release-notes-only push runs this job while the root aggregate expects the
# house-style skip, which would leave main-v2 permanently red. A pure
# release-notes/** diff cannot touch site/, so skipping it loses no coverage.
site:
needs: changes
if: ${{ !cancelled() && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.site != 'false') }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: site
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version-file: .node-version
- name: test
run: npm test
govulncheck:
needs: changes
if: (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true')
@@ -1,46 +0,0 @@
name: Deploy accounts worker
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-accounts-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/accounts/pnpm-lock.yaml
- name: Deploy
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
pnpm test
pnpm typecheck
npx wrangler deploy
- name: Sync RESEND_API_KEY to the worker
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
run: |
if [ -n "$RESEND_API_KEY" ]; then
printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY
else
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
fi
-168
View File
@@ -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
-36
View File
@@ -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
-76
View File
@@ -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
-106
View File
@@ -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
+18 -8
View File
@@ -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
+5 -1
View File
@@ -1084,6 +1084,8 @@ jobs:
permissions:
contents: write
actions: read
issues: read # release-notes credits read PR and issue authors
pull-requests: read
# Approved orchestrators have already passed the matching GitHub environment.
# Direct prereleases and manual Stable recovery pass release-gate above. The
# Certum signing completes before either platform bundle reaches publication.
@@ -1200,6 +1202,8 @@ jobs:
# GitHub release as usual.
- name: Render reviewed release notes
if: ${{ !inputs.orchestrated && needs.resolve.outputs.channel != 'preview' }}
env:
GH_TOKEN: ${{ github.token }}
run: node scripts/release-notes.mjs render --version "${{ needs.resolve.outputs.notes_version }}" --output /tmp/release-notes.md
- name: Revalidate approved release ref
@@ -1825,4 +1829,4 @@ jobs:
if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel != 'preview' && needs.resolve.outputs.prerelease != 'true' && steps.mirror_r2.outputs.pointer_moved == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run pages.yml --ref main-v2
run: gh workflow run pages.yml --ref website
+2 -9
View File
@@ -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
+14 -5
View File
@@ -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:
-111
View File
@@ -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
+7 -1
View File
@@ -55,6 +55,8 @@ concurrency:
permissions:
actions: write
contents: write
issues: read # release-notes credits read PR and issue authors
pull-requests: read
jobs:
preflight:
@@ -63,6 +65,8 @@ jobs:
permissions:
actions: read
contents: read
issues: read # release-notes credits read PR and issue authors
pull-requests: read
outputs:
version: ${{ steps.release.outputs.version }}
cli_tag: ${{ steps.release.outputs.cli_tag }}
@@ -111,6 +115,8 @@ jobs:
bash scripts/validate-stable-candidate.sh "$RELEASE_VERSION" "$RELEASE_SHA"
bash scripts/verify-release-push-ci.sh "$RELEASE_SHA"
- name: Validate reviewed release notes
env:
GH_TOKEN: ${{ github.token }}
run: node scripts/release-notes.mjs render --version "${{ steps.release.outputs.cli_tag }}" --output /tmp/release-notes.md
# Recovery builds deliberately check out the immutable tagged candidate,
# which can predate its reviewed release-note entry. Carry the exact file
@@ -306,4 +312,4 @@ jobs:
- name: Refresh public changelog
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run pages.yml --ref main-v2
run: gh workflow run pages.yml --ref website
+5
View File
@@ -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:
+1 -1
View File
@@ -32,7 +32,7 @@
"test:code-browser": "node bench/code-theme.mjs",
"test:dock-responsive-browser": "node bench/responsive-dock.mjs",
"test:diagnostics": "tsx src/__tests__/diagnostics-settings.test.tsx",
"test:transcript": "tsx src/__tests__/canonical-user-display.test.ts && tsx src/__tests__/steer-transcript.test.ts && tsx src/__tests__/chat-tool-presentation.test.ts && tsx src/__tests__/chat-view-source.test.ts && tsx src/__tests__/maintenance-lifecycle.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/maintenance-card.test.tsx && tsx src/__tests__/presented-files.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/presented-files-ui.test.tsx && tsx src/__tests__/chat-content-loader.test.ts && tsx src/__tests__/chat-details-race.test.tsx && tsx src/__tests__/tool-payload-preview.test.ts && tsx src/__tests__/markdown-natural-flow.test.tsx && tsx src/__tests__/chat-natural-flow.test.tsx && tsx src/__tests__/chat-scroll-controller.test.ts && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/markdown-worker-client.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-memory-pressure.test.ts && tsx src/__tests__/transcript-tool-association.test.ts && tsx src/__tests__/transcript-follow-client.test.ts && tsx src/__tests__/transcript-session-follower.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/session-export-routing.test.tsx && tsx src/__tests__/session-export-operation.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/transcript-outline-store.test.ts && tsx src/__tests__/chat-turn-jump.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/chat-turn-outline-jump.test.tsx && tsx src/__tests__/native-transcript-history.test.ts && tsx src/__tests__/history-read-binding.test.ts",
"test:transcript": "tsx src/__tests__/canonical-user-display.test.ts && tsx src/__tests__/steer-transcript.test.ts && tsx src/__tests__/chat-tool-presentation.test.ts && tsx src/__tests__/chat-view-source.test.ts && tsx src/__tests__/maintenance-lifecycle.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/maintenance-card.test.tsx && tsx src/__tests__/presented-files.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/presented-files-ui.test.tsx && tsx src/__tests__/chat-content-loader.test.ts && tsx src/__tests__/chat-details-race.test.tsx && tsx src/__tests__/tool-payload-preview.test.ts && tsx src/__tests__/markdown-natural-flow.test.tsx && tsx src/__tests__/chat-natural-flow.test.tsx && tsx src/__tests__/chat-scroll-controller.test.ts && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/markdown-worker-client.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-memory-pressure.test.ts && tsx src/__tests__/transcript-tool-association.test.ts && tsx src/__tests__/transcript-follow-client.test.ts && tsx src/__tests__/transcript-session-follower.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/session-export-routing.test.tsx && tsx src/__tests__/session-export-operation.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/transcript-outline-store.test.ts && tsx src/__tests__/chat-turn-jump.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/chat-turn-outline-jump.test.tsx && tsx src/__tests__/native-transcript-history.test.ts && tsx src/__tests__/history-read-binding.test.ts && tsx src/__tests__/history-reload-identity.test.ts",
"test:session-export-browser": "node bench/session-export.mjs",
"test:session-loading-browser": "node bench/session-loading.mjs && node bench/draft-recovery.mjs && node bench/manual-creation.mjs && node bench/manual-creation-recovery.mjs",
"test:draft-recovery-browser": "node bench/draft-recovery.mjs",
@@ -0,0 +1,13 @@
// Run: tsx src/__tests__/decision-surface-remote.test.ts
import assert from "node:assert/strict";
import { projectDecisionSurface } from "../app-runtime/decisionSurfaceProjection";
const base = { approval: undefined, ask: undefined, mcpInteraction: undefined, extensionForm: undefined, workspaceConflict: null, pendingClose: null, clearContextPending: false } as unknown as Parameters<typeof projectDecisionSurface>[0];
const ask = { id: "1", questions: [{ id: "q1", prompt: "?", options: [{ label: "a" }, { label: "b" }] }] } as unknown as Parameters<typeof projectDecisionSurface>[0]["ask"];
const approval = { id: "call-1", tool: "bash" } as unknown as Parameters<typeof projectDecisionSurface>[0]["approval"];
assert.equal(projectDecisionSurface({ ...base, ask }), "ask", "a local ask docks in the footer");
assert.equal(projectDecisionSurface({ ...base, ask, remote: true }), null, "a remote ask belongs to the remote surface, not a second footer card");
assert.equal(projectDecisionSurface({ ...base, approval, remote: true }), null, "a remote approval is not duplicated in the footer");
assert.equal(projectDecisionSurface({ ...base, ask, remote: true, clearContextPending: true }), "clear_context", "shell decisions still reach the footer on a remote tab");
console.log("decision-surface-remote: 4 passed");
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { installDesktopHostStub } from "./desktopHostStub";
import { getTranscriptStore } from "../lib/transcriptStore";
import { loadHistoryWindow, type HistoryWindowAction } from "../lib/historyWindowController";
import { initialState, reducer, type State } from "../lib/useController";
const dom = new JSDOM("", { url: "http://localhost/" });
globalThis.window = dom.window as unknown as Window & typeof globalThis;
let revision = 1;
const stub = installDesktopHostStub({
SessionHistoryWindowForTab: async () => ({
status: "ready",
snapshotSequence: revision,
generation: `generation-${revision}`,
totalTurns: 30,
hasOlder: true,
olderCursor: "older-cursor",
messages: [{ messageId: `user-${revision}`, position: 30, version: 1, role: "user", eventSequence: 30,
visibleTurn: 30, preview: `revision ${revision}`, inline: { id: `user-${revision}`, role: "user", content: `revision ${revision}` } }],
}),
});
try {
const store = getTranscriptStore();
for (const protocol of [1, 2] as const) {
revision = 1;
const tabId = `history-reload-tab-${protocol}`;
const sessionPath = `/fixture/history-reload-${protocol}.jsonl`;
const initial = await store.loadLatest(tabId, sessionPath);
assert.equal(initial?.revision, 1);
store.evictTab(tabId);
revision = 2;
let state: State = {
...initialState,
transcriptProtocol: protocol,
historyStartTurn: initial!.startTurn,
historyTotalTurns: initial!.totalTurns,
historyHasOlder: true,
historyHasNewer: false,
historyOlderLoading: false,
historyNewerLoading: false,
historyRevision: 1,
historyDigest: "generation-1",
running: false,
meta: { label: "fixture", ready: true, eventChannel: "fixture", cwd: "/fixture", sessionPath,
sessionRevision: 1, sessionDigest: "generation-1" },
};
const actions: HistoryWindowAction[] = [];
const outcome = await loadHistoryWindow({
tabId, direction: "older", trigger: "retry", state, requestSeq: 1,
isCurrent: () => true,
currentState: () => state,
dispatch: action => {
actions.push(action);
state = reducer(state, action);
},
});
assert.equal(outcome, "loaded", `protocol ${protocol}: refreshed page should replace an evicted old window: ${JSON.stringify(actions)}`);
assert.equal(actions[actions.length - 1]?.type, protocol === 2 ? "transcript_records" : "history_replace");
assert.equal(actions.find(action => action.type === "history_older_error"), undefined);
assert.equal(state.historyRevision, 2);
assert.equal(state.historyDigest, "generation-2");
assert.equal(state.historyOlderLoading, false);
assert.ok(state.items.some(item => item.kind === "user" && item.text === "revision 2"));
}
} finally {
stub.uninstall();
dom.window.close();
}
@@ -51,7 +51,7 @@ type ControllerState = Parameters<typeof reducer>[0];
const appSource = readFileSync(new URL("../AppRuntime.tsx", import.meta.url), "utf8");
const sessionCompositionSource = readFileSync(new URL("../app-runtime/useAppSessionComposition.ts", import.meta.url), "utf8");
ok(
/\[clearContextPending, pendingClose, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource),
/\[clearContextPending, pendingClose, remoteSurfaceActive, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource),
"App decision surface recomputes when an MCP interaction arrives",
);
@@ -25,8 +25,9 @@ const { ToastProvider } = await import("../lib/toast");
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(done => { resolve = done; });
return { promise, resolve };
let reject!: (reason: Error) => void;
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
return { promise, resolve, reject };
}
const pending: ReturnType<typeof deferred<ProjectTreeSnapshot>>[] = [];
@@ -78,9 +79,21 @@ try {
try {
await render(0);
assert.equal(pending.length, 1, "mount starts one shell read");
assert.match(container.querySelector(".project-tree__empty-state")?.textContent ?? "", /读取|讀取|Reading/,
"an unanswered shell read must say it is loading");
assert.doesNotMatch(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/,
"pending shell read must not claim there are no projects");
if (scenario.name === "addition") {
assert.equal(container.querySelector(".project-tree__empty-state .project-tree__skeleton"), null,
"fast shell reads must not flash a skeleton");
await act(async () => mock.timers.tick(250));
assert.ok(container.querySelector(".project-tree__empty-state .project-tree__skeleton"),
"a delayed shell read shows a skeleton");
}
await act(async () => pending[0].resolve(snapshot(scenario.before)));
await flush();
assert.deepEqual(labels(), scenario.before.map(item => item.label));
assert.equal(container.querySelector(".project-tree__empty-state .project-tree__skeleton"), null);
// A read started before the mutation retains the old shell contents.
// Its scalar revision may equal the post-mutation read: this fixture
@@ -114,6 +127,44 @@ try {
await act(async () => root.unmount());
}
}
pending.length = 0;
localStorage.clear();
const emptyRoot = createRoot(container);
try {
await act(async () => emptyRoot.render(<LocaleProvider><ToastProvider><ProjectTree
onOpenTopic={noop} onAddProject={addProject}
/></ToastProvider></LocaleProvider>));
await flush();
assert.doesNotMatch(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/);
await act(async () => pending[0].resolve(snapshot([])));
await flush();
assert.match(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/,
"an empty answer restores the true empty state");
} finally {
await act(async () => emptyRoot.unmount());
}
pending.length = 0;
localStorage.clear();
const failedRoot = createRoot(container);
try {
await act(async () => failedRoot.render(<LocaleProvider><ToastProvider><ProjectTree
onOpenTopic={noop} onAddProject={addProject}
/></ToastProvider></LocaleProvider>));
await flush();
assert.match(container.querySelector(".project-tree__empty-state")?.textContent ?? "", /读取|讀取|Reading/);
await act(async () => pending[0].reject(new Error("snapshot unavailable")));
await flush();
assert.doesNotMatch(container.textContent ?? "", /读取项目|讀取專案|Reading projects/,
"a failed first shell read must settle the loading state");
assert.match(container.textContent ?? "", /Add new project|添加新项目|新增專案/,
"the empty-state project action must remain available after a failed read");
assert.match(container.textContent ?? "", /Remote connection|远程连接|遠端連線/,
"the empty-state remote action must remain available after a failed read");
} finally {
await act(async () => failedRoot.unmount());
}
} finally {
mock.timers.reset();
dom.window.close();
@@ -391,6 +391,22 @@ await act(async () => {
ok(Boolean(document.querySelector(".prompt-shelf--ask")) && document.body.textContent?.includes("remote answer failed") === true && document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.disabled === false, "a failed remote Ask answer preserves the card, surfaces the error, and re-enables retry"); failAnswer = false; await act(async () => { document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.click(); await flush(); }); ok(tape.filter((entry) => entry.startsWith("answer:tab-remote-1:ask-7:")).length === 2 && !document.querySelector(".prompt-shelf--ask"), "a successful remote Ask retry resubmits the complete answer and clears the card");
}
// A CLI serve never binds desktop routing metadata, so its prompts carry no
// runtimeEpoch; the serve fences on the epoch only when it has one.
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", turnId: "turn-main", ask: { id: "ask-serve", questions: [{ id: "q1", prompt: "Ship it?", options: [{ label: "ship" }, { label: "wait" }] }] } });
await flush();
});
{
ok(Boolean(document.querySelector(".prompt-shelf--ask")) && !document.body.textContent?.includes("exact request identity is unavailable"), "an ask without a runtime epoch is answerable, not refused as unidentified");
await act(async () => {
[...document.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent?.trim() === "ship")?.click();
await flush();
});
await act(async () => { document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.click(); await flush(); });
ok(tape.some((entry) => entry.startsWith("answer:tab-remote-1:ask-serve:")) && !document.querySelector(".prompt-shelf--ask"), "the answer reaches the serve and the card clears");
}
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", turnId: "turn-main", runtimeEpoch: "runtime-main", ask: { id: "ask-custom", questions: [{ id: "q-custom", prompt: "Where?", options: [{ label: "staging" }] }] } });
await flush();
@@ -19,6 +19,26 @@ export function projectDecisionSurface(input: {
workspaceConflict: WorkspaceConflictView | null;
pendingClose: PendingClose;
clearContextPending: boolean;
remote?: boolean;
}): AppDecisionSurfaceKind | null {
// A remote session's prompts and forms are answered by RemoteSessionSurface,
// the one owner of the serve's exact prompt route; a footer copy would be a
// second card whose answer has no local session to reach.
if (!input.remote) {
const prompt = projectPromptSurface(input);
if (prompt) return prompt;
}
if (input.workspaceConflict) return "workspace_conflict";
if (input.pendingClose) return "close_active";
if (input.clearContextPending) return "clear_context";
return null;
}
function projectPromptSurface(input: {
approval: State["approval"];
ask: State["ask"];
mcpInteraction: State["mcpInteraction"];
extensionForm: State["extensionForm"];
}): AppDecisionSurfaceKind | null {
if (input.approval) {
return input.approval.tool === "exit_plan_mode" ? "plan_approval" : "tool_approval";
@@ -26,8 +46,5 @@ export function projectDecisionSurface(input: {
if (input.ask) return "ask";
if (input.mcpInteraction) return "mcp_interaction";
if (input.extensionForm) return "extension_form";
if (input.workspaceConflict) return "workspace_conflict";
if (input.pendingClose) return "close_active";
if (input.clearContextPending) return "clear_context";
return null;
}
@@ -345,8 +345,8 @@ export function useAppSessionComposition(input: AppSessionCompositionInput) {
const decisionSurface = useMemo((): AppDecisionSurfaceKind | null => projectDecisionSurface({
approval: state.approval, ask: state.ask, mcpInteraction: state.mcpInteraction, extensionForm: state.extensionForm,
workspaceConflict, pendingClose, clearContextPending,
}), [clearContextPending, pendingClose, state.approval, state.ask, state.extensionForm, state.mcpInteraction, workspaceConflict]);
workspaceConflict, pendingClose, clearContextPending, remote: remoteSurfaceActive,
}), [clearContextPending, pendingClose, remoteSurfaceActive, state.approval, state.ask, state.extensionForm, state.mcpInteraction, workspaceConflict]);
const visibleDecisionSurface = decisionSurface;
// Navigation used to hide the entire composer until the controller/runtime
// activation ticket and the transcript paint ticket both settled. That made
@@ -45,7 +45,9 @@ export function SidebarRegion(props: SidebarRegionProps) {
</button>
</div>
<section className="sidebar__section sidebar__section--projects">
<Suspense fallback={null}><ProjectTree {...props.projectTree} /></Suspense>
<Suspense fallback={<div className="project-tree__empty project-tree__empty--subtle" role="status">{t("projectTree.loadingProjects")}</div>}>
<ProjectTree {...props.projectTree} />
</Suspense>
</section>
<nav className="sidebar__nav sidebar__nav--footer">
<div className="sidebar__utility-row" aria-label={t("sidebar.utilityActions")}>
@@ -128,6 +128,7 @@ export function ProjectTree({
const compactTopics = variant === "workbench";
const creationTopics = variant === "creation";
const [tree, setTree] = useState<ProjectNode[]>([]);
const [shellStage, setShellStage] = useState<"loading" | "slow" | "ready">("loading");
const treeRef = useRef<ProjectNode[]>([]);
const latestRevisionRef = useRef(0);
const shellRequestRef = useRef(0);
@@ -490,10 +491,11 @@ export function ProjectTree({
})));
await reloadRequestedProjects(projects);
} catch (err) {
// A shell snapshot is metadata-only. If it fails, the resident folder
// identity can still drive the requested canonical topic reload.
// A failed shell read can still reload topics from resident folders.
if (request === shellRequestRef.current) await reloadRequestedProjects(treeRef.current);
if (throwOnSnapshotError) throw err;
} finally {
if (request === shellRequestRef.current) setShellStage("ready");
}
}, [applyRuntimeProjection, reloadProjectTopicLists]);
refreshRef.current = refresh;
@@ -561,6 +563,10 @@ export function ProjectTree({
return () => { shellRequestRef.current++; };
}, [refresh, refreshSignal]);
useEffect(() => {
if (shellStage !== "loading") return;
const timer = setTimeout(() => setShellStage("slow"), 250); return () => clearTimeout(timer);
}, [shellStage]);
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | undefined;
let latest: Parameters<Parameters<typeof onProjectTreeChangedV2>[0]>[0] | undefined;
@@ -2010,6 +2016,11 @@ export function ProjectTree({
);
const renderEmptyState = () => {
if (shellStage !== "ready") return <div className="project-tree__empty-state" role="status"><div className="project-tree__empty project-tree__empty--subtle">{t("projectTree.loadingProjects")}</div>
{shellStage === "slow" && <div className="project-tree__skeleton" aria-hidden="true">
<span className="project-tree__skeleton-bar" /><span className="project-tree__skeleton-bar project-tree__skeleton-bar--short" />
</div>}
</div>;
if (query.trim()) return <div className="project-tree__empty">{t("projectTree.emptyNoMatch")}</div>;
return (
<div className="project-tree__empty-state">
@@ -45,7 +45,9 @@ export function RemoteSessionSurface({ tab, session, surfaceCommitToken, onSurfa
const visiblePrompt = approval || ask || mcpInteraction;
const promptUpgradeRequired = Boolean(visiblePrompt && !tab.interactionTargetSupported);
const promptIdentityUnavailable = Boolean(visiblePrompt && tab.interactionTargetSupported && (
!tab.remote?.hostId || !tab.sessionId || !hasSessionGeneration(tab.sessionGeneration) || !visiblePrompt.turnId || !visiblePrompt.runtimeEpoch
// runtimeEpoch stays optional: a serve fences on it only when it has one,
// and a CLI serve never binds desktop routing metadata.
!tab.remote?.hostId || !tab.sessionId || !hasSessionGeneration(tab.sessionGeneration) || !visiblePrompt.turnId
));
const promptActionDisabled = promptUpgradeRequired || promptIdentityUnavailable;
const formUpgradeRequired = Boolean(extensionForm && !tab.extensionFormInstanceSupported);
@@ -102,7 +102,7 @@ export async function loadHistoryWindow(input: LoadInput): Promise<HistoryWindow
return "empty";
}
if (!result) { input.dispatch({ type: "history_older_error", error: "history page unavailable" }); return "empty"; }
if (!fingerprintMatches(expectedRevision, result.revisionKnown ? result.revision : undefined) || !digestMatches(expectedDigest, result.digest)) {
if (result.kind !== "reload" && (!fingerprintMatches(expectedRevision, result.revisionKnown ? result.revision : undefined) || !digestMatches(expectedDigest, result.digest))) {
input.dispatch({ type: "history_older_error", error: "history identity changed" });
return "empty";
}
+1
View File
@@ -1683,6 +1683,7 @@ export const en = {
"projectTree.colorPink": "Pink",
"projectTree.emptyNoMatch": "No matching projects or sessions",
"projectTree.emptyNoProjects": "No projects yet",
"projectTree.loadingProjects": "Reading projects…",
"projectTree.newTopicTooltip": "New session",
"projectTree.addProjectTooltip": "Add new project",
"projectTree.remoteOpenWindow": "Open in remote window",
+1
View File
@@ -1437,6 +1437,7 @@ export const zhTW: Record<DictKey, string> = {
"projectTree.colorPink": "粉色",
"projectTree.emptyNoMatch": "沒有匹配的專案或會話",
"projectTree.emptyNoProjects": "還沒有專案",
"projectTree.loadingProjects": "正在讀取專案…",
"projectTree.newTopicTooltip": "新建會話",
"projectTree.addProjectTooltip": "新增專案",
"projectTree.remoteOpenWindow": "在遠端視窗開啟",
+1
View File
@@ -1684,6 +1684,7 @@ export const zh: Record<DictKey, string> = {
"projectTree.colorPink": "粉色",
"projectTree.emptyNoMatch": "没有匹配的项目或会话",
"projectTree.emptyNoProjects": "还没有项目",
"projectTree.loadingProjects": "正在读取项目…",
"projectTree.newTopicTooltip": "新建会话",
"projectTree.addProjectTooltip": "添加新项目",
"projectTree.remoteOpenWindow": "在远程窗口打开",
+58 -2
View File
@@ -5,11 +5,14 @@ import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"reasonix/desktop/internal/workspacestate"
"reasonix/internal/agent"
"reasonix/internal/session"
"reasonix/internal/store"
)
// Explicit source archives stage content as archived from the beginning. They
@@ -38,11 +41,29 @@ func (a *App) archiveHistoricalSource(selector SessionSelector) (SessionMutation
slog.Warn("desktop: historical archive failed", "source_key", id, "operation", operationID, "err", err)
return SessionMutationResult{}, sessionOperationErrorForTarget(err, id, operationID)
}
archivedPaths := []string{source.path}
for _, sibling := range recoveredLegacySiblings(source) {
siblingID, siblingSource, err := a.historicalSourceForSelector(SessionSelector{Source: &SessionSourceRef{HostID: localDesktopHostID, Path: sibling}})
if err != nil {
slog.Warn("desktop: recovered sibling archive skipped", "source_key", id, "err", err)
continue
}
siblingResult, err := a.archiveHistoricalSourceWithOperation(ctx, siblingID, siblingSource,
"archive-source-"+strings.TrimPrefix(newTabID(), "tab_"))
if err != nil {
slog.Warn("desktop: recovered sibling archive failed", "source_key", siblingID, "err", err)
continue
}
result.IdentityAliases = append(result.IdentityAliases, siblingResult.IdentityAliases...)
archivedPaths = append(archivedPaths, siblingSource.path)
}
c := &a.historicalImports
c.mu.Lock()
for i := range c.catalog {
if c.catalog[i].node.Source != nil && sameDesktopPath(c.catalog[i].node.Source.Path, source.path) {
c.catalog[i].sourceChanged = false
for _, path := range archivedPaths {
if c.catalog[i].node.Source != nil && sameDesktopPath(c.catalog[i].node.Source.Path, path) {
c.catalog[i].sourceChanged = false
}
}
}
c.mu.Unlock()
@@ -50,6 +71,41 @@ func (a *App) archiveHistoricalSource(selector SessionSelector) (SessionMutation
return result, nil
}
// recoveredLegacySiblings lists the other recovery copies of the conversation a
// recovered legacy source was copied from: same directory, recovered, and the
// same parent in the metadata their writer recorded. The sidebar lists each
// copy as its own row of the same conversation, so archiving one row alone
// only brings the next copy up in its place.
func recoveredLegacySiblings(source historicalSource) []string {
if source.format != "legacy" {
return nil
}
meta, ok, err := agent.LoadBranchMeta(source.path)
if err != nil || !ok || !meta.Recovered || strings.TrimSpace(meta.ParentID) == "" {
return nil
}
dir := filepath.Dir(source.path)
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var siblings []string
for _, entry := range entries {
if entry.IsDir() || !store.IsSessionTranscriptName(entry.Name()) {
continue
}
path := filepath.Join(dir, entry.Name())
if sameDesktopPath(path, source.path) {
continue
}
other, ok, err := agent.LoadBranchMeta(path)
if err == nil && ok && other.Recovered && other.ParentID == meta.ParentID {
siblings = append(siblings, path)
}
}
return siblings
}
func (a *App) archiveHistoricalSourceWithOperation(ctx context.Context, id string, source historicalSource, operationID string) (SessionMutationResult, error) {
release, err := acquireHistoricalSource(ctx, id, source)
if err != nil {
@@ -0,0 +1,98 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/provider"
"reasonix/internal/sessioncatalog"
)
// A write conflict leaves one recovery copy per writer, each listed as its own
// row of the same conversation, so archiving any of them archives them all.
func TestArchivingRecoveredLegacyRowArchivesEveryCopy(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
parent := filepath.Join(dir, "20260826-101500.000000000-fake-model.jsonl")
p := agent.NewSession("system")
p.Add(provider.Message{ID: "q0", Role: provider.RoleUser, Content: "shops from my search"})
if err := p.Save(parent); err != nil {
t.Fatal(err)
}
meta := agent.BranchMeta{Scope: "global", TopicID: "topic_jd", TopicTitle: "shops from my search"}
for _, answer := range []string{"first writer", "second writer", "third writer"} {
s := agent.NewSession("system")
s.Add(provider.Message{ID: "q0", Role: provider.RoleUser, Content: "shops from my search"})
s.Add(provider.Message{ID: "a-" + answer, Role: provider.RoleAssistant, Content: answer})
if _, err := s.SaveConflictRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: parent, Reason: "conflict", BranchMeta: meta}); err != nil {
t.Fatal(err)
}
}
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.HasPrefix(e.Name(), "20260826-101500.000000000-fake-model.") {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
// An unrelated recovered conversation must survive.
other := filepath.Join(dir, "20260826-120000.000000000-fake-model.jsonl")
o := agent.NewSession("system")
o.Add(provider.Message{ID: "q0", Role: provider.RoleUser, Content: "another conversation"})
if err := o.Save(other); err != nil {
t.Fatal(err)
}
if _, err := o.SaveConflictRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: other, Reason: "conflict",
BranchMeta: agent.BranchMeta{Scope: "global", TopicID: "topic_other", TopicTitle: "another conversation"}}); err != nil {
t.Fatal(err)
}
app := NewApp()
app.ctx = t.Context()
pinDesktopSessionRoot(t, app)
installNoopRuntimeEvents(app)
t.Cleanup(app.closeSessionServices)
catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true, MetadataOnly: true})
if err != nil {
t.Fatal(err)
}
app.sessionCatalog.Store(catalog)
t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
recoveredRows := func(topic string) []ProjectNode {
t.Helper()
if err := catalog.ReconcileDirectory(context.Background(), sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
t.Fatal(err)
}
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
if err != nil {
t.Fatal(err)
}
app.ReleaseReadSnapshot(page.SnapshotID)
var rows []ProjectNode
for _, n := range page.Items {
if n.Recovered && n.TopicID == topic {
rows = append(rows, n)
}
}
return rows
}
rows := recoveredRows("topic_jd")
if len(rows) != 3 {
t.Fatalf("recovered rows before archive = %d, want the 3 copies", len(rows))
}
target := rows[0]
if _, err := app.ArchiveSessionTarget(SessionSelector{Ref: target.Session, Source: target.Source, SessionPath: target.SessionPath}); err != nil {
t.Fatal(err)
}
if left := recoveredRows("topic_jd"); len(left) != 0 {
t.Fatalf("archiving one recovered row left %d copies of the same conversation listed", len(left))
}
if kept := recoveredRows("topic_other"); len(kept) != 1 {
t.Fatalf("an unrelated recovered conversation was archived too: %d rows left, want 1", len(kept))
}
}
@@ -0,0 +1,98 @@
package main
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/provider"
"reasonix/internal/sessioncatalog"
)
// A save event indexes a transcript under the global fallback scope when its
// sidecar names none, while archive assigns the owner from the project store
// the file lives in. The row must leave the sidebar it was listed in anyway.
func TestArchivedSourceOwnedByAnotherWorkspaceLeavesSidebar(t *testing.T) {
for _, heads := range []int{1, 2} {
name := "single-head"
if heads == 2 {
name = "branched"
}
t.Run(name, func(t *testing.T) {
isolateDesktopUserDirs(t)
workspace := filepath.Join(t.TempDir(), "project")
if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatal(err)
}
if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: workspace}}}); err != nil {
t.Fatal(err)
}
dir := desktopSessionDir(workspace)
path := filepath.Join(dir, "20260922-122527.100224400-deepseek-chat.jsonl")
legacy := agent.NewSession("system")
legacy.Add(provider.Message{ID: "q0", Role: provider.RoleUser, Content: "question"})
legacy.Add(provider.Message{ID: "a0", Role: provider.RoleAssistant, Content: "answer"})
if err := legacy.Save(path); err != nil {
t.Fatal(err)
}
if heads == 2 {
if _, err := legacy.ForkHead(path, legacy.Snapshot()[1].ID, agent.HeadKindFork, "sibling"); err != nil {
t.Fatal(err)
}
legacy.Add(provider.Message{Role: provider.RoleAssistant, Content: "sibling answer"})
if err := legacy.Save(path); err != nil {
t.Fatal(err)
}
}
if err := agent.SaveBranchMeta(path, agent.BranchMeta{TopicID: "topic_unscoped"}); err != nil {
t.Fatal(err)
}
app := NewApp()
app.ctx = t.Context()
pinDesktopSessionRoot(t, app)
installNoopRuntimeEvents(app)
t.Cleanup(app.closeSessionServices)
catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true, MetadataOnly: true})
if err != nil {
t.Fatal(err)
}
app.sessionCatalog.Store(catalog)
t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
globalRows := func() []ProjectNode {
t.Helper()
// The desktop's persist observer indexes each save under this fallback.
if err := catalog.IndexSessionPath(context.Background(), sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}, path); err != nil {
t.Fatal(err)
}
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
if err != nil {
t.Fatal(err)
}
app.ReleaseReadSnapshot(page.SnapshotID)
return page.Items
}
rows := globalRows()
if len(rows) != heads {
t.Fatalf("global rows before archive = %+v, want %d", rows, heads)
}
for _, row := range rows {
result, err := app.ArchiveSessionTarget(SessionSelector{Ref: row.Session, Source: row.Source, SessionPath: row.SessionPath})
if err != nil || !result.Committed {
t.Fatalf("archive %+v = %+v, %v", row.Source, result, err)
}
}
if left := globalRows(); len(left) != 0 {
var operationErr *SessionOperationError
err := app.SetSessionPinned(SessionSelector{Source: left[0].Source, SessionPath: left[0].SessionPath}, true)
if errors.As(err, &operationErr) {
t.Fatalf("archived rows stay listed (%d) and refuse every action with %q", len(left), operationErr.Code)
}
t.Fatalf("archived rows stay listed: %+v", left)
}
})
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ func (a *App) listSessionsFromDir(dir, active string) []SessionMeta {
Preview: node.Preview, Turns: node.Turns, TurnsState: node.TurnsState,
CreatedAt: node.CreatedAt, LastActivityAt: node.LastActivityAt, ModTime: node.LastActivityAt})
}
adopted := adoptedSourceRows(state, "")
adopted := adoptedSourceRows(state)
catalog := a.sessionCatalog.Load()
if catalog == nil {
return v3
+2 -4
View File
@@ -18,12 +18,10 @@ var sourceHeadRows sync.Map
// A mapped DAG head must not return as an unidentified path placeholder while
// its index is unavailable. This is a display filter, not an ownership alias:
// independently identified siblings remain eligible for their own rows.
func adoptedSourceRows(state workspacestate.State, workspaceID string) map[string]bool {
// Adoption belongs to the source, not to the workspace a sidecar lists it in.
func adoptedSourceRows(state workspacestate.State) map[string]bool {
adopted := map[string]bool{}
for _, mapping := range state.SourceMappings {
if workspaceID != "" && mapping.WorkspaceID != workspaceID {
continue
}
for _, key := range state.SourceKeys(mapping.SourceKey) {
adopted["source\x00local\x00"+key] = true
}
+4 -3
View File
@@ -142,17 +142,18 @@ func (a *App) lazyProjectTopicSnapshot(req ProjectTopicPageRequest, reader works
func lazyTopicSourceExclusions(state workspacestate.State, workspaceID string) ([]string, bool) {
excluded := []string{}
for _, mapping := range state.SourceMappings {
if mapping.WorkspaceID != workspaceID {
continue
}
// Canonical directories have one source identity even if an older
// migration receipt retained their originating head ID.
if mapping.Format != "canonical" && !sourceMappingHasPathAlias(mapping) {
if mapping.WorkspaceID != workspaceID {
continue
}
// The catalog may not yet know about a new sibling, or the head
// index may be unavailable. Only the head-aware adapter can filter
// adopted identities without inventing a new path-only source.
return nil, false
}
// A sidecar can list a file outside the workspace that adopted it.
excluded = append(excluded, mapping.Path)
}
return excluded, true
+1 -1
View File
@@ -177,7 +177,7 @@ func (a *App) materializeProjectTopics(req ProjectTopicPageRequest, reader works
}
workspace.SessionIDs = admittedWorkspaceTopicMembers(req, state, workspace)
infos, _ := listWorkspaceSessionInfo(a.bootContext(), reader, workspace.SessionIDs)
adopted := adoptedSourceRows(state, workspaceID)
adopted := adoptedSourceRows(state)
adoptedTopics := state.AdoptedTopicIDs(workspaceID)
all := req
all.Cursor = ""
@@ -0,0 +1,255 @@
package main
import (
"cmp"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"sync"
"testing"
"time"
"reasonix/internal/config"
"reasonix/internal/transcript"
)
// rendererFollowCursor mirrors TranscriptFollowClient.validate in
// desktop/frontend/src/lib/transcriptFollowClient.ts (lines 174-220): any
// error it returns is one that makes the renderer drop the subscription.
type rendererFollowCursor struct {
revision, coverage uint64
indexes map[string]uint64
attempts map[string]string
results map[string]uint64
resultOrder []string
deltas map[string]string
}
func newRendererFollowCursor(snapshot *transcript.Snapshot) *rendererFollowCursor {
c := &rendererFollowCursor{revision: snapshot.ProjectionRevision, coverage: snapshot.CoveredThroughSeq,
indexes: map[string]uint64{}, attempts: map[string]string{}, results: map[string]uint64{}, deltas: map[string]string{}}
for _, attempt := range snapshot.ActiveAttempts {
c.indexes[attempt.ID], c.attempts[attempt.ID] = attempt.NextIndex, attempt.MessageID
}
return c
}
func (c *rendererFollowCursor) noteResult(messageID string, sequence uint64) {
if _, ok := c.results[messageID]; !ok {
c.resultOrder = append(c.resultOrder, messageID)
}
c.results[messageID] = sequence
for len(c.resultOrder) > 192 {
delete(c.results, c.resultOrder[0])
c.resultOrder = c.resultOrder[1:]
}
}
func (c *rendererFollowCursor) accept(changes []transcript.Change) error {
ordered := slices.Clone(changes)
slices.SortFunc(ordered, func(a, b transcript.Change) int { return cmp.Compare(a.Revision, b.Revision) })
for _, change := range ordered {
if change.Revision <= c.revision {
continue
}
if change.ResetRequired || change.Revision != c.revision+1 {
return fmt.Errorf("revision_gap at revision %d", change.Revision)
}
if change.FirstSeq != 0 {
if change.FirstSeq != c.coverage+1 || change.CommitSeq < change.FirstSeq {
return fmt.Errorf("business_gap at revision %d: first=%d coverage=%d", change.Revision, change.FirstSeq, c.coverage)
}
c.coverage = change.CommitSeq
} else if change.CommitSeq != c.coverage {
return fmt.Errorf("frame_cut_mismatch at revision %d: commit=%d coverage=%d", change.Revision, change.CommitSeq, c.coverage)
}
e := change.Event
for _, record := range change.Records {
if record.MessageID != "" {
c.noteResult(record.MessageID, change.CommitSeq)
}
}
if e != nil && e.Kind == "stream_attempt" && e.StreamAttempt != nil && e.StreamAttempt.Action == "begin" {
if e.MessageID == "" {
return fmt.Errorf("sampling_identity_missing at revision %d", change.Revision)
}
c.indexes[e.StreamAttempt.ID], c.attempts[e.StreamAttempt.ID] = 0, e.MessageID
}
if change.AttemptID != "" && change.ResultSeq == 0 && (e == nil || e.Kind != "stream_attempt") {
if c.indexes[change.AttemptID] != change.Index {
return fmt.Errorf("sampling_gap at revision %d", change.Revision)
}
c.indexes[change.AttemptID] = change.Index + 1
}
if change.ResultSeq != 0 && (change.ResultSeq > c.coverage || (change.ResultKind != "message/complete" && change.ResultKind != "message/interrupted")) {
return fmt.Errorf("settlement_not_committed at revision %d", change.Revision)
}
if change.ResultSeq != 0 {
message := c.attempts[change.AttemptID]
recorded, known := c.results[message]
if message == "" || e == nil || message != e.MessageID || known && recorded != change.ResultSeq {
return fmt.Errorf("settlement_identity_mismatch at revision %d", change.Revision)
}
}
if e != nil && e.Kind == "stream_attempt" && e.StreamAttempt != nil && e.StreamAttempt.Action != "begin" {
delete(c.indexes, e.StreamAttempt.ID)
delete(c.attempts, e.StreamAttempt.ID)
}
if e != nil && (e.Kind == "text" || e.Kind == "reasoning") {
c.deltas[e.Kind] += e.Text
}
c.revision = change.Revision
}
return nil
}
// legacyStreamProvider answers each turn from a script: a streamed reply, a
// tool-call-only round followed by a reply, or a stream that stalls until the
// request is cancelled.
type legacyStreamProvider struct {
mu sync.Mutex
mode string
toolSent bool
}
func (p *legacyStreamProvider) set(mode string) {
p.mu.Lock()
p.mode, p.toolSent = mode, false
p.mu.Unlock()
}
func (p *legacyStreamProvider) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p.mu.Lock()
mode, toolRound := p.mode, p.mode == "tool" && !p.toolSent
if toolRound {
p.toolSent = true
}
p.mu.Unlock()
w.Header().Set("Content-Type", "text/event-stream")
flusher, _ := w.(http.Flusher)
send := func(delta string) {
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{`+delta+`}}]}`+"\n\n")
flusher.Flush()
}
switch {
case toolRound:
send(`"tool_calls":[{"index":0,"id":"call_read","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"note.txt\"}"}}]`)
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}`+"\n\n")
case mode == "stall":
send(`"reasoning_content":"pondering"`)
<-r.Context().Done()
return
default:
for _, chunk := range []string{`"reasoning_content":"weigh "`, `"reasoning_content":"it"`, `"content":"stream"`, `"content":"ed"`} {
send(chunk)
time.Sleep(20 * time.Millisecond)
}
}
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}
type legacyFollowTurn struct{ name, mode, reasoning, text string }
func TestLegacyTopicTranscriptFollowStreamsDeltasTheRendererAccepts(t *testing.T) {
reply := legacyFollowTurn{"streamed reply", "stream", "weigh it", "streamed"}
for _, first := range []legacyFollowTurn{
reply,
{"tool-call-only round", "tool", "weigh it", "streamed"},
{"cancelled round", "stall", "pondering", ""},
} {
t.Run(first.name, func(t *testing.T) { followLegacyTopicTurns(t, first, reply) })
}
}
func followLegacyTopicTurns(t *testing.T, turns ...legacyFollowTurn) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
script := &legacyStreamProvider{}
stub := httptest.NewServer(script)
t.Cleanup(stub.Close)
cfg := config.Default()
cfg.DefaultModel = "test/test-model"
cfg.Desktop.ProviderAccess = []string{"test"}
cfg.Providers = []config.ProviderEntry{{Name: "test", Kind: "openai", BaseURL: stub.URL, Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatal(err)
}
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "note.txt"), []byte("note"), 0o644); err != nil {
t.Fatal(err)
}
if err := addProject(root, "Project"); err != nil {
t.Fatal(err)
}
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
path := writeTopicSession(t, dir, "legacy.jsonl", "legacy-topic", "Legacy", root)
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
t.Cleanup(func() { app.shutdown(context.Background()) })
events := newActivationEventRecorder(app)
ticket, err := app.StartTopicActivation(TopicActivationRequest{Scope: "project", WorkspaceRoot: root, TopicID: "legacy-topic", SessionPath: path, RequestID: "open"})
if err != nil {
t.Fatal(err)
}
if ev := events.waitFor(t, func(ev TopicActivationEvent) bool {
return ev.RequestID == "open" && (ev.Phase == "ready" || ev.Phase == "failed")
}); ev.Phase != "ready" {
t.Fatalf("activation failed: %+v", ev)
}
base, err := app.TranscriptFollowForTab(ticket.TabID, transcript.FollowRequest{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = app.TranscriptFollowForTab(ticket.TabID, transcript.FollowRequest{Subscription: base.Subscription, Close: true})
})
if base.StorageBackend != "legacy" {
t.Fatalf("fixture must follow a legacy-backed session, got %q", base.StorageBackend)
}
cursor := newRendererFollowCursor(base.Snapshot)
for _, turn := range turns {
script.set(turn.mode)
clear(cursor.deltas)
if err := app.SubmitToTab(ticket.TabID, turn.name); err != nil {
t.Fatal(err)
}
cancelled := false
deadline := time.Now().Add(15 * time.Second)
for done := false; !done; {
if time.Now().After(deadline) {
t.Fatalf("%s did not finish", turn.name)
}
resp, err := app.TranscriptFollowForTab(ticket.TabID, transcript.FollowRequest{Subscription: base.Subscription, AfterRevision: cursor.revision})
if err != nil {
t.Fatal(err)
}
if resp.ResetRequired {
t.Fatalf("%s: follower was reset", turn.name)
}
if err := cursor.accept(resp.Changes); err != nil {
t.Fatalf("%s: renderer would discard the follow and re-baseline: %v", turn.name, err)
}
for _, change := range resp.Changes {
done = done || change.Event != nil && change.Event.Kind == "turn_done"
}
if turn.mode == "stall" && !cancelled && cursor.deltas["reasoning"] != "" {
app.CancelTab(ticket.TabID)
cancelled = true
}
}
if cursor.deltas["reasoning"] != turn.reasoning || cursor.deltas["text"] != turn.text {
t.Fatalf("%s: renderer received deltas %+v", turn.name, cursor.deltas)
}
}
}
+18 -4
View File
@@ -159,11 +159,25 @@ entirely of non-ASCII characters get a stable hash suffix such as
generated environment variable remains valid; for example, `9router` becomes
`CUSTOM_9ROUTER_API_KEY`.
The CLI custom-provider wizard uses this rule while building its draft. For example
The CLI custom-provider wizard uses this rule for its draft name. For example
`https://token.sensenova.cn/v1` creates provider name
`custom-token-sensenova-cn`, whose default key env is
`CUSTOM_TOKEN_SENSENOVA_CN_API_KEY`; commit then switches the saved connection
to its newly allocated private slot.
`custom-token-sensenova-cn`, whose draft key env is
`CUSTOM_TOKEN_SENSENOVA_CN_API_KEY`. Pressing Enter at the variable-name
prompt keeps that draft only until the key is saved; the saved connection then
uses a newly allocated private slot.
A variable name you type at that prompt in `reasonix setup` is kept, so scripts
can refer to a stable name, as long as saving under it changes nothing another
connection reads: no other provider, bot or remote-host setting in the config
reads it, the global `.env` holds no value (or cleared marker) for it, and the
environment Reasonix runs in does not already set it. Otherwise the wizard says
what holds the name and asks again; Enter falls back to a
private slot. If the name is claimed between the prompt and saving, the save is
refused and nothing is written.
Saving a new key later for a provider that uses a typed name moves that
provider to a private slot; the old value stays under the typed name. Rotating
in place is tracked in #11010.
Existing configs are not rewritten on upgrade. If an old custom provider already
uses `CUSTOM_API_KEY`, it will keep working with that key. If several old custom
+12 -2
View File
@@ -129,8 +129,18 @@ env 名,例如 `LOCAL_GATEWAY_API_KEY`;如果名称全部由中文等非 ASC
都共用 `CUSTOM_API_KEY`。如果名称以数字开头,则会添加 `CUSTOM_` 前缀以保证生成的
环境变量名合法;例如 `9router` 会生成 `CUSTOM_9ROUTER_API_KEY`。
CLI 的自定义 provider 向导在构建草稿时仍会先根据 base URL 生成 provider 名称,再套用
同一规则;提交时会把保存后的连接切换到新分配的独立槽位。
CLI 的自定义 provider 向导会先根据 base URL 生成 provider 名称,再套用同一规则得到草稿
变量名。在变量名提示处直接回车时,这个草稿名只在保存 key 之前使用;保存后连接会切换到
新分配的独立槽位。
在 `reasonix setup` 的这个提示处手动输入的变量名会被保留,方便脚本引用固定名称,前提是
写入它不会改变其他读取方拿到的值:配置里没有其他 provider、机器人或远程主机设置读取它,
全局 `.env` 里没有它的值(或清空标记),Reasonix 运行的环境变量里也没有设置它。否则向导
会说明是什么占用了它,并请你重新输入;直接回车则改用独立槽位。如果在提示之后、保存之前这个名字被占用,保存会被拒绝,
不写入任何内容。
之后再为使用手动变量名的 provider 保存新 key 时,该 provider 会切换到独立槽位,旧值仍留在
手动输入的变量名下。原地轮换见 #11010。
升级时不会自动改写已有配置。旧配置中已经使用 `CUSTOM_API_KEY` 的自定义 provider 会继续
读取这个 key。若多个旧自定义 provider 已经意外共用了 `CUSTOM_API_KEY`,重新保存每个
+19
View File
@@ -8,6 +8,25 @@ record, checks its provenance and bytes, and requires one `release` approval
before creating the three tags and publishing. `recover` reuses the same sealed
files; it must not rebuild or sign them.
Rendering the notes credits people: each `refs` number that is a pull request
gains `by @author`; an issue gains `fixed in #M by @author` for each merged pull
request GitHub links as closing it. A closing Contributors list names those
humans once, in first-appearance order. Issues fixed by a direct push and bot
authors are left plain. The lookup needs a GitHub token (`GH_TOKEN`, else
`gh auth token` locally) and `issues: read` plus `pull-requests: read`.
Transient failures (5xx, rate limits, dropped connections) are retried with
backoff; one that persists, or an authorisation failure, fails the render with
a `release_credits.*` code instead of publishing notes without that credit. A
number that names a discussion or nothing stays plain with a warning.
The seal job renders the notes once into `evidence/release-notes.md` inside the
candidate payload and records it as `notes.renderedPath`, bound to
`notes.renderedSha256`. Promotion verifies the payload and publishes those
bytes without calling GitHub, so an author renamed, an issue newly linked to a
fix or an API outage after sealing cannot change or block what ships. A
candidate sealed before this has no `renderedPath` and is rendered offline from
its own source, as before.
## Public-site recovery / 官网独立恢复
Candidate preparation and publication execute offline identity, resolver-output,
+13
View File
@@ -6,6 +6,19 @@ Desktop 文件、执行最终安装包的原生验收并封存记录。`Publish
只接受该记录,验证来源与文件字节,经过一次 `release` 批准后创建三个标签并发布。
`recover` 复用相同的封存文件,不重新构建或签名。
渲染发布说明时会为贡献者署名:`refs` 中属于合并请求的编号后面加上
`by @作者`;属于 issue 的编号,对 GitHub 记录为关闭它的每个已合并请求加上
`fixed in #M by @作者`。结尾的「贡献者」列表按首次出现顺序列出这些真人作者,
每人一次。直接推送修复的 issue 和机器人作者保持原样。查询需要 GitHub 令牌(`GH_TOKEN`,
本地没有时用 `gh auth token`),以及 `issues: read` 和 `pull-requests: read` 权限。
暂时性失败(5xx、限流、连接中断)会退避重试;重试后仍失败或鉴权失败时,渲染以
`release_credits.*` 错误码失败,而不是发布缺少署名的说明。指向讨论或不存在的编号保持原样并给出警告。
封存任务只渲染一次,写进候选包的 `evidence/release-notes.md`,记录为
`notes.renderedPath` 并与 `notes.renderedSha256` 绑定。发布时校验候选包后直接发布这份
内容,不再访问 GitHub,因此封存之后作者改名、issue 新关联了修复或接口故障都不会改变或
阻塞发布内容。本改动之前封存的候选没有 `renderedPath`,仍按原方式从自身源码离线渲染。
需要验收但不发布时,在受保护的 `main-v2` 上调度 `Prepare release candidate`,
填写 `version` 和 `rehearsal=true`。可使用已有审核完成的版本进行隔离演练。
它使用独立的 `release-candidate-rehearsal-*` 产物,记录
+3 -1
View File
@@ -1478,7 +1478,9 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
slashEntries = append(slashEntries, command.SlashEntry{
Name: sk.SlashName(),
Description: sk.Description,
Render: func(args []string) string { return skillStore.Render(sk, strings.Join(args, " ")) },
Render: func(args []string) string {
return skill.RenderInvocation(skillStore.Prepare(sk), strings.Join(args, " "))
},
})
}
}
@@ -0,0 +1,99 @@
package boot
import (
"context"
"strings"
"testing"
"time"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
)
// A user-typed inline skill invocation must reach the provider in the same
// pinned form run_skill returns, on both the string and the structured submit path.
func TestEffectInlineSkillInvocationReachesProviderPinned(t *testing.T) {
const pinOpen = "<skill-pin name=\"probe\">\n# Skill: probe"
cases := []struct {
name string
submit func(*control.Controller)
tail string
raw string
}{
{"slash bare", func(c *control.Controller) { c.Submit("/probe") }, "PROBE BODY\nthen run /other\n</skill-pin>", "/probe"},
{"slash with task", func(c *control.Controller) { c.Submit("/probe tidy the notes") }, "PROBE BODY\nthen run /other\n\nArguments: tidy the notes\n</skill-pin>", "/probe tidy the notes"},
{"chip bare", func(c *control.Controller) {
c.SubmitInvocationDisplay("/probe", "", []control.InvocationRequest{{Name: "probe", Kind: "skill"}})
}, "PROBE BODY\nthen run /other\n</skill-pin>", ""},
{"chip with task", func(c *control.Controller) {
c.SubmitInvocationDisplay("/probe tidy the notes", "tidy the notes", []control.InvocationRequest{{Name: "probe", Kind: "skill"}})
}, "PROBE BODY\nthen run /other\n\nArguments: tidy the notes\n</skill-pin>", "tidy the notes"},
}
var rec *effectRecordingProvider
provider.Register("boot-effect-skill-invocation", func(provider.Config) (provider.Provider, error) { return rec, nil })
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
rec = &effectRecordingProvider{}
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[environment]
enabled = false
[[providers]]
name = "test-model"
kind = "boot-effect-skill-invocation"
model = "x"
`)
writeFile(t, dir, ".reasonix/skills/probe/SKILL.md", "---\ndescription: probe skill\ntriggers: probe, tidy\nauto-use: require\n---\nPROBE BODY\nthen run /other")
writeFile(t, dir, ".reasonix/skills/other/SKILL.md", "---\ndescription: other skill\ntriggers: other\nauto-use: require\n---\nOTHER BODY")
ctrl, err := Build(context.Background(), Options{Sink: event.Discard})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
tc.submit(ctrl)
if done, running := ctrl.TurnIdleDone(); running {
select {
case <-done:
case <-time.After(30 * time.Second):
t.Fatal("turn did not finish")
}
}
reqs := rec.requests()
if len(reqs) == 0 {
t.Fatal("no request reached the provider boundary")
}
req := reqs[len(reqs)-1]
if raw := rec.rawUserInputs(); len(raw) == 0 || raw[len(raw)-1] != tc.raw {
t.Fatalf("provider raw user input = %q, want %q", raw, tc.raw)
}
var user string
for _, m := range req.Messages {
if m.Role == provider.RoleUser {
user = m.Content
}
}
if !strings.Contains(user, pinOpen) || !strings.Contains(user, tc.tail) {
t.Fatalf("user message is not the pinned invocation (want tail %q):\n%s", tc.tail, user)
}
if strings.Count(user, "tidy the notes") > 1 {
t.Fatalf("task delivered twice:\n%s", user)
}
if strings.Contains(systemMessage(req.Messages), "PROBE BODY") {
t.Fatal("invocation body leaked into the cache-stable prefix")
}
if strings.Contains(user, "skill:probe require") || strings.Contains(user, "skill:other require") {
t.Fatalf("inline skill already supplied to the model must not require another skill call:\n%s", user)
}
})
}
}
+11 -3
View File
@@ -20,15 +20,17 @@ import (
)
type effectRecordingProvider struct {
mu sync.Mutex
reqs []provider.Request
mu sync.Mutex
reqs []provider.Request
rawInputs []string
}
func (p *effectRecordingProvider) Name() string { return "boot-effect-test" }
func (p *effectRecordingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
func (p *effectRecordingProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
p.mu.Lock()
p.reqs = append(p.reqs, req)
p.rawInputs = append(p.rawInputs, agent.RawUserInput(ctx, ""))
p.mu.Unlock()
chunks := []provider.Chunk{
{Type: provider.ChunkText, Text: "ok"},
@@ -42,6 +44,12 @@ func (p *effectRecordingProvider) Stream(_ context.Context, req provider.Request
return ch, nil
}
func (p *effectRecordingProvider) rawUserInputs() []string {
p.mu.Lock()
defer p.mu.Unlock()
return append([]string(nil), p.rawInputs...)
}
func (p *effectRecordingProvider) requests() []provider.Request {
p.mu.Lock()
defer p.mu.Unlock()
-14
View File
@@ -293,9 +293,6 @@ func RenderTransientBlock(d RouteDecision) string {
func routeEntry(text string, e Entry) (AutoUse, string, bool) {
if e.Kind == KindSkill {
if explicitSkill(text, e.Name) {
return AutoUseRequire, "the user explicitly referenced this skill", true
}
if e.AutoUse == AutoUseOff {
return "", "", false
}
@@ -312,17 +309,6 @@ func routeEntry(text string, e Entry) (AutoUse, string, bool) {
return "", "", false
}
func explicitSkill(text, name string) bool {
n := normalize(name)
return strings.Contains(text, "/"+n) ||
strings.Contains(text, "use "+n+" skill") ||
strings.Contains(text, "using "+n+" skill") ||
strings.Contains(text, "使用 "+n+" skill") ||
strings.Contains(text, "用 "+n+" skill") ||
strings.Contains(text, "使用"+n+"技能") ||
strings.Contains(text, "用"+n+"技能")
}
// namesMCPTool matches the identifier the host mints for the tool. A server
// name or a topic word selects every tool the server exposes, so it is left to
// the semantic router, which reads the whole request.
+6 -7
View File
@@ -26,19 +26,18 @@ func TestRoutePrefersReviewSkillForReviewRequest(t *testing.T) {
}
}
func TestRouteRequiresExplicitSkill(t *testing.T) {
func TestRouteDoesNotTreatTextMentionAsInvocation(t *testing.T) {
entries := SkillEntries([]skill.Skill{{
Name: "audit",
Description: "audit something",
Scope: skill.ScopeProject,
}}, []tool.ContractEntry{{Name: "run_skill"}})
decision := Route("请使用 audit skill 检查一下", entries)
if len(decision.Candidates) == 0 {
t.Fatal("Route returned no candidates")
}
if got := decision.Candidates[0].Policy; got != AutoUseRequire {
t.Fatalf("policy = %s, want require", got)
for _, text := range []string{"/audit 检查一下", "then run /audit", "请使用 audit skill 检查一下"} {
decision := Route(text, entries)
if len(decision.Candidates) != 0 {
t.Errorf("%q must not create a structural skill invocation: %+v", text, decision.Candidates)
}
}
}
+29 -19
View File
@@ -1851,11 +1851,16 @@ func repairInvalidProviderKeyEnvs(providers []config.ProviderEntry) ([]config.Pr
return providers, repairs
}
func promptAPIKeyEnvName(in *bufio.Scanner, w io.Writer, label, def string) string {
// promptAPIKeyEnvName reports explicit=false when the user pressed Enter: def
// is then only a draft name, and a saved key goes to a private slot instead.
func promptAPIKeyEnvName(in *bufio.Scanner, w io.Writer, label, def string) (keyEnv string, explicit bool) {
for {
keyEnv := ask(in, w, label, def)
keyEnv = ask(in, w, label, "")
if keyEnv == "" {
return def, false
}
if config.IsValidCredentialKey(keyEnv) {
return keyEnv
return keyEnv, true
}
fmt.Fprintf(w, i18n.M.InvalidAPIKeyEnvFmt+"\n", keyEnv)
}
@@ -1891,10 +1896,13 @@ func familyOf(name string) providerFamily {
type providerPromptResult struct {
entries []config.ProviderEntry
credentials map[string]string
// keyEnvTyped: the user typed the entries' api_key_env rather than accepting keyEnvDraft.
keyEnvTyped bool
keyEnvDraft string
}
func newProviderPromptResult(entries []config.ProviderEntry, key, value string) providerPromptResult {
result := providerPromptResult{entries: entries}
func newProviderPromptResult(entries []config.ProviderEntry, key, value string, typed bool, draft string) providerPromptResult {
result := providerPromptResult{entries: entries, keyEnvTyped: typed, keyEnvDraft: draft}
if key != "" && value != "" {
result.credentials = map[string]string{key: value}
}
@@ -1918,7 +1926,7 @@ func promptCustomProvider(proxy netclient.ProxySpec) (providerPromptResult, erro
// promptCustomProviderManual handles manual model entry.
func promptCustomProviderManual() (providerPromptResult, error) {
return promptCustomProviderManualWith(bufio.NewScanner(os.Stdin), "", "", "")
return promptCustomProviderManualWith(bufio.NewScanner(os.Stdin), "", "", false, "")
}
// promptCustomProviderManualWith is the shared backend for manual entry.
@@ -1926,7 +1934,7 @@ func promptCustomProviderManual() (providerPromptResult, error) {
// so the URL-fetch flow can fall through to manual entry without re-asking
// the user for information they've already typed. An empty apiKey is allowed
// — the key step happens later in the wizard and Reasonix's global .env is updated then.
func promptCustomProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKey string) (providerPromptResult, error) {
func promptCustomProviderManualWith(in *bufio.Scanner, baseURL, keyEnv string, keyEnvTyped bool, apiKey string) (providerPromptResult, error) {
fmt.Println()
if baseURL == "" {
baseURL = ask(in, os.Stdout, i18n.M.CustomPromptBaseURL, "")
@@ -1939,8 +1947,9 @@ func promptCustomProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKey s
if modelName == "" {
return providerPromptResult{}, fmt.Errorf("model name is required")
}
draft := apiKeyEnvFromProviderName(providerName)
if keyEnv == "" {
keyEnv = promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, apiKeyEnvFromProviderName(providerName))
keyEnv, keyEnvTyped = promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, draft)
} else if !config.IsValidCredentialKey(keyEnv) {
return providerPromptResult{}, fmt.Errorf("invalid API key variable name %q", keyEnv)
}
@@ -1952,7 +1961,7 @@ func promptCustomProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKey s
Model: modelName, APIKeyEnv: keyEnv, ContextWindow: askContextWindow(in, os.Stdout),
}
fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.CustomAddedFmt, entry.Name+"/"+modelName)))
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey, keyEnvTyped, draft), nil
}
// promptCustomProviderFromURL tries the OpenAI-compatible GET /models
@@ -1968,7 +1977,8 @@ func promptCustomProviderFromURL(proxy netclient.ProxySpec) (providerPromptResul
return providerPromptResult{}, fmt.Errorf("base URL is required")
}
providerName := providerSlug("custom", baseURL)
keyEnv := promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, apiKeyEnvFromProviderName(providerName))
draft := apiKeyEnvFromProviderName(providerName)
keyEnv, keyEnvTyped := promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, draft)
apiKey := askSecret(in, os.Stdout, i18n.M.CustomPromptAPIKey)
fmt.Printf(" %s\n", dim(fmt.Sprintf(i18n.M.FetchingModelsFmt, "custom")))
@@ -1981,7 +1991,7 @@ func promptCustomProviderFromURL(proxy netclient.ProxySpec) (providerPromptResul
} else {
fmt.Fprintf(os.Stderr, " %s\n", dim(i18n.M.CustomFetchEmpty))
}
return promptCustomProviderManualWith(in, baseURL, keyEnv, apiKey)
return promptCustomProviderManualWith(in, baseURL, keyEnv, keyEnvTyped, apiKey)
}
fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.FetchModelsSuccessFmt, len(models), "custom")))
@@ -2002,7 +2012,7 @@ func promptCustomProviderFromURL(proxy netclient.ProxySpec) (providerPromptResul
Models: selected, Model: selected[0], APIKeyEnv: keyEnv, ContextWindow: askContextWindow(in, os.Stdout),
}
fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.CustomAddedFmt, entry.Name+"/"+selected[0])))
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey, keyEnvTyped, draft), nil
}
// promptAnthropicProvider handles the Anthropic compatible provider entry flow.
@@ -2022,14 +2032,14 @@ func promptAnthropicProvider(proxy netclient.ProxySpec) (providerPromptResult, e
// promptAnthropicProviderManual handles manual model entry.
func promptAnthropicProviderManual() (providerPromptResult, error) {
return promptAnthropicProviderManualWith(bufio.NewScanner(os.Stdin), "", "", "")
return promptAnthropicProviderManualWith(bufio.NewScanner(os.Stdin), "", "", false, "")
}
// promptAnthropicProviderManualWith is the shared backend for manual entry
// of an Anthropic-compatible custom provider. Pre-filled values (baseURL,
// keyEnv, apiKey) are reused as-is when non-empty so the URL-fetch flow
// can fall through to manual entry without re-asking the user.
func promptAnthropicProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKey string) (providerPromptResult, error) {
func promptAnthropicProviderManualWith(in *bufio.Scanner, baseURL, keyEnv string, keyEnvTyped bool, apiKey string) (providerPromptResult, error) {
fmt.Println()
if baseURL == "" {
baseURL = ask(in, os.Stdout, i18n.M.AnthropicPromptBaseURL, "")
@@ -2042,7 +2052,7 @@ func promptAnthropicProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKe
return providerPromptResult{}, fmt.Errorf("model name is required")
}
if keyEnv == "" {
keyEnv = promptAPIKeyEnvName(in, os.Stdout, i18n.M.AnthropicPromptKeyEnv, "ANTHROPIC_API_KEY")
keyEnv, keyEnvTyped = promptAPIKeyEnvName(in, os.Stdout, i18n.M.AnthropicPromptKeyEnv, "ANTHROPIC_API_KEY")
} else if !config.IsValidCredentialKey(keyEnv) {
return providerPromptResult{}, fmt.Errorf("invalid API key variable name %q", keyEnv)
}
@@ -2054,7 +2064,7 @@ func promptAnthropicProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKe
Model: modelName, APIKeyEnv: keyEnv, ContextWindow: askContextWindow(in, os.Stdout),
}
fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.AnthropicAddedFmt, entry.Name+"/"+modelName)))
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey, keyEnvTyped, "ANTHROPIC_API_KEY"), nil
}
// promptAnthropicProviderFromURL tries the OpenAI-compatible GET /models
@@ -2070,7 +2080,7 @@ func promptAnthropicProviderFromURL(proxy netclient.ProxySpec) (providerPromptRe
if baseURL == "" {
return providerPromptResult{}, fmt.Errorf("base URL is required")
}
keyEnv := promptAPIKeyEnvName(in, os.Stdout, i18n.M.AnthropicPromptKeyEnv, "ANTHROPIC_API_KEY")
keyEnv, keyEnvTyped := promptAPIKeyEnvName(in, os.Stdout, i18n.M.AnthropicPromptKeyEnv, "ANTHROPIC_API_KEY")
apiKey := askSecret(in, os.Stdout, i18n.M.AnthropicPromptAPIKey)
fmt.Printf(" %s\n", dim(fmt.Sprintf(i18n.M.AnthropicFetchingModelsFmt, "anthropic")))
@@ -2083,7 +2093,7 @@ func promptAnthropicProviderFromURL(proxy netclient.ProxySpec) (providerPromptRe
} else {
fmt.Fprintf(os.Stderr, " %s\n", dim(i18n.M.AnthropicFetchEmpty))
}
return promptAnthropicProviderManualWith(in, baseURL, keyEnv, apiKey)
return promptAnthropicProviderManualWith(in, baseURL, keyEnv, keyEnvTyped, apiKey)
}
fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.AnthropicFetchModelsSuccessFmt, len(models), "anthropic")))
@@ -2104,7 +2114,7 @@ func promptAnthropicProviderFromURL(proxy netclient.ProxySpec) (providerPromptRe
Models: selected, Model: selected[0], APIKeyEnv: keyEnv, ContextWindow: askContextWindow(in, os.Stdout),
}
fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.AnthropicAddedFmt, entry.Name+"/"+selected[0])))
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey, keyEnvTyped, "ANTHROPIC_API_KEY"), nil
}
func groupByFamily(providers []config.ProviderEntry) ([]string, map[string][]int, map[string]providerFamily) {
+8 -3
View File
@@ -1838,6 +1838,7 @@ func TestPromptCustomProviderManualDefaultsKeyEnvFromBaseURL(t *testing.T) {
bufio.NewScanner(strings.NewReader("sensenova-chat\n\n\n")),
"https://token.sensenova.cn/v1",
"",
false,
"",
)
if err != nil {
@@ -1857,6 +1858,7 @@ func TestPromptCustomProviderManualPreservesExplicitKeyEnv(t *testing.T) {
bufio.NewScanner(strings.NewReader("manual-chat\n\n")),
"https://token.sensenova.cn/v1",
"CUSTOM_API_KEY",
true,
"",
)
if err != nil {
@@ -1874,14 +1876,14 @@ func TestPromptCustomProviderManualPreservesExplicitKeyEnv(t *testing.T) {
func TestPromptAPIKeyEnvNameRejectsModelName(t *testing.T) {
i18n.DetectLanguage("en")
var out bytes.Buffer
got := promptAPIKeyEnvName(
got, typed := promptAPIKeyEnvName(
bufio.NewScanner(strings.NewReader("grok-4.5\n\n")),
&out,
i18n.M.CustomPromptKeyEnv,
"CUSTOM_API_YAIROUTER_COM_API_KEY",
)
if got != "CUSTOM_API_YAIROUTER_COM_API_KEY" {
t.Fatalf("key env = %q, want generated default", got)
if got != "CUSTOM_API_YAIROUTER_COM_API_KEY" || typed {
t.Fatalf("key env = %q (typed %v), want the untyped generated default", got, typed)
}
if text := out.String(); !strings.Contains(text, "not a valid API Key variable name") || !strings.Contains(text, "do not enter a model name") {
t.Fatalf("validation guidance missing from prompt output: %q", text)
@@ -1893,6 +1895,7 @@ func TestPromptCustomProviderManualAsksForModelBeforeCredentialName(t *testing.T
bufio.NewScanner(strings.NewReader("grok-4.5\ngrok-4.5\n\n\n")),
"https://api.example.com/v1",
"",
false,
"",
)
if err != nil {
@@ -1914,6 +1917,7 @@ func TestPromptCustomProviderStagesExplicitKeyEvenWhenProcessEnvMatches(t *testi
bufio.NewScanner(strings.NewReader("grok-4.5\n")),
"https://api.example.com/v1",
key,
true,
"same-secret",
)
if err != nil {
@@ -1929,6 +1933,7 @@ func TestPromptCustomProviderStagesExplicitKeyEvenWhenProcessEnvMatches(t *testi
bufio.NewScanner(strings.NewReader("grok-4.5\n")),
"https://api.example.com/v1",
key,
true,
"new-secret",
)
if err != nil {
+116
View File
@@ -0,0 +1,116 @@
package cli
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"reasonix/internal/config"
"reasonix/internal/i18n"
)
func (s *providerSetupSession) addPrompted(in *bufio.Scanner, w io.Writer, result providerPromptResult) bool {
if result.keyEnvTyped && len(result.credentials) > 0 && len(result.entries) > 0 {
names := make([]string, 0, len(result.entries))
for _, entry := range result.entries {
names = append(names, entry.Name)
}
from := result.entries[0].APIKeyEnv
to, typed := s.settleTypedKeyEnv(in, w, keyEnvPromptLabel(result.entries[0].Kind), names, from, result.keyEnvDraft)
result.renameKeyEnv(from, to)
result.keyEnvTyped = typed
}
for _, entry := range result.entries {
if !confirmSharedCredential(s.cfg, entry, "") {
return false
}
}
if err := s.add(result.entries); err != nil {
fmt.Fprintln(os.Stderr, err)
return false
}
s.addProviderAccess(result.entries)
for key, value := range result.credentials {
var names []string
for _, entry := range result.entries {
if entry.APIKeyEnv == key {
names = append(names, entry.Name)
}
}
if err := s.setCredentialForProviders(names, key, value); err != nil {
fmt.Fprintln(os.Stderr, err)
return false
}
if result.keyEnvTyped {
for _, name := range names {
s.typedKeyEnv[name] = key
}
}
}
// After the new keys are staged, so usability sees them.
s.promoteDefaultToNewProviders(result.entries)
return true
}
func (r *providerPromptResult) renameKeyEnv(from, to string) {
if from == to {
return
}
for i := range r.entries {
if r.entries[i].APIKeyEnv == from {
r.entries[i].APIKeyEnv = to
}
}
if value, ok := r.credentials[from]; ok {
delete(r.credentials, from)
r.credentials[to] = value
}
}
// settleTypedKeyEnv asks again while providers may not claim key, saying who
// holds it. Pressing Enter returns draft with typed=false: a private slot.
func (s *providerSetupSession) settleTypedKeyEnv(in *bufio.Scanner, w io.Writer, label string, providers []string, key, draft string) (string, bool) {
for {
var held error
for _, name := range providers {
if held = s.cfg.CredentialKeyClaimable(key, name); held != nil {
break
}
}
if held == nil {
return key, true
}
fmt.Fprintln(w, keyEnvInUseText(held))
fmt.Fprintln(w, i18n.M.SetupKeyEnvRetry)
var typed bool
if key, typed = promptAPIKeyEnvName(in, w, label, draft); !typed {
return key, false
}
}
}
func keyEnvPromptLabel(kind string) string {
if kind == "anthropic" {
return i18n.M.AnthropicPromptKeyEnv
}
return i18n.M.CustomPromptKeyEnv
}
func keyEnvInUseText(err error) string {
var inUse *config.CredentialKeyInUseError
if !errors.As(err, &inUse) {
return err.Error()
}
switch inUse.Holder {
case config.CredentialKeyHeldByProvider:
return fmt.Sprintf(i18n.M.SetupKeyEnvTakenFmt, inUse.Key, inUse.Provider)
case config.CredentialKeyHeldBySetting:
return fmt.Sprintf(i18n.M.SetupKeyEnvSettingFmt, inUse.Key)
case config.CredentialKeyHeldByEnvironment:
return fmt.Sprintf(i18n.M.SetupKeyEnvShellFmt, inUse.Key)
default:
return fmt.Sprintf(i18n.M.SetupKeyEnvStoredFmt, inUse.Key)
}
}
+202
View File
@@ -0,0 +1,202 @@
package cli
import (
"bufio"
"bytes"
"errors"
"os"
"strings"
"testing"
"reasonix/internal/config"
)
const opencodeBaseURL = "https://opencode.ai/zen/go/v1"
func newKeyEnvTestSession(t *testing.T, fixture func(*config.Config)) (*providerSetupSession, string) {
t.Helper()
isolateUserConfig(t)
t.Setenv("REASONIX_HOME", t.TempDir())
for _, key := range []string{"CUSTOM_OPENCODE_AI_API_KEY", "OPENCODE_KEY", "ORPHAN_KEY", "SHARED_RELAY_KEY"} {
t.Setenv(key, "") // registers the restore; saving pins the key into the process
_ = os.Unsetenv(key)
}
path := config.UserConfigPath()
c := config.Default()
if fixture != nil {
fixture(c)
}
if err := c.SaveTo(path); err != nil {
t.Fatal(err)
}
c, err := config.LoadForEditReadOnlyStrict(path)
if err != nil {
t.Fatal(err)
}
return newProviderSetupSessionForPath(c, path), path
}
func addOpencodeThroughWizard(t *testing.T, s *providerSetupSession, input string, out *bytes.Buffer) bool {
t.Helper()
in := bufio.NewScanner(strings.NewReader(input))
result, err := promptCustomProviderManualWith(in, opencodeBaseURL, "", false, "sk-opencode")
if err != nil {
t.Fatal(err)
}
return s.addPrompted(in, out, result)
}
func savedKeyEnv(t *testing.T, path, provider string) string {
t.Helper()
saved, err := config.LoadForEditReadOnlyStrict(path)
if err != nil {
t.Fatal(err)
}
entry, ok := saved.Provider(provider)
if !ok {
t.Fatalf("provider %q was not saved", provider)
}
return entry.APIKeyEnv
}
func TestSetupHonoursATypedKeyEnvName(t *testing.T) {
s, path := newKeyEnvTestSession(t, nil)
var out bytes.Buffer
if !addOpencodeThroughWizard(t, s, "chat\nCUSTOM_OPENCODE_AI_API_KEY\n\n", &out) {
t.Fatalf("wizard refused the provider: %s", out.String())
}
if _, err := commitProviderSetupSession(s, path); err != nil {
t.Fatal(err)
}
if got := savedKeyEnv(t, path, "custom-opencode-ai"); got != "CUSTOM_OPENCODE_AI_API_KEY" {
t.Fatalf("api_key_env = %q, want the typed CUSTOM_OPENCODE_AI_API_KEY", got)
}
if res := config.ResolveCredentialForRootGlobalFirst(".", "CUSTOM_OPENCODE_AI_API_KEY"); res.Value != "sk-opencode" {
t.Fatalf("typed variable holds %q, want the entered key", res.Value)
}
}
func TestSetupEnterForDefaultKeepsAPrivateSlot(t *testing.T) {
s, path := newKeyEnvTestSession(t, nil)
var out bytes.Buffer
if !addOpencodeThroughWizard(t, s, "chat\n\n\n", &out) {
t.Fatalf("wizard refused the provider: %s", out.String())
}
if _, err := commitProviderSetupSession(s, path); err != nil {
t.Fatal(err)
}
if got := savedKeyEnv(t, path, "custom-opencode-ai"); !strings.HasPrefix(got, "REASONIX_CONNECTION_") {
t.Fatalf("api_key_env = %q, want a private REASONIX_CONNECTION_ slot", got)
}
}
func TestSetupRefusesATypedNameAnotherProviderHolds(t *testing.T) {
other := config.ProviderEntry{Name: "relay", Kind: "openai", BaseURL: "https://relay.invalid/v1", Model: "chat", APIKeyEnv: "SHARED_RELAY_KEY"}
s, path := newKeyEnvTestSession(t, func(c *config.Config) { c.Providers = append(c.Providers, other) })
var out bytes.Buffer
if !addOpencodeThroughWizard(t, s, "chat\nSHARED_RELAY_KEY\n\n\n", &out) {
t.Fatalf("wizard refused the provider: %s", out.String())
}
if !strings.Contains(out.String(), "SHARED_RELAY_KEY is already used by provider relay") {
t.Fatalf("wizard did not say why the typed name was refused: %q", out.String())
}
if _, err := commitProviderSetupSession(s, path); err != nil {
t.Fatal(err)
}
if got := savedKeyEnv(t, path, "custom-opencode-ai"); !strings.HasPrefix(got, "REASONIX_CONNECTION_") {
t.Fatalf("api_key_env = %q, want a private slot after Enter", got)
}
if config.CredentialStored("SHARED_RELAY_KEY") {
t.Fatal("the new provider's key was written into relay's variable")
}
}
func TestSetupRefusesATypedNameThatAlreadyHoldsAKey(t *testing.T) {
s, path := newKeyEnvTestSession(t, nil)
if _, err := config.SetCredential("ORPHAN_KEY", "sk-someone-else"); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if !addOpencodeThroughWizard(t, s, "chat\nORPHAN_KEY\n\nOPENCODE_KEY\n", &out) {
t.Fatalf("wizard refused the provider: %s", out.String())
}
if !strings.Contains(out.String(), "ORPHAN_KEY already holds a saved credential") {
t.Fatalf("wizard did not say why the typed name was refused: %q", out.String())
}
if _, err := commitProviderSetupSession(s, path); err != nil {
t.Fatal(err)
}
if got := savedKeyEnv(t, path, "custom-opencode-ai"); got != "OPENCODE_KEY" {
t.Fatalf("api_key_env = %q, want the second typed name", got)
}
if res := config.ResolveCredentialForRootGlobalFirst(".", "ORPHAN_KEY"); res.Value != "sk-someone-else" {
t.Fatalf("existing credential became %q", res.Value)
}
}
func TestSetupSaveRefusesATypedNameClaimedAfterTheWizard(t *testing.T) {
// A declared access list keeps the concurrent write from also changing which providers count as configured.
s, path := newKeyEnvTestSession(t, func(c *config.Config) { c.Desktop.ProviderAccess = []string{"deepseek"} })
var out bytes.Buffer
if !addOpencodeThroughWizard(t, s, "chat\nCUSTOM_OPENCODE_AI_API_KEY\n\n", &out) {
t.Fatalf("wizard refused the provider: %s", out.String())
}
if _, err := config.SetCredential("CUSTOM_OPENCODE_AI_API_KEY", "sk-written-meanwhile"); err != nil {
t.Fatal(err)
}
_, err := commitProviderSetupSession(s, path)
var inUse *config.CredentialKeyInUseError
if !errors.As(err, &inUse) || inUse.Holder != config.CredentialKeyHeldByStore {
t.Fatalf("commit error = %v, want CredentialKeyInUseError held by the store", err)
}
if res := config.ResolveCredentialForRootGlobalFirst(".", "CUSTOM_OPENCODE_AI_API_KEY"); res.Value != "sk-written-meanwhile" {
t.Fatalf("commit overwrote the concurrent credential with %q", res.Value)
}
}
func TestSetupRefusesATypedNameABotSettingReads(t *testing.T) {
s, path := newKeyEnvTestSession(t, nil)
secret := s.cfg.Bot.QQ.AppSecretEnv
var out bytes.Buffer
if !addOpencodeThroughWizard(t, s, "chat\n"+secret+"\n\n\n", &out) {
t.Fatalf("wizard refused the provider: %s", out.String())
}
if !strings.Contains(out.String(), secret+" is read by a bot or remote-host setting") {
t.Fatalf("wizard did not say why %s was refused: %q", secret, out.String())
}
if _, err := commitProviderSetupSession(s, path); err != nil {
t.Fatal(err)
}
if config.CredentialStored(secret) {
t.Fatalf("the provider key was written into the bot secret %s", secret)
}
}
func TestSetupRefusesATypedNameTheEnvironmentSets(t *testing.T) {
for _, key := range []string{"PATH", "OPENAI_API_KEY"} {
t.Run(key, func(t *testing.T) {
s, path := newKeyEnvTestSession(t, nil)
before := "shell-value"
if key == "PATH" {
before = os.Getenv("PATH")
}
t.Setenv(key, before)
var out bytes.Buffer
if !addOpencodeThroughWizard(t, s, "chat\n"+key+"\n\n\n", &out) {
t.Fatalf("wizard refused the provider: %s", out.String())
}
if !strings.Contains(out.String(), key+" is already set in the environment") {
t.Fatalf("wizard did not say why %s was refused: %q", key, out.String())
}
if _, err := commitProviderSetupSession(s, path); err != nil {
t.Fatal(err)
}
if got := os.Getenv(key); got != before {
t.Fatalf("%s changed to %q", key, got)
}
if config.CredentialStored(key) {
t.Fatalf("%s was written to the credential store", key)
}
})
}
}
+29 -38
View File
@@ -25,6 +25,7 @@ type providerSetupSession struct {
// Credentials are owned by selected connections, never by their previous
// shared environment variable. The legacy map above is summary-only.
pendingConnectionCredentials map[string]string
typedKeyEnv map[string]string // provider -> api_key_env the user typed; its pending key is saved there
removed map[string]bool
accessDeclared bool
projectScoped bool
@@ -97,6 +98,7 @@ func newProviderSetupSession(cfg *config.Config) *providerSetupSession {
originalDefault: cfg.DefaultModel,
pendingCredentials: map[string]string{},
pendingConnectionCredentials: map[string]string{},
typedKeyEnv: map[string]string{},
removed: map[string]bool{},
}
for _, p := range cfg.Providers {
@@ -231,6 +233,7 @@ func (s *providerSetupSession) remove(name string) error {
return err
}
s.recordProviderMutation(name, before, nil)
delete(s.typedKeyEnv, name)
s.removeProviderAccess(name)
if _, existed := s.originalProviders[name]; existed {
s.removed[name] = true
@@ -578,31 +581,7 @@ func addProviderToSession(s *providerSetupSession, anthropic bool) bool {
}
return false
}
for _, entry := range result.entries {
if !confirmSharedCredential(s.cfg, entry, "") {
return false
}
}
if err := s.add(result.entries); err != nil {
fmt.Fprintln(os.Stderr, err)
return false
}
s.addProviderAccess(result.entries)
for key, value := range result.credentials {
var names []string
for _, entry := range result.entries {
if entry.APIKeyEnv == key {
names = append(names, entry.Name)
}
}
if err := s.setCredentialForProviders(names, key, value); err != nil {
fmt.Fprintln(os.Stderr, err)
return false
}
}
// After the new keys are staged, so usability sees them.
s.promoteDefaultToNewProviders(result.entries)
return true
return s.addPrompted(bufio.NewScanner(os.Stdin), os.Stdout, result)
}
func manageProvider(s *providerSetupSession, providerIndex int) {
@@ -698,9 +677,12 @@ func confirmSharedCredential(cfg *config.Config, candidate config.ProviderEntry,
func updateProviderKey(s *providerSetupSession, p config.ProviderEntry) {
in := bufio.NewScanner(os.Stdin)
keyEnvChanged := false
keyEnvChanged, typed := false, false
if p.APIKeyEnv == "" {
p.APIKeyEnv = promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, apiKeyEnvFromProviderName(p.Name))
draft := apiKeyEnvFromProviderName(p.Name)
if p.APIKeyEnv, typed = promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, draft); typed {
p.APIKeyEnv, typed = s.settleTypedKeyEnv(in, os.Stdout, i18n.M.CustomPromptKeyEnv, []string{p.Name}, p.APIKeyEnv, draft)
}
keyEnvChanged = true
}
if !confirmSharedCredential(s.cfg, p, p.Name) {
@@ -718,6 +700,10 @@ func updateProviderKey(s *providerSetupSession, p config.ProviderEntry) {
}
if err := s.setCredentialForProviders([]string{p.Name}, p.APIKeyEnv, value); err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
if typed {
s.typedKeyEnv[p.Name] = p.APIKeyEnv
}
}
@@ -928,20 +914,23 @@ func commitProviderSetupSession(s *providerSetupSession, configPath string) (boo
return false, err
}
for name, value := range s.pendingConnectionCredentials {
slot, err := fresh.StageModelCredentialLocked(value)
entry, ok := fresh.Provider(name)
if !ok {
return false, &providerSetupConflictError{field: fmt.Sprintf("provider %q", name)}
}
var slot string
if typed := s.typedKeyEnv[name]; typed != "" && typed == entry.APIKeyEnv {
slot, err = fresh.StageNamedModelCredentialLocked(typed, name, value)
} else {
slot, err = fresh.StageModelCredentialLocked(value)
}
if err != nil {
return false, err
}
{
entry, ok := fresh.Provider(name)
if !ok {
return false, &providerSetupConflictError{field: fmt.Sprintf("provider %q", name)}
}
updated := *entry
updated.APIKeyEnv = slot
if err := fresh.UpsertProvider(updated); err != nil {
return false, err
}
updated := *entry
updated.APIKeyEnv = slot
if err := fresh.UpsertProvider(updated); err != nil {
return false, err
}
}
current, err := readProviderSetupFileSnapshot(configPath)
@@ -983,6 +972,8 @@ func saveProviderSetupSession(s *providerSetupSession, configPath, envPath strin
var conflict *providerSetupConflictError
if errors.As(err, &conflict) {
fmt.Fprintf(os.Stderr, i18n.M.SetupConcurrentChangeFmt+"\n", conflict.field)
} else if errors.Is(err, config.ErrCredentialKeyInUse) {
fmt.Fprintln(os.Stderr, i18n.M.WriteConfigErr, keyEnvInUseText(err))
} else {
fmt.Fprintln(os.Stderr, i18n.M.WriteConfigErr, err)
}
+141
View File
@@ -0,0 +1,141 @@
package config
import (
"errors"
"fmt"
"os"
"runtime"
"strings"
)
// ErrCredentialKeyInUse marks a named credential variable that a connection
// may not claim; CredentialKeyInUseError says who holds it.
var ErrCredentialKeyInUse = errors.New("credential variable already in use")
// CredentialKeyHolder names why a variable is unavailable.
type CredentialKeyHolder uint8
const (
// CredentialKeyHeldByProvider: another provider entry references the variable.
CredentialKeyHeldByProvider CredentialKeyHolder = iota + 1
// CredentialKeyHeldBySetting: a bot or remote-host setting reads the variable.
CredentialKeyHeldBySetting
// CredentialKeyHeldByStore: the credential store already has a value or tombstone for it.
CredentialKeyHeldByStore
// CredentialKeyHeldByEnvironment: the process environment sets it from outside the store.
CredentialKeyHeldByEnvironment
)
type CredentialKeyInUseError struct {
Key string
Holder CredentialKeyHolder
Provider string // set when Holder is CredentialKeyHeldByProvider
}
func (e *CredentialKeyInUseError) Error() string {
switch e.Holder {
case CredentialKeyHeldByProvider:
return fmt.Sprintf("%s is referenced by provider %q", e.Key, e.Provider)
case CredentialKeyHeldBySetting:
return fmt.Sprintf("%s is read by a bot or remote-host setting", e.Key)
case CredentialKeyHeldByEnvironment:
return fmt.Sprintf("%s is already set in the environment", e.Key)
default:
return fmt.Sprintf("%s already has a stored credential", e.Key)
}
}
func (e *CredentialKeyInUseError) Unwrap() error { return ErrCredentialKeyInUse }
// sameCredentialName compares variable names the way the OS environment does.
func sameCredentialName(a, b string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(a, b)
}
return a == b
}
func environmentHasName(key string) bool {
for _, kv := range os.Environ() {
name, _, _ := strings.Cut(kv, "=")
if name != "" && sameCredentialName(name, key) {
return true
}
}
return false
}
// CredentialKeyClaimable reports whether provider may write its key under key
// without changing what any other reader of that variable resolves. A stored
// value or tombstone may belong to a config this one cannot see.
func (c *Config) CredentialKeyClaimable(key, provider string) error {
key = strings.TrimSpace(key)
if !isCredentialKey(key) {
return fmt.Errorf("invalid credential key %q", key)
}
for _, p := range c.Providers {
if p.Name != provider && sameCredentialName(strings.TrimSpace(p.APIKeyEnv), key) {
return &CredentialKeyInUseError{Key: key, Holder: CredentialKeyHeldByProvider, Provider: p.Name}
}
}
others := *c
others.Providers = nil
for _, name := range credentialEnvNamesFromConfig(&others) {
if sameCredentialName(name, key) {
return &CredentialKeyInUseError{Key: key, Holder: CredentialKeyHeldBySetting}
}
}
if credentialCurrentStoreHasKey(key) || credentialCurrentStoreClearedKey(key) {
return &CredentialKeyInUseError{Key: key, Holder: CredentialKeyHeldByStore}
}
if environmentHasName(key) {
return &CredentialKeyInUseError{Key: key, Holder: CredentialKeyHeldByEnvironment}
}
return nil
}
// StageNamedModelCredentialLocked is StageModelCredentialLocked for a name the
// user chose. It refuses a name CredentialKeyClaimable rejects, so the no-
// overwrite and rollback guarantees of a fresh slot still hold.
func (c *Config) StageNamedModelCredentialLocked(key, provider, value string) (string, error) {
if err := c.CredentialKeyClaimable(key, provider); err != nil {
return "", err
}
return c.stageModelCredentialLocked(strings.TrimSpace(key), value)
}
// configReferencesCredential reports whether the config at path reads key.
// A config that cannot be parsed counts as referencing it: removal needs proof.
func configReferencesCredential(path, key string) bool {
if _, err := os.Stat(path); os.IsNotExist(err) {
return false
}
cfg, err := LoadForEditWithoutCredentialsReadOnlyStrict(path)
if err != nil {
return true
}
for _, name := range credentialEnvNamesFromConfig(cfg) {
if sameCredentialName(name, key) {
return true
}
}
return false
}
func stagedCredentialDigest(value string) (string, error) {
return ModelSettingsRequestDigest([]byte(strings.TrimSpace(value)))
}
// stagedValueUnchanged reports whether slot still holds what this edit staged.
// No recorded digest means a journal from before digests existed.
func stagedValueUnchanged(slot, digest string) bool {
if digest == "" {
return true
}
value, ok := envFileValue(UserCredentialsPath(), slot)
if !ok {
return !envFileHasClearedKey(UserCredentialsPath(), slot)
}
got, err := stagedCredentialDigest(value)
return err == nil && got == digest
}
@@ -0,0 +1,142 @@
package config
import (
"errors"
"os"
"path/filepath"
"testing"
)
func unsetForTest(t *testing.T, key string) {
t.Helper()
t.Setenv(key, "") // registers the restore; staging pins the key into the process
_ = os.Unsetenv(key)
}
func stageNamedAndStop(t *testing.T, path, key, value string) *Config {
t.Helper()
unsetForTest(t, key)
unlockConfig := LockUserConfigEdits()
defer unlockConfig()
unlockCredentials, err := LockUserCredentialEdits()
if err != nil {
t.Fatal(err)
}
defer unlockCredentials()
cfg, err := LoadForEditReadOnlyStrict(path)
if err != nil {
t.Fatal(err)
}
if err := cfg.BeginModelCredentialCommitLocked(path, "cli-setup"); err != nil {
t.Fatal(err)
}
if _, err := cfg.StageNamedModelCredentialLocked(key, "p", value); err != nil {
t.Fatal(err)
}
return cfg
}
func recoverLocked(t *testing.T, path string) {
t.Helper()
unlockConfig := LockUserConfigEdits()
defer unlockConfig()
unlockCredentials, err := LockUserCredentialEdits()
if err != nil {
t.Fatal(err)
}
defer unlockCredentials()
if err := RecoverModelCredentialCommitsLocked(path); err != nil {
t.Fatal(err)
}
}
func journalCount(t *testing.T) int {
t.Helper()
entries, err := os.ReadDir(modelCredentialTransactionDir())
if os.IsNotExist(err) {
return 0
}
if err != nil {
t.Fatal(err)
}
n := 0
for _, e := range entries {
if filepath.Ext(e.Name()) == ".json" {
n++
}
}
return n
}
func TestRecoveryKeepsANamedSlotAnotherWriterTookOver(t *testing.T) {
isolateUserConfigHome(t)
path := UserConfigPath()
if err := Default().SaveTo(path); err != nil {
t.Fatal(err)
}
stageNamedAndStop(t, path, "TEAM_KEY", "sk-mine")
if _, err := SetCredential("TEAM_KEY", "sk-team-owned-elsewhere"); err != nil {
t.Fatal(err)
}
recoverLocked(t, path)
if value, _ := envFileValue(UserCredentialsPath(), "TEAM_KEY"); value != "sk-team-owned-elsewhere" {
t.Fatalf("recovery removed a value it did not stage: TEAM_KEY=%q", value)
}
}
func TestRecoveryStillRemovesAnUnpublishedNamedSlot(t *testing.T) {
isolateUserConfigHome(t)
path := UserConfigPath()
if err := Default().SaveTo(path); err != nil {
t.Fatal(err)
}
stageNamedAndStop(t, path, "TEAM_KEY", "sk-mine")
recoverLocked(t, path)
if CredentialStored("TEAM_KEY") || journalCount(t) != 0 {
t.Fatalf("unpublished slot stored=%v journals=%d, want both gone", CredentialStored("TEAM_KEY"), journalCount(t))
}
}
func TestCleanupReadsReferencesStructurallyNotBySubstring(t *testing.T) {
isolateUserConfigHome(t)
path := UserConfigPath()
cfg := Default()
cfg.Providers = []ProviderEntry{{Name: "deep", Kind: "openai", BaseURL: "https://deep.invalid/v1", Model: "chat", APIKeyEnv: "DEEPSEEK_API_KEY"}}
if err := cfg.SaveTo(path); err != nil {
t.Fatal(err)
}
staged := stageNamedAndStop(t, path, "API_KEY", "sk-short")
func() {
unlockConfig := LockUserConfigEdits()
defer unlockConfig()
unlockCredentials, err := LockUserCredentialEdits()
if err != nil {
t.Fatal(err)
}
defer unlockCredentials()
staged.CleanupStagedModelCredentialsLocked(path)
}()
if CredentialStored("API_KEY") || journalCount(t) != 0 {
t.Fatalf("API_KEY stored=%v journals=%d: a substring of DEEPSEEK_API_KEY kept the orphan", CredentialStored("API_KEY"), journalCount(t))
}
}
func TestClaimableRefusesNamesOtherReadersHold(t *testing.T) {
isolateUserConfigHome(t)
t.Setenv("OPENAI_API_KEY", "sk-shell")
cfg := Default()
cfg.Remote.Hosts = append(cfg.Remote.Hosts, RemoteHostEntry{PasswordEnv: "BOX_PASSWORD"})
cases := map[string]CredentialKeyHolder{
cfg.Bot.QQ.AppSecretEnv: CredentialKeyHeldBySetting,
"BOX_PASSWORD": CredentialKeyHeldBySetting,
"PATH": CredentialKeyHeldByEnvironment,
"OPENAI_API_KEY": CredentialKeyHeldByEnvironment,
}
for key, want := range cases {
err := cfg.CredentialKeyClaimable(key, "p")
var inUse *CredentialKeyInUseError
if !errors.As(err, &inUse) || inUse.Holder != want {
t.Errorf("CredentialKeyClaimable(%q) = %v, want holder %d", key, err, want)
}
}
}
+50 -30
View File
@@ -1,7 +1,6 @@
package config
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
@@ -10,6 +9,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"
@@ -28,9 +28,11 @@ type modelCredentialCommitJournal struct {
AfterRevision string `json:"afterRevision,omitempty"`
ResultRevision string `json:"resultRevision,omitempty"`
Slots []string `json:"slots"`
Phase string `json:"phase"`
UpdatedAt string `json:"updatedAt"`
journalPath string
// SlotDigests: slot -> keyed digest of the staged value; removal needs the store to still hold it.
SlotDigests map[string]string `json:"slotDigests,omitempty"`
Phase string `json:"phase"`
UpdatedAt string `json:"updatedAt"`
journalPath string
}
// ModelSettingsReceipt is durable evidence that a request crossed the config
@@ -229,16 +231,28 @@ func (c *Config) StageModelCredentialLocked(value string) (string, error) {
if _, err := rand.Read(id[:]); err != nil {
return "", err
}
key := fmt.Sprintf("REASONIX_CONNECTION_%X_KEY", id)
return c.stageModelCredentialLocked(fmt.Sprintf("REASONIX_CONNECTION_%X_KEY", id), value)
}
func (c *Config) stageModelCredentialLocked(key, value string) (string, error) {
value = strings.TrimSpace(value)
if strings.ContainsAny(value, "\r\n") {
return "", fmt.Errorf("credential value contains a newline")
}
if c.modelCredentialCommit != nil {
c.modelCredentialCommit.Slots = append(c.modelCredentialCommit.Slots, key)
c.modelCredentialCommit.Phase = "prepared"
if err := writeModelCredentialJournal(c.modelCredentialCommit); err != nil {
c.modelCredentialCommit.Slots = c.modelCredentialCommit.Slots[:len(c.modelCredentialCommit.Slots)-1]
if j := c.modelCredentialCommit; j != nil {
digest, err := stagedCredentialDigest(value)
if err != nil {
return "", err
}
if j.SlotDigests == nil {
j.SlotDigests = map[string]string{}
}
j.Slots = append(j.Slots, key)
j.SlotDigests[key] = digest
j.Phase = "prepared"
if err := writeModelCredentialJournal(j); err != nil {
j.Slots = j.Slots[:len(j.Slots)-1]
delete(j.SlotDigests, key)
return "", err
}
}
@@ -312,15 +326,16 @@ func (c *Config) CleanupStagedModelCredentialsLocked(path string) {
}
return
}
raw, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return
}
referenced := false
for _, key := range c.stagedModelCredentials {
// Check the entire document to retain references in unknown fields too.
if bytes.Contains(raw, []byte(key)) {
digest := ""
if c.modelCredentialCommit != nil {
digest = c.modelCredentialCommit.SlotDigests[key]
}
if configReferencesCredential(path, key) {
referenced = true
} else if !stagedValueUnchanged(key, digest) {
continue // Another writer owns the slot now.
} else {
if err := removeCredentialFromFile(UserCredentialsPath(), key); err == nil {
_ = os.Unsetenv(key)
@@ -474,17 +489,10 @@ func RecoverModelCredentialCommitsLocked(configPath string) error {
if json.Unmarshal(raw, &j) != nil || j.Schema != modelCredentialCommitSchema || filepath.Clean(j.ConfigPath) != configPath {
continue
}
configRaw, configErr := os.ReadFile(configPath)
if configErr != nil && !os.IsNotExist(configErr) {
if _, statErr := os.Stat(configPath); statErr != nil && !os.IsNotExist(statErr) {
continue
}
anyReferenced := false
for _, slot := range j.Slots {
if bytes.Contains(configRaw, []byte(slot)) {
anyReferenced = true
break
}
}
anyReferenced := slices.ContainsFunc(j.Slots, func(slot string) bool { return configReferencesCredential(configPath, slot) })
committed := false
if anyReferenced || j.AfterRevision != "" {
var committedErr error
@@ -516,11 +524,8 @@ func RecoverModelCredentialCommitsLocked(configPath string) error {
if fileContentRevision(configPath) != j.BeforeRevision {
continue
}
for _, slot := range j.Slots {
if err := removeCredentialFromFile(UserCredentialsPath(), slot); err != nil {
return err
}
_ = os.Unsetenv(slot)
if err := removeUnpublishedSlots(&j); err != nil {
return err
}
if err := os.Remove(journalPath); err != nil && !os.IsNotExist(err) {
return err
@@ -528,3 +533,18 @@ func RecoverModelCredentialCommitsLocked(configPath string) error {
}
return nil
}
// removeUnpublishedSlots skips a slot whose value is no longer the staged one:
// another writer owns it now.
func removeUnpublishedSlots(j *modelCredentialCommitJournal) error {
for _, slot := range j.Slots {
if !stagedValueUnchanged(slot, j.SlotDigests[slot]) {
continue
}
if err := removeCredentialFromFile(UserCredentialsPath(), slot); err != nil {
return err
}
_ = os.Unsetenv(slot)
}
return nil
}
+31 -2
View File
@@ -10,15 +10,35 @@ import (
"reasonix/internal/plugin"
)
type invokedSkillsKey struct{}
func withInvokedSkills(ctx context.Context, names []string) context.Context {
if len(names) == 0 {
return ctx
}
invoked := make(map[string]bool, len(names))
for _, name := range names {
invoked[strings.ToLower(name)] = true
}
return context.WithValue(ctx, invokedSkillsKey{}, invoked)
}
func invokedSkills(ctx context.Context) map[string]bool {
invoked, _ := ctx.Value(invokedSkillsKey{}).(map[string]bool)
return invoked
}
func (c *Controller) withCapabilityRoute(ctx context.Context, composed, routeInput string) string {
if c == nil {
return composed
}
routeInput = strings.TrimSpace(agent.StripTransientUserBlocks(routeInput))
if routeInput == "" {
// A resolved inline invocation already supplies the skill body. With no
// typed task, routing on that body would invent a second skill request.
if routeInput == "" && len(invokedSkills(ctx)) == 0 {
routeInput = strings.TrimSpace(agent.StripTransientUserBlocks(composed))
}
if routeInput == "" {
if routeInput == "" && len(invokedSkills(ctx)) == 0 {
return composed
}
decision := c.routeCapabilities(ctx, routeInput)
@@ -93,6 +113,15 @@ func (c *Controller) routeCapabilities(ctx context.Context, routeInput string) c
}
}
catalog := capability.BuildCatalog(opts)
if invoked := invokedSkills(ctx); len(invoked) > 0 {
entries := make([]capability.Entry, 0, len(catalog.Entries))
for _, entry := range catalog.Entries {
if entry.Kind != capability.KindSkill || !invoked[strings.ToLower(entry.Name)] {
entries = append(entries, entry)
}
}
catalog.Entries = entries
}
decision := capability.Route(routeInput, catalog.Entries)
if c.capabilityProxy {
decision.CapabilityProxy = true
+20 -13
View File
@@ -1375,8 +1375,9 @@ func (c *Controller) submitInvocationsLocked(input, display string, requests []I
}
type preparedInvocationTurn struct {
composed string
subagents []skill.Skill
composed string
subagents []skill.Skill
inlineSkillNames []string
}
func (c *Controller) prepareInvocationTurn(input string, requests []InvocationRequest) (preparedInvocationTurn, error) {
@@ -1403,20 +1404,25 @@ func (c *Controller) prepareInvocationTurn(input string, requests []InvocationRe
}
}
if strings.TrimSpace(input) == "" && len(subagents) > 0 {
return preparedInvocationTurn{}, fmt.Errorf("subagent invocation requires a task")
}
inlineSkillNames := make([]string, 0, len(inline))
for _, sk := range inline {
inlineSkillNames = append(inlineSkillNames, sk.Name)
}
// A lone inline skill takes the typed text as its arguments, as "/name task" does.
if len(inline) == 1 && len(subagents) == 0 {
return preparedInvocationTurn{composed: c.skills.renderInvocation(inline[0], strings.TrimSpace(input)), inlineSkillNames: inlineSkillNames}, nil
}
parts := make([]string, 0, len(inline)+1)
for _, sk := range inline {
parts = append(parts, c.skills.render(sk, ""))
parts = append(parts, c.skills.renderInvocation(sk, ""))
}
if strings.TrimSpace(input) != "" {
parts = append(parts, input)
}
composed := strings.Join(parts, "\n\n")
if strings.TrimSpace(input) == "" {
if len(subagents) > 0 {
return preparedInvocationTurn{}, fmt.Errorf("subagent invocation requires a task")
}
}
return preparedInvocationTurn{composed: composed, subagents: subagents}, nil
return preparedInvocationTurn{composed: strings.Join(parts, "\n\n"), subagents: subagents, inlineSkillNames: inlineSkillNames}, nil
}
func (c *Controller) runPreparedInvocationTurn(
@@ -1425,6 +1431,7 @@ func (c *Controller) runPreparedInvocationTurn(
input, raw, display string,
frozenImages []string,
) error {
ctx = withInvokedSkills(ctx, prepared.inlineSkillNames)
if len(prepared.subagents) == 0 {
return c.runGoalLoopWithFrozenImagesRawDisplay(ctx, prepared.composed, raw, display, frozenImages)
}
@@ -1693,9 +1700,9 @@ func (c *Controller) submitCommandOrTurnReady(trimmed, input, display string, sc
c.runSubagentSkillSlash(sk, task, trimmed, display, admission)
return
}
sent := c.skills.render(sk, task)
sent := c.skills.renderInvocation(sk, task)
c.runGuardedWithAdmission(func(ctx context.Context) error {
return runGoalLoop(ctx, sent, sent, display)
return runGoalLoop(withInvokedSkills(ctx, []string{sk.Name}), sent, input, display)
}, admission)
return
}
@@ -4354,7 +4361,7 @@ func (c *Controller) ReloadCommands(ctx context.Context) error {
entries = append(entries, command.SlashEntry{
Name: sk.SlashName(),
Description: sk.Description,
Render: func(args []string) string { return c.skills.render(sk, strings.Join(args, " ")) },
Render: func(args []string) string { return c.skills.renderInvocation(sk, strings.Join(args, " ")) },
})
}
for _, cmd := range cmds {
+1 -1
View File
@@ -497,7 +497,7 @@ func (c *Controller) RunSkill(input string) (sent string, found bool) {
if !ok {
return "", false
}
return c.skills.render(sk, task), true
return c.skills.renderInvocation(sk, task), true
}
// MCPPrompt resolves a "/mcp__server__prompt args…" line: it maps the positional
@@ -0,0 +1,32 @@
package control
import (
"strings"
"testing"
"reasonix/internal/skill"
)
func TestSubmitInvocationDisplayKeepsSharedTextAcrossSeveralInlineSkills(t *testing.T) {
runner := &fakeTurnRunner{}
c := newOwnedTestController(t, Options{
Runner: runner,
Skills: []skill.Skill{
{Name: "one", Body: "ONE_BODY", RunAs: skill.RunInline, Scope: skill.ScopeGlobal},
{Name: "two", Body: "TWO_BODY", RunAs: skill.RunInline, Scope: skill.ScopeGlobal},
},
})
c.SubmitInvocationDisplay("/one /two shared task", "shared task", []InvocationRequest{
{Name: "two", Kind: "skill", Offset: 5},
{Name: "one", Kind: "skill", Offset: 0},
})
waitIdle(t, c)
if len(runner.inputs) != 1 {
t.Fatalf("inputs = %q", runner.inputs)
}
got := runner.inputs[0]
one, two := strings.Index(got, "<skill-pin name=\"one\">"), strings.Index(got, "<skill-pin name=\"two\">")
if one < 0 || two < one || strings.Contains(got, "Arguments:") || !strings.HasSuffix(got, "</skill-pin>\n\nshared task") {
t.Fatalf("several inline skills should each be pinned with the text appended once:\n%s", got)
}
}
+2 -5
View File
@@ -78,11 +78,8 @@ func (s *skillSet) prepare(sk skill.Skill) skill.Skill {
return sk
}
func (s *skillSet) render(sk skill.Skill, args string) string {
if s.store != nil {
return s.store.Render(sk, args)
}
return skill.Render(sk, args)
func (s *skillSet) renderInvocation(sk skill.Skill, args string) string {
return skill.RenderInvocation(s.prepare(sk), args)
}
// writer returns the live store to use for authoring (create/delete), preferring
+5
View File
@@ -511,6 +511,11 @@ type Messages struct {
SetupBack string
SetupPromptModels string
SetupSharedKeyWarningFmt string
SetupKeyEnvTakenFmt string
SetupKeyEnvStoredFmt string
SetupKeyEnvSettingFmt string
SetupKeyEnvShellFmt string
SetupKeyEnvRetry string
SetupPromptAPIKeyFmt string
SetupSelectDefaultModel string
SetupConfirmRemoveFmt string
+7 -2
View File
@@ -474,6 +474,11 @@ var English = Messages{
SetupBack: "Back",
SetupPromptModels: "Models (comma-separated)",
SetupSharedKeyWarningFmt: "%s is also used by %s at %s. Share this credential?",
SetupKeyEnvTakenFmt: "%s is already used by provider %s; saving this key under it would replace that provider's key.",
SetupKeyEnvStoredFmt: "%s already holds a saved credential; saving this key under it would overwrite that value.",
SetupKeyEnvSettingFmt: "%s is read by a bot or remote-host setting; saving this key under it would hand that setting your key.",
SetupKeyEnvShellFmt: "%s is already set in the environment Reasonix runs in; saving this key under it would override that value.",
SetupKeyEnvRetry: "Enter another name, or press Enter to let Reasonix choose a private variable.",
SetupPromptAPIKeyFmt: "Enter new value for %s",
SetupSelectDefaultModel: "Select default model",
SetupConfirmRemoveFmt: "Remove provider %s?",
@@ -507,7 +512,7 @@ var English = Messages{
CustomMethodURL: "Fetch models from URL",
CustomPromptModel: "Enter model name",
CustomPromptBaseURL: "Enter Base URL",
CustomPromptKeyEnv: "API Key variable name (press Enter to use the default; not the model name)",
CustomPromptKeyEnv: "API Key variable name (press Enter to let Reasonix choose; not the model name)",
CustomPromptAPIKey: "Enter API Key",
CustomPromptWindow: "Context window in tokens (a value below the model's real window makes compaction fire early)",
CustomAddedFmt: "Added custom model: %s",
@@ -519,7 +524,7 @@ var English = Messages{
AnthropicMethodURL: "Fetch models from URL",
AnthropicPromptModel: "Enter model name",
AnthropicPromptBaseURL: "Enter Base URL",
AnthropicPromptKeyEnv: "API Key variable name (press Enter to use the default; not the model name)",
AnthropicPromptKeyEnv: "API Key variable name (press Enter to let Reasonix choose; not the model name)",
AnthropicPromptAPIKey: "Enter API Key",
AnthropicAddedFmt: "Added Anthropic compatible model: %s",
AnthropicFetchingModelsFmt: "Fetching models for %s...",
+7 -2
View File
@@ -475,6 +475,11 @@ var Chinese = Messages{
SetupBack: "返回",
SetupPromptModels: "模型列表(用逗号分隔)",
SetupSharedKeyWarningFmt: "%s 已被 %s(%s)使用。确认共享这项凭据?",
SetupKeyEnvTakenFmt: "%s 已被 provider %s 使用;把这个 key 存进去会替换那个 provider 的 key。",
SetupKeyEnvStoredFmt: "%s 已经保存了一项凭据;把这个 key 存进去会覆盖原来的值。",
SetupKeyEnvSettingFmt: "%s 已被机器人或远程主机设置读取;把这个 key 存进去会让那项设置拿到你的 key。",
SetupKeyEnvShellFmt: "%s 已在 Reasonix 运行的环境变量中设置;把这个 key 存进去会覆盖那个值。",
SetupKeyEnvRetry: "请换一个变量名,或直接回车由 Reasonix 分配独立变量。",
SetupPromptAPIKeyFmt: "输入 %s 的新值",
SetupSelectDefaultModel: "选择默认模型",
SetupConfirmRemoveFmt: "确认删除供应商 %s?",
@@ -508,7 +513,7 @@ var Chinese = Messages{
CustomMethodURL: "从 URL 获取模型列表",
CustomPromptModel: "请输入模型名称",
CustomPromptBaseURL: "请输入 Base URL",
CustomPromptKeyEnv: "API Key 变量名(直接回车使用默认值,不是模型名)",
CustomPromptKeyEnv: "API Key 变量名(直接回车由 Reasonix 自动分配,不是模型名)",
CustomPromptAPIKey: "请输入 API Key",
CustomPromptWindow: "上下文窗口(tokens,填得比模型真实窗口小会导致过早压缩)",
CustomAddedFmt: "已添加自定义模型: %s",
@@ -520,7 +525,7 @@ var Chinese = Messages{
AnthropicMethodURL: "从 URL 获取模型列表",
AnthropicPromptModel: "请输入模型名称",
AnthropicPromptBaseURL: "请输入 Base URL",
AnthropicPromptKeyEnv: "API Key 变量名(直接回车使用默认值,不是模型名)",
AnthropicPromptKeyEnv: "API Key 变量名(直接回车由 Reasonix 自动分配,不是模型名)",
AnthropicPromptAPIKey: "请输入 API Key",
AnthropicAddedFmt: "已添加 Anthropic 兼容模型: %s",
AnthropicFetchingModelsFmt: "正在获取 %s 的模型列表...",
+7 -2
View File
@@ -448,6 +448,11 @@ var ChineseTraditional = Messages{
SetupBack: "返回",
SetupPromptModels: "模型列表(用逗號分隔)",
SetupSharedKeyWarningFmt: "%s 已被 %s(%s)使用。確認共用這項憑據?",
SetupKeyEnvTakenFmt: "%s 已被 provider %s 使用;把這個 key 存進去會取代那個 provider 的 key。",
SetupKeyEnvStoredFmt: "%s 已經儲存了一項憑據;把這個 key 存進去會覆寫原本的值。",
SetupKeyEnvSettingFmt: "%s 已被機器人或遠端主機設定讀取;把這個 key 存進去會讓那項設定拿到你的 key。",
SetupKeyEnvShellFmt: "%s 已在 Reasonix 執行的環境變數中設定;把這個 key 存進去會覆寫那個值。",
SetupKeyEnvRetry: "請換一個變數名稱,或直接按 Enter 由 Reasonix 分配獨立變數。",
SetupPromptAPIKeyFmt: "輸入 %s 的新值",
SetupSelectDefaultModel: "選擇預設模型",
SetupConfirmRemoveFmt: "確認刪除供應商 %s?",
@@ -481,7 +486,7 @@ var ChineseTraditional = Messages{
CustomMethodURL: "從 URL 取得模型列表",
CustomPromptModel: "請輸入模型名稱",
CustomPromptBaseURL: "請輸入 Base URL",
CustomPromptKeyEnv: "API Key 變數名稱(直接按 Enter 使用預設值,不是模型名稱)",
CustomPromptKeyEnv: "API Key 變數名稱(直接按 Enter 由 Reasonix 自動分配,不是模型名稱)",
CustomPromptAPIKey: "請輸入 API Key",
CustomPromptWindow: "上下文視窗(tokens,填得比模型真實視窗小會導致過早壓縮)",
CustomAddedFmt: "已新增自訂模型: %s",
@@ -493,7 +498,7 @@ var ChineseTraditional = Messages{
AnthropicMethodURL: "從 URL 取得模型列表",
AnthropicPromptModel: "請輸入模型名稱",
AnthropicPromptBaseURL: "請輸入 Base URL",
AnthropicPromptKeyEnv: "API Key 變數名稱(直接按 Enter 使用預設值,不是模型名稱)",
AnthropicPromptKeyEnv: "API Key 變數名稱(直接按 Enter 由 Reasonix 自動分配,不是模型名稱)",
AnthropicPromptAPIKey: "請輸入 API Key",
AnthropicAddedFmt: "已新增 Anthropic 相容模型: %s",
AnthropicFetchingModelsFmt: "正在取得 %s 的模型列表...",
+43 -5
View File
@@ -68,11 +68,8 @@ func loadOrBuildSparseIndex(ctx context.Context, dir, cacheDir string) (sparseIn
if err != nil {
return sparseIndex{}, err
}
if data, readErr := os.ReadFile(sparseIndexPath(cacheDir)); readErr == nil {
var cached sparseIndex
if json.Unmarshal(data, &cached) == nil && cached.validFor(info, identity) {
return cached, nil
}
if cached, ok := readValidSparseIndex(cacheDir, info, identity); ok {
return cached, nil
}
rebuilt := sparseIndex{
@@ -110,6 +107,18 @@ func loadOrBuildSparseIndex(ctx context.Context, dir, cacheDir string) (sparseIn
return rebuilt, nil
}
func readValidSparseIndex(cacheDir string, info os.FileInfo, identity string) (sparseIndex, bool) {
data, err := os.ReadFile(sparseIndexPath(cacheDir))
if err != nil {
return sparseIndex{}, false
}
var cached sparseIndex
if json.Unmarshal(data, &cached) != nil || !cached.validFor(info, identity) {
return sparseIndex{}, false
}
return cached, true
}
func (idx sparseIndex) validFor(info os.FileInfo, identity string) bool {
if idx.Codec != sparseIndexCodec || idx.LogSize != info.Size() || idx.LogModTimeNS != info.ModTime().UnixNano() || idx.LogIdentity != identity {
return false
@@ -188,6 +197,35 @@ func (s *Store) recordPersistedIndex(file *os.File, start int64, commits []Commi
writeSparseIndex(s.dir, index)
}
// A reopened writer knows the durable sequence but not the older checkpoints.
// Adopt an already validated cache before appending, so the new commits can
// extend it without scanning history or leaving the disk index stale.
func (s *Store) adoptPersistedIndex(file *os.File, start int64) {
s.indexMu.Lock()
partial, lastSequence := s.index.partial, s.index.LastSequence
s.indexMu.Unlock()
if !partial {
return
}
info, err := file.Stat()
if err != nil || info.Size() != start {
return
}
identity, err := sparseLogIdentity(file, info)
if err != nil {
return
}
cached, ok := readValidSparseIndex(s.dir, info, identity)
if !ok || cached.LastSequence != lastSequence {
return
}
s.indexMu.Lock()
if s.index.partial && s.index.LastSequence == lastSequence {
s.index = cached
}
s.indexMu.Unlock()
}
func (s *Store) rebuildWriterIndex(_ *os.File) error {
index, err := loadOrBuildSparseIndex(context.Background(), s.dir, s.dir)
if err != nil {
+47
View File
@@ -80,3 +80,50 @@ func TestCorruptSparseIndexIsRebuiltFromValidatedLog(t *testing.T) {
t.Fatalf("rebuilt=%+v err=%v", rebuilt, err)
}
}
func TestReopenedWriterKeepsSparseIndexCurrentAcrossAppends(t *testing.T) {
dir := filepath.Join(t.TempDir(), "reopened")
writer, err := Open(dir, "reopened")
if err != nil {
t.Fatal(err)
}
appendEvent := func(operation string) {
t.Helper()
if _, err := writer.Append(t.Context(), Batch{OperationID: operation, Events: []Event{{Kind: "diagnostic/test", Optional: true}}}); err != nil {
t.Fatal(err)
}
if _, err := writer.Flush(t.Context()); err != nil {
t.Fatal(err)
}
}
appendEvent("initial")
if err := writer.Close(t.Context()); err != nil {
t.Fatal(err)
}
writer, err = Open(dir, "reopened")
if err != nil {
t.Fatal(err)
}
defer writer.Close(t.Context())
for i := range 2 {
appendEvent("after-reopen-" + strconv.Itoa(i))
before, err := os.Stat(sparseIndexPath(dir))
if err != nil {
t.Fatal(err)
}
page, err := writer.Read(t.Context(), 0, 10)
if err != nil {
t.Fatal(err)
}
if len(page.Commits) != i+2 {
t.Fatalf("read %d: got %d commits", i, len(page.Commits))
}
after, err := os.Stat(sparseIndexPath(dir))
if err != nil {
t.Fatal(err)
}
if !os.SameFile(before, after) {
t.Fatalf("read %d rebuilt the sparse index after append", i)
}
}
}
+3 -3
View File
@@ -772,9 +772,8 @@ func readStoredManifest(path string) (Manifest, error) {
return manifest, nil
}
// Append writes already-committed batches in order. Sequence allocation,
// validation, and idempotency belong to Session; this method is only the
// physical hand-off and reports uncertainty rather than guessing.
// Append writes committed batches in order. Session owns sequence allocation,
// validation, and idempotency; this method reports physical write uncertainty.
func (s *Store) Append(ctx context.Context, commits []Commit) error {
if s == nil {
return fmt.Errorf("session: nil store")
@@ -851,6 +850,7 @@ func (s *Store) persist(ctx context.Context, file *os.File, commits []Commit) er
if err != nil {
return err
}
s.adoptPersistedIndex(file, start)
if err := copyStagedAppend(ctx, staged, file, s.writeFn); err != nil {
end, statErr := file.Seek(0, io.SeekEnd)
if statErr == nil && end == start {
+1 -1
View File
@@ -70,5 +70,5 @@ func (t *readSkillTool) Execute(_ context.Context, args json.RawMessage) (string
}
return renderEmbeddedReference(sk, reference)
}
return renderInline(sk, strings.TrimSpace(p.Arguments)), nil
return RenderInvocation(sk, strings.TrimSpace(p.Arguments)), nil
}
-3
View File
@@ -337,9 +337,6 @@ func (s *Store) Prepare(sk Skill) Skill {
return sk
}
// Render prepares and renders a skill for a direct slash invocation.
func (s *Store) Render(sk Skill, args string) string { return Render(s.Prepare(sk), args) }
func bindAllowedTools(refs []string, bindings []tool.MCPBinding) []string {
if len(refs) == 0 {
return refs
+9 -11
View File
@@ -185,7 +185,7 @@ func (t *runSkillTool) Execute(ctx context.Context, args json.RawMessage) (strin
if opts.ContinueFrom != "" || opts.ForkFrom != "" {
return "", fmt.Errorf("run_skill: subagent continuation is only valid for runAs=subagent skills")
}
return renderInline(sk, rawArgs), nil
return RenderInvocation(sk, rawArgs), nil
}
func (t *runSkillTool) ResolveProfile(args json.RawMessage) *event.Profile {
@@ -334,7 +334,7 @@ func (t *readOnlySkillTool) Execute(ctx context.Context, args json.RawMessage) (
}
return tool.GuardSubagentHostDecisionText(out), nil
}
return renderInline(sk, rawArgs), nil
return RenderInvocation(sk, rawArgs), nil
}
func (t *readOnlySkillTool) ResolveProfile(args json.RawMessage) *event.Profile {
@@ -681,11 +681,9 @@ func RenderSkillFile(opts SkillFileOptions) string {
// shared helpers
// Render builds a skill's invocation text: a header (name, description, source)
// followed by the body and any arguments. Used directly when a user invokes a
// skill via "/<name>" (sent as a turn); the run_skill tool wraps the same text
// in a skill-pin sentinel (see renderInline).
func Render(sk Skill, args string) string {
// render builds a skill's text: a header (name, description, source) followed
// by the body and any arguments.
func render(sk Skill, args string) string {
var b strings.Builder
b.WriteString("# Skill: " + sk.Name)
if sk.Description != "" {
@@ -699,10 +697,10 @@ func Render(sk Skill, args string) string {
return b.String()
}
// renderInline wraps Render's output in a skill-pin sentinel so context
// compaction preserves the body verbatim instead of paraphrasing it.
func renderInline(sk Skill, args string) string {
return "<skill-pin name=" + strconv.Quote(sk.Name) + ">\n" + Render(sk, args) + "\n</skill-pin>"
// RenderInvocation is the single form every skill invocation reaches the model
// in, whether the user or the model invoked it.
func RenderInvocation(sk Skill, args string) string {
return "<skill-pin name=" + strconv.Quote(sk.Name) + ">\n" + render(sk, args) + "\n</skill-pin>"
}
var bracketTagRe = regexp.MustCompile(`\[[^\]]*\]`)
+3 -3
View File
@@ -57,7 +57,7 @@ func (b bash) startBackground(ctx context.Context, p bashParams, sh sandbox.Shel
runErr = normalizeBashRunError(jobCtx, runErr, p.PreserveBackgroundProcesses)
execution, classifiedErr := classifyBackgroundShellExecution(jobCtx, sh, argv, capture.String(), runErr, started)
if classifiedErr != nil && execution.FailurePhase == tool.ShellPhaseExecution && wrapped {
writeBackgroundSandboxHint(out, capture.String(), classifiedErr, p, jobSpec, permissionPreset)
writeBackgroundSandboxHint(out, capture.String(), classifiedErr, p, jobSpec, permissionPreset, b.workDir)
}
jobs.SetExecution(jobCtx, execution)
return "", classifiedErr
@@ -80,8 +80,8 @@ func (b bash) startBackground(ctx context.Context, p bashParams, sh sandbox.Shel
}, nil
}
func writeBackgroundSandboxHint(out io.Writer, captured string, runErr error, p bashParams, spec sandbox.Spec, permissionPreset string) {
hinted := appendSandboxWriteHint(captured, runErr, p, spec, permissionPreset)
func writeBackgroundSandboxHint(out io.Writer, captured string, runErr error, p bashParams, spec sandbox.Spec, permissionPreset, workDir string) {
hinted := appendSandboxWriteHint(captured, runErr, p, spec, permissionPreset, workDir)
if hint := strings.TrimPrefix(hinted, captured); hint != "" {
_, _ = io.WriteString(out, hint+"\n")
}
+129 -3
View File
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
@@ -97,7 +99,7 @@ func bashLaunchFailure(ex *tool.ShellExecution, start time.Time, err error) (too
func (b bash) appendWriteHints(ctx context.Context, out string, err error, p bashParams, wrapped bool) string {
out = appendSessionDataHint(out, b.guard.CommandHint(b.workDir, p.Command))
if wrapped {
out = appendSandboxWriteHint(out, err, p, b.specForCall(ctx), string(sandbox.PermissionPresetFrom(ctx)))
out = appendSandboxWriteHint(out, err, p, b.specForCall(ctx), string(sandbox.PermissionPresetFrom(ctx)), b.workDir)
}
return out
}
@@ -176,12 +178,17 @@ func looksLikeSandboxWriteDenial(out string, err error) bool {
var localFilePermissionDenied = regexp.MustCompile(`(?m)^(?:bash|zsh|sh|dash|fish|mkdir|touch|cp|mv|rm|ln|install|tee|cat|chmod|chown):[^\n]*permission denied\b`)
var windowsChildProcessDenied = regexp.MustCompile(`\b(?:spawn(?:sync)?|exec(?:file|sync)?)\s+eperm\b`)
func appendSandboxWriteHint(out string, err error, p bashParams, spec sandbox.Spec, preset string) string {
func appendSandboxWriteHint(out string, err error, p bashParams, spec sandbox.Spec, preset, workDir string) string {
if !spec.Enforce() || strings.TrimSpace(preset) == string(permissionpreset.DangerFullAccess) || !looksLikeSandboxWriteDenial(out, err) {
return out
}
hint := bashWriteDeniedHint()
if len(p.AdditionalWriteDirs) > 0 || windowsChildProcessDenied.MatchString(strings.ToLower(out+"\n"+err.Error())) {
if windowsChildProcessDenied.MatchString(strings.ToLower(out + "\n" + err.Error())) {
hint = "The command encountered a permission denial under the OS sandbox. Additional writable directories may not resolve a child-process or named-object denial."
} else if dirs := gitWorktreeWriteDirs(workDir, out, spec.WriteRoots); len(dirs) > 0 {
paths, _ := json.Marshal(dirs)
hint = "Git worktree metadata is outside the writable workspace. Retry this command with additional_write_dirs: " + string(paths) + " and a justification; the host will request approval for these directories."
} else if len(p.AdditionalWriteDirs) > 0 {
hint = "The command encountered a permission denial under the OS sandbox. Additional writable directories may not resolve a child-process or named-object denial."
}
if denialID := sandbox.IssueDenial(p.Command, preset); denialID != "" {
@@ -189,3 +196,122 @@ func appendSandboxWriteHint(out string, err error, p bashParams, spec sandbox.Sp
}
return appendSessionDataHint(out, hint)
}
func gitWorktreeWriteDirs(workDir, output string, writeRoots []string) []string {
gitDir, commonDir := linkedGitMetadataDirs(workDir)
if gitDir == "" {
return nil
}
match := gitWriteDeniedPath.FindStringSubmatch(output)
if len(match) != 2 || !filepath.IsAbs(match[1]) {
return nil
}
denied, err := sandbox.ResolveAbsPath(match[1])
if err != nil {
return nil
}
objects := filepath.Join(commonDir, "objects")
refs := filepath.Join(commonDir, "refs")
logs := filepath.Join(commonDir, "logs")
if !sandbox.PathWithin(gitDir, denied) && !sandbox.PathWithin(objects, denied) && !sandbox.PathWithin(refs, denied) && !sandbox.PathWithin(logs, denied) {
return nil
}
paths := []string{gitDir, objects}
if sandbox.PathWithin(refs, denied) {
paths = append(paths, refs)
}
if sandbox.PathWithin(logs, denied) {
paths = append(paths, logs)
}
var missing []string
for _, path := range paths {
covered := false
for _, root := range writeRoots {
if sandbox.PathWithin(root, path) {
covered = true
break
}
}
if !covered {
missing = append(missing, path)
}
}
return missing
}
var gitWriteDeniedPath = regexp.MustCompile(`(?im)['"]([^'"\n]+)['"]:[ \t]*(?:operation not permitted|read-only file system|permission denied)`)
func linkedGitMetadataDirs(workDir string) (string, string) {
for dir := filepath.Clean(workDir); filepath.IsAbs(dir); dir = filepath.Dir(dir) {
gitPath := filepath.Join(dir, ".git")
pointer, err := readSmallGitFile(gitPath)
if err == nil {
if !strings.HasPrefix(pointer, "gitdir: ") {
return "", ""
}
gitDir := strings.TrimSpace(strings.TrimPrefix(pointer, "gitdir: "))
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(dir, gitDir)
}
gitDir, err = filepath.EvalSymlinks(gitDir)
if err != nil {
return "", ""
}
common, err := readSmallGitFile(filepath.Join(gitDir, "commondir"))
if err != nil {
return "", ""
}
commonDir := common
if !filepath.IsAbs(commonDir) {
commonDir = filepath.Join(gitDir, commonDir)
}
commonDir, err = filepath.EvalSymlinks(commonDir)
if err != nil || filepath.Dir(gitDir) != filepath.Join(commonDir, "worktrees") {
return "", ""
}
backlink, err := readSmallGitFile(filepath.Join(gitDir, "gitdir"))
if err != nil {
return "", ""
}
if !filepath.IsAbs(backlink) {
backlink = filepath.Join(gitDir, backlink)
}
backlink, err = sandbox.ResolveAbsPath(backlink)
if err != nil {
return "", ""
}
actualGitPath, err := sandbox.ResolveAbsPath(gitPath)
if err != nil || backlink != actualGitPath {
return "", ""
}
for _, path := range []string{"objects", "refs"} {
info, err := os.Lstat(filepath.Join(commonDir, path))
if err != nil || !info.IsDir() {
return "", ""
}
}
if info, err := os.Lstat(filepath.Join(commonDir, "HEAD")); err != nil || !info.Mode().IsRegular() {
return "", ""
}
return gitDir, commonDir
} else if !os.IsNotExist(err) {
return "", ""
}
if filepath.Dir(dir) == dir {
break
}
}
return "", ""
}
func readSmallGitFile(path string) (string, error) {
info, err := os.Lstat(path)
if err != nil {
return "", err
}
if !info.Mode().IsRegular() || info.Size() > 4096 {
return "", fmt.Errorf("not a small regular Git metadata file: %s", path)
}
data, err := os.ReadFile(path)
return strings.TrimSpace(string(data)), err
}
@@ -1,8 +1,17 @@
package builtin
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"reasonix/internal/sandbox"
)
func TestSandboxWriteDenialClassifierRejectsOrdinaryFailures(t *testing.T) {
@@ -37,3 +46,120 @@ func TestSandboxWriteDenialClassifierAcceptsFilesystemFailures(t *testing.T) {
}
}
}
func TestSandboxWriteHintNamesGitWorktreeMetadata(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is unavailable")
}
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
main := filepath.Join(root, "main")
worktree := filepath.Join(root, "linked")
if err := os.Mkdir(main, 0o755); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{{"init", main}, {"-C", main, "-c", "user.name=Test", "-c", "[email protected]", "commit", "--allow-empty", "-m", "initial"}, {"-C", main, "worktree", "add", "-b", "linked", worktree}} {
if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
}
subdir := filepath.Join(worktree, "subdir")
if err := os.Mkdir(subdir, 0o755); err != nil {
t.Fatal(err)
}
gitDir := filepath.Join(main, ".git", "worktrees", "linked")
objects := filepath.Join(main, ".git", "objects")
denial := "fatal: Unable to create '" + filepath.Join(gitDir, "index.lock") + "': Operation not permitted"
hint := appendSandboxWriteHint(denial, errors.New("exit status 128"), bashParams{Command: "git add ."}, sandbox.Spec{Mode: "enforce", WriteRoots: []string{worktree}}, "", subdir)
wantDirs, err := json.Marshal([]string{gitDir, objects})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(hint, string(wantDirs)) || !strings.Contains(hint, "additional_write_dirs") {
t.Fatalf("missing actionable worktree metadata hint: %s", hint)
}
if got := gitWorktreeWriteDirs(subdir, "touch: /outside: Operation not permitted", []string{worktree}); len(got) != 0 {
t.Fatalf("unrelated denial must not suggest Git metadata: %v", got)
}
if got := gitWorktreeWriteDirs(subdir, "note: "+gitDir+"\ntouch: '/outside/file': Operation not permitted", []string{worktree}); len(got) != 0 {
t.Fatalf("metadata mention outside the denied path must not trigger a hint: %v", got)
}
if got := gitWorktreeWriteDirs(subdir, "note: '"+filepath.Join(gitDir, "index.lock")+"':\nOperation not permitted", []string{worktree}); len(got) != 0 {
t.Fatalf("denial must share the diagnostic line with the metadata path: %v", got)
}
alias := filepath.Join(root, "metadata-alias")
if err := os.Symlink(gitDir, alias); err == nil {
aliasDenial := "fatal: Unable to create '" + filepath.Join(alias, "index.lock") + "': Operation not permitted"
if got := gitWorktreeWriteDirs(subdir, aliasDenial, []string{worktree}); len(got) != 2 || got[0] != gitDir || got[1] != objects {
t.Fatalf("symlinked diagnostic path must resolve to Git metadata: %v", got)
}
}
if got := gitWorktreeWriteDirs(subdir, denial, []string{worktree, filepath.Join(main, ".git")}); len(got) != 0 {
t.Fatalf("already writable metadata must not be suggested: %v", got)
}
commonDenial := "fatal: cannot lock ref 'refs/heads/linked': Unable to create '" + filepath.Join(main, ".git", "refs", "heads", "linked.lock") + "': Operation not permitted"
if got := gitWorktreeWriteDirs(subdir, commonDenial, []string{worktree, gitDir}); len(got) != 2 || got[0] != objects || got[1] != filepath.Join(main, ".git", "refs") {
t.Fatalf("ref denial must suggest only needed metadata subdirectories: %v", got)
}
commonPath := filepath.Join(gitDir, "commondir")
originalCommon, err := os.ReadFile(commonPath)
if err != nil {
t.Fatal(err)
}
private := filepath.Join(root, "private")
for _, path := range []string{private, filepath.Join(private, "objects"), filepath.Join(private, "refs")} {
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(private, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(commonPath, []byte(private+"\n"), 0o644); err != nil {
t.Fatal(err)
}
forgedDenial := "fatal: Unable to create '" + filepath.Join(private, "refs", "heads", "main.lock") + "': Operation not permitted"
if got := gitWorktreeWriteDirs(subdir, forgedDenial, []string{worktree}); len(got) != 0 {
t.Fatalf("forged commondir must not be suggested: %v", got)
}
if err := os.WriteFile(commonPath, originalCommon, 0o644); err != nil {
t.Fatal(err)
}
backlinkPath := filepath.Join(gitDir, "gitdir")
originalBacklink, err := os.ReadFile(backlinkPath)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(backlinkPath, []byte(filepath.Join(private, ".git")+"\n"), 0o644); err != nil {
t.Fatal(err)
}
if got := gitWorktreeWriteDirs(subdir, denial, []string{worktree}); len(got) != 0 {
t.Fatalf("invalid worktree backlink must not be suggested: %v", got)
}
if err := os.WriteFile(backlinkPath, originalBacklink, 0o644); err != nil {
t.Fatal(err)
}
if runtime.GOOS != "darwin" {
return
}
if _, err := exec.LookPath("sandbox-exec"); err != nil {
t.Skip("macOS Seatbelt is unavailable")
}
if err := os.WriteFile(filepath.Join(worktree, "note.txt"), []byte("change\n"), 0o644); err != nil {
t.Fatal(err)
}
profile := fmt.Sprintf("(version 1) (allow default) (deny file-write*) (allow file-write* (literal \"/dev/null\") (subpath %q))", worktree)
blocked, err := exec.Command("sandbox-exec", "-p", profile, "git", "-C", worktree, "add", "note.txt").CombinedOutput()
if err == nil || !strings.Contains(string(blocked), "index.lock") {
t.Fatalf("git add must fail at external worktree metadata: %v: %s", err, blocked)
}
if hint := appendSandboxWriteHint(string(blocked), err, bashParams{Command: "git add note.txt"}, sandbox.Spec{Mode: "enforce", WriteRoots: []string{worktree}}, "", worktree); !strings.Contains(hint, gitDir) || !strings.Contains(hint, objects) {
t.Fatalf("actual Seatbelt denial did not name the needed directory: %s", hint)
}
profile = fmt.Sprintf("(version 1) (allow default) (deny file-write*) (allow file-write* (literal \"/dev/null\") (subpath %q) (subpath %q) (subpath %q))", worktree, gitDir, objects)
if out, err := exec.Command("sandbox-exec", "-p", profile, "git", "-C", worktree, "add", "note.txt").CombinedOutput(); err != nil {
t.Fatalf("approved metadata must permit git add: %v: %s", err, out)
}
}
+20 -4
View File
@@ -172,7 +172,7 @@ func (p *Projection) Apply(envelope turnevent.Envelope) error {
if envelope.Sequence != p.covered+1 {
return errors.New("transcript projection sequence gap")
}
return p.applyLocked(envelope, envelope.Sequence)
return p.applyLocked(envelope, envelope.Sequence, envelope.Sequence)
}
// ApplyFrame uses an independent display revision. The caller supplies the
@@ -186,14 +186,16 @@ func (p *Projection) ApplyFrame(envelope turnevent.Envelope, covered uint64) err
if covered < p.covered {
return errors.New("transcript business coverage regression")
}
if err := p.applyLocked(envelope, covered); err != nil {
if err := p.applyLocked(envelope, covered, 0); err != nil {
return err
}
p.trimSettledLocked()
return nil
}
func (p *Projection) applyLocked(envelope turnevent.Envelope, covered uint64) error {
// first is the business sequence the envelope itself occupies, or zero for a
// display frame that rides an existing cut.
func (p *Projection) applyLocked(envelope turnevent.Envelope, covered, first uint64) error {
// Detach pointer payloads before retaining them. Event publication cannot
// mutate a previously committed snapshot through an aliased tool slice.
encoded, err := json.Marshal(envelope)
@@ -225,8 +227,14 @@ func (p *Projection) applyLocked(envelope turnevent.Envelope, covered uint64) er
p.revision++
// Legacy ledger numbering is never a chat coverage cursor.
w.Sequence = 0
if first != 0 && owned.Kind == "message" && w.MessageID != "" {
if p.results == nil {
p.results = make(map[string]uint64)
}
p.results[w.MessageID] = first
}
state := p.runtime
change := Change{Event: &w, Runtime: &state}
change := Change{Event: &w, Runtime: &state, FirstSeq: first}
if owned.Kind == "text" || owned.Kind == "reasoning" || owned.Kind == "tool_call_delta" || (owned.Kind == "tool_dispatch" && w.Tool != nil && w.Tool.Partial) {
if attempt, ok := p.attempts[w.AttemptID]; ok {
change.AttemptID, change.Index = attempt.ID, attempt.NextIndex
@@ -236,6 +244,14 @@ func (p *Projection) applyLocked(envelope turnevent.Envelope, covered uint64) er
}
if owned.Kind == "stream_attempt" && w.StreamAttempt != nil && w.StreamAttempt.Action == "commit" {
change.AttemptID, change.ResultSeq = w.StreamAttempt.ID, p.results[w.MessageID]
if first != 0 {
// A tool-only or interrupted round publishes no message event; its
// commit follows the durable write, so its own sequence is committed.
if change.ResultSeq == 0 {
change.ResultSeq = first
}
delete(p.results, w.MessageID)
}
change.ResultKind = "message/complete"
if w.StreamAttempt.Reason == "interrupted" {
change.ResultKind = "message/interrupted"
+503
View File
@@ -1,6 +1,509 @@
{
"schemaVersion": 1,
"releases": [
{
"version": "1.39.2",
"date": "2026-09-27",
"channel": "stable",
"targetingVersion": 1,
"title": {
"en": "Reasonix 1.39.2",
"zh": "Reasonix 1.39.2"
},
"summary": {
"en": "Fixes for desktop and CLI: sessions that could not be opened or archived, live streaming after switching sessions, typed skill invocations, the setup API key name, and readable output on Chinese Windows.",
"zh": "桌面端与 CLI 修复:打不开或归档不掉的会话、切换会话后的实时流式显示、手动调用技能、设置向导的 API Key 变量名,以及中文 Windows 上的输出乱码。"
},
"surfaces": [
"desktop",
"cli"
],
"guides": [],
"highlights": [
{
"kind": "fixed",
"targets": [
"desktop"
],
"title": {
"en": "Sessions that could not be opened or trashed work again",
"zh": "打不开、也删不掉的会话恢复正常"
},
"body": {
"en": "A session whose history recorded the same message twice failed to open and could not be moved to the Trash, showing only a generic error. It now opens (the first copy of the message wins; the file on disk is not rewritten), a damaged session can always be moved to the Trash with a clear message, and new writes can no longer record a duplicate.",
"zh": "历史里同一条消息被记录两次的会话会打不开,也无法移到废纸篓,只提示一个笼统的错误。现在这类会话可以正常打开(以第一条为准,磁盘上的文件不会被改写);文件损坏的会话也总能移到废纸篓,并给出明确提示;新的写入也不会再产生重复消息。"
},
"refs": [
10893
]
},
{
"kind": "fixed",
"targets": [
"desktop"
],
"title": {
"en": "Old sessions you archive leave the sidebar",
"zh": "归档的旧会话会从侧栏消失"
},
"body": {
"en": "Archiving a recovered 1.38 conversation now archives every recovered copy at once instead of the next copy taking its place, and a legacy row listed under the wrong workspace (branch-badged rows, or rows titled with a raw file name) no longer stays stuck in the sidebar refusing pin, rename and open. Everything archived stays restorable from the archive list.",
"zh": "归档 1.38 留下的「已恢复」旧会话时,会一次归档它的所有恢复副本,不会再归档一条又冒出下一条;被列在错误工作区下的旧会话(带分支标记的、或以原始文件名为标题的)也不会再卡在侧栏、既删不掉又不能置顶/重命名/打开。归档的内容都可以在归档列表里恢复。"
},
"refs": [
10883,
11022
]
},
{
"kind": "fixed",
"targets": [
"desktop"
],
"title": {
"en": "Replies stream live again after switching sessions",
"zh": "切换会话后回复重新实时流式显示"
},
"body": {
"en": "A session opened from an older topic path, for example by switching to it in the sidebar, showed no thinking or answer until the turn ended. Output now streams as it arrives, as it does in the CLI.",
"zh": "从旧话题路径打开的会话(例如在侧栏切换过去)在整轮结束前看不到任何思考或回答。现在会像 CLI 一样边生成边显示。"
},
"refs": [
10973
]
},
{
"kind": "fixed",
"targets": [
"desktop"
],
"title": {
"en": "Earlier history loads after the cache is cleared",
"zh": "缓存清理后仍能加载更早的历史"
},
"body": {
"en": "Scrolling up for older messages in a long session no longer fails, with retry stuck, after the session cache was evicted and the session had changed since the tab opened.",
"zh": "长会话里向上翻加载更早消息时,如果会话缓存已被清理且会话在打开后有过变化,不会再加载失败、重试也无效。"
},
"refs": [
10997
]
},
{
"kind": "fixed",
"targets": [
"desktop",
"cli"
],
"title": {
"en": "Typed skill invocations reach the model the same way every time",
"zh": "手动调用技能时,模型收到的内容与工具调用一致"
},
"body": {
"en": "A message that is just /name (or a lone skill chip in the desktop composer) now sends the skill exactly as run_skill does, with your arguments labelled. Routing reads what you typed rather than the skill's body, so a skill that mentions another one no longer makes the model load both, and wording such as \"use X skill\" in the middle of a sentence no longer forces a skill.",
"zh": "只包含 /技能名 的消息(或桌面端输入框里单独的技能标签)现在与 run_skill 以同一种形式发给模型,你输入的参数也会单独标出。技能路由按你实际输入的内容判断,而不是技能正文:一个技能正文里提到另一个技能时,不会再让模型把两个都加载;句子中间出现「用 X 技能」之类的说法也不会再强制调用技能。"
},
"refs": [
11007,
11023
]
},
{
"kind": "fixed",
"targets": [
"cli"
],
"title": {
"en": "Setup keeps the API key variable name you type",
"zh": "reasonix setup 保留你输入的 API Key 变量名"
},
"body": {
"en": "The setup wizard asked for an API key variable name but always saved the key under a generated REASONIX_CONNECTION_…_KEY name. The name you type is now used. If another provider, setting or your environment already uses that variable, setup says what holds it and asks again; pressing Enter still lets Reasonix choose a private name.",
"zh": "设置向导会询问 API Key 变量名,但此前总是把密钥存到自动生成的 REASONIX_CONNECTION_…_KEY 里。现在会使用你输入的名字;如果该变量已被其他提供方、配置项或环境占用,向导会说明被谁占用并请你重新输入;直接回车仍由 Reasonix 自动选择一个私有名字。"
},
"refs": [
11005
]
}
],
"changes": {
"new": [],
"improved": [
{
"targets": [
"desktop"
],
"title": {
"en": "The context panel's runtime keeps counting during long tool runs",
"zh": "上下文面板的运行时间在长时间工具执行中持续计时"
},
"body": {
"en": "Runtime in the context panel and the context ring no longer freezes between model requests, so long sessions show their real running time.",
"zh": "上下文面板和上下文环里的运行时间不再在两次模型请求之间停住,长会话显示的是真实运行时长。"
},
"refs": [
10925
]
},
{
"targets": [
"desktop"
],
"title": {
"en": "History search finds text in tool output",
"zh": "历史搜索能找到工具输出里的内容"
},
"body": {
"en": "Content search in the history panel now matches tool results, such as a failed MCP server listing, and reads each session once instead of once per hit.",
"zh": "历史面板的内容搜索现在也会匹配工具结果(例如 MCP 服务器列表失败的输出),并且每个会话只读取一次。"
},
"refs": [
10938
]
},
{
"targets": [
"desktop"
],
"title": {
"en": "The sidebar says it is reading projects on a slow start",
"zh": "启动较慢时侧栏会提示正在读取项目"
},
"body": {
"en": "Instead of briefly showing \"No projects yet\" before the project list arrives, the sidebar shows a loading state.",
"zh": "项目列表返回之前,侧栏显示加载状态,而不是先闪一下「还没有项目」。"
},
"refs": [
10979
]
},
{
"targets": [
"desktop"
],
"title": {
"en": "Symlinks inside the workspace appear in the file panel",
"zh": "工作区内的符号链接会显示在文件面板中"
},
"body": {
"en": "The file panel and the @ menu list a link whose target stays inside the workspace; links that dangle or point outside it are left out and cannot be previewed.",
"zh": "文件面板和 @ 菜单会列出目标仍在工作区内的符号链接;悬空或指向工作区外的链接不显示,也无法预览。"
},
"refs": [
10888
]
},
{
"targets": [
"desktop",
"cli"
],
"title": {
"en": "Reopened sessions stay fast to read",
"zh": "重新打开的会话读取保持快速"
},
"body": {
"en": "After a session is reopened, new messages keep its on-disk index current, so later reads no longer rebuild it from the full log.",
"zh": "会话重新打开后,新消息会同步更新磁盘上的索引,之后读取不再从完整日志重建。"
},
"refs": [
10977
]
},
{
"targets": [
"desktop",
"cli"
],
"title": {
"en": "Sandbox denials in a Git worktree say which folders to allow",
"zh": "Git worktree 中的沙箱拒绝会提示该放行哪些目录"
},
"body": {
"en": "When git add fails in a linked worktree because the sandbox blocks the main repository's Git metadata, bash now suggests the narrow folders to approve, never the whole .git directory.",
"zh": "在关联的 Git worktree 中,git add 因沙箱拦截主仓库的 Git 元数据而失败时,bash 会提示需要放行的最小目录,绝不会建议放行整个 .git 目录。"
},
"refs": [
11003
]
},
{
"targets": [
"desktop",
"cli"
],
"title": {
"en": "The environment note tells the tool shell from your login shell",
"zh": "环境说明区分工具 shell 与你的登录 shell"
},
"body": {
"en": "When your login shell is fish or zsh, the model is told that its bash tool still runs bash, so shell advice matches where commands actually run.",
"zh": "当你的登录 shell 是 fish 或 zsh 时,模型会知道它的 bash 工具仍然运行在 bash 中,给出的 shell 建议与实际执行环境一致。"
},
"refs": [
10902
]
}
],
"fixed": [
{
"targets": [
"desktop"
],
"title": {
"en": "A remote Ask card shows once and can be answered",
"zh": "远程 Ask 卡片只显示一次且可以提交"
},
"body": {
"en": "In an SSH remote workspace, a question from the agent appeared twice and neither card could be submitted. It now appears once and submits.",
"zh": "在 SSH 远程工作区里,智能体的提问会出现两张卡片且都无法提交。现在只显示一张,并能正常提交。"
},
"refs": [
10914
]
},
{
"targets": [
"desktop"
],
"title": {
"en": "SSH projects stay out of the composer's local folder menu",
"zh": "SSH 项目不再出现在输入框上方的本地文件夹菜单里"
},
"body": {
"en": "Pinned SSH projects were listed as local folders and failed when picked. They remain reachable from the sidebar's remote group.",
"zh": "已固定的 SSH 项目曾被当作本地文件夹列出,选中就报错。现在它们只在侧栏的远程分组中出现。"
},
"refs": [
10955
]
},
{
"targets": [
"desktop"
],
"title": {
"en": "A waiting approval or question notifies once",
"zh": "等待中的审批或提问只通知一次"
},
"body": {
"en": "System notifications and bot-watched chats no longer repeat the same approval or question each time the desktop re-syncs a tab.",
"zh": "桌面端每次重新同步标签页时,系统通知和机器人监听的聊天不会再重复推送同一个审批或提问。"
},
"refs": [
10934,
10942
]
},
{
"targets": [
"desktop"
],
"title": {
"en": "Writes to the session's own temp folder no longer ask for scope",
"zh": "写入会话自己的临时目录不再请求扩大范围"
},
"body": {
"en": "write_file to the app-owned session temp directory stops prompting on every call; other directories still go through the normal approval.",
"zh": "write_file 写入应用自有的会话临时目录时不再每次都弹出扩大写入范围的请求;其他目录仍走正常审批。"
},
"refs": [
10890
]
},
{
"targets": [
"desktop"
],
"title": {
"en": "A single ~ is no longer strikethrough",
"zh": "单个 ~ 不再被渲染成删除线"
},
"body": {
"en": "Ranges such as 500~1000 render as written; ~~text~~ still strikes through.",
"zh": "像 500~1000 这样的范围按原样显示;~~文字~~ 仍是删除线。"
},
"refs": [
10886
]
},
{
"targets": [
"desktop",
"cli"
],
"title": {
"en": "File edits keep untouched bytes in UTF-8 files with CJK text",
"zh": "编辑含中日韩文字的 UTF-8 文件不再改动未编辑的字节"
},
"body": {
"en": "edit_file and related tools could rewrite characters far from the edit when a file had a stray invalid byte. A file is now treated as GB18030 or GBK only when that reading round-trips exactly.",
"zh": "文件中有个别无效字节时,edit_file 等工具可能改写远离编辑处的字符。现在只有能原样往返时才把文件当作 GB18030 或 GBK 处理。"
},
"refs": [
10892
]
},
{
"targets": [
"desktop",
"cli"
],
"title": {
"en": "Command and MCP output on Chinese Windows is readable",
"zh": "中文 Windows 上的命令输出和 MCP 报错可以正常阅读"
},
"body": {
"en": "Output from bash tool commands and MCP server startup errors written in the console code page is decoded instead of turning into mojibake, and output cut in the middle of a UTF-8 character no longer reads as garbled text.",
"zh": "bash 工具命令的输出和 MCP 服务器启动报错按控制台代码页正确解码,不再是乱码;在 UTF-8 字符中间被截断的输出也不会再整体显示成乱码。"
},
"refs": [
10918,
10912,
10948
]
},
{
"targets": [
"desktop",
"cli"
],
"title": {
"en": "Tool calls through Responses relays run",
"zh": "经 Responses 中转的工具调用能正常执行"
},
"body": {
"en": "When a relay drops the per-call done events, the tool calls listed in the completed response are now run instead of the turn ending early.",
"zh": "中转服务丢掉单个工具调用的完成事件时,现在会按完成响应里列出的调用继续执行,而不是提前结束这一轮。"
},
"refs": [
10924
]
},
{
"targets": [
"desktop",
"cli"
],
"title": {
"en": "StepFun presets know their context window",
"zh": "StepFun 预设带上上下文窗口"
},
"body": {
"en": "StepFun models now show the context gauge and compact automatically at their 262144-token limit; existing StepFun entries without a window are updated.",
"zh": "StepFun 模型现在会显示上下文用量,并按 262144 token 的上限自动压缩;已安装但没有设置窗口的 StepFun 配置会自动补上。"
},
"refs": [
10930
]
},
{
"targets": [
"desktop",
"cli"
],
"title": {
"en": "Subagents stay on the provider you picked",
"zh": "子代理留在你选择的提供方上"
},
"body": {
"en": "A subagent model named without a provider now runs on the parent session's provider when it serves that model, instead of the first provider that lists it.",
"zh": "只写模型名、不带提供方的子代理,现在会在父会话的提供方支持该模型时留在同一提供方,而不是跑到第一个列出它的提供方。"
},
"refs": [
10929
]
},
{
"targets": [
"desktop",
"cli"
],
"title": {
"en": "OpenCode Go minimax-m2.7 uses the route it is served on",
"zh": "OpenCode Go 的 minimax-m2.7 走正确的接口"
},
"body": {
"en": "minimax-m2.7 is offered only on the Anthropic route, where OpenCode Go serves it.",
"zh": "minimax-m2.7 只在 OpenCode Go 实际提供它的 Anthropic 接口上出现。"
},
"refs": [
10961
]
},
{
"targets": [
"desktop",
"cli"
],
"title": {
"en": "web_fetch works with fake-ip proxies",
"zh": "web_fetch 在 fake-ip 代理下可用"
},
"body": {
"en": "With Clash or mihomo in fake-ip mode, web_fetch no longer refuses every public site; it connects to an allowed address and still refuses hosts that resolve only to internal ones.",
"zh": "在 Clash 或 mihomo 的 fake-ip 模式下,web_fetch 不再拒绝所有公网站点;它会连接允许的地址,只解析到内网地址的主机仍会被拒绝。"
},
"refs": [
10909
]
},
{
"targets": [
"desktop",
"cli"
],
"title": {
"en": "Projects under a cloud-drive folder load their config",
"zh": "云盘目录下的项目能正常加载配置"
},
"body": {
"en": "On Windows, a workspace inside a Box Drive or similar mount no longer fails every config load with a path error.",
"zh": "在 Windows 上,位于 Box Drive 等云盘挂载目录中的工作区不再每次加载配置都报路径错误。"
},
"refs": [
10904
]
},
{
"targets": [
"cli"
],
"title": {
"en": "Quoted text wraps within the terminal",
"zh": "引用文字在终端宽度内换行"
},
"body": {
"en": "Long blockquotes in the CLI chat, especially CJK text, no longer overflow the terminal and leave a stray rail on the next row.",
"zh": "CLI 对话里的长引用(尤其是中文)不再超出终端宽度、在下一行留下多余的竖线。"
},
"refs": [
10877
]
}
]
},
"upgrade": [],
"risks": [],
"contributors": [
"esengine",
"KHG420"
],
"links": {
"github": "https://github.com/esengine/DeepSeek-Reasonix/releases/tag/desktop-v1.39.2",
"compare": "https://github.com/esengine/DeepSeek-Reasonix/compare/desktop-v1.39.1...desktop-v1.39.2",
"download": "https://reasonix.io/?download=desktop&channel=stable#start"
},
"releaseId": "1.39.2",
"baseVersion": "1.39.2",
"status": "reviewed",
"previousRelease": "1.39.1",
"builds": {
"cli": "v1.39.2",
"desktop": "v1.39.2",
"npm": "1.39.2"
}
},
{
"version": "1.39.1",
"date": "2026-09-26",
+1 -5
View File
@@ -14,7 +14,7 @@ const FRONTEND = /^desktop\/frontend\//;
const DESKTOP_MANIFEST = /^(?:desktop\/(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|\.npmrc)|desktop\/frontend\/(?:package\.json|pnpm-lock\.yaml|vite\.config\.[cm]?[jt]s|tsconfig[^/]*\.json))$/;
const ELECTRON = /^(?:desktop\/electron\/|desktop\/(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)$)/;
const PACKAGING = /^(?:desktop\/(?:packaging\/|build\/|internal\/upgradefixture\/|cmd\/windows-upgrade-fixture\/)|scripts\/(?:desktop-build|package-windows-desktop|install-nsis|check-windows-uninstaller|finalize-windows-signed-candidate|test-windows-(?:installer-startup|startup-recovery|upgrade-startup)|windows-(?:upgrade-ui-evidence|acceptance-environment))\b)/;
const RELEASE_CONTROL = /^(?:\.github\/workflows\/(?:release[^/]*|prepare-release-notes|pages)\.yml|scripts\/(?:release|resolve-release-candidate|validate-release-candidate|build-release-cli-candidate|publish-homebrew-cask|desktop-release-artifacts|finalize-windows-signed-candidate|verify-release-artifact-archive|verify-release-tag-identity|verify-stable-release-artifacts)[^/]*|npm\/publish(?:-candidate)?(?:\.test)?\.mjs)$/;
const RELEASE_CONTROL = /^(?:\.github\/workflows\/release[^/]*\.yml|scripts\/(?:release|resolve-release-candidate|validate-release-candidate|build-release-cli-candidate|publish-homebrew-cask|desktop-release-artifacts|finalize-windows-signed-candidate|verify-release-artifact-archive|verify-release-tag-identity|verify-stable-release-artifacts)[^/]*|npm\/publish(?:-candidate)?(?:\.test)?\.mjs)$/;
const DESKTOP_GO = /^(?:desktop\/(?:[^/]+\.go|go\.(?:mod|sum)|cmd\/|internal\/)|internal\/|cmd\/|go\.(?:mod|sum)$)/;
const SDK = /^(?:sdk\/|internal\/extension\/)/;
const WINDOWS_BUILTIN = /^(?:internal\/(?:tool\/builtin\/|tool\/tool\.go$|sandbox\/|permission\/|permissionpreset\/)|scripts\/windows-pr-contract-tests(?:\.test)?\.mjs$)/;
@@ -60,10 +60,6 @@ export function classifyPaths(input, { full = false } = {}) {
if (releaseControl) {
flags.release_control = true;
setReason(reasons, "release_control", path, "release control plane");
if (path === ".github/workflows/pages.yml") {
flags.site = true;
setReason(reasons, "site", path, "site deployment control");
}
}
if (SDK.test(path)) {
flags.sdk = true;
-1
View File
@@ -202,7 +202,6 @@ test("release control changes run focused contracts without selecting product su
".github/workflows/release-candidate.yml",
".github/workflows/release-candidate-verify.yml",
".github/workflows/release-promote.yml",
".github/workflows/pages.yml",
"scripts/release-candidate.mjs",
"scripts/sync-release-site.sh",
"scripts/sync-release-site.test.mjs",
+8 -8
View File
@@ -93,7 +93,7 @@ test("release tag mutation follows approval with an explicit identity and read-o
test("cancelled CI releases workers, aggregates, and metrics without hiding live failures", () => {
for (const name of ["test", "windows-control", "windows-isolated", "race", "sdk", "desktop-prepare",
"desktop-frontend", "desktop-browser-group", "desktop-go", "desktop-go-race", "desktop-macos",
"desktop-windows", "desktop-windows-go-group", "desktop-windows-package", "lint-code", "site", "coverage", "prune-go-cache"]) {
"desktop-windows", "desktop-windows-go-group", "desktop-windows-package", "lint-code", "coverage", "prune-go-cache"]) {
assert.equal(condition(job(ci, name), { cancelled: () => true }), false, name);
}
for (const name of ["root", "lint", "desktop", "desktop-browser", "desktop-windows-go", "ci-metrics"]) {
@@ -321,19 +321,19 @@ test("required lint aggregates code lint and the deduplicated frontend suite", (
test("required root aggregate covers the jobs the per-OS test legs do not", () => {
const body = job(ci, "root");
const script = shellStep(body, "Verify root validation jobs");
const success = { CHANGES_RESULT: "success", CODE_REQUIRED: "true", SITE_REQUIRED: "true", COVERAGE_REQUIRED: "true",
CONTROL_RESULT: "success", ISOLATED_RESULT: "success", SDK_RESULT: "success", SITE_RESULT: "success", COVERAGE_RESULT: "success" };
const success = { CHANGES_RESULT: "success", CODE_REQUIRED: "true", COVERAGE_REQUIRED: "true",
CONTROL_RESULT: "success", ISOLATED_RESULT: "success", SDK_RESULT: "success", COVERAGE_RESULT: "success" };
const run = env => spawnSync("bash", ["-e", "-c", script], { env: { ...process.env, ...env } }).status;
assert.equal(run(success), 0);
for (const key of ["CHANGES_RESULT", "CONTROL_RESULT", "ISOLATED_RESULT", "SDK_RESULT", "SITE_RESULT", "COVERAGE_RESULT"])
for (const key of ["CHANGES_RESULT", "CONTROL_RESULT", "ISOLATED_RESULT", "SDK_RESULT", "COVERAGE_RESULT"])
for (const value of ["failure", "cancelled", "skipped", ""]) assert.notEqual(run({ ...success, [key]: value }), 0, `${key}=${value}`);
// A pull request unrelated to code or site: the internally-gated jobs still
// A pull request unrelated to code: the internally-gated jobs still
// report success, the skippable ones must actually be skipped.
assert.equal(run({ ...success, CODE_REQUIRED: "false", SITE_REQUIRED: "false", COVERAGE_REQUIRED: "false",
ISOLATED_RESULT: "skipped", SITE_RESULT: "skipped", COVERAGE_RESULT: "skipped" }), 0);
assert.equal(run({ ...success, CODE_REQUIRED: "false", COVERAGE_REQUIRED: "false",
ISOLATED_RESULT: "skipped", COVERAGE_RESULT: "skipped" }), 0);
// Coverage is push-only; a pull request that ran it is a routing defect.
assert.notEqual(run({ ...success, COVERAGE_REQUIRED: "false", COVERAGE_RESULT: "success" }), 0);
assert.match(body, /needs: \[changes, windows-control, windows-isolated, sdk, site, coverage\]/);
assert.match(body, /needs: \[changes, windows-control, windows-isolated, sdk, coverage\]/);
// govulncheck sets continue-on-error, so needs.*.result is success even when
// it fails; aggregating it would be a tautology that reads like coverage.
assert.match(job(ci, "govulncheck"), /continue-on-error: true/);
-336
View File
@@ -1,336 +0,0 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import { loadCatalog, upsertRelease, validateCatalog } from "./release-notes.mjs";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const apiBase = process.env.DEEPSEEK_API_BASE || "https://api.deepseek.com";
const model = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro";
const releaseTargetOrder = ["desktop", "cli", "site", "service"];
export const releaseOutputBudgets = Object.freeze({
standard: Object.freeze({ highlights: 6, changes: 15, guides: 6, upgrade: 4, risks: 4, bodyChars: 280 }),
compact: Object.freeze({ highlights: 4, changes: 9, guides: 4, upgrade: 2, risks: 2, bodyChars: 220 }),
});
export function editorialLimitInstruction(compact = false) {
const budget = compact ? releaseOutputBudgets.compact : releaseOutputBudgets.standard;
return `Editorial budget: at most ${budget.highlights} highlights, ${budget.changes} total change items across new/improved/fixed, ${budget.guides} guides, ${budget.upgrade} upgrade notes, and ${budget.risks} risks. Keep every English and Chinese body within ${budget.bodyChars} characters. Select the most important user outcomes across the supplied PRs, combine related work, and avoid repeating the same outcome in highlights and changes. Always return a complete JSON object within this budget.`;
}
function parseArgs(argv) {
const values = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg.startsWith("--")) throw new Error(`unexpected argument ${arg}`);
values[arg.slice(2)] = argv[++index];
}
return values;
}
function runGit(args) {
return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8" }).trim();
}
function normalizeVersion(version) {
return String(version || "").replace(/^(?:desktop-|npm-)?v/, "");
}
function repositoryName() {
if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY;
const remote = runGit(["remote", "get-url", "origin"]);
const match = remote.match(/github\.com[/:]([^/]+\/[^/.]+)(?:\.git)?$/);
if (!match) throw new Error("cannot determine GitHub repository; set GITHUB_REPOSITORY");
return match[1];
}
function commitRange(from, to) {
return runGit(["log", "--first-parent", "--format=%H%x09%s%x09%b%x00", `${from}..${to}`])
.split("\0")
.map((record) => record.trim())
.filter(Boolean)
.map((record) => {
const [sha, subject, ...body] = record.split("\t");
return { sha, subject, body: body.join("\t").trim() };
});
}
function prNumbersFromCommits(commits) {
const refs = new Set();
for (const commit of commits) {
for (const match of `${commit.subject}\n${commit.body}`.matchAll(/#(\d+)/g)) refs.add(Number(match[1]));
}
return [...refs];
}
async function githubJson(path, { allowMissing = false } = {}) {
const headers = { Accept: "application/vnd.github+json", "User-Agent": "reasonix-release-notes" };
if (process.env.GITHUB_TOKEN || process.env.GH_TOKEN) {
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN || process.env.GH_TOKEN}`;
}
const response = await fetch(`https://api.github.com${path}`, { headers, signal: AbortSignal.timeout(30_000) });
if (allowMissing && response.status === 404) return null;
if (!response.ok) throw new Error(`GitHub API ${path} failed: ${response.status}`);
return response.json();
}
async function githubList(path) {
const items = [];
for (let page = 1; ; page += 1) {
const separator = path.includes("?") ? "&" : "?";
const batch = await githubJson(`${path}${separator}per_page=100&page=${page}`);
items.push(...batch);
if (batch.length < 100) return items;
}
}
export function inferPullTargetHints(labels, files) {
const labelSet = new Set(labels);
const targets = new Set();
const add = (target) => targets.add(target);
const hasPath = (pattern) => files.some((path) => pattern.test(path));
const explicitlyDesktop = labelSet.has("desktop");
const explicitlyCLI = labelSet.has("tui") || labelSet.has("cli");
if (explicitlyDesktop) add("desktop");
if (explicitlyCLI) add("cli");
if (hasPath(/^desktop\//)) add("desktop");
if (hasPath(/^(?:internal\/cli\/|cmd\/)/)) add("cli");
if (hasPath(/^site\//)) add("site");
if (hasPath(/^(?:workers\/|\.github\/|scripts\/|release-notes\/)/)) add("service");
const hasSharedProductCode = files.some((path) =>
/^(?:internal\/|sdk\/|npm\/)/.test(path) &&
!/^internal\/cli\//.test(path) &&
!/^internal\/telemetry\//.test(path),
);
if (!explicitlyDesktop && !explicitlyCLI && hasSharedProductCode) {
add("desktop");
add("cli");
}
if (!targets.size) {
add("desktop");
add("cli");
}
return releaseTargetOrder.filter((target) => targets.has(target));
}
async function collectPullRequests(repository, commits) {
const numbers = new Set(prNumbersFromCommits(commits));
const associated = await Promise.all(
commits.map((commit) => githubJson(`/repos/${repository}/commits/${commit.sha}/pulls`, { allowMissing: true })),
);
for (const pulls of associated) for (const pull of pulls || []) numbers.add(pull.number);
const pulls = await Promise.all([...numbers].map((number) => githubJson(`/repos/${repository}/pulls/${number}`, { allowMissing: true })));
return Promise.all(pulls.filter(Boolean).map(async (pull) => {
const labels = (pull.labels || []).map((label) => label.name);
const files = (await githubList(`/repos/${repository}/pulls/${pull.number}/files`)).map((file) => file.filename);
return {
number: pull.number,
title: pull.title,
body: String(pull.body || "").slice(0, 2000),
author: pull.user?.login || "",
labels,
changedFileCount: files.length,
files: files.slice(0, 200),
targetHints: inferPullTargetHints(labels, files),
};
}));
}
function releaseItems(release) {
return [
...(release.highlights || []),
...["new", "improved", "fixed"].flatMap((kind) => release.changes?.[kind] || []),
...(release.upgrade || []),
...(release.risks || []),
];
}
function normalizeReleaseTargets(release) {
for (const item of releaseItems(release)) {
if (!Array.isArray(item.targets)) continue;
item.targets.sort((a, b) => releaseTargetOrder.indexOf(a) - releaseTargetOrder.indexOf(b));
}
const targets = new Set(releaseItems(release).flatMap((item) => item.targets || []));
release.surfaces = [
...releaseTargetOrder.filter((target) => targets.has(target)),
...[...targets].filter((target) => !releaseTargetOrder.includes(target)).sort(),
];
}
function collectDocLinks(from, repository, to) {
const linkRef = runGit(["rev-parse", to]);
const paths = runGit(["diff", "--name-only", `${from}..${to}`])
.split("\n")
.filter((path) => /^(?:docs|README)[/\w.-]*\.(?:md|mdx)$/i.test(path));
return paths.map((path) => `https://github.com/${repository}/blob/${linkRef}/${path}`);
}
function assertGroundedRefs(value, allowedRefs, path = "release") {
if (Array.isArray(value)) {
value.forEach((item, index) => assertGroundedRefs(item, allowedRefs, `${path}[${index}]`));
return;
}
if (!value || typeof value !== "object") return;
if (Array.isArray(value.refs)) {
for (const ref of value.refs) {
if (!allowedRefs.has(ref)) throw new Error(`${path}.refs contains PR #${ref}, which is outside the release range`);
}
}
for (const [key, child] of Object.entries(value)) assertGroundedRefs(child, allowedRefs, `${path}.${key}`);
}
function extractJson(content) {
if (!content?.trim()) throw new Error("DeepSeek returned empty content");
const parsed = JSON.parse(content);
return parsed.release || parsed;
}
async function askDeepSeek(payload, attempt = 0) {
const key = process.env.DEEPSEEK_API_KEY;
if (!key) throw new Error("DEEPSEEK_API_KEY is required");
const compact = attempt > 0;
const response = await fetch(`${apiBase.replace(/\/$/, "")}/chat/completions`, {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({
model,
// Release notes need deterministic structured output, not hidden chain of
// thought. Thinking tokens share the generation budget with content and
// can otherwise leave response_format JSON empty or truncated.
thinking: { type: "disabled" },
temperature: 0,
max_tokens: 8000,
response_format: { type: "json_object" },
messages: [
{
role: "system",
content: `You are Reasonix's release editor. Return one JSON object with a \"release\" property. Write factual, user-facing product release notes in equivalent English and Simplified Chinese. Group changes by user outcome, not by commit. Never invent capabilities, migrations, risks, PR numbers, contributors, URLs, or metrics. Every highlight and change must cite one or more supplied PR numbers. Use this exact release shape:
{
\"version\": \"semver\", \"date\": \"YYYY-MM-DD\", \"channel\": \"stable|prerelease\", \"targetingVersion\": 1,
\"title\": {\"en\":\"\",\"zh\":\"\"}, \"summary\": {\"en\":\"\",\"zh\":\"\"},
\"surfaces\": [\"desktop\",\"cli\"],
\"guides\": [{\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"href\":\"https://...\"}],
\"highlights\": [{\"kind\":\"new|improved|fixed|security\",\"targets\":[\"desktop\",\"cli\"],\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"refs\":[123]}],
\"changes\": {\"new\":[],\"improved\":[],\"fixed\":[]},
\"upgrade\": [{\"level\":\"info|warning\",\"targets\":[\"desktop\"],\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"refs\":[123]}],
\"risks\": [{\"targets\":[\"cli\"],\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"refs\":[123]}],
\"contributors\": [], \"links\": {\"github\":\"https://...\",\"compare\":\"https://...\",\"download\":\"https://...\"}
}
Every highlight, change, upgrade note, and risk must have a non-empty \"targets\" array using only this canonical order: desktop, cli, site, service. Use each PR's targetHints as deterministic candidates, then choose the user-visible delivery target supported by its labels, changed files, title, and body. Shared product-core behavior belongs to both desktop and cli. Website-only work belongs to site; hosted workers and release infrastructure belong to service and must not be marked as a client update merely because a client file accompanies the server change. Set top-level surfaces to the canonical union of all item targets. Return guides only for supplied documentation URLs. Mention upgrade action or risk only when explicitly supported; otherwise use empty arrays. ${editorialLimitInstruction(compact)} Output JSON only.`,
},
{
role: "user",
content: `${compact ? "The previous response was invalid or truncated. Produce a smaller complete record and obey the compact editorial budget.\n" : ""}Create the release record from these public GitHub sources:\n${JSON.stringify(payload)}`,
},
],
}),
});
if (!response.ok) throw new Error(`DeepSeek API failed: ${response.status} ${await response.text()}`);
const data = await response.json();
const choice = data.choices?.[0];
if (choice?.finish_reason === "length") {
if (compact) throw new Error("DeepSeek response exceeded the compact editorial budget (finish_reason=length)");
return askDeepSeek(payload, attempt + 1);
}
try {
return extractJson(choice?.message?.content);
} catch (error) {
if (compact) {
throw new Error(`${error.message} (finish_reason=${choice?.finish_reason || "unknown"})`);
}
return askDeepSeek(payload, attempt + 1);
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.version) throw new Error("--version is required");
const version = normalizeVersion(args.version);
const catalog = await loadCatalog();
const channel = version.includes("-") ? "prerelease" : "stable";
const baseVersion = version.split("-")[0];
const previousRecord = channel === "stable"
? catalog.releases.find((release) => release.version !== version && release.channel === "stable")
: catalog.releases.find(
(release) =>
release.version !== version &&
release.channel === "prerelease" &&
release.baseVersion === baseVersion,
) || catalog.releases.find((release) => release.channel === "stable");
const previous = args.from || previousRecord?.version;
if (!previous) throw new Error("--from is required when no previous release exists");
const previousVersion = normalizeVersion(previous);
const previousIsPreview = previousVersion.includes("-");
const from = previous.match(/^(?:desktop-|npm-)?v/)
? previous
: previousIsPreview
? `v${previousVersion}`
: `desktop-v${previousVersion}`;
const to = args.to || "HEAD";
const repository = repositoryName();
const commits = commitRange(from, to);
if (!commits.length) throw new Error(`no commits found in ${from}..${to}`);
const pulls = await collectPullRequests(repository, commits);
if (!pulls.length) throw new Error(`no pull requests found in ${from}..${to}`);
const docLinks = collectDocLinks(from, repository, to);
const date = args.date || new Date().toISOString().slice(0, 10);
const tag = args.tag || (channel === "prerelease" ? `v${version}` : `desktop-v${version}`);
const source = {
version,
date,
channel,
range: `${from}..${to}`,
pullRequests: pulls,
documentationUrls: docLinks,
};
const release = await askDeepSeek(source);
release.targetingVersion = 1;
release.version = version;
release.releaseId = version;
release.baseVersion = baseVersion;
release.date = date;
release.channel = source.channel;
release.status = "reviewed";
release.previousRelease = previousVersion;
const previewOrdinal = version.match(/-preview\.([1-9][0-9]*)$/)?.[1];
if (channel === "prerelease") {
if (!previewOrdinal) throw new Error("Preview release version must use MAJOR.MINOR.PATCH-preview.N");
release.builds = {
cli: `v${version}`,
desktop: `v${baseVersion}-preview.${previewOrdinal}`,
npm: `${baseVersion}-canary.${previewOrdinal}`,
};
} else {
release.builds = {
cli: `v${version}`,
desktop: `v${version}`,
npm: version,
};
}
release.contributors = [...new Set(pulls.map((pull) => pull.author).filter(Boolean))];
release.links = {
github: `https://github.com/${repository}/releases/tag/${tag}`,
compare: `https://github.com/${repository}/compare/${from}...${tag}`,
download: channel === "prerelease"
? "https://reasonix.io/?download=desktop&channel=preview#start"
: "https://reasonix.io/?download=desktop&channel=stable#start",
};
release.guides = (release.guides || []).filter((guide) => docLinks.includes(guide.href));
normalizeReleaseTargets(release);
assertGroundedRefs(release, new Set(pulls.map((pull) => pull.number)));
validateCatalog({ schemaVersion: 1, releases: [release] });
await upsertRelease(release);
console.log(`Generated bilingual release notes for v${version} from ${pulls.length} pull request(s).`);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
+16
View File
@@ -150,6 +150,16 @@ function requireAcceptanceReceipt(payloadRoot, files, metadata, item) {
return { ...item, evidenceSha256: evidence.sha256, artifactSha256: receipt.sha256 };
}
// The notes are rendered once, at seal, because rendering reads GitHub: a later
// render can differ (a renamed author, a newly linked fix) or fail outright.
export const sealedNotesPath = "evidence/release-notes.md";
function requireSealedNotes(files, renderedSha256) {
const notes = files.find(file => file.path === sealedNotesPath);
if (!notes) throw new Error(`candidate is missing sealed release notes: ${sealedNotesPath}`);
if (notes.sha256 !== renderedSha256) throw new Error("sealed release notes do not match renderedSha256");
}
export function sealCandidate(payloadRoot, metadata) {
requireIdentity(metadata);
const files = walk(payloadRoot);
@@ -162,6 +172,7 @@ export function sealCandidate(payloadRoot, metadata) {
throw new Error("invalid candidate validity window");
}
const acceptance = metadata.acceptance.map(item => requireAcceptanceReceipt(payloadRoot, files, metadata, item));
requireSealedNotes(files, metadata.renderedNotesSha256);
return {
schema: 1,
purpose: metadata.purpose ?? "release",
@@ -174,6 +185,7 @@ export function sealCandidate(payloadRoot, metadata) {
sourceSHA: metadata.notesSourceSHA,
catalogSha256: metadata.catalogSha256,
renderedSha256: metadata.renderedNotesSha256,
renderedPath: sealedNotesPath,
},
source: {
repository: metadata.repository,
@@ -224,6 +236,10 @@ export function verifyCandidate(payloadRoot, record, now = new Date(), purpose =
const actual = walk(payloadRoot);
requirePayloadLayout(payloadRoot, actual);
if (JSON.stringify(actual) !== JSON.stringify(record.files)) throw new Error("candidate payload digest mismatch");
if (record.notes.renderedPath !== undefined) {
if (record.notes.renderedPath !== sealedNotesPath) throw new Error("invalid sealed release notes path");
requireSealedNotes(actual, record.notes.renderedSha256);
}
requireDesktopIdentities(payloadRoot, {
version: record.version,
sourceSHA: record.sourceSHA,
+37 -2
View File
@@ -6,7 +6,15 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { artifactNamespace, candidateId, desktopPlatforms, npmPackages, sealCandidate, verifyCandidate } from "./release-candidate.mjs";
import {
artifactNamespace,
candidateId,
desktopPlatforms,
npmPackages,
sealCandidate,
sealedNotesPath,
verifyCandidate,
} from "./release-candidate.mjs";
function fixture(t) {
const root = mkdtempSync(path.join(tmpdir(), "reasonix-candidate-"));
@@ -50,13 +58,16 @@ function fixture(t) {
sha256: createHash("sha256").update(platform).digest("hex"),
}));
}
writeFileSync(path.join(evidence, "release-notes.md"), sealedNotes);
return root;
}
const sealedNotes = "notes by @alice\n";
const metadata = {
version: "1.2.3", sourceSHA: "a".repeat(40), buildControlSHA: "b".repeat(40),
acceptanceControlSHA: "c".repeat(40), notesSourceSHA: "a".repeat(40),
catalogSha256: "d".repeat(64), renderedNotesSha256: "e".repeat(64),
catalogSha256: "d".repeat(64), renderedNotesSha256: createHash("sha256").update("notes by @alice\n").digest("hex"),
repository: "esengine/DeepSeek-Reasonix", workflow: ".github/workflows/release-candidate.yml",
runId: "123", runAttempt: "1", desktopPrefix: "desktop-123-1-preflight",
payloadArtifactId: "456", payloadArtifactName: `release-candidate-payload-${candidateId("1.2.3", "a".repeat(40), "d".repeat(64))}`,
@@ -73,6 +84,30 @@ test("seals and verifies a complete immutable candidate", t => {
assert.doesNotThrow(() => verifyCandidate(root, record, new Date("2026-01-02T00:00:00Z")));
});
test("the rendered notes are sealed into the payload and bound to their hash", t => {
const root = fixture(t);
const record = sealCandidate(root, metadata);
assert.equal(record.notes.renderedPath, sealedNotesPath);
assert.equal(readFileSync(path.join(root, record.notes.renderedPath), "utf8"), sealedNotes);
assert.throws(() => sealCandidate(root, { ...metadata, renderedNotesSha256: "e".repeat(64) }), /do not match renderedSha256/);
writeFileSync(path.join(root, sealedNotesPath), "notes by @mallory\n");
assert.throws(() => verifyCandidate(root, record, new Date("2026-01-02T00:00:00Z")), /digest mismatch/);
rmSync(path.join(root, sealedNotesPath));
assert.throws(() => sealCandidate(root, metadata), /missing sealed release notes/);
});
test("a candidate sealed before notes were sealed still verifies", t => {
const root = fixture(t);
const record = sealCandidate(root, metadata);
rmSync(path.join(root, sealedNotesPath));
const legacy = structuredClone(record);
delete legacy.notes.renderedPath;
legacy.files = legacy.files.filter(file => file.path !== sealedNotesPath);
assert.doesNotThrow(() => verifyCandidate(root, legacy, new Date("2026-01-02T00:00:00Z")));
const forged = { ...legacy, notes: { ...legacy.notes, renderedPath: "evidence/other.md" } };
assert.throws(() => verifyCandidate(root, forged, new Date("2026-01-02T00:00:00Z")), /sealed release notes path/);
});
test("rehearsal bytes remain isolated from the publication identity", t => {
const root = fixture(t);
const id = candidateId(metadata.version, metadata.sourceSHA, metadata.catalogSha256);
+241
View File
@@ -0,0 +1,241 @@
// Resolves the #N references in release notes to the people they credit: a
// pull request's author, or for an issue the authors of the merged pull
// requests that closed it. A number that names a discussion or nothing stays
// plain with a warning; a lookup GitHub could not answer fails by code.
import { execFileSync } from "node:child_process";
export const defaultRepository = "esengine/DeepSeek-Reasonix";
export const CreditErrorCode = Object.freeze({
noToken: "release_credits.no_token",
unreachable: "release_credits.api_unreachable",
status: "release_credits.api_status",
notFound: "release_credits.ref_not_found",
discussion: "release_credits.ref_is_discussion",
unresolved: "release_credits.unresolved",
});
export class CreditError extends Error {
constructor(code, message, { ref, failures } = {}) {
super(message);
this.name = "CreditError";
this.code = code;
if (ref !== undefined) this.ref = ref;
if (failures !== undefined) this.failures = failures;
}
}
const botLogins = new Set(["github-actions", "dependabot", "dependabot-preview"]);
export function isBotAccount(login, type) {
if (type === "Bot") return true;
const name = String(login).toLowerCase();
return name.endsWith("[bot]") || botLogins.has(name);
}
export function tokenFromEnvironment(env = process.env, runGh = defaultRunGh) {
const token = env.GH_TOKEN || env.GITHUB_TOKEN;
if (token) return token;
try {
const fromGh = runGh().trim();
if (fromGh) return fromGh;
} catch {
// gh missing or signed out; reported below as no_token.
}
throw new CreditError(
CreditErrorCode.noToken,
"no GitHub token: set GH_TOKEN (in CI: ${{ github.token }}), or sign in with `gh auth login`",
);
}
function defaultRunGh() {
return execFileSync("gh", ["auth", "token"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
}
const refQuery = `query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
discussion(number: $number) { number }
issueOrPullRequest(number: $number) {
__typename
... on PullRequest { author { login __typename } }
... on Issue {
closedByPullRequestsReferences(first: 20, includeClosedPrs: true) {
nodes { number merged author { login __typename } }
}
timelineItems(itemTypes: [CLOSED_EVENT], last: 1) {
nodes { ... on ClosedEvent { closer { __typename ... on PullRequest { number merged author { login __typename } } } } }
}
}
}
}
}`;
function person(author) {
if (!author) return { login: null, bot: true };
return { login: author.login, bot: isBotAccount(author.login, author.__typename) };
}
// An issue's fixers are the merged pull requests GitHub links to it as closing
// it: the one that closed it, and any linked by a closing keyword or by hand.
// A mere mention is not a fix, so cross-references are not read.
function issueFixes(issue) {
const closer = issue.timelineItems?.nodes?.[0]?.closer;
const candidates = [...(issue.closedByPullRequestsReferences?.nodes || [])];
if (closer?.__typename === "PullRequest") candidates.push(closer);
const fixes = new Map();
for (const pull of candidates) {
if (pull?.merged && !fixes.has(pull.number)) fixes.set(pull.number, { number: pull.number, ...person(pull.author) });
}
return [...fixes.values()].sort((a, b) => a.number - b.number);
}
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// A 5xx, a 429, a 403 that carries rate-limit headers, a GraphQL RATE_LIMITED
// error or a dropped connection is transient; anything else is final.
function retryDelay(response, payload, attempt) {
const backoff = 1000 * 2 ** attempt;
if (!response) return backoff;
const header = (name) => response.headers?.get?.(name) ?? null;
const after = header("retry-after") === null ? null : Number(header("retry-after"));
const limited =
response.status === 429 ||
(response.status === 403 && (after !== null || header("x-ratelimit-remaining") === "0")) ||
payload?.errors?.some((error) => error.type === "RATE_LIMITED");
if (!limited && response.status < 500) return null;
return after > 0 ? Math.min(after * 1000, 60_000) : backoff;
}
// Returns an async lookup: number -> { kind: "pull", login, bot } |
// { kind: "issue", fixes: [{ number, login, bot }] } | { kind: "discussion" } |
// { kind: "missing" }.
export function githubRefLookup({
repository = defaultRepository,
token,
fetchImpl = fetch,
sleep = defaultSleep,
attempts = 4,
} = {}) {
const [owner, name] = repository.split("/");
const headers = { "Content-Type": "application/json", "User-Agent": "reasonix-release-credits" };
if (token) headers.Authorization = `Bearer ${token}`;
const body = (ref) => JSON.stringify({ query: refQuery, variables: { owner, name, number: ref } });
async function query(ref) {
for (let attempt = 0; ; attempt += 1) {
let response = null;
let payload = null;
let failure;
try {
response = await fetchImpl("https://api.github.com/graphql", { method: "POST", headers, body: body(ref) });
if (response.ok) payload = await response.json();
if (response.ok && !payload.errors?.some((error) => error.type === "RATE_LIMITED")) return payload;
const why = response.ok ? "GraphQL RATE_LIMITED" : `status ${response.status}`;
failure = new CreditError(CreditErrorCode.status, `#${ref}: GitHub API answered ${why}`, { ref });
} catch (error) {
if (error instanceof CreditError) throw error;
response = null;
failure = new CreditError(CreditErrorCode.unreachable, `#${ref}: GitHub API unreachable (${error.message})`, { ref });
}
const delay = retryDelay(response, payload, attempt);
if (delay === null || attempt + 1 >= attempts) throw failure;
await sleep(delay);
}
}
return async (ref) => {
const payload = await query(ref);
const repositoryNode = payload.data?.repository;
const node = repositoryNode?.issueOrPullRequest;
if (node?.__typename === "PullRequest") return { kind: "pull", ...person(node.author) };
if (node?.__typename === "Issue") return { kind: "issue", fixes: issueFixes(node) };
const errors = payload.errors || [];
if (repositoryNode && errors.every((error) => error.type === "NOT_FOUND")) {
return { kind: repositoryNode.discussion ? "discussion" : "missing" };
}
const types = errors.map((error) => error.type || "unknown").join(", ") || "empty response";
throw new CreditError(CreditErrorCode.status, `#${ref}: GitHub GraphQL returned no node (${types})`, { ref });
};
}
// Looks each distinct ref up once. Failures are collected, not thrown, so the
// caller can still render every ref it has and then fail with all of them. A
// discussion or a number that names nothing is a warning, not a failure.
export async function resolveCredits(refs, lookup, { concurrency = 6 } = {}) {
const distinct = [...new Set(refs)];
const outcomes = new Array(distinct.length);
let next = 0;
async function worker() {
while (next < distinct.length) {
const index = next++;
try {
outcomes[index] = { credit: await lookup(distinct[index]) };
} catch (error) {
if (!(error instanceof CreditError)) throw error;
outcomes[index] = { failure: { ref: distinct[index], code: error.code, message: error.message } };
}
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, distinct.length) }, worker));
const credits = new Map();
const failures = [];
const warnings = [];
distinct.forEach((ref, index) => {
const { credit, failure } = outcomes[index];
if (failure) return failures.push(failure);
credits.set(ref, credit);
if (credit.kind === "discussion") {
warnings.push({ ref, code: CreditErrorCode.discussion, message: `#${ref} is a discussion; left uncredited` });
} else if (credit.kind === "missing") {
warnings.push({ ref, code: CreditErrorCode.notFound, message: `#${ref} names no issue, pull request or discussion; left uncredited` });
}
});
return { credits, failures, warnings };
}
export function reportWarnings(warnings, { env = process.env, write = (line) => console.error(line) } = {}) {
const prefix = env.GITHUB_ACTIONS === "true" ? "::warning::" : "warning: ";
for (const warning of warnings) write(`${prefix}${warning.code} ${warning.message}`);
}
export function unresolvedError(failures) {
const lines = failures.map((failure) => ` ${failure.code} ${failure.message}`);
return new CreditError(
CreditErrorCode.unresolved,
`${failures.length} reference(s) could not be credited:\n${lines.join("\n")}`,
{ failures },
);
}
function humanLogin(person) {
return person.login && !person.bot ? person.login : null;
}
export function creditedLogins(credit) {
if (credit?.kind === "pull") return [humanLogin(credit)].filter(Boolean);
if (credit?.kind === "issue") return credit.fixes.map(humanLogin).filter(Boolean);
return [];
}
// " by @a" for a pull request, " fixed in #2 by @a and #3" for an issue.
export function creditSuffix(credit) {
const by = (person) => (humanLogin(person) ? ` by @${person.login}` : "");
if (credit?.kind === "pull") return by(credit);
if (credit?.kind !== "issue" || !credit.fixes.length) return "";
return ` fixed in ${credit.fixes.map((fix) => `#${fix.number}${by(fix)}`).join(" and ")}`;
}
// Unique human logins in the order their refs first appear.
export function contributorLogins(refs, credits) {
const logins = [];
const seen = new Set();
for (const ref of refs) {
for (const login of creditedLogins(credits.get(ref))) {
if (isBotAccount(login) || seen.has(login.toLowerCase())) continue;
seen.add(login.toLowerCase());
logins.push(login);
}
}
return logins;
}
+218
View File
@@ -0,0 +1,218 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
contributorLogins,
CreditError,
CreditErrorCode,
creditSuffix,
githubRefLookup,
isBotAccount,
reportWarnings,
resolveCredits,
tokenFromEnvironment,
unresolvedError,
} from "./release-credits.mjs";
function jsonResponse(status, body, headers = {}) {
return {
status,
ok: status >= 200 && status < 300,
headers: { get: (name) => headers[name] ?? null },
json: async () => body,
};
}
// routes: number -> node | "discussion" | Error | [status, body, headers] |
// an array of those, answered in turn; a missing number is NOT_FOUND.
function fakeGitHub(routes) {
const calls = [];
const fetchImpl = async (url, init) => {
const { variables } = JSON.parse(init.body);
calls.push({ url, init, variables });
let route = routes[variables.number];
if (route?.sequence) route = route.sequence.shift();
if (route instanceof Error) throw route;
if (Array.isArray(route)) return jsonResponse(...route);
if (route && route !== "discussion") {
return jsonResponse(200, { data: { repository: { discussion: null, issueOrPullRequest: route } } });
}
const errors = [{ type: "NOT_FOUND", message: "Could not resolve" }];
if (!route) errors.push({ type: "NOT_FOUND", message: "Could not resolve" });
return jsonResponse(200, {
data: { repository: { discussion: route ? { number: variables.number } : null, issueOrPullRequest: null } },
errors,
});
};
return { calls, fetchImpl };
}
const noSleep = { sleep: async () => {} };
const user = (login, type = "User") => ({ login, __typename: type });
const pull = (login, type) => ({ __typename: "PullRequest", author: user(login, type) });
const linked = (number, login, merged = true, type) => ({ number, merged, author: user(login, type) });
const issue = (references = [], closer = null) => ({
__typename: "Issue",
closedByPullRequestsReferences: { nodes: references },
timelineItems: { nodes: closer ? [{ closer }] : [] },
});
test("a pull request credits its author", async () => {
const { calls, fetchImpl } = fakeGitHub({ 1: pull("alice") });
const lookup = githubRefLookup({ repository: "o/r", token: "t", fetchImpl });
assert.deepEqual(await lookup(1), { kind: "pull", login: "alice", bot: false });
assert.equal(calls[0].url, "https://api.github.com/graphql");
assert.equal(calls[0].init.headers.Authorization, "Bearer t");
assert.deepEqual(calls[0].variables, { owner: "o", name: "r", number: 1 });
});
test("an issue credits the merged pull requests that closed it, and nothing else", async () => {
const { fetchImpl } = fakeGitHub({
2: issue([linked(12, "bob"), linked(11, "carol", false)], { __typename: "PullRequest", ...linked(10, "alice") }),
3: issue([], { __typename: "Commit" }),
4: issue([linked(14, "bob"), linked(14, "bob")], { __typename: "PullRequest", ...linked(14, "bob") }),
});
const lookup = githubRefLookup({ repository: "o/r", fetchImpl });
assert.deepEqual(await lookup(2), {
kind: "issue",
fixes: [
{ number: 10, login: "alice", bot: false },
{ number: 12, login: "bob", bot: false },
],
});
assert.deepEqual(await lookup(3), { kind: "issue", fixes: [] });
assert.deepEqual(await lookup(4), { kind: "issue", fixes: [{ number: 14, login: "bob", bot: false }] });
});
test("bot and deleted authors are never credited", async () => {
const { fetchImpl } = fakeGitHub({
1: pull("github-actions", "Bot"),
2: pull("dependabot"),
3: pull("renovate[bot]"),
4: { __typename: "PullRequest", author: null },
5: issue([linked(15, "dependabot[bot]")]),
});
const lookup = githubRefLookup({ repository: "o/r", fetchImpl });
for (const ref of [1, 2, 3, 4]) assert.equal((await lookup(ref)).bot, true);
const fixed = await lookup(5);
assert.equal(fixed.fixes[0].bot, true);
assert.equal(creditSuffix(fixed), " fixed in #15");
assert.equal(isBotAccount("github-actions"), true);
assert.equal(isBotAccount("esengine"), false);
});
test("a discussion or a number naming nothing resolves, and warns instead of failing", async () => {
const { fetchImpl } = fakeGitHub({ 8: "discussion", 1: pull("alice") });
const lookup = githubRefLookup({ repository: "o/r", fetchImpl });
assert.deepEqual(await lookup(8), { kind: "discussion" });
assert.deepEqual(await lookup(9), { kind: "missing" });
const { credits, failures, warnings } = await resolveCredits([8, 1, 9], lookup);
assert.deepEqual(failures, []);
assert.equal(creditSuffix(credits.get(8)), "");
assert.deepEqual(
warnings.map((warning) => [warning.ref, warning.code]),
[
[8, CreditErrorCode.discussion],
[9, CreditErrorCode.notFound],
],
);
const lines = [];
reportWarnings(warnings, { env: { GITHUB_ACTIONS: "true" }, write: (line) => lines.push(line) });
assert.ok(lines.every((line) => line.startsWith("::warning::release_credits.")));
});
test("transient failures are retried with backoff; the last one is reported by code", async () => {
const slept = [];
const sleep = async (ms) => slept.push(ms);
const { calls, fetchImpl } = fakeGitHub({
1: { sequence: [[502, {}], new TypeError("socket hang up"), [429, {}, { "retry-after": "7" }], pull("alice")] },
2: [503, {}],
3: new TypeError("fetch failed"),
4: { sequence: [[200, { data: null, errors: [{ type: "RATE_LIMITED" }] }], pull("bob")] },
5: [403, {}, { "x-ratelimit-remaining": "0" }],
});
const lookup = githubRefLookup({ repository: "o/r", fetchImpl, sleep });
assert.equal((await lookup(1)).login, "alice");
assert.deepEqual(slept, [1000, 2000, 7000]);
await assert.rejects(lookup(2), { code: CreditErrorCode.status, ref: 2 });
await assert.rejects(lookup(3), { code: CreditErrorCode.unreachable, ref: 3 });
assert.equal((await lookup(4)).login, "bob");
await assert.rejects(lookup(5), { code: CreditErrorCode.status, ref: 5 });
const tries = (ref) => calls.filter((call) => call.variables.number === ref).length;
assert.deepEqual([1, 2, 3, 4, 5].map(tries), [4, 4, 4, 2, 4]);
});
test("an authorisation failure is final and never retried", async () => {
const { calls, fetchImpl } = fakeGitHub({ 1: [401, {}], 2: [403, {}], 3: [200, { data: null, errors: [{ type: "FORBIDDEN" }] }] });
const lookup = githubRefLookup({ repository: "o/r", fetchImpl, ...noSleep });
await assert.rejects(lookup(1), { code: CreditErrorCode.status });
await assert.rejects(lookup(2), { code: CreditErrorCode.status });
await assert.rejects(lookup(3), { code: CreditErrorCode.status });
assert.equal(calls.length, 3);
});
test("suffixes use one wording for pull requests and fixed issues", () => {
assert.equal(creditSuffix({ kind: "pull", login: "alice", bot: false }), " by @alice");
assert.equal(creditSuffix({ kind: "pull", login: "x[bot]", bot: true }), "");
assert.equal(creditSuffix({ kind: "issue", fixes: [] }), "");
assert.equal(
creditSuffix({
kind: "issue",
fixes: [
{ number: 2, login: "alice", bot: false },
{ number: 3, login: "bob", bot: false },
],
}),
" fixed in #2 by @alice and #3 by @bob",
);
});
test("each distinct ref is looked up once and failures are collected in ref order", async () => {
const seen = [];
const lookup = async (ref) => {
seen.push(ref);
if (ref === 3 || ref === 1) throw new CreditError(CreditErrorCode.notFound, `#${ref}`, { ref });
return { kind: "pull", login: `u${ref}`, bot: false };
};
const { credits, failures } = await resolveCredits([3, 2, 3, 1, 2], lookup, { concurrency: 2 });
assert.deepEqual(seen.sort(), [1, 2, 3]);
assert.deepEqual([...credits.keys()], [2]);
assert.deepEqual(failures.map((failure) => failure.ref), [3, 1]);
const error = unresolvedError(failures);
assert.equal(error.code, CreditErrorCode.unresolved);
assert.deepEqual(error.failures.map((failure) => failure.code), [CreditErrorCode.notFound, CreditErrorCode.notFound]);
});
test("an unexpected lookup error is not swallowed", async () => {
await assert.rejects(
resolveCredits([1], async () => {
throw new RangeError("bug");
}),
RangeError,
);
});
test("contributors are unique humans in first-appearance order, fixers included", () => {
const credits = new Map([
[1, { kind: "pull", login: "bob", bot: false }],
[2, { kind: "pull", login: "alice", bot: false }],
[3, { kind: "issue", fixes: [{ number: 30, login: "carol", bot: false }] }],
[4, { kind: "pull", login: "github-actions[bot]", bot: true }],
[5, { kind: "pull", login: "Bob", bot: false }],
[6, { kind: "issue", fixes: [] }],
]);
assert.deepEqual(contributorLogins([3, 1, 4, 2, 1, 5, 6, 9], credits), ["carol", "bob", "alice"]);
});
test("the token comes from the environment, then gh, else a coded error", () => {
assert.equal(tokenFromEnvironment({ GH_TOKEN: "a", GITHUB_TOKEN: "b" }, () => "c"), "a");
assert.equal(tokenFromEnvironment({ GITHUB_TOKEN: "b" }, () => "c"), "b");
assert.equal(tokenFromEnvironment({}, () => "c\n"), "c");
assert.throws(
() =>
tokenFromEnvironment({}, () => {
throw new Error("gh: not logged in");
}),
{ code: CreditErrorCode.noToken },
);
});
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env bash
# Start CI on a Notes PR that the Actions bot opened. Events raised with
# GITHUB_TOKEN never start workflows, so such a PR shows action_required with
# zero jobs until a human-authenticated close/reopen re-emits pull_request.
set -euo pipefail
if [ "$#" -ne 1 ] || [[ ! "$1" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "usage: scripts/release-notes-pr-kick.sh MAJOR.MINOR.PATCH" >&2
exit 2
fi
version="$1"
repository="${RELEASE_REPOSITORY:-esengine/DeepSeek-Reasonix}"
branch="release-notes/v$version"
wait_seconds="${RELEASE_NOTES_KICK_WAIT_SECONDS:-120}"
for command in gh jq; do
command -v "$command" >/dev/null || {
echo "required command is unavailable: $command" >&2
exit 2
}
done
prs="$(gh pr list --repo "$repository" --head "$branch" --state open --json number,headRefOid --limit 2)"
count="$(jq 'length' <<<"$prs")"
if [ "$count" -ne 1 ]; then
echo "expected exactly one open PR for $branch, found $count" >&2
exit 1
fi
number="$(jq -r '.[0].number' <<<"$prs")"
head="$(jq -r '.[0].headRefOid' <<<"$prs")"
check_runs() {
gh api "repos/$repository/commits/$head/check-runs" --jq '.total_count'
}
if [ "$(check_runs)" -gt 0 ]; then
echo "PR #$number already has check runs on $head; nothing to kick"
exit 0
fi
# Only the recorded-but-never-started shape is safe to kick. Anything else
# with zero check runs needs a look, not a reopen.
stalled="$(gh api "repos/$repository/actions/runs?event=pull_request&head_sha=$head&per_page=20" \
--jq "[.workflow_runs[] | select(.head_sha == \"$head\" and .conclusion == \"action_required\")] | length")"
if [ "$stalled" -eq 0 ]; then
echo "PR #$number has no check runs and no action_required run on $head; inspect before kicking" >&2
exit 1
fi
echo "PR #$number: $stalled recorded-but-unstarted run(s) on $head; closing and reopening"
gh pr close "$number" --repo "$repository"
gh pr reopen "$number" --repo "$repository"
deadline=$((SECONDS + wait_seconds))
while [ "$(check_runs)" -eq 0 ]; do
if [ "$SECONDS" -ge "$deadline" ]; then
echo "no check runs appeared on $head within ${wait_seconds}s" >&2
exit 1
fi
sleep 5
done
echo "CI started on PR #$number ($head)"
+55 -31
View File
@@ -3,6 +3,15 @@
import { readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
contributorLogins,
creditSuffix,
githubRefLookup,
reportWarnings,
resolveCredits,
tokenFromEnvironment,
unresolvedError,
} from "./release-credits.mjs";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
export const defaultCatalogPath = resolve(repoRoot, "release-notes/releases.json");
@@ -237,9 +246,11 @@ function localized(value, lang) {
return value[lang] || value.en;
}
function refsSuffix(refs = []) {
function refsSuffix(refs = [], credits) {
if (!refs.length) return "";
return ` (${refs.map((ref) => `[#${ref}](https://github.com/esengine/DeepSeek-Reasonix/pull/${ref})`).join(", ")})`;
return ` (${refs
.map((ref) => `[#${ref}](https://github.com/esengine/DeepSeek-Reasonix/pull/${ref})${creditSuffix(credits.get(ref))}`)
.join(", ")})`;
}
function targetLabel(target) {
@@ -251,9 +262,12 @@ function targetsPrefix(item) {
return `**[${item.targets.map(targetLabel).join(" · ")}]** `;
}
function renderItems(items, lang) {
function renderItems(items, lang, credits) {
return items
.map((item) => `- ${targetsPrefix(item)}**${localized(item.title, lang)}** — ${localized(item.body, lang)}${refsSuffix(item.refs)}`)
.map(
(item) =>
`- ${targetsPrefix(item)}**${localized(item.title, lang)}** — ${localized(item.body, lang)}${refsSuffix(item.refs, credits)}`,
)
.join("\n");
}
@@ -265,7 +279,7 @@ function targetItemCount(release, target) {
return releaseItems(release).filter((item) => hasTarget(item, target)).length;
}
function appendTargetSection(lines, release, target, lang) {
function appendTargetSection(lines, release, target, lang, credits) {
const isZh = lang === "zh";
const title = target === "desktop"
? (isZh ? "桌面端更新" : "Desktop updates")
@@ -274,7 +288,7 @@ function appendTargetSection(lines, release, target, lang) {
const highlights = release.highlights.filter((item) => hasTarget(item, target));
if (highlights.length) {
lines.push(`### ${isZh ? "重点内容" : "Highlights"}`, "", renderItems(highlights, lang), "");
lines.push(`### ${isZh ? "重点内容" : "Highlights"}`, "", renderItems(highlights, lang, credits), "");
}
const headings = {
new: isZh ? "新增" : "New",
@@ -283,7 +297,7 @@ function appendTargetSection(lines, release, target, lang) {
};
for (const kind of changeKinds) {
const items = release.changes[kind].filter((item) => hasTarget(item, target));
if (items.length) lines.push(`### ${headings[kind]}`, "", renderItems(items, lang), "");
if (items.length) lines.push(`### ${headings[kind]}`, "", renderItems(items, lang, credits), "");
}
if (!highlights.length && !changeKinds.some((kind) => release.changes[kind].some((item) => hasTarget(item, target)))) {
@@ -297,7 +311,7 @@ function appendTargetSection(lines, release, target, lang) {
}
}
function appendOtherTargetSection(lines, release, lang) {
function appendOtherTargetSection(lines, release, lang, credits) {
const isZh = lang === "zh";
const isOtherOnly = (item) => item.targets?.length && !hasTarget(item, "desktop") && !hasTarget(item, "cli");
const highlights = release.highlights.filter(isOtherOnly);
@@ -305,13 +319,18 @@ function appendOtherTargetSection(lines, release, lang) {
if (!highlights.length && !changeKinds.some((kind) => changes[kind].length)) return;
lines.push(`## ${isZh ? "其他项目更新" : "Other project updates"}`, "");
if (highlights.length) lines.push(renderItems(highlights, lang), "");
if (highlights.length) lines.push(renderItems(highlights, lang, credits), "");
for (const kind of changeKinds) {
if (changes[kind].length) lines.push(renderItems(changes[kind], lang), "");
if (changes[kind].length) lines.push(renderItems(changes[kind], lang, credits), "");
}
}
export function renderGitHubRelease(release, lang = "zh") {
export function releaseRefs(release) {
return releaseItems(release).flatMap((item) => item.refs || []);
}
// credits maps a ref to its lookup result; a ref absent from it renders plain.
export function renderGitHubRelease(release, lang = "zh", credits = new Map()) {
const isZh = lang === "zh";
const isPreview = release.channel === "prerelease";
const channelLabel = isPreview ? (isZh ? "预览版" : "Preview") : (isZh ? "稳定版" : "Stable");
@@ -350,11 +369,11 @@ export function renderGitHubRelease(release, lang = "zh") {
`**Desktop ${targetItemCount(release, "desktop")} · CLI ${targetItemCount(release, "cli")}**`,
"",
);
appendTargetSection(lines, release, "desktop", lang);
appendTargetSection(lines, release, "cli", lang);
appendOtherTargetSection(lines, release, lang);
appendTargetSection(lines, release, "desktop", lang, credits);
appendTargetSection(lines, release, "cli", lang, credits);
appendOtherTargetSection(lines, release, lang, credits);
} else {
lines.push(`## ${isZh ? "重点内容" : "Highlights"}`, "", renderItems(release.highlights, lang), "");
lines.push(`## ${isZh ? "重点内容" : "Highlights"}`, "", renderItems(release.highlights, lang, credits), "");
const headings = {
new: isZh ? "新功能" : "New",
improved: isZh ? "改进" : "Improved",
@@ -363,31 +382,20 @@ export function renderGitHubRelease(release, lang = "zh") {
for (const kind of changeKinds) {
const items = release.changes[kind];
if (!items.length) continue;
lines.push(`## ${headings[kind]}`, "", renderItems(items, lang), "");
lines.push(`## ${headings[kind]}`, "", renderItems(items, lang, credits), "");
}
}
lines.push(`## ${isZh ? "升级提醒" : "Upgrade notes"}`, "");
if (release.upgrade.length) lines.push(renderItems(release.upgrade, lang));
if (release.upgrade.length) lines.push(renderItems(release.upgrade, lang, credits));
else lines.push(isZh ? "本版本无需手动迁移。" : "No manual migration is required.");
lines.push("");
lines.push(`## ${isZh ? "风险提示" : "Risk notes"}`, "");
if (release.risks.length) lines.push(renderItems(release.risks, lang));
if (release.risks.length) lines.push(renderItems(release.risks, lang, credits));
else lines.push(isZh ? "当前没有需要额外操作的已知风险。" : "There are no known risks requiring extra action.");
lines.push("");
if (release.contributors.length) {
lines.push(
`## ${isZh ? "致谢" : "Thanks"}`,
"",
`${isZh ? "感谢本版本的贡献者" : "Thanks to the contributors in this release"}:${release.contributors
.map((name) => `[@${name}](https://github.com/${name})`)
.join("、")}`,
"",
);
}
lines.push(
`## ${isZh ? "下载与安装" : "Download and install"}`,
"",
@@ -395,6 +403,18 @@ export function renderGitHubRelease(release, lang = "zh") {
`- [${isZh ? "查看完整差异" : "Full comparison"}](${release.links.compare})`,
"",
);
const contributors = contributorLogins(releaseRefs(release), credits);
if (contributors.length) {
lines.push(
`## ${isZh ? "贡献者" : "Contributors"}`,
"",
`${isZh ? "感谢本版本的贡献者:" : "Thanks to the contributors in this release: "}${contributors
.map((login) => `@${login}`)
.join(isZh ? "、" : ", ")}`,
"",
);
}
return `${lines.join("\n").trim()}\n`;
}
@@ -434,14 +454,18 @@ async function main() {
invariant(args.version, "render requires --version");
invariant(args.output, "render requires --output");
const release = releaseForVersion(catalog, args.version);
const lookup = githubRefLookup({ token: tokenFromEnvironment() });
const { credits, failures, warnings } = await resolveCredits(releaseRefs(release), lookup);
reportWarnings(warnings);
const output = resolve(args.output);
await writeFile(output, renderGitHubRelease(release, args.lang === "en" ? "en" : "zh"));
await writeFile(output, renderGitHubRelease(release, args.lang === "en" ? "en" : "zh", credits));
if (failures.length) throw unresolvedError(failures);
console.log(`Rendered v${release.version} release notes to ${output}`);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error.message);
console.error(error.code ? `${error.code}: ${error.message}` : error.message);
process.exitCode = 1;
});
}
+34 -23
View File
@@ -1,11 +1,6 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
editorialLimitInstruction,
inferPullTargetHints,
releaseOutputBudgets,
} from "./generate-release-notes.mjs";
import { loadCatalog, releaseForVersion, renderGitHubRelease, validateCatalog } from "./release-notes.mjs";
import { loadCatalog, releaseForVersion, releaseRefs, renderGitHubRelease, validateCatalog } from "./release-notes.mjs";
import { validateReleaseEvent } from "./release-event.mjs";
test("the committed release catalog is valid and newest first", async () => {
@@ -28,11 +23,43 @@ test("GitHub rendering keeps product sections and source PR links", async () =>
assert.match(markdown, /## 重点内容/);
assert.match(markdown, /## 升级提醒/);
assert.match(markdown, /## 风险提示/);
assert.match(markdown, /## 致谢/);
assert.match(markdown, /\/pull\/6460/);
assert.match(markdown, /reasonix\.io\/changelog\/v1\.17\.13/);
});
test("rendering credits authors and fixers and lists them once, in order", async () => {
const catalog = await loadCatalog();
const release = structuredClone(releaseForVersion(catalog, "1.39.1"));
release.contributors = ["late-comer"];
const refs = releaseRefs(release);
const special = {
10853: { kind: "pull", login: "github-actions[bot]", bot: true },
10858: { kind: "issue", fixes: [{ number: 20000, login: "fixer", bot: false }] },
10495: { kind: "issue", fixes: [] },
};
const credits = new Map(
refs.map((ref) => [ref, special[ref] || { kind: "pull", login: ref % 2 ? "KHG420" : "esengine", bot: false }]),
);
const zh = renderGitHubRelease(release, "zh", credits);
assert.match(zh, /\[#10763\]\([^)]+\/pull\/10763\) by @KHG420/);
assert.match(zh, /\[#10858\]\([^)]+\/pull\/10858\) fixed in #20000 by @fixer, /);
assert.match(zh, /\[#10495\]\([^)]+\/pull\/10495\), /);
assert.doesNotMatch(zh, /by @github-actions/);
assert.match(zh, /## 贡献者\n\n感谢本版本的贡献者:@KHG420、@fixer、@esengine\n$/);
const en = renderGitHubRelease(release, "en", credits);
assert.match(en, /## Contributors\n\nThanks to the contributors in this release: @KHG420, @fixer, @esengine\n$/);
assert.doesNotMatch(en, /late-comer/);
});
test("an unresolved ref renders as the plain link it always was", async () => {
const catalog = await loadCatalog();
const release = releaseForVersion(catalog, "1.39.0");
const markdown = renderGitHubRelease(release, "zh", new Map());
assert.doesNotMatch(markdown, / by @/);
assert.match(markdown, /\(\[#10731\]\(https:\/\/github\.com\/esengine\/DeepSeek-Reasonix\/pull\/10731\)\)/);
assert.doesNotMatch(markdown, /## 贡献者/);
});
test("targeted releases render Desktop and CLI sections from one shared item", async () => {
const catalog = await loadCatalog();
const markdown = renderGitHubRelease(releaseForVersion(catalog, "1.31.4"), "zh");
@@ -126,22 +153,6 @@ test("targeting adoption boundary preserves historical releases and rejects futu
}
});
test("release target hints prefer explicit product labels and identify shared or service paths", () => {
assert.deepEqual(inferPullTargetHints(["desktop"], ["internal/sessioncatalog/catalog.go"]), ["desktop"]);
assert.deepEqual(inferPullTargetHints(["desktop", "tui"], ["desktop/app.go", "internal/cli/cli.go"]), ["desktop", "cli"]);
assert.deepEqual(inferPullTargetHints([], ["internal/agent/agent.go"]), ["desktop", "cli"]);
assert.deepEqual(inferPullTargetHints([], ["workers/crash-report/src/index.ts"]), ["service"]);
});
test("release generation uses bounded output and a tighter truncation retry", () => {
assert.ok(releaseOutputBudgets.standard.highlights > releaseOutputBudgets.compact.highlights);
assert.ok(releaseOutputBudgets.standard.changes > releaseOutputBudgets.compact.changes);
assert.ok(releaseOutputBudgets.standard.bodyChars > releaseOutputBudgets.compact.bodyChars);
assert.match(editorialLimitInstruction(), /Always return a complete JSON object/);
assert.match(editorialLimitInstruction(true), /at most 4 highlights/);
assert.match(editorialLimitInstruction(true), /9 total change items/);
});
test("validation rejects bilingual drift", () => {
assert.throws(
() =>
-23
View File
@@ -158,14 +158,6 @@ for retired in release-preview.yml release-cli-trigger.yml release-desktop-trigg
done
npm_events="$(sed -n '/^on:/,/^permissions:/p' "$repo_root/.github/workflows/release-npm.yml")"
! grep -Eq 'push:|npm-v\*-\*' <<<"$npm_events"
grep -Eq '^name: Prepare release$' "$repo_root/.github/workflows/prepare-release-notes.yml"
[ "$(grep -Fc 'git merge --no-edit origin/main-v2' "$repo_root/.github/workflows/prepare-release-notes.yml")" = "0" ]
grep -Fq 'RELEASE_NOTES_SOURCE_SHA=$source_sha' "$repo_root/.github/workflows/prepare-release-notes.yml"
grep -Fq -- '--to "$RELEASE_NOTES_SOURCE_SHA"' "$repo_root/.github/workflows/prepare-release-notes.yml"
notes_dispatch="$(sed -n '/workflow_dispatch:/,/permissions:/p' "$repo_root/.github/workflows/prepare-release-notes.yml")"
[ "$(grep -Ec '^ [a-z_]+:$' <<<"$notes_dispatch")" = "1" ]
grep -Fq 'GitHub Actions could not open the PR; the reviewed branch is preserved.' \
"$repo_root/.github/workflows/prepare-release-notes.yml"
if grep -Eq '^ push:$' "$repo_root/.github/workflows/release-stable.yml" ||
grep -Eq '^ push:$' "$repo_root/.github/workflows/release.yml" ||
grep -Eq '^ push:$' "$repo_root/.github/workflows/release-desktop.yml"; then
@@ -1338,21 +1330,6 @@ expect_invalid_desktop_manifest "a Preview manifest as Stable" stable "$desktop_
expect_invalid_desktop_manifest "a non-official asset base" preview "$desktop_preview_version" \
"https://cdn.invalid/desktop-${desktop_preview_version}/" "$desktop_preview_manifest"
# Release notes use one deterministic branch per official version. Failure to
# open the PR must preserve that branch and print an exact manual handoff.
prepare_notes="$repo_root/.github/workflows/prepare-release-notes.yml"
generate_notes="$repo_root/scripts/generate-release-notes.mjs"
if grep -Eq '^ (target_pr|from_tag):$' "$prepare_notes"; then
echo "Prepare release must expose only the official version input" >&2
exit 1
fi
grep -Fq 'branch="release-notes/v${VERSION}"' "$prepare_notes"
grep -Fq 'GitHub Actions could not open the PR; the reviewed branch is preserved.' "$prepare_notes"
grep -Fq 'gh pr create --repo ${{ github.repository }} --base main-v2 --head $RELEASE_NOTES_BRANCH --fill' "$prepare_notes"
grep -Eq 'GITHUB_STEP_SUMMARY' "$prepare_notes"
grep -Fq 'node scripts/generate-release-notes.mjs --version "$VERSION" --to "$RELEASE_NOTES_SOURCE_SHA"' "$prepare_notes"
grep -Fq 'thinking: { type: "disabled" }' "$generate_notes"
desktop_candidate_resolver="$repo_root/scripts/resolve-desktop-candidate.sh"
test -x "$desktop_candidate_resolver"
+2 -2
View File
@@ -67,12 +67,12 @@ test "$update" = true
stage=pages-dispatch
request="release-${GITHUB_RUN_ID:?}-${GITHUB_RUN_ATTEMPT:?}"
title="Deploy site v$version [$request]"
payload="$(jq -cn --arg version "$version" --arg request "$request" '{ref:"main-v2",inputs:{release_version:$version,release_request:$request}}')"
payload="$(jq -cn --arg version "$version" --arg request "$request" '{ref:"website",inputs:{release_version:$version,release_request:$request}}')"
# Dispatch once. On an uncertain response, recovery observes public state again;
# never blindly repeat this write within the same run.
gh api -X POST "repos/$repository/actions/workflows/pages.yml/dispatches" --input - <<< "$payload"
for _ in $(seq 1 30); do
runs="$(gh run list --repo "$repository" --workflow pages.yml --event workflow_dispatch --branch main-v2 --limit 100 --json databaseId,displayTitle)"
runs="$(gh run list --repo "$repository" --workflow pages.yml --event workflow_dispatch --branch website --limit 100 --json databaseId,displayTitle)"
pages_run="$(jq -er --arg title "$title" '[.[] | select(.displayTitle == $title)] | if length == 0 then "" elif length == 1 then .[0].databaseId else error("ambiguous owned Pages deployment") end' <<< "$runs")"
[ -z "$pages_run" ] || break
sleep 2
+8 -14
View File
@@ -42,7 +42,7 @@ const args=process.argv.slice(2), root=process.env.FIXTURE;
fs.appendFileSync(path.join(root,'calls'),JSON.stringify(args)+'\\n');
if(args[0]==='api' && args.includes('POST')) {
const body=JSON.parse(fs.readFileSync(0,'utf8'));
if(body.ref!=='main-v2' || body.inputs.release_version!=='1.2.3' || body.inputs.release_request!=='release-123-2') process.exit(3);
if(body.ref!=='website' || body.inputs.release_version!=='1.2.3' || body.inputs.release_request!=='release-123-2') process.exit(3);
fs.writeFileSync(path.join(root,'request'),body.inputs.release_request);
} else if(args[0]==='api') console.log('2026-01-01T00:00:00Z');
else if(args[0]==='release' && args[1]==='view') console.log(process.env.MISSING_EVENT==='true'?'0':'1');
@@ -132,19 +132,13 @@ test("real public verifier rejects tag drift before inspecting any packages", t
assert.equal(f.calls(), "");
});
test("recovery and publication share one owner and recovery cannot enter a publisher", () => {
const recovery = readFileSync(".github/workflows/release-site-recovery.yml", "utf8");
test("publication delegates website deployment to the website branch", () => {
const promote = readFileSync(".github/workflows/release-promote.yml", "utf8");
const sync = readFileSync("scripts/sync-release-site.sh", "utf8");
assert.match(readFileSync(".github/workflows/release-stable.yml", "utf8"), /group: stable-release-publication/);
for (const workflow of [recovery, promote]) {
assert.match(workflow, /group: stable-release-publication/);
assert.match(workflow, /bash scripts\/sync-release-site.sh/);
assert.match(workflow, /retention-days: 90/);
assert.match(workflow, /bash scripts\/check-release-public-access.sh/);
}
assert.match(recovery, /RELEASE_SITE_RECOVERY_ONLY: "true"/);
assert.doesNotMatch(recovery, /secrets: inherit|release-candidate-tags.sh activate|contents: write|uses: .*release-(desktop|npm)\.yml/);
assert.equal((recovery.match(/environment: release/g) || []).length, 1);
const pages = readFileSync(".github/workflows/pages.yml", "utf8");
assert.ok(pages.indexOf("Recheck release ownership") < pages.indexOf("uses: actions/deploy-pages"));
assert.match(promote, /group: stable-release-publication/);
assert.match(promote, /bash scripts\/sync-release-site.sh/);
assert.match(promote, /retention-days: 90/);
assert.match(sync, /\{ref:"website"/);
assert.match(sync, /--branch website/);
});
@@ -7,7 +7,6 @@ required=(
.github/workflows/release-candidate.yml
.github/workflows/release-candidate-verify.yml
.github/workflows/release-promote.yml
.github/workflows/release-site-recovery.yml
.github/workflows/release-desktop.yml
.signpath/contracts/release-signing.yml
npm/publish-candidate.mjs
-16
View File
@@ -1,16 +0,0 @@
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
// Served from the custom domain reasonix.io at the site root.
export default defineConfig({
site: 'https://reasonix.io',
// Documentation code samples intentionally use source newlines as content.
// Astro 7's JSX-style compression removes those newlines unless disabled.
compressHTML: false,
build: { assets: 'static' },
integrations: [sitemap({
filter: (page) => !/\/changelog\/(?:stable|preview)\/?$/.test(page) &&
!/\/changelog\/v\d+\.\d+\.\d+-/.test(page) &&
!/\/(?:login|register|forgot|reset|account|device|u)\/?$/.test(page),
})],
});
-4248
View File
File diff suppressed because it is too large. Load diff
-24
View File
@@ -1,24 +0,0 @@
{
"name": "reasonix-site",
"type": "module",
"private": true,
"scripts": {
"dev": "astro dev",
"prebuild": "node scripts/fetch-community.mjs && node scripts/fetch-publications.mjs",
"build": "astro build",
"preview": "astro preview",
"test": "node --test src/scripts/*.test.mjs"
},
"dependencies": {
"@astrojs/sitemap": "^3.7.4",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"astro": "^7.3.1"
},
"devDependencies": {
"sharp": "^0.35.3"
},
"overrides": {
"esbuild": "0.28.1"
}
}
-16
View File
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_1" data-name=" 图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<style>
.cls-1 {
fill: #fff;
}
.cls-2 {
fill: #0153e5;
}
</style>
</defs>
<rect class="cls-2" y="0" width="64" height="64" rx="20.22" ry="20.22"/>
<path class="cls-1" d="M40.53,37.67h0c2.26-.67,4.24-1.86,5.83-3.59h0c1.73-1.94,2.82-4.29,3.2-6.87.86-5.64-1.94-10.79-7.21-12.97-1.85-.74-3.76-1.06-5.78-1.04h-19.6v37.56h9.64v-12.68c1.05.34,2.16.52,3.29.52l3.15,4.35,5.73,7.83,11.44-.02-9.7-13.1h0ZM40.14,35.94c-1.31.43-2.72.37-4.06-.02-2.3-1.74-3.55-3.6-5.52-5.37-2.52-2.27-5.84-3.84-9.25-3.69.27-.74.66-1.45,1.17-2.11,1.76-2.2,4.16-2.9,6.92-2.51,1.02.14,2.11-1.05,4.18-.82.14.02.28.12.29.2.05.28-.75.44-.75,1.21,0,.32.15.7.45.92,1.06.74,1.97,1.57,2.93,2.43.47.42,2.04,1.59,2.44.64.24-.56.39-1.14.54-1.74.08-.29-.09-.47-.32-.61-1.63-.96-2.24-2.97-1.6-4.75.06-.17.22-.25.36-.26.55-.02.25.99,1.61,1.4,1.28.4,1.32,1.46,1.94,1.04,1.45-.99,1.97-.18,3.32-1.45.14-.13.38-.14.52-.04.09.06.17.22.16.41-.04,1.07-.46,2.1-1.19,2.88-1.39,1.49-2.55.77-2.62,1.82-.22,3.06-1.22,6.11-3.39,8.33-.07.07-.11.17-.1.24.01.07.1.14.19.17l1.83.62c.26.09.45.32.41.57-.03.22-.2.41-.45.49h0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

-21
View File
@@ -1,21 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_1" data-name=" 图层 1" xmlns="http://www.w3.org/2000/svg" width="1151.55" height="217.07" viewBox="0 0 1151.55 217.07">
<defs>
<style>
.cls-1 {
fill: #0153e5;
}
</style>
</defs>
<circle class="cls-1" cx="972.16" cy="33.55" r="23.88"/>
<g>
<path class="cls-1" d="M488.72,211.37c-12.37,1.71-24.96.46-38.13.63l-1.23-16.27c-12.87,14.9-29.84,21.97-49.07,19.87-24.58-2.69-44.41-19.38-53.2-42.6-7.32-19.32-6.99-40.86.81-60.12,9.5-23.46,30.72-40.21,55.73-42.02,18.41-1.33,34.33,4.84,45.96,18.54l1.13-15.37,37.63-.22.36,137.56h.01ZM451.41,143.22c0-19.82-16.06-35.88-35.88-35.88s-35.88,16.06-35.88,35.88,16.06,35.88,35.88,35.88,35.88-16.06,35.88-35.88Z"/>
<path class="cls-1" d="M294.69,165.71c11.08,4.99,20.6,11.87,31.28,18.31-14.54,22.26-39.3,32.78-65.14,31.91-42.29-1.42-73.12-35.5-70.7-77.31,2.1-36.29,28.66-64.56,65.17-67.92,26.31-2.42,52.01,8.19,66.02,30.99,9.52,15.5,11.55,33.39,9.76,50.92l-101,.13c1.4,14.77,13.51,25.01,25.92,27.74,14.72,3.24,29.02-.82,38.69-14.77h0ZM294.02,127.16c-4.12-16.52-17.68-24.4-32.5-24.13-13.74.25-27.5,9.07-30.5,24.11l63,.02h0Z"/>
<path class="cls-1" d="M780.3,143.31c0,40.74-33.02,73.76-73.76,73.76s-73.76-33.02-73.76-73.76,33.02-73.76,73.76-73.76,73.76,33.02,73.76,73.76ZM742.63,143.26c0-19.97-16.19-36.16-36.16-36.16s-36.16,16.19-36.16,36.16,16.19,36.16,36.16,36.16,36.16-16.19,36.16-36.16Z"/>
<path class="cls-1" d="M865.82,106.67c-16.4-.48-30.2,10.42-30.32,26.41l-.59,78.88-38.91-.18.06-137.83,38.15.35c.41,5.66-1.03,9.97,1.45,15.75,19.54-26.51,63.34-25.07,82.95-1.16,9.2,11.23,13.46,25.62,13.45,40.38l-.06,82.62-38.94.14-.69-79.28c-.13-14.92-10.82-25.64-26.54-26.1v.02h-.01Z"/>
<path class="cls-1" d="M1151.55,211.51l-44.84.51-29.9-41.82-30.52,41.81-43.29-.52,50.71-70.03-48.12-67.06c16.29-1.81,30.31-.14,45.42-.02l26.06,38.81,27.14-39.29c14.85-.38,28.79-.61,44.5.69l-47.96,66.65,50.79,70.28h0Z"/>
<path class="cls-1" d="M595.11,210.55c-30.01,11.52-78.69,6.96-94.37-27.63l29.88-14.4c4.49,1.96,5.5,6.8,9.44,9.49,16.43,11.17,43.99,9.75,44.36-4.61.12-4.72-3.46-8.89-8.58-10.29l-36.51-9.95c-11.77-3.21-22.59-9.81-28.13-21.2-10.25-21.06.12-45.53,21.63-54.96,29.01-12.72,77.53-8.27,89.7,25.13l-29.06,12.62c-10.47-12.8-27.66-16.84-42.64-10.42-3.51,1.5-5.54,6.03-5.28,8.81.35,3.7,3.39,7.71,7.59,8.86l40.46,11.09c9.77,2.68,19.02,7.82,24.29,16.77,13.52,22.96,2.31,51.05-22.78,60.69h0Z"/>
<rect class="cls-1" x="902.92" y="123.55" width="138.32" height="38.82" transform="translate(828.4464 1114.9374) rotate(-89.96)"/>
<path class="cls-1" d="M134.57,139.75l.02-.02c12.9-3.8,24.24-10.63,33.31-20.48v-.03c9.89-11.07,16.1-24.46,18.25-39.19,4.93-32.21-11.06-61.62-41.17-74.07C134.39,1.71,123.51-.11,111.97,0L0,.03v214.57l55.04-.04v-72.42c6,1.93,12.33,2.97,18.82,2.94l18.02,24.87,32.75,44.72,65.37-.09-55.43-74.84h0ZM132.36,129.86c-7.5,2.48-15.55,2.11-23.21-.12-13.17-9.95-20.3-20.57-31.51-30.7-14.37-12.97-33.39-21.96-52.86-21.08,1.55-4.25,3.78-8.31,6.68-12.03,10.05-12.55,23.77-16.55,39.52-14.34,5.82.82,12.03-6.02,23.86-4.7.79.09,1.6.68,1.67,1.16.27,1.61-4.3,2.49-4.3,6.9,0,1.86.83,4.01,2.59,5.24,6.03,4.23,11.26,8.99,16.72,13.9,2.66,2.39,11.66,9.1,13.95,3.64,1.34-3.19,2.22-6.53,3.11-9.93.44-1.68-.49-2.68-1.85-3.48-9.29-5.51-12.79-16.96-9.16-27.12.35-.98,1.26-1.46,2.03-1.49,3.12-.13,1.46,5.65,9.18,8.03,7.32,2.26,7.54,8.34,11.1,5.92,8.29-5.65,11.28-1.02,18.98-8.27.79-.74,2.15-.8,2.97-.22.51.35.95,1.26.91,2.33-.22,6.12-2.61,11.97-6.8,16.46-7.91,8.49-14.56,4.43-14.99,10.38-1.26,17.46-6.97,34.9-19.37,47.58-.4.41-.63.99-.57,1.38.07.41.56.82,1.1.99l10.44,3.55c1.47.5,2.55,1.83,2.35,3.24-.17,1.23-1.15,2.32-2.55,2.79h.01Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

-4
View File
@@ -1,4 +0,0 @@
User-agent: *
Allow: /
Sitemap: https://reasonix.io/sitemap-index.xml
-63
View File
@@ -1,63 +0,0 @@
// Prebuild: fetch live community stats + bake contributor avatars into public/av/.
// Never fails the build — falls back to the committed snapshot on any error.
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import sharp from 'sharp';
const repo = 'esengine/DeepSeek-Reasonix';
const api = `https://api.github.com/repos/${repo}`;
const headers = { 'User-Agent': 'reasonix-site', Accept: 'application/vnd.github+json' };
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
const fallback = JSON.parse(await readFile('src/data/contributors.json', 'utf8'));
let { stars, mergedPrs, contributors, list } = fallback;
try {
const r = await fetch(`${api}/contributors?per_page=100`, { headers });
if (r.ok) {
const humans = (await r.json())
.filter((c) => c.type === 'User')
.map((c) => ({ login: c.login, avatar: String(c.avatar_url).split('?')[0], url: c.html_url, c: c.contributions }));
if (humans.length) { list = humans; contributors = humans.length; }
}
} catch {}
try {
const r = await fetch(api, { headers });
if (r.ok) { const d = await r.json(); if (typeof d.stargazers_count === 'number') stars = d.stargazers_count; }
} catch {}
try {
const r = await fetch(`https://api.github.com/search/issues?q=repo:${repo}+is:pr+is:merged&per_page=1`, { headers });
if (r.ok) { const d = await r.json(); if (typeof d.total_count === 'number') mergedPrs = d.total_count; }
} catch {}
// 64px is the largest rendered avatar; bake at 2x. GitHub honours ?s= for its
// own CDN but serves some custom avatars unscaled and in whatever format was
// uploaded, so re-encode rather than trusting the response.
const AVATAR_PX = 128;
await rm('public/av', { recursive: true, force: true });
await mkdir('public/av', { recursive: true });
let baked = 0;
let bakedBytes = 0;
const out = await Promise.all(list.map(async (c) => {
const remote = `${c.avatar}?s=${AVATAR_PX}`;
try {
const r = await fetch(remote);
if (!r.ok) throw new Error(String(r.status));
const webp = await sharp(Buffer.from(await r.arrayBuffer()))
.resize(AVATAR_PX, AVATAR_PX, { fit: 'cover' })
.webp({ quality: 80 })
.toBuffer();
await writeFile(`public/av/${c.login}.webp`, webp);
baked++;
bakedBytes += webp.byteLength;
return { ...c, avatar: `/av/${c.login}.webp` };
} catch {
return { ...c, avatar: remote };
}
}));
await writeFile('src/data/community.json', JSON.stringify({ stars, mergedPrs, contributors, list: out }, null, 2) + '\n');
console.log(
`community: ${contributors} contributors · ${stars} stars · ${mergedPrs} merged PRs · ` +
`${baked}/${out.length} avatars baked (${(bakedBytes / 1024).toFixed(0)} KB)`,
);
-50
View File
@@ -1,50 +0,0 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { validateReleaseEvent } from "../../scripts/release-event.mjs";
const siteRoot = resolve(import.meta.dirname, "..");
const repoRoot = resolve(siteRoot, "..");
const catalog = JSON.parse(await readFile(resolve(repoRoot, "release-notes/releases.json"), "utf8"));
const output = resolve(siteRoot, ".generated/publications.json");
const headers = {
Accept: "application/vnd.github+json",
"User-Agent": "reasonix-site-publications",
};
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
const publications = [];
for (const release of catalog.releases) {
// Legacy entries predate publication markers and remain public. Every new
// reviewed entry fails closed until its exact CLI release carries the marker
// uploaded by the all-surface postflight.
if (!release.status || release.status === "published") {
publications.push(release.version);
continue;
}
const tag = `v${release.version}`;
try {
const response = await fetch(
`https://api.github.com/repos/esengine/DeepSeek-Reasonix/releases/tags/${encodeURIComponent(tag)}`,
{ headers, signal: AbortSignal.timeout(20_000) },
);
if (!response.ok) continue;
const githubRelease = await response.json();
const marker = githubRelease.assets?.find((asset) => asset.name === "release-event.json" && asset.size > 0);
if (!marker?.browser_download_url) continue;
const markerResponse = await fetch(marker.browser_download_url, {
headers,
signal: AbortSignal.timeout(20_000),
});
if (!markerResponse.ok) continue;
const event = await markerResponse.json();
validateReleaseEvent(event, release);
publications.push(release.version);
} catch {
// A reviewed release must never become public because an upstream lookup
// failed. The next Pages build retries after the release postflight.
}
}
await mkdir(resolve(siteRoot, ".generated"), { recursive: true });
await writeFile(output, `${JSON.stringify({ schemaVersion: 1, publications }, null, 2)}\n`);
console.log(`Resolved ${publications.length} public release note(s).`);
-411
View File
@@ -1,411 +0,0 @@
---
import Base from '../layouts/Base.astro';
import SiteHeader from './SiteHeader.astro';
import type { ReleaseItem, ReleaseRecord, ReleaseTarget } from '../data/releases';
import { releasePath, releaseTargetCount } from '../data/releases';
import '../styles/changelog.css';
const { release, releases, canonical } = Astro.props as {
release: ReleaseRecord;
releases: ReleaseRecord[];
canonical?: string;
};
const base = import.meta.env.BASE_URL.replace(/\/$/, '');
const repo = 'https://github.com/esengine/DeepSeek-Reasonix';
const prHref = (ref: number) => `${repo}/pull/${ref}`;
const isPreview = release.channel === 'prerelease';
const latestForChannel = releases[0];
const downloadHref = isPreview ? 'https://reasonix.io/?download=desktop#start' : release.links.download;
const kindLabel = (kind: string) => ({
new: { en: 'New', zh: '新增' },
improved: { en: 'Improved', zh: '改进' },
fixed: { en: 'Fixed', zh: '修复' },
security: { en: 'Security', zh: '安全' },
}[kind] ?? { en: kind, zh: kind });
const surfaceLabel = (surface: string) => ({
agent: { en: 'Agent', zh: 'Agent' },
desktop: { en: 'Desktop', zh: '桌面端' },
cli: { en: 'CLI', zh: 'CLI' },
mcp: { en: 'MCP', zh: 'MCP' },
security: { en: 'Security', zh: '安全' },
provider: { en: 'Provider', zh: '模型服务' },
plugin: { en: 'Plugins', zh: '插件' },
site: { en: 'Site', zh: '网站' },
service: { en: 'Service', zh: '服务端' },
}[surface] ?? { en: surface, zh: surface });
const targetingEnabled = release.targetingVersion === 1;
const changeKinds = ['new', 'improved', 'fixed'] as const;
const clientTargets: { id: ReleaseTarget; en: string; zh: string }[] = [
{ id: 'desktop', en: 'Desktop updates', zh: '桌面端更新' },
{ id: 'cli', en: 'CLI updates', zh: 'CLI 端更新' },
];
const hasTarget = (item: ReleaseItem, target: ReleaseTarget) => item.targets?.includes(target) ?? false;
const clientHighlights = (target: ReleaseTarget) => release.highlights.filter((item) => hasTarget(item, target));
const clientChanges = (target: ReleaseTarget, kind: typeof changeKinds[number]) =>
release.changes[kind].filter((item) => hasTarget(item, target));
const hasClientChangeContent = (target: ReleaseTarget) =>
clientHighlights(target).length > 0 || changeKinds.some((kind) => clientChanges(target, kind).length > 0);
const isOtherOnly = (item: ReleaseItem) =>
Boolean(item.targets?.length) && !hasTarget(item, 'desktop') && !hasTarget(item, 'cli');
const otherHighlights = release.highlights.filter(isOtherOnly);
const otherChanges = Object.fromEntries(
changeKinds.map((kind) => [kind, release.changes[kind].filter(isOtherOnly)]),
) as Record<typeof changeKinds[number], ReleaseItem[]>;
const hasOtherChanges = otherHighlights.length > 0 || changeKinds.some((kind) => otherChanges[kind].length > 0);
/* Sections render only when they have content; numbering is sequential. */
const sectionDefs = [
...(release.guides.length ? [{ id: 'guides', en: 'Guides', zh: '使用攻略' }] : []),
...(targetingEnabled
? [
...clientTargets,
...(hasOtherChanges ? [{ id: 'other', en: 'Other project updates', zh: '其他项目更新' }] : []),
]
: [
{ id: 'highlights', en: 'Highlights', zh: '重点内容' },
...changeKinds
.filter((kind) => release.changes[kind].length)
.map((kind) => ({ id: kind, ...kindLabel(kind) })),
]),
...(release.upgrade.length ? [{ id: 'upgrade', en: 'Upgrade notes', zh: '升级提醒' }] : []),
...(release.risks.length ? [{ id: 'risks', en: 'Risk notes', zh: '风险提示' }] : []),
...(release.contributors.length ? [{ id: 'thanks', en: 'Thanks', zh: '致谢' }] : []),
];
const sectionNum = new Map(sectionDefs.map((s, i) => [s.id, String(i + 1).padStart(2, '0')]));
const tocItems = [...sectionDefs, { id: 'download', en: 'Download', zh: '下载与安装' }];
/* Version rail: group by minor, show the latest few patches per group,
older ones behind a native <details> expander. */
const minorKey = (v: string) => v.split('.').slice(0, 2).join('.');
const railGroups: { key: string; items: ReleaseRecord[] }[] = [];
for (const item of releases) {
const key = minorKey(item.version);
const last = railGroups[railGroups.length - 1];
if (last && last.key === key) last.items.push(item);
else railGroups.push({ key, items: [item] });
}
const RAIL_VISIBLE = 3;
const titleEn = `Reasonix v${release.version} release notes`;
const titleZh = `Reasonix v${release.version} 更新日志`;
const description = release.summary.en;
---
<Base title={titleEn} titleEn={titleEn} titleZh={titleZh} description={description} canonical={canonical} noindex={isPreview}>
<SiteHeader active="changelog" scrolled />
<main class="release-stage">
<div class="release-mobile-switcher">
<label for="release-version"><span class="l-en">Version</span><span class="l-zh">版本</span></label>
<select id="release-version" data-release-version>
{releases.map((item) => <option value={item.version} selected={item.version === release.version}>v{item.version}</option>)}
</select>
</div>
<div class="release-grid">
<aside class="release-rail" aria-label="Release versions">
<div class="release-rail__eyebrow"><span class="release-rail__mark"></span><span class="l-en">Release ledger</span><span class="l-zh">版本档案</span></div>
<nav>
{railGroups.map((group) => {
const channelItems = group.items;
if (!channelItems.length) return null;
const visible = channelItems.slice(0, RAIL_VISIBLE);
const overflow = channelItems.slice(RAIL_VISIBLE);
return (
<div class="release-rail__group">
<p class="release-rail__minor">{group.key}.x</p>
{visible.map((item) => (
<a class:list={["release-version-link", { active: item.version === release.version }]} href={`${base}${releasePath(item.version)}`}>
<span class="release-version-link__line"><strong>v{item.version}</strong>{item === latestForChannel && <em><span class="l-en">Latest</span><span class="l-zh">最新</span></em>}</span>
<small>{item.date}</small>
</a>
))}
{overflow.length > 0 && (
<details class="release-rail__more" open={overflow.some((item) => item.version === release.version)}>
<summary>
<span class="when-closed"><span class="l-en">+{overflow.length} more</span><span class="l-zh">还有 {overflow.length} 个</span></span>
<span class="when-open"><span class="l-en">Show less</span><span class="l-zh">收起</span></span>
</summary>
{overflow.map((item) => (
<a class:list={["release-version-link", { active: item.version === release.version }]} href={`${base}${releasePath(item.version)}`}>
<span class="release-version-link__line"><strong>v{item.version}</strong>{item === latestForChannel && <em><span class="l-en">Latest</span><span class="l-zh">最新</span></em>}</span>
<small>{item.date}</small>
</a>
))}
</details>
)}
</div>
);
})}
</nav>
<a class="release-rail__all" href={`${repo}/releases`}><span class="l-en">All GitHub releases ↗</span><span class="l-zh">全部 GitHub Releases ↗</span></a>
</aside>
<article class="release-paper">
<header class="release-hero">
<div class="release-hero__meta">
{isPreview && <span class="release-channel release-channel--preview"><span class="release-channel__dot"></span><span class="l-en">Historical archive</span><span class="l-zh">历史归档</span></span>}
<time datetime={release.date}>{release.date}</time>
</div>
<h1>Reasonix v{release.version}</h1>
<div class="release-surfaces">
{release.surfaces.map((surface) => {
const label = surfaceLabel(surface);
return <span><span class="l-en">{label.en}</span><span class="l-zh">{label.zh}</span></span>;
})}
</div>
{release.builds && (
<div class="release-builds" aria-label="Published builds">
<span><strong>CLI</strong> {release.builds.cli}</span>
<span><strong>Desktop</strong> {release.builds.desktop}</span>
<span><strong>npm</strong> {release.builds.npm}</span>
</div>
)}
{targetingEnabled && (
<div class="release-target-summary" aria-label="Updates by product">
<span><strong>Desktop</strong> {releaseTargetCount(release, 'desktop')}</span>
<span><strong>CLI</strong> {releaseTargetCount(release, 'cli')}</span>
</div>
)}
<p class="release-lede"><span class="l-en">{release.summary.en}</span><span class="l-zh">{release.summary.zh}</span></p>
</header>
{release.guides.length > 0 && (
<section id="guides" class="release-section release-guides">
<div class="release-section__head"><span>{sectionNum.get('guides')}</span><h2><span class="l-en">Start here</span><span class="l-zh">使用攻略</span></h2></div>
<div class="release-guide-grid">
{release.guides.map((guide) => (
<a class="release-guide" href={guide.href}>
<span class="release-guide__arrow">↗</span>
<h3><span class="l-en">{guide.title.en}</span><span class="l-zh">{guide.title.zh}</span></h3>
<p><span class="l-en">{guide.body.en}</span><span class="l-zh">{guide.body.zh}</span></p>
</a>
))}
</div>
</section>
)}
{targetingEnabled ? (
<>
{clientTargets.map((target) => {
const highlights = clientHighlights(target.id);
const total = releaseTargetCount(release, target.id);
return (
<section id={target.id} class="release-section release-target-section" data-target={target.id}>
<div class="release-section__head"><span>{sectionNum.get(target.id)}</span><h2><span class="l-en">{target.en}</span><span class="l-zh">{target.zh}</span></h2></div>
<p class="release-target-section__summary">
<span class="l-en">{total} update{total === 1 ? '' : 's'} relevant to {target.id === 'desktop' ? 'Desktop' : 'CLI'}.</span>
<span class="l-zh">与{target.id === 'desktop' ? '桌面端' : ' CLI'}相关的更新共 {total} 项。</span>
</p>
{highlights.length > 0 && (
<div class="release-target-group">
<h3 class="release-target-group__title"><span class="l-en">Highlights</span><span class="l-zh">重点内容</span></h3>
<div class="release-highlight-list">
{highlights.map((item, index) => {
const kind = kindLabel(item.kind);
return (
<article class="release-highlight" data-kind={item.kind}>
<div class="release-highlight__index">{String(index + 1).padStart(2, '0')}</div>
<div>
<div class="release-item-meta">
<div class="release-highlight__kind"><span class="l-en">{kind.en}</span><span class="l-zh">{kind.zh}</span></div>
<div class="release-item-targets">{item.targets?.map((itemTarget) => {
const label = surfaceLabel(itemTarget);
return <span><span class="l-en">{label.en}</span><span class="l-zh">{label.zh}</span></span>;
})}</div>
</div>
<h3><span class="l-en">{item.title.en}</span><span class="l-zh">{item.title.zh}</span></h3>
<p><span class="l-en">{item.body.en}</span><span class="l-zh">{item.body.zh}</span></p>
{item.refs?.length && <div class="release-refs">{item.refs.map((ref) => <a href={prHref(ref)}>#{ref}</a>)}</div>}
</div>
</article>
);
})}
</div>
</div>
)}
{changeKinds.map((kind) => {
const items = clientChanges(target.id, kind);
if (!items.length) return null;
const label = kindLabel(kind);
return (
<div class="release-target-group">
<h3 class="release-target-group__title"><span class="l-en">{label.en}</span><span class="l-zh">{label.zh}</span></h3>
<div class="release-change-list">
{items.map((item: ReleaseItem) => (
<article>
<div class="release-item-targets">{item.targets?.map((itemTarget) => {
const targetLabel = surfaceLabel(itemTarget);
return <span><span class="l-en">{targetLabel.en}</span><span class="l-zh">{targetLabel.zh}</span></span>;
})}</div>
<h3><span class="l-en">{item.title.en}</span><span class="l-zh">{item.title.zh}</span></h3>
<p><span class="l-en">{item.body.en}</span><span class="l-zh">{item.body.zh}</span></p>
{item.refs?.length && <div class="release-refs">{item.refs.map((ref) => <a href={prHref(ref)}>#{ref}</a>)}</div>}
</article>
))}
</div>
</div>
);
})}
{!hasClientChangeContent(target.id) && (
<div class="release-target-empty">
{target.id === 'cli' && total === 0 ? (
<><strong><span class="l-en">No CLI changes in this release.</span><span class="l-zh">本版本没有 CLI 相关改动。</span></strong><p><span class="l-en">CLI users may skip this version if preferred.</span><span class="l-zh">CLI 用户可按需跳过这个版本。</span></p></>
) : (
<><strong><span class="l-en">No feature or fix entries in this section.</span><span class="l-zh">此板块没有功能或修复条目。</span></strong><p><span class="l-en">See the upgrade and risk notes below for relevant guidance.</span><span class="l-zh">相关说明请查看下方升级提醒和风险提示。</span></p></>
)}
</div>
)}
</section>
);
})}
{hasOtherChanges && (
<section id="other" class="release-section release-target-section" data-target="other">
<div class="release-section__head"><span>{sectionNum.get('other')}</span><h2><span class="l-en">Other project updates</span><span class="l-zh">其他项目更新</span></h2></div>
<p class="release-target-section__summary"><span class="l-en">Website and service changes that do not require a Desktop or CLI update.</span><span class="l-zh">不要求更新桌面端或 CLI 的网站与服务端变更。</span></p>
<div class="release-change-list">
{[...otherHighlights, ...changeKinds.flatMap((kind) => otherChanges[kind])].map((item) => (
<article>
<div class="release-item-targets">{item.targets?.map((itemTarget) => {
const label = surfaceLabel(itemTarget);
return <span><span class="l-en">{label.en}</span><span class="l-zh">{label.zh}</span></span>;
})}</div>
<h3><span class="l-en">{item.title.en}</span><span class="l-zh">{item.title.zh}</span></h3>
<p><span class="l-en">{item.body.en}</span><span class="l-zh">{item.body.zh}</span></p>
{item.refs?.length && <div class="release-refs">{item.refs.map((ref) => <a href={prHref(ref)}>#{ref}</a>)}</div>}
</article>
))}
</div>
</section>
)}
</>
) : (
<>
<section id="highlights" class="release-section">
<div class="release-section__head"><span>{sectionNum.get('highlights')}</span><h2><span class="l-en">Highlights</span><span class="l-zh">重点内容</span></h2></div>
<div class="release-highlight-list">
{release.highlights.map((item, index) => {
const kind = kindLabel(item.kind);
return (
<article class="release-highlight" data-kind={item.kind}>
<div class="release-highlight__index">{String(index + 1).padStart(2, '0')}</div>
<div>
<div class="release-highlight__kind"><span class="l-en">{kind.en}</span><span class="l-zh">{kind.zh}</span></div>
<h3><span class="l-en">{item.title.en}</span><span class="l-zh">{item.title.zh}</span></h3>
<p><span class="l-en">{item.body.en}</span><span class="l-zh">{item.body.zh}</span></p>
{item.refs?.length && <div class="release-refs">{item.refs.map((ref) => <a href={prHref(ref)}>#{ref}</a>)}</div>}
</div>
</article>
);
})}
</div>
</section>
{changeKinds.map((kind) => {
const items = release.changes[kind];
if (!items.length) return null;
const label = kindLabel(kind);
return (
<section id={kind} class="release-section release-change-section">
<div class="release-section__head"><span>{sectionNum.get(kind)}</span><h2><span class="l-en">{label.en}</span><span class="l-zh">{label.zh}</span></h2></div>
<div class="release-change-list">
{items.map((item: ReleaseItem) => (
<article>
<h3><span class="l-en">{item.title.en}</span><span class="l-zh">{item.title.zh}</span></h3>
<p><span class="l-en">{item.body.en}</span><span class="l-zh">{item.body.zh}</span></p>
{item.refs?.length && <div class="release-refs">{item.refs.map((ref) => <a href={prHref(ref)}>#{ref}</a>)}</div>}
</article>
))}
</div>
</section>
);
})}
</>
)}
{release.upgrade.length > 0 && (
<section id="upgrade" class="release-section">
<div class="release-section__head"><span>{sectionNum.get('upgrade')}</span><h2><span class="l-en">Upgrade notes</span><span class="l-zh">升级提醒</span></h2></div>
<div class="release-notice-list">
{release.upgrade.map((item) => (
<article class:list={["release-notice", `release-notice--${item.level}`]}>
<div class="release-notice__icon">{item.level === 'warning' ? '!' : 'i'}</div>
<div>
{item.targets?.length && <div class="release-item-targets">{item.targets.map((itemTarget) => {
const label = surfaceLabel(itemTarget);
return <span><span class="l-en">{label.en}</span><span class="l-zh">{label.zh}</span></span>;
})}</div>}
<h3><span class="l-en">{item.title.en}</span><span class="l-zh">{item.title.zh}</span></h3>
<p><span class="l-en">{item.body.en}</span><span class="l-zh">{item.body.zh}</span></p>
{item.refs?.length && <div class="release-refs">{item.refs.map((ref) => <a href={prHref(ref)}>#{ref}</a>)}</div>}
</div>
</article>
))}
</div>
</section>
)}
{release.risks.length > 0 && (
<section id="risks" class="release-section">
<div class="release-section__head"><span>{sectionNum.get('risks')}</span><h2><span class="l-en">Risk notes</span><span class="l-zh">风险提示</span></h2></div>
<div class="release-change-list">
{release.risks.map((item) => (
<article>
{item.targets?.length && <div class="release-item-targets">{item.targets.map((itemTarget) => {
const label = surfaceLabel(itemTarget);
return <span><span class="l-en">{label.en}</span><span class="l-zh">{label.zh}</span></span>;
})}</div>}
<h3><span class="l-en">{item.title.en}</span><span class="l-zh">{item.title.zh}</span></h3>
<p><span class="l-en">{item.body.en}</span><span class="l-zh">{item.body.zh}</span></p>
{item.refs?.length && <div class="release-refs">{item.refs.map((ref) => <a href={prHref(ref)}>#{ref}</a>)}</div>}
</article>
))}
</div>
</section>
)}
{release.contributors.length > 0 && (
<section id="thanks" class="release-section release-thanks">
<div class="release-section__head"><span>{sectionNum.get('thanks')}</span><h2><span class="l-en">Thanks</span><span class="l-zh">致谢</span></h2></div>
<p><span class="l-en">This release was shaped by:</span><span class="l-zh">感谢本版本的贡献者:</span></p>
<div>{release.contributors.map((name) => <a href={`https://github.com/${name}`}>@{name}</a>)}</div>
</section>
)}
<section id="download" class="release-section release-download">
<div>
<p class="release-download__kicker"><span class="l-en">Ready when you are</span><span class="l-zh">准备好即可升级</span></p>
<h2><span class="l-en">Ship with a safer Reasonix.</span><span class="l-zh">升级到更可靠的 Reasonix。</span></h2>
</div>
<div class="release-download__actions">
<a class="btn btn-dark" href={downloadHref}><span class="l-en">Download</span><span class="l-zh">下载</span></a>
<a class="release-compare" href={release.links.compare}><span class="l-en">Full comparison ↗</span><span class="l-zh">完整差异 ↗</span></a>
</div>
</section>
</article>
<aside class="release-toc">
<p><span class="l-en">In v{release.version}</span><span class="l-zh">在 v{release.version} 中</span></p>
<nav>{tocItems.map((item) => <a href={`#${item.id}`}><span class="l-en">{item.en}</span><span class="l-zh">{item.zh}</span></a>)}</nav>
</aside>
</div>
</main>
<footer class="release-footer">
<a class="brand" href={`${base}/`}><img src={`${base}/logo.svg`} alt="" /><span>Reasonix</span></a>
<span>© 2026 Reasonix · MIT</span>
<a href={repo}>GitHub ↗</a>
</footer>
<script>
import '../scripts/changelog.js';
</script>
</Base>
-130
View File
@@ -1,130 +0,0 @@
---
type ActiveItem = 'features' | 'surfaces' | 'skills' | 'community' | 'docs' | 'changelog';
const {
active,
scrolled = false,
accountSlot = false,
} = Astro.props as {
active?: ActiveItem;
scrolled?: boolean;
accountSlot?: boolean;
};
const base = import.meta.env.BASE_URL.replace(/\/$/, '');
const repo = 'https://github.com/esengine/DeepSeek-Reasonix';
const items: Array<{
id: ActiveItem;
href: string;
en: string;
zh: string;
secondary?: boolean;
}> = [
{ id: 'features', href: `${base}/#features`, en: 'Features', zh: '特性' },
{ id: 'surfaces', href: `${base}/#surfaces`, en: 'Surfaces', zh: '使用形态', secondary: true },
{ id: 'skills', href: `${base}/skills/`, en: 'Skills', zh: '技能市场' },
{ id: 'community', href: `${base}/#community`, en: 'Community', zh: '社区', secondary: true },
{ id: 'docs', href: `${base}/docs/`, en: 'Docs', zh: '文档' },
{ id: 'changelog', href: `${base}/changelog/`, en: 'Changelog', zh: '更新日志' },
];
---
<header class:list={['nav', { scrolled }]}>
<div class="nav-inner">
<a class="brand" href={`${base}/`}>
<picture class="brand-picture">
<source media="(max-width: 400px)" srcset={`${base}/favicon.svg`} />
<img src={`${base}/logo.svg`} alt="Reasonix logo" />
</picture>
</a>
<nav class="nav-links" aria-label="Primary navigation">
{items.map((item) => (
<a
class:list={[{ here: active === item.id, 'nav-link--secondary': item.secondary }]}
href={item.href}
aria-current={active === item.id ? 'page' : undefined}
>
<span class="l-en">{item.en}</span>
<span class="l-zh">{item.zh}</span>
</a>
))}
</nav>
<a
class="nav-github"
href={repo}
target="_blank"
rel="noreferrer"
aria-label="Reasonix GitHub repository"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M12 .7a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2.3c-3.3.7-4-1.4-4-1.4-.5-1.4-1.3-1.8-1.3-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.8-1.6-2.6-.3-5.4-1.3-5.4-5.9 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.6.3 2.8.1 3.1.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.4 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .7Z" />
</svg>
<span class="nav-github-label">GitHub</span>
</a>
<div class="lang-switch" role="group" aria-label="Language">
<button type="button" data-lang="en" class="active">EN</button>
<button type="button" data-lang="zh">中文</button>
</div>
<div class="theme-switch" role="group" aria-label="Theme">
<button type="button" data-theme="system" class="active" aria-label="System theme"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></button>
<button type="button" data-theme="light" aria-label="Light theme"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="12" y1="1" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="23"/><line x1="4.2" y1="4.2" x2="6.3" y2="6.3"/><line x1="17.7" y1="17.7" x2="19.8" y2="19.8"/><line x1="1" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="23" y2="12"/><line x1="4.2" y1="19.8" x2="6.3" y2="17.7"/><line x1="17.7" y1="6.3" x2="19.8" y2="4.2"/></svg></button>
<button type="button" data-theme="dark" aria-label="Dark theme"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></button>
</div>
{accountSlot ? (
<span class="nav-acct" data-account-slot></span>
) : (
<a class="btn btn-ghost nav-sign-in" href={`${base}/login/`}>
<span class="l-en">Sign in</span>
<span class="l-zh">登录</span>
</a>
)}
<a class="btn btn-dark nav-install" href={`${base}/#start`}>
<span class="l-en">Install</span>
<span class="l-zh">安装</span>
</a>
<button
class="nav-menu-toggle"
type="button"
data-mobile-nav-toggle
aria-controls="site-mobile-nav"
aria-expanded="false"
aria-label="Open navigation"
title="Open navigation"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
<div class="mobile-nav" id="site-mobile-nav" data-mobile-nav hidden>
<button class="mobile-nav-backdrop" type="button" data-mobile-nav-close aria-label="Close navigation"></button>
<aside class="mobile-nav-panel" role="dialog" aria-modal="true" aria-labelledby="site-mobile-nav-title">
<div class="mobile-nav-head">
<span id="site-mobile-nav-title"><span class="l-en">Navigation</span><span class="l-zh">导航</span></span>
<button class="mobile-nav-close" type="button" data-mobile-nav-close aria-label="Close navigation" title="Close navigation">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18" /></svg>
</button>
</div>
<nav class="mobile-nav-links" aria-label="Mobile primary navigation">
{items.map((item) => (
<a class:list={[{ here: active === item.id }]} href={item.href} aria-current={active === item.id ? 'page' : undefined}>
<span class="l-en">{item.en}</span>
<span class="l-zh">{item.zh}</span>
</a>
))}
</nav>
<div class="mobile-nav-actions">
<a class="mobile-nav-github" href={repo} target="_blank" rel="noreferrer">
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 .7a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2.3c-3.3.7-4-1.4-4-1.4-.5-1.4-1.3-1.8-1.3-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.8-1.6-2.6-.3-5.4-1.3-5.4-5.9 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.6.3 2.8.1 3.1.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.4 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .7Z" /></svg>
GitHub
</a>
{accountSlot ? (
<span class="nav-acct" data-account-slot></span>
) : (
<a class="btn btn-ghost" href={`${base}/login/`}><span class="l-en">Sign in</span><span class="l-zh">登录</span></a>
)}
<a class="btn btn-dark" href={`${base}/#start`}><span class="l-en">Install</span><span class="l-zh">安装</span></a>
</div>
</aside>
</div>
</header>
Loaded 100 of 287 files, more files were not shown because too many files have changed in this diff. Show more