History

Preserve catalogs on failed sync (#1335)

Rhys Sullivan committed 4 months ago

Commit efd09e91 parent c1893c56 files · +610 −28Browse files

Unified · Split

6 files
  1. packages/core/sdk/src/connections.test.ts +260 −1
  2. packages/core/sdk/src/executor.ts +77 −18
  3. packages/core/sdk/src/plugin.ts +3 −0
  4. packages/plugins/graphql/src/sdk/plugin.ts +23 −4
  5. packages/plugins/openapi/src/sdk/backing.ts +18 −5
  6. packages/plugins/openapi/src/sdk/spec-blob.test.ts +229 −0
packages/core/sdk/src/connections.test.ts+260−1
@@ -10,9 +10,10 @@ import { · 9 unmodified lines
1010 ToolAddress,
1111 ToolName,
1212 } from "./ids";
13+import { createExecutor } from "./executor";
1314 import { definePlugin } from "./plugin";
1415 import type { CredentialProvider } from "./provider";
15−import { makeTestExecutor } from "./testing";
16+import { makeTestConfig, makeTestExecutor } from "./testing";
1617
1718 // removed: v1 connection-refresh lifecycle, ConnectionProvider.refresh,
1819 // SecretProvider, accessToken token-refresh + in-flight dedup tests — the v2
@@ -359,6 +360,264 @@ describe("connections.refresh", () => { · 340 unmodified lines
359360 );
360361 });
361362
363+describe("tool catalog sync safety", () => {
364+ it.effect(
365+ "background sync preserves a nonzero catalog when a plugin returns authoritative empty",
366+ () =>
367+ Effect.scoped(
368+ Effect.gen(function* () {
369+ let empty = false;
370+ const guardedPlugin = definePlugin(() => ({
371+ id: "guarded" as const,
372+ credentialProviders: [memoryProvider()],
373+ storage: () => ({}),
374+ resolveTools: () =>
375+ Effect.sync(() => ({
376+ tools: empty
377+ ? []
378+ : [
379+ { name: ToolName.make("deploy"), description: "deploy" },
380+ { name: ToolName.make("list"), description: "list" },
381+ ],
382+ })),
383+ invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }),
384+ extension: (ctx) => ({
385+ seed: () =>
386+ ctx.core.integrations.register({
387+ slug: INTEG,
388+ description: "Vercel",
389+ config: {},
390+ }),
391+ }),
392+ }))();
393+ const config = makeTestConfig({ plugins: [guardedPlugin] as const });
394+ const executor = yield* createExecutor(config);
395+ yield* executor.guarded.seed();
396+ yield* executor.connections.create({
397+ owner: "org",
398+ name: ConnectionName.make("main"),
399+ integration: INTEG,
400+ template: TEMPLATE,
401+ value: "secret-token",
402+ });
403+
404+ empty = true;
405+ yield* Effect.promise(() =>
406+ config.db.updateMany("connection", {
407+ where: (b) => b.and(b("integration", "=", String(INTEG)), b("name", "=", "main")),
408+ set: { tools_synced_at: null },
409+ }),
410+ );
411+ const tools = yield* executor.tools.list({ integration: INTEG });
412+ const connection = yield* executor.connections.get({
413+ owner: "org",
414+ integration: INTEG,
415+ name: ConnectionName.make("main"),
416+ });
417+
418+ expect(tools.map((tool) => String(tool.name)).sort()).toEqual(["deploy", "list"]);
419+ expect(connection?.lastHealth).toMatchObject({
420+ status: "degraded",
421+ detail: expect.stringContaining("authoritative empty catalog"),
422+ });
423+ }),
424+ ),
425+ );
426+
427+ it.effect("explicit refresh accepts an authoritative empty catalog", () =>
428+ Effect.scoped(
429+ Effect.gen(function* () {
430+ let empty = false;
431+ const guardedPlugin = definePlugin(() => ({
432+ id: "guarded" as const,
433+ credentialProviders: [memoryProvider()],
434+ storage: () => ({}),
435+ resolveTools: () =>
436+ Effect.sync(() => ({
437+ tools: empty
438+ ? []
439+ : [
440+ { name: ToolName.make("deploy"), description: "deploy" },
441+ { name: ToolName.make("list"), description: "list" },
442+ ],
443+ })),
444+ invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }),
445+ extension: (ctx) => ({
446+ seed: () =>
447+ ctx.core.integrations.register({
448+ slug: INTEG,
449+ description: "Vercel",
450+ config: {},
451+ }),
452+ }),
453+ }))();
454+ const executor = yield* createExecutor(
455+ makeTestConfig({ plugins: [guardedPlugin] as const }),
456+ );
457+ yield* executor.guarded.seed();
458+ yield* executor.connections.create({
459+ owner: "org",
460+ name: ConnectionName.make("main"),
461+ integration: INTEG,
462+ template: TEMPLATE,
463+ value: "secret-token",
464+ });
465+
466+ empty = true;
467+ const refreshed = yield* executor.connections.refresh({
468+ owner: "org",
469+ integration: INTEG,
470+ name: ConnectionName.make("main"),
471+ });
472+ const tools = yield* executor.tools.list({ integration: INTEG });
473+
474+ expect(refreshed).toEqual([]);
475+ expect(tools).toEqual([]);
476+ }),
477+ ),
478+ );
479+
480+ it.effect("successful sync clears a prior tool-sync failure health record", () =>
481+ Effect.scoped(
482+ Effect.gen(function* () {
483+ let incomplete = false;
484+ const guardedPlugin = definePlugin(() => ({
485+ id: "guarded" as const,
486+ credentialProviders: [memoryProvider()],
487+ storage: () => ({}),
488+ resolveTools: () =>
489+ Effect.sync(() =>
490+ incomplete
491+ ? {
492+ tools: [],
493+ incomplete: true,
494+ incompleteReason: "temporary catalog outage",
495+ }
496+ : {
497+ tools: [
498+ { name: ToolName.make("deploy"), description: "deploy" },
499+ { name: ToolName.make("list"), description: "list" },
500+ ],
501+ },
502+ ),
503+ invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }),
504+ extension: (ctx) => ({
505+ seed: () =>
506+ ctx.core.integrations.register({
507+ slug: INTEG,
508+ description: "Vercel",
509+ config: {},
510+ }),
511+ }),
512+ }))();
513+ const config = makeTestConfig({ plugins: [guardedPlugin] as const });
514+ const executor = yield* createExecutor(config);
515+ yield* executor.guarded.seed();
516+ yield* executor.connections.create({
517+ owner: "org",
518+ name: ConnectionName.make("main"),
519+ integration: INTEG,
520+ template: TEMPLATE,
521+ value: "secret-token",
522+ });
523+
524+ incomplete = true;
525+ yield* Effect.promise(() =>
526+ config.db.updateMany("connection", {
527+ where: (b) => b.and(b("integration", "=", String(INTEG)), b("name", "=", "main")),
528+ set: { tools_synced_at: null },
529+ }),
530+ );
531+ yield* executor.tools.list({ integration: INTEG });
532+ expect(
533+ (yield* executor.connections.get({
534+ owner: "org",
535+ integration: INTEG,
536+ name: ConnectionName.make("main"),
537+ }))?.lastHealth,
538+ ).toMatchObject({
539+ status: "degraded",
540+ detail: expect.stringContaining("temporary catalog outage"),
541+ });
542+
543+ incomplete = false;
544+ yield* Effect.promise(() =>
545+ config.db.updateMany("connection", {
546+ where: (b) => b.and(b("integration", "=", String(INTEG)), b("name", "=", "main")),
547+ set: { tools_synced_at: null },
548+ }),
549+ );
550+ yield* executor.tools.list({ integration: INTEG });
551+ const connection = yield* executor.connections.get({
552+ owner: "org",
553+ integration: INTEG,
554+ name: ConnectionName.make("main"),
555+ });
556+
557+ expect(connection?.lastHealth).toBeNull();
558+ }),
559+ ),
560+ );
561+
562+ it.effect("successful sync preserves genuine health-check records", () =>
563+ Effect.scoped(
564+ Effect.gen(function* () {
565+ const guardedPlugin = definePlugin(() => ({
566+ id: "guarded" as const,
567+ credentialProviders: [memoryProvider()],
568+ storage: () => ({}),
569+ resolveTools: () =>
570+ Effect.succeed({
571+ tools: [
572+ { name: ToolName.make("deploy"), description: "deploy" },
573+ { name: ToolName.make("list"), description: "list" },
574+ ],
575+ }),
576+ invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }),
577+ extension: (ctx) => ({
578+ seed: () =>
579+ ctx.core.integrations.register({
580+ slug: INTEG,
581+ description: "Vercel",
582+ config: {},
583+ }),
584+ }),
585+ }))();
586+ const config = makeTestConfig({ plugins: [guardedPlugin] as const });
587+ const executor = yield* createExecutor(config);
588+ yield* executor.guarded.seed();
589+ yield* executor.connections.create({
590+ owner: "org",
591+ name: ConnectionName.make("main"),
592+ integration: INTEG,
593+ template: TEMPLATE,
594+ value: "secret-token",
595+ });
596+
597+ const health = {
598+ status: "degraded" as const,
599+ checkedAt: Date.now(),
600+ detail: "health check returned HTTP 503",
601+ };
602+ yield* Effect.promise(() =>
603+ config.db.updateMany("connection", {
604+ where: (b) => b.and(b("integration", "=", String(INTEG)), b("name", "=", "main")),
605+ set: { tools_synced_at: null, last_health: health },
606+ }),
607+ );
608+ yield* executor.tools.list({ integration: INTEG });
609+ const connection = yield* executor.connections.get({
610+ owner: "org",
611+ integration: INTEG,
612+ name: ConnectionName.make("main"),
613+ });
614+
615+ expect(connection?.lastHealth).toMatchObject(health);
616+ }),
617+ ),
618+ );
619+});
620+
362621 describe("execute over a connection", () => {
363622 it.effect("resolves the credential value and hands it to invokeTool", () =>
364623 Effect.gen(function* () {
packages/core/sdk/src/executor.ts+77−18
@@ -2002,9 +2002,21 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2001 unmodified lines
20022002 // Per-connection tool production
20032003 // ------------------------------------------------------------------
20042004
2005+ const toolSyncHealthDetailPrefix = "Tool sync failing";
2006+
2007+ const toolSyncHealth = (reason: string): HealthCheckResult => ({
2008+ status: "degraded",
2009+ checkedAt: Date.now(),
2010+ detail: `${toolSyncHealthDetailPrefix}: ${reason}`,
2011+ });
2012+
2013+ const syncHealthReason = (result: ResolveToolsResult): string =>
2014+ result.incompleteReason ?? "plugin returned an incomplete tool catalog";
2015+
20052016 const produceConnectionTools = (
20062017 integrationRow: IntegrationRow,
20072018 ref: ConnectionRef,
2019+ mode: "explicit" | "background" = "explicit",
20082020 ): Effect.Effect<readonly Tool[], IntegrationNotFoundError | StorageFailure> =>
20092021 Effect.gen(function* () {
20102022 const runtime = runtimes.get(integrationRow.plugin_id);
@@ -2019,18 +2031,39 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 8 unmodified lines
20192031 b("integration", "=", String(ref.integration)),
20202032 b("connection", "=", String(ref.name)),
20212033 );
2034+ const connectionWhere = (b: AnyCb) =>
2035+ b.and(
2036+ byOwner(owner)(b),
2037+ b("integration", "=", String(ref.integration)),
2038+ b("name", "=", String(ref.name)),
2039+ );
2040+ const isToolSyncHealth = (health: HealthCheckResult | null): boolean =>
2041+ health?.detail?.startsWith(toolSyncHealthDetailPrefix) === true;
2042+ const syncedSet = (row: ConnectionRow | null) => {
2043+ const health = row ? Option.getOrNull(decodeLastHealth(row.last_health)) : null;
2044+ return isToolSyncHealth(health)
2045+ ? { tools_synced_at: Date.now(), last_health: null, updated_at: new Date() }
2046+ : { tools_synced_at: Date.now() };
2047+ };
20222048 // Every exit stamps the sync time — including the cleanup paths that
20232049 // produce zero tools — so the stale-catalog check (`config_revised_at`
20242050 // vs `tools_synced_at`) doesn't re-attempt this connection per read.
2025− const stampSynced = core.updateMany("connection", {
2026− where: (b: AnyCb) =>
2027− b.and(
2028− byOwner(owner)(b),
2029− b("integration", "=", String(ref.integration)),
2030− b("name", "=", String(ref.name)),
2031− ),
2032− set: { tools_synced_at: Date.now() },
2033− });
2051+ // Successful syncs also clear stale sync-failure health records, while
2052+ // preserving genuine health-check outcomes.
2053+ const stampSynced = (row: ConnectionRow | null) =>
2054+ core.updateMany("connection", {
2055+ where: connectionWhere,
2056+ set: syncedSet(row),
2057+ });
2058+ const stampSyncedWithHealth = (reason: string) =>
2059+ core.updateMany("connection", {
2060+ where: connectionWhere,
2061+ set: {
2062+ tools_synced_at: Date.now(),
2063+ last_health: toolSyncHealth(reason),
2064+ updated_at: new Date(),
2065+ },
2066+ });
20342067
20352068 // Defense in depth (and cleanup for rows created before the create-time
20362069 // guard, or emptied by an external edit): a credentialed non-OAuth
@@ -2051,7 +2084,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 14 unmodified lines
20512084 Effect.gen(function* () {
20522085 yield* core.deleteMany("tool", { where });
20532086 yield* core.deleteMany("definition", { where });
2054− yield* stampSynced;
2087+ yield* stampSynced(existingRow);
20552088 }),
20562089 );
20572090 return [];
@@ -2063,7 +2096,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
20632096 Effect.gen(function* () {
20642097 yield* core.deleteMany("tool", { where });
20652098 yield* core.deleteMany("definition", { where });
2066− yield* stampSynced;
2099+ yield* stampSynced(existingRow);
20672100 }),
20682101 );
20692102 return [];
@@ -2092,11 +2125,33 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 22 unmodified lines
20922125 // over a transient outage — and stamp the sync time anyway so a down
20932126 // server isn't re-dialed on every read; the freshness TTL re-attempts
20942127 // later.
2095− yield* stampSynced;
2128+ const reason = syncHealthReason(result);
2129+ yield* stampSyncedWithHealth(reason);
2130+ yield* Effect.logWarning("executor tool sync preserved catalog", {
2131+ reason,
2132+ integration: String(ref.integration),
2133+ connection: String(ref.name),
2134+ });
20962135 const keptRows = yield* core.findMany("tool", { where });
20972136 return keptRows.map((row) => rowToTool(row as ConnectionToolRow));
20982137 }
20992138
2139+ if (mode === "background" && result.tools.length === 0) {
2140+ const keptRows = yield* core.findMany("tool", { where });
2141+ if (keptRows.length > 0) {
2142+ const reason =
2143+ "background tool sync produced an authoritative empty catalog for a connection with existing tools";
2144+ yield* stampSyncedWithHealth(reason);
2145+ yield* Effect.logWarning("executor tool sync preserved nonzero catalog", {
2146+ reason,
2147+ integration: String(ref.integration),
2148+ connection: String(ref.name),
2149+ existingToolCount: keptRows.length,
2150+ });
2151+ return keptRows.map((row) => rowToTool(row as ConnectionToolRow));
2152+ }
2153+ }
2154+
21002155 const now = new Date();
21012156 const toolRows = result.tools.map((tool: ToolDef) => ({
21022157 tenant: keys.tenant,
@@ -2132,7 +2187,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 29 unmodified lines
21322187 yield* core.deleteMany("definition", { where });
21332188 yield* core.createMany("tool", toolRows);
21342189 yield* core.createMany("definition", definitionRows);
2135− yield* stampSynced;
2190+ yield* stampSynced(existingRow);
21362191 }),
21372192 );
21382193
@@ -2965,11 +3020,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 826 unmodified lines
29653020 syncedAt < cutoff;
29663021 if (!staleMarked && !configRevised && !expired) continue;
29673022
2968− yield* produceConnectionTools(integrationRow, {
2969− owner: connection.owner as Owner,
2970− integration: IntegrationSlug.make(connection.integration),
2971− name: ConnectionName.make(connection.name),
2972− }).pipe(
3023+ yield* produceConnectionTools(
3024+ integrationRow,
3025+ {
3026+ owner: connection.owner as Owner,
3027+ integration: IntegrationSlug.make(connection.integration),
3028+ name: ConnectionName.make(connection.name),
3029+ },
3030+ "background",
3031+ ).pipe(
29733032 Effect.catch(() => Effect.succeed([] as readonly Tool[])),
29743033 Effect.withSpan("executor.tools.sync_stale", {
29753034 attributes: {
packages/core/sdk/src/plugin.ts+3−0
@@ -297,6 +297,9 @@ export interface ResolveToolsResult { · 296 unmodified lines
297297 * listing is authoritative, including a genuine "this source has zero
298298 * tools". */
299299 readonly incomplete?: boolean;
300+ /** Human-readable reason for an incomplete listing. Persisted by core when it
301+ * preserves the prior catalog so operators can see why data is stale. */
302+ readonly incompleteReason?: string;
300303 }
301304
302305 // ---------------------------------------------------------------------------
packages/plugins/graphql/src/sdk/plugin.ts+23−4
@@ -975,10 +975,17 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 974 unmodified lines
975975 readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>;
976976 }) =>
977977 Effect.gen(function* () {
978+ const incomplete = (reason: string) => ({
979+ tools: [] as readonly ToolDef[],
980+ incomplete: true,
981+ incompleteReason: reason,
982+ });
978983 const decoded = yield* decodeGraphqlIntegrationConfig(config).pipe(Effect.option);
979984 if (Option.isNone(decoded)) return { tools: [] };
980985 const graphqlConfig = decoded.value;
981− const introspectionJson = yield* loadIntrospectionJson(storage, graphqlConfig);
986+ const introspectionJson = yield* loadIntrospectionJson(storage, graphqlConfig).pipe(
987+ Effect.catch(() => Effect.succeed(null)),
988+ );
982989 // Live introspection (no stored snapshot) needs the connection's
983990 // credential inputs for auth-required endpoints; resolve them lazily.
984991 const values =
@@ -994,15 +1001,27 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 9 unmodified lines
9941001 template,
9951002 options?.httpClientLayer ?? httpClientLayer,
9961003 ).pipe(Effect.option);
997− if (Option.isNone(introspection)) return { tools: [] };
1004+ if (Option.isNone(introspection)) {
1005+ return incomplete("GraphQL introspection could not be loaded.");
1006+ }
9981007 const extracted = yield* extract(introspection.value).pipe(Effect.option);
999− if (Option.isNone(extracted)) return { tools: [] };
1008+ if (Option.isNone(extracted)) {
1009+ return incomplete("GraphQL introspection result could not be converted to tools.");
1010+ }
10001011 const prepared = prepareOperations(extracted.value.result.fields, introspection.value);
10011012 return {
10021013 tools: buildToolDefs(prepared),
10031014 definitions: extracted.value.definitions,
10041015 };
1005− }).pipe(Effect.catch(() => Effect.succeed({ tools: [] as readonly ToolDef[] }))),
1016+ }).pipe(
1017+ Effect.catch(() =>
1018+ Effect.succeed({
1019+ tools: [] as readonly ToolDef[],
1020+ incomplete: true,
1021+ incompleteReason: "GraphQL tool catalog could not be resolved.",
1022+ }),
1023+ ),
1024+ ),
10061025
10071026 // -----------------------------------------------------------------------
10081027 // Invoke one of a connection's tools. Look up the operation by integration
packages/plugins/openapi/src/sdk/backing.ts+18−5
@@ -579,24 +579,37 @@ export const resolveOpenApiBackedTools = ({ · 578 unmodified lines
579579 readonly storage: OpenapiStore;
580580 }): Effect.Effect<ResolveToolsResult, StorageFailure> =>
581581 Effect.gen(function* () {
582+ const incomplete = (reason: string): ResolveToolsResult => ({
583+ tools: [],
584+ definitions: {},
585+ incomplete: true,
586+ incompleteReason: reason,
587+ });
582588 const openApiConfig = decodeOpenApiIntegrationConfig(config);
583589 if (!openApiConfig) return { tools: [], definitions: {} };
584590 if (openApiConfig.specHash != null) {
585− const defsJson = yield* storage.getDefs(openApiConfig.specHash);
591+ const defsJson = yield* storage
592+ .getDefs(openApiConfig.specHash)
593+ .pipe(Effect.catch(() => Effect.succeed(null)));
586594 if (defsJson != null) {
587595 const definitions = Option.getOrNull(decodeDefsJson(defsJson));
588596 if (definitions != null) {
589− const ops = yield* storage.listOperations(String(integration.slug));
597+ const ops = yield* storage
598+ .listOperations(String(integration.slug))
599+ .pipe(Effect.catch(() => Effect.succeed(null)));
600+ if (ops == null) return incomplete("OpenAPI operation bindings could not be loaded.");
590601 return { tools: ops.map(toolDefFromStoredOperation), definitions };
591602 }
592603 }
593604 }
594− const specText = yield* loadOpenApiSpecText(storage, openApiConfig);
595− if (specText == null) return { tools: [], definitions: {} };
605+ const specText = yield* loadOpenApiSpecText(storage, openApiConfig).pipe(
606+ Effect.catch(() => Effect.succeed(null)),
607+ );
608+ if (specText == null) return incomplete("OpenAPI spec blob could not be loaded.");
596609 const compiled = yield* compileOpenApiSpecCached(openApiConfig.specHash, specText).pipe(
597610 Effect.catch(() => Effect.succeed(null)),
598611 );
599− if (!compiled) return { tools: [], definitions: {} };
612+ if (!compiled) return incomplete("OpenAPI spec could not be parsed.");
600613 return {
601614 tools: openApiToolDefsFromCompiled(compiled),
602615 definitions: compiled.hoistedDefs,
packages/plugins/openapi/src/sdk/spec-blob.test.ts+229−0
@@ -32,6 +32,7 @@ import { variable } from "@executor-js/sdk/http-auth"; · 31 unmodified lines
3232 import { openApiPlugin } from "./plugin";
3333 import type { OpenapiStore } from "./store";
3434 import type { AuthenticationInput } from "./types";
35+import { defsBlobKey, specBlobKey } from "./store";
3536 import {
3637 makeOpenApiHttpApiTestSourceConfig,
3738 serveOpenApiHttpApiTestServer,
@@ -65,12 +66,49 @@ const specText = () => { · 27 unmodified lines
6566 return spec.url;
6667 };
6768
69+const specTextWithDefinition = () =>
70+ JSON.stringify({
71+ openapi: "3.1.0",
72+ info: { title: "Catalog", version: "1.0.0" },
73+ paths: {
74+ "/items": {
75+ get: {
76+ operationId: "listItems",
77+ responses: {
78+ "200": {
79+ description: "ok",
80+ content: {
81+ "application/json": {
82+ schema: {
83+ type: "array",
84+ items: { $ref: "#/components/schemas/Item" },
85+ },
86+ },
87+ },
88+ },
89+ },
90+ },
91+ },
92+ },
93+ components: {
94+ schemas: {
95+ Item: {
96+ type: "object",
97+ properties: { id: { type: "string" } },
98+ required: ["id"],
99+ },
100+ },
101+ },
102+ });
103+
68104 const apiKeyTemplate: AuthenticationInput = {
69105 slug: AuthTemplateSlug.make("apiKey"),
70106 type: "apiKey",
71107 headers: { "x-api-key": [variable("token")] },
72108 };
73109
110+const openApiBlobNamespace = "o:test-tenant/openapi";
111+
74112 describe("OpenAPI plugin — spec blob storage", () => {
75113 it.effect("addSpec stores a content pointer, not the inline spec text", () =>
76114 Effect.scoped(
@@ -125,6 +163,197 @@ describe("OpenAPI plugin — spec blob storage", () => { · 48 unmodified lines
125163 ),
126164 );
127165
166+ it.effect(
167+ "stale sync preserves tools and definitions when the spec blob parses to a non-object",
168+ () =>
169+ Effect.scoped(
170+ Effect.gen(function* () {
171+ const config = makeTestConfig({ plugins: testPlugins() });
172+ const executor = yield* createExecutor(config);
173+ const text = specTextWithDefinition();
174+ const hash = yield* sha256Hex(text);
175+
176+ yield* executor.openapi.addSpec({
177+ spec: { kind: "blob", value: text },
178+ slug: "corrupt_blob",
179+ });
180+ yield* executor.connections.create({
181+ owner: "org",
182+ name: ConnectionName.make("main"),
183+ integration: IntegrationSlug.make("corrupt_blob"),
184+ template: AuthTemplateSlug.make("none"),
185+ values: {},
186+ });
187+
188+ const beforeTools = yield* executor.tools.list({
189+ integration: IntegrationSlug.make("corrupt_blob"),
190+ });
191+ const beforeDefinitions = yield* Effect.promise(() =>
192+ config.db.findMany("definition", {
193+ where: (b) => b("integration", "=", "corrupt_blob"),
194+ }),
195+ );
196+ expect(beforeTools.length).toBeGreaterThan(0);
197+ expect(beforeDefinitions.length).toBeGreaterThan(0);
198+
199+ yield* Effect.promise(() =>
200+ config.db.updateMany("blob", {
201+ where: (b) =>
202+ b.and(b("namespace", "=", openApiBlobNamespace), b("key", "=", specBlobKey(hash))),
203+ set: { value: JSON.stringify("not an object") },
204+ }),
205+ );
206+ yield* Effect.promise(() =>
207+ config.db.deleteMany("blob", {
208+ where: (b) =>
209+ b.and(b("namespace", "=", openApiBlobNamespace), b("key", "=", defsBlobKey(hash))),
210+ }),
211+ );
212+ yield* Effect.promise(() =>
213+ config.db.updateMany("connection", {
214+ where: (b) => b.and(b("integration", "=", "corrupt_blob"), b("name", "=", "main")),
215+ set: { tools_synced_at: null },
216+ }),
217+ );
218+
219+ const afterTools = yield* executor.tools.list({
220+ integration: IntegrationSlug.make("corrupt_blob"),
221+ });
222+ const afterDefinitions = yield* Effect.promise(() =>
223+ config.db.findMany("definition", {
224+ where: (b) => b("integration", "=", "corrupt_blob"),
225+ }),
226+ );
227+ const connection = yield* executor.connections.get({
228+ owner: "org",
229+ integration: IntegrationSlug.make("corrupt_blob"),
230+ name: ConnectionName.make("main"),
231+ });
232+
233+ expect(afterTools.map((tool) => String(tool.name)).sort()).toEqual(
234+ beforeTools.map((tool) => String(tool.name)).sort(),
235+ );
236+ expect(afterDefinitions).toHaveLength(beforeDefinitions.length);
237+ expect(connection?.lastHealth).toMatchObject({
238+ status: "degraded",
239+ detail: expect.stringContaining("OpenAPI spec could not be parsed"),
240+ });
241+ }),
242+ ),
243+ );
244+
245+ it.effect("stale sync preserves tools and definitions when the spec blob is missing", () =>
246+ Effect.scoped(
247+ Effect.gen(function* () {
248+ const config = makeTestConfig({ plugins: testPlugins() });
249+ const executor = yield* createExecutor(config);
250+ const text = specTextWithDefinition();
251+ const hash = yield* sha256Hex(text);
252+
253+ yield* executor.openapi.addSpec({
254+ spec: { kind: "blob", value: text },
255+ slug: "missing_blob",
256+ });
257+ yield* executor.connections.create({
258+ owner: "org",
259+ name: ConnectionName.make("main"),
260+ integration: IntegrationSlug.make("missing_blob"),
261+ template: AuthTemplateSlug.make("none"),
262+ values: {},
263+ });
264+
265+ const beforeTools = yield* executor.tools.list({
266+ integration: IntegrationSlug.make("missing_blob"),
267+ });
268+ const beforeDefinitions = yield* Effect.promise(() =>
269+ config.db.findMany("definition", {
270+ where: (b) => b("integration", "=", "missing_blob"),
271+ }),
272+ );
273+ expect(beforeTools.length).toBeGreaterThan(0);
274+ expect(beforeDefinitions.length).toBeGreaterThan(0);
275+
276+ yield* Effect.promise(() =>
277+ config.db.deleteMany("blob", {
278+ where: (b) =>
279+ b.and(
280+ b("namespace", "=", openApiBlobNamespace),
281+ b("key", "in", [specBlobKey(hash), defsBlobKey(hash)]),
282+ ),
283+ }),
284+ );
285+ yield* Effect.promise(() =>
286+ config.db.updateMany("connection", {
287+ where: (b) => b.and(b("integration", "=", "missing_blob"), b("name", "=", "main")),
288+ set: { tools_synced_at: null },
289+ }),
290+ );
291+
292+ const afterTools = yield* executor.tools.list({
293+ integration: IntegrationSlug.make("missing_blob"),
294+ });
295+ const afterDefinitions = yield* Effect.promise(() =>
296+ config.db.findMany("definition", {
297+ where: (b) => b("integration", "=", "missing_blob"),
298+ }),
299+ );
300+ const connection = yield* executor.connections.get({
301+ owner: "org",
302+ integration: IntegrationSlug.make("missing_blob"),
303+ name: ConnectionName.make("main"),
304+ });
305+
306+ expect(afterTools.map((tool) => String(tool.name)).sort()).toEqual(
307+ beforeTools.map((tool) => String(tool.name)).sort(),
308+ );
309+ expect(afterDefinitions).toHaveLength(beforeDefinitions.length);
310+ expect(connection?.lastHealth).toMatchObject({
311+ status: "degraded",
312+ detail: expect.stringContaining("OpenAPI spec blob could not be loaded"),
313+ });
314+ }),
315+ ),
316+ );
317+
318+ it.effect("explicit spec refresh accepts a valid OpenAPI document with zero operations", () =>
319+ Effect.scoped(
320+ Effect.gen(function* () {
321+ const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
322+ const emptySpec = JSON.stringify({
323+ openapi: "3.1.0",
324+ info: { title: "Empty", version: "1.0.0" },
325+ paths: {},
326+ });
327+
328+ yield* executor.openapi.addSpec({
329+ spec: { kind: "blob", value: specText() },
330+ slug: "healthy_zero",
331+ });
332+ yield* executor.connections.create({
333+ owner: "org",
334+ name: ConnectionName.make("main"),
335+ integration: IntegrationSlug.make("healthy_zero"),
336+ template: AuthTemplateSlug.make("none"),
337+ values: {},
338+ });
339+ expect(
340+ (yield* executor.tools.list({ integration: IntegrationSlug.make("healthy_zero") }))
341+ .length,
342+ ).toBeGreaterThan(0);
343+
344+ const update = yield* executor.openapi.updateSpec("healthy_zero", {
345+ spec: { kind: "blob", value: emptySpec },
346+ });
347+ const tools = yield* executor.tools.list({
348+ integration: IntegrationSlug.make("healthy_zero"),
349+ });
350+
351+ expect(update.toolCount).toBe(0);
352+ expect(tools).toEqual([]);
353+ }),
354+ ),
355+ );
356+
128357 it.effect("resolveTools reads the spec from the store, never an inline field", () =>
129358 Effect.gen(function* () {
130359 const plugin = openApiPlugin({ httpClientLayer: FetchHttpClient.layer });