Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
run: go test -race ./internal/usage ./internal/store ./internal/server
- name: Native macOS install, restart and uninstall
if: runner.os == 'macOS'
run: bash scripts/test-macos.sh
run: for attempt in 1 2 3; do bash scripts/test-macos.sh; done

cross-build:
name: cross-build
Expand Down
19 changes: 19 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,26 @@ permissions:
contents: write

jobs:
macos:
name: release macOS (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [macos-latest, macos-15-intel]
runs-on: ${{ matrix.os }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: "1.26.8"
cache: true
- run: go test ./internal/platform ./internal/app
- name: Repeated native service lifecycle checks
run: for attempt in 1 2 3; do bash scripts/test-macos.sh; done

release:
needs: macos
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
Expand Down
2 changes: 2 additions & 0 deletions docs/macos.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Default locations:

Uninstall unloads the agent and removes the installed executable, retaining the database. `uninstall --purge` additionally deletes only a validated, marked state directory. Optional in-app updates use a separate launchd helper so stopping the main agent does not kill the updater. Downloads are checked against SHA256SUMS; program and data backups are retained, with startup-failure rollback as on Windows/Linux.

After launchd stops its job, the application waits for the original process to exit. An exited zombie or a PID now owned by another process is treated as the old process having ended; no additional signal is sent to a reused PID. Native CI and release checks repeat installation, uninstall and reinstall three times on both architectures.

These command-line binaries do not have an Apple Developer ID signature or Apple notarization. If macOS blocks a browser-downloaded binary, verify the checksum and follow [Apple's instructions for opening a trusted app](https://support.apple.com/en-us/102445). Do not disable Gatekeeper globally. Native CI covers macOS Intel and Apple Silicon; this is not a notarized `.app`/`.pkg` distribution.

The service follows [Apple's LaunchAgent model](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html). macOS includes `launchctl` for loading and unloading these jobs, as described in [Apple's Terminal guide](https://support.apple.com/guide/terminal/script-management-with-launchd-apdc6c1077b-5d5d-4d35-9c19-60f2397b2369/mac).
2 changes: 2 additions & 0 deletions docs/releases/v2.6.0.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# codex-usage v2.6.0

此版本保留为预发布记录:合并后的重复检查发现 macOS Intel 存在退出时序问题,可能导致卸载/更新误报 PID 不匹配。请使用修复此问题的 v2.6.1 或后续稳定版。

修复计量与查询一致性,新增主任务/子任务树及 macOS 安装分发。

- **Turn 累计计量**:区分 Session 与 Turn 累计,新 Turn 数值小于、等于或大于上一 Turn 都可正确计量;保留旧格式、fork 排重、模式分类和分类修正。
Expand Down
36 changes: 36 additions & 0 deletions docs/releases/v2.6.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# codex-usage v2.6.1

修复计量与查询一致性,新增主任务/子任务树及 macOS 安装分发。

此稳定版包含 v2.6.0 的全部功能,并修复原生重复检查发现的 macOS 退出时序问题:launchd bootout 后等待原进程退出,将僵尸进程和已被复用的 PID 视为原进程已结束,避免再次向该 PID 发送信号或误报安装路径不匹配。CI 和 Release 均在两个 macOS 架构上重复执行三轮安装、卸载和重装;发布必须等待原生检查通过。

- **Turn 累计计量**:区分 Session 与 Turn 累计,新 Turn 数值小于、等于或大于上一 Turn 都可正确计量;保留旧格式、fork 排重、模式分类和分类修正。
- **可靠入账**:事件、模式、累计进度和文件游标原子提交;存储错误回滚并保留重试位置,半行 JSONL 不会被提前跳过。
- **跨进程刷新**:数据库版本号随事务持久化;任务与费用使用一致快照,本机价格配置变化也能使缓存失效。
- **搜索与查询**:搜索确定任务后,用相同筛选范围计算 Token 与费用;先选任务、聚合事件再关联元数据。相同 114,960 条事件快照的三轮本机比较,列表中位耗时 2.75 s → 0.43 s,任务费用接口 3.12 s → 0.83 s(均未命中应用缓存)。
- **统一时区**:计量时区保存在数据库并显示在页面底部;异地浏览器按服务端日期和真实小时边界显示,支持夏令时重复小时与 23/25 小时日期。
- **任务树**:明细支持列表/树切换、折叠、根任务分页、本任务及含子任务用量;仅使用明确的父子元数据,fork 来源单列,缺失父任务或循环关系会明确提示。
- **macOS**:新增 Apple Silicon / Intel 单文件程序、用户 LaunchAgent 安装卸载、浏览器打开及可选更新;原有 Windows / Linux 与 Fast 费用功能保留。
- 发布构建更新为 Go 1.26.8,产物为六个平台程序与 `SHA256SUMS`。中英文、浅深色、窄屏和合成 Demo 同步更新。

**历史数据保留:** 升级不会自动重算旧账。本次计量修复对新增记录生效;若要修正旧版本已经漏计的历史,需要先备份并核对源 JSONL 覆盖,再显式运行 `codex-usage scan --rebuild`。源文件已删除的历史不能靠重建恢复。旧 Session 出现在新的物理文件且无法验证排重边界时,会请求确认重建。

macOS 程序未经 Apple Developer ID 签名/公证;请核验校验和,参见 [macOS 安装说明](https://github.com/zJay26/codex-usage/blob/v2.6.1/docs/macos.md)。无需也不建议全局关闭 Gatekeeper。

详见 [计量、任务树与验证说明](https://github.com/zJay26/codex-usage/blob/v2.6.1/docs/accounting-v2.6.md)。现有 v2.5.0 安装可从“软件更新”选择升级。

---

Fix accounting and query consistency; add task trees and native macOS installation.

This stable release includes all v2.6.0 features and fixes a macOS shutdown race found by repeated native checks. After launchd bootout, wait for the original process to exit; treat zombies or a reused PID as the original process having ended, without signalling the new PID owner. CI and release gates repeat the install/uninstall/reinstall cycle three times on both Mac architectures.

- Distinguish session and turn cumulative counters, including smaller/equal/larger first snapshots; preserve legacy writers, fork deduplication, Fast attribution and classification corrections.
- Commit events, modes, progress and file offsets together. Database failures roll back and retry; incomplete JSONL prefixes remain unread until complete.
- Persist cross-process data revisions and use consistent session/cost snapshots. External pricing changes invalidate caches. Search and costs use the same filtered session scope.
- Aggregate only paged sessions before joining metadata. Three local cache-miss runs on the same 114,960-event snapshot reduced median session-list latency from 2.75 s to 0.43 s and session-cost latency from 3.12 s to 0.83 s.
- Persist the accounting time zone; use real hour identities for remote browsers, DST repeated hours and 23/25-hour days.
- Add collapsible, paginated task trees with own and subtree usage, explicit parent metadata, separate fork lineage and visible orphan/cycle handling.
- Add macOS Apple Silicon / Intel binaries, per-user LaunchAgents, uninstall, browser opening and optional updates. Build six platform binaries and checksums with Go 1.26.8.

**History stays intact.** New accounting applies to newly read records. Historical recalculation requires an explicit `scan --rebuild` after verifying retained JSONL coverage and backing up the state; deleted sources cannot be recovered by rebuilding. macOS binaries are not Developer-ID signed or notarized. See the linked installation and accounting notes for contracts and validation boundaries.
2 changes: 1 addition & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import (
)

var (
Version = "2.6.0"
Version = "2.6.1"
Commit = "dev"
BuildDate = "unknown"
)
Expand Down
46 changes: 37 additions & 9 deletions internal/platform/platform_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,24 +150,52 @@ func stopDarwinProcess(pid int, executable string) error {
if pid <= 0 || pid == os.Getpid() {
return nil
}
output, err := exec.Command("/bin/ps", "-p", strconv.Itoa(pid), "-o", "comm=").Output()
if err != nil {
if syscall.Kill(pid, 0) == syscall.ESRCH {
return nil
}
matches, live, err := darwinProcessIdentity(pid, executable)
if err != nil || !live {
return err
}
actual := strings.TrimSpace(string(output))
expected, _ := filepath.EvalSymlinks(executable)
if actual != executable && (expected == "" || actual != expected) {
if !matches {
return fmt.Errorf("service PID does not match installed application")
}
if err = syscall.Kill(pid, syscall.SIGTERM); err != nil && err != syscall.ESRCH {
return err
}
return waitDarwinProcessExit(pid, executable)
}

func darwinProcessIdentity(pid int, executable string) (matches, live bool, err error) {
output, err := exec.Command("/bin/ps", "-p", strconv.Itoa(pid), "-o", "stat=", "-o", "comm=").Output()
if err != nil {
if syscall.Kill(pid, 0) == syscall.ESRCH {
return false, false, nil
}
return false, false, err
}
fields := strings.Fields(string(output))
if len(fields) < 2 || strings.HasPrefix(fields[0], "Z") {
// An exited launchd child can remain briefly as a zombie. Its command
// path is no longer reliable, and it must not be signalled again.
return false, false, nil
}
actual := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(string(output)), fields[0]))
expected, _ := filepath.EvalSymlinks(executable)
return actual == executable || (expected != "" && actual == expected), true, nil
}

// Call only after the matching process was signalled or its launchd job was
// booted out. A changed identity means the original PID has been reaped/reused;
// never send a signal to the new occupant of that PID.
func waitDarwinProcessExit(pid int, executable string) error {
if pid <= 0 || pid == os.Getpid() {
return nil
}
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
if syscall.Kill(pid, 0) == syscall.ESRCH {
matches, live, err := darwinProcessIdentity(pid, executable)
if err != nil {
return err
}
if !live || !matches {
return nil
}
time.Sleep(100 * time.Millisecond)
Expand Down
39 changes: 39 additions & 0 deletions internal/platform/platform_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import (
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
)

func TestLaunchAgentEscapesPathsAndSeparatesUpdateHelper(t *testing.T) {
Expand Down Expand Up @@ -44,3 +46,40 @@ func TestDarwinStopRejectsAnotherExecutable(t *testing.T) {
t.Fatal("unrelated process was not rejected")
}
}

func TestDarwinStopAcceptsExitedChildBeforeReaping(t *testing.T) {
cmd := exec.Command("/bin/sh", "-c", "exit 0")
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = cmd.Wait() })
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
_, live, err := darwinProcessIdentity(cmd.Process.Pid, "/bin/sh")
if err != nil {
t.Fatal(err)
}
if !live {
if err := stopDarwinProcess(cmd.Process.Pid, "/bin/sh"); err != nil {
t.Fatal(err)
}
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("child did not exit")
}

func TestDarwinWaitDoesNotSignalReusedPID(t *testing.T) {
cmd := exec.Command("/bin/sleep", "30")
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
if err := waitDarwinProcessExit(cmd.Process.Pid, "/tmp/previous/codex-usage"); err != nil {
t.Fatal(err)
}
if err := syscall.Kill(cmd.Process.Pid, 0); err != nil {
t.Fatal("replacement process was signalled", err)
}
}
1 change: 1 addition & 0 deletions internal/platform/update_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func StopForUpdate(executable, stateDir string, managed bool) error {
if err := launchctl("bootout", target); err != nil {
return err
}
return waitDarwinProcessExit(pid, executable)
}
}
return stopDarwinProcess(pid, executable)
Expand Down
2 changes: 1 addition & 1 deletion scripts/build.ps1
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
param(
[string]$Version = "2.6.0"
[string]$Version = "2.6.1"
)

$ErrorActionPreference = "Stop"
Expand Down
2 changes: 1 addition & 1 deletion scripts/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ set -euo pipefail

project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
dist="$project_root/dist"
version="${VERSION:-2.6.0}"
version="${VERSION:-2.6.1}"
build_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
commit="$(git -C "$project_root" rev-parse --short HEAD 2>/dev/null || printf source)"
if git -C "$project_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 &&
Expand Down
4 changes: 2 additions & 2 deletions scripts/demo-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@
};
}

const demoUpdates = { current_version: "2.6.0-demo", latest_version: "2.6.0", release_url: "https://github.com/zJay26/codex-usage/releases", auto_check: true, available: false, can_install: false, phase: "idle" };
const demoUpdates = { current_version: "2.6.1-demo", latest_version: "2.6.1", release_url: "https://github.com/zJay26/codex-usage/releases", auto_check: true, available: false, can_install: false, phase: "idle" };
async function syntheticFetch(input, init = {}) {
const raw = typeof input === "string" ? input : input.url;
const url = new URL(raw, root.location.href);
Expand All @@ -313,7 +313,7 @@
return jsonResponse(demoUpdates);
}
if (endpoint === "/api/v1/status") return jsonResponse({
version: "2.6.0-demo", scanning: false,
version: "2.6.1-demo", scanning: false,
status: {
machine: { id: "synthetic-machine", label: "Synthetic Windows · demo", hostname: "synthetic-host", os: "windows", arch: "amd64" },
last_scan: now.toISOString(), accounting_mode: "jsonl_only", otel_active: false,
Expand Down
2 changes: 1 addition & 1 deletion scripts/mock-dashboard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ function costEstimate(url) {
const server = http.createServer(async (request, response) => {
const url = new URL(request.url, `http://127.0.0.1:${port}`);
if (url.pathname === "/api/v1/status") return json(response, {
version: "2.6.0-preview", scanning: false,
version: "2.6.1-preview", scanning: false,
status: {
machine: { id: "62c0172d-36c4-4ec9-a074-02b9ec2b45e1", label: "WORKSTATION-19 · windows", hostname: "WORKSTATION-19", os: "windows", arch: "amd64" },
last_scan: now.toISOString(), accounting_mode: "jsonl_only", otel_active: false,
Expand Down