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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

- Add a per-account drop-system-prompt switch (on by default, WorkBuddy accounts): caller system prompts are stripped before provider-native chat so upstream content screening no longer rejects them
- Treat upstream content-screening rejections as request-level errors: they return 400 without failing over to other accounts or putting the account into cooldown
- Managed update now jumps directly to the latest stable release instead of advancing one release at a time; the console System page lists every intermediate version the update passes over

### 中文

- 新增账号级「丢弃系统提示词」开关(默认开启,WorkBuddy 账号):请求发出前剥离调用方系统提示词,避免被上游内容审核拒绝
- 上游内容审核拒绝改按请求级错误处理:直接返回 400,不再向其他账号无谓切换,也不给账号打冷却
- 管理更新改为直接升级到最新稳定版本,不再逐版本前进;控制台 System 页会列出更新经过的全部中间版本

## 0.2.11 - 2026-08-27

Expand Down
3 changes: 2 additions & 1 deletion deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,8 @@ restores both the previous image and the pre-update SQLite snapshot, then pins
snapshots are retained.

The updater remains unavailable for development builds without a semantic
version. The System page only offers the immediate next stable release.
version. The System page updates directly to the latest stable release and
lists the intermediate versions it passes over.

The updater API currently reports protocol version `1`. Protocol `0` remains
temporarily accepted for bootstrap compatibility; unknown versions fail closed.
Expand Down
2 changes: 1 addition & 1 deletion docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ Sub2API-style SQLite account registry while preserving the working Qoder executi

### H6 safe managed update

- [x] Expose the build version and select only the immediate next stable GitHub release
- [x] Expose the build version and update directly to the latest stable GitHub release
- [x] Delegate Docker replacement to a host updater over Unix Socket on Linux or authenticated loopback HTTP on macOS/Windows
- [x] Pause new API traffic, drain in-flight requests, and snapshot SQLite before replacement
- [x] Preserve and verify the existing /data mount across container recreation
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type UpdateAgentStatus = {
export type SystemUpdateInfo = {
current_version: string
next_version?: string
skipped_versions?: string[]
has_update: boolean
managed: boolean
cached: boolean
Expand Down
14 changes: 8 additions & 6 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,15 @@ export const messages: Record<Lang, Dict> = {
navSystem: 'System',
sidebarFoot: 'CLI login-state gateway. More providers later.',
systemUpdateTitle: 'Managed update',
systemUpdateLead: 'Move one stable release at a time. The updater snapshots SQLite before replacing the container.',
systemUpdateLead: 'Updates directly to the latest stable release. The updater snapshots SQLite before replacing the container.',
checkUpdates: 'Check updates',
versionUpdate: 'Version update',
nextVersionOnly: 'Only the immediate next stable release is allowed.',
latestVersionHint: 'Updates go directly to the latest stable release.',
updateAvailable: 'Update available',
upToDate: 'Up to date',
currentVersion: 'Current version',
nextVersion: 'Next version',
latestVersion: 'Latest version',
skippedBetweenLabel: 'Versions in between',
releaseNotes: 'Release notes',
updateNoNotes: 'No release notes were provided.',
updaterReady: 'Host updater connected',
Expand Down Expand Up @@ -381,14 +382,15 @@ export const messages: Record<Lang, Dict> = {
navSystem: '系统更新',
sidebarFoot: 'CLI 登录态网关,后续扩展更多提供方。',
systemUpdateTitle: '管理更新',
systemUpdateLead: '每次只更新到下一个稳定版本;替换容器前会先为 SQLite 创建快照。',
systemUpdateLead: '一次性更新到最新稳定版本;替换容器前会先为 SQLite 创建快照。',
checkUpdates: '检查更新',
versionUpdate: '版本更新',
nextVersionOnly: '只允许更新到紧邻的下一个稳定版本。',
latestVersionHint: '更新会直接升级到最新稳定版本。',
updateAvailable: '有可用更新',
upToDate: '已是最新版本',
currentVersion: '当前版本',
nextVersion: '下一版本',
latestVersion: '最新版本',
skippedBetweenLabel: '中间经过的版本',
releaseNotes: '版本说明',
updateNoNotes: '该版本暂无说明。',
updaterReady: '宿主机更新器已连接',
Expand Down
23 changes: 21 additions & 2 deletions frontend/src/pages/SystemPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export function SystemPage() {
<div className="flex items-center justify-between gap-3 border-b border-[var(--app-line)] px-5 py-4">
<div>
<h3 className="font-semibold tracking-[-0.015em]">{t('versionUpdate')}</h3>
<p className="mt-0.5 text-xs text-[var(--app-faint)]">{t('nextVersionOnly')}</p>
<p className="mt-0.5 text-xs text-[var(--app-faint)]">{t('latestVersionHint')}</p>
</div>
<Chip size="sm" variant="soft" color={info?.has_update ? 'warning' : 'success'}>
{info?.has_update ? t('updateAvailable') : t('upToDate')}
Expand All @@ -123,11 +123,30 @@ export function SystemPage() {
<ArrowCircleUp size={18} />
</div>
<div className="border-t border-[var(--app-line)] p-4 sm:border-t-0">
<div className="text-xs font-medium text-[var(--app-faint)]">{t('nextVersion')}</div>
<div className="text-xs font-medium text-[var(--app-faint)]">{t('latestVersion')}</div>
<div className="mono mt-2 text-lg font-semibold">{info?.next_version || '—'}</div>
</div>
</div>

{info?.skipped_versions?.length ? (
<div className="mt-4 rounded-lg border border-[var(--app-line)] bg-[var(--app-surface-muted)] px-4 py-3">
<div className="text-xs font-medium text-[var(--app-muted)]">{t('skippedBetweenLabel')}</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<span className="mono text-xs text-[var(--app-faint)]">{info.current_version}</span>
{info.skipped_versions.map((version) => (
<span key={version} className="mono flex items-center gap-1.5 text-xs text-[var(--app-faint)]">
<span aria-hidden>→</span>
<span className="rounded border border-[var(--app-line)] bg-[var(--app-surface)] px-1.5 py-0.5">{version}</span>
</span>
))}
<span className="mono flex items-center gap-1.5 text-xs font-semibold text-[var(--app-ink)]">
<span aria-hidden>→</span>
<span className="rounded border border-[var(--app-line)] bg-[var(--app-surface)] px-1.5 py-0.5">{info.next_version}</span>
</span>
</div>
</div>
) : null}

<div className="mt-5 border-t border-[var(--app-line)] pt-4">
<div className="text-xs font-medium text-[var(--app-muted)]">{t('releaseNotes')}</div>
<p className="mt-2 max-h-32 overflow-y-auto whitespace-pre-wrap text-xs leading-5 text-[var(--app-faint)]">{releaseBody}</p>
Expand Down
16 changes: 9 additions & 7 deletions internal/update/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ type ReleaseSource interface {
}

type Info struct {
CurrentVersion string `json:"current_version"`
NextVersion string `json:"next_version,omitempty"`
HasUpdate bool `json:"has_update"`
Managed bool `json:"managed"`
Cached bool `json:"cached"`
Warning string `json:"warning,omitempty"`
Release *Release `json:"release,omitempty"`
CurrentVersion string `json:"current_version"`
NextVersion string `json:"next_version,omitempty"`
SkippedVersions []string `json:"skipped_versions,omitempty"`
HasUpdate bool `json:"has_update"`
Managed bool `json:"managed"`
Cached bool `json:"cached"`
Warning string `json:"warning,omitempty"`
Release *Release `json:"release,omitempty"`
}

type Checker struct {
Expand Down Expand Up @@ -68,6 +69,7 @@ func (c *Checker) Check(ctx context.Context, force bool) (Info, error) {
if next, ok := SelectNextRelease(currentVersion, releases); ok {
info.HasUpdate = true
info.NextVersion = next.TagName
info.SkippedVersions = UpgradePath(currentVersion, next.TagName, releases)
info.Release = &next
}
c.mu.Lock()
Expand Down
11 changes: 7 additions & 4 deletions internal/update/checker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ func (s *releaseSourceStub) ListReleases(context.Context) ([]Release, error) {
return s.releases, s.err
}

func TestCheckerReturnsOnlyImmediateNextRelease(t *testing.T) {
func TestCheckerTargetsLatestReleaseAndReportsSkippedVersions(t *testing.T) {
source := &releaseSourceStub{releases: []Release{
{TagName: "v0.2.4", Name: "far"},
{TagName: "v0.2.4", Name: "latest"},
{TagName: "v0.2.2", Name: "next"},
{TagName: "v0.2.3", Name: "middle"},
}}
Expand All @@ -28,10 +28,13 @@ func TestCheckerReturnsOnlyImmediateNextRelease(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !info.Managed || !info.HasUpdate || info.NextVersion != "v0.2.2" {
if !info.Managed || !info.HasUpdate || info.NextVersion != "v0.2.4" {
t.Fatalf("info = %+v", info)
}
if info.Release == nil || info.Release.Name != "next" {
if len(info.SkippedVersions) != 2 || info.SkippedVersions[0] != "v0.2.2" || info.SkippedVersions[1] != "v0.2.3" {
t.Fatalf("skipped = %v", info.SkippedVersions)
}
if info.Release == nil || info.Release.Name != "latest" {
t.Fatalf("release = %+v", info.Release)
}
}
Expand Down
6 changes: 5 additions & 1 deletion internal/update/github_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ func TestGitHubReleaseSourcePaginatesAllReleases(t *testing.T) {
t.Fatalf("releases = %d, last = %+v", len(releases), releases[len(releases)-1])
}
next, ok := SelectNextRelease("v0.2.1", releases)
if !ok || next.TagName != "v0.2.2" {
if !ok || next.TagName != "v0.2.102" {
t.Fatalf("next = %+v, ok = %v", next, ok)
}
path := UpgradePath("v0.2.1", next.TagName, releases)
if len(path) != 100 || path[0] != "v0.2.2" || path[len(path)-1] != "v0.2.101" {
t.Fatalf("path len = %d, first = %v, last = %v", len(path), path[0], path[len(path)-1])
}
}
58 changes: 48 additions & 10 deletions internal/update/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,7 @@ func SelectNextRelease(current string, releases []Release) (Release, bool) {
if err != nil {
return Release{}, false
}
type candidate struct {
release Release
version Version
}
candidates := make([]candidate, 0, len(releases))
candidates := make([]Release, 0, len(releases))
for _, release := range releases {
if release.Draft || release.Prerelease {
continue
Expand All @@ -79,15 +75,57 @@ func SelectNextRelease(current string, releases []Release) (Release, bool) {
if err != nil || version.Compare(currentVersion) <= 0 {
continue
}
candidates = append(candidates, candidate{release: release, version: version})
release.TagName = version.String()
candidates = append(candidates, release)
}
if len(candidates) == 0 {
return Release{}, false
}
sort.SliceStable(candidates, func(i, j int) bool {
return candidates[i].version.Compare(candidates[j].version) < 0
left, _ := ParseVersion(candidates[i].TagName)
right, _ := ParseVersion(candidates[j].TagName)
return left.Compare(right) < 0
})
next := candidates[0].release
next.TagName = candidates[0].version.String()
return next, true
return candidates[len(candidates)-1], true
}

// UpgradePath lists every stable release strictly between current and target,
// in ascending order, so the console can show what a direct update passes over.
// Versions the release list does not cover are silently skipped.
func UpgradePath(current, target string, releases []Release) []string {
currentVersion, err := ParseVersion(current)
if err != nil {
return nil
}
targetVersion, err := ParseVersion(target)
if err != nil || targetVersion.Compare(currentVersion) <= 0 {
return nil
}
intermediate := make([]Release, 0, len(releases))
for _, release := range releases {
if release.Draft || release.Prerelease {
continue
}
version, err := ParseVersion(release.TagName)
if err != nil {
continue
}
if version.Compare(currentVersion) > 0 && version.Compare(targetVersion) < 0 {
release.TagName = version.String()
intermediate = append(intermediate, release)
}
}
if len(intermediate) == 0 {
return nil
}
sort.SliceStable(intermediate, func(i, j int) bool {
left, _ := ParseVersion(intermediate[i].TagName)
right, _ := ParseVersion(intermediate[j].TagName)
return left.Compare(right) < 0
})
path := make([]string, 0, len(intermediate))
for _, release := range intermediate {
path = append(path, release.TagName)
}
return path
}
35 changes: 32 additions & 3 deletions internal/update/version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package update

import "testing"

func TestSelectNextReleaseUsesImmediateStableVersion(t *testing.T) {
func TestSelectNextReleaseTargetsLatestStableVersion(t *testing.T) {
releases := []Release{
{TagName: "v0.2.4"},
{TagName: "v0.2.2"},
Expand All @@ -15,8 +15,37 @@ func TestSelectNextReleaseUsesImmediateStableVersion(t *testing.T) {
if !ok {
t.Fatal("expected a next release")
}
if next.TagName != "v0.2.2" {
t.Fatalf("next release = %q, want v0.2.2", next.TagName)
if next.TagName != "v0.2.4" {
t.Fatalf("next release = %q, want v0.2.4", next.TagName)
}
}

func TestSelectNextReleaseSkipsOlderVersions(t *testing.T) {
releases := []Release{{TagName: "v0.1.9"}, {TagName: "v0.2.1"}, {TagName: "v0.2.2"}}

if _, ok := SelectNextRelease("v0.2.2", releases); ok {
t.Fatal("no release is newer than v0.2.2")
}
}

func TestUpgradePathListsIntermediateStableVersions(t *testing.T) {
releases := []Release{
{TagName: "v0.2.3"},
{TagName: "v0.2.2"},
{TagName: "v0.2.4"},
{TagName: "v0.2.3-rc1"},
{TagName: "v0.1.9"},
}

path := UpgradePath("v0.2.1", "v0.2.4", releases)
if len(path) != 2 || path[0] != "v0.2.2" || path[1] != "v0.2.3" {
t.Fatalf("path = %v, want [v0.2.2 v0.2.3]", path)
}
if path := UpgradePath("v0.2.3", "v0.2.4", releases); path != nil {
t.Fatalf("adjacent path = %v, want nil", path)
}
if path := UpgradePath("dev", "v0.2.4", releases); path != nil {
t.Fatalf("development path = %v, want nil", path)
}
}

Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions internal/webui/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-BC8fNei-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DYditYli.css">
<script type="module" crossorigin src="/assets/index-tCNC2HEM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CWLKwVem.css">
</head>
<body>
<div id="root"></div>
Expand Down
Loading