Skip to content
Draft
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
8 changes: 8 additions & 0 deletions packages/harness/src/board.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,20 @@ test("polls queue entry, queue failure, and recovery through the real sync engin
{ number: 101, ciStatus: "failure" },
]);
await expect(card.getByText("failing", { exact: true })).toBeVisible();
await expect(
page.getByLabel("Sync status: Sync failed — data may be stale", {
exact: true,
}),
).toBeVisible();

scenario.setPhase("in-flight");
await scenario.syncPrs();
await expect(card.getByText("passing", { exact: true })).toBeVisible();
await expect(card).toHaveCount(1);
expect(await scenario.persistedPrs()).toHaveLength(1);
await expect(
page.getByLabel("Sync status: Up to date", { exact: true }),
).toBeVisible();
expect(errors).toEqual([]);
expect(apiResponses.length).toBeGreaterThan(3);
expect(apiResponses.every((response) => response.status === 200)).toBe(
Expand Down
14 changes: 14 additions & 0 deletions packages/harness/src/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ it("syncs queue transitions into SQLite, preserves the last snapshot on failure,
expect(prs).toHaveLength(1);
expect(prs[0]).toMatchObject({ number: 101, inMergeQueue, ciStatus });
}
const successful = engine
.getHealth()
.find((health) => health.kind === "prs")!;
expect(successful).toMatchObject({ status: "updated", reason: null });
expect(successful.lastSuccessAt).toBeTruthy();
github.setPhase("offline");
const failed = await engine.sync();
expect(failed.results[0].fetches.every((f) => f.status === "failed")).toBe(
Expand All @@ -38,8 +43,17 @@ it("syncs queue transitions into SQLite, preserves the last snapshot on failure,
expect(engine.listPullRequests(github.instance.id, "prs")[0].ciStatus).toBe(
"failure",
);
expect(
engine.getHealth().find((health) => health.kind === "prs"),
).toMatchObject({
status: "failed",
lastSuccessAt: successful.lastSuccessAt,
});
await engine.close();
engine = createSync(options);
expect(
engine.getHealth().find((health) => health.kind === "prs")?.status,
).toBe("failed");
expect(engine.listPullRequests(github.instance.id, "prs")).toHaveLength(1);
expect(engine.listPullRequests(github.instance.id, "prs")[0]).toMatchObject(
{ inMergeQueue: false, ciStatus: "failure" },
Expand Down
24 changes: 24 additions & 0 deletions packages/harness/src/stale.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { test, expect } from "@playwright/test";
import { startScenario } from "./runtime.js";

test("recomputes stale age when unchanged health is polled", async ({
page,
}) => {
const scenario = await startScenario();
try {
await scenario.syncPrs();
await page.goto(scenario.url);
await expect(
page.getByLabel("Sync status: Up to date", { exact: true }),
).toBeVisible();
// Advance client time without waiting 90 seconds. The persisted upstream
// timestamp remains unchanged; structural sharing must not hide its age.
await page.clock.install();
await page.clock.fastForward(120_000);
await expect(
page.getByLabel("Sync status: Data is stale", { exact: true }),
).toBeVisible();
} finally {
await scenario.close();
}
});
7 changes: 7 additions & 0 deletions packages/server/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ api.post("/config/create", (c) => {
return c.json(result);
});

api.get("/sync/health", async (c) => {
if (process.env.DEMO === "1") return c.json([]);
const engine = getSync();
await engine.reconcileConfiguration();
return c.json(engine.getHealth());
});

// Authored PRs with CI + review status
api.get("/:instanceId/prs", async (c) => {
const { instanceId } = c.req.param();
Expand Down
21 changes: 21 additions & 0 deletions packages/sync/src/cache/open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,27 @@ describe("cache open", () => {
db.close();
});

test("adds health to v3 without discarding snapshots", () => {
const first = openCache();
first.db
.prepare(
"INSERT INTO instances VALUES ('x','X','https://api.github.com','user','key')",
)
.run();
first.db
.prepare("UPDATE meta SET value='3' WHERE key='schema_version'")
.run();
first.db.exec("DROP TABLE sync_health");
first.db.close();
const next = openCache();
expect(next.wiped).toBe(false);
expect(next.db.prepare("SELECT id FROM instances").all()).toEqual([
{ id: "x" },
]);
expect(next.db.prepare("SELECT * FROM sync_health").all()).toEqual([]);
next.db.close();
});

test("wipeCacheFile removes the file", () => {
const { db } = openCache();
db.close();
Expand Down
12 changes: 10 additions & 2 deletions packages/sync/src/cache/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, rmSync } from "node:fs";
import { dirname } from "node:path";
import Database from "better-sqlite3";
import { resolveCachePath } from "./path.js";
import { CACHE_SCHEMA_VERSION, SCHEMA_DDL } from "./schema.js";
import { CACHE_SCHEMA_VERSION, SCHEMA_DDL, SYNC_HEALTH_DDL } from "./schema.js";

export type Cache = Database.Database;

Expand All @@ -25,7 +25,15 @@ export function openCache(): OpenCacheResult {
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'meta'",
)
.get();
const stored = hasMeta ? readSchemaVersion(db) : null;
let stored = hasMeta ? readSchemaVersion(db) : null;
// Health is additive: preserve v3 snapshots and mutation guards.
if (stored === 3 && CACHE_SCHEMA_VERSION === 4) {
db.transaction(() => {
db.exec(SYNC_HEALTH_DDL);
writeSchemaVersion(db, CACHE_SCHEMA_VERSION);
})();
stored = CACHE_SCHEMA_VERSION;
}
if (
(hasTables && stored !== CACHE_SCHEMA_VERSION) ||
(stored !== null && stored !== CACHE_SCHEMA_VERSION)
Expand Down
9 changes: 8 additions & 1 deletion packages/sync/src/cache/schema.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
export const CACHE_SCHEMA_VERSION = 3;
export const CACHE_SCHEMA_VERSION = 4;
export const SYNC_HEALTH_DDL = `
CREATE TABLE IF NOT EXISTS sync_health (
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
kind TEXT NOT NULL, attempted_at TEXT NOT NULL, status TEXT NOT NULL,
reason TEXT, retry_at TEXT, PRIMARY KEY(instance_id,kind)
);`;
export const SCHEMA_DDL = `
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
${SYNC_HEALTH_DDL}
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS instances (
id TEXT PRIMARY KEY, label TEXT NOT NULL, base_url TEXT NOT NULL,
Expand Down
45 changes: 45 additions & 0 deletions packages/sync/src/cache/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,48 @@ test("mutation guards survive reopen, protect all list writes, merge patches and
rmSync(dir, { recursive: true, force: true });
}
});

test("health and rate-budget errors from an old identity cannot overwrite a replacement", () => {
const dir = mkdtempSync(join(tmpdir(), "ghd-health-store-"));
const old = process.env.XDG_CACHE_HOME;
process.env.XDG_CACHE_HOME = dir;
const { db } = openCache();
try {
const store = createStore(db);
const instance = {
id: "health",
label: "Health",
baseUrl: "https://api.github.com",
username: "u",
credentialKey: "old",
};
store.reconcile([instance]);
store.recordHealth(
"health",
"prs",
{ status: "failed", reason: "old failure", retryAt: null },
"old",
);
store.reconcile([{ ...instance, credentialKey: "new" }]);
expect(
store.recordHealth(
"health",
"prs",
{ status: "failed", reason: "late failure", retryAt: null },
"old",
),
).toBe(false);
store.recordBudget("health", "graphql", { remaining: 0 }, "old");
expect(store.health("health", "prs").status).toBe("never");
expect(store.budget("health", "graphql")).toBeUndefined();
store.reconcile([]);
expect(() =>
store.recordBudget("health", "graphql", { remaining: 0 }, "new"),
).not.toThrow();
} finally {
db.close();
if (old === undefined) delete process.env.XDG_CACHE_HOME;
else process.env.XDG_CACHE_HOME = old;
rmSync(dir, { recursive: true, force: true });
}
});
90 changes: 81 additions & 9 deletions packages/sync/src/cache/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ export type CacheMutation =
| { kind: "patch"; repo: string; number: number; patch: PullRequestPatch }
| { kind: "notificationDone"; id: string };

export interface SyncHealth {
instanceId: string;
kind: Kind;
lastSuccessAt: string | null;
lastAttemptAt: string | null;
status: "never" | "syncing" | "updated" | "unchanged" | "skipped" | "failed";
reason: string | null;
retryAt: string | null;
}

export interface Metadata {
etag?: string | null;
remaining?: number | null;
Expand Down Expand Up @@ -63,16 +73,30 @@ export function createStore(db: Cache) {
db.prepare(`INSERT INTO budgets VALUES (?,?,?,?) ON CONFLICT(instance_id,resource)
DO UPDATE SET remaining=excluded.remaining,reset_at=excluded.reset_at`);

function recordBudget(id: string, resource: string, metadata: Metadata) {
if (metadata.remaining !== undefined && metadata.remaining !== null) {
saveBudget.run(
id,
resource,
metadata.remaining,
metadata.resetAt ?? null,
);
}
function recordBudget(
id: string,
resource: string,
metadata: Metadata,
credentialKey?: string,
) {
db.transaction(() => {
if (credentialKey !== undefined) {
const current = oldInstance.get(id) as
| { credential_key: string }
| undefined;
if (current?.credential_key !== credentialKey) return;
}
if (metadata.remaining !== undefined && metadata.remaining !== null) {
saveBudget.run(
id,
resource,
metadata.remaining,
metadata.resetAt ?? null,
);
}
})();
}

// Guards live beside snapshots so another process/in-flight fetch cannot
// resurrect stale search-index data after a successful write.
const clearNotificationEtag = db.prepare(
Expand Down Expand Up @@ -187,6 +211,54 @@ export function createStore(db: Cache) {
if (mutation.kind === "notificationDone") clearNotificationEtag.run(id);
})();
},
health(id: string, kind: Kind): SyncHealth {
const row = db
.prepare("SELECT * FROM sync_health WHERE instance_id=? AND kind=?")
.get(id, kind) as
| {
attempted_at: string;
status: SyncHealth["status"];
reason: string | null;
retry_at: string | null;
}
| undefined;
const snapshot = state.get(id, kind) as
| { last_run_at: string | null }
| undefined;
return {
instanceId: id,
kind,
lastSuccessAt: snapshot?.last_run_at ?? null,
lastAttemptAt: row?.attempted_at ?? null,
status: row?.status ?? "never",
reason: row?.reason ?? null,
retryAt: row?.retry_at ?? null,
};
},
recordHealth(
id: string,
kind: Kind,
outcome: Pick<SyncHealth, "status" | "reason" | "retryAt">,
credentialKey: string,
) {
return db.transaction(() => {
const current = oldInstance.get(id) as
| { credential_key: string }
| undefined;
if (current?.credential_key !== credentialKey) return false;
db.prepare(
"INSERT OR REPLACE INTO sync_health VALUES (?,?,?,?,?,?)",
).run(
id,
kind,
new Date().toISOString(),
outcome.status,
outcome.reason,
outcome.retryAt,
);
return true;
})();
},
listInstances: () => listInstances.all() as Instance[],
updateAuthenticatedIdentity(
id: string,
Expand Down
Loading
Loading