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 @@ -10,12 +10,14 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.
- Show a compact version history on the System page: one release open at a time, with notes and restore for the three previous versions
- Use plain update copy on the System page: download the update, tap Update now, then refresh after a short countdown
- Explain Models-page defaults: Trae Max is a larger context window, and reasoning intensity is a fallback when the request omits it
- Skip WorkBuddy check-in for the rest of the local day after a success or already-checked-in result, and treat HTTP 400 “already checked in” as that result instead of a failure

### 中文

- 系统页用折叠列表展示近期版本:一次只展开一条,可看说明,并可恢复到最近三个旧版本
- 系统页更新文案改为普通说法:先下载更新,再点立即更新,完成后倒计时刷新页面
- 模型页补充说明:Trae 的 Max 是更大上下文,推理强度只是请求未指定时的默认档,不会锁死每次调用
- 当天本地日历日已签到成功或确认「已签到」后,不再重复打上游、不再写入签到记录;HTTP 400 的「今天已签到」按已签到处理,不再记成失败

## 0.3.1 - 2026-09-07

Expand Down
35 changes: 35 additions & 0 deletions internal/accounts/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -1455,6 +1455,12 @@ func (m *Manager) CheckinAccount(ctx context.Context, accountID string) (Account
if account.Provider != "workbuddy" {
return account, fmt.Errorf("check-in is only available for WorkBuddy accounts")
}
if checkedInLocalDay(account.LastCheckinAt, account.LastCheckinStatus, time.Now()) {
if adapter, ok := m.providers.Get("workbuddy"); ok && adapter.Prober != nil {
m.fetchProviderQuota(ctx, accountID, adapter.Prober)
}
return m.store.Get(ctx, accountID)
}
msg, checkErr := m.workbuddy.DailyCheckin(ctx, accountID)
if msg == "" && checkErr != nil {
msg = checkErr.Error()
Expand Down Expand Up @@ -1499,12 +1505,41 @@ func (m *Manager) CheckinOptedIn(ctx context.Context) {
if account.Provider != "workbuddy" || !account.Enabled || !account.WorkBuddyAutoCheckin {
continue
}
if checkedInLocalDay(account.LastCheckinAt, account.LastCheckinStatus, time.Now()) {
continue
}
if _, err := m.CheckinAccount(ctx, account.ID); err != nil {
log.Printf("workbuddy checkin account_id=%s op=checkin err=%v", account.ID, err)
}
}
}

// checkedInLocalDay is true when the last recorded check-in is success or
// already on the process-local calendar day. Error rows do not skip, so the
// evening slot can retry a morning miss.
func checkedInLocalDay(at, status string, now time.Time) bool {
switch strings.TrimSpace(status) {
case "success", "already":
default:
return false
}
raw := strings.TrimSpace(at)
if raw == "" {
return false
}
parsed, err := time.Parse(time.RFC3339Nano, raw)
if err != nil {
parsed, err = time.Parse(time.RFC3339, raw)
if err != nil {
return false
}
}
loc := now.Location()
localAt := parsed.In(loc)
localNow := now.In(loc)
return localAt.Year() == localNow.Year() && localAt.YearDay() == localNow.YearDay()
}

// KeepaliveWorkBuddy refreshes tokens for enabled WorkBuddy accounts.
// When onlyOptIn is true, only auto-checkin accounts are touched (scheduled
// path). Manual/batch keepalive can pass false.
Expand Down
134 changes: 134 additions & 0 deletions internal/accounts/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1384,3 +1384,137 @@ func TestEnsureModelCatalogsDoesNotBlock(t *testing.T) {
t.Fatal("EnsureModelCatalogs blocked >2s; must be async/non-blocking")
}
}

type fakeCheckinMaintainer struct {
calls int
msg string
err error
}

func (f *fakeCheckinMaintainer) DailyCheckin(context.Context, string) (string, error) {
f.calls++
return f.msg, f.err
}

func (f *fakeCheckinMaintainer) Keepalive(context.Context, string) error { return nil }

type fakeAlreadyCheckedInError struct{ msg string }

func (e fakeAlreadyCheckedInError) Error() string { return e.msg }
func (fakeAlreadyCheckedInError) AlreadyCheckedIn() bool { return true }

func TestCheckedInLocalDay(t *testing.T) {
loc := time.FixedZone("CST", 8*3600)
now := time.Date(2026, 9, 7, 21, 0, 0, 0, loc)
today := time.Date(2026, 9, 7, 0, 1, 28, 0, loc).UTC().Format(time.RFC3339Nano)
yesterday := time.Date(2026, 9, 6, 17, 0, 2, 0, loc).UTC().Format(time.RFC3339Nano)
if !checkedInLocalDay(today, "success", now) {
t.Fatal("same-day success must skip")
}
if !checkedInLocalDay(today, "already", now) {
t.Fatal("same-day already must skip")
}
if checkedInLocalDay(today, "error", now) {
t.Fatal("same-day error must retry")
}
if checkedInLocalDay(yesterday, "success", now) {
t.Fatal("yesterday success must not skip")
}
if checkedInLocalDay("", "success", now) {
t.Fatal("empty timestamp must not skip")
}
}

func TestCheckinOptedInSkipsSameDaySuccess(t *testing.T) {
ctx := context.Background()
store, err := OpenStore(filepath.Join(t.TempDir(), "qoder.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
account, err := store.Create(ctx, CreateAccount{
Name: "wb", Provider: "workbuddy", Region: "cn", Enabled: true,
WorkBuddyAutoCheckin: boolPtr(true),
})
if err != nil {
t.Fatal(err)
}
if err := store.RecordCheckin(ctx, account.ID, "success", "ok", time.Now().UTC()); err != nil {
t.Fatal(err)
}
ops := &fakeCheckinMaintainer{msg: "ok"}
manager := NewManager(ManagerConfig{DataDir: t.TempDir()}, store, &fakeStarter{})
defer manager.Close()
manager.SetWorkBuddy(ops)
manager.CheckinOptedIn(ctx)
if ops.calls != 0 {
t.Fatalf("scheduled check-in must skip same-day success, calls=%d", ops.calls)
}
records, err := store.ListCheckinRecords(ctx, account.ID, 20)
if err != nil || len(records) != 1 {
t.Fatalf("records=%+v err=%v", records, err)
}
}

func TestCheckinOptedInRetriesSameDayError(t *testing.T) {
ctx := context.Background()
store, err := OpenStore(filepath.Join(t.TempDir(), "qoder.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
account, err := store.Create(ctx, CreateAccount{
Name: "wb", Provider: "workbuddy", Region: "cn", Enabled: true,
WorkBuddyAutoCheckin: boolPtr(true),
})
if err != nil {
t.Fatal(err)
}
if err := store.RecordCheckin(ctx, account.ID, "error", "timeout", time.Now().UTC()); err != nil {
t.Fatal(err)
}
ops := &fakeCheckinMaintainer{msg: "ok"}
manager := NewManager(ManagerConfig{DataDir: t.TempDir()}, store, &fakeStarter{})
defer manager.Close()
manager.SetWorkBuddy(ops)
manager.CheckinOptedIn(ctx)
if ops.calls != 1 {
t.Fatalf("same-day error must retry, calls=%d", ops.calls)
}
}

func TestCheckinAccountRecordsFirstAlreadyThenSkips(t *testing.T) {
ctx := context.Background()
store, err := OpenStore(filepath.Join(t.TempDir(), "qoder.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
account, err := store.Create(ctx, CreateAccount{
Name: "wb", Provider: "workbuddy", Region: "cn", Enabled: true,
})
if err != nil {
t.Fatal(err)
}
ops := &fakeCheckinMaintainer{msg: "今天已签到,请明天再来", err: fakeAlreadyCheckedInError{msg: "今天已签到,请明天再来"}}
manager := NewManager(ManagerConfig{DataDir: t.TempDir()}, store, &fakeStarter{})
defer manager.Close()
manager.SetWorkBuddy(ops)
updated, err := manager.CheckinAccount(ctx, account.ID)
if err != nil {
t.Fatal(err)
}
if updated.LastCheckinStatus != "already" || ops.calls != 1 {
t.Fatalf("first already: status=%q calls=%d", updated.LastCheckinStatus, ops.calls)
}
if _, err := manager.CheckinAccount(ctx, account.ID); err != nil {
t.Fatal(err)
}
if ops.calls != 1 {
t.Fatalf("second already must skip upstream, calls=%d", ops.calls)
}
records, err := store.ListCheckinRecords(ctx, account.ID, 20)
if err != nil || len(records) != 1 || records[0].Status != "already" {
t.Fatalf("records=%+v err=%v", records, err)
}
}
26 changes: 22 additions & 4 deletions internal/providers/workbuddy/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,9 @@ func (c *Client) DailyCheckin(ctx context.Context, accountID string) (string, er
if classified.Kind == accounts.KindAuth {
return "", fmt.Errorf("workbuddy checkin session dead: re-login required")
}
if msg, ok := alreadyCheckedInMessage(status, body); ok {
return msg, AlreadyCheckedInError{Msg: msg}
}
if status >= 300 {
return "", fmt.Errorf("checkin status=%d: %s", status, text)
}
Expand All @@ -726,10 +729,6 @@ func (c *Client) DailyCheckin(ctx context.Context, accountID string) (string, er
}
msg := strings.TrimSpace(env.Msg)
if env.Code != 0 {
lower := strings.ToLower(msg)
if strings.Contains(msg, "已签到") || (strings.Contains(lower, "already") && strings.Contains(lower, "check")) {
return msg, AlreadyCheckedInError{Msg: msg}
}
if msg == "" {
msg = fmt.Sprintf("checkin code=%d", env.Code)
}
Expand All @@ -741,6 +740,25 @@ func (c *Client) DailyCheckin(ctx context.Context, accountID string) (string, er
return msg, nil
}

func alreadyCheckedInMessage(status int, body []byte) (string, bool) {
var env envelope
msg := ""
if json.Unmarshal(body, &env) == nil {
msg = strings.TrimSpace(env.Msg)
if env.Code == 0 && status < 300 {
return "", false
}
}
if msg == "" {
msg = strings.TrimSpace(string(body))
}
lower := strings.ToLower(msg)
if strings.Contains(msg, "已签到") || (strings.Contains(lower, "already") && strings.Contains(lower, "check")) {
return msg, true
}
return "", false
}

func retryDailyCheckin(ctx context.Context, err error, status, attempt int) bool {
if attempt >= len(dailyCheckinRetryDelays) || ctx.Err() != nil {
return false
Expand Down
20 changes: 20 additions & 0 deletions internal/providers/workbuddy/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,26 @@ func TestDailyCheckinAlreadyCheckedIn(t *testing.T) {
}
}

func TestDailyCheckinAlreadyCheckedInHTTP400(t *testing.T) {
client, store := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{"code": 10001, "msg": "今天已签到,请明天再来", "requestId": "2647945e-7f7e-44cc-9e34-737dd078119e"})
}))
payload, _ := json.Marshal(Credential{
AccessToken: "at", RefreshToken: "rt", ExpiresAt: 4102444800, Domain: DomainCN, UID: "u1",
})
_ = store.SaveCredentialPayload(context.Background(), "acc1", CredentialFormat, payload)
msg, err := client.DailyCheckin(context.Background(), "acc1")
var already AlreadyCheckedInError
if !errors.As(err, &already) {
t.Fatalf("HTTP 400 already-checked-in must not be a generic failure, err=%v", err)
}
if msg != "今天已签到,请明天再来" {
t.Fatalf("msg=%q", msg)
}
}

func TestDailyCheckinRetriesTransientFailures(t *testing.T) {
var calls atomic.Int32
client, store := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading