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
31 changes: 31 additions & 0 deletions .github/scripts/write_sample_trace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Write one synthetic, content-free Codex session so CI installs index real data."""

import json
from pathlib import Path


ROWS = [
{"timestamp": "2026-01-02T10:00:00Z", "type": "session_meta",
"payload": {"id": "ci-sample-session", "cwd": "/ci/project", "model_provider": "openai"}},
{"timestamp": "2026-01-02T10:00:01Z", "type": "turn_context",
"payload": {"model": "gpt-5.5", "cwd": "/ci/project"}},
{"timestamp": "2026-01-02T10:00:02Z", "type": "event_msg",
"payload": {"type": "task_started"}},
{"timestamp": "2026-01-02T10:00:03Z", "type": "event_msg",
"payload": {"type": "token_count", "info": {"last_token_usage": {
"input_tokens": 1200, "output_tokens": 80, "cached_input_tokens": 400}}}},
{"timestamp": "2026-01-02T10:00:05Z", "type": "event_msg",
"payload": {"type": "task_complete", "duration_ms": 3000}},
]


def main():
folder = Path.home() / ".codex" / "sessions" / "2026" / "01" / "02"
folder.mkdir(parents=True, exist_ok=True)
trace = folder / "rollout-ci-sample-session.jsonl"
trace.write_text("".join(json.dumps(row) + "\n" for row in ROWS), encoding="utf-8")
print(f"Wrote {trace}")


if __name__ == "__main__":
main()
169 changes: 169 additions & 0 deletions .github/workflows/install-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
name: Install checks

# Installs the backend on clean Linux and Windows hosts, checks the running
# service, then uninstalls. Tray companions need a desktop session that hosted
# runners do not reliably provide, so they are not covered here.
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

permissions:
contents: read

# Superseded pull-request runs are cancelled. Every other run gets its own
# group (keyed by commit), so each commit on main is checked to completion.
concurrency:
group: install-checks-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
linux:
name: Linux install
runs-on: ubuntu-latest
timeout-minutes: 20
env:
TOKEN_METER_READINESS_TIMEOUT_SECONDS: "600"
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0

# Hosted runners have no logged-in user session; lingering starts the
# systemd user manager the installer expects from a desktop login.
- name: Start a systemd user session
run: |
sudo loginctl enable-linger "$USER"
runtime_dir="/run/user/$(id -u)"
export XDG_RUNTIME_DIR="$runtime_dir"
export DBUS_SESSION_BUS_ADDRESS="unix:path=$runtime_dir/bus"
for _ in $(seq 1 30); do
systemctl --user show-environment >/dev/null 2>&1 && break
sleep 1
done
systemctl --user show-environment >/dev/null
echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" >> "$GITHUB_ENV"
echo "DBUS_SESSION_BUS_ADDRESS=$DBUS_SESSION_BUS_ADDRESS" >> "$GITHUB_ENV"

- name: Add a sample session
run: python3 .github/scripts/write_sample_trace.py

- name: Install
run: ./scripts/install --backend-only

- name: Check the installation
run: |
runtime="${XDG_DATA_HOME:-$HOME/.local/share}/token-meter/runtime"
systemctl --user is-active --quiet token-meter-server.service
test "$(cat "$runtime/INSTALL_MODE")" = "backend-only"
# The installer waits for state_ready; the session inventory can
# finish a moment later, so allow it up to a minute.
python3 - <<'PY'
import json, time, urllib.request
deadline = time.monotonic() + 60
while True:
with urllib.request.urlopen("http://127.0.0.1:8722/health", timeout=10) as response:
health = json.load(response)
if health.get("inventory_ready") or time.monotonic() > deadline:
break
time.sleep(1)
assert health.get("ok") and health.get("state_ready"), health
assert health.get("inventory_ready"), health
assert (health.get("sources") or 0) >= 1, health
assert not health.get("runtime_adapter_failures"), health
print("health ok:", health["sources"], "session(s) indexed")
PY
curl -fsS --max-time 10 http://127.0.0.1:8722/menubar | python3 -c 'import json, sys; json.load(sys.stdin); print("menubar ok")'

- name: Uninstall
run: |
runtime="${XDG_DATA_HOME:-$HOME/.local/share}/token-meter/runtime"
"$runtime/scripts/uninstall-systemd-user"
if systemctl --user is-active --quiet token-meter-server.service; then
echo "token-meter-server.service is still running" >&2
exit 1
fi
if curl -fsS --max-time 3 http://127.0.0.1:8722/health >/dev/null 2>&1; then
echo "the server still answers after uninstall" >&2
exit 1
fi
echo "uninstall ok"

- name: Show server logs
if: failure()
run: journalctl --user -u token-meter-server.service -n 200 --no-pager || true

windows:
name: Windows install
runs-on: windows-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0

- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"

- name: Add a sample session
run: python .github/scripts/write_sample_trace.py

- name: Install
shell: powershell
run: |
& powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass `
-File scripts\install-windows.ps1 -BackendOnly -ReadinessTimeoutSeconds 600
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }

- name: Check the installation
shell: powershell
run: |
$Runtime = Join-Path $env:LOCALAPPDATA "Token Meter\runtime"
# The session inventory can finish a moment after state_ready.
$Deadline = (Get-Date).AddSeconds(60)
do {
$Health = Invoke-RestMethod -Uri "http://127.0.0.1:8722/health" -TimeoutSec 10
if ($Health.inventory_ready) { break }
Start-Sleep -Seconds 1
} while ((Get-Date) -lt $Deadline)
if (-not $Health.ok -or -not $Health.state_ready -or -not $Health.inventory_ready) { throw "server is not ready: $($Health | ConvertTo-Json -Compress)" }
if ([int]$Health.sources -lt 1) { throw "no sessions were indexed: $($Health | ConvertTo-Json -Compress)" }
if (@($Health.runtime_adapter_failures).Count -gt 0) { throw "runtime adapters failed: $($Health | ConvertTo-Json -Compress)" }
Write-Host "health ok: $($Health.sources) session(s) indexed"
$null = Invoke-RestMethod -Uri "http://127.0.0.1:8722/menubar" -TimeoutSec 10
Write-Host "menubar ok"
$Mode = (Get-Content -LiteralPath (Join-Path $Runtime "INSTALL_MODE") -Raw).Trim()
if ($Mode -ne "backend-only") { throw "unexpected install mode: $Mode" }
$RunKey = "Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run"
if (-not (Get-ItemProperty -LiteralPath $RunKey -Name "Token Meter" -ErrorAction SilentlyContinue)) {
throw "the login startup entry is missing"
}
Write-Host "startup entry ok"

- name: Uninstall
shell: powershell
run: |
$Runtime = Join-Path $env:LOCALAPPDATA "Token Meter\runtime"
& powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File (Join-Path $Runtime "scripts\uninstall-windows.ps1")
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$RunKey = "Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run"
if (Get-ItemProperty -LiteralPath $RunKey -Name "Token Meter" -ErrorAction SilentlyContinue) {
throw "the login startup entry is still present"
}
$StillServing = $true
try { $null = Invoke-RestMethod -Uri "http://127.0.0.1:8722/health" -TimeoutSec 3 } catch { $StillServing = $false }
if ($StillServing) { throw "the server still answers after uninstall" }
Write-Host "uninstall ok"

- name: Show server logs
if: failure()
shell: powershell
run: |
$Runtime = Join-Path $env:LOCALAPPDATA "Token Meter\runtime"
foreach ($Name in @("meter.err.log", "meter.log")) {
$Path = Join-Path $Runtime $Name
if (Test-Path -LiteralPath $Path) { Write-Host "== $Name"; Get-Content -LiteralPath $Path -Tail 200 }
}
48 changes: 35 additions & 13 deletions scripts/install-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,29 @@ function Fail([string]$Message) {
throw "Token Meter installation failed: $Message"
}

# Windows PowerShell 5.1 turns a native command's stderr into a terminating
# error under ErrorActionPreference=Stop, even when it is redirected, so native
# probes whose stderr is discarded (git on a detached HEAD or without an
# upstream, Python warnings) run here. Failure returns "".
function Invoke-NativeText([string]$Command, [string[]]$Arguments) {
$PreviousPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$Output = @(& $Command @Arguments 2>$null)
$ExitCode = $LASTEXITCODE
} finally {
$ErrorActionPreference = $PreviousPreference
}
if ($ExitCode -ne 0) {
return ""
}
return (($Output | ForEach-Object { [string]$_ }) -join [Environment]::NewLine).Trim()
}

function Get-GitText([string[]]$Arguments) {
return Invoke-NativeText $Git.Source $Arguments
}

function Write-Utf8File([string]$Path, [string]$Value) {
[System.IO.File]::WriteAllText(
$Path,
Expand Down Expand Up @@ -366,12 +389,12 @@ $ManagedSourceRoot = if ($env:TOKEN_METER_SOURCE_ROOT) {
Join-Path $InstallParent "source"
}
$UpdateSourceRoot = $SourceRoot
$SourceUpstream = (& $Git.Source -C $SourceRoot rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>$null | Out-String).Trim()
$SourceUpstream = (Get-GitText @("-C", $SourceRoot, "rev-parse", "--abbrev-ref", "--symbolic-full-name", '@{upstream}'))
if ($SourceUpstream -like "*/*" -and $SourceRoot -ne $ManagedSourceRoot) {
$Slash = $SourceUpstream.IndexOf('/')
$SourceRemote = $SourceUpstream.Substring(0, $Slash)
$SourceBranch = $SourceUpstream.Substring($Slash + 1)
$SourceRemoteUrl = (& $Git.Source -C $SourceRoot remote get-url $SourceRemote 2>$null | Out-String).Trim()
$SourceRemoteUrl = (Get-GitText @("-C", $SourceRoot, "remote", "get-url", $SourceRemote))
if ($SourceRemoteUrl -and $SourceBranch) {
if (-not (Test-Path -LiteralPath $ManagedSourceRoot)) {
& $Git.Source clone --quiet --no-local $SourceRoot $ManagedSourceRoot
Expand All @@ -382,11 +405,11 @@ if ($SourceUpstream -like "*/*" -and $SourceRoot -ne $ManagedSourceRoot) {
if (-not (Test-Path -LiteralPath (Join-Path $ManagedSourceRoot ".git") -PathType Container)) {
Fail "the managed update checkout is not a Git repository."
}
$ManagedDirty = (& $Git.Source -C $ManagedSourceRoot status --porcelain 2>$null | Out-String).Trim()
$ManagedDirty = (Get-GitText @("-C", $ManagedSourceRoot, "status", "--porcelain"))
if ($ManagedDirty) {
Fail "the managed update checkout has local changes."
}
$SourceRevision = (& $Git.Source -C $SourceRoot rev-parse HEAD 2>$null | Out-String).Trim()
$SourceRevision = (Get-GitText @("-C", $SourceRoot, "rev-parse", "HEAD"))
if ($SourceRevision) {
& $Git.Source -C $ManagedSourceRoot fetch --quiet --no-tags $SourceRoot $SourceRevision
if ($LASTEXITCODE -ne 0) {
Expand All @@ -397,7 +420,7 @@ if ($SourceUpstream -like "*/*" -and $SourceRoot -ne $ManagedSourceRoot) {
Fail "the managed update checkout has diverged from the installed source."
}
}
$ManagedRemoteUrl = (& $Git.Source -C $ManagedSourceRoot remote get-url origin 2>$null | Out-String).Trim()
$ManagedRemoteUrl = (Get-GitText @("-C", $ManagedSourceRoot, "remote", "get-url", "origin"))
if ($ManagedRemoteUrl -ne $SourceRemoteUrl) {
$ManagedRemoteIsSource = $false
try {
Expand All @@ -409,7 +432,7 @@ if ($SourceUpstream -like "*/*" -and $SourceRoot -ne $ManagedSourceRoot) {
Fail "the managed update checkout belongs to a different upstream."
}
}
$ManagedBranch = (& $Git.Source -C $ManagedSourceRoot rev-parse --abbrev-ref HEAD 2>$null | Out-String).Trim()
$ManagedBranch = (Get-GitText @("-C", $ManagedSourceRoot, "rev-parse", "--abbrev-ref", "HEAD"))
if ($ManagedBranch) {
& $Git.Source -C $ManagedSourceRoot config "branch.$ManagedBranch.remote" origin
& $Git.Source -C $ManagedSourceRoot config "branch.$ManagedBranch.merge" "refs/heads/$SourceBranch"
Expand Down Expand Up @@ -492,10 +515,10 @@ Write-Utf8File (Join-Path $StagingRoot "PYTHON_WINDOWED_EXECUTABLE") ($PythonwEx
$InstallMode = if ($BackendOnly) { "backend-only" } else { "full" }
Write-Utf8File (Join-Path $StagingRoot "INSTALL_MODE") ($InstallMode + [Environment]::NewLine)

$Commit = (& $Git.Source -C $SourceRoot rev-parse --short HEAD 2>$null | Out-String).Trim()
$Commit = (Get-GitText @("-C", $SourceRoot, "rev-parse", "--short", "HEAD"))
if ($Commit) {
$DirtyPaths = @(
& $Git.Source -C $SourceRoot status --short --untracked-files=all 2>$null |
(Get-GitText @("-C", $SourceRoot, "status", "--short", "--untracked-files=all")) -split "\r?\n" |
Where-Object { $_ -and $_ -notmatch '^\?\? plan\.md$' }
)
if ($DirtyPaths.Count -gt 0) {
Expand Down Expand Up @@ -527,11 +550,10 @@ try {
$DeliveryBootstrapJson = ""
Push-Location $InstallRoot
try {
$DeliveryBootstrapJson = (
& $PythonExe -X utf8 -c `
'import json; from token_meter.app import bootstrap_git_delivery; print(json.dumps(bootstrap_git_delivery()))' `
2>$null | Out-String
).Trim()
$DeliveryBootstrapJson = Invoke-NativeText $PythonExe @(
"-X", "utf8", "-c",
'import json; from token_meter.app import bootstrap_git_delivery; print(json.dumps(bootstrap_git_delivery()))'
)
} finally {
Pop-Location
}
Expand Down
7 changes: 6 additions & 1 deletion scripts/uninstall-systemd-user
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ SYSTEMD_USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
SERVER_UNIT="$SYSTEMD_USER_DIR/token-meter-server.service"
TRAY_UNIT="$SYSTEMD_USER_DIR/token-meter-tray.service"

systemctl --user disable --now token-meter-tray.service token-meter-server.service >/dev/null 2>&1 || true
# One unit at a time: systemctl refuses the whole command when any listed unit
# file is missing (backend-only installs have no tray unit), which would leave
# the server running after its unit file is removed.
for unit in token-meter-tray.service token-meter-server.service; do
systemctl --user disable --now "$unit" >/dev/null 2>&1 || true
done
rm -f "$TRAY_UNIT" "$SERVER_UNIT"
systemctl --user daemon-reload
systemctl --user reset-failed token-meter-tray.service token-meter-server.service >/dev/null 2>&1 || true
Expand Down
31 changes: 25 additions & 6 deletions scripts/update-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,25 @@ try {
}
} catch { }

# Windows PowerShell 5.1 turns a native command's stderr into a terminating
# error under ErrorActionPreference=Stop, even when it is redirected, so git
# probes that may legitimately fail (detached HEAD, no upstream) run here and
# reach the matching Fail-Update status instead of aborting the helper.
function Get-GitText([string[]]$Arguments) {
$PreviousPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$Output = @(& $Git.Source @Arguments 2>$null)
$ExitCode = $LASTEXITCODE
} finally {
$ErrorActionPreference = $PreviousPreference
}
if ($ExitCode -ne 0) {
return ""
}
return (($Output | ForEach-Object { [string]$_ }) -join [Environment]::NewLine).Trim()
}

function Write-UpdateStatus(
[string]$Phase,
[string]$ErrorCode,
Expand Down Expand Up @@ -94,11 +113,11 @@ if (-not $Git) {
Fail-Update "git_unavailable"
}

$Upstream = (& $Git.Source -C $SourceRoot rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>$null | Out-String).Trim()
$Upstream = (Get-GitText @("-C", $SourceRoot, "rev-parse", "--abbrev-ref", "--symbolic-full-name", '@{upstream}'))
if ($LASTEXITCODE -ne 0 -or $Upstream -notlike "*/*") {
Fail-Update "upstream_unavailable"
}
$Branch = (& $Git.Source -C $SourceRoot rev-parse --abbrev-ref HEAD 2>$null | Out-String).Trim()
$Branch = (Get-GitText @("-C", $SourceRoot, "rev-parse", "--abbrev-ref", "HEAD"))
if ($Branch -ne "main" -or $Upstream.Substring($Upstream.LastIndexOf('/') + 1) -ne "main") {
Fail-Update "unsupported_update_branch"
}
Expand All @@ -112,15 +131,15 @@ if ($LASTEXITCODE -ne 0) {
Fail-Update "fetch_failed"
}

$script:CurrentRevision = (& $Git.Source -C $SourceRoot rev-parse HEAD 2>$null | Out-String).Trim()
$script:LatestRevision = (& $Git.Source -C $SourceRoot rev-parse '@{upstream}' 2>$null | Out-String).Trim()
$script:CurrentRevision = (Get-GitText @("-C", $SourceRoot, "rev-parse", "HEAD"))
$script:LatestRevision = (Get-GitText @("-C", $SourceRoot, "rev-parse", '@{upstream}'))
if (-not $script:PreviousRevision) {
$script:PreviousRevision = $script:CurrentRevision
}
if (-not $script:CurrentRevision -or -not $script:LatestRevision) {
Fail-Update "inspect_failed"
}
$Dirty = (& $Git.Source -C $SourceRoot status --porcelain 2>$null | Out-String).Trim()
$Dirty = (Get-GitText @("-C", $SourceRoot, "status", "--porcelain"))
if ($Dirty) {
Fail-Update "dirty_checkout"
}
Expand All @@ -135,7 +154,7 @@ if ($script:CurrentRevision -ne $script:LatestRevision) {
if ($LASTEXITCODE -ne 0) {
Fail-Update "diverged_checkout"
}
$script:CurrentRevision = (& $Git.Source -C $SourceRoot rev-parse HEAD 2>$null | Out-String).Trim()
$script:CurrentRevision = (Get-GitText @("-C", $SourceRoot, "rev-parse", "HEAD"))
}
Write-UpdateStatus "installing" "" $script:CurrentRevision $script:LatestRevision $script:PreviousRevision
try {
Expand Down
Loading
Loading