Unified · Split
| @@ -0,0 +1,290 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | TOOL_SYNC_BACKOFF_CEILING_MS, | ||
| 5 | TOOL_SYNC_CLAIM_LEASE_MS, | ||
| 6 | TOOL_SYNC_JITTER_MAX, | ||
| 7 | TOOL_SYNC_JITTER_MIN, | ||
| 8 | classifyToolSync, | ||
| 9 | decideToolSync, | ||
| 10 | isSyncEligible, | ||
| 11 | isToolSyncBackedOff, | ||
| 12 | isToolSyncClaimLive, | ||
| 13 | isToolSyncErrorKind, | ||
| 14 | isToolSyncParked, | ||
| 15 | scheduleAfterFailure, | ||
| 16 | scheduleAfterSuccess, | ||
| 17 | type ToolSyncCandidate, | ||
| 18 | } from "./tool-sync-schedule"; | ||
| 19 | |||
| 20 | // The catalog sync lifecycle is a pure decision function, and these are the | ||
| 21 | // decisions it exists to make: what a persisted catalog IS, whether anyone is | ||
| 22 | // allowed to re-list it right now, and how long a failing connection is left | ||
| 23 | // alone. Testing it here rather than only through the executor is what keeps | ||
| 24 | // the ladder, the cap and the park rule honest without a database and a live | ||
| 25 | // upstream in the loop. | ||
| 26 | |||
| 27 | const NOW = 1_700_000_000_000; | ||
| 28 | const TTL = 15 * 60 * 1000; | ||
| 29 | |||
| 30 | /** A connection that has just synced successfully against a remote catalog. */ | ||
| 31 | const fresh = (overrides: Partial<ToolSyncCandidate> = {}): ToolSyncCandidate => ({ | ||
| 32 | toolsSyncedAt: NOW - 1000, | ||
| 33 | toolsStaleAt: null, | ||
| 34 | toolsSyncClaimId: null, | ||
| 35 | toolsSyncClaimAt: null, | ||
| 36 | toolsSyncFailures: 0, | ||
| 37 | toolsSyncRetryAt: null, | ||
| 38 | toolsSyncErrorKind: null, | ||
| 39 | configRevisedAt: null, | ||
| 40 | remoteToolCatalog: true, | ||
| 41 | ttlMs: TTL, | ||
| 42 | ...overrides, | ||
| 43 | }); | ||
| 44 | |||
| 45 | describe("isToolSyncErrorKind", () => { | ||
| 46 | it("accepts exactly the four kinds", () => { | ||
| 47 | expect(["auth", "unreachable", "protocol", "config"].every(isToolSyncErrorKind)).toBe(true); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("rejects anything else a text column could hold", () => { | ||
| 51 | for (const value of ["", "AUTH", "authentication", null, undefined, 401, {}]) { | ||
| 52 | expect(isToolSyncErrorKind(value)).toBe(false); | ||
| 53 | } | ||
| 54 | }); | ||
| 55 | }); | ||
| 56 | |||
| 57 | describe("classifyToolSync", () => { | ||
| 58 | it("calls a never-synced, never-marked connection cold", () => { | ||
| 59 | expect(classifyToolSync(fresh({ toolsSyncedAt: null }), NOW)).toBe("cold"); | ||
| 60 | }); | ||
| 61 | |||
| 62 | it("calls a connection with a drift mark stale_marked, keeping its stamp", () => { | ||
| 63 | const candidate = fresh({ toolsSyncedAt: NOW - 60_000, toolsStaleAt: NOW - 1000 }); | ||
| 64 | expect(classifyToolSync(candidate, NOW)).toBe("stale_marked"); | ||
| 65 | }); | ||
| 66 | |||
| 67 | it("still sees a drift mark that landed in the same millisecond as the stamp", () => { | ||
| 68 | // Date.now() has millisecond granularity, and a `tools/list_changed` | ||
| 69 | // arriving inside the millisecond it invalidates must not be swallowed. | ||
| 70 | expect(classifyToolSync(fresh({ toolsSyncedAt: NOW, toolsStaleAt: NOW }), NOW)).toBe( | ||
| 71 | "stale_marked", | ||
| 72 | ); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("calls a never-synced connection carrying a drift mark stale_marked, not cold", () => { | ||
| 76 | expect(classifyToolSync(fresh({ toolsSyncedAt: null, toolsStaleAt: NOW }), NOW)).toBe( | ||
| 77 | "stale_marked", | ||
| 78 | ); | ||
| 79 | }); | ||
| 80 | |||
| 81 | it("calls a catalog older than its integration's config revision config_revised", () => { | ||
| 82 | expect( | ||
| 83 | classifyToolSync(fresh({ toolsSyncedAt: NOW - 60_000, configRevisedAt: NOW - 30_000 }), NOW), | ||
| 84 | ).toBe("config_revised"); | ||
| 85 | }); | ||
| 86 | |||
| 87 | it("leaves a catalog newer than the config revision alone", () => { | ||
| 88 | expect( | ||
| 89 | classifyToolSync(fresh({ toolsSyncedAt: NOW - 30_000, configRevisedAt: NOW - 60_000 }), NOW), | ||
| 90 | ).toBe("fresh"); | ||
| 91 | }); | ||
| 92 | |||
| 93 | it("expires a remote catalog past the TTL", () => { | ||
| 94 | expect(classifyToolSync(fresh({ toolsSyncedAt: NOW - TTL - 1 }), NOW)).toBe("expired"); | ||
| 95 | }); | ||
| 96 | |||
| 97 | it("never expires a catalog the plugin does not list remotely", () => { | ||
| 98 | // An OpenAPI catalog is derived from a stored spec: time alone cannot make | ||
| 99 | // it wrong, and re-listing it on a timer is pure waste. | ||
| 100 | expect( | ||
| 101 | classifyToolSync(fresh({ toolsSyncedAt: NOW - TTL - 1, remoteToolCatalog: false }), NOW), | ||
| 102 | ).toBe("fresh"); | ||
| 103 | }); | ||
| 104 | |||
| 105 | it("never expires anything when the TTL is disabled", () => { | ||
| 106 | expect(classifyToolSync(fresh({ toolsSyncedAt: 0, ttlMs: null }), NOW)).toBe("fresh"); | ||
| 107 | }); | ||
| 108 | |||
| 109 | it("ranks an explicit drift signal above a config revision and the clock", () => { | ||
| 110 | const candidate = fresh({ | ||
| 111 | toolsSyncedAt: NOW - TTL - 1, | ||
| 112 | toolsStaleAt: NOW, | ||
| 113 | configRevisedAt: NOW - 1, | ||
| 114 | }); | ||
| 115 | expect(classifyToolSync(candidate, NOW)).toBe("stale_marked"); | ||
| 116 | }); | ||
| 117 | |||
| 118 | it("ranks a config revision above the clock", () => { | ||
| 119 | const candidate = fresh({ toolsSyncedAt: NOW - TTL - 1, configRevisedAt: NOW - 1 }); | ||
| 120 | expect(classifyToolSync(candidate, NOW)).toBe("config_revised"); | ||
| 121 | }); | ||
| 122 | }); | ||
| 123 | |||
| 124 | describe("isToolSyncClaimLive", () => { | ||
| 125 | it("is false with no claim", () => { | ||
| 126 | expect(isToolSyncClaimLive(fresh(), NOW)).toBe(false); | ||
| 127 | }); | ||
| 128 | |||
| 129 | it("is true inside the lease", () => { | ||
| 130 | const candidate = fresh({ | ||
| 131 | toolsSyncClaimId: "sync_abc", | ||
| 132 | toolsSyncClaimAt: NOW - TOOL_SYNC_CLAIM_LEASE_MS + 1, | ||
| 133 | }); | ||
| 134 | expect(isToolSyncClaimLive(candidate, NOW)).toBe(true); | ||
| 135 | }); | ||
| 136 | |||
| 137 | it("is false once the lease has run out", () => { | ||
| 138 | const candidate = fresh({ | ||
| 139 | toolsSyncClaimId: "sync_abc", | ||
| 140 | toolsSyncClaimAt: NOW - TOOL_SYNC_CLAIM_LEASE_MS, | ||
| 141 | }); | ||
| 142 | expect(isToolSyncClaimLive(candidate, NOW)).toBe(false); | ||
| 143 | }); | ||
| 144 | |||
| 145 | it("treats a claim with no timestamp as dead rather than eternal", () => { | ||
| 146 | // Otherwise a half-written row would lock a connection out of every future | ||
| 147 | // refresh, forever, with nothing to expire it. | ||
| 148 | const candidate = fresh({ toolsSyncClaimId: "sync_abc", toolsSyncClaimAt: null }); | ||
| 149 | expect(isToolSyncClaimLive(candidate, NOW)).toBe(false); | ||
| 150 | }); | ||
| 151 | }); | ||
| 152 | |||
| 153 | describe("isToolSyncBackedOff", () => { | ||
| 154 | it("holds a failing connection off until its retry instant", () => { | ||
| 155 | const candidate = fresh({ toolsSyncFailures: 2, toolsSyncRetryAt: NOW + 1 }); | ||
| 156 | expect(isToolSyncBackedOff(candidate, NOW)).toBe(true); | ||
| 157 | expect(isToolSyncBackedOff(candidate, NOW + 1)).toBe(false); | ||
| 158 | }); | ||
| 159 | |||
| 160 | it("ignores the retry instant a SUCCESS wrote", () => { | ||
| 161 | // After a success `tools_sync_retry_at` carries the freshness horizon, not | ||
| 162 | // a gate: a drift signal arriving inside the window must heal the catalog | ||
| 163 | // immediately instead of waiting the window out. | ||
| 164 | const candidate = fresh({ | ||
| 165 | toolsSyncFailures: 0, | ||
| 166 | toolsSyncRetryAt: NOW + TTL, | ||
| 167 | toolsStaleAt: NOW, | ||
| 168 | }); | ||
| 169 | expect(isToolSyncBackedOff(candidate, NOW)).toBe(false); | ||
| 170 | expect(isSyncEligible(candidate, NOW)).toBe(true); | ||
| 171 | }); | ||
| 172 | }); | ||
| 173 | |||
| 174 | describe("isToolSyncParked", () => { | ||
| 175 | it("parks only on an auth verdict", () => { | ||
| 176 | expect(isToolSyncParked(fresh({ toolsSyncErrorKind: "auth" }))).toBe(true); | ||
| 177 | for (const kind of ["unreachable", "protocol", "config", null] as const) { | ||
| 178 | expect(isToolSyncParked(fresh({ toolsSyncErrorKind: kind }))).toBe(false); | ||
| 179 | } | ||
| 180 | }); | ||
| 181 | }); | ||
| 182 | |||
| 183 | describe("isSyncEligible", () => { | ||
| 184 | it("passes a due connection with a clear ledger, reporting what made it due", () => { | ||
| 185 | expect(decideToolSync(fresh({ toolsSyncedAt: null }), NOW)).toEqual({ | ||
| 186 | kind: "refresh", | ||
| 187 | trigger: "cold", | ||
| 188 | }); | ||
| 189 | expect(isSyncEligible(fresh({ toolsSyncedAt: null }), NOW)).toBe(true); | ||
| 190 | }); | ||
| 191 | |||
| 192 | it("skips a fresh connection", () => { | ||
| 193 | expect(decideToolSync(fresh(), NOW)).toEqual({ kind: "skip", reason: "fresh" }); | ||
| 194 | expect(isSyncEligible(fresh(), NOW)).toBe(false); | ||
| 195 | }); | ||
| 196 | |||
| 197 | it("skips a parked connection however overdue it is", () => { | ||
| 198 | const candidate = fresh({ | ||
| 199 | toolsSyncedAt: null, | ||
| 200 | toolsSyncFailures: 9, | ||
| 201 | toolsSyncErrorKind: "auth", | ||
| 202 | toolsSyncRetryAt: NOW - 1, | ||
| 203 | }); | ||
| 204 | expect(decideToolSync(candidate, NOW)).toEqual({ kind: "skip", reason: "parked" }); | ||
| 205 | expect(isSyncEligible(candidate, NOW)).toBe(false); | ||
| 206 | }); | ||
| 207 | |||
| 208 | it("skips a connection inside its backoff window", () => { | ||
| 209 | const candidate = fresh({ | ||
| 210 | toolsSyncedAt: null, | ||
| 211 | toolsSyncFailures: 1, | ||
| 212 | toolsSyncErrorKind: "unreachable", | ||
| 213 | toolsSyncRetryAt: NOW + 1, | ||
| 214 | }); | ||
| 215 | expect(decideToolSync(candidate, NOW)).toEqual({ kind: "skip", reason: "backoff" }); | ||
| 216 | }); | ||
| 217 | |||
| 218 | it("skips a connection another attempt is already holding", () => { | ||
| 219 | const candidate = fresh({ | ||
| 220 | toolsSyncedAt: null, | ||
| 221 | toolsSyncClaimId: "sync_abc", | ||
| 222 | toolsSyncClaimAt: NOW, | ||
| 223 | }); | ||
| 224 | expect(decideToolSync(candidate, NOW)).toEqual({ kind: "skip", reason: "claimed" }); | ||
| 225 | }); | ||
| 226 | |||
| 227 | it("re-admits a connection whose claimant died", () => { | ||
| 228 | const candidate = fresh({ | ||
| 229 | toolsSyncedAt: null, | ||
| 230 | toolsSyncClaimId: "sync_abc", | ||
| 231 | toolsSyncClaimAt: NOW - TOOL_SYNC_CLAIM_LEASE_MS, | ||
| 232 | }); | ||
| 233 | expect(isSyncEligible(candidate, NOW)).toBe(true); | ||
| 234 | }); | ||
| 235 | }); | ||
| 236 | |||
| 237 | describe("scheduleAfterSuccess", () => { | ||
| 238 | it("puts the next attempt one freshness window out", () => { | ||
| 239 | expect(scheduleAfterSuccess(NOW, TTL)).toBe(NOW + TTL); | ||
| 240 | }); | ||
| 241 | |||
| 242 | it("schedules nothing when time-based re-sync is disabled", () => { | ||
| 243 | expect(scheduleAfterSuccess(NOW, null)).toBe(null); | ||
| 244 | }); | ||
| 245 | }); | ||
| 246 | |||
| 247 | describe("scheduleAfterFailure", () => { | ||
| 248 | const delay = (failures: number, jitter = 1, ttlMs = TTL) => | ||
| 249 | scheduleAfterFailure(NOW, ttlMs, failures, jitter) - NOW; | ||
| 250 | |||
| 251 | it("doubles the wait with every consecutive failure", () => { | ||
| 252 | expect(delay(1)).toBe(TTL); | ||
| 253 | expect(delay(2)).toBe(2 * TTL); | ||
| 254 | expect(delay(3)).toBe(4 * TTL); | ||
| 255 | expect(delay(4)).toBe(8 * TTL); | ||
| 256 | }); | ||
| 257 | |||
| 258 | it("caps the ladder rather than growing without bound", () => { | ||
| 259 | // A server dead for a week is still probed a few times a day, which is what | ||
| 260 | // makes recovery automatic. | ||
| 261 | expect(delay(20)).toBe(TOOL_SYNC_BACKOFF_CEILING_MS); | ||
| 262 | expect(delay(1000)).toBe(TOOL_SYNC_BACKOFF_CEILING_MS); | ||
| 263 | }); | ||
| 264 | |||
| 265 | it("reads a failure count below one as the first failure", () => { | ||
| 266 | expect(delay(0)).toBe(TTL); | ||
| 267 | expect(delay(-5)).toBe(TTL); | ||
| 268 | }); | ||
| 269 | |||
| 270 | it("stays within the jitter bounds at every rung", () => { | ||
| 271 | for (let failures = 1; failures <= 12; failures += 1) { | ||
| 272 | const base = Math.min(TOOL_SYNC_BACKOFF_CEILING_MS, TTL * 2 ** (failures - 1)); | ||
| 273 | expect(delay(failures, TOOL_SYNC_JITTER_MIN)).toBe(Math.round(base * TOOL_SYNC_JITTER_MIN)); | ||
| 274 | expect(delay(failures, TOOL_SYNC_JITTER_MAX)).toBe(Math.round(base * TOOL_SYNC_JITTER_MAX)); | ||
| 275 | for (const jitter of [TOOL_SYNC_JITTER_MIN, 0.93, 1.07, TOOL_SYNC_JITTER_MAX]) { | ||
| 276 | const value = delay(failures, jitter); | ||
| 277 | expect(value).toBeGreaterThanOrEqual(Math.round(base * TOOL_SYNC_JITTER_MIN)); | ||
| 278 | expect(value).toBeLessThanOrEqual(Math.round(base * TOOL_SYNC_JITTER_MAX)); | ||
| 279 | } | ||
| 280 | } | ||
| 281 | }); | ||
| 282 | |||
| 283 | it("returns a whole number of milliseconds for the bigint column", () => { | ||
| 284 | expect(Number.isInteger(scheduleAfterFailure(NOW, TTL, 3, 0.8137))).toBe(true); | ||
| 285 | }); | ||
| 286 | |||
| 287 | it("scales the ladder to whatever base it is given", () => { | ||
| 288 | expect(delay(5, 1, 1000)).toBe(16_000); | ||
| 289 | }); | ||
| 290 | }); | ||
| @@ -0,0 +1,233 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Tool-catalog sync lifecycle — the pure state model behind every decision the | ||
| 3 | // executor makes about re-listing a connection's tools. | ||
| 4 | // | ||
| 5 | // No storage, no clock, no randomness: the connection's columns arrive as a | ||
| 6 | // value, `now` and the backoff jitter arrive as arguments, and every function | ||
| 7 | // here is total. That is what makes the ladder, the cap and the eligibility | ||
| 8 | // rules testable directly rather than through a database and a live upstream, | ||
| 9 | // and it is what will let PR 3's background scheduler reuse the same rules | ||
| 10 | // without reimplementing them against a different clock. | ||
| 11 | // --------------------------------------------------------------------------- | ||
| 12 | |||
| 13 | /** Why a catalog listing could not be completed, as a CLOSED set persisted on | ||
| 14 | * `connection.tools_sync_error_kind`. | ||
| 15 | * | ||
| 16 | * Structural on purpose: the previous mechanism was a `"Tool sync failing: "` | ||
| 17 | * prefix on the human-readable health detail, which meant every consumer that | ||
| 18 | * wanted to know "is this connection's catalog broken, and how" had to match a | ||
| 19 | * display string. These four values are the whole vocabulary: | ||
| 20 | * | ||
| 21 | * - `auth` the credential is rejected or absent. Retrying the same grant | ||
| 22 | * cannot change the verdict, so it PARKS the connection (see | ||
| 23 | * {@link isToolSyncParked}) until a human re-authorizes. | ||
| 24 | * - `unreachable` the upstream did not answer (DNS, connect, timeout). | ||
| 25 | * - `protocol` the upstream answered but the listing could not be read. | ||
| 26 | * - `config` the integration's own configuration cannot produce a listing | ||
| 27 | * (unparseable spec, disabled transport, missing endpoint). */ | ||
| 28 | export type ToolSyncErrorKind = "auth" | "unreachable" | "protocol" | "config"; | ||
| 29 | |||
| 30 | export const TOOL_SYNC_ERROR_KINDS = [ | ||
| 31 | "auth", | ||
| 32 | "unreachable", | ||
| 33 | "protocol", | ||
| 34 | "config", | ||
| 35 | ] as const satisfies readonly ToolSyncErrorKind[]; | ||
| 36 | |||
| 37 | /** Narrow a persisted `tools_sync_error_kind` column back to the closed set. | ||
| 38 | * The column is plain text, so a value written by a newer build (or edited by | ||
| 39 | * hand) has to be parsed rather than trusted. */ | ||
| 40 | export const isToolSyncErrorKind = (value: unknown): value is ToolSyncErrorKind => | ||
| 41 | typeof value === "string" && (TOOL_SYNC_ERROR_KINDS as readonly string[]).includes(value); | ||
| 42 | |||
| 43 | /** | ||
| 44 | * What a connection's persisted catalog is, relative to now. | ||
| 45 | * | ||
| 46 | * Every non-`fresh` value is exactly the reason a read has to re-list, so this | ||
| 47 | * doubles as the refresh trigger reported on the `executor.tools.sync` span. | ||
| 48 | * | ||
| 49 | * `cold` and `stale_marked` were one value before the `tools_stale_at` column | ||
| 50 | * existed: `connections.markToolsStale` cleared `tools_synced_at`, which made a | ||
| 51 | * connection that had never synced indistinguishable from one whose catalog was | ||
| 52 | * invalidated mid-invocation, and destroyed the last-verified timestamp in the | ||
| 53 | * process. | ||
| 54 | */ | ||
| 55 | export type ToolSyncState = "fresh" | "cold" | "stale_marked" | "config_revised" | "expired"; | ||
| 56 | |||
| 57 | /** Why an otherwise-due connection is not being refreshed right now. Enumerable | ||
| 58 | * so the read's span can count each reason. */ | ||
| 59 | export type ToolSyncSkip = "fresh" | "parked" | "backoff" | "claimed"; | ||
| 60 | |||
| 61 | /** | ||
| 62 | * A connection's sync lifecycle columns, plus the two facts that live on its | ||
| 63 | * integration and plugin (`config_revised_at`, whether the plugin lists a live | ||
| 64 | * remote catalog) and the executor's freshness window. | ||
| 65 | * | ||
| 66 | * Decoded from the row by the caller: bigint columns arrive as `bigint | null` | ||
| 67 | * from the driver, and the text `tools_sync_error_kind` has to pass | ||
| 68 | * {@link isToolSyncErrorKind} before it gets here. | ||
| 69 | */ | ||
| 70 | export interface ToolSyncCandidate { | ||
| 71 | /** Epoch ms of the last AUTHORITATIVE listing. Never stamped by a failed or | ||
| 72 | * incomplete one — a month-dead server must not read as "synced 30s ago". */ | ||
| 73 | readonly toolsSyncedAt: number | null; | ||
| 74 | /** Epoch ms an event declared the persisted catalog drifted. */ | ||
| 75 | readonly toolsStaleAt: number | null; | ||
| 76 | /** The nonce of the refresh attempt currently holding the write lease. */ | ||
| 77 | readonly toolsSyncClaimId: string | null; | ||
| 78 | /** Epoch ms the claim was taken, against which the lease expires. */ | ||
| 79 | readonly toolsSyncClaimAt: number | null; | ||
| 80 | /** CONSECUTIVE failed listings; zeroed by an authoritative one. */ | ||
| 81 | readonly toolsSyncFailures: number; | ||
| 82 | /** Epoch ms of the earliest next attempt. */ | ||
| 83 | readonly toolsSyncRetryAt: number | null; | ||
| 84 | readonly toolsSyncErrorKind: ToolSyncErrorKind | null; | ||
| 85 | /** The integration's `config_revised_at`: its last tool-affecting config | ||
| 86 | * change. */ | ||
| 87 | readonly configRevisedAt: number | null; | ||
| 88 | /** The plugin lists a live remote catalog (an MCP server, whose tool set can | ||
| 89 | * change with no executor-visible signal), so time alone can expire it. */ | ||
| 90 | readonly remoteToolCatalog: boolean; | ||
| 91 | /** The freshness window; `null` disables time-based expiry entirely. */ | ||
| 92 | readonly ttlMs: number | null; | ||
| 93 | } | ||
| 94 | |||
| 95 | /** | ||
| 96 | * How long a refresh claim stays valid. | ||
| 97 | * | ||
| 98 | * A lease rather than a lock: the claimant can be a Workers isolate that is | ||
| 99 | * evicted mid-handshake, and nothing would ever release its claim. Long enough | ||
| 100 | * to cover a slow upstream listing (discovery is bounded well under this), | ||
| 101 | * short enough that a lost claimant costs one wasted freshness window. | ||
| 102 | */ | ||
| 103 | export const TOOL_SYNC_CLAIM_LEASE_MS = 60_000; | ||
| 104 | |||
| 105 | /** The longest a failing connection is left alone. Past this the ladder stops | ||
| 106 | * doubling: a server that has been dead for a week still gets probed a few | ||
| 107 | * times a day, which is what makes recovery automatic. */ | ||
| 108 | export const TOOL_SYNC_BACKOFF_CEILING_MS = 6 * 60 * 60 * 1000; | ||
| 109 | |||
| 110 | /** Bounds of the multiplicative jitter applied to every backoff delay, so a | ||
| 111 | * fleet that lost the same upstream at the same moment does not re-dial it in | ||
| 112 | * lockstep. The factor itself is supplied by the caller — this module owns no | ||
| 113 | * randomness. */ | ||
| 114 | export const TOOL_SYNC_JITTER_MIN = 0.8; | ||
| 115 | export const TOOL_SYNC_JITTER_MAX = 1.2; | ||
| 116 | |||
| 117 | /** | ||
| 118 | * Classify a connection's catalog. Total, and ordered by authority: an explicit | ||
| 119 | * drift signal outranks a config revision, which outranks the clock. | ||
| 120 | * | ||
| 121 | * The stale comparison is `>=`, not `>`: `Date.now()` has millisecond | ||
| 122 | * granularity, and a `tools/list_changed` notification arriving in the same | ||
| 123 | * millisecond as the stamp it invalidates must not be swallowed. | ||
| 124 | */ | ||
| 125 | export const classifyToolSync = (candidate: ToolSyncCandidate, now: number): ToolSyncState => { | ||
| 126 | const syncedAt = candidate.toolsSyncedAt; | ||
| 127 | // A never-synced connection sorts before every stamp, which is why the | ||
| 128 | // comparisons below read through `?? 0` rather than special-casing null. | ||
| 129 | if (candidate.toolsStaleAt !== null && candidate.toolsStaleAt >= (syncedAt ?? 0)) { | ||
| 130 | return "stale_marked"; | ||
| 131 | } | ||
| 132 | if (syncedAt === null) return "cold"; | ||
| 133 | if (candidate.configRevisedAt !== null && syncedAt < candidate.configRevisedAt) { | ||
| 134 | return "config_revised"; | ||
| 135 | } | ||
| 136 | if (candidate.remoteToolCatalog && candidate.ttlMs !== null && syncedAt < now - candidate.ttlMs) { | ||
| 137 | return "expired"; | ||
| 138 | } | ||
| 139 | return "fresh"; | ||
| 140 | }; | ||
| 141 | |||
| 142 | /** Whether another attempt currently holds the write lease. A claim with no | ||
| 143 | * `claim_at` is treated as dead rather than eternal, so a half-written row can | ||
| 144 | * never wedge a connection out of every future refresh. */ | ||
| 145 | export const isToolSyncClaimLive = (candidate: ToolSyncCandidate, now: number): boolean => | ||
| 146 | candidate.toolsSyncClaimId !== null && | ||
| 147 | candidate.toolsSyncClaimAt !== null && | ||
| 148 | now - candidate.toolsSyncClaimAt < TOOL_SYNC_CLAIM_LEASE_MS; | ||
| 149 | |||
| 150 | /** | ||
| 151 | * Whether the failure ladder is still holding this connection off. | ||
| 152 | * | ||
| 153 | * Gated on `failures > 0` deliberately. `tools_sync_retry_at` is also written | ||
| 154 | * after a SUCCESS, where it records the TTL horizon so a scheduler can order | ||
| 155 | * work by one column; treating that as a gate would make a `list_changed` | ||
| 156 | * notification wait out the freshness window before it could heal the catalog. | ||
| 157 | * An event-driven drift re-lists immediately; only a failing one waits. | ||
| 158 | */ | ||
| 159 | export const isToolSyncBackedOff = (candidate: ToolSyncCandidate, now: number): boolean => | ||
| 160 | candidate.toolsSyncFailures > 0 && | ||
| 161 | candidate.toolsSyncRetryAt !== null && | ||
| 162 | now < candidate.toolsSyncRetryAt; | ||
| 163 | |||
| 164 | /** Whether the connection is parked: its credential is rejected, and no amount | ||
| 165 | * of retrying the same grant will change that. Cleared by the human-initiated | ||
| 166 | * paths that can actually fix it (explicit refresh, connection update, an | ||
| 167 | * OAuth re-mint, a healthy probe, a fresh drift signal). */ | ||
| 168 | export const isToolSyncParked = (candidate: ToolSyncCandidate): boolean => | ||
| 169 | candidate.toolsSyncErrorKind === "auth"; | ||
| 170 | |||
| 171 | /** | ||
| 172 | * Refresh this connection, or pass it over and say why. ONE decision rather | ||
| 173 | * than a predicate plus a re-classification, so a caller that needs both the | ||
| 174 | * verdict and the trigger cannot end up asking two questions with two answers. | ||
| 175 | * | ||
| 176 | * Every clause that can stop a refresh lives here, and that is the point: a | ||
| 177 | * read's inline refresh, an explicit signal and a background sweep all consult | ||
| 178 | * this, so "when do we dial an upstream" has one answer rather than one per | ||
| 179 | * caller. A `connection.status` lifecycle (disabled, revoked) belongs here too | ||
| 180 | * when it lands, as one more skip clause — putting it at a call site is what | ||
| 181 | * would reintroduce the divergence this exists to prevent. | ||
| 182 | * | ||
| 183 | * Ordered cheapest-and-most-final first, so the reason an operator sees on the | ||
| 184 | * span is the one they would name. A live claim observed on the row is only a | ||
| 185 | * pre-filter: the binding decision is the compare-and-set the caller runs | ||
| 186 | * against the database, and two readers that both see a free claim here still | ||
| 187 | * resolve to one refresh. | ||
| 188 | */ | ||
| 189 | export type ToolSyncDecision = | ||
| 190 | | { readonly kind: "skip"; readonly reason: ToolSyncSkip } | ||
| 191 | | { readonly kind: "refresh"; readonly trigger: Exclude<ToolSyncState, "fresh"> }; | ||
| 192 | |||
| 193 | export const decideToolSync = (candidate: ToolSyncCandidate, now: number): ToolSyncDecision => { | ||
| 194 | const state = classifyToolSync(candidate, now); | ||
| 195 | if (state === "fresh") return { kind: "skip", reason: "fresh" }; | ||
| 196 | if (isToolSyncParked(candidate)) return { kind: "skip", reason: "parked" }; | ||
| 197 | if (isToolSyncBackedOff(candidate, now)) return { kind: "skip", reason: "backoff" }; | ||
| 198 | if (isToolSyncClaimLive(candidate, now)) return { kind: "skip", reason: "claimed" }; | ||
| 199 | return { kind: "refresh", trigger: state }; | ||
| 200 | }; | ||
| 201 | |||
| 202 | /** THE eligibility predicate, for callers that only need the yes/no. */ | ||
| 203 | export const isSyncEligible = (candidate: ToolSyncCandidate, now: number): boolean => | ||
| 204 | decideToolSync(candidate, now).kind === "refresh"; | ||
| 205 | |||
| 206 | /** The earliest next attempt after an AUTHORITATIVE listing: one freshness | ||
| 207 | * window out, or `null` when time-based re-sync is disabled. Not a gate (see | ||
| 208 | * {@link isToolSyncBackedOff}) — it is the horizon a scheduler sorts by. */ | ||
| 209 | export const scheduleAfterSuccess = (now: number, ttlMs: number | null): number | null => | ||
| 210 | ttlMs === null ? null : now + ttlMs; | ||
| 211 | |||
| 212 | /** | ||
| 213 | * The earliest next attempt after a FAILED listing: `ttlMs × 2^(failures−1)`, | ||
| 214 | * capped at {@link TOOL_SYNC_BACKOFF_CEILING_MS}, scaled by `jitter`. | ||
| 215 | * | ||
| 216 | * `failures` is the count INCLUDING the failure being recorded, so the first | ||
| 217 | * failure waits one plain TTL; values below 1 are read as 1 rather than halving | ||
| 218 | * the first delay. `jitter` belongs to the caller and is expected in | ||
| 219 | * [{@link TOOL_SYNC_JITTER_MIN}, {@link TOOL_SYNC_JITTER_MAX}]; it is not | ||
| 220 | * clamped here, because a call site that passes something else has a bug worth | ||
| 221 | * seeing rather than silently correcting. | ||
| 222 | * | ||
| 223 | * Rounded: the result lands in a bigint column. | ||
| 224 | */ | ||
| 225 | export const scheduleAfterFailure = ( | ||
| 226 | now: number, | ||
| 227 | ttlMs: number, | ||
| 228 | failures: number, | ||
| 229 | jitter: number, | ||
| 230 | ): number => | ||
| 231 | Math.round( | ||
| 232 | now + Math.min(TOOL_SYNC_BACKOFF_CEILING_MS, ttlMs * 2 ** (Math.max(1, failures) - 1)) * jitter, | ||
| 233 | ); | ||