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
47 changes: 46 additions & 1 deletion src/cache/hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,51 @@ export function canonicalJson(value: unknown): string {
});
}

/** SHA-256 hex digest of a string. */
async function sha256Hex(input: string): Promise<string> {
const data = new TextEncoder().encode(input);
const buf = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}

/**
* Aggregate fingerprint of a service's whole schema surface.
*
* Order-independent: hashes the sorted list of per-tool hashes, so the same set
* of tools always yields the same fingerprint regardless of listing order. A
* change to ANY tool's surface (including a new inputSchema field like
* `create_if_missing`) changes the fingerprint. This is the fallback signal for
* servers that do not publish an authoritative contract hash; for servers that
* do (e.g. Open Brain's `get_contract.schema_hash`), prefer that value.
*/
export async function fingerprintSchemas(
tools: { hash: string }[],
): Promise<string> {
const joined = tools
.map((t) => t.hash)
.sort()
.join("|");
return sha256Hex(joined);
}

/**
* Placeholder some call sites substitute for a missing description before they
* reach the hasher. Normalized to "" so the fingerprint is identical whether a
* writer passed `undefined`, "", or this placeholder -- otherwise the daemon
* (which hashes `description ?? ""`) and the client warm path (which hashes the
* already-defaulted "(no description)") would produce divergent fingerprints
* for the same tool, clearing the cache on every read. See #58.
*/
const MISSING_DESCRIPTION = "(no description)";

/** Canonical, writer-independent description used for hashing. */
function normalizeDescription(description?: string): string {
if (!description || description === MISSING_DESCRIPTION) return "";
return description;
}

/**
* Compute SHA-256 hash of a tool's schema surface.
* The "surface" is: name + description + inputSchema + annotations.
Expand All @@ -33,7 +78,7 @@ export async function hashToolSchema(tool: {
}): Promise<string> {
const surface = canonicalJson({
name: tool.name,
description: tool.description ?? "",
description: normalizeDescription(tool.description),
inputSchema: tool.inputSchema,
annotations: tool.annotations ?? null,
});
Expand Down
4 changes: 3 additions & 1 deletion src/cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export type {
DriftResult,
} from "./types.ts";

export { canonicalJson, hashToolSchema } from "./hash.ts";
export { canonicalJson, hashToolSchema, fingerprintSchemas } from "./hash.ts";

export {
getCacheDir,
Expand All @@ -19,6 +19,8 @@ export {
readCacheRaw,
writeCache,
clearCache,
clearServiceCacheKeys,
readCacheFingerprint,
listCachedServices,
isCacheExpired,
resolveTtlMs,
Expand Down
87 changes: 87 additions & 0 deletions src/cache/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { mkdir, unlink, readdir, rename } from "node:fs/promises";
import { dirname, join } from "node:path";
import { createLogger } from "../logger/index.ts";
import type { CacheEntry, CacheMetadata, CachedToolSchema } from "./types.ts";
import { fingerprintSchemas } from "./hash.ts";

const log = createLogger("cache");

Expand Down Expand Up @@ -126,19 +127,30 @@ export async function writeCache(
service: string,
tools: CachedToolSchema[],
ttlMs: number = DEFAULT_TTL_MS,
/**
* Authoritative contract fingerprint from a server that publishes one
* (e.g. Open Brain's `get_contract.schema_hash`). When omitted, the
* fingerprint is derived from the tool surface hashes so staleness can still
* be detected for servers without a contract.
*/
contractFingerprint?: string,
): Promise<void> {
const filePath = getCacheFilePath(service);
const dir = dirname(filePath);

// Ensure cache directory exists
await mkdir(dir, { recursive: true });

const schemaFingerprint =
contractFingerprint ?? (await fingerprintSchemas(tools));

const entry: CacheEntry = {
metadata: {
service,
cachedAt: new Date().toISOString(),
ttlMs,
toolCount: tools.length,
schemaFingerprint,
},
tools,
};
Expand Down Expand Up @@ -196,6 +208,81 @@ export async function clearCache(service?: string): Promise<number> {
return cleared;
}

/**
* Clear every cache key belonging to a service: the bare `<service>.json`
* entry AND all per-credential entries `credential:<base64url([service,user])>.json`.
*
* A contract bump must invalidate all of these together -- otherwise the base
* key looks refreshed while a user's credential-scoped read still serves the
* old schema (the #58 failure). Credential keys encode the service name as the
* first element of the base64url-encoded `[service, userId]` tuple, so we decode
* each and match. Returns the number of files removed.
*/
export async function clearServiceCacheKeys(service: string): Promise<number> {
validateServiceName(service);
const cacheDir = getCacheDir();
let cleared = 0;

let entries: string[];
try {
entries = await readdir(cacheDir);
} catch {
return 0;
}

for (const entry of entries) {
if (!entry.endsWith(".json")) continue;
const key = entry.replace(/\.json$/, "");

let matches = key === service;
if (!matches && key.startsWith("credential:")) {
const encoded = key.slice("credential:".length);
try {
const decoded = JSON.parse(
Buffer.from(encoded, "base64url").toString("utf8"),
);
matches = Array.isArray(decoded) && decoded[0] === service;
} catch {
// Not a decodable credential key -- leave it alone.
matches = false;
}
}

if (matches) {
// Count only files actually removed. A swallowed unlink that still
// incremented the count would report success while a stale credential
// entry survives -- silently re-opening the #58 staleness it exists to fix.
try {
await unlink(join(cacheDir, entry));
cleared++;
} catch (err) {
log.warn("cache_unlink_failed", {
service,
entry,
error: err instanceof Error ? err.message : String(err),
});
}
}
}

if (cleared > 0) {
log.info("cache_keys_cleared", { service, count: cleared });
}
return cleared;
}

/**
* Read just the schema fingerprint for a cached service key, ignoring TTL.
* Returns undefined when there is no cache entry or it predates fingerprinting.
* Used to stamp daemon responses for #58 client-side cache coherence.
*/
export async function readCacheFingerprint(
service: string,
): Promise<string | undefined> {
const entry = await readCacheRaw(service);
return entry?.metadata.schemaFingerprint;
}

/**
* List all cached services.
* Returns service names that have cache files.
Expand Down
8 changes: 8 additions & 0 deletions src/cache/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ export interface CacheMetadata {
ttlMs: number;
/** Number of tools cached */
toolCount: number;
/**
* Deterministic fingerprint of the full schema surface (every tool's
* name + description + inputSchema + annotations) at write time. Lets the
* cache detect a contract change independent of the TTL: when a live fetch
* produces a different fingerprint, the cache is stale and must be refetched.
* Optional for backward compatibility with entries written before this field.
*/
schemaFingerprint?: string;
}

/** A complete cache entry -- metadata + tool schemas */
Expand Down
14 changes: 13 additions & 1 deletion src/cli/commands/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ export const handleCache: CommandHandler = async (args: string[]) => {
" status Show cache status for all services",
" diff <service> Compare cached vs live schemas for a service",
" warm [service] Fetch and cache schemas (all or specific service)",
"",
"OPTIONS:",
" --force (warm) clear the existing cache entry before refetching.",
" Use this after an upstream contract/schema bump -- the",
" cache otherwise serves the old schema until its TTL.",
].join("\n"),
);
process.exitCode = subcommand
Expand Down Expand Up @@ -132,7 +137,9 @@ async function handleCacheDiff(args: string[]): Promise<void> {
}

async function handleCacheWarm(args: string[]): Promise<void> {
const targetService = args[0];
const force = args.includes("--force");
const positional = args.filter((a) => !a.startsWith("--"));
const targetService = positional[0];
const config = await loadConfig();
const serviceNames = targetService
? [targetService]
Expand All @@ -153,6 +160,11 @@ async function handleCacheWarm(args: string[]): Promise<void> {
const service = config.services[serviceName]!;
console.log(` warming ${serviceName}...`);
try {
// --force: drop the stale entry first so a refetch failure can't leave the
// old schema in place. The recommended recovery after a contract bump.
if (force) {
await clearCache(serviceName);
}
const result = await Promise.race([
(async () => {
const { cachedSchemas } = await discoverServiceSchemas(
Expand Down
16 changes: 15 additions & 1 deletion src/daemon/drift-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* ADV-06: Triggers auto-regeneration of skill files when drift is detected.
*/
import type { McpConnection } from "../connection/types.ts";
import { readCacheRaw, writeCache, detectDrift, resolveTtlMs, mapToolsToCachedSchemas } from "../cache/index.ts";
import { readCacheRaw, writeCache, detectDrift, resolveTtlMs, mapToolsToCachedSchemas, clearServiceCacheKeys } from "../cache/index.ts";
import type { AccessPolicy } from "../access/types.ts";
import { listAllTools } from "../schema/introspect.ts";
import { autoRegenerateSkills } from "../generation/auto-regen.ts";
Expand Down Expand Up @@ -54,6 +54,20 @@ export async function checkDriftOnConnect(
changeCount: drift.changes.length,
});

// #58: a contract change must invalidate EVERY cache key for this
// service -- the bare entry AND all per-credential keys -- not just the
// base entry rewritten below. Otherwise a user's credential-scoped read
// keeps serving the old schema after the bump. Clear them all here; the
// base entry is repopulated immediately below, and credential entries
// refill on their next read.
const clearedKeys = await clearServiceCacheKeys(serviceName);
if (clearedKeys > 0) {
log.info("drift_cache_invalidated", {
service: serviceName,
keysCleared: clearedKeys,
});
}

// ADV-06: Auto-regenerate skill files on drift
const toolSummaries = liveTools.map((t) => ({
name: t.name,
Expand Down
18 changes: 16 additions & 2 deletions src/daemon/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
resolveToolNameCached,
} from "../schema/cached.ts";
import { getToolSchema, listToolsForService } from "../schema/introspect.ts";
import { readCacheFingerprint } from "../cache/index.ts";
import { auditToolCall, sanitizeParams } from "../logger/audit.ts";
import { checkToolAccess, extractPolicy } from "../access/index.ts";
import { ConnectionError } from "../connection/errors.ts";
Expand Down Expand Up @@ -424,7 +425,18 @@ export function createDaemonServer(opts: DaemonServerOptions) {
const tools = body.fresh
? await listToolsForService(conn.client)
: await listToolsCached(conn.client, listPoolKey);
return Response.json({ success: true, result: tools });
// #58: stamp the BARE-service fingerprint (not the credential pool
// key). The drift-hook maintains the bare entry as the canonical,
// credential-independent per-service signal, and the client also keys
// its cache by bare service -- so both sides compare the same key.
// Comparing a credential-scoped fingerprint against the client's bare
// key never converges and would clear the cache on every call.
const listFingerprint = await readCacheFingerprint(body.service);
return Response.json({
success: true,
result: tools,
schemaFingerprint: listFingerprint,
});
} catch (err) {
return handleEndpointError(err, pool);
} finally {
Expand Down Expand Up @@ -463,7 +475,9 @@ export function createDaemonServer(opts: DaemonServerOptions) {
404,
);
}
return Response.json({ success: true, result });
// #58: stamp the BARE-service fingerprint (see /list-tools).
const schemaFingerprint = await readCacheFingerprint(body.service);
return Response.json({ success: true, result, schemaFingerprint });
} catch (err) {
return handleEndpointError(err, pool);
} finally {
Expand Down
9 changes: 9 additions & 0 deletions src/daemon/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ export interface DaemonSchemaRequest {
export interface DaemonCallResponse {
success: true;
result: unknown;
/**
* #58 cache coherence: the daemon's current schema fingerprint for the
* service this response targeted. The client compares it against its own
* cached fingerprint and drops its local cache on mismatch -- so a contract
* bump that the daemon has already absorbed invalidates the client cache on
* the next call, with no extra round-trip. Per-service: a response only
* carries the fingerprint for the single service it was about.
*/
schemaFingerprint?: string;
}

/** Error daemon response envelope with typed error code */
Expand Down
33 changes: 31 additions & 2 deletions src/process/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getDaemonPaths, getRemoteConfig } from "../daemon/paths.ts";
import { ConnectionError } from "../connection/errors.ts";
import { getDaemonStatus, cleanStaleDaemon } from "./liveness.ts";
import { getRemoteServiceAvailability } from "./remote-discovery.ts";
import { readCacheFingerprint, clearServiceCacheKeys } from "../cache/index.ts";
import type { ServiceSource, ServicesConfig } from "../config/index.ts";
import type { DaemonPaths } from "../daemon/types.ts";
import type {
Expand Down Expand Up @@ -517,13 +518,39 @@ export async function callViaDaemon(
return fetchDaemon("/call", request);
}

/**
* #58 client-side cache coherence: if the daemon stamped a schemaFingerprint
* that differs from the client's locally-cached fingerprint for this service,
* the client's cache is stale (the daemon already absorbed an upstream contract
* bump). Drop the client's local entries so the next read refetches. Best-effort
* and per-service -- a response only carries the fingerprint for its own service.
*/
export async function reconcileClientCache(
service: string,
response: DaemonResponse,
): Promise<void> {
if (!response.success || !response.schemaFingerprint) return;
try {
const localFingerprint = await readCacheFingerprint(service);
// Only invalidate when we HAVE a local fingerprint that disagrees. A missing
// local fingerprint is a cold/absent cache -- nothing to invalidate.
if (localFingerprint && localFingerprint !== response.schemaFingerprint) {
await clearServiceCacheKeys(service);
}
} catch {
// Coherence is an optimization; never fail a read because of it.
}
}

/**
* Send a list-tools request to the daemon.
*/
export async function listToolsViaDaemon(
request: DaemonListToolsRequest,
): Promise<DaemonResponse> {
return fetchDaemon("/list-tools", request);
const response = await fetchDaemon("/list-tools", request);
await reconcileClientCache(request.service, response);
return response;
}

/**
Expand All @@ -532,7 +559,9 @@ export async function listToolsViaDaemon(
export async function getSchemaViaDaemon(
request: DaemonSchemaRequest,
): Promise<DaemonResponse> {
return fetchDaemon("/schema", request);
const response = await fetchDaemon("/schema", request);
await reconcileClientCache(request.service, response);
return response;
}

/**
Expand Down
Loading