Unified · Split
| @@ -10,9 +10,10 @@ import { · 9 unmodified lines | ||
| 10 | 10 | ToolAddress, |
| 11 | 11 | ToolName, |
| 12 | 12 | } from "./ids"; |
| 13 | +import { createExecutor } from "./executor"; | |
| 13 | 14 | import { definePlugin } from "./plugin"; |
| 14 | 15 | import type { CredentialProvider } from "./provider"; |
| 15 | −import { makeTestExecutor } from "./testing"; | |
| 16 | +import { makeTestConfig, makeTestExecutor } from "./testing"; | |
| 16 | 17 | |
| 17 | 18 | // removed: v1 connection-refresh lifecycle, ConnectionProvider.refresh, |
| 18 | 19 | // SecretProvider, accessToken token-refresh + in-flight dedup tests — the v2 |
| @@ -359,6 +360,264 @@ describe("connections.refresh", () => { · 340 unmodified lines | ||
| 359 | 360 | ); |
| 360 | 361 | }); |
| 361 | 362 | |
| 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 | + | |
| 362 | 621 | describe("execute over a connection", () => { |
| 363 | 622 | it.effect("resolves the credential value and hands it to invokeTool", () => |
| 364 | 623 | Effect.gen(function* () { |
| @@ -2002,9 +2002,21 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2001 unmodified lines | ||
| 2002 | 2002 | // Per-connection tool production |
| 2003 | 2003 | // ------------------------------------------------------------------ |
| 2004 | 2004 | |
| 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 | + | |
| 2005 | 2016 | const produceConnectionTools = ( |
| 2006 | 2017 | integrationRow: IntegrationRow, |
| 2007 | 2018 | ref: ConnectionRef, |
| 2019 | + mode: "explicit" | "background" = "explicit", | |
| 2008 | 2020 | ): Effect.Effect<readonly Tool[], IntegrationNotFoundError | StorageFailure> => |
| 2009 | 2021 | Effect.gen(function* () { |
| 2010 | 2022 | const runtime = runtimes.get(integrationRow.plugin_id); |
| @@ -2019,18 +2031,39 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 8 unmodified lines | ||
| 2019 | 2031 | b("integration", "=", String(ref.integration)), |
| 2020 | 2032 | b("connection", "=", String(ref.name)), |
| 2021 | 2033 | ); |
| 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 | + }; | |
| 2022 | 2048 | // Every exit stamps the sync time — including the cleanup paths that |
| 2023 | 2049 | // produce zero tools — so the stale-catalog check (`config_revised_at` |
| 2024 | 2050 | // 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 | + }); | |
| 2034 | 2067 | |
| 2035 | 2068 | // Defense in depth (and cleanup for rows created before the create-time |
| 2036 | 2069 | // 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 | ||
| 2051 | 2084 | Effect.gen(function* () { |
| 2052 | 2085 | yield* core.deleteMany("tool", { where }); |
| 2053 | 2086 | yield* core.deleteMany("definition", { where }); |
| 2054 | − yield* stampSynced; | |
| 2087 | + yield* stampSynced(existingRow); | |
| 2055 | 2088 | }), |
| 2056 | 2089 | ); |
| 2057 | 2090 | return []; |
| @@ -2063,7 +2096,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines | ||
| 2063 | 2096 | Effect.gen(function* () { |
| 2064 | 2097 | yield* core.deleteMany("tool", { where }); |
| 2065 | 2098 | yield* core.deleteMany("definition", { where }); |
| 2066 | − yield* stampSynced; | |
| 2099 | + yield* stampSynced(existingRow); | |
| 2067 | 2100 | }), |
| 2068 | 2101 | ); |
| 2069 | 2102 | return []; |
| @@ -2092,11 +2125,33 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 22 unmodified lines | ||
| 2092 | 2125 | // over a transient outage — and stamp the sync time anyway so a down |
| 2093 | 2126 | // server isn't re-dialed on every read; the freshness TTL re-attempts |
| 2094 | 2127 | // 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 | + }); | |
| 2096 | 2135 | const keptRows = yield* core.findMany("tool", { where }); |
| 2097 | 2136 | return keptRows.map((row) => rowToTool(row as ConnectionToolRow)); |
| 2098 | 2137 | } |
| 2099 | 2138 | |
| 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 | + | |
| 2100 | 2155 | const now = new Date(); |
| 2101 | 2156 | const toolRows = result.tools.map((tool: ToolDef) => ({ |
| 2102 | 2157 | tenant: keys.tenant, |
| @@ -2132,7 +2187,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 29 unmodified lines | ||
| 2132 | 2187 | yield* core.deleteMany("definition", { where }); |
| 2133 | 2188 | yield* core.createMany("tool", toolRows); |
| 2134 | 2189 | yield* core.createMany("definition", definitionRows); |
| 2135 | − yield* stampSynced; | |
| 2190 | + yield* stampSynced(existingRow); | |
| 2136 | 2191 | }), |
| 2137 | 2192 | ); |
| 2138 | 2193 | |
| @@ -2965,11 +3020,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 826 unmodified lines | ||
| 2965 | 3020 | syncedAt < cutoff; |
| 2966 | 3021 | if (!staleMarked && !configRevised && !expired) continue; |
| 2967 | 3022 | |
| 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( | |
| 2973 | 3032 | Effect.catch(() => Effect.succeed([] as readonly Tool[])), |
| 2974 | 3033 | Effect.withSpan("executor.tools.sync_stale", { |
| 2975 | 3034 | attributes: { |
| @@ -297,6 +297,9 @@ export interface ResolveToolsResult { · 296 unmodified lines | ||
| 297 | 297 | * listing is authoritative, including a genuine "this source has zero |
| 298 | 298 | * tools". */ |
| 299 | 299 | 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; | |
| 300 | 303 | } |
| 301 | 304 | |
| 302 | 305 | // --------------------------------------------------------------------------- |
| @@ -975,10 +975,17 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 974 unmodified lines | ||
| 975 | 975 | readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>; |
| 976 | 976 | }) => |
| 977 | 977 | Effect.gen(function* () { |
| 978 | + const incomplete = (reason: string) => ({ | |
| 979 | + tools: [] as readonly ToolDef[], | |
| 980 | + incomplete: true, | |
| 981 | + incompleteReason: reason, | |
| 982 | + }); | |
| 978 | 983 | const decoded = yield* decodeGraphqlIntegrationConfig(config).pipe(Effect.option); |
| 979 | 984 | if (Option.isNone(decoded)) return { tools: [] }; |
| 980 | 985 | 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 | + ); | |
| 982 | 989 | // Live introspection (no stored snapshot) needs the connection's |
| 983 | 990 | // credential inputs for auth-required endpoints; resolve them lazily. |
| 984 | 991 | const values = |
| @@ -994,15 +1001,27 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 9 unmodified lines | ||
| 994 | 1001 | template, |
| 995 | 1002 | options?.httpClientLayer ?? httpClientLayer, |
| 996 | 1003 | ).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 | + } | |
| 998 | 1007 | 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 | + } | |
| 1000 | 1011 | const prepared = prepareOperations(extracted.value.result.fields, introspection.value); |
| 1001 | 1012 | return { |
| 1002 | 1013 | tools: buildToolDefs(prepared), |
| 1003 | 1014 | definitions: extracted.value.definitions, |
| 1004 | 1015 | }; |
| 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 | + ), | |
| 1006 | 1025 | |
| 1007 | 1026 | // ----------------------------------------------------------------------- |
| 1008 | 1027 | // Invoke one of a connection's tools. Look up the operation by integration |
| @@ -579,24 +579,37 @@ export const resolveOpenApiBackedTools = ({ · 578 unmodified lines | ||
| 579 | 579 | readonly storage: OpenapiStore; |
| 580 | 580 | }): Effect.Effect<ResolveToolsResult, StorageFailure> => |
| 581 | 581 | Effect.gen(function* () { |
| 582 | + const incomplete = (reason: string): ResolveToolsResult => ({ | |
| 583 | + tools: [], | |
| 584 | + definitions: {}, | |
| 585 | + incomplete: true, | |
| 586 | + incompleteReason: reason, | |
| 587 | + }); | |
| 582 | 588 | const openApiConfig = decodeOpenApiIntegrationConfig(config); |
| 583 | 589 | if (!openApiConfig) return { tools: [], definitions: {} }; |
| 584 | 590 | 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))); | |
| 586 | 594 | if (defsJson != null) { |
| 587 | 595 | const definitions = Option.getOrNull(decodeDefsJson(defsJson)); |
| 588 | 596 | 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."); | |
| 590 | 601 | return { tools: ops.map(toolDefFromStoredOperation), definitions }; |
| 591 | 602 | } |
| 592 | 603 | } |
| 593 | 604 | } |
| 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."); | |
| 596 | 609 | const compiled = yield* compileOpenApiSpecCached(openApiConfig.specHash, specText).pipe( |
| 597 | 610 | Effect.catch(() => Effect.succeed(null)), |
| 598 | 611 | ); |
| 599 | − if (!compiled) return { tools: [], definitions: {} }; | |
| 612 | + if (!compiled) return incomplete("OpenAPI spec could not be parsed."); | |
| 600 | 613 | return { |
| 601 | 614 | tools: openApiToolDefsFromCompiled(compiled), |
| 602 | 615 | definitions: compiled.hoistedDefs, |
| @@ -32,6 +32,7 @@ import { variable } from "@executor-js/sdk/http-auth"; · 31 unmodified lines | ||
| 32 | 32 | import { openApiPlugin } from "./plugin"; |
| 33 | 33 | import type { OpenapiStore } from "./store"; |
| 34 | 34 | import type { AuthenticationInput } from "./types"; |
| 35 | +import { defsBlobKey, specBlobKey } from "./store"; | |
| 35 | 36 | import { |
| 36 | 37 | makeOpenApiHttpApiTestSourceConfig, |
| 37 | 38 | serveOpenApiHttpApiTestServer, |
| @@ -65,12 +66,49 @@ const specText = () => { · 27 unmodified lines | ||
| 65 | 66 | return spec.url; |
| 66 | 67 | }; |
| 67 | 68 | |
| 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 | + | |
| 68 | 104 | const apiKeyTemplate: AuthenticationInput = { |
| 69 | 105 | slug: AuthTemplateSlug.make("apiKey"), |
| 70 | 106 | type: "apiKey", |
| 71 | 107 | headers: { "x-api-key": [variable("token")] }, |
| 72 | 108 | }; |
| 73 | 109 | |
| 110 | +const openApiBlobNamespace = "o:test-tenant/openapi"; | |
| 111 | + | |
| 74 | 112 | describe("OpenAPI plugin — spec blob storage", () => { |
| 75 | 113 | it.effect("addSpec stores a content pointer, not the inline spec text", () => |
| 76 | 114 | Effect.scoped( |
| @@ -125,6 +163,197 @@ describe("OpenAPI plugin — spec blob storage", () => { · 48 unmodified lines | ||
| 125 | 163 | ), |
| 126 | 164 | ); |
| 127 | 165 | |
| 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 | + | |
| 128 | 357 | it.effect("resolveTools reads the spec from the store, never an inline field", () => |
| 129 | 358 | Effect.gen(function* () { |
| 130 | 359 | const plugin = openApiPlugin({ httpClientLayer: FetchHttpClient.layer }); |