Unified · Split
| @@ -1,6 +1,6 @@ | ||
| 1 | 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 | 2 | import { Buffer } from "node:buffer"; |
| 3 | −import { Data, Effect } from "effect"; | |
| 3 | +import { Data, Effect, Predicate } from "effect"; | |
| 4 | 4 | import { z } from "zod"; |
| 5 | 5 | |
| 6 | 6 | import { validToolKey } from "../pipeline/discover"; |
| @@ -126,6 +126,13 @@ const jsonSchemaFor = (schema: unknown): unknown => { · 119 unmodified lines | ||
| 126 | 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 | 136 | type CollectedIntegrationDecl = CollectedTool["integrations"][string]; |
| 130 | 137 | |
| 131 | 138 | const integrationDeclaration = (value: unknown): CollectedIntegrationDecl | null => { |
| @@ -452,6 +459,10 @@ export const makeInProcessAppToolExecutor = (): AppToolExecutor => ({ · 320 unmodified lines | ||
| 452 | 459 | catch: (cause) => |
| 453 | 460 | cause instanceof AppExecutorError |
| 454 | 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 | 48 | readonly version: typeof DESCRIPTOR_VERSION; |
| 49 | 49 | readonly app: string; |
| 50 | 50 | readonly sourceRef: string; |
| 51 | − readonly descriptorKey: string; | |
| 52 | 51 | readonly publishedAt: number; |
| 53 | 52 | readonly toolchain: ToolchainRef; |
| 54 | 53 | readonly tools: readonly ToolDescriptor[]; |
| @@ -28,6 +28,7 @@ const makeMemoryStore = (): AppsStore & { · 27 unmodified lines | ||
| 28 | 28 | const blobs = new Map<string, string>(); |
| 29 | 29 | const rows = new Map<string, unknown>(); |
| 30 | 30 | let descriptor: AppDescriptor | null = null; |
| 31 | + let descriptorKey: string | null = null; | |
| 31 | 32 | return { |
| 32 | 33 | rows, |
| 33 | 34 | blobs, |
| @@ -40,11 +41,9 @@ const makeMemoryStore = (): AppsStore & { · 6 unmodified lines | ||
| 40 | 41 | getBlob: (key) => Effect.sync(() => blobs.get(key) ?? null), |
| 41 | 42 | getDescriptorRecord: () => |
| 42 | 43 | Effect.sync(() => |
| 43 | − descriptor | |
| 44 | − ? { sourceRef: descriptor.sourceRef, descriptorKey: descriptor.descriptorKey } | |
| 45 | − : null, | |
| 44 | + descriptor ? { sourceRef: descriptor.sourceRef, descriptorKey: descriptorKey ?? "" } : null, | |
| 46 | 45 | ), |
| 47 | − putPublished: (next, _owner, expectedSourceRef) => | |
| 46 | + putPublished: (next, nextDescriptorKey, _owner, expectedSourceRef) => | |
| 48 | 47 | Effect.gen(function* () { |
| 49 | 48 | const actualSourceRef = descriptor?.sourceRef ?? null; |
| 50 | 49 | if (actualSourceRef !== expectedSourceRef) { |
| @@ -55,6 +54,7 @@ const makeMemoryStore = (): AppsStore & { · 4 unmodified lines | ||
| 55 | 54 | }); |
| 56 | 55 | } |
| 57 | 56 | descriptor = next; |
| 57 | + descriptorKey = nextDescriptorKey; | |
| 58 | 58 | for (const key of rows.keys()) rows.delete(key); |
| 59 | 59 | for (const item of next.tools) rows.set(item.name, item); |
| 60 | 60 | }), |
| @@ -132,6 +132,7 @@ describe("publish", () => { · 71 unmodified lines | ||
| 132 | 132 | expect(result.publishedTools).toEqual(["sync"]); |
| 133 | 133 | expect(result.skipped).toEqual([{ path: "workflows/later.ts", reason: "not supported yet" }]); |
| 134 | 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 | 184 | version: DESCRIPTOR_VERSION, |
| 185 | 185 | app: input.app, |
| 186 | 186 | sourceRef: input.sourceRef, |
| 187 | − descriptorKey: "", | |
| 188 | 187 | publishedAt: deps.now?.() ?? Date.now(), |
| 189 | 188 | toolchain: toolchainRef(), |
| 190 | 189 | tools, |
| @@ -193,49 +192,38 @@ export const publish = ( · 2 unmodified lines | ||
| 193 | 192 | skills: discovered.skipped.filter((item) => item.path.startsWith("skills/")), |
| 194 | 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 | 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 | 197 | Effect.mapError( |
| 211 | 198 | () => |
| 212 | 199 | new PublishError({ |
| 213 | 200 | stage: "project", |
| 214 | − message: "failed to write final descriptor blob", | |
| 201 | + message: "failed to write descriptor blob", | |
| 215 | 202 | diagnostics: [], |
| 216 | 203 | }), |
| 217 | 204 | ), |
| 218 | 205 | ); |
| 219 | − const finalDescriptor: AppDescriptor = { ...descriptor, descriptorKey: finalDescriptorKey }; | |
| 220 | − yield* deps.store.putPublished(finalDescriptor, owner, existing?.sourceRef ?? null).pipe( | |
| 221 | − Effect.mapError((cause) => { | |
| 222 | − if (Predicate.isTagged("AppPublishConflictError")(cause)) { | |
| 206 | + yield* deps.store | |
| 207 | + .putPublished(descriptor, descriptorKey, owner, existing?.sourceRef ?? null) | |
| 208 | + .pipe( | |
| 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 | 217 | return new PublishError({ |
| 224 | 218 | stage: "project", |
| 225 | − message: `app "${input.app}" changed during publish`, | |
| 226 | − diagnostics: [{ path: "", message: "publish sourceRef changed during publish" }], | |
| 219 | + message: "failed to persist app publication", | |
| 220 | + diagnostics: [], | |
| 227 | 221 | }); |
| 228 | − } | |
| 229 | − return new PublishError({ | |
| 230 | − stage: "project", | |
| 231 | − message: "failed to persist app publication", | |
| 232 | − diagnostics: [], | |
| 233 | − }); | |
| 234 | − }), | |
| 235 | − ); | |
| 222 | + }), | |
| 223 | + ); | |
| 236 | 224 | return { |
| 237 | − descriptor: finalDescriptor, | |
| 238 | − publishedTools: finalDescriptor.tools.map((tool) => tool.name), | |
| 225 | + descriptor, | |
| 226 | + publishedTools: descriptor.tools.map((tool) => tool.name), | |
| 239 | 227 | skipped: discovered.skipped, |
| 240 | 228 | noop: false, |
| 241 | 229 | }; |
| @@ -1,9 +1,60 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 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 | 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 | 58 | describe("apps plugin schema projection", () => { |
| 8 | 59 | it.effect("narrows synthesized integration fields to connection address enums", () => |
| 9 | 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"; | |
| 2 | −import { ToolName, definePlugin, type PluginCtx, type ToolDef } from "@executor-js/sdk"; | |
| 1 | +import { Data, Effect, Predicate } from "effect"; | |
| 2 | +import { ToolName, ToolResult, definePlugin, type PluginCtx, type ToolDef } from "@executor-js/sdk"; | |
| 3 | 3 | |
| 4 | 4 | import { makeInProcessAppToolExecutor, type AppToolExecutor } from "../executor/app-tool-executor"; |
| 5 | 5 | import { publish } from "../pipeline/publish"; |
| @@ -19,6 +19,43 @@ interface ProjectedToolSchema { · 13 unmodified lines | ||
| 19 | 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 | 59 | const makeAppsExtension = (ctx: PluginCtx<AppsStore>, executor?: AppToolExecutor) => ({ |
| 23 | 60 | publish: (input: Parameters<typeof publish>[1]) => |
| 24 | 61 | publish({ store: ctx.storage, executor: executor ?? makeInProcessAppToolExecutor() }, input), |
| @@ -92,14 +129,28 @@ export const makeAppsPlugin = (options?: { readonly executor?: AppToolExecutor } · 67 unmodified lines | ||
| 92 | 129 | resolver, |
| 93 | 130 | invokeOptions, |
| 94 | 131 | }); |
| 95 | − const result = yield* (options?.executor ?? makeInProcessAppToolExecutor()).invoke( | |
| 96 | − bundle, | |
| 97 | − { toolName: tool.name }, | |
| 98 | − { ...bindings.input, ...bindings.bindings }, | |
| 99 | − bridge, | |
| 100 | − { timeoutMs: 30_000 }, | |
| 101 | − ); | |
| 102 | − return result.output; | |
| 132 | + const result = yield* (options?.executor ?? makeInProcessAppToolExecutor()) | |
| 133 | + .invoke( | |
| 134 | + bundle, | |
| 135 | + { toolName: tool.name }, | |
| 136 | + { ...bindings.input, ...bindings.bindings }, | |
| 137 | + bridge, | |
| 138 | + { timeoutMs: 30_000 }, | |
| 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 | 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 | 23 | export interface ConnectionCandidate { |
| 15 | 24 | readonly address: string; |
| 16 | 25 | readonly integration: string; |
| @@ -29,7 +38,7 @@ export interface ClientResolver { · 12 unmodified lines | ||
| 29 | 38 | readonly path: readonly string[]; |
| 30 | 39 | readonly args: unknown; |
| 31 | 40 | readonly invokeOptions?: InvokeOptions; |
| 32 | − }) => Effect.Effect<unknown, BindingError>; | |
| 41 | + }) => Effect.Effect<unknown, BindingError | AppInnerToolError>; | |
| 33 | 42 | } |
| 34 | 43 | |
| 35 | 44 | export interface ResolvedBindings { |
| @@ -1,15 +1,21 @@ | ||
| 1 | −import { Effect } from "effect"; | |
| 1 | +import { Effect, Predicate } from "effect"; | |
| 2 | 2 | import { |
| 3 | 3 | ConnectionName, |
| 4 | 4 | IntegrationSlug, |
| 5 | 5 | ToolAddress, |
| 6 | 6 | isToolResult, |
| 7 | + type ExecuteError, | |
| 7 | 8 | type ConnectionRef, |
| 8 | 9 | type Owner, |
| 9 | 10 | type PluginCtx, |
| 10 | 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 | 20 | export interface AppsResolverPluginCtx { |
| 15 | 21 | readonly connections: Pick<PluginCtx["connections"], "list" | "get">; |
| @@ -44,6 +50,34 @@ const toCandidate = (connection: { · 28 unmodified lines | ||
| 44 | 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 | 81 | export const makePluginCtxAppsResolver = (input: { |
| 48 | 82 | readonly ctx: AppsResolverPluginCtx; |
| 49 | 83 | }): ClientResolver => ({ |
| @@ -98,22 +132,19 @@ export const makePluginCtxAppsResolver = (input: { · 48 unmodified lines | ||
| 98 | 132 | const address = ToolAddress.make(`${connection}.${path.join(".")}`); |
| 99 | 133 | const result = yield* input.ctx.execute(address, args, invokeOptions).pipe( |
| 100 | 134 | Effect.mapError( |
| 101 | − () => | |
| 102 | − new BindingError({ | |
| 103 | − role: integration, | |
| 104 | − integration, | |
| 105 | − requestedConnection: connection, | |
| 106 | − message: `failed to execute ${address}`, | |
| 135 | + (cause) => | |
| 136 | + new AppInnerToolError({ | |
| 137 | + address, | |
| 138 | + innerMessage: innerMessageFromCause(cause), | |
| 107 | 139 | }), |
| 108 | 140 | ), |
| 109 | 141 | ); |
| 110 | 142 | if (isToolResult(result)) { |
| 111 | 143 | if (result.ok) return result.data; |
| 112 | − return yield* new BindingError({ | |
| 113 | − role: integration, | |
| 114 | − integration, | |
| 115 | − requestedConnection: connection, | |
| 116 | − message: result.error.message, | |
| 144 | + return yield* new AppInnerToolError({ | |
| 145 | + address, | |
| 146 | + code: result.error.code, | |
| 147 | + innerMessage: result.error.message, | |
| 117 | 148 | }); |
| 118 | 149 | } |
| 119 | 150 | return result; |
| @@ -139,7 +139,6 @@ const descriptor = (tools: readonly AppDescriptor["tools"][number][]): AppDescri · 138 unmodified lines | ||
| 139 | 139 | version: 6, |
| 140 | 140 | app: "crm", |
| 141 | 141 | sourceRef: "sha-1", |
| 142 | − descriptorKey: "descriptor", | |
| 143 | 142 | publishedAt: 1, |
| 144 | 143 | toolchain: { |
| 145 | 144 | bundler: { name: "esbuild", version: "0" }, |
| @@ -169,8 +168,13 @@ describe("apps store", () => { · 23 unmodified lines | ||
| 169 | 168 | blobs: pluginBlobStore(makeInMemoryBlobStore(), { org: "tenant", user: null }, "apps"), |
| 170 | 169 | pluginStorage: makeMemoryPluginStorage(), |
| 171 | 170 | }); |
| 172 | − yield* store.putPublished(descriptor([tool("first"), tool("second")]), "org", null); | |
| 173 | − yield* store.putPublished(descriptor([tool("first")]), "org", "sha-1"); | |
| 171 | + yield* store.putPublished( | |
| 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 | 178 | const active = yield* store.listActiveTools(); |
| 175 | 179 | expect(active.map((item) => item.name)).toEqual(["first"]); |
| 176 | 180 | }), |
| @@ -59,6 +59,7 @@ export interface AppsStore { · 58 unmodified lines | ||
| 59 | 59 | >; |
| 60 | 60 | readonly putPublished: ( |
| 61 | 61 | descriptor: AppDescriptor, |
| 62 | + descriptorKey: string, | |
| 62 | 63 | owner: Owner, |
| 63 | 64 | expectedSourceRef: string | null, |
| 64 | 65 | ) => Effect.Effect<void, StorageFailure | AppPublishConflictError>; |
| @@ -113,7 +114,7 @@ export const makeAppsStore = (input: { · 48 unmodified lines | ||
| 113 | 114 | : null, |
| 114 | 115 | ), |
| 115 | 116 | ), |
| 116 | − putPublished: (descriptor, owner, expectedSourceRef) => | |
| 117 | + putPublished: (descriptor, descriptorKey, owner, expectedSourceRef) => | |
| 117 | 118 | Effect.gen(function* () { |
| 118 | 119 | const current = yield* descriptors.get({ key: descriptor.app }); |
| 119 | 120 | const actualSourceRef = current?.data.sourceRef ?? null; |
| @@ -134,7 +135,7 @@ export const makeAppsStore = (input: { · 14 unmodified lines | ||
| 134 | 135 | app: descriptor.app, |
| 135 | 136 | name: tool.name, |
| 136 | 137 | sourceRef: descriptor.sourceRef, |
| 137 | − descriptorKey: descriptor.descriptorKey, | |
| 138 | + descriptorKey, | |
| 138 | 139 | bundleKey: tool.bundleKey, |
| 139 | 140 | description: tool.description, |
| 140 | 141 | inputSchema: tool.inputSchema, |
| @@ -161,7 +162,7 @@ export const makeAppsStore = (input: { · 20 unmodified lines | ||
| 161 | 162 | data: { |
| 162 | 163 | app: descriptor.app, |
| 163 | 164 | sourceRef: descriptor.sourceRef, |
| 164 | − descriptorKey: descriptor.descriptorKey, | |
| 165 | + descriptorKey, | |
| 165 | 166 | publishedAt: descriptor.publishedAt, |
| 166 | 167 | toolchain: descriptor.toolchain, |
| 167 | 168 | }, |