Skip to content

Commit 79d1f42

Browse files
committed
Add sealed local history persistence
Introduce sealed local-history support and persistence for provider usage. Adds a HistoryStore API with Memory and SQLite implementations, payload composition/sealing, and helper utilities (days, payload, compose, seal). Integrates history into the engine: EngineService persists live scans, falls back to composed history on refresh failures, and accepts a history DB path (USAGEATLAS_HISTORY_DB) from the main process. Updates provider adapters to surface accountKey and history lookback, extends sqlite platform API, and updates renderer UI (provider marks/logos, model-mix) and styles. Adds tests for history behavior, updates contracts with HISTORY_* types/constants, docs (README/VISION), and CI checks for PostHog secrets.
1 parent 74f0651 commit 79d1f42

34 files changed

Lines changed: 2360 additions & 93 deletions

.github/workflows/release-desktop.yml

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,8 @@ permissions:
2121
contents: read
2222

2323
env:
24-
# Repository variables, not secrets: a PostHog project token is a public client key.
25-
POSTHOG_PROJECT_TOKEN: ${{ vars.POSTHOG_PROJECT_TOKEN }}
26-
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
24+
POSTHOG_PROJECT_TOKEN: ${{ secrets.POSTHOG_PROJECT_TOKEN }}
25+
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST || 'https://usageatlas.com/signals' }}
2726

2827
jobs:
2928
gate:
@@ -202,6 +201,13 @@ jobs:
202201
bun-version-file: package.json
203202
- run: bun ci
204203
- run: bun run generate:desktop-icons
204+
- name: Require PostHog token
205+
shell: bash
206+
run: |
207+
if [ -z "${POSTHOG_PROJECT_TOKEN}" ]; then
208+
echo "::error::Add repository secret POSTHOG_PROJECT_TOKEN."
209+
exit 1
210+
fi
205211
- name: Prepare signing
206212
shell: pwsh
207213
# An unsigned Windows build is allowed and announced; set WINDOWS_CERTIFICATE_BASE64 to sign.
@@ -270,6 +276,13 @@ jobs:
270276
with:
271277
bun-version-file: package.json
272278
- run: bun ci
279+
- name: Require PostHog token
280+
shell: bash
281+
run: |
282+
if [ -z "${POSTHOG_PROJECT_TOKEN}" ]; then
283+
echo "::error::Add repository secret POSTHOG_PROJECT_TOKEN."
284+
exit 1
285+
fi
273286
- name: Generate macOS icon
274287
shell: bash
275288
run: |
@@ -361,6 +374,13 @@ jobs:
361374
sudo apt-get update
362375
sudo apt-get install -y fakeroot rpm squashfs-tools xvfb fuse3
363376
- run: bun ci
377+
- name: Require PostHog token
378+
shell: bash
379+
run: |
380+
if [ -z "${POSTHOG_PROJECT_TOKEN}" ]; then
381+
echo "::error::Add repository secret POSTHOG_PROJECT_TOKEN."
382+
exit 1
383+
fi
364384
- run: bun run desktop:make -- --platform=linux --arch=${{ matrix.arch }}
365385
- name: Packaged smoke test
366386
shell: bash

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,16 @@ handling, and deterministic adapter tests.
5858
- Usage alerts that fire a native notification before you run into a limit
5959
- Tray presence, launch at login, and system/light/dark themes
6060
- Local-first: no account, no sync, and no usage data leaves the machine
61+
- Sealed local history: completed days are stored on disk so past usage survives restarts and provider outages
6162
- Sandboxed renderer with no Node.js access, an allowlisted preload API, ASAR integrity, and Electron fuses
6263

6364
## Privacy
6465

65-
Provider data is read from local files and provider APIs, cached in memory, and written nowhere else. There is no
66-
UsageAtlas account and no cloud sync. An anonymous install count tells us how many people use the app; it never
67-
includes usage figures and can be switched off in Settings. Diagnostics are redacted before they are shown or copied.
66+
Provider data is read from local files and provider APIs. Completed-day usage and capacity
67+
snapshots are sealed into a local history database in the app data directory. There is no
68+
UsageAtlas account and no cloud sync. An anonymous install count tells us how many people use
69+
the app; it never includes usage figures and can be switched off in Settings. Diagnostics are
70+
redacted before they are shown or copied.
6871

6972
## Build from source
7073

VISION.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ UsageAtlas is a secure, cross-platform desktop command center for AI-provider ca
55
Prefer working provider coverage over a large placeholder catalog. New providers should reuse the shared dashboard
66
contract and engine services, require no provider-specific renderer privileges, and arrive with deterministic fixtures.
77
Keep the application local-first, responsive, dependency-light, and honest about provider and platform capability.
8+
Completed local days should be sealed on disk so history survives restarts and provider failures, with an optional
9+
remote sync path designed as a later plug-in rather than a required account.

apps/desktop/src/engine/analytics/local-usage.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export type AnalyticsProvider = "codex" | "claude";
2727
export interface AnalyticsScanContext {
2828
signal: AbortSignal;
2929
now: Date;
30+
historyDays?: number;
3031
}
3132

3233
export interface AnalyticsScanner {
@@ -141,6 +142,7 @@ export class LocalUsageScanner implements AnalyticsScanner {
141142

142143
async scan(provider: AnalyticsProvider, context: AnalyticsScanContext): Promise<LocalUsageAnalytics> {
143144
context.signal.throwIfAborted();
145+
const historyDays = clampInteger(context.historyDays ?? this.historyDays, 1, 366);
144146
const catalog = this.pricingCatalogLoader
145147
? await this.pricingCatalogLoader(context).catch(() => emptyPricingCatalog())
146148
: emptyPricingCatalog();
@@ -177,7 +179,7 @@ export class LocalUsageScanner implements AnalyticsScanner {
177179
await Promise.all(workers);
178180
context.signal.throwIfAborted();
179181
if (unreadableFiles > 0 && unreadableFiles === discovery.files.length) {
180-
return unavailableAnalytics(context.now, this.historyDays, {
182+
return unavailableAnalytics(context.now, historyDays, {
181183
code: "analytics_unavailable",
182184
message: "Local session analytics could not read the available logs.",
183185
retryable: true
@@ -196,7 +198,7 @@ export class LocalUsageScanner implements AnalyticsScanner {
196198
return buildAnalytics(
197199
records,
198200
context.now,
199-
this.historyDays,
201+
historyDays,
200202
discovery.files.length,
201203
gapMessage !== null,
202204
"local_sessions",

apps/desktop/src/engine/analytics/opencode-usage.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,22 +68,23 @@ export class OpenCodeUsageScanner {
6868
return await fileExists(this.locations.database) || await fileExists(this.locations.auth);
6969
}
7070

71-
async scan(context: { signal: AbortSignal; now: Date }): Promise<OpenCodeUsageSnapshot> {
71+
async scan(context: { signal: AbortSignal; now: Date; historyDays?: number }): Promise<OpenCodeUsageSnapshot> {
7272
context.signal.throwIfAborted();
7373
if (!await fileExists(this.locations.database)) {
7474
throw new ProviderError(
7575
"credentials_missing",
7676
"OpenCode local data was not found. Run OpenCode once, then refresh."
7777
);
7878
}
79+
const historyDays = clampInteger(context.historyDays ?? this.historyDays, 1, 366);
7980
const parsed = this.readRecords(context.signal);
8081
const hasGoAuth = await hasOpenCodeGoAuth(this.locations.auth);
8182
const hasGoPlan = hasGoAuth || parsed.records.some((record) => record.serviceTier === "opencode-go");
8283
return {
8384
analytics: buildAnalytics(
8485
parsed.records,
8586
context.now,
86-
this.historyDays,
87+
historyDays,
8788
1,
8889
parsed.partial
8990
),

apps/desktop/src/engine/analytics/provider-analytics.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export async function scanProviderAnalytics(
1212
return await scanner.scan(provider, context);
1313
} catch {
1414
if (context.signal.aborted) throw new DOMException("Analytics scan timed out.", "AbortError");
15-
return unavailableAnalytics(context.now, 90, {
15+
return unavailableAnalytics(context.now, context.historyDays ?? 90, {
1616
code: "analytics_unavailable",
1717
message: "Local session analytics could not be refreshed.",
1818
retryable: true

apps/desktop/src/engine/engine-entry.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { EngineService } from "./engine-service";
2+
import { MemoryHistoryStore, SqliteHistoryStore, type HistoryStore } from "./history";
23
import {
34
createEngineReadyMessage,
45
EngineRequestError,
@@ -10,7 +11,8 @@ import { createProviderAdapters } from "./providers/registry";
1011
const port = process.parentPort;
1112
if (!port) throw new Error("Engine utility process parent port is unavailable");
1213

13-
const engine = new EngineService(createProviderAdapters());
14+
const history = openHistoryStore(process.env.USAGEATLAS_HISTORY_DB);
15+
const engine = new EngineService(createProviderAdapters(), () => new Date(), history);
1416
let queue = Promise.resolve();
1517

1618
port.on("message", (event) => {
@@ -39,6 +41,20 @@ port.on("message", (event) => {
3941

4042
port.postMessage(createEngineReadyMessage());
4143

44+
function openHistoryStore(databasePath: string | undefined): HistoryStore {
45+
if (!databasePath?.trim()) return new MemoryHistoryStore();
46+
try {
47+
return SqliteHistoryStore.open(databasePath.trim());
48+
} catch (error) {
49+
process.stderr.write(
50+
`History store unavailable; continuing without persistence (${
51+
error instanceof Error ? error.message : "open failed"
52+
})\n`
53+
);
54+
return new MemoryHistoryStore();
55+
}
56+
}
57+
4258
function requestID(value: unknown): string {
4359
if (!value || typeof value !== "object" || Array.isArray(value)) return "invalid";
4460
const id = (value as Record<string, unknown>).id;

0 commit comments

Comments
 (0)