Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4

# 发布页的正文只在推 tag 时才写(release.yml 的 publish)。脚本和
# `release-notes/` 下的说明在改它们的那个 PR 上就核对:链接对不对得上
# 发出去的文件、说明里有没有混进中文。只要 Python,放在编译之前
- name: Release page text
run: python3 scripts/release_notes_test.py

- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
Expand Down
59 changes: 43 additions & 16 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ jobs:
# `generate_release_notes`。Release 已经存在时,这个 action 照样再要一份
# 生成的说明,接在原有正文后面 —— 于是 v0.44.0 到 v0.47.0 的说明各有四份
# (四个 job),v0.30.0 到 v0.43.0 各有两份(macOS、Windows 两个 job)。
# 现在正文由下面一步自己拼好交给它,不再让它生成。
publish:
name: Publish
needs: [twcore, twcore-windows, twcore-linux]
Expand All @@ -324,6 +325,10 @@ jobs:
dist/twcore-aarch64-unknown-linux-gnu.tar.gz
dist/twcore-aarch64-unknown-linux-gnu.tar.gz.sha256
steps:
# 发布页的正文要用这个仓库里的脚本和 `release-notes/`。先取代码再下载产物:
# checkout 会清空工作目录
- uses: actions/checkout@v4

- uses: actions/download-artifact@v4
with:
path: dist
Expand All @@ -338,33 +343,55 @@ jobs:
cd dist
sha256sum -c ./*.sha256

# 同一个原因,这一个 job 也会接上第二份:Release 已经在了 —— 重跑这个
# job(比如挂文件挂到一半断了),或者 Release 先手工建好了(v0.11.0 就是
# 这样多出一份的)。所以只在它还不存在时生成
# 发布页:标题「ThinkWatch Core <版本>」,正文(英文)由
# `scripts/release_notes.py` 写 —— `release-notes/<版本>.md` 里的说明(可以
# 没有)、下载表、服务器上安装和升级的命令、核对方法,最后是 GitHub 按上一版
# 以来合进 main 的 PR 生成的清单。排练也写,写进这次运行的摘要里,不发布。
#
# **正文只写一次。**Release 已经在了 —— 重跑这个 job(比如挂文件挂到一半
# 断了),或者 Release 先手工建好了(v0.11.0 就是这样多出一份说明的)——
# 就不动它的正文。以前这里每跑一次就接上一份生成的清单。
#
# **「不存在」只认 gh 查不到时的那句 `release not found`。**网络断了、令牌
# 不对、被限流,gh 一样失败;把那些也当成「不存在」,Release 其实在的话就又
# 接上一份。所以别的失败让这一步挂掉,原话留在日志里
- name: Notes only for a release that does not exist yet
# 不对、被限流,gh 一样失败;把那些也当成「不存在」,Release 其实在的话它的
# 正文就被盖掉。所以别的失败让这一步挂掉,原话留在日志里
- name: The page text, only for a release that does not exist yet
id: notes
if: github.ref_type == 'tag'
env:
GH_TOKEN: ${{ github.token }}
run: |
if out=$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json tagName 2>&1); then
echo "generate=false"
elif grep -qx 'release not found' <<<"$out"; then
echo "generate=true"
set -euo pipefail
if [ "$GITHUB_REF_TYPE" = tag ]; then
VERSION="${GITHUB_REF_NAME#v}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
if out=$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json tagName 2>&1); then
echo "release $GITHUB_REF_NAME already exists; its text is left as it is"
echo "path=" >> "$GITHUB_OUTPUT"
exit 0
elif ! grep -qx 'release not found' <<<"$out"; then
echo "$out" >&2
exit 1
fi
else
echo "$out" >&2
exit 1
fi >> "$GITHUB_OUTPUT"
# 排练没有 tag:用代码里的版本号
VERSION=$(awk -F'"' '/^version *= *"/ { print $2; exit }' Cargo.toml)
fi
# 清单向 GitHub 要:和它在发布页上「自动生成」的是同一份。排练时这个 tag
# 可能还不存在,那就算到这次的 commit 为止(tag 存在时 target_commitish
# 不起作用)
gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \
-f tag_name="v$VERSION" -f target_commitish="$GITHUB_SHA" \
--jq .body > "$RUNNER_TEMP/changes.md"
python3 scripts/release_notes.py "$VERSION" "$RUNNER_TEMP/changes.md" > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md" >> "$GITHUB_STEP_SUMMARY"
echo "path=$RUNNER_TEMP/notes.md" >> "$GITHUB_OUTPUT"

- uses: softprops/action-gh-release@v2
# 排练到上面为止
if: github.ref_type == 'tag'
with:
name: ThinkWatch Core ${{ env.VERSION }}
# 空的话(Release 已经在了)这个 action 保留原有的正文
body_path: ${{ steps.notes.outputs.path }}
files: ${{ env.FILES }}
fail_on_unmatched_files: true
# GitHub 生成的说明:上一版以来合进 main 的 PR
generate_release_notes: ${{ steps.notes.outputs.generate }}
27 changes: 24 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,9 @@ answered on the request itself.
"which build is in there" has to be a fact somebody can check rather than
whatever sat in a `target/` directory that afternoon.

1. Bump `version` in the workspace `Cargo.toml`, land it on `main`.
1. Bump `version` in the workspace `Cargo.toml`, add
`release-notes/X.Y.Z.md` if the release page should carry a summary
(see below), and land both on `main`.
2. Tag that commit `vX.Y.Z` and push the tag.
3. `release.yml` builds `twcore` for every target below. It checks that
each binary is built for its target and, where the runner can execute
Expand All @@ -185,10 +187,29 @@ The bare binaries are what the desktop app's pipeline bundles and what
the unit and the binary come from the same commit. The file names are a
contract with both: `twcore upgrade` has a test that reads `release.yml`.

The release is titled `ThinkWatch Core X.Y.Z`. Its text, in English, is
written by `scripts/release_notes.py` in this order:

1. `release-notes/X.Y.Z.md`, when that file exists: a summary of the
release in paragraphs or lists, without a top-level heading. It goes
in with the version bump, because the tag fixes what the tree
contains.
2. A table of the files for each platform.
3. The commands that install this version on a server and switch an
existing installation to it.
4. How to verify a download against its `.sha256`.
5. GitHub's list of the pull requests merged since the previous release.

The text is written when the release is created. A release that already
exists keeps its text, so re-running the job does not add to it, and a
correction after publishing is made on the release page itself. CI runs
`python3 scripts/release_notes_test.py`, which checks the script against
`release.yml` and renders every file in `release-notes/`.

To try a change to `release.yml` without publishing, run it by hand on
your branch (`gh workflow run release.yml --ref <branch>`): it builds and
checks everything, leaves the files as the run's artifacts, and publishes
nothing.
checks everything, leaves the files as the run's artifacts, shows the
release text in the run's summary, and publishes nothing.

The desktop app pins `tw-api` (and the few other crates it uses:
`tw-types`, `tw-yaml`, `tw-guard`, `tw-watch`, `tw-link`) to the same tag
Expand Down
17 changes: 17 additions & 0 deletions release-notes/0.47.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
This release adds a remote control port, through which ThinkWatch Lite on another machine can manage a core running on a server, together with an install script, a systemd unit and `twcore upgrade` for running `twcore` as a service on Linux. ThinkWatch Lite 2026.9.16 includes this version.

**Upgrade notes**

- A misspelled field under `proxies` (for example `typ:` for `type:`) is now a configuration error; it used to be ignored. A configuration that contains one does not pass `twcore check`, and core does not start with it.
- Every connection to the control plane now begins with a Noise handshake, and control requests no longer use a bearer token. HTTP clients such as curl cannot call the control plane directly; `twcore call` sends a request through the handshake.
- Client adoption, MCP editing and the client configuration scan moved to ThinkWatch Lite, which runs on the machine where those files are. The `twcore scan` and `twcore clients` subcommands and the related control-plane endpoints and events are removed; `POST /clients/{id}/key` remains.

**Control key.** `listen.control.key` in `config.yaml` holds the key, and `twcore serve` adds one to a configuration that has none. Every control transport (the unix socket, the loopback port on Windows and the remote port) starts with a `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` handshake keyed by it, and a wrong key is refused before any HTTP. `twcore control-key` prints the key; `--rotate` replaces it and closes the connections made with the previous one. The key is masked in `GET /config`, in the configuration history and in diagnostic bundles, and it cannot be changed through the control plane.

**Remote control port.** `listen.control.remote` opens a TCP port for ThinkWatch Lite on another machine, in addition to the local channel. `twcore init` writes the section disabled, with a random port from 20000–32000. `twcore remote enable [--bind B] [--port N] [--allow CIDR]`, `twcore remote disable` and `twcore remote show` change and report it, and a running core applies a change within a second. A source outside `allow_from` (the private ranges by default; loopback is not added automatically) is closed before the handshake, and a source with five failed handshakes within 60 seconds is ignored for 60 seconds. Remote connections cannot shut core down, download diagnostics or change `listen.control`.

**Server deployment.** `scripts/install.sh` installs `twcore` on Linux (x86_64, aarch64) as a systemd service, with a `thinkwatch` system user, data in `/var/lib/thinkwatch` and environment variables in `/etc/thinkwatch/env`; it checks the SHA-256 and never starts or restarts the service. Releases now include `twcore-<target>.tar.gz` for both Linux targets, holding the binary, `twcore.service` and `LICENSE`. `twcore upgrade [--check] [--restart] [--version X.Y.Z]` replaces a separately installed `twcore` with a release from GitHub after checking its SHA-256 and running it once; it does not replace the copy inside ThinkWatch Lite, which the app updates itself. The guide is [docs/server.md](https://github.com/ThinkWatchProject/ThinkWatch-Core/blob/main/docs/server.md).

**Configuration reference.** [docs/config.md](https://github.com/ThinkWatchProject/ThinkWatch-Core/blob/main/docs/config.md) (and `docs/config.zh-CN.md`) describes every section and field of `config.yaml`. The field tables and the lists of built-in rules are generated from the code, and a test fails when the manual and the code disagree.

**Fix.** On Linux, changing `listen.gateway.bind` between `all` and a single address on the same port now takes effect without a restart; it used to be reported as a port in use by another program.
23 changes: 23 additions & 0 deletions release-notes/0.48.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
This release reports more of what core knows about each request: the session and the route from the moment a request starts, a snapshot of running requests that a client can replay, and how often each route and rule was used. It also names the account signed in on a ChatGPT account upstream, counts the requests in each cost group that have no price or no usage, and gives the security log totals for the whole window.

**Upgrade notes**

- The control-plane protocol version (`CONTROL_API_VERSION`) is now 21. ThinkWatch Lite connects only to a core with the same protocol version, so a server has to run the core version that the app includes. ThinkWatch Lite 2026.9.16 includes 0.47.0 (protocol 20) and does not connect to 0.48.0: a server used with it stays on 0.47.0 until the app is updated to a release that includes 0.48.0. `sudo twcore upgrade --version 0.47.0 --restart` switches a server back to 0.47.0.
- The format of the request history changed (request store schema 20). On its first start, 0.48.0 replaces the request history and the stored request and response bodies of an earlier version with an empty store; the configuration is kept. Switching back to an earlier version empties it again.
- `GET /in-flight` returns an object, `InFlight { now_ms, requests: [{ id, events }] }`, instead of an array of start events. `RequestStarted.session_fp` is replaced by `session`. `ChatgptUsage.email` and `ChatgptUsage.plan` are removed; the signed-in account is in `ProviderView.oauth.account`.

**Session and route from the start.** The gateway assigns a request to a session when the request starts (the same conversation, within 30 minutes of its previous request), and `RequestStarted` carries that session id, the one the request is stored under. The first routing phase now finishes before the start event, so `RequestStarted` also carries the route, the rule, the strategy group and the rules that rewrote the request. `RequestRouted` carries the final record, including rewrites from the second phase and `denied_by` for a rule that denied the request there. The stored routing record has the same fields and is written from the start event, so a request that ends before routing completes still shows the route and rule it matched.

**Requests a rule decided.** A request that a rule denies in the first routing phase, or that none of the upstreams a rule chose can serve, used to produce no events and no stored row. It now produces start, routed and failed events and a row, and counts among the requests and failures in `/summary`; a denial in the second phase now also produces the routed event. Requests that fail before a rule decides (authentication, model admission, no matching rule) are still not recorded.

**Live views.** `GET /in-flight` returns core's clock and, for each running request, the events seen so far in order, so that a client connecting while requests are running can replay them and compute elapsed time from core's timestamps. The running requests in `GET /live` add `elapsed_ms`, `session`, `route`, `rule`, `group` and `upstream`.

**Rule hit counts.** `GET /summary/routes` reports, for a time window (today by default), each route's requests, failures and last request, and for each rule the requests it decided, the requests it applied to, their failures and the last one. The counts come from the stored routing records, not from the current configuration.

**Cost groups and the security log.** Every group returned by `GET /summary/buckets/by` reports `unpriced_requests` and `no_usage_requests`, so a group with no cost can be told apart as unpriced, missing usage or free. `GET /security/events` adds `total` and `by_outcome` (recorded, replaced, cut, blocked): the counts for everything the query's guard and time window match, the same on every page.

**ChatGPT account upstreams.** `ProviderView.oauth.account` gives the email and plan of the account signed in on a ChatGPT account upstream. Both are read from the access token core already keeps with the credential: nothing is requested from the network, nothing new is stored, and the account and user ids in the token are not exposed. The plan is reported as the backend names it, including plans that core does not list.

**Releases.** A release is now published by a single job once every platform has been built and each file matches its SHA-256. Previously the first platform to finish created the release, and made it the latest, while the others were still building, and each build job added another copy of the release notes. The server guide, [docs/server.md](https://github.com/ThinkWatchProject/ThinkWatch-Core/blob/main/docs/server.md), states that ThinkWatch Lite keeps a remote connection's key in a private file in its data directory, and applies to the app on macOS, Windows and Linux. The crates carry homepage and documentation links, and `twcore --help` begins with the binary's description.

**Tests.** Tests no longer hand core a port that was just released, which could make tests that bind ports, such as those of the remote control port, fail intermittently on Linux.
Loading
Loading