Unified · Split
| @@ -1,6 +1,6 @@ | |||
| 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-double-cast, executor/no-promise-reject, executor/no-instanceof-tagged-error -- boundary: in-process dynamic-import backing converts JavaScript exceptions into typed AppExecutorError */ | 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-double-cast, executor/no-promise-reject, executor/no-instanceof-tagged-error -- boundary: in-process dynamic-import backing converts JavaScript exceptions into typed AppExecutorError */ |
| 2 | import { Buffer } from "node:buffer"; | 2 | import { Buffer } from "node:buffer"; |
| 3 | import { Data, Effect } from "effect"; | 3 | import { Data, Effect, Predicate } from "effect"; |
| 4 | import { z } from "zod"; | 4 | import { z } from "zod"; |
| 5 | 5 | ||
| 6 | import { validToolKey } from "../pipeline/discover"; | 6 | import { validToolKey } from "../pipeline/discover"; |
| @@ -126,6 +126,13 @@ const jsonSchemaFor = (schema: unknown): unknown => { · 119 unmodified lines | |||
| 126 | return schema; | 126 | return schema; |
| 127 | }; | 127 | }; |
| 128 | 128 | ||
| 129 | const innerToolFailureMessage = (cause: unknown): string | null => { | ||
| 130 | if (!Predicate.isTagged("AppInnerToolError")(cause)) return null; | ||
| 131 | const error = cause as { readonly address?: unknown; readonly innerMessage?: unknown }; | ||
| 132 | if (typeof error.address !== "string" || typeof error.innerMessage !== "string") return null; | ||
| 133 | return `Inner tool ${error.address} failed: "${error.innerMessage}"`; | ||
| 134 | }; | ||
| 135 | |||
| 129 | type CollectedIntegrationDecl = CollectedTool["integrations"][string]; | 136 | type CollectedIntegrationDecl = CollectedTool["integrations"][string]; |
| 130 | 137 | ||
| 131 | const integrationDeclaration = (value: unknown): CollectedIntegrationDecl | null => { | 138 | const integrationDeclaration = (value: unknown): CollectedIntegrationDecl | null => { |
| @@ -452,6 +459,10 @@ export const makeInProcessAppToolExecutor = (): AppToolExecutor => ({ · 320 unmodified lines | |||
| 452 | catch: (cause) => | 459 | catch: (cause) => |
| 453 | cause instanceof AppExecutorError | 460 | cause instanceof AppExecutorError |
| 454 | ? cause | 461 | ? cause |
| 455 | : new AppExecutorError({ kind: "invoke", message: "app tool invocation failed", cause }), | 462 | : new AppExecutorError({ |
| 463 | kind: "invoke", | ||
| 464 | message: innerToolFailureMessage(cause) ?? "app tool invocation failed", | ||
| 465 | cause, | ||
| 466 | }), | ||
| 456 | }), | 467 | }), |
| 457 | }); | 468 | }); |
| @@ -48,7 +48,6 @@ export interface AppDescriptor { · 47 unmodified lines | |||
| 48 | readonly version: typeof DESCRIPTOR_VERSION; | 48 | readonly version: typeof DESCRIPTOR_VERSION; |
| 49 | readonly app: string; | 49 | readonly app: string; |
| 50 | readonly sourceRef: string; | 50 | readonly sourceRef: string; |
| 51 | readonly descriptorKey: string; | ||
| 52 | readonly publishedAt: number; | 51 | readonly publishedAt: number; |
| 53 | readonly toolchain: ToolchainRef; | 52 | readonly toolchain: ToolchainRef; |
| 54 | readonly tools: readonly ToolDescriptor[]; | 53 | readonly tools: readonly ToolDescriptor[]; |
| @@ -28,6 +28,7 @@ const makeMemoryStore = (): AppsStore & { · 27 unmodified lines | |||
| 28 | const blobs = new Map<string, string>(); | 28 | const blobs = new Map<string, string>(); |
| 29 | const rows = new Map<string, unknown>(); | 29 | const rows = new Map<string, unknown>(); |
| 30 | let descriptor: AppDescriptor | null = null; | 30 | let descriptor: AppDescriptor | null = null; |
| 31 | let descriptorKey: string | null = null; | ||
| 31 | return { | 32 | return { |
| 32 | rows, | 33 | rows, |
| 33 | blobs, | 34 | blobs, |
| @@ -40,11 +41,9 @@ const makeMemoryStore = (): AppsStore & { · 6 unmodified lines | |||
| 40 | getBlob: (key) => Effect.sync(() => blobs.get(key) ?? null), | 41 | getBlob: (key) => Effect.sync(() => blobs.get(key) ?? null), |
| 41 | getDescriptorRecord: () => | 42 | getDescriptorRecord: () => |
| 42 | Effect.sync(() => | 43 | Effect.sync(() => |
| 43 | descriptor | 44 | descriptor ? { sourceRef: descriptor.sourceRef, descriptorKey: descriptorKey ?? "" } : null, |
| 44 | ? { sourceRef: descriptor.sourceRef, descriptorKey: descriptor.descriptorKey } | ||
| 45 | : null, | ||
| 46 | ), | 45 | ), |
| 47 | putPublished: (next, _owner, expectedSourceRef) => | 46 | putPublished: (next, nextDescriptorKey, _owner, expectedSourceRef) => |
| 48 | Effect.gen(function* () { | 47 | Effect.gen(function* () { |
| 49 | const actualSourceRef = descriptor?.sourceRef ?? null; | 48 | const actualSourceRef = descriptor?.sourceRef ?? null; |
| 50 | if (actualSourceRef !== expectedSourceRef) { | 49 | if (actualSourceRef !== expectedSourceRef) { |
| @@ -55,6 +54,7 @@ const makeMemoryStore = (): AppsStore & { · 4 unmodified lines | |||
| 55 | }); | 54 | }); |
| 56 | } | 55 | } |
| 57 | descriptor = next; | 56 | descriptor = next; |
| 57 | descriptorKey = nextDescriptorKey; | ||
| 58 | for (const key of rows.keys()) rows.delete(key); | 58 | for (const key of rows.keys()) rows.delete(key); |
| 59 | for (const item of next.tools) rows.set(item.name, item); | 59 | for (const item of next.tools) rows.set(item.name, item); |
| 60 | }), | 60 | }), |
| @@ -132,6 +132,7 @@ describe("publish", () => { · 71 unmodified lines | |||
| 132 | expect(result.publishedTools).toEqual(["sync"]); | 132 | expect(result.publishedTools).toEqual(["sync"]); |
| 133 | expect(result.skipped).toEqual([{ path: "workflows/later.ts", reason: "not supported yet" }]); | 133 | expect(result.skipped).toEqual([{ path: "workflows/later.ts", reason: "not supported yet" }]); |
| 134 | expect(result.descriptor.toolchain.executor.name).toBe("in-process-data-url"); | 134 | expect(result.descriptor.toolchain.executor.name).toBe("in-process-data-url"); |
| 135 | expect(store.blobs.size).toBe(2); | ||
| 135 | }), | 136 | }), |
| 136 | ); | 137 | ); |
| 137 | 138 | ||
| @@ -180,11 +180,10 @@ export const publish = ( · 179 unmodified lines | |||
| 180 | } | 180 | } |
| 181 | } | 181 | } |
| 182 | 182 | ||
| 183 | const descriptorSeed = { | 183 | const descriptor: AppDescriptor = { |
| 184 | version: DESCRIPTOR_VERSION, | 184 | version: DESCRIPTOR_VERSION, |
| 185 | app: input.app, | 185 | app: input.app, |
| 186 | sourceRef: input.sourceRef, | 186 | sourceRef: input.sourceRef, |
| 187 | descriptorKey: "", | ||
| 188 | publishedAt: deps.now?.() ?? Date.now(), | 187 | publishedAt: deps.now?.() ?? Date.now(), |
| 189 | toolchain: toolchainRef(), | 188 | toolchain: toolchainRef(), |
| 190 | tools, | 189 | tools, |
| @@ -193,49 +192,38 @@ export const publish = ( · 2 unmodified lines | |||
| 193 | skills: discovered.skipped.filter((item) => item.path.startsWith("skills/")), | 192 | skills: discovered.skipped.filter((item) => item.path.startsWith("skills/")), |
| 194 | skipped: discovered.skipped, | 193 | skipped: discovered.skipped, |
| 195 | }; | 194 | }; |
| 196 | const descriptorBodyWithoutKey = stableStringify(descriptorSeed); | ||
| 197 | const descriptorKey = yield* deps.store.putBlob(descriptorBodyWithoutKey, owner).pipe( | ||
| 198 | Effect.mapError( | ||
| 199 | () => | ||
| 200 | new PublishError({ | ||
| 201 | stage: "project", | ||
| 202 | message: "failed to write descriptor blob", | ||
| 203 | diagnostics: [], | ||
| 204 | }), | ||
| 205 | ), | ||
| 206 | ); | ||
| 207 | const descriptor: AppDescriptor = { ...descriptorSeed, descriptorKey }; | ||
| 208 | const descriptorBody = stableStringify(descriptor); | 195 | const descriptorBody = stableStringify(descriptor); |
| 209 | const finalDescriptorKey = yield* deps.store.putBlob(descriptorBody, owner).pipe( | 196 | const descriptorKey = yield* deps.store.putBlob(descriptorBody, owner).pipe( |
| 210 | Effect.mapError( | 197 | Effect.mapError( |
| 211 | () => | 198 | () => |
| 212 | new PublishError({ | 199 | new PublishError({ |
| 213 | stage: "project", | 200 | stage: "project", |
| 214 | message: "failed to write final descriptor blob", | 201 | message: "failed to write descriptor blob", |
| 215 | diagnostics: [], | 202 | diagnostics: [], |
| 216 | }), | 203 | }), |
| 217 | ), | 204 | ), |
| 218 | ); | 205 | ); |
| 219 | const finalDescriptor: AppDescriptor = { ...descriptor, descriptorKey: finalDescriptorKey }; | 206 | yield* deps.store |
| 220 | yield* deps.store.putPublished(finalDescriptor, owner, existing?.sourceRef ?? null).pipe( | 207 | .putPublished(descriptor, descriptorKey, owner, existing?.sourceRef ?? null) |
| 221 | Effect.mapError((cause) => { | 208 | .pipe( |
| 222 | if (Predicate.isTagged("AppPublishConflictError")(cause)) { | 209 | Effect.mapError((cause) => { |
| 210 | if (Predicate.isTagged("AppPublishConflictError")(cause)) { | ||
| 211 | return new PublishError({ | ||
| 212 | stage: "project", | ||
| 213 | message: `app "${input.app}" changed during publish`, | ||
| 214 | diagnostics: [{ path: "", message: "publish sourceRef changed during publish" }], | ||
| 215 | }); | ||
| 216 | } | ||
| 223 | return new PublishError({ | 217 | return new PublishError({ |
| 224 | stage: "project", | 218 | stage: "project", |
| 225 | message: `app "${input.app}" changed during publish`, | 219 | message: "failed to persist app publication", |
| 226 | diagnostics: [{ path: "", message: "publish sourceRef changed during publish" }], | 220 | diagnostics: [], |
| 227 | }); | 221 | }); |
| 228 | } | 222 | }), |
| 229 | return new PublishError({ | 223 | ); |
| 230 | stage: "project", | ||
| 231 | message: "failed to persist app publication", | ||
| 232 | diagnostics: [], | ||
| 233 | }); | ||
| 234 | }), | ||
| 235 | ); | ||
| 236 | return { | 224 | return { |
| 237 | descriptor: finalDescriptor, | 225 | descriptor, |
| 238 | publishedTools: finalDescriptor.tools.map((tool) => tool.name), | 226 | publishedTools: descriptor.tools.map((tool) => tool.name), |
| 239 | skipped: discovered.skipped, | 227 | skipped: discovered.skipped, |
| 240 | noop: false, | 228 | noop: false, |
| 241 | }; | 229 | }; |
| @@ -1,9 +1,60 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect } from "effect"; | 2 | import { Effect } from "effect"; |
| 3 | import { ToolResult } from "@executor-js/sdk"; | ||
| 3 | 4 | ||
| 4 | import { projectAppsToolSchema } from "./apps-plugin"; | 5 | import { bundleEntry } from "../pipeline/bundle"; |
| 6 | import { makeInProcessAppToolExecutor } from "../executor/app-tool-executor"; | ||
| 7 | import type { AppDescriptor } from "../pipeline/descriptor"; | ||
| 8 | import { makeAppsPlugin, projectAppsToolSchema } from "./apps-plugin"; | ||
| 5 | import type { AppsStore } from "./store"; | 9 | import type { AppsStore } from "./store"; |
| 6 | 10 | ||
| 11 | const bundle = (source: string) => | ||
| 12 | bundleEntry({ | ||
| 13 | files: new Map([["tools/sync.ts", source]]), | ||
| 14 | entry: "tools/sync.ts", | ||
| 15 | }); | ||
| 16 | |||
| 17 | const makeInvokeStore = (input: { | ||
| 18 | readonly bundle: string; | ||
| 19 | readonly integrations: AppDescriptor["tools"][number]["integrations"]; | ||
| 20 | }): AppsStore => ({ | ||
| 21 | putBlob: () => Effect.succeed("bundle"), | ||
| 22 | getBlob: () => Effect.succeed(input.bundle), | ||
| 23 | getDescriptorRecord: () => Effect.succeed(null), | ||
| 24 | putPublished: () => Effect.void, | ||
| 25 | listActiveTools: () => Effect.succeed([]), | ||
| 26 | getTool: () => | ||
| 27 | Effect.succeed({ | ||
| 28 | app: "crm", | ||
| 29 | name: "sync", | ||
| 30 | bundleKey: "bundle", | ||
| 31 | description: "Sync", | ||
| 32 | integrations: input.integrations, | ||
| 33 | }), | ||
| 34 | }); | ||
| 35 | |||
| 36 | const invokeCtx = (input: { | ||
| 37 | readonly bundle: string; | ||
| 38 | readonly execute: (address: string, args: unknown) => Effect.Effect<unknown, unknown>; | ||
| 39 | }) => | ||
| 40 | ({ | ||
| 41 | storage: makeInvokeStore({ | ||
| 42 | bundle: input.bundle, | ||
| 43 | integrations: { crm: { slug: "dealcloud", mode: "one" } }, | ||
| 44 | }), | ||
| 45 | connections: { | ||
| 46 | list: () => Effect.succeed([]), | ||
| 47 | get: () => | ||
| 48 | Effect.succeed({ | ||
| 49 | address: "tools.dealcloud.org.main", | ||
| 50 | integration: "dealcloud", | ||
| 51 | owner: "org", | ||
| 52 | name: "main", | ||
| 53 | }), | ||
| 54 | }, | ||
| 55 | execute: (address: string, args: unknown) => input.execute(String(address), args), | ||
| 56 | }) as never; | ||
| 57 | |||
| 7 | describe("apps plugin schema projection", () => { | 58 | describe("apps plugin schema projection", () => { |
| 8 | it.effect("narrows synthesized integration fields to connection address enums", () => | 59 | it.effect("narrows synthesized integration fields to connection address enums", () => |
| 9 | Effect.gen(function* () { | 60 | Effect.gen(function* () { |
| @@ -63,3 +114,83 @@ describe("apps plugin schema projection", () => { · 53 unmodified lines | |||
| 63 | }), | 114 | }), |
| 64 | ); | 115 | ); |
| 65 | }); | 116 | }); |
| 117 | |||
| 118 | describe("apps plugin invocation", () => { | ||
| 119 | it.effect("surfaces uncaught inner tool failures without binding_error", () => | ||
| 120 | Effect.gen(function* () { | ||
| 121 | const bundled = yield* bundle(` | ||
| 122 | import { z } from "zod"; | ||
| 123 | import { defineTool, integration } from "executor:app"; | ||
| 124 | export default defineTool({ | ||
| 125 | description: "Sync", | ||
| 126 | integrations: { crm: integration("dealcloud") }, | ||
| 127 | input: z.object({}), | ||
| 128 | async handler(_input, { crm }) { | ||
| 129 | await crm.deals.list({}); | ||
| 130 | return { ok: true }; | ||
| 131 | }, | ||
| 132 | }); | ||
| 133 | `); | ||
| 134 | const plugin = makeAppsPlugin({ executor: makeInProcessAppToolExecutor() }); | ||
| 135 | const result = yield* plugin.invokeTool!({ | ||
| 136 | ctx: invokeCtx({ | ||
| 137 | bundle: bundled.code, | ||
| 138 | execute: () => | ||
| 139 | Effect.succeed( | ||
| 140 | ToolResult.fail({ code: "upstream_failed", message: "CRM unavailable" }), | ||
| 141 | ), | ||
| 142 | }), | ||
| 143 | toolRow: { name: "sync" }, | ||
| 144 | args: { crm: "tools.dealcloud.org.main" }, | ||
| 145 | } as never); | ||
| 146 | expect(result).toMatchObject({ | ||
| 147 | ok: false, | ||
| 148 | error: { | ||
| 149 | code: "upstream_failed", | ||
| 150 | message: expect.stringContaining("tools.dealcloud.org.main.deals.list"), | ||
| 151 | }, | ||
| 152 | }); | ||
| 153 | expect(result).toMatchObject({ | ||
| 154 | error: { message: expect.stringContaining('"CRM unavailable"') }, | ||
| 155 | }); | ||
| 156 | expect((result as { readonly error: { readonly code: string } }).error.code).not.toBe( | ||
| 157 | "binding_error", | ||
| 158 | ); | ||
| 159 | }), | ||
| 160 | ); | ||
| 161 | |||
| 162 | it.effect("lets handlers catch inner tool failures and return a fallback", () => | ||
| 163 | Effect.gen(function* () { | ||
| 164 | const bundled = yield* bundle(` | ||
| 165 | import { z } from "zod"; | ||
| 166 | import { defineTool, integration } from "executor:app"; | ||
| 167 | export default defineTool({ | ||
| 168 | description: "Sync", | ||
| 169 | integrations: { crm: integration("dealcloud") }, | ||
| 170 | input: z.object({}), | ||
| 171 | async handler(_input, { crm }) { | ||
| 172 | try { | ||
| 173 | await crm.deals.list({}); | ||
| 174 | return { fallback: false }; | ||
| 175 | } catch { | ||
| 176 | return { fallback: true }; | ||
| 177 | } | ||
| 178 | }, | ||
| 179 | }); | ||
| 180 | `); | ||
| 181 | const plugin = makeAppsPlugin({ executor: makeInProcessAppToolExecutor() }); | ||
| 182 | const result = yield* plugin.invokeTool!({ | ||
| 183 | ctx: invokeCtx({ | ||
| 184 | bundle: bundled.code, | ||
| 185 | execute: () => | ||
| 186 | Effect.succeed( | ||
| 187 | ToolResult.fail({ code: "upstream_failed", message: "CRM unavailable" }), | ||
| 188 | ), | ||
| 189 | }), | ||
| 190 | toolRow: { name: "sync" }, | ||
| 191 | args: { crm: "tools.dealcloud.org.main" }, | ||
| 192 | } as never); | ||
| 193 | expect(result).toEqual({ fallback: true }); | ||
| 194 | }), | ||
| 195 | ); | ||
| 196 | }); | ||
| @@ -1,5 +1,5 @@ | |||
| 1 | import { Data, Effect } from "effect"; | 1 | import { Data, Effect, Predicate } from "effect"; |
| 2 | import { ToolName, definePlugin, type PluginCtx, type ToolDef } from "@executor-js/sdk"; | 2 | import { ToolName, ToolResult, definePlugin, type PluginCtx, type ToolDef } from "@executor-js/sdk"; |
| 3 | 3 | ||
| 4 | import { makeInProcessAppToolExecutor, type AppToolExecutor } from "../executor/app-tool-executor"; | 4 | import { makeInProcessAppToolExecutor, type AppToolExecutor } from "../executor/app-tool-executor"; |
| 5 | import { publish } from "../pipeline/publish"; | 5 | import { publish } from "../pipeline/publish"; |
| @@ -19,6 +19,43 @@ interface ProjectedToolSchema { · 13 unmodified lines | |||
| 19 | readonly outputSchema?: unknown; | 19 | readonly outputSchema?: unknown; |
| 20 | } | 20 | } |
| 21 | 21 | ||
| 22 | const innerToolError = ( | ||
| 23 | cause: unknown, | ||
| 24 | ): { readonly address: string; readonly innerMessage: string; readonly code?: string } | null => { | ||
| 25 | const direct = | ||
| 26 | Predicate.isTagged("AppInnerToolError")(cause) && typeof cause === "object" && cause !== null | ||
| 27 | ? (cause as { | ||
| 28 | readonly address?: unknown; | ||
| 29 | readonly innerMessage?: unknown; | ||
| 30 | readonly code?: unknown; | ||
| 31 | }) | ||
| 32 | : null; | ||
| 33 | const nested = | ||
| 34 | direct === null && | ||
| 35 | cause !== null && | ||
| 36 | typeof cause === "object" && | ||
| 37 | "cause" in cause && | ||
| 38 | Predicate.isTagged("AppInnerToolError")(cause.cause) | ||
| 39 | ? (cause.cause as { | ||
| 40 | readonly address?: unknown; | ||
| 41 | readonly innerMessage?: unknown; | ||
| 42 | readonly code?: unknown; | ||
| 43 | }) | ||
| 44 | : direct; | ||
| 45 | if ( | ||
| 46 | nested === null || | ||
| 47 | typeof nested.address !== "string" || | ||
| 48 | typeof nested.innerMessage !== "string" | ||
| 49 | ) { | ||
| 50 | return null; | ||
| 51 | } | ||
| 52 | return { | ||
| 53 | address: nested.address, | ||
| 54 | innerMessage: nested.innerMessage, | ||
| 55 | ...(typeof nested.code === "string" ? { code: nested.code } : {}), | ||
| 56 | }; | ||
| 57 | }; | ||
| 58 | |||
| 22 | const makeAppsExtension = (ctx: PluginCtx<AppsStore>, executor?: AppToolExecutor) => ({ | 59 | const makeAppsExtension = (ctx: PluginCtx<AppsStore>, executor?: AppToolExecutor) => ({ |
| 23 | publish: (input: Parameters<typeof publish>[1]) => | 60 | publish: (input: Parameters<typeof publish>[1]) => |
| 24 | publish({ store: ctx.storage, executor: executor ?? makeInProcessAppToolExecutor() }, input), | 61 | publish({ store: ctx.storage, executor: executor ?? makeInProcessAppToolExecutor() }, input), |
| @@ -92,14 +129,28 @@ export const makeAppsPlugin = (options?: { readonly executor?: AppToolExecutor } · 67 unmodified lines | |||
| 92 | resolver, | 129 | resolver, |
| 93 | invokeOptions, | 130 | invokeOptions, |
| 94 | }); | 131 | }); |
| 95 | const result = yield* (options?.executor ?? makeInProcessAppToolExecutor()).invoke( | 132 | const result = yield* (options?.executor ?? makeInProcessAppToolExecutor()) |
| 96 | bundle, | 133 | .invoke( |
| 97 | { toolName: tool.name }, | 134 | bundle, |
| 98 | { ...bindings.input, ...bindings.bindings }, | 135 | { toolName: tool.name }, |
| 99 | bridge, | 136 | { ...bindings.input, ...bindings.bindings }, |
| 100 | { timeoutMs: 30_000 }, | 137 | bridge, |
| 101 | ); | 138 | { timeoutMs: 30_000 }, |
| 102 | return result.output; | 139 | ) |
| 140 | .pipe( | ||
| 141 | Effect.catch((cause: unknown) => { | ||
| 142 | const inner = innerToolError(cause); | ||
| 143 | return inner | ||
| 144 | ? Effect.succeed( | ||
| 145 | ToolResult.fail({ | ||
| 146 | code: inner.code ?? "inner_tool_error", | ||
| 147 | message: `Inner tool ${inner.address} failed: "${inner.innerMessage}"`, | ||
| 148 | }), | ||
| 149 | ) | ||
| 150 | : Effect.fail(cause); | ||
| 151 | }), | ||
| 152 | ); | ||
| 153 | return "output" in result ? result.output : result; | ||
| 103 | }), | 154 | }), |
| 104 | }))(); | 155 | }))(); |
| 105 | 156 | ||
| @@ -11,6 +11,15 @@ export class BindingError extends Data.TaggedError("BindingError")<{ · 10 unmodified lines | |||
| 11 | readonly requestedConnection?: string; | 11 | readonly requestedConnection?: string; |
| 12 | }> {} | 12 | }> {} |
| 13 | 13 | ||
| 14 | export class AppInnerToolError extends Data.TaggedError("AppInnerToolError")<{ | ||
| 15 | readonly address: string; | ||
| 16 | readonly innerMessage: string; | ||
| 17 | readonly code?: string; | ||
| 18 | }> {} | ||
| 19 | |||
| 20 | export const appInnerToolFailureMessage = (error: AppInnerToolError): string => | ||
| 21 | `Inner tool ${error.address} failed: "${error.innerMessage}"`; | ||
| 22 | |||
| 14 | export interface ConnectionCandidate { | 23 | export interface ConnectionCandidate { |
| 15 | readonly address: string; | 24 | readonly address: string; |
| 16 | readonly integration: string; | 25 | readonly integration: string; |
| @@ -29,7 +38,7 @@ export interface ClientResolver { · 12 unmodified lines | |||
| 29 | readonly path: readonly string[]; | 38 | readonly path: readonly string[]; |
| 30 | readonly args: unknown; | 39 | readonly args: unknown; |
| 31 | readonly invokeOptions?: InvokeOptions; | 40 | readonly invokeOptions?: InvokeOptions; |
| 32 | }) => Effect.Effect<unknown, BindingError>; | 41 | }) => Effect.Effect<unknown, BindingError | AppInnerToolError>; |
| 33 | } | 42 | } |
| 34 | 43 | ||
| 35 | export interface ResolvedBindings { | 44 | export interface ResolvedBindings { |
| @@ -1,15 +1,21 @@ | |||
| 1 | import { Effect } from "effect"; | 1 | import { Effect, Predicate } from "effect"; |
| 2 | import { | 2 | import { |
| 3 | ConnectionName, | 3 | ConnectionName, |
| 4 | IntegrationSlug, | 4 | IntegrationSlug, |
| 5 | ToolAddress, | 5 | ToolAddress, |
| 6 | isToolResult, | 6 | isToolResult, |
| 7 | type ExecuteError, | ||
| 7 | type ConnectionRef, | 8 | type ConnectionRef, |
| 8 | type Owner, | 9 | type Owner, |
| 9 | type PluginCtx, | 10 | type PluginCtx, |
| 10 | } from "@executor-js/sdk"; | 11 | } from "@executor-js/sdk"; |
| 11 | 12 | ||
| 12 | import { BindingError, type ClientResolver, type ConnectionCandidate } from "./bindings"; | 13 | import { |
| 14 | AppInnerToolError, | ||
| 15 | BindingError, | ||
| 16 | type ClientResolver, | ||
| 17 | type ConnectionCandidate, | ||
| 18 | } from "./bindings"; | ||
| 13 | 19 | ||
| 14 | export interface AppsResolverPluginCtx { | 20 | export interface AppsResolverPluginCtx { |
| 15 | readonly connections: Pick<PluginCtx["connections"], "list" | "get">; | 21 | readonly connections: Pick<PluginCtx["connections"], "list" | "get">; |
| @@ -44,6 +50,34 @@ const toCandidate = (connection: { · 28 unmodified lines | |||
| 44 | integration: String(connection.integration), | 50 | integration: String(connection.integration), |
| 45 | }); | 51 | }); |
| 46 | 52 | ||
| 53 | const innerMessageFromCause = (cause: ExecuteError): string => { | ||
| 54 | if ( | ||
| 55 | Predicate.isTagged("ToolInvocationError")(cause) || | ||
| 56 | Predicate.isTagged("CredentialResolutionError")(cause) || | ||
| 57 | Predicate.isTagged("StorageError")(cause) | ||
| 58 | ) { | ||
| 59 | // oxlint-disable-next-line executor/no-unknown-error-message -- typed ExecuteError variants expose message as caller-facing failure text | ||
| 60 | return cause.message; | ||
| 61 | } | ||
| 62 | if (Predicate.isTagged("ToolNotFoundError")(cause)) return `Tool not found: ${cause.address}`; | ||
| 63 | if (Predicate.isTagged("ToolBlockedError")(cause)) return `Tool blocked: ${cause.address}`; | ||
| 64 | if (Predicate.isTagged("PluginNotLoadedError")(cause)) { | ||
| 65 | return `Plugin not loaded for tool: ${cause.address}`; | ||
| 66 | } | ||
| 67 | if (Predicate.isTagged("NoHandlerError")(cause)) return `No handler for tool: ${cause.address}`; | ||
| 68 | if (Predicate.isTagged("ConnectionNotFoundError")(cause)) { | ||
| 69 | return `Connection not found: ${cause.integration}.${cause.owner}.${cause.name}`; | ||
| 70 | } | ||
| 71 | if (Predicate.isTagged("CredentialProviderNotRegisteredError")(cause)) { | ||
| 72 | return `Credential provider not registered: ${cause.provider}`; | ||
| 73 | } | ||
| 74 | if (Predicate.isTagged("ElicitationDeclinedError")(cause)) { | ||
| 75 | return `Tool approval ${cause.action === "cancel" ? "cancelled" : "declined"}: ${cause.address}`; | ||
| 76 | } | ||
| 77 | if (Predicate.isTagged("UniqueViolationError")(cause)) return "storage unique violation"; | ||
| 78 | return "inner tool failed"; | ||
| 79 | }; | ||
| 80 | |||
| 47 | export const makePluginCtxAppsResolver = (input: { | 81 | export const makePluginCtxAppsResolver = (input: { |
| 48 | readonly ctx: AppsResolverPluginCtx; | 82 | readonly ctx: AppsResolverPluginCtx; |
| 49 | }): ClientResolver => ({ | 83 | }): ClientResolver => ({ |
| @@ -98,22 +132,19 @@ export const makePluginCtxAppsResolver = (input: { · 48 unmodified lines | |||
| 98 | const address = ToolAddress.make(`${connection}.${path.join(".")}`); | 132 | const address = ToolAddress.make(`${connection}.${path.join(".")}`); |
| 99 | const result = yield* input.ctx.execute(address, args, invokeOptions).pipe( | 133 | const result = yield* input.ctx.execute(address, args, invokeOptions).pipe( |
| 100 | Effect.mapError( | 134 | Effect.mapError( |
| 101 | () => | 135 | (cause) => |
| 102 | new BindingError({ | 136 | new AppInnerToolError({ |
| 103 | role: integration, | 137 | address, |
| 104 | integration, | 138 | innerMessage: innerMessageFromCause(cause), |
| 105 | requestedConnection: connection, | ||
| 106 | message: `failed to execute ${address}`, | ||
| 107 | }), | 139 | }), |
| 108 | ), | 140 | ), |
| 109 | ); | 141 | ); |
| 110 | if (isToolResult(result)) { | 142 | if (isToolResult(result)) { |
| 111 | if (result.ok) return result.data; | 143 | if (result.ok) return result.data; |
| 112 | return yield* new BindingError({ | 144 | return yield* new AppInnerToolError({ |
| 113 | role: integration, | 145 | address, |
| 114 | integration, | 146 | code: result.error.code, |
| 115 | requestedConnection: connection, | 147 | innerMessage: result.error.message, |
| 116 | message: result.error.message, | ||
| 117 | }); | 148 | }); |
| 118 | } | 149 | } |
| 119 | return result; | 150 | return result; |
| @@ -139,7 +139,6 @@ const descriptor = (tools: readonly AppDescriptor["tools"][number][]): AppDescri · 138 unmodified lines | |||
| 139 | version: 6, | 139 | version: 6, |
| 140 | app: "crm", | 140 | app: "crm", |
| 141 | sourceRef: "sha-1", | 141 | sourceRef: "sha-1", |
| 142 | descriptorKey: "descriptor", | ||
| 143 | publishedAt: 1, | 142 | publishedAt: 1, |
| 144 | toolchain: { | 143 | toolchain: { |
| 145 | bundler: { name: "esbuild", version: "0" }, | 144 | bundler: { name: "esbuild", version: "0" }, |
| @@ -169,8 +168,13 @@ describe("apps store", () => { · 23 unmodified lines | |||
| 169 | blobs: pluginBlobStore(makeInMemoryBlobStore(), { org: "tenant", user: null }, "apps"), | 168 | blobs: pluginBlobStore(makeInMemoryBlobStore(), { org: "tenant", user: null }, "apps"), |
| 170 | pluginStorage: makeMemoryPluginStorage(), | 169 | pluginStorage: makeMemoryPluginStorage(), |
| 171 | }); | 170 | }); |
| 172 | yield* store.putPublished(descriptor([tool("first"), tool("second")]), "org", null); | 171 | yield* store.putPublished( |
| 173 | yield* store.putPublished(descriptor([tool("first")]), "org", "sha-1"); | 172 | descriptor([tool("first"), tool("second")]), |
| 173 | "descriptor", | ||
| 174 | "org", | ||
| 175 | null, | ||
| 176 | ); | ||
| 177 | yield* store.putPublished(descriptor([tool("first")]), "descriptor-2", "org", "sha-1"); | ||
| 174 | const active = yield* store.listActiveTools(); | 178 | const active = yield* store.listActiveTools(); |
| 175 | expect(active.map((item) => item.name)).toEqual(["first"]); | 179 | expect(active.map((item) => item.name)).toEqual(["first"]); |
| 176 | }), | 180 | }), |
| @@ -59,6 +59,7 @@ export interface AppsStore { · 58 unmodified lines | |||
| 59 | >; | 59 | >; |
| 60 | readonly putPublished: ( | 60 | readonly putPublished: ( |
| 61 | descriptor: AppDescriptor, | 61 | descriptor: AppDescriptor, |
| 62 | descriptorKey: string, | ||
| 62 | owner: Owner, | 63 | owner: Owner, |
| 63 | expectedSourceRef: string | null, | 64 | expectedSourceRef: string | null, |
| 64 | ) => Effect.Effect<void, StorageFailure | AppPublishConflictError>; | 65 | ) => Effect.Effect<void, StorageFailure | AppPublishConflictError>; |
| @@ -113,7 +114,7 @@ export const makeAppsStore = (input: { · 48 unmodified lines | |||
| 113 | : null, | 114 | : null, |
| 114 | ), | 115 | ), |
| 115 | ), | 116 | ), |
| 116 | putPublished: (descriptor, owner, expectedSourceRef) => | 117 | putPublished: (descriptor, descriptorKey, owner, expectedSourceRef) => |
| 117 | Effect.gen(function* () { | 118 | Effect.gen(function* () { |
| 118 | const current = yield* descriptors.get({ key: descriptor.app }); | 119 | const current = yield* descriptors.get({ key: descriptor.app }); |
| 119 | const actualSourceRef = current?.data.sourceRef ?? null; | 120 | const actualSourceRef = current?.data.sourceRef ?? null; |
| @@ -134,7 +135,7 @@ export const makeAppsStore = (input: { · 14 unmodified lines | |||
| 134 | app: descriptor.app, | 135 | app: descriptor.app, |
| 135 | name: tool.name, | 136 | name: tool.name, |
| 136 | sourceRef: descriptor.sourceRef, | 137 | sourceRef: descriptor.sourceRef, |
| 137 | descriptorKey: descriptor.descriptorKey, | 138 | descriptorKey, |
| 138 | bundleKey: tool.bundleKey, | 139 | bundleKey: tool.bundleKey, |
| 139 | description: tool.description, | 140 | description: tool.description, |
| 140 | inputSchema: tool.inputSchema, | 141 | inputSchema: tool.inputSchema, |
| @@ -161,7 +162,7 @@ export const makeAppsStore = (input: { · 20 unmodified lines | |||
| 161 | data: { | 162 | data: { |
| 162 | app: descriptor.app, | 163 | app: descriptor.app, |
| 163 | sourceRef: descriptor.sourceRef, | 164 | sourceRef: descriptor.sourceRef, |
| 164 | descriptorKey: descriptor.descriptorKey, | 165 | descriptorKey, |
| 165 | publishedAt: descriptor.publishedAt, | 166 | publishedAt: descriptor.publishedAt, |
| 166 | toolchain: descriptor.toolchain, | 167 | toolchain: descriptor.toolchain, |
| 167 | }, | 168 | }, |