* sessions: dsh、ZCode、WorkBuddy 的会话文件里有各自花掉的 token,用量台账从来不读
Sessions 页面一直都在读这些 agent 自己的会话文件,用量台账没有:用量页只显示网关本
身应答过的调用——也就是装上 magpie 之前的什么都没有,而一个直接指向厂商的 dsh 更
是什么都没有。本机上装 magpie 之前就已经在用这些 agent(用它们自带的模型),那段
用量一直对不上账。
* 新增三类"整文件读取"的调用来源,走 wholeCallReaders(它们的调用不是一行一条,
或者文件不能从中间接着读):dsh 的会话是 zstd 分帧追加的;ZCode 用的是 OpenCode
那几张表;WorkBuddy 的同一句回复会在多行里重复同一个 messageId。三者的 token
语义各自换算成 Call 的 Input/Output/CacheRead/CacheWrite,调用缓存跟着记一份(v9)。
* 每个来源都跳过经由 magpie 网关发出的调用:网关自己记过那一本账,还带着真正应答
的供应商和账号;dsh 发往网关的请求不带会话头,两边事后对不上(token 数每次不同、
又没有可配对的 id),硬读就是同一通调用记两次。dsh 认它被配置去的那个 provider
(dshGatewayProvider:magpie / dial),ZCode 和 OpenCode 认路由里写的 provider
(ocGatewayProvider)。WorkBuddy 那部分见下一个提交。
* WorkBuddy 的调用里,同一句回复的多行只记最后一次(它带这个回复的总量);而它从
旧历史搬过来的回复一条 messageId 都没有,每一条都是独立的一通调用——会话汇总的
读法(workbuddyLine)就是这么数的,这里照它来,否则台账会比 Sessions 页面少算,
正是本次要消灭的那类"两页对不上",只是方向相反。
* dsh 的一通调用保留它文件里记的两样东西:被配置去的供应商(Upstream,作为历史,
从来不当成路由的证据),以及厂商真正应答的模型——回复的 replayState 里那个和
请求模型不一致时,Model 记应答的、Requested 记被请求的,和 Claude Code 的文件
一样的读法,好让请求表的若干列有东西可填。
* 会话的 fork:dsh 的会话从另一个会话续起来时会带上对方已写入的事件(isSeeded /
seedLength,session/end-seed 标记之后的不算),parser 跳过的这里同样跳过,否则
同一通调用会在每个 fork 上再花一次。
* WorkBuddy 的 subagents/ 子目录是父会话自己的调用来源(它带自己的消息 id,会话
归到父会话名下);调用这条路不读 file.main,那个标记是 Sessions 页面给"会话自己的
文件、不是子代理的"用的。
夹具:dsh 的三个会话(普通、带供应商与应答模型、以及一通经网关的),ZCode 的两条
(其中 ses_zcA 那条的 providerID 是 magpie,应被跳过),WorkBuddy 的既有会话(m1 的
usage 同时落在 assistant message 和它最后一个 function_call 上,只能记一次;旧历史
三条无 messageId 的回复各记一次)和一通经网关、一通走用户自建端点的会话。
调用缓存升到 v9:v8 里既没有 dsh 的供应商和应答模型,也没有"跳过网关调用"这件事,
旧缓存会继续把旧账 Serve 出来。
TestDshCalls、TestZCodeCalls、TestWorkBuddyCalls 是新的;把跳过网关调用那几行、以及
WorkBuddy 旧历史那条判据去掉,它们必挂、加上必过(都实际跑过反向验证)。
验证:go vet;go test ./internal/sessions(只余 TestCursor,Windows 路径前缀问题,
改动前同样失败);go build(windows)。
* usage: 请求页把"magpie 转的"和"它只读到的"分成两个数,GUI 跟着拆成两栏
用量页一直只显示网关自己应答过的调用,而现在它还要读 agent 自己会话文件里的调用:
两本账混在一行里,读者分不清哪一段是 magpie 转的、哪一段只是它从文件里读到的,
而总数事实上几乎全部来自后者(本机 30 天 847M token 里,网关只转了 2.4%)。Summary
里的两个来源卡片也加不出这个数来:它们只算 input 和 output,上面的 KPI 把缓存的
token 也算在内,于是"全部请求"读作 13.6M,而"Tokens"是 847M。
* usage.RequestPage 和 usage.Filter 各多一样东西:前者在 Sum 之外带上 Through 和
Direct(网关应答的,和从会话文件读到的),后者多一个 Through 维度。两种建页路径
(流式的 queryPage 和治疗桌用的 pageFromLedger)都做同一个拆分,两边一致——
TestCompactPageMatchesLedger 现在也覆盖两个来源筛选。
* 每一行本来就是按 Source 区分这两类的("log" 是会话文件里的调用,其余走的网关),
拆分就落在它上面;网关自己拒掉的调用两边都不算(页面在总数下面也这么说),所以
SourceOf 对它返回 "",点哪一栏都同样把它排除,两栏自己的计数本来也不含它。
* 两栏自己的数字是在"来源"这个挑选被清空的情况下数的,和各维度分组一样:三栏是读者
来回切换的地方,各自得一直报着自己的数,否则点了其中一栏另一栏读作 0,且没有回
到全部的路。
* /api/usage/requests 支持 via=through|direct;GUI 那一侧的三个格子、计数单位、
方块样式在下一个提交里。
* Filter.Via 这个名字和 Record.Via(转发调用的那台电脑,#542)重了,改叫 Filter.Through。
Sessions 页列表长度那件事(sessionListLimit)拆到单独的 PR 里,不在这里。
验证:go vet;go test ./internal/usage(只余 TestOTelRetryAfterBound,计时的 ±30ms,
origin/main 同样失败);go build(windows)。
* gui: 请求页把来源拆成三栏,汇总那一排也改成方块
用途页的请求栏一直只有一本账(Sum),而现在它既显示网关应答的调用,也显示从各 agent
自己会话文件里读到的调用。三个格子画在上方汇总那一排方块里,样式一致:三张各自带
边框、彼此留缝的卡片,窄屏(≤620px)下竖排。
* 每栏是一个 button:点一下就只列这一来源的请求,再点一下、或点"全部请求"回到
全部;被选中的那一栏底下有一道本主题色的线。aria-pressed 标出当前选中的是哪一栏。
* 计数后面跟单位("147 请求"),免得一个光秃秃的数字被读成下面的 token;token 是
四项合计,和上面 KPI 的算法一致。
* 三个格子在重绘之间是同一批元素、原地改(class / aria / 文本 / onclick),不是每次
replaceChildren 重建——同一份 app.js 里 5810 行的规定就是这么写的("The switch is
the same element across redraws, changed in place, so one being pressed or focused is
never swapped out"),正在点的那个不该被换掉。
* 汇总那一排本身(五张 KPI 卡)改成同样的方块:各自一张卡、有缝,边框写成
var(--line) 而不是 --card-edge(那是面板用的变量,这些卡在独立窗口里也有)。
≤860px 两列排布时卡片之间的那几条线照旧:奇数位去掉左边框、第三张起加上边框,
是 e0eaf5b4 带 #799 的老规则,这里只是跟着搬了位置,没有删。三张来源格是 button,
不参与这套分隔线。
* 供应商·账号 一栏:来自会话文件的行显示它文件里记的那个供应商 id(Codex 一向
如此),有账号时仍是账号优先,什么都没有才说"本地会话"。"本地会话"角标的条件
跟着放宽到账号或供应商任一存在,提示分两种说法:文件记了供应商的,说明这是它
被配置去调用的那个记录、不是 magpie 看到的路由(不等于官方供应商);只认得出
账号的,沿用原来那句"仅当本地元数据能明确识别时显示账号"。
* 窗口参数支持 via,用后从地址栏清掉,和 tab/provider/agent 等一致。
* 新增的串子在 en/zh/ja/de 四种语言里都有("{n} tokens" 上游已有,不重复加,
i18n-keys 绿)。
验证:node --check(app.js、i18n.js);go vet;go build(windows)。
界面效果在本机用改动后的源码构建的二进制上过目:三栏的切换与计数、方块样式、
窄屏竖排、请求表新增的会话文件行(含供应商 id)。
浏览器套件(chromium 与 webkit、en/zh):session-identity、usage-ledger、
ledger-protocol、number-units、usage-ledger-chart、usage-ttft-rounding、
usage-chart-axis、usage-fold、web-page、usage-accounts、i18n-keys 全绿;
ledger-hscroll、usage-speed、click-scroll 全绿;gui-ja、gui-de 各一条失败、
usage-ledger-detail 六条失败,均在本机 origin/main 上同样失败,与本次改动无关。
usage-ledger-chart 新钉了 ≤620px 下 KPI 卡与来源格的分隔线(把 app.css 换回上游版本
它必挂,实测过)。
---------
Co-authored-by: wizzy-yang <wizzy-yang@users.noreply.github.com>
430 lines
12 KiB
Go
430 lines
12 KiB
Go
package sessions
|
|
|
|
// Request metadata uses checksummed append-only frames. A growing session saves
|
|
// only new/changed calls and its parser continuation. Corrupt shards rebuild
|
|
// from source. Sessions summaries never load these files.
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"encoding/gob"
|
|
"hash/crc32"
|
|
"hash/fnv"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// v3 persists Codex cumulative counters explicitly; v2 shards can contain
|
|
// inflated deltas after a reload, so rebuild their derived rows from source.
|
|
// v4: a Codex call's input no longer holds what it wrote to the cache (#589).
|
|
// v5: an OpenCode call keeps the effort its prompt asked for (#680).
|
|
// v6: Codex response records and compact disk-only parser state.
|
|
// v7: reconcile recent Claude message revisions.
|
|
// v8: a Claude Code call's 1-hour cache writes.
|
|
// v9: a dsh call carries the provider it named and the model that answered,
|
|
// and a call through magpie's own gateway is left out of dsh's (the gateway
|
|
// logs it itself).
|
|
// v10: the calls ZCode's and WorkBuddy's session files hold are read too, and
|
|
// WorkBuddy's that went through the gateway are left out of them as a dsh
|
|
// one is.
|
|
// What each version names is what a cache of the one before it cannot serve:
|
|
// the version is part of the directory's name, so a cache written by an older
|
|
// one is never read (pruneCalls).
|
|
const callCacheVersion = "calls-v10"
|
|
const maxKeptCalls = 131072
|
|
const maxKeptFiles = 64
|
|
|
|
var callsMu sync.Mutex
|
|
var callIO [64]sync.Mutex
|
|
var callGeneration uint64
|
|
var callCache = map[string]*callFile{}
|
|
var callCounts = map[string]int{} // small allocation hints, never call records
|
|
var callOrder []string
|
|
var callRoot string
|
|
|
|
func callCacheDir() string { return filepath.Join(filepath.Dir(CachePath()), callCacheVersion) }
|
|
func callCachePath(path string) string { return filepath.Join(callCacheDir(), hashHead(path)+".gob") }
|
|
|
|
func resetCalls() {
|
|
callsMu.Lock()
|
|
defer callsMu.Unlock()
|
|
callCache, callOrder = map[string]*callFile{}, nil
|
|
callContinuations, callContinuationOrder = map[string]callContinuation{}, nil
|
|
callCounts = map[string]int{}
|
|
callRoot = ""
|
|
callGeneration++
|
|
resetOCCalls()
|
|
// ZCode's listing and WorkBuddy's gateway ids name another HOME's files
|
|
// and models.json: a read after a Reset() would otherwise hand back
|
|
// sources that are not here. Both are stamped, so the stale stamp alone
|
|
// would miss; clearing them makes a new HOME read its own
|
|
zcCallList.Lock()
|
|
zcCallList.stamp, zcCallList.files = "", nil
|
|
zcCallList.Unlock()
|
|
wbGateway.Lock()
|
|
wbGateway.stamp, wbGateway.ids = "", nil
|
|
wbGateway.Unlock()
|
|
}
|
|
|
|
func pruneCalls(files []file) {
|
|
root := callCacheDir()
|
|
callsMu.Lock()
|
|
moved := callRoot != root
|
|
if moved {
|
|
callGeneration++
|
|
callCache, callOrder, callRoot = map[string]*callFile{}, nil, root
|
|
callCounts = map[string]int{}
|
|
}
|
|
on := make(map[string]bool, len(files))
|
|
shards := make(map[string]bool, len(files))
|
|
for _, f := range files {
|
|
on[f.path] = true
|
|
shards[hashHead(f.path)+".gob"] = true
|
|
}
|
|
order := callOrder[:0]
|
|
for _, path := range callOrder {
|
|
if on[path] {
|
|
order = append(order, path)
|
|
} else {
|
|
delete(callCache, path)
|
|
}
|
|
}
|
|
callOrder = order
|
|
for path := range callCounts {
|
|
if !on[path] {
|
|
delete(callCounts, path)
|
|
}
|
|
}
|
|
order = callContinuationOrder[:0]
|
|
for _, path := range callContinuationOrder {
|
|
if on[path] {
|
|
order = append(order, path)
|
|
} else {
|
|
delete(callContinuations, path)
|
|
}
|
|
}
|
|
callContinuationOrder = order
|
|
|
|
trimCallRevisions(time.Now())
|
|
callsMu.Unlock()
|
|
if moved {
|
|
// an older version's shards are never read again
|
|
olds, _ := filepath.Glob(filepath.Join(filepath.Dir(root), "calls-v*"))
|
|
for _, old := range olds {
|
|
if old != root {
|
|
_ = os.RemoveAll(old)
|
|
}
|
|
}
|
|
}
|
|
// IO never holds the metadata lock. A grace period protects live writers.
|
|
entries, _ := os.ReadDir(root)
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
orphan := strings.HasSuffix(e.Name(), ".gob") && !shards[e.Name()]
|
|
if strings.HasPrefix(e.Name(), "calls-") && strings.HasSuffix(e.Name(), ".tmp") {
|
|
if st, err := e.Info(); err == nil && time.Since(st.ModTime()) > time.Hour {
|
|
orphan = true
|
|
}
|
|
}
|
|
if orphan {
|
|
_ = os.Remove(filepath.Join(root, e.Name()))
|
|
}
|
|
}
|
|
}
|
|
|
|
func keepCalls(path string, st *callFile) {
|
|
defer trimCallRevisions(time.Now())
|
|
callCounts[path] = len(st.Calls)
|
|
delete(callCache, path)
|
|
order := callOrder[:0]
|
|
for _, p := range callOrder {
|
|
if p != path {
|
|
order = append(order, p)
|
|
}
|
|
}
|
|
callOrder = order
|
|
if len(st.Calls) > maxKeptCalls {
|
|
return
|
|
}
|
|
n := len(st.Calls)
|
|
for _, s := range callCache {
|
|
n += len(s.Calls)
|
|
}
|
|
for len(callOrder) > 0 && (n > maxKeptCalls || len(callOrder) >= maxKeptFiles) {
|
|
p := callOrder[0]
|
|
n -= len(callCache[p].Calls)
|
|
delete(callCache, p)
|
|
callOrder = callOrder[1:]
|
|
}
|
|
callCache[path] = st
|
|
callOrder = append(callOrder, path)
|
|
}
|
|
|
|
// wholeCallReaders read an agent's calls from one session file at a time,
|
|
// rather than a line at a time: its calls are not lines, or its file cannot
|
|
// be read on from the middle of one. Every agent in callSources that is not
|
|
// in callFiles needs an entry here, or its calls are never read.
|
|
var wholeCallReaders = map[string]func(file) (*callFile, bool){
|
|
"opencode": readOpenCodeCalls,
|
|
"zcode": readZCodeCalls,
|
|
"dsh": readDshCalls,
|
|
"workbuddy": readWorkBuddyCalls,
|
|
}
|
|
|
|
func readCalls(f file) *callFile {
|
|
h := fnv.New32a()
|
|
_, _ = h.Write([]byte(f.path))
|
|
lock := &callIO[h.Sum32()%uint32(len(callIO))]
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
callsMu.Lock()
|
|
old, generation := callCache[f.path], callGeneration
|
|
callsMu.Unlock()
|
|
if old == nil {
|
|
old = restoreCallContinuation(loadCalls(f))
|
|
}
|
|
if old != nil && old.Size == f.size && old.Mod == f.mod.UnixNano() {
|
|
callsMu.Lock()
|
|
if generation == callGeneration {
|
|
keepCalls(f.path, old)
|
|
}
|
|
callsMu.Unlock()
|
|
return old
|
|
}
|
|
if f.cold || wslCold(f.path) {
|
|
// in a stopped WSL distro: opening it would start it
|
|
if old != nil {
|
|
return old
|
|
}
|
|
return &callFile{Path: f.path, Agent: f.agent}
|
|
}
|
|
// Agents whose calls are read whole rather than line by line: a session
|
|
// that changed is read again, and one that did not is kept. OpenCode's
|
|
// and ZCode's are rows of a database or JSON documents; dsh's file is
|
|
// packed in frames, so it cannot be read on from the middle of one.
|
|
if read, ok := wholeCallReaders[f.agent]; ok {
|
|
st, readOK := read(f)
|
|
if !readOK && old != nil {
|
|
return old // not readable now: what was read before
|
|
}
|
|
if readOK {
|
|
st.Size, st.Mod = f.size, f.mod.UnixNano()
|
|
writeCalls(st, 0, false)
|
|
}
|
|
callsMu.Lock()
|
|
if readOK && generation == callGeneration {
|
|
keepCalls(f.path, st)
|
|
}
|
|
callsMu.Unlock()
|
|
return st
|
|
}
|
|
st := prepareCalls(f, old)
|
|
continued := old != nil && st.Off == old.Off && st.Off > 0
|
|
start := 0
|
|
if continued {
|
|
start = len(old.Calls)
|
|
}
|
|
off, err := scanAt(f.path, st.Off, func(b []byte) bool { return callHead(st, b) }, func(b []byte, start, end int64) bool {
|
|
st.At, st.End = start, end
|
|
if f.agent == "codex" {
|
|
codexCallLine(st, b)
|
|
} else {
|
|
claudeCallLine(st, b)
|
|
}
|
|
return true
|
|
})
|
|
if err == nil {
|
|
st.Off, st.Size, st.Mod = off, f.size, f.mod.UnixNano()
|
|
st.ContentHash = prefixHash(f.path, f.size)
|
|
writeCalls(st, start, continued)
|
|
}
|
|
// Do not turn a one-time historical scan into an indefinitely retained
|
|
// window. Previously retained windows may survive idle time in spare slots.
|
|
if st.Claude != nil && (old == nil || old.Claude == nil) && !workingRevision(f.mod.UnixNano(), time.Now()) {
|
|
st = st.withoutRevisions()
|
|
}
|
|
|
|
st.dirty = nil
|
|
callsMu.Lock()
|
|
if generation == callGeneration {
|
|
keepCalls(f.path, st)
|
|
}
|
|
callsMu.Unlock()
|
|
return st
|
|
}
|
|
|
|
// Common strings are stored once per append frame; File is implied by State.
|
|
type callPatch struct {
|
|
Index int
|
|
Call Call
|
|
Text [12]uint32
|
|
Began time.Time
|
|
}
|
|
type callFrame struct {
|
|
State callDiskState
|
|
Strings []string
|
|
Patches []callPatch
|
|
}
|
|
|
|
// Disk state contains only scalar continuation metadata. Even a nil runtime
|
|
// index makes gob emit its recursive type descriptors in every append frame.
|
|
// Keep those types entirely outside the wire schema.
|
|
type callDiskState struct {
|
|
ContentHash string
|
|
Size, Mod, Off int64
|
|
Head string
|
|
HeadSize int
|
|
Path, Agent string
|
|
At, End, AsstEnd int64
|
|
Session, Requested string
|
|
LastUser, LastAsst time.Time
|
|
}
|
|
|
|
func diskCallState(s *callFile) callDiskState {
|
|
return callDiskState{s.ContentHash, s.Size, s.Mod, s.Off, s.Head, s.HeadSize,
|
|
s.Path, s.Agent, s.At, s.End, s.AsstEnd, s.Session, s.Requested, s.LastUser, s.LastAsst}
|
|
}
|
|
func (s callDiskState) restore() *callFile {
|
|
return &callFile{ContentHash: s.ContentHash, Size: s.Size, Mod: s.Mod, Off: s.Off,
|
|
Head: s.Head, HeadSize: s.HeadSize, Path: s.Path, Agent: s.Agent, At: s.At, End: s.End,
|
|
AsstEnd: s.AsstEnd, Session: s.Session, Requested: s.Requested, LastUser: s.LastUser, LastAsst: s.LastAsst}
|
|
}
|
|
|
|
func callText(c *Call) [12]*string {
|
|
return [12]*string{&c.Agent, &c.Session, &c.Model, &c.Requested, &c.Effort, &c.Cwd, &c.Upstream, &c.AccountID, &c.UserID, &c.Error, &c.ErrorText, &c.File}
|
|
}
|
|
|
|
func loadCalls(f file) *callFile {
|
|
in, err := os.Open(callCachePath(f.path))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer in.Close()
|
|
var st *callFile
|
|
var calls []Call
|
|
msgs := map[string]int{}
|
|
began := map[string]time.Time{}
|
|
for {
|
|
var header [8]byte
|
|
_, err = io.ReadFull(in, header[:])
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
size := binary.LittleEndian.Uint32(header[:4])
|
|
if size == 0 || size > 256<<20 {
|
|
return nil
|
|
}
|
|
data := make([]byte, int(size))
|
|
if _, err = io.ReadFull(in, data); err != nil || crc32.ChecksumIEEE(data) != binary.LittleEndian.Uint32(header[4:]) {
|
|
return nil
|
|
}
|
|
var frame callFrame
|
|
if gob.NewDecoder(bytes.NewReader(data)).Decode(&frame) != nil || frame.State.Path != f.path || frame.State.Agent != f.agent {
|
|
return nil
|
|
}
|
|
if st != nil && frame.State.Off < st.Off {
|
|
return nil
|
|
}
|
|
for _, p := range frame.Patches {
|
|
if p.Index < 0 || p.Index > len(calls) {
|
|
return nil
|
|
}
|
|
for i, field := range callText(&p.Call) {
|
|
if int(p.Text[i]) >= len(frame.Strings) {
|
|
return nil
|
|
}
|
|
*field = frame.Strings[p.Text[i]]
|
|
}
|
|
p.Call.File = f.path
|
|
if p.Index == len(calls) {
|
|
calls = append(calls, p.Call)
|
|
} else {
|
|
calls[p.Index] = p.Call
|
|
}
|
|
if p.Call.Msg != "" {
|
|
msgs[p.Call.Msg] = p.Index
|
|
if !p.Began.IsZero() {
|
|
began[p.Call.Msg] = p.Began
|
|
}
|
|
}
|
|
}
|
|
st = frame.State.restore()
|
|
}
|
|
if st == nil {
|
|
return nil
|
|
}
|
|
st.Calls, st.Msgs, st.Began = calls, msgs, began
|
|
return st
|
|
}
|
|
|
|
func writeCalls(st *callFile, start int, appendOnly bool) {
|
|
frame := callFrame{State: diskCallState(st), Strings: []string{""}}
|
|
dict := map[string]uint32{"": 0}
|
|
add := func(i int) {
|
|
p := callPatch{Index: i, Call: st.Calls[i], Began: st.Began[st.Calls[i].Msg]}
|
|
p.Call.File = ""
|
|
for j, field := range callText(&p.Call) {
|
|
id, ok := dict[*field]
|
|
if !ok {
|
|
id = uint32(len(frame.Strings))
|
|
dict[*field] = id
|
|
frame.Strings = append(frame.Strings, *field)
|
|
}
|
|
p.Text[j] = id
|
|
*field = ""
|
|
}
|
|
frame.Patches = append(frame.Patches, p)
|
|
}
|
|
for i := 0; i < start; i++ {
|
|
if st.dirty[i] {
|
|
add(i)
|
|
}
|
|
}
|
|
for i := start; i < len(st.Calls); i++ {
|
|
add(i)
|
|
}
|
|
var data bytes.Buffer
|
|
if gob.NewEncoder(&data).Encode(frame) != nil {
|
|
return
|
|
}
|
|
var header [8]byte
|
|
binary.LittleEndian.PutUint32(header[:4], uint32(data.Len()))
|
|
binary.LittleEndian.PutUint32(header[4:], crc32.ChecksumIEEE(data.Bytes()))
|
|
dir := callCacheDir()
|
|
if os.MkdirAll(dir, 0700) != nil {
|
|
return
|
|
}
|
|
var out *os.File
|
|
var err error
|
|
if appendOnly {
|
|
out, err = os.OpenFile(callCachePath(st.Path), os.O_WRONLY|os.O_APPEND, 0600)
|
|
} else {
|
|
out, err = os.CreateTemp(dir, "calls-*.tmp")
|
|
}
|
|
if err != nil {
|
|
return
|
|
}
|
|
if !appendOnly {
|
|
defer os.Remove(out.Name())
|
|
}
|
|
_, err = out.Write(header[:])
|
|
if err == nil {
|
|
_, err = out.Write(data.Bytes())
|
|
}
|
|
if closeErr := out.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
if err == nil && !appendOnly {
|
|
_ = os.Rename(out.Name(), callCachePath(st.Path))
|
|
}
|
|
}
|