Skip to content

Commit 0ee0052

Browse files
billg-splunkbhavsarpratikclaude
authored
fix: Update logic fix for Linux (#82)
* update logic fix attempt for linux * ci: run tests on every host, check macOS install and Linux updates Unit and contract suites now run on Linux, macOS, and Windows with static and native-build gates. Install checks add a macOS install and a Linux end-to-end update that restarts the server mid-install. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix(update): keep Windows updates alive and launch systemd-run only under systemd The stale-update recovery treated Windows status records, which carry no started_at, as stale within seconds; it now falls back to checked_at and never recovers an undated record. The Linux helper is wrapped in systemd-run only when the server itself runs under systemd, so a server started by hand keeps the direct launch. POSIX platform paths use posixpath so their contracts hold on any host. CI: name the detached checkout main for installer tests, run the Windows-clean modules on Windows, and start a server before the menu bar smoke check. Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: Pratik Bhavsar <ptkbhv@cisco.com> Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent e769025 commit 0ee0052

11 files changed

Lines changed: 615 additions & 32 deletions

File tree

‎.github/workflows/install-checks.yml‎

Lines changed: 198 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
name: Install checks
22

3-
# Installs the backend on clean Linux and Windows hosts, checks the running
4-
# service, then uninstalls. Tray companions need a desktop session that hosted
5-
# runners do not reliably provide, so they are not covered here.
3+
# Installs the backend on clean macOS, Linux, and Windows hosts, checks the
4+
# running service, then uninstalls; on Linux it also runs a real update. Tray
5+
# companions need a desktop session that hosted runners do not reliably
6+
# provide, so they are not covered here.
67
on:
78
push:
89
branches: [main]
@@ -95,6 +96,200 @@ jobs:
9596
if: failure()
9697
run: journalctl --user -u token-meter-server.service -n 200 --no-pager || true
9798

99+
# Installs from a local clone whose origin is a local bare remote, publishes a
100+
# newer commit there, then drives Install through the dashboard API. The
101+
# installer restarts the server mid-update, so this proves the helper
102+
# outlives that restart and the server comes back on the new revision.
103+
linux-update:
104+
name: Linux update
105+
runs-on: ubuntu-latest
106+
timeout-minutes: 25
107+
env:
108+
TOKEN_METER_READINESS_TIMEOUT_SECONDS: "600"
109+
steps:
110+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
111+
with:
112+
fetch-depth: 0
113+
114+
- name: Start a systemd user session
115+
run: |
116+
sudo loginctl enable-linger "$USER"
117+
runtime_dir="/run/user/$(id -u)"
118+
export XDG_RUNTIME_DIR="$runtime_dir"
119+
export DBUS_SESSION_BUS_ADDRESS="unix:path=$runtime_dir/bus"
120+
for _ in $(seq 1 30); do
121+
systemctl --user show-environment >/dev/null 2>&1 && break
122+
sleep 1
123+
done
124+
systemctl --user show-environment >/dev/null
125+
echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" >> "$GITHUB_ENV"
126+
echo "DBUS_SESSION_BUS_ADDRESS=$DBUS_SESSION_BUS_ADDRESS" >> "$GITHUB_ENV"
127+
128+
- name: Publish this revision to a local remote
129+
run: |
130+
remote="$RUNNER_TEMP/update-remote.git"
131+
source="$RUNNER_TEMP/update-source"
132+
git init --quiet --bare --initial-branch=main "$remote"
133+
git push --quiet "$remote" HEAD:refs/heads/main
134+
git clone --quiet "$remote" "$source"
135+
echo "UPDATE_SOURCE=$source" >> "$GITHUB_ENV"
136+
137+
- name: Add a sample session and turn off automatic installs
138+
run: |
139+
python3 .github/scripts/write_sample_trace.py
140+
mkdir -p "$HOME/.token-meter"
141+
printf '%s\n' '{"updates": {"enabled": true, "auto_install": false}}' \
142+
> "$HOME/.token-meter/settings.json"
143+
144+
- name: Install
145+
run: |
146+
cd "$UPDATE_SOURCE"
147+
./scripts/install --backend-only
148+
systemctl --user show -p MainPID --value token-meter-server.service \
149+
> "$RUNNER_TEMP/server-pid-before"
150+
151+
- name: Publish a newer revision
152+
run: |
153+
cd "$UPDATE_SOURCE"
154+
git -c user.name="Token Meter CI" -c user.email="ci@token-meter.invalid" \
155+
commit --quiet --allow-empty -m "ci: update bump"
156+
git push --quiet origin main
157+
echo "UPDATE_TARGET=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
158+
159+
- name: Check for and install the update
160+
run: |
161+
python3 - <<'PY'
162+
import json, os, time, urllib.request
163+
base = "http://127.0.0.1:8722"
164+
target = os.environ["UPDATE_TARGET"]
165+
166+
def get(path):
167+
with urllib.request.urlopen(base + path, timeout=10) as response:
168+
return json.load(response)
169+
170+
def post(path, token):
171+
request = urllib.request.Request(
172+
base + path, data=b"{}", method="POST",
173+
headers={"Content-Type": "application/json", "X-Token-Meter-Action": token},
174+
)
175+
with urllib.request.urlopen(request, timeout=10) as response:
176+
return response.status, json.load(response)
177+
178+
token = get("/updates/status")["actions"]["token"]
179+
deadline = time.monotonic() + 180
180+
# A check already running from server startup ignores a new request,
181+
# so keep asking until one sees the published revision.
182+
while True:
183+
status = get("/updates/status")
184+
if status.get("can_update") and target.startswith(status.get("latest_revision") or "-"):
185+
break
186+
assert time.monotonic() < deadline, status
187+
if not status.get("checking"):
188+
post("/updates/check", token)
189+
time.sleep(2)
190+
code, result = post("/updates/install", token)
191+
assert code == 202 and result.get("ok"), (code, result)
192+
print("update started for", target[:12])
193+
PY
194+
195+
- name: Wait for the update to finish
196+
run: |
197+
status_file="$HOME/.token-meter/update-status.json"
198+
deadline=$((SECONDS + 720))
199+
while (( SECONDS < deadline )); do
200+
phase="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("phase",""))' "$status_file" 2>/dev/null || true)"
201+
case "$phase" in
202+
complete) echo "update complete"; break ;;
203+
failed) cat "$status_file"; exit 1 ;;
204+
esac
205+
sleep 2
206+
done
207+
test "$phase" = complete || { echo "update stuck in phase: $phase" >&2; cat "$status_file"; exit 1; }
208+
209+
- name: Check the updated installation
210+
run: |
211+
runtime="${XDG_DATA_HOME:-$HOME/.local/share}/token-meter/runtime"
212+
installed="$(cat "$runtime/INSTALLED_REVISION")"
213+
case "$UPDATE_TARGET" in
214+
"$installed"*) echo "installed revision ok: $installed" ;;
215+
*) echo "installed $installed, expected $UPDATE_TARGET" >&2; exit 1 ;;
216+
esac
217+
systemctl --user is-active --quiet token-meter-server.service
218+
before="$(cat "$RUNNER_TEMP/server-pid-before")"
219+
after="$(systemctl --user show -p MainPID --value token-meter-server.service)"
220+
test "$before" != "$after" || { echo "the server was not restarted" >&2; exit 1; }
221+
curl -fsS --max-time 10 http://127.0.0.1:8722/health | python3 -c 'import json, sys; h = json.load(sys.stdin); assert h.get("ok"), h; print("health ok")'
222+
223+
- name: Show update and server logs
224+
if: failure()
225+
run: |
226+
cat "$HOME/.token-meter/update-status.json" || true
227+
journalctl --user -u token-meter-update.service -n 200 --no-pager || true
228+
journalctl --user -u token-meter-server.service -n 200 --no-pager || true
229+
230+
macos:
231+
name: macOS install
232+
runs-on: macos-latest
233+
timeout-minutes: 20
234+
env:
235+
TOKEN_METER_READINESS_TIMEOUT_SECONDS: "600"
236+
steps:
237+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
238+
with:
239+
fetch-depth: 0
240+
241+
- name: Add a sample session
242+
run: python3 .github/scripts/write_sample_trace.py
243+
244+
- name: Install
245+
run: ./scripts/install --backend-only
246+
247+
- name: Check the installation
248+
run: |
249+
runtime="$HOME/Library/Application Support/Token Meter/runtime"
250+
launchctl print "gui/$UID/com.token-meter.server" >/dev/null
251+
test "$(cat "$runtime/INSTALL_MODE")" = "backend-only"
252+
python3 - <<'PY'
253+
import json, time, urllib.request
254+
deadline = time.monotonic() + 60
255+
while True:
256+
with urllib.request.urlopen("http://127.0.0.1:8722/health", timeout=10) as response:
257+
health = json.load(response)
258+
if health.get("inventory_ready") or time.monotonic() > deadline:
259+
break
260+
time.sleep(1)
261+
assert health.get("ok") and health.get("state_ready"), health
262+
assert health.get("inventory_ready"), health
263+
assert (health.get("sources") or 0) >= 1, health
264+
assert not health.get("runtime_adapter_failures"), health
265+
print("health ok:", health["sources"], "session(s) indexed")
266+
PY
267+
curl -fsS --max-time 10 http://127.0.0.1:8722/menubar | python3 -c 'import json, sys; json.load(sys.stdin); print("menubar ok")'
268+
269+
- name: Uninstall
270+
run: |
271+
runtime="$HOME/Library/Application Support/Token Meter/runtime"
272+
"$runtime/scripts/uninstall-launch-agent"
273+
if launchctl print "gui/$UID/com.token-meter.server" >/dev/null 2>&1; then
274+
echo "com.token-meter.server is still loaded" >&2
275+
exit 1
276+
fi
277+
for _ in $(seq 1 10); do
278+
curl -fsS --max-time 3 http://127.0.0.1:8722/health >/dev/null 2>&1 || { echo "uninstall ok"; exit 0; }
279+
sleep 1
280+
done
281+
echo "the server still answers after uninstall" >&2
282+
exit 1
283+
284+
- name: Show server logs
285+
if: failure()
286+
run: |
287+
runtime="$HOME/Library/Application Support/Token Meter/runtime"
288+
for name in meter.err.log meter.log; do
289+
[[ -f "$runtime/$name" ]] && { echo "== $name"; tail -n 200 "$runtime/$name"; }
290+
done
291+
true
292+
98293
windows:
99294
name: Windows install
100295
runs-on: windows-latest

‎.github/workflows/tests.yml‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
name: Tests
2+
3+
# Runs the unit and contract suites on every supported host, so a change made
4+
# for one platform is checked on the others, plus the static gates from
5+
# specs/AGENTS.md and the native companion build.
6+
on:
7+
push:
8+
branches: [main]
9+
pull_request:
10+
branches: [main]
11+
workflow_dispatch:
12+
13+
permissions:
14+
contents: read
15+
16+
concurrency:
17+
group: tests-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
18+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
19+
20+
jobs:
21+
unit:
22+
name: Unit tests (${{ matrix.os }})
23+
runs-on: ${{ matrix.os }}
24+
timeout-minutes: 30
25+
strategy:
26+
fail-fast: false
27+
matrix:
28+
os: [ubuntu-latest, macos-latest, windows-latest]
29+
env:
30+
PYTHONUTF8: "1"
31+
steps:
32+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
33+
with:
34+
fetch-depth: 0
35+
36+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
37+
with:
38+
python-version: "3.12"
39+
40+
# Pull-request checkouts are detached; installer tests clone this
41+
# repository and expect a local main branch, as a developer clone has.
42+
- name: Name the checked-out commit main
43+
run: git switch --quiet -C main
44+
45+
- name: Run the test suite
46+
if: runner.os != 'Windows'
47+
run: python -m unittest discover -s tests
48+
49+
# The full suite is not yet Windows-clean (SQLite handles held across
50+
# temp-dir cleanup, mktime range limits). Run every module that is, so
51+
# a regression in them still fails here.
52+
- name: Run the Windows-clean test modules
53+
if: runner.os == 'Windows'
54+
run: >-
55+
python -m unittest
56+
tests.contracts.test_agent_workflow tests.contracts.test_architecture_guards
57+
tests.contracts.test_compat tests.contracts.test_contracts tests.contracts.test_design_system
58+
tests.contracts.test_kiro_extension_budget tests.contracts.test_model_pricing
59+
tests.contracts.test_path_cache tests.contracts.test_platform_services
60+
tests.contracts.test_public_projections tests.contracts.test_quota_privacy
61+
tests.contracts.test_quota_registry tests.contracts.test_runtime_catalog
62+
tests.contracts.test_runtime_manifest tests.contracts.test_runtime_registry
63+
tests.contracts.test_telemetry_mapping tests.contracts.test_windows_packaging
64+
tests.domain.test_aggregates tests.domain.test_compare tests.domain.test_matched_pace_parity
65+
tests.domain.test_timing_tools_insights tests.domain.test_usage
66+
tests.integration.test_application_composition tests.integration.test_discovery_performance
67+
tests.integration.test_model_pricing_compat
68+
tests.integration.test_runtime_dispatch_characterization
69+
tests.integration.test_session_selection
70+
tests.runtimes.test_claude_adapter tests.runtimes.test_codex_adapter
71+
tests.runtimes.test_kiro_adapter tests.runtimes.test_pi_adapter
72+
tests.test_claude_cost_correctness tests.test_codex_model_identity
73+
tests.test_current_model_catalog tests.test_goals tests.test_installer_modes tests.test_linux_update
74+
tests.test_mcp_queries tests.test_mcp_server tests.test_menubar_settings
75+
76+
static:
77+
name: Static checks
78+
runs-on: ubuntu-latest
79+
timeout-minutes: 10
80+
steps:
81+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
82+
83+
- name: Compile Python
84+
run: |
85+
PYTHONPYCACHEPREFIX="$RUNNER_TEMP/pycache" python3 -m py_compile \
86+
meter.py token_meter_mcp.py $(find token_meter -type f -name '*.py' -print)
87+
88+
- name: Parse the dashboard JavaScript
89+
run: |
90+
node -e "const fs=require('fs');const h=fs.readFileSync('page.html','utf8');const m=h.match(/<script>([\s\S]*)<\/script>/);new Function(m[1]);console.log('js ok')"
91+
92+
- name: Check shell syntax
93+
run: |
94+
for script in scripts/*; do
95+
if head -n 1 "$script" | grep -q bash; then
96+
bash -n "$script"
97+
fi
98+
done
99+
100+
- name: Reject whitespace errors
101+
if: github.event_name == 'pull_request'
102+
run: |
103+
git fetch --no-tags --depth=1 origin "${{ github.base_ref }}"
104+
git diff --check FETCH_HEAD HEAD
105+
106+
native:
107+
name: macOS companion build
108+
runs-on: macos-latest
109+
timeout-minutes: 15
110+
steps:
111+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
112+
113+
- name: Compile the menu-bar companion
114+
run: swiftc menubar/TokenMeterMenuBar.swift -o "$RUNNER_TEMP/token-meter-menubar"
115+
116+
# The smoke check reads the live /menubar payload, as it does after install.
117+
- name: Start the local server
118+
run: |
119+
python3 .github/scripts/write_sample_trace.py
120+
nohup python3 meter.py > "$RUNNER_TEMP/meter.log" 2>&1 &
121+
for _ in $(seq 1 120); do
122+
curl -fsS --max-time 3 http://127.0.0.1:8722/menubar >/dev/null 2>&1 && exit 0
123+
sleep 1
124+
done
125+
cat "$RUNNER_TEMP/meter.log"
126+
exit 1
127+
128+
- name: Run the smoke check
129+
run: TOKEN_METER_MENUBAR_SMOKE=1 "$RUNNER_TEMP/token-meter-menubar"

0 commit comments

Comments
 (0)