routing: every request's route is kept on disk, for a day long gone to be looked at and replayed, with retention (历史中的路由记录是不是都应该保存下来,方便用户去回放?…这种历史记录会特别大,应该有自动的retention) — the trace holds only the last 60 routes, and only since the gateway started. Now each route, once done, is appended to ~/.config/magpie/routing/YYYY-MM-DD.jsonl; a day that is over is gzipped, days older than 30 are dropped, then the oldest until all of it fits 200 MB (today is never dropped), checked at most hourly. Route ids start at the time the gateway started, so they don't repeat across runs. /api/gateway/history?day= gives the days kept with their counts and a day's routes (its latest 2000). The Requests list has a chip per day (实时, 今天 3, 昨天, …): a day's requests can be picked, looked at, replayed all, or replayed from one on (从这里重放), with the real-time clock as before. Checked in a sandbox HOME against real DeepSeek: three requests written to the day's file, the gateway restarted, the list showed 今天 3 with them, 从这里重放 played them at 02:19:46 · 第 1/3, stop and 实时 came back to live

This commit is contained in:
yetone committed 2026-09-29 02:22:35 +08:00
1 parent 3a18ec8efc
commit 4cc32dbcb0
7 files changed
+351 -8

No files matched your search

+207
View File
@@ -0,0 +1,207 @@
package gateway
// The routing history: every request's route, once it is done, kept on
// disk so the Routing view can show and replay a day long gone — the trace
// holds only the last few, and only since the gateway started. A day is a
// file of its own, one route a line; a day that is over is gzipped, the
// routes being much alike. Kept historyDays days and historyBytes in all,
// the oldest days going first.
import (
"bufio"
"bytes"
"compress/gzip"
"encoding/json"
"io"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/provider"
)
const (
historyDays = 30
historyBytes = 200 << 20
historyMax = 2000 // the routes of one day served at most: its latest
)
// HistoryDir is where the days are kept: ~/.config/magpie/routing.
func HistoryDir() string { return filepath.Join(filepath.Dir(provider.Path()), "routing") }
var history struct {
mu sync.Mutex
pruned time.Time
counts map[string]counted // a day's file → its requests, as of its size
}
type counted struct {
size int64
n int
}
const dayForm = "2006-01-02"
// saveRoute adds a done route to its day. Errors are swallowed: keeping
// the history must never break a call.
func saveRoute(r Route) {
r.Seq = 0
b, err := json.Marshal(r)
if err != nil {
return
}
history.mu.Lock()
defer history.mu.Unlock()
dir := HistoryDir()
if os.MkdirAll(dir, 0o755) != nil {
return
}
f, err := os.OpenFile(filepath.Join(dir, r.Time.Local().Format(dayForm)+".jsonl"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return
}
f.Write(append(b, '\n'))
f.Close()
if time.Since(history.pruned) > time.Hour {
history.pruned = time.Now()
pruneHistory(dir, time.Now())
}
}
// pruneHistory gzips the days that are over, and drops the days older than
// historyDays, then the oldest until what is left fits historyBytes.
func pruneHistory(dir string, now time.Time) {
today := now.Local().Format(dayForm)
for _, d := range historyFiles(dir) {
if d.day < today && !strings.HasSuffix(d.path, ".gz") {
gzipFile(d.path)
}
}
oldest := now.Local().AddDate(0, 0, -historyDays+1).Format(dayForm)
days := historyFiles(dir)
var total int64
for _, d := range days {
total += d.size
}
for _, d := range days { // oldest first
if d.day >= oldest && total <= historyBytes || d.day == today {
continue
}
if os.Remove(d.path) == nil {
total -= d.size
}
}
}
func gzipFile(path string) {
b, err := os.ReadFile(path)
if err != nil {
return
}
var buf bytes.Buffer
z := gzip.NewWriter(&buf)
z.Write(b)
if z.Close() != nil || os.WriteFile(path+".gz", buf.Bytes(), 0o600) != nil {
return
}
os.Remove(path)
}
type dayFile struct {
day, path string
size int64
}
// historyFiles are the kept days, oldest first.
func historyFiles(dir string) []dayFile {
es, _ := os.ReadDir(dir)
var out []dayFile
for _, e := range es {
name := e.Name()
day, _, _ := strings.Cut(name, ".")
if _, err := time.Parse(dayForm, day); err != nil || !(strings.HasSuffix(name, ".jsonl") || strings.HasSuffix(name, ".jsonl.gz")) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, dayFile{day, filepath.Join(dir, name), info.Size()})
}
sort.Slice(out, func(i, j int) bool { return out[i].day < out[j].day })
return out
}
// HistoryDay is a kept day, and how many requests it had.
type HistoryDay struct {
Day string `json:"day"`
Requests int `json:"requests"`
}
// History is the days kept, newest first, and the routes of day, oldest
// first — its latest historyMax, and whether there were more.
func History(day string) (days []HistoryDay, routes []Route, cut bool) {
history.mu.Lock()
defer history.mu.Unlock()
routes = []Route{}
if history.counts == nil {
history.counts = map[string]counted{}
}
for _, d := range historyFiles(HistoryDir()) {
// a day is read only when it is the one asked for, or it has
// changed since it was counted: thirty days gzipped are not read
// again at every look
c, ok := history.counts[d.path]
if d.day == day || !ok || c.size != d.size {
n := 0
readDay(d.path, func(line []byte) {
n++
if d.day != day {
return
}
var r Route
if json.Unmarshal(line, &r) == nil {
routes = append(routes, r)
}
})
c = counted{d.size, n}
history.counts[d.path] = c
}
if n := c.n; n > 0 {
days = append([]HistoryDay{{d.day, n}}, days...)
}
}
sort.SliceStable(routes, func(i, j int) bool { return routes[i].Time.Before(routes[j].Time) })
if len(routes) > historyMax {
routes, cut = routes[len(routes)-historyMax:], true
}
return days, routes, cut
}
// readDay calls f with each line of a day's file.
func readDay(path string, f func([]byte)) {
file, err := os.Open(path)
if err != nil {
return
}
defer file.Close()
var r io.Reader = file
if strings.HasSuffix(path, ".gz") {
z, err := gzip.NewReader(file)
if err != nil {
return
}
defer z.Close()
r = z
}
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 64<<10), 16<<20)
for sc.Scan() {
if line := bytes.TrimSpace(sc.Bytes()); len(line) > 0 {
f(line)
}
}
}
+52
View File
@@ -0,0 +1,52 @@
package gateway
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// A done route is kept on disk by its day, a day that is over gzipped and
// still read, and a day too old or past the size kept dropped.
func TestHistoryKeepsDaysAndDropsOld(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
now := time.Now()
yday := now.AddDate(0, 0, -1)
saveRoute(Route{ID: 1, Time: yday, Model: "a", Done: true})
saveRoute(Route{ID: 2, Time: now, Model: "b", Done: true})
saveRoute(Route{ID: 3, Time: now.Add(time.Second), Model: "c", Done: true})
dir := HistoryDir()
old := filepath.Join(dir, now.AddDate(0, 0, -historyDays-2).Format(dayForm)+".jsonl")
os.WriteFile(old, []byte(`{"id":9,"model":"z"}`+"\n"), 0o600)
pruneHistory(dir, now)
if _, err := os.Stat(old); !os.IsNotExist(err) {
t.Fatal("a day older than the days kept is still there")
}
if _, err := os.Stat(filepath.Join(dir, yday.Format(dayForm)+".jsonl.gz")); err != nil {
t.Fatal("yesterday isn't gzipped:", err)
}
days, rs, _ := History(now.Format(dayForm))
if len(days) != 2 || days[0].Requests != 2 || days[1].Requests != 1 || len(rs) != 2 || rs[0].Model != "b" || rs[1].Model != "c" {
t.Fatalf("days %+v routes %+v", days, rs)
}
if _, rs, _ := History(yday.Format(dayForm)); len(rs) != 1 || rs[0].Model != "a" {
t.Fatalf("yesterday, gzipped: %+v", rs)
}
// over the bytes kept: the oldest go, today stays
big := strings.Repeat("x", historyBytes/3)
for i := 3; i <= 5; i++ {
os.WriteFile(filepath.Join(dir, now.AddDate(0, 0, -i).Format(dayForm)+".jsonl.gz"), []byte(big), 0o600)
}
pruneHistory(dir, now)
var left []string
for _, d := range historyFiles(dir) {
left = append(left, d.day)
}
want := []string{now.AddDate(0, 0, -4).Format(dayForm), now.AddDate(0, 0, -3).Format(dayForm), yday.Format(dayForm), now.Format(dayForm)}
if strings.Join(left, ",") != strings.Join(want, ",") {
t.Fatalf("kept %v, want %v", left, want)
}
}
+10
View File
@@ -212,6 +212,11 @@ func (t *trace) changed() {
func (t *trace) begin(r Route) *Route {
t.mu.Lock()
defer t.mu.Unlock()
if t.ids == 0 {
// ids go on from one run to the next, the history keeping them
// all: counted on from the time the gateway began
t.ids = time.Now().UnixMilli()
}
t.ids++
r.ID = t.ids
if r.Tries == nil {
@@ -246,6 +251,11 @@ func (t *trace) update(r *Route, f func(r *Route)) {
if r.Status >= 400 {
t.totals.Errors++
}
c := *r
c.Order = append([]Weighed(nil), r.Order...)
c.Left = append([]Weighed(nil), r.Left...)
c.Tries = append([]Try{}, r.Tries...)
go saveRoute(c)
}
t.changed()
r.Seq = t.seq
+5
View File
@@ -931,6 +931,11 @@ const I18N = {
"request {i} of {n}": "第 {i}/{n} 个请求",
"sent {time}": "发出于 {time}",
"starting…": "即将开始…",
"Replay from here": "从这里重放",
"Live": "实时",
"yesterday": "昨天",
"{n} on {day}": "{day}的 {n} 个请求",
"the last {n} of {day}": "{day}最后的 {n} 个请求",
"How the last request was routed": "最近一个请求是怎么路由的",
"How the request at {time} was routed": "{time} 的请求是怎么路由的",
"Waiting for a request": "等待请求",
+8
View File
@@ -304,3 +304,11 @@
.rp-mark.on { opacity: 1; transform: scaleY(1.5); }
.rp-head { position: absolute; top: 1px; width: 2px; height: 14px; margin-left: -1px; border-radius: 1px; background: var(--fg); pointer-events: none; }
@media (max-width: 520px) { .rp-req .rp-at { display: none; } }
/* the days the routing history keeps: live, then each day, newest first */
.rt-days { display: flex; gap: 4px; overflow-x: auto; padding: 0 2px 6px; scrollbar-width: none; }
.rt-days::-webkit-scrollbar { display: none; }
.rt-day { flex: none; display: inline-flex; align-items: baseline; gap: 5px; font-size: 12px; color: var(--muted); padding: 3px 9px; border: 1px solid var(--line); border-radius: 999px; background: none; white-space: nowrap; }
.rt-day small { font-size: 10.5px; color: var(--faint); font-variant-numeric: tabular-nums; }
.rt-day:hover { color: var(--fg); }
.rt-day.on { color: var(--fg); border-color: var(--fg); }
+63 -8
View File
@@ -81,11 +81,13 @@
const more = $("#rtMore");
const reqHead = el("div", "row-head"), reqNote = el("span", "note");
const reqs = el("div", "list rt-reqs");
// the days the history keeps on disk, to look back at one: see listed
const dayBar = el("div", "rt-days");
const actHead = el("div", "row-head"), actNote = el("span", "note");
const acts = el("div", "list rt-acts");
const hist = el("div", "rt-cols");
const colA = el("div", "rt-col"), colB = el("div", "rt-col");
colA.append(reqHead, reqs);
colA.append(reqHead, dayBar, reqs);
colB.append(actHead, acts);
hist.append(colA, colB);
more.append(hist);
@@ -442,7 +444,7 @@
// ---------- state ----------
const routes = new Map(); // id → the latest of each route
let seq = 0, mine = true, loaded = false;
let seq = 0, mine = true, loaded = false, daysAt = 0;
let cur = null; // the route the header and the log tell of: the newest played
let pinned = null; // a past route picked from the strip
let rows = new Map(); // id → { li, wire, st, bi, tg, w, rid, up }
@@ -451,6 +453,7 @@
let sets = []; // the account sets on the stage, in the order they came
const playing = new Map(); // the routes being played → the gen playing each
let rp = null; // a replay playing: see replay
let day = "", days = [], past = [], pastCut = false; // a kept day looked at ("" for live), the days kept, its routes
const src = () => rp ? rp.routes : routes; // the routes the stage plays from
const LINGER = 12e3; // how long an agent's last request stays on the stage
let gen = 0, trips = [], waiters = [];
@@ -851,10 +854,17 @@
const again = el("button", "text", t("Replay"));
again.onclick = () => replay([r], pinned);
logHead.append(again);
// and on from it: the requests listed after it, as they came
const on = listed().filter((x) => x.id >= r.id);
if (pinned && on.length > 1) {
const from = el("button", "text", t("Replay from here"));
from.onclick = () => replay(on, pinned);
logHead.append(from);
}
}
if (pinned && !rp) {
const live = el("button", "text", t("Back to live"));
live.onclick = () => { pinned = null; cur = newest(); sync(true); renderAll(); };
live.onclick = () => { if (day) lookAt(""); else { pinned = null; cur = newest(); sync(true); renderAll(); } };
logHead.append(live);
}
const items = [];
@@ -896,16 +906,59 @@
}
// the requests the gateway keeps, newest first: pick one to see how it was routed
// listed: the requests the list shows, newest first — the gateway's last
// few, or a day the history keeps
const listed = () => (day ? past : [...routes.values()]).slice().sort((a, b) => b.id - a.id);
const dayName = (d) => {
const x = new Date(d + "T12:00:00"), n = new Date(), y = new Date(n.getTime() - 864e5);
return x.toDateString() === n.toDateString() ? t("today") : x.toDateString() === y.toDateString() ? t("yesterday")
: x.toLocaleDateString([], { month: "short", day: "numeric", weekday: "short" });
};
async function loadDays(d) {
try {
const res = await (await fetch("/api/gateway/history?day=" + encodeURIComponent(d || ""))).json();
days = res.days || [];
if (d && d === day) { past = res.routes || []; pastCut = !!res.cut; }
} catch {}
renderDays();
}
async function lookAt(d) {
if (rp) endReplay(true);
day = d;
past = [];
if (d) await loadDays(d);
pinned = null;
const r = d ? listed()[0] : newest();
if (r && d) pick(r);
else if (r) { stopPlays(); cur = r; sync(true); say(affWhy(r, true) || ruleWhy(r, true) || firstWhy(r)); }
renderAll();
}
function renderDays() {
const b = (d, label, n) => {
const x = el("button", "rt-day" + (d === day ? " on" : ""));
x.append(el("span", "", label), ...(n != null ? [el("small", "", String(n))] : []));
x.setAttribute("aria-pressed", String(d === day));
x.onclick = () => { if (d !== day) lookAt(d); };
return x;
};
const key = day + "|" + days.map((d) => d.day + ":" + d.requests).join(",");
if (dayBar.dataset.key === key) return;
dayBar.dataset.key = key;
dayBar.replaceChildren(b("", t("Live")), ...days.map((d) => b(d.day, dayName(d.day), d.requests)));
dayBar.hidden = !days.length && !day;
}
function renderHist() {
const rs = [...routes.values()].sort((a, b) => b.id - a.id);
hist.hidden = !rs.length;
const rs = listed();
hist.hidden = !rs.length && !day && !days.length;
reqHead.replaceChildren(el("span", "label", t("Requests")), el("span", "grow"), reqNote);
if (rs.filter((r) => r.done).length > 1 && !rp) {
const all = el("button", "text", t("Replay them all"));
all.onclick = () => replay(rs, pinned);
reqHead.append(all);
}
reqNote.textContent = t("the last {n} the gateway keeps", { n: rs.length });
reqNote.textContent = day ? t(pastCut ? "the last {n} of {day}" : "{n} on {day}", { n: rs.length, day: dayName(day) })
: t("the last {n} the gateway keeps", { n: rs.length });
renderDays();
reqs.replaceChildren(...rs.map((r) => {
const [said, how, tr] = outcome(r);
const b = el("button", "rt-req " + how);
@@ -1319,7 +1372,7 @@
rp = null;
rbar.hidden = true;
if (stop) stopPlays();
const b = p.back && routes.get(p.back.id);
const b = p.back && (routes.get(p.back.id) || (day && past.find((x) => x.id === p.back.id)));
pinned = b || null;
cur = b || newest();
if (cur) { sync(true); renderAll(); }
@@ -1383,7 +1436,7 @@
requestAnimationFrame(frame);
}
// countdowns tick once a second
setInterval(() => { if (shown()) { if (!pinned && loaded && cur) sync(); render(); renderActs([...routes.values()].sort((a, b) => b.id - a.id)); } }, 1000);
setInterval(() => { if (shown()) { if (!pinned && loaded && cur) sync(); render(); renderActs(listed()); } }, 1000);
function offline(msg) {
off.textContent = msg;
@@ -1446,6 +1499,8 @@
}
for (const id of [...routes.keys()].sort((a, b) => a - b).slice(0, -60)) routes.delete(id);
loaded = true;
// a request done is a day's count grown: heard of now and then
if (first || (d.routes.some((r) => r.done) && performance.now() - daysAt > 15e3)) { daysAt = performance.now(); loadDays(); }
if (first) {
offline("");
// the agents' names come with the app's state, which may not be here yet
+6
View File
@@ -34,6 +34,12 @@ func traceRoutes(mux *http.ServeMux) {
out.Now = time.Now()
writeJSON(rw, out)
})
// the routes of a day gone by, from the history the gateway keeps on
// disk — read whichever magpie serves the gateway
mux.HandleFunc("GET /api/gateway/history", func(rw http.ResponseWriter, r *http.Request) {
days, routes, cut := gateway.History(r.URL.Query().Get("day"))
writeJSON(rw, map[string]any{"days": days, "routes": routes, "cut": cut})
})
// an account's rest lifted by hand: verified with its vendor, say
mux.HandleFunc("POST /api/gateway/unrest", func(rw http.ResponseWriter, r *http.Request) {
var in struct {