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
63 changes: 56 additions & 7 deletions src/services/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@

import * as fs from "fs";
import * as path from "path";
import { isConfigured, loadScarsWithEmbeddings } from "./supabase-client.js";
import { isConfigured, loadScarsWithEmbeddings, getLearningsFingerprint, getSupabaseUrl } from "./supabase-client.js";
import { computeStoreKey, readVectorCache, writeVectorCache, tryAcquireDownloadLock, waitForLeaderCache } from "./vector-disk-cache.js";
import { getTableName } from "./tier.js";
import { getGitmemDir } from "./gitmem-dir.js";
import {
Expand Down Expand Up @@ -106,13 +107,51 @@ interface ScarWithEmbedding {
* NOTE: Now loads all learning types (scars, patterns, wins, anti-patterns),
* not just scars. This fixes the issue where ~64 patterns were being ignored.
*/
async function loadScarsFromSupabase(): Promise<{
async function loadScarsFromSupabase(options: { bypassDiskCache?: boolean } = {}): Promise<{
scars: ScarWithEmbedding[];
latestUpdatedAt: string | null;
source: "disk" | "network";
}> {
console.error(`[startup] Loading ALL learnings with embeddings from Supabase (cross-project)`);
const startTime = Date.now();

// GIT-98: ask the store what it holds (one row, a few hundred bytes) before
// paying for the bulk download. Observed BEFORE the download on purpose: if
// the store changes mid-load the cache is merely stale and the next start
// reloads, which is the safe direction.
const storeKey = computeStoreKey(getSupabaseUrl(), getTableName("learnings"));
const fingerprint = await getLearningsFingerprint();

if (!options.bypassDiskCache) {
const cached = readVectorCache<ScarWithEmbedding>(storeKey, fingerprint);
if (cached.hit) {
console.error(
`[startup] Vector cache HIT: ${cached.rows.length} learnings from disk in ${Date.now() - startTime}ms ` +
`(${cached.bytesOnDisk} bytes on disk, bulk download skipped)`
);
return { scars: cached.rows, latestUpdatedAt: fingerprint.latestUpdatedAt, source: "disk" };
}
console.error(`[startup] Vector cache MISS: ${cached.reason}`);
}

// Single-flight the download across processes. A fan-out starts N workers at
// once and a single store write invalidates the cache for all of them, so
// without this a miss costs N identical downloads.
let releaseLock = tryAcquireDownloadLock(storeKey);
if (!releaseLock && !options.bypassDiskCache && fingerprint.count >= 0) {
console.error("[startup] Another process is downloading the index — waiting for its cache");
const fromLeader = await waitForLeaderCache<ScarWithEmbedding>(storeKey, fingerprint);
if (fromLeader) {
console.error(
`[startup] Vector cache HIT (via leader): ${fromLeader.rows.length} learnings in ${Date.now() - startTime}ms, bulk download skipped`
);
return { scars: fromLeader.rows, latestUpdatedAt: fingerprint.latestUpdatedAt, source: "disk" };
}
console.error("[startup] Leader did not produce a usable cache — downloading");
}
releaseLock = releaseLock ?? (() => {});

console.error(`[startup] Loading ALL learnings with embeddings from Supabase (cross-project)`);

try {
// Load ALL learnings across projects — semantic similarity handles relevance
const learnings = await loadScarsWithEmbeddings<ScarWithEmbedding>(undefined, 500);
Expand All @@ -127,10 +166,19 @@ async function loadScarsFromSupabase(): Promise<{
// Get latest updated_at for staleness tracking
const latestUpdatedAt = learnings.length > 0 ? learnings[0].updated_at || null : null;

return { scars: learnings, latestUpdatedAt };
// Never cache an empty result: an empty download is far more often a
// transient failure than a genuinely empty store, and caching it would make
// the failure sticky.
if (learnings.length > 0) {
writeVectorCache(storeKey, fingerprint, learnings);
}

return { scars: learnings, latestUpdatedAt, source: "network" };
} catch (error) {
console.error("[startup] Failed to load learnings:", error);
return { scars: [], latestUpdatedAt: null };
return { scars: [], latestUpdatedAt: null, source: "network" };
} finally {
releaseLock();
}
}

Expand Down Expand Up @@ -576,8 +624,9 @@ export async function flushCache(_project?: Project): Promise<CacheFlushResult>
const previousCount = getLocalVectorSearch().getScarCount();

try {
// Load ALL fresh scars (cross-project)
const { scars, latestUpdatedAt } = await loadScarsFromSupabase();
// Load ALL fresh scars (cross-project). A flush is the user saying "I do not
// trust what you have" — bypass the disk cache (GIT-98) and rewrite it.
const { scars, latestUpdatedAt } = await loadScarsFromSupabase({ bypassDiskCache: true });

// Reinitialize the unified index
await reinitializeLocalSearch(scars, undefined, latestUpdatedAt || undefined);
Expand Down
60 changes: 60 additions & 0 deletions src/services/supabase-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,66 @@ export async function loadScarsWithEmbeddings<T = unknown>(
}
}

/**
* Cheap fingerprint of the learnings the local index is built from (GIT-98).
*
* One request, one row: `Prefer: count=exact` puts the total in Content-Range
* ("0-0/273") and the single row carries the newest updated_at. A few hundred
* bytes, versus megabytes for the bulk load it lets a warm start skip.
*
* The filters MUST stay identical to loadScarsWithEmbeddings, or the
* fingerprint describes a different set from the one that was cached.
*
* Returns count -1 on any failure; callers treat that as "unknown", never as a match.
*/
export async function getLearningsFingerprint(): Promise<{ count: number; latestUpdatedAt: string | null }> {
if (!isConfigured()) return { count: -1, latestUpdatedAt: null };

try {
const url = new URL(`${SUPABASE_REST_URL}/${getTableName("learnings")}`);
url.searchParams.set("select", "updated_at");
url.searchParams.set("learning_type", "in.(scar,pattern,win,anti_pattern)");
url.searchParams.set("is_active", "eq.true");
url.searchParams.set("order", "updated_at.desc");
url.searchParams.set("limit", "1");

const response = await fetch(url.toString(), {
method: "GET",
headers: {
apikey: SUPABASE_KEY,
Authorization: `Bearer ${SUPABASE_KEY}`,
"Accept-Profile": "public",
Prefer: "count=exact",
},
signal: AbortSignal.timeout(10_000),
});

if (!response.ok) {
console.error(`[supabase-direct] Fingerprint request failed: ${response.status}`);
return { count: -1, latestUpdatedAt: null };
}

// Content-Range: "0-0/273", or "*/0" for an empty set.
const range = response.headers.get("content-range") || "";
const total = Number(range.split("/")[1]);
if (!Number.isInteger(total) || total < 0) {
console.error(`[supabase-direct] Fingerprint: unparseable Content-Range "${range}"`);
return { count: -1, latestUpdatedAt: null };
}

const rows = (await response.json()) as Array<{ updated_at?: string }>;
return { count: total, latestUpdatedAt: rows[0]?.updated_at ?? null };
} catch (error) {
console.error("[supabase-direct] Fingerprint request error:", error instanceof Error ? error.message : error);
return { count: -1, latestUpdatedAt: null };
}
}

/** The configured Supabase URL, for keying per-store caches. Empty when unconfigured. */
export function getSupabaseUrl(): string {
return SUPABASE_URL || "";
}

/**
* Scar search with severity weighting
*
Expand Down
Loading
Loading