Commit Graph
42 Commits
Author SHA1 Message Date
forever e961836fb0 usage, gateway, gui, provider, cli: the tests pass when a run crosses midnight, and a usage answer reads the clock once (#1046)
* usage: the usage tests pass at any time of day, midnight included

The usage tests stamped their calls with the machine's time and then
asked for today, 7 or 30 days, or a cached answer, which the code
worked out from its own time.Now a moment later. A minute after
midnight a call made "a minute ago" was yesterday's, and when midnight
fell between the test's read and the code's, the call and the period
were of two days. magpie usage, the TUI's Usage page, the two Codex
cache-write tests and the computers' shared days failed in the first
minutes after midnight (the TUI's after midnight UTC, the zone its
package's tests run in since 1caea638), and 28 more failed when
midnight fell inside them.

usage.Clock is now the time every period is read by: the start of
today, 7 days and 30 days (LedgerOf, Summarize, Direct, QueryPage), the
hours or days of the chart (LedgerSeries, QueryPage), the day a cached
summary or page is for, the time Append gives a call that has none,
the days davsync shares and the date in an exported CSV's name. It is
time.Now, as before, and a variable for the tests, like LogCalls. Each
test that built its calls from the time now holds Clock at noon on
2026-09-30 (holdClock; holdUsageClock in gui and the CLI; three lines
in tui and davsync) and stamps its calls from that instant, among them
TestReasoningReplyTimedByItsAnswer, whose timeline rows 117b6c7f made
at time.Now. TestPeriodsReadTheClock checks that every one of these
reads goes through Clock. TestQueryPageSourceAndIdentityInvalidation is
held too; that it fails when run alone is fixed apart from this, in
#1045.

Seven held tests could not fail at midnight before, and are held so
that the tests read one clock: TestUsageSameSizeAndMtimeRewrite (All's
totals), TestDirectCountsOnlySessionFileCalls (30 days around its own
noon), TestQueryPageIOOutsideCacheLock (a file two days old), gui's
TestUsageLedgerDayRoutes (7 days hold its calls from either day),
TestLedgerSingleSnapshotAndLocalSession and
TestLedgerNamesModelAtProvider (their calls are stamped after the code
reads the time), and TestUsageRawAndPricedChunksShareBudget, which
fails only when the midnight in it starts a new week of All's chart, on
a Sunday night; at.sh sets the time of day and not the weekday, so that
one was not shown.

Verified with at.sh, a scratch script that runs a test binary under a
zone made with zic, so that the local time of day is the one asked for
as the run starts (HOME in a sandbox, Go's caches pinned). The runs were
first made on c5a54bc7, and those marked (698da36f) again on 698da36f,
with the same results. The tui package runs in UTC since 1caea638, a
zone at.sh doesn't move, so its runs on 698da36f used copies of both
trees whose TestMain keeps the zone TZ sets.

Before, at 00:00:30, 00:01:30 and 12:00:00 (698da36f):
  TestUsageShowsCallsNotThroughMagpie, 00:00:30:
    usage_cli_test.go:95: no calls today · route an agent through
    magpie and its usage shows up here
  00:01:30: "5 tokens today · 1 call · no price", without the calls
  not through magpie; TestUsagePageShowsCallsNotThroughMagpie (tui)
  fails the same two ways at usage_test.go:138
  --- FAIL: TestCodexCacheWriteNotInInput (00:00:30)
    codex_cache_write_test.go:104: rows []
  --- FAIL: TestCodexCacheWriteMatchesGatewayCall (00:01:30)
    codex_cache_write_test.go:73: filter {...}: 0 rows, 0 calls, want
    the gateway's 2: []
  --- FAIL: TestUsageSharedBetweenComputers (00:01:30)
    usage_share_test.go:134: a's days on the server:
    [...-2026-10-05 ...-2026-10-06 ...-2026-10-07]
  12:00:00: PASS

Midnight inside a test, in scratch copies of the head. Started at
23:59:59.3 with the code's first period read held 1.5s, so that it
reads after midnight what the test stamped before it, 16 usage tests,
3 gui tests and TestUsageCSV FAIL, among them
  ledger_test.go:257: today: 0
  review_test.go:69: chart counted 0
  incremental_log_test.go:202: concurrent append lost
  TestCachedSummaryRemainsCallerOwned: panic: runtime error: index out
  of range [0] with length 0
  usage_test.go:121: second page: {... Period:today Rows:[] ...
  Total:0 ...}
  usage_test.go:311: "": magpie-requests-7d-2026-10-06
Started at 23:59:55 with the test waiting past midnight between two of
its reads, 7 more FAIL:
  request_day_test.go:45: selecting a day changed the chart or its
  legend
  cache_bounds_test.go:84: uncached today summary stale
  growing_rewrite_test.go:71: today summary stale
  cache_bounds_test.go:153: page key changed instead of exercising the
  same cache
  priced_blocks_test.go:55, caller_provider_test.go:129 and :172: page
  differs
TestReasoningReplyTimedByItsAnswer, with a scratch 1.5s sleep before
its LedgerSeries, started at 23:59:59.3 (698da36f):
  usage_test.go:255: part: {Calls:0 Tokens:0 Cost:0 Timed:0 TTFT:0
  DecodeMs:0 DecodeOut:0}

After: the same at.sh runs PASS at 00:00:30, 00:01:30 and 12:00:00
(698da36f); the same scratch copies made of this change PASS, each
test taking 1.5s or 5.2s, so midnight did fall inside it (698da36f for
TestReasoningReplyTimedByItsAnswer's); and every changed test PASS at
23:59:59.9, 00:00:30 and 12:00:00 (698da36f).

Without the change (698da36f): with the reads put back on time.Now, 21
usage tests, 6 gui, 2 CLI, the tui and the davsync test fail, among
them
  --- FAIL: TestPeriodsReadTheClock
    clock_test.go:55: Direct(Today): 0 calls since 2026-10-06 00:00:00
    -0700 PDT, want 1 since 2026-09-30 00:00:00 -0700 PDT
    clock_test.go:63: Summarize(Today) the next day: 1 calls, want 0
  --- FAIL: TestReasoningReplyTimedByItsAnswer
    usage_test.go:255: part: {Calls:0 Tokens:0 ...}
Each of the eleven reads put back alone fails a test:
TestPeriodsReadTheClock for the nine in usage (LedgerSeries:
"clock_test.go:52: LedgerSeries(Today): 0 calls, want 2"; the page's
cache key: "clock_test.go:66: QueryPage(Today) the next day: 1 rows,
want 0"), TestCSVStamp for the CSV's name (usage_test.go:325: "":
magpie-requests-7d-2026-10-06) and TestUsageSharedBetweenComputers for
davsync (usage_share_test.go:224: all computers: 4 rows, 707 in; want
5, 737).

On this commit: go test -tags nogui -count=1 . ./internal/usage
./internal/tui ./internal/davsync ./internal/gui ok; -race -count=20
over the changed tests of each of these packages ok; gofmt -l prints
nothing, and go vet -tags nogui on the five packages finds nothing.

* usage: pages, CSVs and magpie usage asked at midnight show one day

An answer about usage read the time more than once. The Requests page
read it for the day it is kept for, for the period its log and session
files are read over, for the period of its rows and for its chart's
hours; the page from the ledger (QueryPage's path when LogCalls is
set, as in the tests) for its rows and for its chart; Direct for its
period and for its rows; a CSV for its rows and for its name;
magpie usage and the TUI's Usage page for Summarize and then for
Direct. Most of these reads have the log and the session files read
between them, seconds on a large log, and when midnight fell between
two of them the answer was of two days: yesterday's calls listed over
today's empty chart, Direct starting yesterday and counting none of
yesterday's calls, the calls not through magpie left out of magpie
usage and the Usage page (they were the new day's, none yet), and
yesterday's calls saved as magpie-requests-today-<the new day>.csv.

Each answer now reads Clock once and passes that moment down. queryPage
reads it for its key, its sources, its rows and its chart
(buildRequestBlocks takes it); the page from the ledger reads its rows
and its chart at it (LedgerOfAt, ledgerSeriesAt); Direct reads the
ledger at the moment of its period. usage.Summaries(p) is Summarize(p)
and Direct(p) at one moment, and magpie usage and the TUI's Usage page,
when a period is picked and when it is read again, ask for it: one
exported name, the clock left inside usage and one line changed at
each caller, where SummarizeAt and DirectAt would be two names and
each caller would read the clock itself. The CSV download and export
read their rows with the new usage.LedgerOfAt at the moment their file
is named for, which csvStamp now takes; the rows and the name are read
in two packages, so LedgerOfAt is exported. LedgerOf, LedgerSeries,
Summarize, Direct and QueryPage keep their signatures, each reading
Clock once.

The new tests set Clock to read a tenth of a second before midnight
the first time and a tenth of a second after it from then on, set
again for each answer. The midnight is the one that starts 2026-10-01,
local time: a past instant, at midnight because that is what is
tested, and the day before it is the 2026-09-30 the held tests use.
TestAnswerReadsTheClockOnce asks QueryPage(Today) from the indexed log
and from the ledger: its 3 rows, one from a session file written that
day at 23:00, are under that day's chart, and asked again after
midnight the page is the next day's, not the one kept for the day
before; Direct(Today) counts the call and starts that day.
TestUsageAskedAtMidnight (magpie usage today), TestUsagePageAtMidnight
(the TUI's Usage page, today picked and read again) and
TestUsageCSVAtMidnight (download and export) check that the calls
through magpie and those not through it, or the rows and the file's
name, are of the day asked on. TestCSVStamp passes its time.

Verified on the previous commit, at.sh as there. The runs were first
made on c5a54bc7, when the tui package's tests still ran in the local
zone, and those marked (698da36f) again on 698da36f, with the same
results.

Before: the new tests on the previous commit, at any time of day
(at.sh at 12:00:00 too) (698da36f):
  --- FAIL: TestAnswerReadsTheClockOnce
    clock_test.go:131: QueryPage(Today): 0 rows, 0 in a chart from
    2026-10-01 00:00:00 -0700 PDT; want 3 under one from 2026-09-30
    00:00:00 -0700 PDT
    clock_test.go:141: QueryPage(Today): 3 rows, 0 in a chart from
    2026-10-01 ...; want 3 under one from 2026-09-30 ...
    clock_test.go:145: Direct(Today): 0 calls since 2026-09-30
    00:00:00 -0700 PDT, want 1 since 2026-09-30 00:00:00 -0700 PDT
  --- FAIL: TestUsageAskedAtMidnight
    usage_cli_test.go:154: 5 tokens today · 1 call · no price ...,
    with no part for the calls not through magpie
  --- FAIL: TestUsagePageAtMidnight
    usage_test.go:184: today picked: ... (the same)
    usage_test.go:187: read again: ...
  --- FAIL: TestUsageCSVAtMidnight
    usage_test.go:367: download: 200, attachment;
    filename="magpie-requests-today-2026-10-01.csv" with 2 rows
    (<nil>), want magpie-requests-today-2026-09-30.csv with 2
    usage_test.go:378: export: 200
    {"path":"~/Downloads/magpie-requests-today-2026-10-01.csv",
    "rows":1}, want magpie-requests-today-2026-09-30.csv with 1 row

With the real clock, in scratch copies of both trees: scratch tests
held one of Clock's reads 1.5s after it was made, as reading a large
log does (the first; for one of them the page's third, its chart's),
and at.sh started each at 23:59:59.3. Before, all 9 FAIL, among them
  asked 23:59:59.564, answered 00:00:01.068
  Direct(Today): 0 calls since 2026-10-05 00:00:00 -0940 MID, want 1
  since 2026-10-05 00:00:00 -0940 MID
  QueryPage(Today): 2 rows, 0 in a chart from 2026-10-06 ...; want 2
  under one from 2026-10-05 ...
  download: attachment; filename="magpie-requests-today-2026-10-06.csv"
  with a call of 2026-10-05 23:59
After, all 9 PASS, answered at 00:00:00.9 to 00:00:01.0, so midnight
fell inside each, except the chart's: its held read is gone with the
change. A scratch sleep before the chart shows that one: before "2
rows, 0 in a chart from 2026-10-06", after PASS, asked 23:59:59.344
and answered 00:00:00.941. At 12:00:00 all of them PASS in both trees.

After: every changed test PASS with at.sh at 23:59:59.9, 00:00:30 and
12:00:00 (698da36f).

Without the change (698da36f): with the production put back to the
previous commit's (gui/usage.go only so far that TestCSVStamp compiles:
csvStamp takes the time, read apart from the rows as before), go test
-count=1 . ./internal/usage ./internal/tui ./internal/gui fails the four
new tests and no other, with the lines above. Each of the 16 places put
back alone fails one of them: the page's key (clock_test.go:134: "3
rows, 3 in a chart from 2026-09-30 ...; want 0 under one from 2026-10-01
..."), its sources (:131: "2 rows"), its rows (:131: "0 rows"), its
chart (:131), the ledger page's rows and chart (:141), LedgerOfAt (:141,
:145), ledgerSeriesAt (:141), Direct (:145); Summaries, indexedSummary
and magpie usage (usage_cli_test.go:154); Summaries and the TUI's period
key and reload (usage_test.go:184, :187); the CSV's download and export
(usage_test.go:367, :378).

On this commit: go test -tags nogui -count=1 . ./internal/usage
./internal/gui ./internal/tui ./internal/davsync ./internal/gateway ok;
-race -count=20 over the changed tests of each package ok; gofmt -l
prints nothing, and go vet -tags nogui finds nothing.

* gateway: the key-limit tests pass when a run crosses local midnight

A go test run that crossed local midnight could fail the gateway's
key-limit tests. keyLimited and the /v1/magpie/limit endpoint each read
time.Now() when they check a key, and access.Window turns that into a
calendar window (a day from midnight, a week from Monday, a month from
the 1st). The tests seed "yesterday" from their own time.Now() and then
make several calls, so a run that crossed midnight saw its calls split
over two windows: the second call of a 350-token day was let in, or a
restart counted nothing.

The gateway's key-limit path now reads limitClock, a variable for the
tests like ruleClock and idClock, and production behaviour is unchanged
(it is time.Now). TestKeyLimitRefusesBeforeUpstream,
TestKeyLimitHoldsParallelRequests and TestKeyLimitWindowResets pin it
at 2026-09-30 12:00 local and seed "yesterday" from that instant. Calls
are still recorded at the real time, after the pinned one, and a window
counts every call from its start, so they all count in the pinned
window; the "tomorrow's window" check in TestKeyLimitWindowResets stays
on the real now, the first window with none of those calls in it. No
other test sets a key limit with records in its window (the gateway-key
CLI test and the GUI caller-key tests count 0 calls or have no limit).

TestKeyLimitRefusesBeforeUpstream now also checks that the 429's
Retry-After and X-Magpie-Limit-Reset name the next midnight after the
check, and that the limit endpoint's week starts on the pinned
Monday.

Verified with at.sh, a scratch script that runs the test binary under
a zone made with zic, so that the local time of day is the one asked
for as the run starts:
- before, at.sh at 23:59:59 with COUNT=500/300 and FRAC 0.3/0.6/0.8:
  3 of 6 runs of each test failed with
  key_limit_test.go:217: spent key let in
  key_limit_test.go:117: a restart forgot what the key used 200
  key_limit_test.go:121: status &{Period:day Start:2026-10-06 ...
  Calls:0 Tokens:0 ...}
- after, the same 6 runs (COUNT=500, both tests) print PASS, and so do
  ^TestKeyLimit at 12:00:00 and 00:00:00 with COUNT=20.
- go test -tags nogui -count=1 ./internal/gateway: ok (79.5s);
  -race -count=20 -run '^TestKeyLimit' ./internal/gateway: ok.
- gofmt -l prints nothing; go vet -tags nogui ./internal/gateway is
  clean.

The month boundary (TestKeyLimitHoldsParallelRequests) and Monday can't
be reproduced by changing the time of day; the pinned clock covers them
the same way.

Fails without the change (tests pinned, keyLimited back on time.Now):
--- FAIL: TestKeyLimitRefusesBeforeUpstream
    key_limit_test.go:107: headers map[... Retry-After:[80228]
    X-Magpie-Limit-Reset:[2026-10-07T00:00:00-07:00] ...]
and with only the limit endpoint back on time.Now:
--- FAIL: TestKeyLimitRefusesBeforeUpstream
    key_limit_test.go:159: limit endpoint {... "period":"week",
    "start":"2026-10-05T00:00:00-07:00" ...}

* gateway: the routing history tests pass when a run crosses midnight

TestHistoryKeepsDaysAndDropsOld and TestRemoteMagpieGroupHistory took
now := time.Now() and saved routes at now and now+1s. saveRoute files a
route by its own day (r.Time.Local()), so a test started at 23:59:59 put
the +1s route in tomorrow's file and History(today) found one route
less. TestHistoryKeepsSessionAndTokenTiers raced the other clock:
saveRoute prunes by time.Now(), so when the day turned between the
test's time.Now() and the first saveRoute, the prune gzipped "today"
after the first route, the second went to a new .jsonl, and the test's
own pruneHistory gzipped that over the first .gz, losing route 1.

saveRoute now prunes by historyClock (time.Now outside the tests). The
three tests take historyNoon(t), which holds that clock at
2026-09-30 12:00 local and returns it as their now, restored with
t.Cleanup. Every route, day file and prune is then on that one past day.
The fixture can't be a fixed past day without the clock: pruning by
the real today gzips that day's file at once (and, a month on, drops
it as too old).

The other history tests keep the real clock:
TestHistoryRouteOutsideDayLimit writes its day files itself from one
now and the code under test reads no clock, and
TestHistoryRouteDayAndNoLock already uses a fixed day.

Verified with at.sh, a scratch script that sets the local time of day
through a zone made with zic as the run starts:
- before, at.sh ./internal/gateway at 23:59:59:
  --- FAIL: TestHistoryKeepsDaysAndDropsOld
      history_test.go:50: days [{2026-10-06 1} {2026-10-05 1} {2026-10-04 1}]
  --- FAIL: TestRemoteMagpieGroupHistory
      remote_group_served_test.go:144: routes [... only ID 1 ...]
  and, with a 2s sleep after now := time.Now() standing in for the turn
  of the day, --- FAIL: TestHistoryKeepsSessionAndTokenTiers
      history_test.go:22: compressed history lost accounting: [only ID 2]
  12:00:00 control: PASS.
- after: the three tests PASS at 23:59:59, 23:59:59.9 (FRAC=0.9),
  12:00:00, and 23:59:59 with the same 2s sleep; all TestHistory* and
  TestRemoteMagpieGroupHistory PASS at 23:59:59 with -test.count=3.
- with saveRoute pruning by time.Now() again (the fixtures still pinned),
  at any time of day:
  --- FAIL: TestHistoryKeepsSessionAndTokenTiers
      history_test.go:32: compressed history lost accounting: [only ID 2]
- go test -tags nogui -count=1 ./internal/gateway: ok;
  -race -count=20 on the three tests: ok; gofmt -l and
  go vet -tags nogui ./internal/gateway print nothing.

* gateway, davsync: the request archive tests pass when a run crosses UTC midnight

The archive names a call "<date>/<id>" from the call's own start, in
UTC (archiving in archive.go). TestArchiveOn read time.Now().UTC()
before posting and TestRequestArchiveToS3 read it before post(), and
each expected that read's date. A run where UTC midnight (17:00 PDT)
fell between the test's read and the request's start expected the day
before the one the archive used, and failed.

Both tests now take the date from the Time the call carries
(s.Recent()[0].Time, the same start the archive's name is made from),
in UTC, so nothing is read from the clock a second time. No production
code changes. The other archive tests already go by the call's Archive
name, and internal/gui's archive test uses a fixed name; no other test
in internal/gateway, internal/davsync or internal/gui predicts an
archive key or a UTC-dated name from its own clock.

Verified: at.sh only moves the local time of day, and this is a UTC
crossing (UTC midnight was ~16h away), so it was shown in a scratch
copy whose serve starts each request 0.2s after the next UTC midnight,
as a request does when the test read the clock just before it. There
the old tests fail:
  archive_test.go:115: call archive "2026-10-07/000000-f4690de23ee8a859",
  want "2026-10-06/000000-f4690de23ee8a859"
  archive_test.go:93: nothing at
  "team x+y/magpie/archive/2026-10-06/000000-0e74a87816ef6a56.json"
and the new ones pass. Unshifted, under at.sh at local 23:59:59,
00:00:00 and 12:00:00, both pass, as the old ones do at 23:59:59 and
00:00:00 (local time doesn't enter the name). With archiving naming by
start.Format (local) instead of start.UTC().Format, both new tests fail
under at.sh at local 23:00:00, where the local date is a day behind
UTC's:
  --- FAIL: TestArchiveOn ... archive_test.go:116: call archive
  "2026-10-05/082500-7322712bd89f71da", want
  "2026-10-06/082500-7322712bd89f71da"
  --- FAIL: TestRequestArchiveToS3 ... archive_test.go:95: nothing at
  "team x+y/magpie/archive/2026-10-06/082500-3587ab4192da2cd5.json"
and pass at 12:00:00. go test -tags nogui -count=1 ./internal/gateway
./internal/davsync passes in a sandbox HOME; -race -count=20 -run
'^(TestArchiveOn|TestRequestArchiveToS3)$' passes; gofmt -l and go vet
-tags nogui on both packages are clean.

* gui: TestWhatsNewTodayRoute passes when the run crosses local midnight

TestWhatsNewTodayRoute posts {"on":true} to /api/whatsnew/today, which
writes today() to whatsnew-quiet-day, then checks quietToday(), which
compares the file with a second today(). A run that crossed local
midnight between the two read different days and failed with
"on: 204 false". The neighbouring TestWhatsNewQuietAfterRestartToUpdate
already pins today; this test didn't.

The test now pins today to 2026-09-30 (restored with t.Cleanup), so the
post and the check read the same day. The other What's new tests start
from an empty folder, so quietToday finds no file and never reads
today().

Verified: with a temporary 1s sleep between the post and the check, the
unpinned test under at.sh (local time of day set by a zone) started at
local 23:59:59.5 failed with "whatsnew_test.go:237: on: 204 false" and
passed at 12:00:00; pinned, it passes at 23:59:59.5 (same sleep), and,
without the sleep, at 23:59:59, 00:00:00 and 12:00:00. With
setQuietToday writing time.Now().Format("2006-01-02") instead of
today(), the test fails: "--- FAIL: TestWhatsNewTodayRoute ...
whatsnew_test.go:239: on: 204 false". go test -tags nogui -count=1
./internal/gui passes in a sandbox HOME; -race -count=20 -run
'^TestWhatsNewTodayRoute$' passes; gofmt -l and go vet -tags nogui
./internal/gui are clean.

* provider: TestZCodeStartSentAsTheApp passes when the run crosses midnight

A ZCode Start Plan request opens with a context prefix that says
"Today's date is <day>" (zcodeDateReminder), the day zcodeStartRequest
took from its own time.Now(). TestZCodeStartSentAsTheApp sent the
request, then built the expected prefix from a second time.Now(). A run
that crossed midnight between the two failed on "the context prefix",
the request holding the day before.

zcodeStartRequest now takes the time from zcodeClock (a package
variable, time.Now outside tests, as gateway's ruleClock), and the test
pins it to 2026-09-30 12:00 local, restored with t.Cleanup, and expects
"Today's date is 2026-09-30." The test sets TZ=Asia/Shanghai, which
moves time.Local only when nothing has read it yet; a mid-day instant in
time.Local formats to the same day either way. The other calls to
zcodeStartBody in the test check shaping only, not the day.

No other provider test builds a day string from a second clock: the
WorkBuddy credits, daily warm-up and check-in tests pass fixed times to
code that takes now as an argument, and TestQoderCheckinOnTheCard's day
is only carried, not compared.

Verified with at.sh (local time of day set by a zone, run as
-run '^(TestDailyCredits|TestZCodeStartSentAsTheApp)$' so that
time.Local is read before the test's t.Setenv("TZ"), as in a full run;
alone, Local becomes Asia/Shanghai): with a temporary 1s sleep after the
request, the unpinned test started at local 23:59:59.5 failed with
"zcode_client_test.go:195: the context prefix: ... Today's date is
2026-10-05." and passed at 12:00:00; pinned, it passes at 23:59:59.5
(same sleep) and, without the sleep, at 23:59:59, 00:00:00 and 12:00:00.
With zcodeStartRequest back on time.Now(), the pinned test fails:
"--- FAIL: TestZCodeStartSentAsTheApp ... zcode_client_test.go:196: the
context prefix: ... Today's date is 2026-10-06." go test -tags nogui
-count=1 ./internal/provider passes in a sandbox HOME; -race -count=20
-run '^TestZCodeStartSentAsTheApp$' passes; gofmt -l and go vet -tags
nogui ./internal/provider are clean.

* accounts, quota: a window's reset reads against one time per listing; TestQuotaCell passes across midnight

quotaCell, a window in `magpie accounts` and `magpie quota`, read the
clock three times: whether the reset had passed, how long until it, and
ResetClock's label, which names the calendar day ("tomorrow 02:13" or
"02:13"). TestQuotaCell set the reset 2h13m30s from time.Now() and
built the expected label from yet another time.Now(); a run that crossed
local midnight between quotaCell's read and the test's got "tomorrow
02:13" from one and "02:13" from the other and failed.

quotaCell now takes now. accountsCmd and quotaCmd take one time.Now()
just before printing and pass it to every window's cell, so a listing's
windows agree on what "today" is. The test passes a fixed 2026-09-30
12:00 local and expects "↻2h13m 14:13", and for a reset the next day
"↻14h13m tomorrow 02:13"; no zone in tzdata 2026c changes its clock
between those instants, so both read the same in any TZ. resetsCell's
label, quotaReadingCell's expired check and quotaResetCmd's prompt
still read time.Now(); no test compares them against a second clock.
internal/tui's quotaCell and quotaLines already take now, and their
tests pass a fixed one.

Verified with at.sh (local time of day set by a zone): with a temporary
1s sleep between quotaCell and the check, the old test started at local
23:59:59.5 failed with "accounts_cli_test.go:128: \"5h 42% ↻2h13m
tomorrow 02:13\"" and passed at 12:00:00; the new one passes at
23:59:59.5 (same sleep) and, without the sleep, at 23:59:59, 00:00:00
and 12:00:00. Putting any of quotaCell's three reads back on the wall
clock fails the test ("--- FAIL: TestQuotaCell"). ResetClock's now
alone fails the next-day case: "accounts_cli_test.go:134: tomorrow:
\"7d 42% ↻14h13m 02:13\"". The passed check alone, or all three:
"accounts_cli_test.go:129: \"5h 42% · reset time passed Sep 30
14:13\"". untilShort alone: "accounts_cli_test.go:129: \"5h 42% ↻now
14:13\"". go test -tags nogui -count=1 . passes in a sandbox HOME;
-race -count=20 -run '^TestQuotaCell$' passes; gofmt -l and go vet
-tags nogui . are clean.
2026-10-07 02:14:29 +08:00
yetone b1cba65477 davsync tests: the slow transfer runs on a line with small buffers, and the PUT's tail is the server's to hold (TestSlowTransferIsNotCutShort red on ubuntu in PR #1030's run 37466034290: "the server sent and took nothing for 300ms (PUT)")
The test scales the watchdog from 60 s down to 300 ms and sends 20 MB at 6.4 MB/s, taking each of magpie's reads of the body as the line moving. On Linux the line's buffers aren't scaled with it: the kernel hands a writer room in bursts, waking it once a third of its send buffer has drained, and it tunes that buffer up to 4 MB (the receiver's window opens in steps the same way). A probe on magpie's body reads in a golang:1.26.3 container showed gaps of 130–290 ms between two of them while the line never stopped, every run; one over 300 ms failed the test. That is the test's ratio, not the watchdog's: at the real 60 s, a burst that long needs a line under ~25 KB/s. Not a race (the same in -race and without).

narrowLine serves the fake WebDAV server and gives the client with both ends' buffers held to 128 KB (SetWriteBuffer on the client's connection, SetReadBuffer on the server's), so a burst is a chunk or two. The MBs that used to sit in the buffers after magpie had handed the body over, which is what allSent's longer wait is for, are now the server's own tail: it answers the PUT twice the watchdog after the body is in. The server also grows its buffer to the body's length up front.

On Linux (golang:1.26.3, 14 CPUs): before, 5 of 30 runs failed (-race -count=10 -cpu 1,2,4); after, 30 of 30 pass, and -race -count=20 -cpu 1,2,8 of TestSlowTransferIsNotCutShort, TestStalledTransferFailsPromptly and TestMetadataRequestHasALimit with four busy loops beside them pass. With allSent changed to put the watchdog back by stallAfter alone, the test fails 3 of 3 ("took nothing for 300ms (PUT)"), so the tail is still tested. go test -race ./internal/davsync (darwin), go vet and gofmt -l pass. No change outside the test.
2026-10-06 23:25:27 +08:00
yetone 58ecd4454c davsync, agent: sync turned off and on again to the same server keeps what changed here, and an agent shown only routing groups connects on the first (#939, aiirobyte)
Off deleted sync-state.json, so turning sync on again made this computer a
new one: the first sync brought the server's whole setup in over it, and
every agent changed while sync was off went back to the server's older
fields. Off now keeps what this computer last synced (dropping only the
error, notice, undo and usage state), so it resumes: changes here go up.
TestOffAndOnAgainKeepsChangesHere fails without it (Codex's model went
back to "gpt-old").

The Agents switch said "magpie has no models Codex can use" when the only
models shown to the agent were routing groups (its own ChatGPT account's
are left out of Codex's list), while `magpie codex group/folia-1` worked.
Connect now falls back to the first routing group.
TestConnectOnARoutingGroupAlone fails without it.
2026-10-06 02:16:08 +08:00
yetone 76f3b97c09 sync: it can sync by itself less often or only when asked, and the server's setup can be restored over this computer's, undoably (Sun1090, #847: 同步太快了,需要设置个开关控制自动同步,还有恢复按钮) — the gateway's magpie synced every 3 minutes with no way to change it, and a free 坚果云 account is allowed only so many WebDAV requests (600 per 30 minutes, 1 GB up and 3 GB down a month); and there was no way to take the server's setup back over a computer's own, which the next sync would otherwise push up. Settings › Sync now has Sync by itself, the app's segmented control (no <select>): Off, 3 min (the default, as before), 15 min, 30 min, 1 hour; Off syncs only on Sync now, and the sync row says only when asked. The loop reads the setting each minute (no request), so a change takes effect without a restart, and a server limiting requests is still waited for as backoff says. Restore from the server asks first in the app's own dialog, naming the parts replaced; it reads the server's file as it is now, seals this computer's setup (keys and all) into the sync folder as <time>-before-restore (newest 5 kept), brings in each part that differs as a sync brings one changed only there, and leaves the state as such a sync would, so the next sync pushes nothing back. Its notice says what was restored, with Undo, which brings the kept setup back (the next sync then takes it to the others); OK, or a later sync's own notice, drops the undo. The form's Save keeps the setting (SetAuto alone sets it). CLI: magpie webdav|s3 auto off|3|15|30|60, restore, undo; the status says how often it syncs by itself. zh, ja, de strings. TestAutoSync (off: no request by itself; back to 15 min: one sync, then none; Save keeps it; 7 refused) and TestRestore (b's own changes replaced by the server's with no push, the kept copy holds b's provider and key, Undo puts it back and a gets it, nothing differing restores nothing) pass -race; sync-auto-restore.test.cjs (WebKit, en and zh: Off posts -1 and the rows say so, Cancel posts nothing, Restore and Undo post, no click moves the page) fails on the old code. sync-other-kind, sync-spin, s3-sync, webdav-settings-refresh, usage-computers, click-scroll, gui-ja, gui-de and settings-sections pass on WebKit. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. 2026-10-05 10:36:46 +08:00
yetone 3ec6b4da27 gateway, gui: a provider's keys can be routed by weight, a key weighing 3 taking three requests for every one a key weighing 1 takes (dengtongcai, #841) — Routing already spread requests over a provider's keys in order, in turn, least used first or by pace, but never in a set proportion: two team keys meant to split 75/25 could only go evenly or one after the other. Routing now has By weight (routing "weight") for a provider with keys: each key that's on shows its weight and its share beside it in the editor (Weight 3 · 75%), a click edits it in place and posts keys/weight (provider.SetKeyWeight, 1 to 1000; none or 0 counts as 1, kept out of the file), and providers.json takes it as "weight" on each of keys (keyWeight for the first). The gateway picks as NGINX's smooth weighted round robin does (weighted.go): 3, 1 and 4 go round in every 8 requests in that ratio, never bunched; the others follow in their order for failover. A key resting after a failure is left out of the round while it rests, the others taking its share by their weights, and goes last. A conversation still stays with its key as Stays says (cache-warm across turns by default, within a turn always), and a request kept so is counted against the key that took it, not the one the round picked, so new conversations make up the share; Stays Off gives the ratio request by request. The weight goes with its key through Make first, the editor's Save, backups and WebDAV sync. The Routing page offers By weight for key pools and explains a pick by it; sink (Goes to the back) is off for it as for In turn. zh, ja, de strings. TestWeightedKeys (3:1:4 exact in each of 50 rounds of 8 through perKey and route; with one resting the other two 75/25 and the resting one last; a kept request made up by the next picks) and TestSetKeyWeight fail on the old code (no Weighted); key-weight.test.cjs (WebKit, en and zh: By weight shows the weights and shares, a click sets one and the shares redraw, Smart hides them, no scroll) fails on the old code (no By weight). key-import, panel-routing, routing-save, rate-limited-sink, routing-reset-deadline, account-arrange, gui-ja, gui-de and click-scroll pass on WebKit. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. 2026-10-05 08:50:57 +08:00
TryWorld 05596ef4e2 davsync: scope provider and library credentials during partial sync (#744)
* davsync: scope provider and library credentials during partial sync

* davsync: a sync writes which part carries its credentials, and no blank clears one

yetone's review of the first cut found two losses the per-part markers
caused, and both are fixed by making the markers say exactly which part
they belong to:

1. A keyed computer's library-only upload cleared a keyless computer's
   private MCP token. The blanks in the uploader's library are redactions
   it downloaded and never held a value for, not clears it made; take now
   keeps the server's value for an empty secret whatever the policy is, so
   only a server taken out here is gone. The test that had the loss as
   its expected now expects the token kept.

2. Mixed versions dropped the remote's provider keys. A keyed upload
   didn't write the whole-bundle Keys bit, and an older keyless magpie,
   which reads no per-part marker, uploaded its own providers over the
   server's with nothing to keep. take sets Keys for the providers' again.

The per-part markers are three-state now: a whole collect and a magpie
before them write none, and the whole-bundle bit says it then; a sync
writes one for every part it merges — true when the upload carried that
part's credentials, false when it didn't — and that bit, not Keys, is
what a reader goes by. That is what keeps 1 and 2 apart: the providers'
whole-bundle bit is set for older readers, while the settings' and the
library's own false is what tells a new reader they came without keys and
their local values stay. take marks its merged bundle as a sync one
(BundleVersion), so a sync's readers never fall back to Keys for a part
it wrote.

The two regression tests of the first cut that encoded the losses as
expected are updated: library/clear keeps the server's secret, and the
keyed providers upload writes the whole-bundle bit while leaving the
other parts' contents alone.

A remote that main already flipped to Keys=true over redacted settings or
library isn't repaired by this: the fix keeps new syncs from flipping it,
and a keyed computer syncing those parts repairs them by writing them
whole.

Validation on Windows: go test ./internal/... under a temp HOME passes
whole; the davsync, backup, access and settings packages pass with -race;
stashing the change fails TestTakeScopedPartCredentials (8 cases), the
scoped-sync tests that cover both losses, and the balance-token and
restore-scope tests that read the markers. go vet ./internal/... and the
darwin, GOOS=linux and GOOS=windows builds pass.

* davsync: preserve local secrets when applying partial uploads
2026-10-04 23:42:12 +08:00
tobitege 21702d01ff Add German translation; harden paths against unset env vars (#770)
* Add German translation and harden paths against unset env vars

Add a German locale to the GUI, tray menu, usage alerts and website home page, and refuse to start when the home folder is unset or relative so nothing is written under the working folder; the pi plugin no longer adopts the user's PI_CODING_AGENT_DIR, and an inherited APPIMAGE no longer selects portable mode. Scripts refuse to run from the wrong folder or empty a shared out folder, and a new internal/testenv sandboxes every test package from the user's real files, which also fixes the Claude picker and Termux installer tests on Windows.

* Makefile: know magpie's folder by its go.mod, so paths with spaces work

The check compared $(CURDIR) with $(dir $(abspath …)), which make splits on spaces, so a checkout under 'My Projects' or 'John Doe' refused every target. It now reads go.mod relative to the working folder and wants magpie's module there: make -C and a symlinked Makefile work, make -f from another folder still stops before any recipe runs.

* i18n: German for the strings main added since, with a parity test

75 strings main added (group order, patterns, slow-start wait, usage caps, RTK savings …) get their German, and two that main dropped go. gui-de.test.cjs checks, as gui-ja does, that de has every zh string with its placeholders and tags, and that the window reads in German when picked or on a German system.

* appdir: ignore relative folder variables when reading, keep them for children

CheckEnv no longer unsets anything. magpie reads the XDG folders, APPDATA/LOCALAPPDATA and every agent's folder variable through appdir.Getenv/LookupEnv, which pass over a relative value; the environment stays as it is, so the agents magpie starts get the variable as the user set it. That covers what proc.UserPath lends from the login shell too, which is set after main's check and reached the desktop app unchecked. On Windows a path rooted on the current drive (\Users\x, Git Bash's /c/x) counts as usable, and magpie version and help answer without a home, before run makes any folder.

* Sandbox the tests main added since

internal/wslrun gets testenv's TestMain, and four tests main added that set HOME alone set their home with testenv.SetHome, so on Windows they don't read the runner's profile through USERPROFILE.

* agentenv: PI_CONFIG_DIR and OPENCODE_DB take relative names on purpose

omp puts $PI_CONFIG_DIR under the home and OpenCode a relative $OPENCODE_DB in its data folder, so neither depends on the working folder: appdir no longer passes them over.

* i18n: German for the Library's multi-select and System One's decision API

The 19 strings main added since (#791's skill multi-select, the decision API snippets) get their German; the one main replaced (four APIs → five APIs) goes.

* appdir: tidy the import into magpie's group; say what Go makes of /c/x

The appdir import joins the group of magpie's own imports where a file has one. rooted's comment now says that Go takes a Git Bash path like /c/x as \c\x on the current drive, not C:\x.
2026-10-04 23:30:56 +08:00
yetone 9aa7a3dca6 tray: the Mac menu bar can show the allowances without magpie's icon (KevinXC on Discord: 能否支持隐藏状态栏里的图标,只显示各个供应商的剩余额度?) — Settings → Usage has a Menu bar magpie icon row (On by default) under Menu bar logos, on a Mac with a card in the menu bar. Off (settings.TrayNoBird) draws the cards alone: the image starts at the first card, with no room left for the bird; a click on a card still opens the panel at it, a modified click opens it as before, and the flap leaves the image alone. With no cards up the bird is shown as before, so the item never goes blank. It is the computer's own, like the other menu-bar settings (KeepOwn), and a change redraws at once. Menu bar logos is 菜单栏订阅图标 in Chinese now, so the two rows read apart. TestTrayImageNoBird (the image 48 px narrower at 2x, the rest the same cells, ink at its left edge, logos and plain, light and dark) fails on the old code; tray-bird.test.cjs (WebKit, en and zh) and tray-logos pass; the birdless image was looked at in light and dark. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass (TestClientGoneRecordedAsCanceled failed once under the full run's load and passes alone, five times, and in the gateway package's own run). Not tried in the real menu bar. 2026-10-04 20:01:57 +08:00
yetone 855e7985f2 routing: the groups are put in an order of the user's, as the Agents page's rows are, and /v1/models lists them so (#779)
The Routing page's group list followed the order the groups were made in, found ones after, so a group used every day couldn't be put first, though /v1/models lists the groups in that order and agents' pickers (Claude Desktop's 3P list among them) show it and some clients probe its first model. A group row's logos are now its handle, as an agent row's logo is (#57): dragged (dragRows, the Agents page's sorter) it moves the row, clicked, or the row right-clicked, it opens Move up and Move down (openRowMenu, with the Agents menu's arrows, now MOVE_UP/MOVE_DOWN), and Alt+↑/↓ moves it from the keyboard, which stays on it. The grip in the row's margin is drawn only while the row is under the pointer, dragged, or its handle is focused or its menu open, as on the Agents page. The list is drawn in its new order at once and put back if the save fails; a later move wins over an earlier one still saving.

The order is kept in providers.json as groupOrder (provider.SetGroupOrder, POST /api/groups/arrange), beside the providers' own order (#499), and applied in groupsIn, which every list of the groups is made from: the Routing page, Catalog and so /v1/models, magpie group ls and the TUI's routing page. A found group, which isn't stored until changed, can be put among the user's this way, which reordering the groups array couldn't do. A group not named (one made or found after) goes last; a renamed one keeps its place. Backup and sync carry the order as they do the providers' (MirrorGroupOrder).

TestGroupOrder (Groups and the catalog follow SetGroupOrder, providers.json has it, a new group last, a rename in place, bad orders refused, a synced order first) fails on the old code. group-arrange.test.cjs (WebKit, en and zh): the grip hidden with the pointer away and shown on the hovered row only, in its margin; right-click Move down with Move up off on the first; Alt+↓ and Alt+↑ with focus kept; the handle's click opens the menu, not the editor; a drag by the grip to the top; each posting every group's id with the removed one last; no click moving the page. It fails on the old code. agent-grip, click-scroll, account-arrange, usage-arrange, provider-order and the group-*/routing-* GUI tests pass in WebKit, but for group-from-model-add's 600x420 cases, which fail on main as well. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
2026-10-04 19:27:51 +08:00
TryWorld 07644556a7 fix(davsync): scope settings credentials during partial sync (#673) 2026-10-03 20:43:13 +08:00
yetone f1224a055e sync: a backup on a slow line is no longer cut off at 2 minutes; a server that stops moving is (#657, irx999: a 43 MB backup with many skills to a WebDAV server on a 2.4 Mbps VPS, 305 KB/s, needs 2.4 minutes and every sync failed) — a sync had a fixed deadline in all (2 minutes from the gateway's loop and magpie webdav/s3 sync, 90 s from Settings › Sync now), so a large backup on a slow line never got through, however steadily it moved. WebDAV and S3 requests now go through a watchdog instead (stall.go): the backup's GET and PUT fail once nothing has moved either way for 60 s, each byte of the body sent or read putting it back, and after a PUT's body is all handed over the answer gets 60 s on top of as long as the write took (at least 4 minutes), for what still sits in the system's buffers to leave; the other requests (PROPFIND, MKCOL, HEAD, MOVE, DELETE) have 30 s in all. A stall says so ("the server sent and took nothing for 1m0s (PUT)"). Sync now and the CLI have no deadline; the gateway's loop keeps an hour as a backstop; a wait for another magpie's sync stays 30 s. TestSlowTransferIsNotCutShort (20 MB up and down at a steady trickle, over 5 times the watchdog each way), TestStalledTransferFailsPromptly (a server stopping mid-PUT and mid-GET fails within a second) and TestMetadataRequestHasALimit fail on the old code (no watchdog: the stalled ones hang) and pass. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried against a real slow WebDAV server. 2026-10-03 16:10:02 +08:00
yetone 9ccabdfbd8 usage: quota cards draw what was left over time, against an even burn, and the tray card its cycle's line (#651, songlairui) — each reading magpie makes anyway of a subscription's windows (and of a plan bought with a key) is kept as a point in quota-history.json beside the config: provider, account, window, when read, percent left, the window's start and reset — no token, no prompt. A failed reading, one kept from before (As of), an unlimited window and a model family's aren't kept; Antigravity's pools are kept by pool. Within a cycle points are 5 minutes apart at least and a run of one figure is its two ends; a cycle ends where the window starts again (its reset passed, or moved further than the time between two readings, so a rolling window's isn't one). 45 days and 6000 points a window are kept. The Usage page's card has, under its meters, a line a window (several tiers of one account on one chart), broken at each reset, a dashed even burn from each window's start to its reset and an upright now line, with 2 days / Cycle (the account's longest current window, start to reset) in the app's segmented control, remembered, turning every card without moving the page. The tray's card has a thin line of its first window's current cycle. GET /v1/magpie/quotas/history (?days=, ?provider=, ?user=; local or LAN with the key, as /v1/magpie/quotas) and /api/usage/quotas/history serve it; /v1/magpie/quotas and magpie quota stay snapshots. Computers sharing usage (WebDAV/S3, Settings › Sync › usage) put their history beside their days, sealed, as <id>.magpie-quotas, and merge the others': the union by account, window and time, two points within 30 s the newer, nothing twice however often they sync; an older magpie never reads the file. TestQuotaHistoryRecords, TestQuotaHistoryCycles, TestMergeQuotaHistory, TestQuotasHistory and TestQuotaHistorySharedBetweenComputers cover recording, cycles, pruning, merge, the API and two computers on one WebDAV folder; quota-curve.test.cjs (WebKit, en/zh, light and dark) the curves, the toggle, no curve without readings, and the tray's line (Chromium doesn't start in this sandbox). go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried with real accounts; pay-as-you-go balances aren't drawn. 2026-10-03 15:19:19 +08:00
TryWorld 960e611a79 fix(davsync): sync independent gateway key changes (#643) 2026-10-03 13:49:32 +08:00
尔立andClaude Code c221b9e2a9 davsync: a damaged file on the server is rebuilt, not failed on forever (#607)
* davsync: a damaged file on the server is rebuilt, not failed on forever

When the server's backup can't be read as a backup at all — a write a
relay or tunnel cut short, or one damaged since — syncOnce returned the
error and stopped. This computer's own setup is fine, so it could never
be pushed over the unreadable file to fix it, and every sync after
failed the same way: the only way out was deleting the file by hand.
(#531 detects a short write as it happens, but a file already damaged —
by an older magpie without that check, or by anything since — stayed
stuck on "not a magpie backup".)

The rebuild merges; it never pushes this computer's bundle as it is. A
sync that finds the file unreadable falls back to the last good copy of
the server's file it read whole — its cache, when that copy is
trustworthy — and runs the normal three-way merge over it, so another
computer's newer setup the damaged write swallowed survives. Three
guards say when a computer may do that, and any one missing leaves the
error standing: it has synced before (a fresh computer has no last-good
copy and no right to rebuild what it never saw); the cached copy is the
version the damage replaced (the damaged body is a prefix of it, which
is what a cut-short write leaves — and what an out-of-date computer's
older copy is not); and the body really begins like a magpie backup, so
a server answering with a page (a captive portal) is reported, never
rebuilt over. backup.ErrCorrupt — the message those two damaged checks
already gave — marks that case, told from a wrong passphrase and from a
newer magpie's file, both still refused. The push carries the damaged
file's ETag, so it stays checked against the server, and it happens
even when the merge matches the cached copy: the server's file is the
damaged one and must be replaced. The unreadable body is kept aside
under the sync folder, the newest few, so a server that always answers
the same unreadable way doesn't pile one up per tick.

Tests: TestSyncRebuildsFromCachedCopy pins the self-heal (the server
ends whole, the damaged body kept aside); TestSyncFreshComputerDoesNot
Rebuild and TestSyncOutOfDateComputerDoesNotRebuild pin the two
data-loss cases — the out-of-date one fails without the prefix guard
(verified by mutation); TestSyncPageIsNotDamage pins that a 200 HTML
page is reported, not pushed over; the newer-magpie refusal is guarded
against regression. go vet, and go test -tags nogui ./internal/davsync
./internal/backup under a temp HOME pass, as do the linux and windows
builds.

* davsync: a body cut inside the sealed file's constant head rebuilds nothing (yetone on #607)

The prefix check alone let an out-of-date computer rebuild: Seal writes
the envelope with MarshalIndent, so every version begins with the same
constant head up to the salt, and a body cut inside it (40 or 90 bytes)
is a prefix of every version — including an older cached copy — so that
copy's computer passed the guard and pushed its merge over a newer one's
data. The guard now also requires the damaged body to reach past the
random fields, to the "data" key that follows the salt and nonce, which
ties it to the one version it was cut from. A body cut before them is
attributable to no version, so it stays an error for every computer,
even the one that saw the good version — fail loud rather than rebuild
blind; a relay cutting a real write cuts deep into the data, so legit
rebuilds still go. TestSyncOutOfDateComputerDoesNotRebuild now cuts at
half, 90 and 40 bytes; the two short cuts fail against the old guard
(the out-of-date computer rebuilds and kimi is lost).

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-03 11:20:57 +08:00
TryWorld bb42b47663 fix(davsync): retry short shared usage uploads (#574) 2026-10-02 22:42:47 +08:00
yetone da834411b5 usage: with WebDAV or S3 sync, each computer shares its usage and sees the others', the total and each computer's part (#542) — sync carried keys, agents and the library, and each computer's Usage page counted only the calls made on it. Sync now has a Usage tick (off unless picked, usage=yes in magpie webdav/magpie s3): a computer with it on puts its own calls up as one file a day, magpie/usage/<id>-<day>.magpie-usage beside the bundle (under the prefix's usage/ on S3), each a gzipped list of times, agents, providers, models, tokens, costs and statuses, sealed with the sync passphrase (backup.SealData, the key cached per passphrase so a sync derives it once); no request body, session, archive name or key goes, a call another computer passed on over the LAN (Via) isn't put up again, and an error is cut to 300 characters. The id is a random one kept in usage-computer.json, which no backup carries, with the host name beside it. A computer only ever writes and deletes its own names, so none overwrites another's; the files are not in the bundle and its hash, so sharing usage never makes a sync or a conflict of the bundle; the first sync puts up the last 90 days, after that only yesterday and today, at most every 15 minutes (a Save or Sync now at once), and a day past 90 is deleted by the computer it is of. The others' files, listed (PROPFIND Depth 1; ListObjectsV2) and fetched only when changed, are kept in usage-others and read with the ledger, as Record.Computer; a failure is said in the sync row ("Couldn't share usage") and never fails the sync. The Requests page and the panel's Usage tab get a Computer pick (this computer, other computers when more than one, each by name) sending computer=this|others|<id>; a row from another computer says "· on <name>" beside its agent, and its details name the computer and say what was said stays there, nothing asked of this computer's session files for it. With no other computer's usage there is no pick and nothing new on either page, and an older magpie never lists or reads usage/. TestUsageSharedBetweenComputers (computers a and b over a fake WebDAV under httptest: each sees 4 calls, the totals and its own and the other's part, nothing counted twice over repeated syncs, the bundle not put again for usage, a new call reaching the other, a day past the window deleted), TestUsageSharedThroughS3 (the same through a fake S3, signed list and delete, a prefix with a space and a plus) and TestUsageOffAsksNothing are new and don't build without the change; usage-computers.test.cjs (WebKit, en and zh: no pick without other computers, the pick and computer=this, another computer's row and details with no /content asked, the panel's pick, the sync form's tick posting usage: true, no click moving the page) fails without it on all but the no-other-computers case. s3-sync, sync-other-kind, panel-usage, request-archive-usage, click-scroll and usage-ledger-chart pass in WebKit. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestFXInState failed once on its TempDir cleanup, passes alone). Turning the Usage tick off, or sync off, lets the other computers' usage kept here go (DropAllShared), so the Usage page counts this computer's alone again: TestUsageSharedBetweenComputers checks both, and fails without it. 2026-10-02 17:47:36 +08:00
Lin Huazhong d8ebf2b893 test: one list of the agent folders a sandbox clears, so no sandbox misses an agent (yetone, on #522: A shared list of env vars for the other sandboxes would be a welcome follow-up) — every sandbox kept its own list of the variables that point at an agent's folder, and each had drifted: the gateway's cleared 5 of the 32 magpie reads, the provider package's 2, the tui's 10, the library's 20. A sandbox clearing 2 meets whatever Grok, Kimi, Hermes, MiniMax, Qoder or ZCode the developer has installed, which is how #522 came about — Qoder's two were in the library's list and in none of the four that failed. internal/agentenv is now the one list, and the 21 sandboxes that fake an environment with no agent in it clear it, each keeping beside it the variables that are not an agent's: APPDATA and LOCALAPPDATA, which a sandbox sets to a folder of its own rather than clearing, VISUAL and EDITOR. A name in the list is one magpie reads, and TestVarsAreRead walks the sources to say so, which is how two names a sandbox had been clearing turned out to be read by nothing at all — GEMINI_CLI_HOME and OPENCODE_CONFIG in internal/backup's, while OPENCODE_CONFIG_DIR, the one internal/agent reads (agents.go:545), was not in it. Where a sandbox set an agent's folder to a fixture of its own, the clearing now comes first, since a list of 32 wipes what a list of 5 left standing. Not converted: the omp tests' four variables, which are the ones omp's own lookup consults (a Pi fork) rather than every agent's, and the warm-up's Claude Code traffic flags, which are not folders — CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC stays out of the list for that reason too, its one test setting it on purpose. On Windows 11 the whole suite answers as it did before the change: 31 packages pass, and the same 15 tests in internal/library, internal/gateway and internal/agent fail for what they failed for before (symlinks this machine has no privilege for, and TestSuffixModesReachAgentFiles). go vet ./..., go build -tags nogui (windows, GOOS=linux, GOOS=darwin), and go vet -tags nogui for linux and darwin over every package touched pass. (#543) 2026-10-02 17:22:16 +08:00
尔立andClaude Code 93a1e3e7fc fix(davsync): detect a WebDAV write the server kept short (#531)
Relays and tunnels cut long uploads short. Over a Cloudflare Tunnel
with a 3MB write this came up 2 of 3 times, and each time the client
saw it happen: the relay answered 502 or 423, which the sync reports
as itself. The quiet shape is a server that answers 2xx with half the
file on disk: every later sync then reads the half and fails as "not
a magpie backup", with no way to tell what actually went wrong.

A write is now looked at after it lands: a HEAD read of the backup is
compared with the size sent, a short write is retried over the version
the short one made (its ETag, from the PUT's answer or the HEAD's), and
after three tries reported as itself. A HEAD that can't be done — an
error, a 429 or 503, a non-200 — leaves the write unchecked: it landed
either way. Servers without HEAD check as before.

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-02 16:42:37 +08:00
yetone 7b4065b12a sync: the order the providers were put in on the Providers tab goes with them, so a computer that syncs lists and tries them as the other does (ARNO on Discord: 发现webdav同步的时候没有同步provider的顺序) — since #499 the order is kept apart from the providers, as providers.json's "order" (ids, the built-in signed-in accounts among them), and the list is sorted by it when read; the providers themselves stay in the file in the order they were added. The sync's bundle (backup.Bundle) carried the providers, icons, groups and search APIs but not that order, and the providers part was hashed without it, so arranging the list was not even a change to sync, and a computer bringing the providers in (provider.Mirror) kept its own order, or none. The bundle now has Order (omitempty, from provider.StoredOrder), hashed with the providers part only when there is one, so the hashes of a setup never arranged are as before and an older magpie's file still compares the same; take carries it with the providers, and bring applies it after Mirror with the new provider.MirrorOrder: the other computer's ids first as it has them, then the ones named here alone (an account only this computer has) in the order they have here; none, from a magpie before this or a setup never arranged, leaves the order here as it is. This goes for WebDAV and S3 alike (one path). A backup restored by hand still merges providers without changing the order. TestSyncProviderOrder (a arranges three providers, b joins and lists them in a's order and pushes nothing back, b arranging only is pushed and a follows; against an httptest WebDAV server) fails without the change ("b after joining: [one two three]"); TestMirrorOrder covers the merge. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... (temp HOME) pass. 2026-10-02 15:01:24 +08:00
yetone 7ab91961d2 davsync: an unchanged file on the server costs a request, not a download (Crispin on Discord: 是不是Magpie后台一直在自动同步,导致被坚果云给限制了) — every 3 minutes sync read the whole sealed file even when nothing had changed, and 坚果云's free plan caps monthly downloads and requests per 30 minutes. The sync state now keeps the server's version beside its hash (its ETag, or a Last-Modified at least a second older than the server's Date, so a write in that same second can't go unseen), from the read or from the ETag a write answers with (none said: the next sync reads in full, as before); the next read sends If-None-Match (or If-Modified-Since) and a 304 with nothing changed here ends the sync, while a change here merges with a copy of the server's file kept beside sync-state.json (sync-server.magpie-backup, 0600, checked against the hash; gone or different, the file is read again), so it isn't read twice. A server that ignores the condition sends the file and syncs exactly as today. S3 does the same with If-None-Match on GetObject, signed with the rest (the fake S3 checks every signature). A 429 or 503 (S3's SlowDown) now says the server is limiting how often magpie may reach it, and Run waits as long as Retry-After says (up to 6 h) or else doubles from 6 up to 30 minutes, back to 3 after a sync that goes through; log de-dup is kept, as the message doesn't carry the wait. The 403 paths (there, forbidden) only run when the server answers 403, so a working setup costs one conditional GET per cycle. TestSyncUnchangedIsNotRead (ETag, ETag not on PUT, Last-Modified: three unchanged cycles read 0 bytes, a server change is read once and merged, a change here is pushed without a read, a missing copy is read again), TestSyncRateLimited and TestS3Unchanged fail without the change (with the conditions dropped: 3 full reads of 1932 bytes, 'HTTP 429' with no backoff, unconditional S3 GETs); TestSyncIgnoringConditions guards servers without conditional reads. go vet ./internal/..., go build -tags nogui ./... (darwin, GOOS=linux), GOOS=windows go build ./..., go test -tags nogui ./internal/davsync/ ./internal/gui/ pass. 2026-10-02 11:08:14 +08:00
yetone da1ee0f81a sync: the menu bar's usage is each computer's own, not synced (yoooo on Discord: usage in the menu bar turned off on a Mac came back on from a Windows box that shows it, over WebDAV) — a sync and a restored backup kept only the window's size, the proxy and the Dock from this computer, so which subscriptions show beside the icon, how often they refresh and their logos came from whichever computer synced last. They are now kept like the others (Settings.KeepOwn, used by both), and left out of what tells a change. TestSync fails without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass. 2026-10-02 01:13:29 +08:00
yetone 3c04877cfc archive: a request the archive kept is opened and downloaded from the Usage page, with its bodies whole (jorben, #447: 请求存档 reachable from usage history, full bodies, download) — the archive's name was only on the Gateway page's recent calls, gone once a call left the list or the gateway restarted, and the archive kept the first 256 KB of each body, the recent list's cut. The usage ledger's row now names the call's archive (archive in usage.jsonl, kept through the Requests page's packed rows), and its details on Usage → Requests show the Request archive panel, Fetch from archive and Download; with the archive on, a request it has no copy of says "Not archived". The bodies are kept whole up to requestArchiveMaxMB in settings.json (32 MB unless set, at most 1024): the response is teed to a temp file as it is sent, the request body written to one when the call ends, so the upload queue holds file handles, not bodies, and one upload at a time reads them; a body past the limit is kept to it and marked cut, with its whole size. What goes upstream is unchanged, the archive stays off unless turned on, headers and bodies are scrubbed as before, and the temp files are removed after the upload or when the queue is full. The page is sent a body to show only when it is 256 KB or less (a longer one is said by its size), an archive over 80 MB is only said to be; Download streams the object whole, to Downloads in the app (archive/export, -2, -3… for a name taken) and as the browser's download in magpie web (archive/file). TestArchiveWholeBodies, TestArchiveLimit, TestRowKeepsArchive and TestArchivePreviewAndDownload fail without the change; TestArchiveOffUsage, TestArchiveTooLargeToPreview, TestArchiveOldPreview keep the rest. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; request-archive-usage (new), request-archive, click-scroll, old-webkit, usage-ledger, usage-ledger-detail, usage-ledger-content, web-page, sse-body pass. 2026-10-02 00:57:51 +08:00
yetone 0361189f9e test: every package's tests pass on Windows, and none writes outside its sandbox (the user: 把 Windows 上那几个失败的测试修掉) — on a Windows box the suite failed in most packages, and the root package hung for 25 minutes. Tests set HOME, which os.UserHomeDir ignores on Windows (it reads USERPROFILE), so they read and wrote the runner's own home; each now sets USERPROFILE beside HOME, and Devin's, Goose's, Crush's and Claude Desktop's tests set APPDATA/LOCALAPPDATA, where those keep their files there. said() read the command's output only once it returned, and a pipe holds a few KB on Windows, so magpie model --help blocked forever; it reads while the command writes. Unix permission bits (the gateway key store's 0600 too), exec bits and symlinks Windows lacks are checked only off Windows; TOML strings hold Windows paths as literal strings, JSON as marshalled ones; a URL's path is compared as one. The provider and usage packages run in a home of their own (TestMain, as the gateway's already does), since a CLI's identity kept by a refresh still going when its test ended landed in the runner's ~/.config/magpie — and on a Mac in the user's own. Two product fixes came out of it: imagemcp took a file:///C:/… root or image for the path /C:/…, which doesn't exist on Windows, and now makes a Windows path of it (a host for a UNC share); and session discovery could keep a new Cowork session hidden on Linux, where a folder's mtime is a kernel tick and its size doesn't change, so an entry added within the tick of the last listing left both as they were — a listing made within 2s of its folder's last change is read again, as git does for racy files. TestFilePath and TestRacyDirectoryListedAgain fail without the change. On Windows 11 every package passes (32 of 32) and a run of each package in a fresh home leaves nothing in home, APPDATA or LOCALAPPDATA; on Linux (WSL) and macOS go test -tags nogui ./... passes. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build pass. 2026-10-01 23:15:07 +08:00
yetone 9c54a560cc provider: a providers.json that can't be read is said where the providers are listed, never backed up or synced as none, and is replaced through a rename (PR #415's review) — #415 made edits refuse a file they couldn't read or decode, but every read for the user still took it as empty: magpie providers said "no providers yet", magpie models "no models yet", the Providers page opened the Add sheet as for a first use, and backup.Collect, so an export and a WebDAV/S3 sync, carried it as no providers, which another computer's Mirror would then take as every provider removed. magpie providers and magpie models now list what is left (the signed-in accounts) and end in the error, the TUI says it over its list, and the Providers page shows a "Your providers file can't be read" box (无法读取供应商配置文件) over the list with magpie's reason and no Add sheet; start-up, the gateway and the catalog still go on with it as empty. Stored and StoredGroups return the read error, so a backup and a sync stop on it with nothing pushed. The error, for reads and refused writes alike, names the file, says magpie left it unchanged and to fix it or move it aside (errors.Is ErrUnreadable). store writes through writePrivate, a 0600 temp file renamed over it (steady.Rename waits out a Windows reader holding it, a symlink keeps its target), instead of os.WriteFile's truncate-then-write, and the read goes through steady.ReadFile. TestFileErrorSaysUnreadable, TestWriteErrorsSayLeftUnchanged, TestStoredRefusesUnreadable, TestStoreReplacesAtomically, TestSyncStopsOnUnreadableProviders, TestProvidersCmdSaysUnreadable and provider-file-error.test.cjs fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; provider-file-error, click-scroll, old-webkit, providers-off-fold, add-sheet pass. 2026-10-01 19:45:24 +08:00
yetone f654bec468 gateway: a model's web search goes to a search API of the user's own when no provider can search (#419 by HwFee: let me set up Tavily, Brave or SearXNG with my key rather than lose the search tool) — with no provider that searches by itself and no provider magpie could search with, the gateway took the client's web search tool away. Tavily, Brave Search, Exa, Firecrawl and SearXNG (an address of one's own, a key only if it asks for one) can now be set up, in an order: the injected web_search tool is then offered, and a search goes to the searcher's model as before, or, with none or when it fails, to each search API in turn, called directly with no model, its results handed back as the same hits and text the searcher path gives, so clients see the same web_search_tool_result blocks. The order is provider searches itself, searcher provider, search APIs, strip. They are kept in providers.json (0600) like provider keys; a backup carries them, their keys only when it carries keys, and one restored without keys keeps the key here; sync does the same, and a backup or sync from a magpie before them leaves the ones here alone. magpie search lists them, magpie search add <api> [key] [url=…] adds or updates one, magpie search rm <api> removes one; Settings has a Web search section (联网搜索) to add and remove them. Prompts, identity lines and headers to model providers are unchanged. TestWebSearchBySearchAPI, TestNoSearchAPINoTool, TestBackupSearchAPIs, TestSyncSearchAPIs and search-api.test.cjs fail without the change; TestSearchAPIsEachAsked checks each vendor against a fake, TestSearchCLI the command. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; search-api, provider-search, click-scroll, old-webkit pass. 2026-10-01 19:44:44 +08:00
yetone 2e94643c7b sync: moving between WebDAV and S3 keeps the other's server, and moving back finds it (ARNO on Discord: 我体验了一下s3同步,结果它把我之前配置好的webdav同步设置给删了) — sync.json held one setup, and saving S3 in Settings › Sync and backup (or magpie s3 on) wrote the bucket over it, so the WebDAV address, user and password were gone. sync.json now keeps the other kind's server beside the one synced to (other: its address, user, password or secret, and S3's endpoint, region and path-style), in the same file, readable by the user alone, and never in a backup, as before. Only one is synced to. Moving back with the password left empty uses the one kept, for the same server and user alone; magpie webdav on / magpie s3 on with nothing else given moves back to the kept one, and magpie webdav / s3 say which is kept. In Settings the row says "WebDAV settings kept" (or S3's), the form marks the kind synced to "· on", says whether saving keeps it or moves sync, its button reads "Move sync to WebDAV" when it does, and the other kind's fields show the kept server. A sync.json from before reads as it did; turning sync off still removes it all. TestConfigureKeepsTheOtherKind, TestS3Cmd and sync-other-kind.test.cjs fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; sync-other-kind, s3-sync, webdav-settings-refresh, click-scroll, old-webkit pass. 2026-10-01 19:44:29 +08:00
TryWorld a32186a6ba fix(profile): preserve default values in snapshots (#421) 2026-10-01 19:43:32 +08:00
TryWorld e4bf875b85 fix(backup): exclude balance tokens from keyless exports (#418) 2026-10-01 19:43:27 +08:00
yetone 258d04f569 gateway: a request archive, off unless turned on, keeps each call's headers and bodies in the user's own S3 bucket and a recent call reads its own back (Jorben on Discord: 请求存档) — the Gateway page's recent calls only ever held the last bodies in memory, the first 256 KB of each, with no headers, gone at a restart. A switch over Recent calls ("Request archive", POST /api/settings/archive, settings' requestArchive) turns it on, refused with a reason while sync isn't set up with an s3:// bucket; with it on, each call the gateway serves gets an id (UTC HHMMSS-<16 hex>, said back in X-Magpie-Archive-Id) and, once it is recorded, its method, path and query, request headers and body and response status, headers and body go as one JSON file to <prefix>/magpie/archive/<YYYY-MM-DD>/<id>.json in the bucket sync keeps its backup in, signed with the same client (davsync.Bucket, no new SDK). Before anything leaves, Authorization, x-api-key, Cookie, Set-Cookie, any header or JSON field or query parameter named for a key, token, secret or password, and anything that looks like a key or token anywhere in the text (the redaction rules' patterns, in a stream's data lines too) become [REDACTED] — gone, no placeholder kept; the archive is plain JSON, readable in the bucket, not sealed with the passphrase. Uploads go one at a time on their own goroutine from a queue of 16 calls, each body capped at the 256 KB the call already keeps and marked truncated; a full queue drops the call with a log line, a failed upload is logged and shown under the switch until the next one goes, and no request waits on any of it. A recent call the archive kept shows its date and id under its bodies, and "Fetch from archive" (GET /api/archive?date=&id=, only a date and an id of that shape) shows the request's and response's headers and bodies as the bucket has them, or says it isn't there yet. The hook is in record() and the Call literal, so PR #239's request log lines are not touched; the archived call carries its usage, and with #239 its vendor request id. Only calls through serve() are archived, not Codex's backend, image or video calls. TestArchiveOn, TestArchiveOff, TestArchiveName, TestRequestArchiveToS3 (a real gateway request through the signature-checking fake S3), TestScrub and request-archive.test.cjs fail without the change; TestSettingsSaveKeepsWhatItDoesNotSend now sets requestArchive. A sandbox magpie web with a fake vendor and a fake S3 server uploaded nothing while off; on, it uploaded a signed archive with none of eight planted secrets in it, read it back through /api/archive and in the page, and with the S3 server down served the call in 0.1 s and said why the upload failed. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (Claude account tests and TestClaudeRunEndsWhenTheClientLeaves pass alone); request-archive, click-scroll, old-webkit, sse-body, gateway-fold pass. 2026-10-01 17:13:34 +08:00
yetone 8931a4b3ac davsync: sync can keep its file in an S3-compatible bucket as well as a WebDAV folder (#296) — AWS S3, Cloudflare R2, Backblaze B2, MinIO, Garage or a NAS. Settings › Sync and backup picks WebDAV or S3: endpoint (empty is AWS), bucket, prefix, region (empty is us-east-1, auto for R2), access key ID and secret, path-style; CLI magpie s3 on s3://bucket/prefix access-key-id=… endpoint=… region=… path-style. The bucket holds the same passphrase-sealed file at <prefix>/magpie/magpie.magpie-backup, signed with SigV4 (no SDK). It is read with its ETag and written with If-Match (If-None-Match: * for the first), so two computers syncing at once don't overwrite each other; a server that refuses conditional writes (501, or 400 naming the header) is remembered and gets a HEAD-then-PUT instead. An IP, localhost or a dotted bucket on https goes path-style on its own. Errors say what to do: wrong key/secret, clock skew, bucket in another region (names it), no such bucket, 403 on the first read on AWS without s3:ListBucket. The secret is kept as the WebDAV password is, sent only to the endpoint and key it was typed for, and may not be the passphrase. sigv4_test (AWS's published vectors), s3_test (fake server: conditions, conflict, fallback, regions, errors), s3_cli_test and s3-sync.test.cjs (Chromium, WebKit, en/zh) fail without the change; tested against MinIO. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestGroupAddSetRemove, a TempDir-cleanup flake under load, passes alone); click-scroll, old-webkit pass. 2026-09-30 17:07:47 +08:00
yetone 04f374fa8c agent: OmO (oh-my-openagent, omo-ai), a fork of Pi that keeps its own ~/.omo/agent, gets its own row: a magpie model sets settings.json's defaultProvider/defaultModel and magpie's provider in models.json there as Pi's does, $OMO_CODING_AGENT_DIR / $SENPI_CODING_AGENT_DIR are honoured, the Library writes its AGENTS.md, mcp.json and skills, and omo update updates it (#264 rhyme227). Pi's own ~/.pi is left alone. piIn becomes piLike(at, id, name, dir) so both share one wiring. Tested with omo_test.go in agent and library (fail without the change); go vet, go build -tags nogui (darwin, GOOS=linux, GOOS=windows), go test -tags nogui ./... pass. 2026-09-30 11:38:07 +08:00
AronnaxandClaude Opus 5.5 7931723d79 cli: 新增 magpie webdav,在终端里开启、修改和触发 WebDAV 同步 / add magpie webdav to turn on, change and run WebDAV sync from the terminal (#127)
* cli: magpie webdav — WebDAV sync from the terminal, what Settings › WebDAV sync does, for a machine with no window (WSL, a server over SSH)

magpie webdav shows it; on <address> [user=…] [password=…] [keys=no] [agents=no] [library=no] turns it on and syncs at once, asking for the passphrase (and the password, when a user has none); set k=v… changes what is given and keeps the rest; now, dismiss and off are the page's Sync now, OK and Turn off. Through the same davsync.Configure, Now, Dismiss and Off.

A sync, Off and Dismiss hold a lock the system keeps on sync.lock (flock; LockFileEx on Windows): the gateway's sync every 3 minutes and one from a terminal — or from the app's window while magpie serve has the gateway — would each save sync-state.json over the other's. The system lets it go when the magpie holding it ends, however it ends.

Configure keeps the saved password only for the same server (scheme and host) and user: an address moved to another server, or another user, no longer takes it there — in the app too, where a new address with the password left empty was sent the old one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* davsync, cli: from the review of #127 — the setup read again under the lock, Configure locked too, the password never an argument, one left behind asked for, a wait that runs out said in the status

Now reads the setup again once it has the lock: one that waited while another magpie turned sync off went on with the setup it had read, and saved sync-state.json back after the Off. Configure takes mu and the lock too, after a sync in progress.

magpie webdav takes neither the password nor the passphrase as an argument: password= asks for it, as passphrase= does.

Configure with the saved password left behind — another server or user, the password empty — says to type it and saves nothing, where the Settings page dropped it and the sync failed with 401 under a field that still said saved.

A wait for the lock that runs out is davsync.ErrBusy. Dismiss returns it, and the davsync routes put ErrBusy from a sync in the status they answer with, where the page shows a sync's failures; Save's own errors stay in its form, what was typed in it kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* davsync, cli: the lock before mu, a wait bounded under the page's, one called off not called busy, the password rule in one place, a bare address with = in it

The lock comes before mu everywhere, so a wait for another magpie never holds mu, and Configure, Off and Dismiss wait for it 30 seconds at most, less than the Settings page gives a request: before, a request could hang on mu and then on the lock for minutes.

A wait for the lock called off, as when the page is closed or the gateway stops, is its context's error, not ErrBusy: nothing said another magpie was syncing.

What becomes of the saved password is decided in one place, davsync.SavedPassword, which Configure and magpie webdav both go by, where the two had rules of their own that had come apart: an account with a password and no user name, moved to another server, had its password dropped with nothing asked, and the sync failed with 401. It is asked for now, as for an account with a user.

magpie webdav takes a bare argument with :// in it as the address, = in it or not: https://host/dav?token=… was read as a field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:54:04 +08:00
yetone 2b2a7bf83f sync: a WebDAV server that answers 403 for what isn't there (a Synology, PROPFIND too) no longer fails the first sync as if the account couldn't write in the folder — magpie's own folder in it is looked for, and when it isn't there a 403 to reading the backup is no backup yet and one to writing it is made as a 404 is 2026-09-28 10:51:05 +08:00
yetone c6dc08e105 sync: an address at OpenList's or Alist's /dav/ says to go into one of its storages, naming them — the list of storages there takes no folder (MKCOL 405, as if it were there) and no file (PUT 404), so a first sync failed with a bare HTTP 404; a folder in the address that isn't there says so. Tried on OpenList 4.2.6 2026-09-28 10:10:03 +08:00
yetone 920bc04d48 sync: a WebDAV 403 says why rather than blaming the password — a folder in the address the server hasn't got (a Synology answers 403 for a shared folder not made), one the account may only read, or one it may not use, told apart by looking at that folder; only a 401 is the user name or password 2026-09-28 09:31:10 +08:00
yetone 6c9a0aa4d7 agents: OpenHanako — magpie is a provider there, chat completions at the gateway's /v1 under the key magpie-hanako with every model it serves, their windows, output, images and reasoning said; picking one makes it the primary agent's chat model, and default puts back the one it had and removes the provider, marked deleted as OpenHanako marks one it removes. While OpenHanako runs it goes through its local API (server-info.json's port and token), which reloads at once; otherwise into provider-catalog.json and agents/<id>/config.yaml for its next start. Asked on Discord; checked with OpenHanako's own provider registry and model manager, which list magpie's models and resolve the chat model to the gateway 2026-09-28 01:12:46 +08:00
yetone 115f0cbd9d sync: 坚果云 (Nutstore) syncs from the first time — it answers a read in a folder not made yet with a 409 where other WebDAV servers say 404, which magpie took for a failure (#114); a 409 is now no backup yet, and the first sync makes the folder and puts it 2026-09-27 21:01:18 +08:00
yetone e173710982 backup: the library goes with a backup and a WebDAV sync — the instructions, MCP servers and skills with their files, a fifth part, ticked by default (magpie backup/restore --no-library). A backup without keys leaves the servers' secret env and headers empty and a restore fills them from the servers kept here; a skill's file over 2 MB, or past 32 MB in all, stays behind; a path that climbs out of its skill is refused. What it replaces is kept with the library's backups. Tested from a sandbox: exported without keys, changed and removed things, imported — the server back with the local key kept, the skill back as files, run.sh still executable 2026-09-27 17:24:53 +08:00
yetone dad2787f55 settings: the Dock can follow the window — Hide, With window, Show. With window, magpie is in the Dock (and Cmd-Tab) while its window is open and leaves it when the window is closed, so it can be switched to without an icon kept there the rest of the time. Tested on a sandboxed bundle: shown at start, gone on close, back on reopen; Show and Hide behave as before. Issue #101 2026-09-27 16:14:54 +08:00
yetone 99fc679e38 sync: a passphrase that is the WebDAV password is refused — the server is sent the password, so with it the server could open the file it is only meant to hold sealed; pick a passphrase of its own 2026-09-26 15:54:16 +08:00
yetone 0423553454 gui: magpie can stay in the Mac's Dock — Settings → Dock → Show keeps it there as well as in the menu bar, for a menu bar too full to show its icon; it takes effect at once and at every start, the Dock icon opens the window (only the window: Wails would have shown the panel too), and it stays each computer's own in sync and backups 2026-09-25 21:13:30 +08:00
yetone 4c67d069d0 sync: magpie's setup kept the same on every computer through WebDAV (坚果云, Nextcloud, a NAS…) — Settings has Sync and backup: the providers with their keys, groups and icons, the settings, the profiles and each agent's models go to the server as one file sealed with a passphrase, so the server never sees them; the magpie serving the gateway looks every three minutes, sends what changed here and brings in what changed elsewhere, a removal too; a part changed on both sides goes to the newer one, and the copy it replaced is kept in the sync folder with a note saying what was replaced; the proxy and the window size stay each computer's own, and a computer set not to send keys never strips them from the server; Export and Import carry the same file by hand (#28) 2026-09-25 19:52:37 +08:00