mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-15 09:22:18 +00:00
Compare commits
1
Commits
v0.45.17.0
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4deee227be |
@@ -1,4 +1,4 @@
|
||||
<!-- gbrain-runbook-stamp: 0.45.17.0 -->
|
||||
<!-- gbrain-runbook-stamp: 0.45.18.0 -->
|
||||
<!-- This stamp must equal the VERSION file at every release; CI enforces it
|
||||
(scripts/check-bootstrap-tag.sh). `gbrain bootstrap status` compares it to
|
||||
the installed binary and warns on skew. -->
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.45.18.0] - 2026-08-15
|
||||
|
||||
**Today's agent spend now reads correctly at every hour, in every timezone.** The admin spend endpoint computed "today" against a naive timestamp that each database session reinterpreted in its own timezone — on any non-UTC session (a PGLite brain following the host clock, a timezone-configured Postgres role), the day boundary shifted by the offset and every evening's spend silently underreported as 0. The boundary is now a UTC instant, independent of session timezone, pinned by a regression test that exercises sessions 12 hours either side of UTC at any wall-clock hour.
|
||||
|
||||
The same class also made the new test-suite snapshot fixture time-of-day flaky: the snapshot bakes the build machine's timezone into the restored cluster, so snapshot-restored engines ran sessions in the builder's zone while cold-init engines followed the running process. Restored engines now re-pin their session to the runtime zone (existing tarballs heal without a rebuild), the snapshot builder pins UTC so tarballs are deterministic across hosts, and a parity test asserts cold and snapshot engines agree on their UTC offset.
|
||||
|
||||
### Fixed
|
||||
- `/admin/api/agents/spend`: `spent_cents_today` no longer underreports on non-UTC sessions (UTC-instant day boundary).
|
||||
- Snapshot-restored PGLite engines behave identically to cold-init engines regardless of the machine that built the tarball.
|
||||
|
||||
## [0.45.17.0] - 2026-08-15
|
||||
|
||||
**A test run can no longer silently touch a real brain.** `gbrain init` writes your
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "gbrain-context-engine",
|
||||
"name": "gbrain",
|
||||
"version": "0.45.17.0",
|
||||
"version": "0.45.18.0",
|
||||
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
|
||||
"family": "bundle-plugin",
|
||||
"configSchema": {
|
||||
|
||||
+1
-1
@@ -156,7 +156,7 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.45.17.0",
|
||||
"version": "0.45.18.0",
|
||||
"overrides": {
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"fast-uri": "^3.1.5",
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
#!/usr/bin/env bun
|
||||
// scripts/build-pglite-snapshot.ts
|
||||
//
|
||||
// TZ pinned to UTC BEFORE any PGLite work: dumpDataDir bakes this process's
|
||||
// TimeZone into the tar's cluster defaults. Building under the host zone made
|
||||
// restored engines run sessions in the build machine's zone (the engine also
|
||||
// re-pins at restore — this is the belt to that suspender, and it keeps any
|
||||
// OTHER zone-derived state baked into the tar deterministic across hosts).
|
||||
process.env.TZ = 'UTC';
|
||||
//
|
||||
// Tier 3 fast-restore: boot a fresh PGLite, run the full initSchema (forward
|
||||
// bootstrap + PGLITE_SCHEMA_SQL + every migration), dump the post-init state
|
||||
// to a tar fixture. Test files that read GBRAIN_PGLITE_SNAPSHOT can skip the
|
||||
|
||||
@@ -551,7 +551,13 @@ export async function queryAgentClientSpend(engine: BrainEngine): Promise<AgentC
|
||||
SELECT SUM(spend_cents)::text
|
||||
FROM mcp_spend_log
|
||||
WHERE client_id = c.client_id
|
||||
AND created_at >= date_trunc('day', now() AT TIME ZONE 'UTC')
|
||||
-- Double AT TIME ZONE: the inner one yields NAIVE UTC-midnight;
|
||||
-- the outer one converts it back to a timestamptz INSTANT. Without
|
||||
-- it, the naive value is reinterpreted in the SESSION timezone, so
|
||||
-- any non-UTC session (host-tz PGLite, a tz-configured Postgres
|
||||
-- role) shifts the day boundary by the offset and today's spend
|
||||
-- underreports every evening.
|
||||
AND created_at >= date_trunc('day', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'
|
||||
), '0') AS spent_cents_today,
|
||||
COALESCE((
|
||||
SELECT SUM(estimated_cents)::text
|
||||
|
||||
@@ -616,6 +616,18 @@ export class PGLiteEngine implements BrainEngine {
|
||||
...embedded,
|
||||
}),
|
||||
);
|
||||
// Snapshot-timezone parity: dumpDataDir bakes the BUILD process's
|
||||
// TimeZone into the restored cluster's defaults, so a snapshot-loaded
|
||||
// engine would run sessions in the build machine's zone while a
|
||||
// cold-init engine follows this process (bun test pins TZ=UTC; bun run
|
||||
// follows the host). That divergence shifted every naive-timestamp
|
||||
// day-boundary comparison by the offset — date-dependent tests failed
|
||||
// only in the evening, only under the snapshot. Pin the session to the
|
||||
// RUNTIME zone so restored engines behave exactly like cold ones.
|
||||
if (this._snapshotLoaded && this._db) {
|
||||
const runtimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
await this._db.query(`SELECT set_config('TimeZone', $1, false)`, [runtimeZone]);
|
||||
}
|
||||
// Healthy open: close any repair episode left open by a prior failed
|
||||
// attempt (red-team: episodes otherwise stayed open forever — doctor
|
||||
// kept reporting corruption-likely and a weeks-stale episode backup
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# gbrain agent workspace — template
|
||||
|
||||
<!-- gbrain-template-stamp: 0.45.17.0 -->
|
||||
<!-- gbrain-template-stamp: 0.45.18.0 -->
|
||||
|
||||
This repository is the **"Use this template"** distribution artifact for a
|
||||
[gbrain](https://github.com/garrytan/gbrain) personal-agent workspace — the same
|
||||
|
||||
@@ -150,6 +150,41 @@ describe('queryAgentClientSpend (v0.38 Slice 4 — /admin/api/agents/spend SQL)'
|
||||
expect(rows[0].spent_cents_today).toBe(50);
|
||||
});
|
||||
|
||||
it('day boundary is a UTC INSTANT, independent of the session timezone', async () => {
|
||||
// Regression pin for the snapshot-timezone incident: the old predicate
|
||||
// compared created_at against a NAIVE date_trunc result, which the
|
||||
// session timezone reinterpreted — a non-UTC session (host-tz PGLite,
|
||||
// tz-configured Postgres role, snapshot-restored engine pre-parity-fix)
|
||||
// shifted the day boundary by its offset and underreported evening spend.
|
||||
//
|
||||
// Deterministic at ANY wall-clock hour: rows exactly AT UTC midnight and
|
||||
// 1s BEFORE it must classify identically under sessions ±12h from UTC.
|
||||
// Under the old predicate, Etc/GMT+12 excluded the midnight row and
|
||||
// Etc/GMT-12 included the pre-midnight row — one of the two always broke.
|
||||
await seedClient({ id: 'tz-edge', scope: 'read agent' });
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO mcp_spend_log (client_id, operation, spend_cents, created_at)
|
||||
VALUES
|
||||
('tz-edge', 'subagent_loop', 7,
|
||||
date_trunc('day', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'),
|
||||
('tz-edge', 'subagent_loop', 999,
|
||||
(date_trunc('day', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC') - interval '1 second')`,
|
||||
);
|
||||
const original = (await engine.executeRaw<{ TimeZone: string }>(`SHOW timezone`))[0].TimeZone;
|
||||
for (const zone of ['Etc/GMT+12', 'Etc/GMT-12', 'UTC']) {
|
||||
await engine.executeRaw(`SELECT set_config('TimeZone', '${zone}', false)`);
|
||||
try {
|
||||
const rows = await queryAgentClientSpend(engine);
|
||||
const edge = rows.find(r => r.client_id === 'tz-edge')!;
|
||||
// Only the exactly-at-midnight row (7¢) counts as today — never the
|
||||
// 1s-before row (999¢) — regardless of session zone.
|
||||
expect(`${zone}:${edge.spent_cents_today}`).toBe(`${zone}:7`);
|
||||
} finally {
|
||||
await engine.executeRaw(`SELECT set_config('TimeZone', $1, false)`, [original]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('isolates spend by client_id (no cross-client leakage)', async () => {
|
||||
await seedClient({ id: 'alice', scope: 'read agent' });
|
||||
await seedClient({ id: 'bob', scope: 'read agent' });
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Snapshot-timezone parity pin.
|
||||
*
|
||||
* dumpDataDir bakes the BUILD process's TimeZone into the snapshot tar's
|
||||
* cluster defaults. Un-pinned, a snapshot-restored engine ran sessions in the
|
||||
* build machine's zone while cold-init engines follow the runtime process
|
||||
* (bun test pins TZ=UTC) — so every naive-timestamp day-boundary comparison
|
||||
* shifted by the offset, and date-dependent tests failed only in the evening,
|
||||
* only under GBRAIN_PGLITE_SNAPSHOT. Two fixes hold the line: the build
|
||||
* script pins TZ=UTC before dumping, and the engine re-pins the session to
|
||||
* the runtime zone on snapshot restore.
|
||||
*
|
||||
* This test is the deterministic pin: at ANY wall-clock hour, a cold engine
|
||||
* and a snapshot engine created by the same process must report the same
|
||||
* session TimeZone. Serial file: it mutates GBRAIN_PGLITE_SNAPSHOT (R1).
|
||||
*/
|
||||
import { describe, test, expect, afterAll } from 'bun:test';
|
||||
import { existsSync } from 'fs';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
|
||||
const SNAPSHOT = 'test/fixtures/pglite-snapshot.tar';
|
||||
|
||||
async function sessionUtcOffsetSeconds(env: Record<string, string | undefined>): Promise<number> {
|
||||
const prev = process.env.GBRAIN_PGLITE_SNAPSHOT;
|
||||
if (env.GBRAIN_PGLITE_SNAPSHOT === undefined) delete process.env.GBRAIN_PGLITE_SNAPSHOT;
|
||||
else process.env.GBRAIN_PGLITE_SNAPSHOT = env.GBRAIN_PGLITE_SNAPSHOT;
|
||||
const engine = new PGLiteEngine();
|
||||
try {
|
||||
await engine.connect({} as never);
|
||||
await engine.initSchema();
|
||||
// Compare the effective UTC OFFSET, not the zone label: cold init spells
|
||||
// the runtime zone one way (Etc/GMT0), the restore re-pin another (UTC) —
|
||||
// the invariant is identical instant arithmetic, not identical strings.
|
||||
const rows = await engine.executeRaw<{ off: string }>(
|
||||
`SELECT extract(timezone FROM now())::text AS off`,
|
||||
);
|
||||
return Number(rows[0].off);
|
||||
} finally {
|
||||
await engine.disconnect?.();
|
||||
if (prev === undefined) delete process.env.GBRAIN_PGLITE_SNAPSHOT;
|
||||
else process.env.GBRAIN_PGLITE_SNAPSHOT = prev;
|
||||
}
|
||||
}
|
||||
|
||||
describe('PGLite snapshot timezone parity', () => {
|
||||
// Build the fixture in-test (idempotent hash short-circuit makes re-runs
|
||||
// cheap) so this pin cannot silently skip in lanes without a prebuilt tar.
|
||||
test('snapshot fixture builds', async () => {
|
||||
const proc = Bun.spawnSync(['bun', 'run', 'build:pglite-snapshot'], {
|
||||
cwd: `${import.meta.dir}/..`,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
timeout: 300_000,
|
||||
killSignal: 'SIGKILL',
|
||||
});
|
||||
expect(proc.exitCode).toBe(0);
|
||||
expect(existsSync(`${import.meta.dir}/../${SNAPSHOT}`)).toBe(true);
|
||||
}, 320_000);
|
||||
|
||||
test('snapshot-restored session UTC offset equals cold-init session UTC offset', async () => {
|
||||
const cold = await sessionUtcOffsetSeconds({ GBRAIN_PGLITE_SNAPSHOT: undefined });
|
||||
const snap = await sessionUtcOffsetSeconds({ GBRAIN_PGLITE_SNAPSHOT: SNAPSHOT });
|
||||
expect(snap).toBe(cold);
|
||||
}, 120_000);
|
||||
});
|
||||
Reference in New Issue
Block a user