import { describe, expect, it } from "@effect/vitest"; import { Duration, Effect, Fiber, Latch } from "effect"; import { CONNECTION_CATALOG_SCAN_COLUMNS } from "./core-schema"; import { DEFAULT_TOOLS_SYNC_TTL_MS, createExecutor } from "./executor"; import type { FumaDb } from "./fuma-runtime"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName } from "./ids"; import { definePlugin } from "./plugin"; import { makeTestConfig, memoryCredentialsPlugin } from "./testing"; import type { ToolSyncErrorKind } from "./tool-sync-schedule"; // A tools READ refreshes stale catalogs before it answers, and each refresh is // a live upstream handshake. These cases pin the three properties that keep // that affordable and safe: the refresh sees only connections inside the read's // own filter, in steady state it sees nothing at all, and a wide refresh runs // its handshakes concurrently WITHOUT letting the persists interleave. const INTEG_A = IntegrationSlug.make("alpha"); const INTEG_B = IntegrationSlug.make("beta"); const TEMPLATE = AuthTemplateSlug.make("apiKey"); /** How long any latch in this file is allowed to hold a fiber. Far longer than * a passing run needs (the whole suite is milliseconds), and only ever reached * when the overlap a case is establishing never happened — a sequential * refresh fan-out, or a claim compare-and-set that stopped excluding. Reaching * it lets the fiber through so the case fails on its own assertion instead of * hanging the suite into an opaque timeout with no diff. */ const LATCH_TIMEOUT_MS = 5000; const withLatchTimeout = (effect: Effect.Effect) => effect.pipe( Effect.timeoutOrElse({ duration: Duration.millis(LATCH_TIMEOUT_MS), orElse: () => Effect.succeed(undefined), }), ); /** A plugin owning two integrations that records every `resolveTools` it is * asked for, as `/`. The recorded list IS the cost * a read pays upstream, so most assertions below are about its contents. * * `holdResolvesUntil` turns "the handshakes overlapped" from a race the test * hopes for into one it establishes: every resolve blocks on a latch that * only opens once N of them are in flight together. Left disarmed during * setup, where connections are created one at a time and a latch waiting for * a second resolve would simply hang. */ const makeCountingPlugin = () => { const resolved: string[] = []; let dying = false; let toolSuffix = "deploy"; let holdUntil: number | null = null; let inFlight = 0; let peakInFlight = 0; let incompleteKind: ToolSyncErrorKind | null | undefined; const gate = Latch.makeUnsafe(true); const entered = Latch.makeUnsafe(false); const plugin = definePlugin(() => ({ id: "counting" as const, storage: () => ({}), // Stands in for an MCP server: a catalog only the upstream knows, so the // freshness TTL can expire it. Without this the `expired` state is // unreachable from these tests, and `expired` is the one state the park // gates. remoteToolCatalog: true, resolveTools: (input) => Effect.gen(function* () { const slug = String(input.connection.integration); resolved.push(`${slug}/${String(input.connection.name)}`); inFlight += 1; peakInFlight = Math.max(peakInFlight, inFlight); entered.openUnsafe(); if (holdUntil !== null && inFlight >= holdUntil) gate.openUnsafe(); yield* withLatchTimeout(gate.await); inFlight -= 1; if (dying) return yield* Effect.die("resolveTools blew up"); if (incompleteKind !== undefined) { return { tools: [], incomplete: true, incompleteReason: "the fixture upstream refused", ...(incompleteKind === null ? {} : { incompleteKind }), }; } return { tools: [{ name: ToolName.make(`${slug}_${toolSuffix}`), description: toolSuffix }], }; }), invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), extension: (ctx) => ({ seed: () => Effect.all([ ctx.core.integrations.register({ slug: INTEG_A, description: "Alpha", config: {} }), ctx.core.integrations.register({ slug: INTEG_B, description: "Beta", config: {} }), ]), /** The `config_revised` trigger as a plugin actually fires it. The public * `integrations.update` accepts only name/description; `config` — and so * the `config_revised_at` stamp — is on the plugin-facing surface. */ revise: (slug: IntegrationSlug) => ctx.core.integrations.update(slug, { config: { revision: 2 } }), /** The `stale_marked` trigger as a plugin actually fires it (an MCP * `tools/list_changed`, an unknown-tool rejection). */ markStale: (slug: IntegrationSlug, name: string) => ctx.connections.markToolsStale({ owner: "org", integration: slug, name: ConnectionName.make(name), }), }), }))(); return { plugin, resolved, startDying: () => void (dying = true), /** Change what the next resolve returns, so a rebuild that never landed is * distinguishable from one that did. Without this every refresh rewrites * the identical row and a persist that silently failed still looks * correct — the catalog it left behind is the one it would have written. */ renameToolsTo: (suffix: string) => void (toolSuffix = suffix), holdResolvesUntil: (count: number) => { holdUntil = count; gate.closeUnsafe(); }, peakConcurrentResolves: () => peakInFlight, /** Make every resolve report a non-authoritative listing. `null` leaves it * unclassified (a plugin that knows it failed but not why); a kind is what * core parks or backs off on. `undefined` restores real listings. */ resolveIncompleteAs: (kind: ToolSyncErrorKind | null | undefined) => { incompleteKind = kind; }, /** Block every resolve until {@link releaseResolves}, so a second read can * be driven all the way through while the first is mid-handshake. */ blockResolves: () => { entered.closeUnsafe(); gate.closeUnsafe(); }, releaseResolves: () => gate.openUnsafe(), /** Resolves when a resolve has actually STARTED — the claim is taken by * then, which is the state the contention cases need to observe. */ awaitResolveStarted: withLatchTimeout(entered.await), }; }; /** Wrap a query object so every `connection` read reports its row count AND its * column projection, and so overlapping transactions are visible. * * The refresh's scan is the query this change narrows: "returned zero rows" is * what separates a narrowed scan from one that over-fetches and then discards, * and the recorded `select` is what stops the projection from being deleted * while the row-count assertions stay green. * * `transaction` is wrapped too — the wrapped orm is forwarded into the run * callback so reads issued inside a transaction are counted, and the open * count is what proves catalog persists do not interleave. `internal` is * deliberately out of scope: it is the test harness's own lazy-open escape * hatch, so wrapping it would count harness traffic rather than the * executor's. `withContext` is forwarded so the executor's owner-policy * binding keeps using the wrapper (as FumaDB requires). */ const observeConnectionReads = (db: FumaDb) => { const connectionScans: { readonly rows: number; readonly select: unknown }[] = []; let openTransactions = 0; let peakOpenTransactions = 0; const wrap = (inner: FumaDb): FumaDb => new Proxy(inner, { get(target, prop, receiver) { const value: unknown = Reflect.get(target, prop, receiver); if (typeof value !== "function") return value; // Cast to the interface's OWN member types, never to a hand-written // mirror: the recorder's contract is with `AbstractQuery`, and a // re-declared signature is one that silently stops matching it. if (prop === "withContext") { const withContext = value as NonNullable; return (context: unknown) => wrap(Reflect.apply(withContext, inner, [context])); } if (prop === "transaction") { const transaction = value as FumaDb["transaction"]; return (run: (orm: FumaDb) => Promise) => { openTransactions += 1; peakOpenTransactions = Math.max(peakOpenTransactions, openTransactions); return Reflect.apply(transaction, inner, [(orm: FumaDb) => run(wrap(orm))]).finally( () => { openTransactions -= 1; }, ); }; } if (prop !== "findMany") return value; const findMany = value as FumaDb["findMany"]; return async (table: string, options?: { readonly select?: unknown }) => { const rows = await Reflect.apply(findMany, inner, [table, options]); if (table === "connection") { connectionScans.push({ rows: rows.length, select: options?.select }); } return rows; }; }, }); return { db: wrap(db), connectionScans, peakOpenTransactions: () => peakOpenTransactions, }; }; /** The projection the catalog-refresh scan is expected to ask for, written out * rather than re-exported from the production constant: comparing the * implementation to itself would catch only the `select` option being deleted, * never the failure the constant's own docstring names — someone adding * `value`, `oauth_state` or `last_health` to it so the every-read scan starts * pulling credential and health JSON. */ const SCAN_PROJECTION = [ "owner", "integration", "name", "tools_synced_at", "tools_stale_token", "tools_sync_claim_id", "tools_sync_claim_at", "tools_sync_failures", "tools_sync_retry_at", "tools_sync_error_kind", ]; describe("connection catalog scan projection", () => { it("is the address plus the sync lifecycle, and nothing else", () => { expect([...CONNECTION_CATALOG_SCAN_COLUMNS]).toEqual(SCAN_PROJECTION); }); }); const makeHarness = () => Effect.acquireRelease( Effect.gen(function* () { const counting = makeCountingPlugin(); const config = makeTestConfig({ plugins: [memoryCredentialsPlugin(), counting.plugin] as const, }); const observed = observeConnectionReads(config.db); const executor = yield* createExecutor({ ...config, db: observed.db }); yield* executor.counting.seed(); return { ...counting, executor, testDb: config.testDb, connectionScans: observed.connectionScans, peakOpenTransactions: observed.peakOpenTransactions, connect: (integration: IntegrationSlug, name: string) => executor.connections.create({ owner: "org", name: ConnectionName.make(name), integration, template: TEMPLATE, value: "secret-token", }), /** Clear every connection's catalog stamp, which is the `cold` trigger: * a row with no stamp and no drift mark has never synced. NOT the * drift trigger — `connections.markToolsStale` leaves `tools_synced_at` * alone and writes `tools_stale_token`, which is what * `executor.counting.markStale` drives. Used by the cases that only * need a connection to be due, whatever made it due. */ clearEveryCatalogStamp: () => Effect.promise(() => observed.db.updateMany("connection", { where: (b) => b.isNotNull("tools_synced_at"), set: { tools_synced_at: null }, }), ), /** Push every catalog past the freshness window, which is the `expired` * trigger: a connection that HAS a working catalog and is only being * asked to re-verify it. Distinct from `cold` in exactly the way the * park cares about, so the two cannot share a helper. */ expireEveryCatalog: () => Effect.promise(() => observed.db.updateMany("connection", { where: (b) => b.isNotNull("tools_synced_at"), set: { tools_synced_at: Date.now() - DEFAULT_TOOLS_SYNC_TTL_MS - 1 }, }), ), /** Push every catalog stamp into the past. The `config_revised` trigger * compares `tools_synced_at` against a stamp taken later in the same * test; on an in-memory database both can land in one millisecond and * the comparison is strict, so the ordering is made explicit here * rather than left to the clock. */ backdateEveryCatalog: () => Effect.promise(() => observed.db.updateMany("connection", { where: (b) => b.isNotNull("tools_synced_at"), set: { tools_synced_at: Date.now() - 60_000 }, }), ), /** The sync lifecycle columns, as persisted. Read straight from the * database rather than through a projection, because "what did the * failed refresh actually write" is the whole question. */ syncStateOf: (name: string) => Effect.promise(() => observed.db.findFirst("connection", { where: (b) => b("name", "=", name) }), ).pipe( Effect.flatMap((row) => // A missing row is a defect, not a state. Projecting it into a // populated "clean" record is what would let `syncedAt` and // `claimId` assert null against an implementation that DELETED // the connection rather than one that correctly declined to // stamp it. row == null ? Effect.die(`no connection row named ${name}`) : Effect.succeed({ syncedAt: row.tools_synced_at == null ? null : Number(row.tools_synced_at), staleToken: row.tools_stale_token == null ? null : String(row.tools_stale_token), failures: row.tools_sync_failures == null ? 0 : Number(row.tools_sync_failures), retryAt: row.tools_sync_retry_at == null ? null : Number(row.tools_sync_retry_at), errorKind: row.tools_sync_error_kind ?? null, claimId: row.tools_sync_claim_id ?? null, }), ), ), /** Re-claim a connection out from under whoever holds it, as a second * Workers isolate does when it observes an expired lease. There is no * in-process seam for this — the row IS the coordination medium. */ stealClaim: (name: string) => Effect.promise(() => observed.db.updateMany("connection", { where: (b) => b("name", "=", name), set: { tools_sync_claim_id: "another-isolate", tools_sync_claim_at: Date.now() }, }), ), /** Move every backoff window into the past. The ladder's first rung is * the freshness TTL (15 minutes by default), so the alternative is * sleeping through it. */ elapseEveryBackoff: () => Effect.promise(() => observed.db.updateMany("connection", { where: (b) => b.isNotNull("tools_sync_retry_at"), set: { tools_sync_retry_at: Date.now() - 1 }, }), ), }; }), ({ executor, testDb }) => executor .close() .pipe( Effect.ignore, Effect.andThen(Effect.promise(() => testDb.close()).pipe(Effect.ignore)), ), ); describe("tools read catalog refresh scope", () => { it.effect("refreshes only the integration the read filters to", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeHarness(); yield* harness.connect(INTEG_A, "main"); yield* harness.connect(INTEG_B, "main"); yield* harness.clearEveryCatalogStamp(); harness.resolved.length = 0; const alpha = yield* harness.executor.tools.list({ integration: INTEG_A }); expect(harness.resolved).toEqual(["alpha/main"]); expect(alpha.map((tool) => String(tool.name))).toEqual(["alpha_deploy"]); const beta = yield* harness.executor.tools.list({ integration: INTEG_B }); expect(harness.resolved).toEqual(["alpha/main", "beta/main"]); expect(beta.map((tool) => String(tool.name))).toEqual(["beta_deploy"]); }), ), ); it.effect("scans no connection rows once every catalog is fresh", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeHarness(); yield* harness.connect(INTEG_A, "main"); yield* harness.connect(INTEG_B, "main"); harness.resolved.length = 0; harness.connectionScans.length = 0; const tools = yield* harness.executor.tools.list(); expect(tools.map((tool) => String(tool.name)).sort()).toEqual([ "alpha_deploy", "beta_deploy", ]); // One scan, and it matched nothing: the steady-state read pays an // indexed lookup and no upstream call at all. expect(harness.connectionScans).toEqual([{ rows: 0, select: SCAN_PROJECTION }]); expect(harness.resolved).toEqual([]); }), ), ); it.effect("narrows the refresh by owner and connection too", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeHarness(); yield* harness.connect(INTEG_A, "main"); yield* harness.connect(INTEG_A, "second"); yield* harness.connect(INTEG_B, "main"); yield* harness.clearEveryCatalogStamp(); harness.resolved.length = 0; harness.connectionScans.length = 0; const tools = yield* harness.executor.tools.list({ integration: INTEG_A, owner: "org", connection: ConnectionName.make("second"), }); expect(harness.resolved).toEqual(["alpha/second"]); // One row, and only the columns the refresh actually reads — the // connection's address plus its sync lifecycle. The scan runs on every // read, so the projection is part of its cost. expect(harness.connectionScans).toEqual([{ rows: 1, select: SCAN_PROJECTION }]); expect(tools.map((tool) => String(tool.name))).toEqual(["alpha_deploy"]); }), ), ); it.effect("re-resolves only the integration whose config was revised", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeHarness(); yield* harness.connect(INTEG_A, "main"); yield* harness.connect(INTEG_B, "main"); yield* harness.backdateEveryCatalog(); yield* harness.executor.counting.revise(INTEG_A); harness.resolved.length = 0; yield* harness.executor.tools.list({ integration: INTEG_A }); expect(harness.resolved).toEqual(["alpha/main"]); harness.resolved.length = 0; yield* harness.executor.tools.list({ integration: INTEG_B }); // Beta's catalog is older than alpha's revision stamp, and under one // global `staleBefore` that alone made it a candidate. The trigger is // per-integration: only the revised integration re-lists. expect(harness.resolved).toEqual([]); }), ), ); it.effect("survives a plugin whose resolveTools dies with a defect", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeHarness(); yield* harness.connect(INTEG_A, "main"); yield* harness.clearEveryCatalogStamp(); harness.startDying(); harness.resolved.length = 0; const tools = yield* harness.executor.tools.list({ integration: INTEG_A }); expect(harness.resolved).toEqual(["alpha/main"]); // The refresh died; the read still answers from the stale-but-working // catalog rather than failing or returning an empty list. expect(tools.map((tool) => String(tool.name))).toEqual(["alpha_deploy"]); const retried = yield* harness.executor.tools.list({ integration: INTEG_A }); // A failed refresh stamps nothing, so the connection stays stale and // the NEXT read attempts it again — best-effort means retried, not // abandoned. expect(harness.resolved).toEqual(["alpha/main", "alpha/main"]); expect(retried.map((tool) => String(tool.name))).toEqual(["alpha_deploy"]); }), ), ); it.effect("persists every catalog when one read refreshes many connections", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeHarness(); const names = ["one", "two", "three", "four", "five"]; for (const name of names) yield* harness.connect(INTEG_A, name); // Make the rebuild observably different from what `connect` already // persisted, so a refresh whose write never landed is visible. harness.renameToolsTo("redeploy"); yield* harness.clearEveryCatalogStamp(); harness.resolved.length = 0; // Arm the overlap latch only now: `connect` resolves one connection at // a time, and a latch waiting for a second in-flight resolve would // never open during setup. harness.holdResolvesUntil(2); const tools = yield* harness.executor.tools.list({ integration: INTEG_A }); expect(harness.resolved.slice().sort()).toEqual(names.map((n) => `alpha/${n}`).sort()); // Every connection's rebuild landed. fumadb runs a SQLite transaction // as raw BEGIN/COMMIT on the shared connection, so a second persist // entering while the first is open hits "cannot start a transaction // within a transaction" and loses its whole rebuild, leaving that // connection on the catalog it had before. Without the persist permit // this assertion reports four of the five still on `alpha_deploy`. expect( tools.map((tool) => `${String(tool.connection)}/${String(tool.name)}`).sort(), ).toEqual(names.map((n) => `${n}/alpha_redeploy`).sort()); // The two halves of the invariant: discovery overlapped, persists did // not. expect(harness.peakConcurrentResolves()).toBeGreaterThan(1); expect(harness.peakOpenTransactions()).toBe(1); }), ), ); }); // --------------------------------------------------------------------------- // Sync lifecycle. Concurrent reads have no shared memory (each is potentially // its own Workers isolate), and a broken upstream has no way of telling us to // stop calling it. Both are answered by state on the connection row: a refresh // claim, a failure ladder, and a stamp that is only ever written by a listing // that actually succeeded. // --------------------------------------------------------------------------- describe("tools read catalog refresh lifecycle", () => { it.effect("lets exactly one of two concurrent reads dial a drifted connection", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeHarness(); yield* harness.connect(INTEG_A, "main"); // Genuinely drifted, through the path a plugin actually uses: the // connection keeps its stamp and carries a `tools_stale_token`. yield* harness.executor.counting.markStale(INTEG_A, "main"); harness.renameToolsTo("redeploy"); harness.resolved.length = 0; harness.blockResolves(); // First reader: forked, and parked inside `resolveTools` — which means // it has already taken the claim. const first = yield* Effect.forkChild( harness.executor.tools.list({ integration: INTEG_A }), ); yield* harness.awaitResolveStarted; // Second reader runs to completion against the same drifted connection // while the first still holds the claim. const second = yield* harness.executor.tools.list({ integration: INTEG_A }); // One handshake between them, not one each. expect(harness.resolved).toEqual(["alpha/main"]); // And the loser still ANSWERS — from the stale-but-working catalog. expect(second.map((tool) => String(tool.name))).toEqual(["alpha_deploy"]); harness.releaseResolves(); const firstTools = yield* Fiber.join(first); expect(firstTools.map((tool) => String(tool.name))).toEqual(["alpha_redeploy"]); expect(harness.resolved).toEqual(["alpha/main"]); // The winner released its lease on the way out. expect((yield* harness.syncStateOf("main")).claimId).toBeNull(); }), ), ); it.effect("discards a listing whose lease was taken while it was in flight", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeHarness(); yield* harness.connect(INTEG_A, "main"); yield* harness.clearEveryCatalogStamp(); harness.renameToolsTo("redeploy"); harness.resolved.length = 0; harness.blockResolves(); const slow = yield* Effect.forkChild(harness.executor.tools.list({ integration: INTEG_A })); yield* harness.awaitResolveStarted; // Another isolate saw the lease expire and re-claimed the connection. // The in-flight listing is now the OLDER one, whatever order it lands // in, and must not overwrite what the new claimant will write. yield* harness.stealClaim("main"); harness.releaseResolves(); const tools = yield* Fiber.join(slow); expect(tools.map((tool) => String(tool.name))).toEqual(["alpha_deploy"]); const state = yield* harness.syncStateOf("main"); // Nothing of the discarded attempt survives: not the catalog, and not // the freshness stamp that would have hidden the drift. expect(state.syncedAt).toBeNull(); expect(state.claimId).toBe("another-isolate"); }), ), ); it.effect("holds a failing connection off until its backoff window elapses", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeHarness(); yield* harness.connect(INTEG_A, "main"); yield* harness.clearEveryCatalogStamp(); harness.resolveIncompleteAs(null); harness.resolved.length = 0; yield* harness.executor.tools.list({ integration: INTEG_A }); expect(harness.resolved).toEqual(["alpha/main"]); const afterFailure = yield* harness.syncStateOf("main"); expect(afterFailure.failures).toBe(1); expect(afterFailure.retryAt).not.toBeNull(); // Inside the window: no upstream call at all, however many reads land. yield* harness.executor.tools.list({ integration: INTEG_A }); yield* harness.executor.tools.list({ integration: INTEG_A }); expect(harness.resolved).toEqual(["alpha/main"]); // Past it: one more attempt, and the ladder advances. yield* harness.elapseEveryBackoff(); yield* harness.executor.tools.list({ integration: INTEG_A }); expect(harness.resolved).toEqual(["alpha/main", "alpha/main"]); expect((yield* harness.syncStateOf("main")).failures).toBe(2); }), ), ); it.effect("parks a connection whose credential was refused, and un-parks on refresh", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeHarness(); yield* harness.connect(INTEG_A, "main"); // EXPIRED, not cold: the connection has a working catalog and the clock // is only asking it to re-verify. That is the one thing the park gates, // and the case below pins the other side of the rule. yield* harness.expireEveryCatalog(); harness.resolveIncompleteAs("auth"); harness.resolved.length = 0; yield* harness.executor.tools.list({ integration: INTEG_A }); expect(harness.resolved).toEqual(["alpha/main"]); expect((yield* harness.syncStateOf("main")).errorKind).toBe("auth"); // Parked: not even elapsing the backoff window lets a read dial again. // Retrying a rejected credential to re-verify a catalog we can still // serve cannot change the verdict, and 401 handshakes were the largest // single source of wasted upstream calls. yield* harness.elapseEveryBackoff(); yield* harness.executor.tools.list({ integration: INTEG_A }); expect(harness.resolved).toEqual(["alpha/main"]); // An explicit refresh is a human saying "I fixed it": it bypasses the // park, dials, and clears the ledger on success. harness.resolveIncompleteAs(undefined); yield* harness.executor.connections.refresh({ owner: "org", integration: INTEG_A, name: ConnectionName.make("main"), }); expect(harness.resolved).toEqual(["alpha/main", "alpha/main"]); const recovered = yield* harness.syncStateOf("main"); expect(recovered.errorKind).toBeNull(); expect(recovered.failures).toBe(0); expect(recovered.syncedAt).not.toBeNull(); }), ), ); it.effect("walks the ladder for a never-synced connection whose credential was refused", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeHarness(); yield* harness.connect(INTEG_A, "main"); // COLD: no catalog at all. Parking this serves nothing, forever, until // a human notices — and nothing in this system observes a credential // repaired upstream, so the clock has to be what brings it back. yield* harness.clearEveryCatalogStamp(); harness.resolveIncompleteAs("auth"); harness.resolved.length = 0; yield* harness.executor.tools.list({ integration: INTEG_A }); expect(harness.resolved).toEqual(["alpha/main"]); expect((yield* harness.syncStateOf("main")).errorKind).toBe("auth"); // The LADDER holds it off, not the park — so reads inside the window // still cost nothing. yield* harness.executor.tools.list({ integration: INTEG_A }); expect(harness.resolved).toEqual(["alpha/main"]); // Past the rung, a credential fixed upstream is picked up with no human // in the loop. Under a park that gated `cold` this read never dials and // the connection advertises no tools indefinitely. harness.resolveIncompleteAs(undefined); yield* harness.elapseEveryBackoff(); const tools = yield* harness.executor.tools.list({ integration: INTEG_A }); expect(harness.resolved).toEqual(["alpha/main", "alpha/main"]); expect(tools.map((tool) => String(tool.name))).toEqual(["alpha_deploy"]); const recovered = yield* harness.syncStateOf("main"); expect(recovered.errorKind).toBeNull(); expect(recovered.failures).toBe(0); expect(recovered.syncedAt).not.toBeNull(); }), ), ); it.effect("keeps retrying a parked connection whose integration config was revised", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeHarness(); yield* harness.connect(INTEG_A, "main"); yield* harness.backdateEveryCatalog(); // Editing the integration's config is how an `auth` verdict CAUSED by // configuration is fixed (the key in the wrong place, the wrong token // endpoint), and `integrations.update` is the one invalidation that // does not clear the ladder. So this is the case where a park could // swallow the repair outright. yield* harness.executor.counting.revise(INTEG_A); harness.resolveIncompleteAs("auth"); harness.resolved.length = 0; yield* harness.executor.tools.list({ integration: INTEG_A }); expect(harness.resolved).toEqual(["alpha/main"]); expect((yield* harness.syncStateOf("main")).errorKind).toBe("auth"); // The park does NOT apply while the revision is outstanding — but the // ladder still does, so a broken config costs one dial per rung rather // than one per read. yield* harness.executor.tools.list({ integration: INTEG_A }); expect(harness.resolved).toEqual(["alpha/main"]); // Past the rung, the fixed config is actually tried. Under a park that // ignored the revision this read would never dial and the connection // would serve its old catalog forever. harness.resolveIncompleteAs(undefined); harness.renameToolsTo("redeploy"); yield* harness.elapseEveryBackoff(); const tools = yield* harness.executor.tools.list({ integration: INTEG_A }); expect(harness.resolved).toEqual(["alpha/main", "alpha/main"]); expect(tools.map((tool) => String(tool.name))).toEqual(["alpha_redeploy"]); const recovered = yield* harness.syncStateOf("main"); expect(recovered.errorKind).toBeNull(); expect(recovered.failures).toBe(0); }), ), ); it.effect("marking a catalog stale records the drift without erasing the stamp", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeHarness(); yield* harness.connect(INTEG_A, "main"); const synced = yield* harness.syncStateOf("main"); expect(synced.syncedAt).not.toBeNull(); expect(synced.staleToken).toBeNull(); yield* harness.executor.counting.markStale(INTEG_A, "main"); const drifted = yield* harness.syncStateOf("main"); // Both facts survive: when it last verified, and that it has since // drifted. Clearing the stamp used to conflate this with "never synced". expect(drifted.syncedAt).toBe(synced.syncedAt); expect(drifted.staleToken).not.toBeNull(); harness.renameToolsTo("redeploy"); harness.resolved.length = 0; const tools = yield* harness.executor.tools.list({ integration: INTEG_A }); expect(harness.resolved).toEqual(["alpha/main"]); expect(tools.map((tool) => String(tool.name))).toEqual(["alpha_redeploy"]); // The re-list settled the drift by CLEARING the token it observed. It // is a compare-and-set, not an unconditional null (the case below is // the mark it must not clear), and the clear is what makes the // resolution observable rather than inferred from two timestamps that // can land in the same millisecond. const settled = yield* harness.syncStateOf("main"); expect(settled.staleToken).toBeNull(); expect(settled.syncedAt).not.toBeNull(); // And the connection is genuinely done: the next read does not dial // it — and, the property the token encoding exists for, does not even // SCAN it. A drift mark nothing ever clears leaves the scan's // `tools_stale_token IS NOT NULL` arm matching this row on every read // for the rest of its life, which is the steady state PR 1 established // and its `candidates` gauge measures. harness.resolved.length = 0; harness.connectionScans.length = 0; yield* harness.executor.tools.list({ integration: INTEG_A }); expect(harness.resolved).toEqual([]); expect(harness.connectionScans).toEqual([{ rows: 0, select: SCAN_PROJECTION }]); }), ), ); it.effect("keeps a drift signal that landed while the listing was in flight", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeHarness(); yield* harness.connect(INTEG_A, "main"); yield* harness.executor.counting.markStale(INTEG_A, "main"); const observed = yield* harness.syncStateOf("main"); harness.renameToolsTo("redeploy"); harness.resolved.length = 0; harness.blockResolves(); // A read is dialing, and parked inside `resolveTools`. const listing = yield* Effect.forkChild( harness.executor.tools.list({ integration: INTEG_A }), ); yield* harness.awaitResolveStarted; // The server sends `tools/list_changed` WHILE we are listing: a // different fiber, no coordination with the one in flight, and it // describes a tool set this listing cannot have seen. yield* harness.executor.counting.markStale(INTEG_A, "main"); const marked = yield* harness.syncStateOf("main"); // A FRESH token, which is the entire mechanism: the listing will clear // only the token it observed at its start, and this is not that token. expect(marked.staleToken).not.toBe(observed.staleToken); harness.releaseResolves(); yield* Fiber.join(listing); // The mark survived the success write, verbatim. Nulling // `tools_stale_token` unconditionally here is a lost update, and for a // plugin with no remote catalog nothing else would ever re-list the // connection. const state = yield* harness.syncStateOf("main"); expect(state.staleToken).toBe(marked.staleToken); // Which is the fact that matters: the next read dials again. harness.renameToolsTo("rerelease"); harness.resolved.length = 0; const tools = yield* harness.executor.tools.list({ integration: INTEG_A }); expect(harness.resolved).toEqual(["alpha/main"]); expect(tools.map((tool) => String(tool.name))).toEqual(["alpha_rerelease"]); // And it CONVERGES. That read observed the surviving token and cleared // it, so the drift is settled in one extra listing rather than leaving // the connection permanently marked — a clear that misses costs one // re-list, never a standing scan. expect((yield* harness.syncStateOf("main")).staleToken).toBeNull(); }), ), ); it.effect("never advances the sync stamp for a listing that did not succeed", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeHarness(); yield* harness.connect(INTEG_A, "main"); const synced = yield* harness.syncStateOf("main"); harness.resolveIncompleteAs("unreachable"); // One drift signal, then four attempts at it. A failed refresh leaves // the drift mark standing (nothing resolved it), so the connection // stays due and only the ladder holds it off — which is exactly what // `elapseEveryBackoff` steps past. yield* harness.executor.counting.markStale(INTEG_A, "main"); harness.resolved.length = 0; for (let attempt = 0; attempt < 4; attempt += 1) { yield* harness.elapseEveryBackoff(); yield* harness.executor.tools.list({ integration: INTEG_A }); } expect(harness.resolved).toHaveLength(4); const state = yield* harness.syncStateOf("main"); // The anti-lying assertion. A month of failed refreshes must leave // `tools_synced_at` where the last SUCCESSFUL listing put it, or every // freshness decision downstream is made on a fabricated timestamp. expect(state.syncedAt).toBe(synced.syncedAt); expect(state.failures).toBe(4); expect(state.errorKind).toBe("unreachable"); // The drift mark also stands: only an authoritative listing clears it, // and none of these four were one. expect(state.staleToken).not.toBeNull(); }), ), ); });