Unified · Split
| @@ -6,7 +6,6 @@ import { · 5 unmodified lines | |||
| 6 | ToolAddress, | 6 | ToolAddress, |
| 7 | isToolResult, | 7 | isToolResult, |
| 8 | type ConnectionRef, | 8 | type ConnectionRef, |
| 9 | type ExecuteError, | ||
| 10 | type Owner, | 9 | type Owner, |
| 11 | type PluginCtx, | 10 | type PluginCtx, |
| 12 | } from "@executor-js/sdk"; | 11 | } from "@executor-js/sdk"; |
| @@ -29,22 +28,6 @@ const parseConnectionAddress = (address: string): ConnectionRef | null => { · 16 unmodified lines | |||
| 29 | }; | 28 | }; |
| 30 | }; | 29 | }; |
| 31 | 30 | ||
| 32 | const bindingError = (input: { | ||
| 33 | readonly integration: string; | ||
| 34 | readonly connection?: string; | ||
| 35 | readonly message: string; | ||
| 36 | readonly cause?: unknown; | ||
| 37 | }): BindingError => | ||
| 38 | new BindingError({ | ||
| 39 | role: input.integration, | ||
| 40 | integration: input.integration, | ||
| 41 | requestedConnection: input.connection, | ||
| 42 | message: input.message, | ||
| 43 | }); | ||
| 44 | |||
| 45 | const executeErrorMessage = (cause: ExecuteError): string => | ||
| 46 | typeof cause.message === "string" && cause.message.length > 0 ? cause.message : cause._tag; | ||
| 47 | |||
| 48 | const toCandidate = (connection: { | 31 | const toCandidate = (connection: { |
| 49 | readonly address: unknown; | 32 | readonly address: unknown; |
| 50 | readonly integration: unknown; | 33 | readonly integration: unknown; |
| @@ -61,12 +44,13 @@ export const makeSelfHostAppsResolver = (input: { readonly ctx: PluginCtx }): Cl · 10 unmodified lines | |||
| 61 | listConnections: ({ integration }) => | 44 | listConnections: ({ integration }) => |
| 62 | input.ctx.connections.list({ integration: IntegrationSlug.make(integration) }).pipe( | 45 | input.ctx.connections.list({ integration: IntegrationSlug.make(integration) }).pipe( |
| 63 | Effect.map((connections) => connections.map(toCandidate)), | 46 | Effect.map((connections) => connections.map(toCandidate)), |
| 64 | Effect.mapError((cause) => | 47 | Effect.mapError( |
| 65 | bindingError({ | 48 | () => |
| 66 | integration, | 49 | new BindingError({ |
| 67 | message: `failed to list ${integration} connections`, | 50 | role: integration, |
| 68 | cause, | 51 | integration, |
| 69 | }), | 52 | message: `failed to list ${integration} connections`, |
| 53 | }), | ||
| 70 | ), | 54 | ), |
| 71 | ), | 55 | ), |
| 72 | 56 | ||
| @@ -75,13 +59,14 @@ export const makeSelfHostAppsResolver = (input: { readonly ctx: PluginCtx }): Cl · 2 unmodified lines | |||
| 75 | const ref = parseConnectionAddress(connection); | 59 | const ref = parseConnectionAddress(connection); |
| 76 | if (!ref) return null; | 60 | if (!ref) return null; |
| 77 | const row = yield* input.ctx.connections.get(ref).pipe( | 61 | const row = yield* input.ctx.connections.get(ref).pipe( |
| 78 | Effect.mapError((cause) => | 62 | Effect.mapError( |
| 79 | bindingError({ | 63 | () => |
| 80 | integration: String(ref.integration), | 64 | new BindingError({ |
| 81 | connection, | 65 | role: String(ref.integration), |
| 82 | message: `failed to resolve connection ${connection}`, | 66 | integration: String(ref.integration), |
| 83 | cause, | 67 | requestedConnection: connection, |
| 84 | }), | 68 | message: `failed to resolve connection ${connection}`, |
| 69 | }), | ||
| 85 | ), | 70 | ), |
| 86 | ); | 71 | ); |
| 87 | return row ? toCandidate(row) : null; | 72 | return row ? toCandidate(row) : null; |
| @@ -91,16 +76,18 @@ export const makeSelfHostAppsResolver = (input: { readonly ctx: PluginCtx }): Cl · 3 unmodified lines | |||
| 91 | Effect.gen(function* () { | 76 | Effect.gen(function* () { |
| 92 | const ref = parseConnectionAddress(connection); | 77 | const ref = parseConnectionAddress(connection); |
| 93 | if (!ref) { | 78 | if (!ref) { |
| 94 | return yield* bindingError({ | 79 | return yield* new BindingError({ |
| 80 | role: integration, | ||
| 95 | integration, | 81 | integration, |
| 96 | connection, | 82 | requestedConnection: connection, |
| 97 | message: `invalid connection address ${connection}`, | 83 | message: `invalid connection address ${connection}`, |
| 98 | }); | 84 | }); |
| 99 | } | 85 | } |
| 100 | if (String(ref.integration) !== integration) { | 86 | if (String(ref.integration) !== integration) { |
| 101 | return yield* bindingError({ | 87 | return yield* new BindingError({ |
| 88 | role: integration, | ||
| 102 | integration, | 89 | integration, |
| 103 | connection, | 90 | requestedConnection: connection, |
| 104 | message: `connection "${connection}" belongs to integration "${ref.integration}", not "${integration}"`, | 91 | message: `connection "${connection}" belongs to integration "${ref.integration}", not "${integration}"`, |
| 105 | }); | 92 | }); |
| 106 | } | 93 | } |
| @@ -108,21 +95,24 @@ export const makeSelfHostAppsResolver = (input: { readonly ctx: PluginCtx }): Cl · 1 unmodified line | |||
| 108 | const address = ToolAddress.make(`${connection}.${tool}`); | 95 | const address = ToolAddress.make(`${connection}.${tool}`); |
| 109 | const payload = args[0] ?? {}; | 96 | const payload = args[0] ?? {}; |
| 110 | const result = yield* input.ctx.execute(address, payload, invokeOptions).pipe( | 97 | const result = yield* input.ctx.execute(address, payload, invokeOptions).pipe( |
| 111 | Effect.mapError((cause) => | 98 | Effect.mapError( |
| 112 | bindingError({ | 99 | () => |
| 113 | integration, | 100 | new BindingError({ |
| 114 | connection, | 101 | role: integration, |
| 115 | message: executeErrorMessage(cause), | 102 | integration, |
| 116 | cause, | 103 | requestedConnection: connection, |
| 117 | }), | 104 | message: `failed to execute ${address}`, |
| 105 | }), | ||
| 118 | ), | 106 | ), |
| 119 | ); | 107 | ); |
| 120 | if (isToolResult(result)) { | 108 | if (isToolResult(result)) { |
| 121 | if (result.ok) return result.data; | 109 | if (result.ok) return result.data; |
| 122 | return yield* bindingError({ | 110 | const { message } = result.error; |
| 111 | return yield* new BindingError({ | ||
| 112 | role: integration, | ||
| 123 | integration, | 113 | integration, |
| 124 | connection, | 114 | requestedConnection: connection, |
| 125 | message: result.error.message, | 115 | message, |
| 126 | }); | 116 | }); |
| 127 | } | 117 | } |
| 128 | return result; | 118 | return result; |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | 1 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 2 | import { Data, Effect, Layer } from "effect"; | 2 | import { Data, Effect, Layer, Predicate } from "effect"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | IdentityProvider, | 5 | IdentityProvider, |
| @@ -19,6 +19,9 @@ class AppsSyncRouteError extends Data.TaggedError("AppsSyncRouteError")<{ · 13 unmodified lines | |||
| 19 | readonly message: string; | 19 | readonly message: string; |
| 20 | }> {} | 20 | }> {} |
| 21 | 21 | ||
| 22 | const isAppsSyncRouteError = (failure: unknown): failure is AppsSyncRouteError => | ||
| 23 | Predicate.isTagged("AppsSyncRouteError")(failure); | ||
| 24 | |||
| 22 | const parseJson = (request: Request): Effect.Effect<unknown, AppsSyncRouteError> => | 25 | const parseJson = (request: Request): Effect.Effect<unknown, AppsSyncRouteError> => |
| 23 | Effect.tryPromise({ | 26 | Effect.tryPromise({ |
| 24 | try: () => request.json() as Promise<unknown>, | 27 | try: () => request.json() as Promise<unknown>, |
| @@ -50,29 +53,28 @@ const routeHandler = ( · 25 unmodified lines | |||
| 50 | ): Effect.Effect<HttpServerResponse.HttpServerResponse, never, unknown> => | 53 | ): Effect.Effect<HttpServerResponse.HttpServerResponse, never, unknown> => |
| 51 | Effect.gen(function* () { | 54 | Effect.gen(function* () { |
| 52 | const httpRequest = yield* HttpServerRequest.HttpServerRequest; | 55 | const httpRequest = yield* HttpServerRequest.HttpServerRequest; |
| 53 | const request = yield* HttpServerRequest.toWeb(httpRequest).pipe(Effect.orDie); | 56 | const request = yield* HttpServerRequest.toWeb(httpRequest).pipe( |
| 57 | Effect.catch(() => Effect.succeed(null)), | ||
| 58 | ); | ||
| 59 | if (request === null) { | ||
| 60 | return HttpServerResponse.text("Internal Server Error", { status: 500 }); | ||
| 61 | } | ||
| 54 | return yield* run(request).pipe( | 62 | return yield* run(request).pipe( |
| 55 | Effect.map((body) => HttpServerResponse.jsonUnsafe(body)), | 63 | Effect.map((body) => HttpServerResponse.jsonUnsafe(body)), |
| 56 | Effect.catch((error: unknown) => { | 64 | Effect.catch((failure: unknown) => { |
| 57 | const tag = | 65 | if (Predicate.isTagged("Unauthorized")(failure)) { |
| 58 | error && typeof error === "object" && "_tag" in error ? String(error._tag) : null; | ||
| 59 | if (tag === "Unauthorized") { | ||
| 60 | return Effect.succeed(HttpServerResponse.text("Unauthorized", { status: 401 })); | 66 | return Effect.succeed(HttpServerResponse.text("Unauthorized", { status: 401 })); |
| 61 | } | 67 | } |
| 62 | if (tag === "NoOrganization") { | 68 | if (Predicate.isTagged("NoOrganization")(failure)) { |
| 63 | return Effect.succeed(HttpServerResponse.text("Forbidden", { status: 403 })); | 69 | return Effect.succeed(HttpServerResponse.text("Forbidden", { status: 403 })); |
| 64 | } | 70 | } |
| 65 | if (tag === "Unavailable") { | 71 | if (Predicate.isTagged("Unavailable")(failure)) { |
| 66 | return Effect.succeed(HttpServerResponse.text("Unavailable", { status: 503 })); | 72 | return Effect.succeed(HttpServerResponse.text("Unavailable", { status: 503 })); |
| 67 | } | 73 | } |
| 68 | if (tag === "AppsSyncRouteError") { | 74 | if (isAppsSyncRouteError(failure)) { |
| 69 | const routeError = error as AppsSyncRouteError; | 75 | const message = failure.message; |
| 70 | return Effect.succeed( | 76 | const status = failure.status; |
| 71 | HttpServerResponse.jsonUnsafe( | 77 | return Effect.succeed(HttpServerResponse.jsonUnsafe({ error: message }, { status })); |
| 72 | { error: routeError.message }, | ||
| 73 | { status: routeError.status }, | ||
| 74 | ), | ||
| 75 | ); | ||
| 76 | } | 78 | } |
| 77 | return Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })); | 79 | return Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })); |
| 78 | }), | 80 | }), |
| @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; · 5 unmodified lines | |||
| 6 | import { join } from "node:path"; | 6 | import { join } from "node:path"; |
| 7 | 7 | ||
| 8 | import { afterAll, beforeAll, expect, test } from "@effect/vitest"; | 8 | import { afterAll, beforeAll, expect, test } from "@effect/vitest"; |
| 9 | import { Effect, Schema } from "effect"; | ||
| 9 | import { createEmulator, type Emulator } from "@executor-js/emulate"; | 10 | import { createEmulator, type Emulator } from "@executor-js/emulate"; |
| 10 | 11 | ||
| 11 | const dataDir = mkdtempSync(join(tmpdir(), "eh-apps-wire-")); | 12 | const dataDir = mkdtempSync(join(tmpdir(), "eh-apps-wire-")); |
| @@ -69,16 +70,30 @@ const jsonHeaders = { · 57 unmodified lines | |||
| 69 | "x-test-org": TEST_ORG, | 70 | "x-test-org": TEST_ORG, |
| 70 | }; | 71 | }; |
| 71 | 72 | ||
| 73 | const decodeJsonText = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); | ||
| 74 | |||
| 75 | const parseJsonText = async <T>(text: string): Promise<T> => { | ||
| 76 | if (text.length === 0) return null as T; | ||
| 77 | const value = await Effect.runPromise(decodeJsonText(text)); | ||
| 78 | return value as T; | ||
| 79 | }; | ||
| 80 | |||
| 72 | const findFreePort = (): Promise<number> => | 81 | const findFreePort = (): Promise<number> => |
| 73 | new Promise((resolve, reject) => { | 82 | Effect.runPromise( |
| 74 | const probe = createNetServer(); | 83 | Effect.callback<number, string>((resume) => { |
| 75 | probe.once("error", reject); | 84 | const probe = createNetServer(); |
| 76 | probe.listen(0, "127.0.0.1", () => { | 85 | probe.once("error", () => resume(Effect.fail("failed to find a free port"))); |
| 77 | const address = probe.address(); | 86 | probe.listen(0, "127.0.0.1", () => { |
| 78 | const port = typeof address === "object" && address ? address.port : 0; | 87 | const address = probe.address(); |
| 79 | probe.close((error) => (error ? reject(error) : resolve(port))); | 88 | const port = typeof address === "object" && address ? address.port : 0; |
| 80 | }); | 89 | probe.close((error) => |
| 81 | }); | 90 | error |
| 91 | ? resume(Effect.fail("failed to close free-port probe")) | ||
| 92 | : resume(Effect.succeed(port)), | ||
| 93 | ); | ||
| 94 | }); | ||
| 95 | }), | ||
| 96 | ); | ||
| 82 | 97 | ||
| 83 | const readRequestBody = async (request: IncomingMessage): Promise<Buffer | undefined> => { | 98 | const readRequestBody = async (request: IncomingMessage): Promise<Buffer | undefined> => { |
| 84 | if (request.method === "GET" || request.method === "HEAD") return undefined; | 99 | if (request.method === "GET" || request.method === "HEAD") return undefined; |
| @@ -118,29 +133,42 @@ const startHttpServer = async ( · 33 unmodified lines | |||
| 118 | ): Promise<TestHttpServer> => { | 133 | ): Promise<TestHttpServer> => { |
| 119 | let port = 0; | 134 | let port = 0; |
| 120 | const server = createServer((request, response) => { | 135 | const server = createServer((request, response) => { |
| 121 | void (async () => { | 136 | void Effect.runPromise( |
| 122 | try { | 137 | Effect.tryPromise({ |
| 123 | await sendWebResponse(await handler(await webRequestFromNode(request, port)), response); | 138 | try: async () => { |
| 124 | } catch (error) { | 139 | await sendWebResponse(await handler(await webRequestFromNode(request, port)), response); |
| 125 | response.statusCode = 500; | 140 | }, |
| 126 | response.end(error instanceof Error ? error.message : String(error)); | 141 | catch: () => "request failed", |
| 127 | } | 142 | }).pipe( |
| 128 | })(); | 143 | Effect.catch(() => |
| 129 | }); | 144 | Effect.sync(() => { |
| 130 | await new Promise<void>((resolve, reject) => { | 145 | response.statusCode = 500; |
| 131 | server.once("error", reject); | 146 | response.end("request failed"); |
| 132 | server.listen(0, "127.0.0.1", () => { | 147 | }), |
| 133 | const address = server.address(); | 148 | ), |
| 134 | port = typeof address === "object" && address ? address.port : 0; | 149 | ), |
| 135 | resolve(); | 150 | ); |
| 136 | }); | ||
| 137 | }); | 151 | }); |
| 152 | await Effect.runPromise( | ||
| 153 | Effect.callback<void, string>((resume) => { | ||
| 154 | server.once("error", () => resume(Effect.fail("failed to start test server"))); | ||
| 155 | server.listen(0, "127.0.0.1", () => { | ||
| 156 | const address = server.address(); | ||
| 157 | port = typeof address === "object" && address ? address.port : 0; | ||
| 158 | resume(Effect.void); | ||
| 159 | }); | ||
| 160 | }), | ||
| 161 | ); | ||
| 138 | return { | 162 | return { |
| 139 | baseUrl: `http://127.0.0.1:${port}`, | 163 | baseUrl: `http://127.0.0.1:${port}`, |
| 140 | close: () => | 164 | close: () => |
| 141 | new Promise((resolve, reject) => { | 165 | Effect.runPromise( |
| 142 | server.close((error) => (error ? reject(error) : resolve())); | 166 | Effect.callback<void, string>((resume) => { |
| 143 | }), | 167 | server.close((error) => |
| 168 | error ? resume(Effect.fail("failed to close test server")) : resume(Effect.void), | ||
| 169 | ); | ||
| 170 | }), | ||
| 171 | ), | ||
| 144 | }; | 172 | }; |
| 145 | }; | 173 | }; |
| 146 | 174 | ||
| @@ -152,7 +180,7 @@ const requestJson = async <T>( · 5 unmodified lines | |||
| 152 | const response = await fetch(`${server.baseUrl}${path}`, init); | 180 | const response = await fetch(`${server.baseUrl}${path}`, init); |
| 153 | const text = await response.text(); | 181 | const text = await response.text(); |
| 154 | expect(response.status).toBe(expectedStatus); | 182 | expect(response.status).toBe(expectedStatus); |
| 155 | return (text.length > 0 ? JSON.parse(text) : null) as T; | 183 | return parseJsonText<T>(text); |
| 156 | }; | 184 | }; |
| 157 | 185 | ||
| 158 | const postJson = <T>(path: string, body: unknown, expectedStatus = 200): Promise<T> => | 186 | const postJson = <T>(path: string, body: unknown, expectedStatus = 200): Promise<T> => |
| @@ -179,7 +207,7 @@ const githubFetch = async <T>( · 20 unmodified lines | |||
| 179 | const response = await fetch(`${emulator.url}${path}`, { ...init, headers }); | 207 | const response = await fetch(`${emulator.url}${path}`, { ...init, headers }); |
| 180 | const text = await response.text(); | 208 | const text = await response.text(); |
| 181 | expect(response.ok).toBe(true); | 209 | expect(response.ok).toBe(true); |
| 182 | return (text.length > 0 ? JSON.parse(text) : null) as T; | 210 | return parseJsonText<T>(text); |
| 183 | }; | 211 | }; |
| 184 | 212 | ||
| 185 | const createIssue = ( | 213 | const createIssue = ( |
| @@ -1,8 +1,9 @@ | |||
| 1 | import { describe, expect, it, vi } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import appsClientPlugin, { | 3 | import appsClientPlugin, { |
| 4 | CUSTOM_TOOLS_LABEL, | 4 | CUSTOM_TOOLS_LABEL, |
| 5 | CUSTOM_TOOLS_PLUGIN_KEY, | 5 | CUSTOM_TOOLS_PLUGIN_KEY, |
| 6 | type CustomToolsFetch, | ||
| 6 | appsIntegrationPlugin, | 7 | appsIntegrationPlugin, |
| 7 | formatSyncErrors, | 8 | formatSyncErrors, |
| 8 | listCustomToolSources, | 9 | listCustomToolSources, |
| @@ -42,7 +43,7 @@ describe("custom tools console client", () => { · 33 unmodified lines | |||
| 42 | }); | 43 | }); |
| 43 | 44 | ||
| 44 | it("surfaces successful sync and source detail data", async () => { | 45 | it("surfaces successful sync and source detail data", async () => { |
| 45 | const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { | 46 | const fetchImpl: CustomToolsFetch = async (input) => { |
| 46 | const url = String(input); | 47 | const url = String(input); |
| 47 | if (url.endsWith("/sync")) { | 48 | if (url.endsWith("/sync")) { |
| 48 | return jsonResponse({ | 49 | return jsonResponse({ |
| @@ -68,7 +69,7 @@ describe("custom tools console client", () => { · 19 unmodified lines | |||
| 68 | }, | 69 | }, |
| 69 | ], | 70 | ], |
| 70 | }); | 71 | }); |
| 71 | }) as unknown as typeof fetch; | 72 | }; |
| 72 | 73 | ||
| 73 | const syncResult = await syncCustomToolSource( | 74 | const syncResult = await syncCustomToolSource( |
| 74 | { | 75 | { |
| @@ -85,7 +86,7 @@ describe("custom tools console client", () => { · 10 unmodified lines | |||
| 85 | }); | 86 | }); |
| 86 | 87 | ||
| 87 | it("renders failed sync errors readably", async () => { | 88 | it("renders failed sync errors readably", async () => { |
| 88 | const fetchImpl = vi.fn(async () => | 89 | const fetchImpl: CustomToolsFetch = async () => |
| 89 | jsonResponse({ | 90 | jsonResponse({ |
| 90 | status: "failed", | 91 | status: "failed", |
| 91 | tools: [], | 92 | tools: [], |
| @@ -97,8 +98,7 @@ describe("custom tools console client", () => { · 5 unmodified lines | |||
| 97 | diagnostics: [{ path: "tools/x.ts", message: "vendor: nope" }], | 98 | diagnostics: [{ path: "tools/x.ts", message: "vendor: nope" }], |
| 98 | }, | 99 | }, |
| 99 | ], | 100 | ], |
| 100 | }), | 101 | }); |
| 101 | ) as unknown as typeof fetch; | ||
| 102 | 102 | ||
| 103 | const result = await syncCustomToolSource( | 103 | const result = await syncCustomToolSource( |
| 104 | { | 104 | { |
| @@ -115,14 +115,13 @@ describe("custom tools console client", () => { · 10 unmodified lines | |||
| 115 | }); | 115 | }); |
| 116 | 116 | ||
| 117 | it("shows the up-to-date sync state", async () => { | 117 | it("shows the up-to-date sync state", async () => { |
| 118 | const fetchImpl = vi.fn(async () => | 118 | const fetchImpl: CustomToolsFetch = async () => |
| 119 | jsonResponse({ | 119 | jsonResponse({ |
| 120 | status: "up-to-date", | 120 | status: "up-to-date", |
| 121 | upstreamSha: "abc123", | 121 | upstreamSha: "abc123", |
| 122 | tools: ["repo-summary"], | 122 | tools: ["repo-summary"], |
| 123 | skipped: [], | 123 | skipped: [], |
| 124 | }), | 124 | }); |
| 125 | ) as unknown as typeof fetch; | ||
| 126 | 125 | ||
| 127 | const result = await syncCustomToolSource( | 126 | const result = await syncCustomToolSource( |
| 128 | { | 127 | { |
| @@ -0,0 +1 @@ | |||
| 1 | # @executor-js/plugin-apps | ||
| @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; · 1 unmodified line | |||
| 2 | import { tmpdir } from "node:os"; | 2 | import { tmpdir } from "node:os"; |
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| 5 | import { describe, expect, it } from "vitest"; | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | import { Effect } from "effect"; | 6 | import { Effect } from "effect"; |
| 7 | 7 | ||
| 8 | import { makeGitArtifactStore } from "./git-artifact-store"; | 8 | import { makeGitArtifactStore } from "./git-artifact-store"; |
| @@ -30,6 +30,14 @@ const BRANCH = "refs/heads/main"; · 29 unmodified lines | |||
| 30 | // scope repo). git treats the empty string as "the ref must not already exist". | 30 | // scope repo). git treats the empty string as "the ref must not already exist". |
| 31 | const EMPTY_OID = ""; | 31 | const EMPTY_OID = ""; |
| 32 | 32 | ||
| 33 | const stderrText = (stderr: string | Buffer): string => | ||
| 34 | Buffer.isBuffer(stderr) ? stderr.toString("utf8").trim() : String(stderr).trim(); | ||
| 35 | |||
| 36 | const gitFailureMessage = (command: string, stderr: string | Buffer): string => { | ||
| 37 | const detail = stderrText(stderr); | ||
| 38 | return detail ? `git ${command} failed: ${detail}` : `git ${command} failed`; | ||
| 39 | }; | ||
| 40 | |||
| 33 | const run = ( | 41 | const run = ( |
| 34 | cwd: string, | 42 | cwd: string, |
| 35 | args: readonly string[], | 43 | args: readonly string[], |
| @@ -45,7 +53,7 @@ const run = ( · 9 unmodified lines | |||
| 45 | resume( | 53 | resume( |
| 46 | Effect.fail( | 54 | Effect.fail( |
| 47 | new ArtifactStoreError({ | 55 | new ArtifactStoreError({ |
| 48 | message: `git ${args[0]} failed: ${(stderr as Buffer)?.toString() || error.message}`, | 56 | message: gitFailureMessage(args[0] ?? "command", stderr), |
| 49 | cause: error, | 57 | cause: error, |
| 50 | }), | 58 | }), |
| 51 | ), | 59 | ), |
| @@ -114,7 +122,7 @@ const makeScopeStore = (repoDir: string): ScopeArtifactStore => { · 62 unmodified lines | |||
| 114 | resume( | 122 | resume( |
| 115 | Effect.fail( | 123 | Effect.fail( |
| 116 | new ArtifactStoreError({ | 124 | new ArtifactStoreError({ |
| 117 | message: `git ${args[0]} failed: ${(stderr as Buffer)?.toString() || error.message}`, | 125 | message: gitFailureMessage(args[0] ?? "command", stderr), |
| 118 | cause: error, | 126 | cause: error, |
| 119 | }), | 127 | }), |
| 120 | ), | 128 | ), |
| @@ -159,7 +167,7 @@ const makeScopeStore = (repoDir: string): ScopeArtifactStore => { · 38 unmodified lines | |||
| 159 | resume( | 167 | resume( |
| 160 | Effect.fail( | 168 | Effect.fail( |
| 161 | new ArtifactStoreError({ | 169 | new ArtifactStoreError({ |
| 162 | message: `git commit-tree failed: ${(stderr as Buffer)?.toString() || error.message}`, | 170 | message: gitFailureMessage("commit-tree", stderr), |
| 163 | cause: error, | 171 | cause: error, |
| 164 | }), | 172 | }), |
| 165 | ), | 173 | ), |
| @@ -271,11 +279,7 @@ export const makeGitArtifactStore = (options: GitArtifactStoreOptions): Artifact · 105 unmodified lines | |||
| 271 | const init = async (key: string): Promise<ScopeArtifactStore> => { | 279 | const init = async (key: string): Promise<ScopeArtifactStore> => { |
| 272 | const repoDir = join(options.root, `${key}.git`); | 280 | const repoDir = join(options.root, `${key}.git`); |
| 273 | await mkdir(repoDir, { recursive: true }); | 281 | await mkdir(repoDir, { recursive: true }); |
| 274 | await new Promise<void>((resolve, reject) => { | 282 | await Effect.runPromise(run(repoDir, ["init", "--bare", "--quiet"])); |
| 275 | execFile("git", ["init", "--bare", "--quiet"], { cwd: repoDir }, (error) => | ||
| 276 | error ? reject(error) : resolve(), | ||
| 277 | ); | ||
| 278 | }); | ||
| 279 | return makeScopeStore(repoDir); | 283 | return makeScopeStore(repoDir); |
| 280 | }; | 284 | }; |
| 281 | 285 | ||
| @@ -86,7 +86,7 @@ const makeHandle = (address: ScopeAddress, client: Client): ScopeDbHandle => { · 85 unmodified lines | |||
| 86 | await bump(targets); | 86 | await bump(targets); |
| 87 | } | 87 | } |
| 88 | } | 88 | } |
| 89 | return result.rows as unknown as readonly Row[]; | 89 | return result.rows.map((row) => row as Row); |
| 90 | }, | 90 | }, |
| 91 | catch: (cause) => | 91 | catch: (cause) => |
| 92 | new ScopeDbError({ message: `scope-db statement failed for scope ${label}`, cause }), | 92 | new ScopeDbError({ message: `scope-db statement failed for scope ${label}`, cause }), |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect } from "effect"; | 2 | import { Effect, Exit } from "effect"; |
| 3 | 3 | ||
| 4 | import { bundleEntry } from "../pipeline/bundle"; | 4 | import { bundleEntry } from "../pipeline/bundle"; |
| 5 | import { makeQuickjsToolSandbox } from "./quickjs-tool-sandbox"; | 5 | import { makeQuickjsToolSandbox } from "./quickjs-tool-sandbox"; |
| @@ -100,10 +100,9 @@ export default defineTool({ · 94 unmodified lines | |||
| 100 | ), | 100 | ), |
| 101 | ); | 101 | ); |
| 102 | 102 | ||
| 103 | expect(exit._tag).toBe("Failure"); | 103 | expect(Exit.isFailure(exit)).toBe(true); |
| 104 | if (exit._tag !== "Failure") throw new Error("expected validation failure"); | 104 | expect(JSON.stringify(exit)).toContain("InputValidationError"); |
| 105 | expect(JSON.stringify(exit.cause)).toContain("InputValidationError"); | 105 | expect(JSON.stringify(exit)).toContain("q"); |
| 106 | expect(JSON.stringify(exit.cause)).toContain("q"); | ||
| 107 | }); | 106 | }); |
| 108 | 107 | ||
| 109 | it("invokes raw JSON Schema tools without sandbox Standard Schema validation", async () => { | 108 | it("invokes raw JSON Schema tools without sandbox Standard Schema validation", async () => { |
| @@ -139,7 +138,7 @@ export default defineTool({ · 29 unmodified lines | |||
| 139 | const files = new Map([["tools/rng.ts", nondeterministic]]); | 138 | const files = new Map([["tools/rng.ts", nondeterministic]]); |
| 140 | const { code } = await run(bundleEntry({ files, entry: "tools/rng.ts" })); | 139 | const { code } = await run(bundleEntry({ files, entry: "tools/rng.ts" })); |
| 141 | const exit = await Effect.runPromiseExit(sandbox.collect(code)); | 140 | const exit = await Effect.runPromiseExit(sandbox.collect(code)); |
| 142 | expect(exit._tag).toBe("Failure"); | 141 | expect(Exit.isFailure(exit)).toBe(true); |
| 143 | }); | 142 | }); |
| 144 | 143 | ||
| 145 | it("denies network (fetch throws in the sandbox)", async () => { | 144 | it("denies network (fetch throws in the sandbox)", async () => { |
| @@ -156,7 +155,7 @@ export default defineTool({ · 10 unmodified lines | |||
| 156 | const exit = await Effect.runPromiseExit( | 155 | const exit = await Effect.runPromiseExit( |
| 157 | sandbox.invoke(code, { artifact: "net", kind: "tool", input: {}, roots: {} }, bridge), | 156 | sandbox.invoke(code, { artifact: "net", kind: "tool", input: {}, roots: {} }, bridge), |
| 158 | ); | 157 | ); |
| 159 | expect(exit._tag).toBe("Failure"); | 158 | expect(Exit.isFailure(exit)).toBe(true); |
| 160 | }); | 159 | }); |
| 161 | 160 | ||
| 162 | it("returns typed InputValidationError from promise rejection", async () => { | 161 | it("returns typed InputValidationError from promise rejection", async () => { |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { Effect } from "effect"; | 1 | import { Effect, Option, Predicate, Schema } from "effect"; |
| 2 | import type { SandboxToolInvoker } from "@executor-js/codemode-core"; | 2 | import type { SandboxToolInvoker } from "@executor-js/codemode-core"; |
| 3 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; | 3 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; |
| 4 | 4 | ||
| @@ -85,14 +85,13 @@ ${bundle} · 80 unmodified lines | |||
| 85 | })(module, exports, require); | 85 | })(module, exports, require); |
| 86 | `; | 86 | `; |
| 87 | 87 | ||
| 88 | const decodeJsonMarker = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); | ||
| 89 | |||
| 90 | const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| 91 | value !== null && typeof value === "object" && !Array.isArray(value); | ||
| 92 | |||
| 88 | const parseMarker = (message: string): unknown => { | 93 | const parseMarker = (message: string): unknown => { |
| 89 | const parse = (text: string): unknown => { | 94 | const parse = (text: string): unknown => Option.getOrNull(decodeJsonMarker(text)); |
| 90 | try { | ||
| 91 | return JSON.parse(text); | ||
| 92 | } catch { | ||
| 93 | return null; | ||
| 94 | } | ||
| 95 | }; | ||
| 96 | const direct = parse(message); | 95 | const direct = parse(message); |
| 97 | if (direct) return direct; | 96 | if (direct) return direct; |
| 98 | const firstBrace = message.indexOf("{"); | 97 | const firstBrace = message.indexOf("{"); |
| @@ -104,15 +103,20 @@ const parseMarker = (message: string): unknown => { · 5 unmodified lines | |||
| 104 | }; | 103 | }; |
| 105 | 104 | ||
| 106 | const isMarker = (value: unknown, tag: string): value is Record<string, unknown> => { | 105 | const isMarker = (value: unknown, tag: string): value is Record<string, unknown> => { |
| 107 | if (value === null || typeof value !== "object") return false; | 106 | if (!isRecord(value)) return false; |
| 108 | return (value as { readonly _tag?: unknown })._tag === tag; | 107 | return Predicate.isTagged(tag)(value); |
| 109 | }; | 108 | }; |
| 110 | 109 | ||
| 111 | const markerMessage = (marker: Record<string, unknown>, fallback: string): string => | 110 | const markerMessage = (marker: Record<string, unknown>, fallback: string): string => |
| 112 | typeof marker.message === "string" ? marker.message : fallback; | 111 | typeof marker.message === "string" ? marker.message : fallback; |
| 113 | 112 | ||
| 114 | const markerIssues = (marker: Record<string, unknown>): readonly ValidationIssue[] => | 113 | const markerIssues = (marker: Record<string, unknown>): readonly ValidationIssue[] => |
| 115 | Array.isArray(marker.issues) ? (marker.issues as readonly ValidationIssue[]) : []; | 114 | Array.isArray(marker.issues) ? marker.issues.filter(isValidationIssue) : []; |
| 115 | |||
| 116 | const isValidationIssue = (value: unknown): value is ValidationIssue => | ||
| 117 | isRecord(value) && | ||
| 118 | typeof value.message === "string" && | ||
| 119 | (value.path === undefined || Array.isArray(value.path)); | ||
| 116 | 120 | ||
| 117 | // Collect driver: describe the artifact's integrations + input/output schema. | 121 | // Collect driver: describe the artifact's integrations + input/output schema. |
| 118 | // Deterministic JSON only — no handler execution. | 122 | // Deterministic JSON only — no handler execution. |
| @@ -236,7 +240,10 @@ const buildInvokeCode = (bundle: string, request: InvokeRequest): string => · 117 unmodified lines | |||
| 236 | // A no-op invoker for collect: no handle calls should happen; if they do | 240 | // A no-op invoker for collect: no handle calls should happen; if they do |
| 237 | // (misbehaving describe path), fail loudly. | 241 | // (misbehaving describe path), fail loudly. |
| 238 | const collectInvoker: SandboxToolInvoker = { | 242 | const collectInvoker: SandboxToolInvoker = { |
| 239 | invoke: () => Effect.fail(new Error("collect must not make handle calls")) as never, | 243 | invoke: () => |
| 244 | Effect.fail( | ||
| 245 | new ToolSandboxError({ kind: "collect", message: "collect must not make handle calls" }), | ||
| 246 | ), | ||
| 240 | }; | 247 | }; |
| 241 | 248 | ||
| 242 | export interface QuickjsToolSandboxOptions { | 249 | export interface QuickjsToolSandboxOptions { |
| @@ -286,13 +293,11 @@ export const makeQuickjsToolSandbox = (options: QuickjsToolSandboxOptions = {}): · 43 unmodified lines | |||
| 286 | const a = stableStringify(first); | 293 | const a = stableStringify(first); |
| 287 | const b = stableStringify(second); | 294 | const b = stableStringify(second); |
| 288 | if (a !== b) { | 295 | if (a !== b) { |
| 289 | return yield* Effect.fail( | 296 | return yield* new ToolSandboxError({ |
| 290 | new ToolSandboxError({ | 297 | kind: "nondeterministic", |
| 291 | kind: "nondeterministic", | 298 | message: |
| 292 | message: | 299 | "descriptor collection is non-deterministic (an artifact read Math.random/Date.now or otherwise diverged between runs)", |
| 293 | "descriptor collection is non-deterministic (an artifact read Math.random/Date.now or otherwise diverged between runs)", | 300 | }); |
| 294 | }), | ||
| 295 | ); | ||
| 296 | } | 301 | } |
| 297 | const descriptor = first as { artifact?: string }; | 302 | const descriptor = first as { artifact?: string }; |
| 298 | const result: CollectResult = { | 303 | const result: CollectResult = { |
| @@ -318,8 +323,11 @@ export const makeQuickjsToolSandbox = (options: QuickjsToolSandboxOptions = {}): · 19 unmodified lines | |||
| 318 | // through an unexpected channel. | 323 | // through an unexpected channel. |
| 319 | if (input.path !== "__handle__") { | 324 | if (input.path !== "__handle__") { |
| 320 | return Effect.fail( | 325 | return Effect.fail( |
| 321 | new Error(`unexpected sandbox bridge path: ${input.path}`), | 326 | new ToolSandboxError({ |
| 322 | ) as never; | 327 | kind: "invoke", |
| 328 | message: `unexpected sandbox bridge path: ${input.path}`, | ||
| 329 | }), | ||
| 330 | ); | ||
| 323 | } | 331 | } |
| 324 | const call = input.args as { | 332 | const call = input.args as { |
| 325 | root: string; | 333 | root: string; |
| @@ -327,7 +335,9 @@ export const makeQuickjsToolSandbox = (options: QuickjsToolSandboxOptions = {}): · 1 unmodified line | |||
| 327 | args: readonly unknown[]; | 335 | args: readonly unknown[]; |
| 328 | }; | 336 | }; |
| 329 | if (!call || typeof call.root !== "string" || !Array.isArray(call.path)) { | 337 | if (!call || typeof call.root !== "string" || !Array.isArray(call.path)) { |
| 330 | return Effect.fail(new Error("malformed sandbox bridge call")) as never; | 338 | return Effect.fail( |
| 339 | new ToolSandboxError({ kind: "invoke", message: "malformed sandbox bridge call" }), | ||
| 340 | ); | ||
| 331 | } | 341 | } |
| 332 | return bridge.call({ root: call.root, path: call.path, args: call.args }) as never; | 342 | return bridge.call({ root: call.root, path: call.path, args: call.args }) as never; |
| 333 | }, | 343 | }, |
| @@ -343,24 +353,18 @@ export const makeQuickjsToolSandbox = (options: QuickjsToolSandboxOptions = {}): · 9 unmodified lines | |||
| 343 | if (result.error) { | 353 | if (result.error) { |
| 344 | const marker = parseMarker(result.error); | 354 | const marker = parseMarker(result.error); |
| 345 | if (isMarker(marker, "InputValidationError")) { | 355 | if (isMarker(marker, "InputValidationError")) { |
| 346 | return yield* Effect.fail( | 356 | return yield* new InputValidationError({ |
| 347 | new InputValidationError({ | 357 | message: markerMessage(marker, "input validation failed"), |
| 348 | message: markerMessage(marker, "input validation failed"), | 358 | issues: markerIssues(marker), |
| 349 | issues: markerIssues(marker), | 359 | }); |
| 350 | }), | ||
| 351 | ); | ||
| 352 | } | 360 | } |
| 353 | if (isMarker(marker, "OutputValidationError")) { | 361 | if (isMarker(marker, "OutputValidationError")) { |
| 354 | return yield* Effect.fail( | 362 | return yield* new OutputValidationError({ |
| 355 | new OutputValidationError({ | 363 | message: markerMessage(marker, "output validation failed"), |
| 356 | message: markerMessage(marker, "output validation failed"), | 364 | issues: markerIssues(marker), |
| 357 | issues: markerIssues(marker), | 365 | }); |
| 358 | }), | ||
| 359 | ); | ||
| 360 | } | 366 | } |
| 361 | return yield* Effect.fail( | 367 | return yield* new ToolSandboxError({ kind: "invoke", message: result.error }); |
| 362 | new ToolSandboxError({ kind: "invoke", message: result.error }), | ||
| 363 | ); | ||
| 364 | } | 368 | } |
| 365 | return { output: result.result, logs: result.logs ?? [] } satisfies InvokeResult; | 369 | return { output: result.result, logs: result.logs ?? [] } satisfies InvokeResult; |
| 366 | }), | 370 | }), |
| @@ -2,7 +2,7 @@ import { mkdtempSync, readdirSync } from "node:fs"; · 1 unmodified line | |||
| 2 | import { tmpdir } from "node:os"; | 2 | import { tmpdir } from "node:os"; |
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| 5 | import { describe, expect, it } from "vitest"; | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | import { Effect } from "effect"; | 6 | import { Effect } from "effect"; |
| 7 | 7 | ||
| 8 | import { makeLibsqlScopeDb } from "./libsql-scope-db"; | 8 | import { makeLibsqlScopeDb } from "./libsql-scope-db"; |
| @@ -2,9 +2,9 @@ import { mkdirSync } from "node:fs"; · 1 unmodified line | |||
| 2 | import { dirname, resolve } from "node:path"; | 2 | import { dirname, resolve } from "node:path"; |
| 3 | 3 | ||
| 4 | import { createClient, type Client } from "@libsql/client"; | 4 | import { createClient, type Client } from "@libsql/client"; |
| 5 | import { Effect } from "effect"; | 5 | import { Effect, Schema } from "effect"; |
| 6 | 6 | ||
| 7 | import type { StorageFailure } from "@executor-js/sdk"; | 7 | import { StorageError, type StorageFailure } from "@executor-js/sdk"; |
| 8 | 8 | ||
| 9 | import type { AppDescriptor } from "../pipeline/descriptor"; | 9 | import type { AppDescriptor } from "../pipeline/descriptor"; |
| 10 | import type { AppsStore } from "../plugin/store"; | 10 | import type { AppsStore } from "../plugin/store"; |
| @@ -22,7 +22,18 @@ CREATE TABLE IF NOT EXISTS scope_connections (tenant TEXT NOT NULL, connection_n · 11 unmodified lines | |||
| 22 | `; | 22 | `; |
| 23 | 23 | ||
| 24 | const storageFail = (message: string, cause: unknown): StorageFailure => | 24 | const storageFail = (message: string, cause: unknown): StorageFailure => |
| 25 | ({ _tag: "StorageError", message, cause }) as unknown as StorageFailure; | 25 | new StorageError({ message, cause }); |
| 26 | |||
| 27 | const decodeDescriptorJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); | ||
| 28 | |||
| 29 | const decodeDescriptor = ( | ||
| 30 | value: unknown, | ||
| 31 | message: string, | ||
| 32 | ): Effect.Effect<AppDescriptor, StorageFailure> => | ||
| 33 | decodeDescriptorJson(String(value)).pipe( | ||
| 34 | Effect.map((descriptor) => descriptor as AppDescriptor), | ||
| 35 | Effect.mapError((cause) => storageFail(message, cause)), | ||
| 36 | ); | ||
| 26 | 37 | ||
| 27 | export interface SqliteAppsStoreOptions { | 38 | export interface SqliteAppsStoreOptions { |
| 28 | readonly path: string; | 39 | readonly path: string; |
| @@ -73,10 +84,16 @@ export const makeSqliteAppsStore = (options: SqliteAppsStoreOptions): AppsStore · 44 unmodified lines | |||
| 73 | args: [tenant, scope], | 84 | args: [tenant, scope], |
| 74 | }); | 85 | }); |
| 75 | const row = res.rows[0]; | 86 | const row = res.rows[0]; |
| 76 | return row ? (JSON.parse(String(row.descriptor)) as AppDescriptor) : null; | 87 | return row?.descriptor ?? null; |
| 77 | }, | 88 | }, |
| 78 | catch: (cause) => storageFail("getDescriptor failed", cause), | 89 | catch: (cause) => storageFail("getDescriptor failed", cause), |
| 79 | }), | 90 | }).pipe( |
| 91 | Effect.flatMap((descriptor) => | ||
| 92 | descriptor === null | ||
| 93 | ? Effect.succeed(null) | ||
| 94 | : decodeDescriptor(descriptor, "getDescriptor failed"), | ||
| 95 | ), | ||
| 96 | ), | ||
| 80 | listDescriptors: (tenant) => | 97 | listDescriptors: (tenant) => |
| 81 | Effect.tryPromise({ | 98 | Effect.tryPromise({ |
| 82 | try: async () => { | 99 | try: async () => { |
| @@ -85,13 +102,21 @@ export const makeSqliteAppsStore = (options: SqliteAppsStoreOptions): AppsStore · 2 unmodified lines | |||
| 85 | sql: "SELECT descriptor, published_at FROM descriptors WHERE tenant = ? ORDER BY published_at DESC", | 102 | sql: "SELECT descriptor, published_at FROM descriptors WHERE tenant = ? ORDER BY published_at DESC", |
| 86 | args: [tenant], | 103 | args: [tenant], |
| 87 | }); | 104 | }); |
| 88 | return res.rows.map((row) => ({ | 105 | return res.rows; |
| 89 | descriptor: JSON.parse(String(row.descriptor)) as AppDescriptor, | ||
| 90 | publishedAt: Number(row.published_at), | ||
| 91 | })); | ||
| 92 | }, | 106 | }, |
| 93 | catch: (cause) => storageFail("listDescriptors failed", cause), | 107 | catch: (cause) => storageFail("listDescriptors failed", cause), |
| 94 | }), | 108 | }).pipe( |
| 109 | Effect.flatMap((rows) => | ||
| 110 | Effect.forEach(rows, (row) => | ||
| 111 | decodeDescriptor(row.descriptor, "listDescriptors failed").pipe( | ||
| 112 | Effect.map((descriptor) => ({ | ||
| 113 | descriptor, | ||
| 114 | publishedAt: Number(row.published_at), | ||
| 115 | })), | ||
| 116 | ), | ||
| 117 | ), | ||
| 118 | ), | ||
| 119 | ), | ||
| 95 | putScopeForConnection: (tenant, connectionName, scope) => | 120 | putScopeForConnection: (tenant, connectionName, scope) => |
| 96 | Effect.tryPromise({ | 121 | Effect.tryPromise({ |
| 97 | try: async () => { | 122 | try: async () => { |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { build, version as esbuildVersion } from "esbuild"; | 1 | import { build, version as esbuildVersion } from "esbuild"; |
| 2 | 2 | ||
| 3 | import { Effect } from "effect"; | 3 | import { Effect, Option, Schema } from "effect"; |
| 4 | 4 | ||
| 5 | import { ToolSandboxError } from "../seams/tool-sandbox"; | 5 | import { ToolSandboxError } from "../seams/tool-sandbox"; |
| 6 | import type { ToolchainRef } from "./descriptor"; | 6 | import type { ToolchainRef } from "./descriptor"; |
| @@ -52,6 +52,25 @@ export interface BundleOutput { · 45 unmodified lines | |||
| 52 | readonly code: string; | 52 | readonly code: string; |
| 53 | } | 53 | } |
| 54 | 54 | ||
| 55 | const EsbuildDiagnostic = Schema.Struct({ | ||
| 56 | text: Schema.String, | ||
| 57 | }); | ||
| 58 | |||
| 59 | const EsbuildFailure = Schema.Struct({ | ||
| 60 | errors: Schema.Array(EsbuildDiagnostic), | ||
| 61 | }); | ||
| 62 | |||
| 63 | const decodeEsbuildFailure = Schema.decodeUnknownOption(EsbuildFailure); | ||
| 64 | |||
| 65 | const bundleFailureMessage = (entry: string, cause: unknown): string => { | ||
| 66 | const decoded = Option.getOrNull(decodeEsbuildFailure(cause)); | ||
| 67 | const detail = decoded?.errors | ||
| 68 | .map((error) => error.text) | ||
| 69 | .filter((text) => text.length > 0) | ||
| 70 | .join("; "); | ||
| 71 | return detail ? `bundle failed for ${entry}: ${detail}` : `bundle failed for ${entry}`; | ||
| 72 | }; | ||
| 73 | |||
| 55 | const resolveRelative = (base: string, rel: string): string => { | 74 | const resolveRelative = (base: string, rel: string): string => { |
| 56 | const parts = (base ? base.split("/") : []).concat(rel.split("/")); | 75 | const parts = (base ? base.split("/") : []).concat(rel.split("/")); |
| 57 | const out: string[] = []; | 76 | const out: string[] = []; |
| @@ -166,8 +185,8 @@ const fileSetPlugin = (files: ReadonlyMap<string, string>, authorEntry: string) · 108 unmodified lines | |||
| 166 | /** Bundle one entry from the file set to a single CJS string. */ | 185 | /** Bundle one entry from the file set to a single CJS string. */ |
| 167 | export const bundleEntry = (input: BundleInput): Effect.Effect<BundleOutput, ToolSandboxError> => | 186 | export const bundleEntry = (input: BundleInput): Effect.Effect<BundleOutput, ToolSandboxError> => |
| 168 | Effect.tryPromise({ | 187 | Effect.tryPromise({ |
| 169 | try: async () => { | 188 | try: () => |
| 170 | const result = await build({ | 189 | build({ |
| 171 | entryPoints: [VIRTUAL_ENTRY], | 190 | entryPoints: [VIRTUAL_ENTRY], |
| 172 | bundle: true, | 191 | bundle: true, |
| 173 | write: false, | 192 | write: false, |
| @@ -180,15 +199,23 @@ export const bundleEntry = (input: BundleInput): Effect.Effect<BundleOutput, Too · 6 unmodified lines | |||
| 180 | logLevel: "silent", | 199 | logLevel: "silent", |
| 181 | external: [...PLATFORM_MODULES], | 200 | external: [...PLATFORM_MODULES], |
| 182 | plugins: [fileSetPlugin(input.files, input.entry) as never], | 201 | plugins: [fileSetPlugin(input.files, input.entry) as never], |
| 183 | }); | 202 | }), |
| 184 | const out = result.outputFiles?.[0]?.text; | ||
| 185 | if (out === undefined) throw new Error("esbuild produced no output"); | ||
| 186 | return { code: out }; | ||
| 187 | }, | ||
| 188 | catch: (cause) => | 203 | catch: (cause) => |
| 189 | new ToolSandboxError({ | 204 | new ToolSandboxError({ |
| 190 | kind: "bundle", | 205 | kind: "bundle", |
| 191 | message: `bundle failed for ${input.entry}: ${cause instanceof Error ? cause.message : String(cause)}`, | 206 | message: bundleFailureMessage(input.entry, cause), |
| 192 | cause, | 207 | cause, |
| 193 | }), | 208 | }), |
| 194 | }); | 209 | }).pipe( |
| 210 | Effect.flatMap((result) => { | ||
| 211 | const out = result.outputFiles?.[0]?.text; | ||
| 212 | return out === undefined | ||
| 213 | ? Effect.fail( | ||
| 214 | new ToolSandboxError({ | ||
| 215 | kind: "bundle", | ||
| 216 | message: `bundle failed for ${input.entry}: esbuild produced no output`, | ||
| 217 | }), | ||
| 218 | ) | ||
| 219 | : Effect.succeed({ code: out }); | ||
| 220 | }), | ||
| 221 | ); | ||
| @@ -108,7 +108,7 @@ export const stableStringify = (value: unknown): string => { · 107 unmodified lines | |||
| 108 | const seen = new WeakSet<object>(); | 108 | const seen = new WeakSet<object>(); |
| 109 | const walk = (v: unknown): unknown => { | 109 | const walk = (v: unknown): unknown => { |
| 110 | if (v === null || typeof v !== "object") return v; | 110 | if (v === null || typeof v !== "object") return v; |
| 111 | if (seen.has(v)) throw new Error("cyclic value cannot be serialized deterministically"); | 111 | if (seen.has(v)) return "[Circular]"; |
| 112 | seen.add(v); | 112 | seen.add(v); |
| 113 | if (Array.isArray(v)) return v.map(walk); | 113 | if (Array.isArray(v)) return v.map(walk); |
| 114 | const out: Record<string, unknown> = {}; | 114 | const out: Record<string, unknown> = {}; |
| @@ -2,8 +2,8 @@ import { mkdtempSync } from "node:fs"; · 1 unmodified line | |||
| 2 | import { tmpdir } from "node:os"; | 2 | import { tmpdir } from "node:os"; |
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| 5 | import { describe, expect, it } from "vitest"; | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | import { Effect } from "effect"; | 6 | import { Effect, Exit } from "effect"; |
| 7 | 7 | ||
| 8 | import { makeGitArtifactStore } from "../backing/git-artifact-store"; | 8 | import { makeGitArtifactStore } from "../backing/git-artifact-store"; |
| 9 | import { makeQuickjsToolSandbox } from "../backing/quickjs-tool-sandbox"; | 9 | import { makeQuickjsToolSandbox } from "../backing/quickjs-tool-sandbox"; |
| @@ -89,7 +89,7 @@ export default defineTool({ · 79 unmodified lines | |||
| 89 | 89 | ||
| 90 | const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files })); | 90 | const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files })); |
| 91 | 91 | ||
| 92 | expect(exit._tag).toBe("Failure"); | 92 | expect(Exit.isFailure(exit)).toBe(true); |
| 93 | expect(JSON.stringify(exit)).toContain("collides"); | 93 | expect(JSON.stringify(exit)).toContain("collides"); |
| 94 | expect(JSON.stringify(exit)).toContain("crm"); | 94 | expect(JSON.stringify(exit)).toContain("crm"); |
| 95 | }); | 95 | }); |
| @@ -117,7 +117,7 @@ export default defineTool({ · 21 unmodified lines | |||
| 117 | 117 | ||
| 118 | const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files })); | 118 | const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files })); |
| 119 | 119 | ||
| 120 | expect(exit._tag).toBe("Failure"); | 120 | expect(Exit.isFailure(exit)).toBe(true); |
| 121 | expect(JSON.stringify(exit)).toContain("vendor-without-json-schema"); | 121 | expect(JSON.stringify(exit)).toContain("vendor-without-json-schema"); |
| 122 | expect(JSON.stringify(exit)).toContain("vendor"); | 122 | expect(JSON.stringify(exit)).toContain("vendor"); |
| 123 | expect(JSON.stringify(exit)).toContain("input"); | 123 | expect(JSON.stringify(exit)).toContain("input"); |
| @@ -155,10 +155,8 @@ export default defineTool({ · 31 unmodified lines | |||
| 155 | ], | 155 | ], |
| 156 | ]); | 156 | ]); |
| 157 | const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files })); | 157 | const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files })); |
| 158 | expect(exit._tag).toBe("Failure"); | 158 | expect(Exit.isFailure(exit)).toBe(true); |
| 159 | if (exit._tag === "Failure") { | 159 | expect(JSON.stringify(exit)).toContain("bundle"); |
| 160 | expect(JSON.stringify(exit.cause)).toContain("bundle"); | ||
| 161 | } | ||
| 162 | }); | 160 | }); |
| 163 | 161 | ||
| 164 | it("rejects an oversized publish set (too many files) and persists nothing", async () => { | 162 | it("rejects an oversized publish set (too many files) and persists nothing", async () => { |
| @@ -168,7 +166,7 @@ export default defineTool({ · 3 unmodified lines | |||
| 168 | files.set(`tools/t${i}.ts`, "// noop"); | 166 | files.set(`tools/t${i}.ts`, "// noop"); |
| 169 | } | 167 | } |
| 170 | const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files })); | 168 | const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files })); |
| 171 | expect(exit._tag).toBe("Failure"); | 169 | expect(Exit.isFailure(exit)).toBe(true); |
| 172 | expect(JSON.stringify(exit)).toContain("exceeding the limit"); | 170 | expect(JSON.stringify(exit)).toContain("exceeding the limit"); |
| 173 | const latest = await run( | 171 | const latest = await run( |
| 174 | Effect.flatMap(deps.artifactStore.forScope(scopeAddress("org", "s")), (s) => s.latest()), | 172 | Effect.flatMap(deps.artifactStore.forScope(scopeAddress("org", "s")), (s) => s.latest()), |
| @@ -181,7 +179,7 @@ export default defineTool({ · 6 unmodified lines | |||
| 181 | const big = "x".repeat(PUBLISH_LIMITS.maxFileBytes + 1); | 179 | const big = "x".repeat(PUBLISH_LIMITS.maxFileBytes + 1); |
| 182 | const files = new Map([["tools/big.ts", big]]); | 180 | const files = new Map([["tools/big.ts", big]]); |
| 183 | const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files })); | 181 | const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files })); |
| 184 | expect(exit._tag).toBe("Failure"); | 182 | expect(Exit.isFailure(exit)).toBe(true); |
| 185 | expect(JSON.stringify(exit)).toContain("per-file limit"); | 183 | expect(JSON.stringify(exit)).toContain("per-file limit"); |
| 186 | }); | 184 | }); |
| 187 | 185 | ||
| @@ -192,7 +190,7 @@ export default defineTool({ · 4 unmodified lines | |||
| 192 | const count = Math.ceil(PUBLISH_LIMITS.maxTotalBytes / PUBLISH_LIMITS.maxFileBytes) + 1; | 190 | const count = Math.ceil(PUBLISH_LIMITS.maxTotalBytes / PUBLISH_LIMITS.maxFileBytes) + 1; |
| 193 | for (let i = 0; i < count; i++) files.set(`tools/t${i}.ts`, half); | 191 | for (let i = 0; i < count; i++) files.set(`tools/t${i}.ts`, half); |
| 194 | const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files })); | 192 | const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files })); |
| 195 | expect(exit._tag).toBe("Failure"); | 193 | expect(Exit.isFailure(exit)).toBe(true); |
| 196 | expect(JSON.stringify(exit)).toContain("total limit"); | 194 | expect(JSON.stringify(exit)).toContain("total limit"); |
| 197 | }); | 195 | }); |
| 198 | }); | 196 | }); |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { Effect } from "effect"; | 1 | import { Effect, Predicate, Schema } from "effect"; |
| 2 | 2 | ||
| 3 | import type { ArtifactStore, FileSet, SnapshotId } from "../seams/artifact-store"; | 3 | import type { ArtifactStore, FileSet, SnapshotId } from "../seams/artifact-store"; |
| 4 | import { scopeAddress } from "../seams/scope-address"; | 4 | import { scopeAddress } from "../seams/scope-address"; |
| @@ -155,33 +155,39 @@ export interface PublishDeps { · 150 unmodified lines | |||
| 155 | readonly sandbox: ToolSandbox; | 155 | readonly sandbox: ToolSandbox; |
| 156 | } | 156 | } |
| 157 | 157 | ||
| 158 | const isPublishError = Predicate.isTagged("PublishError") as ( | ||
| 159 | value: unknown, | ||
| 160 | ) => value is PublishError; | ||
| 161 | |||
| 162 | const decodeDescriptorJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); | ||
| 163 | |||
| 158 | const assemble = (deps: PublishDeps, files: FileSet): Effect.Effect<AssembledApp, PublishError> => | 164 | const assemble = (deps: PublishDeps, files: FileSet): Effect.Effect<AssembledApp, PublishError> => |
| 159 | Effect.gen(function* () { | 165 | Effect.gen(function* () { |
| 160 | const discovered = discover(files); | 166 | const discovered = discover(files); |
| 161 | if (discovered instanceof PublishError) return yield* Effect.fail(discovered); | 167 | if (isPublishError(discovered)) return yield* discovered; |
| 162 | 168 | ||
| 163 | const tools: ToolDescriptor[] = []; | 169 | const tools: ToolDescriptor[] = []; |
| 164 | 170 | ||
| 165 | for (const artifact of discovered.artifacts) { | 171 | for (const artifact of discovered.artifacts) { |
| 166 | const bundle = yield* bundleEntry({ files, entry: artifact.entry }).pipe( | 172 | const bundle = yield* bundleEntry({ files, entry: artifact.entry }).pipe( |
| 167 | Effect.mapError( | 173 | Effect.mapError((bundleFailure) => { |
| 168 | (cause) => | 174 | const message = bundleFailure.message; |
| 169 | new PublishError({ | 175 | return new PublishError({ |
| 170 | message: cause.message, | 176 | message, |
| 171 | stage: "bundle", | 177 | stage: "bundle", |
| 172 | diagnostics: [{ path: artifact.entry, message: cause.message }], | 178 | diagnostics: [{ path: artifact.entry, message }], |
| 173 | }), | 179 | }); |
| 174 | ), | 180 | }), |
| 175 | ); | 181 | ); |
| 176 | const collected = yield* deps.sandbox.collect(bundle.code, { artifact: artifact.name }).pipe( | 182 | const collected = yield* deps.sandbox.collect(bundle.code, { artifact: artifact.name }).pipe( |
| 177 | Effect.mapError( | 183 | Effect.mapError((collectFailure) => { |
| 178 | (cause) => | 184 | const message = collectFailure.message; |
| 179 | new PublishError({ | 185 | return new PublishError({ |
| 180 | message: cause.message, | 186 | message, |
| 181 | stage: "collect", | 187 | stage: "collect", |
| 182 | diagnostics: [{ path: artifact.entry, message: cause.message }], | 188 | diagnostics: [{ path: artifact.entry, message }], |
| 183 | }), | 189 | }); |
| 184 | ), | 190 | }), |
| 185 | ); | 191 | ); |
| 186 | const raw = collected.artifacts.default?.descriptor as CollectedDescriptor | undefined; | 192 | const raw = collected.artifacts.default?.descriptor as CollectedDescriptor | undefined; |
| 187 | const keyed = collected.artifacts[artifact.name]?.descriptor as | 193 | const keyed = collected.artifacts[artifact.name]?.descriptor as |
| @@ -189,13 +195,11 @@ const assemble = (deps: PublishDeps, files: FileSet): Effect.Effect<AssembledApp · 1 unmodified line | |||
| 189 | | undefined; | 195 | | undefined; |
| 190 | const descriptor = keyed ?? raw; | 196 | const descriptor = keyed ?? raw; |
| 191 | if (!descriptor) { | 197 | if (!descriptor) { |
| 192 | return yield* Effect.fail( | 198 | return yield* new PublishError({ |
| 193 | new PublishError({ | 199 | message: `no descriptor collected from ${artifact.entry}`, |
| 194 | message: `no descriptor collected from ${artifact.entry}`, | 200 | stage: "collect", |
| 195 | stage: "collect", | 201 | diagnostics: [{ path: artifact.entry, message: "defineTool did not run" }], |
| 196 | diagnostics: [{ path: artifact.entry, message: "defineTool did not run" }], | 202 | }); |
| 197 | }), | ||
| 198 | ); | ||
| 199 | } | 203 | } |
| 200 | const integrations = toIntegrationDecls(descriptor.integrations); | 204 | const integrations = toIntegrationDecls(descriptor.integrations); |
| 201 | const collision = rejectRoleInputCollisions( | 205 | const collision = rejectRoleInputCollisions( |
| @@ -204,7 +208,7 @@ const assemble = (deps: PublishDeps, files: FileSet): Effect.Effect<AssembledApp · 2 unmodified lines | |||
| 204 | descriptor.inputJsonSchema, | 208 | descriptor.inputJsonSchema, |
| 205 | integrations, | 209 | integrations, |
| 206 | ); | 210 | ); |
| 207 | if (collision) return yield* Effect.fail(collision); | 211 | if (collision) return yield* collision; |
| 208 | const source = yield* sourceRef(artifact.entry, files.get(artifact.entry) ?? ""); | 212 | const source = yield* sourceRef(artifact.entry, files.get(artifact.entry) ?? ""); |
| 209 | tools.push({ | 213 | tools.push({ |
| 210 | name: artifact.name, | 214 | name: artifact.name, |
| @@ -247,19 +251,17 @@ export const publish = ( · 36 unmodified lines | |||
| 247 | Effect.gen(function* () { | 251 | Effect.gen(function* () { |
| 248 | const tenant = input.tenant ?? "org"; | 252 | const tenant = input.tenant ?? "org"; |
| 249 | const overLimit = enforcePublishLimits(input.files); | 253 | const overLimit = enforcePublishLimits(input.files); |
| 250 | if (overLimit) return yield* Effect.fail(overLimit); | 254 | if (overLimit) return yield* overLimit; |
| 251 | 255 | ||
| 252 | const assembled = yield* assemble(deps, input.files); | 256 | const assembled = yield* assemble(deps, input.files); |
| 253 | const body = descriptorBody(tenant, input.scope, assembled, input); | 257 | const body = descriptorBody(tenant, input.scope, assembled, input); |
| 254 | 258 | ||
| 255 | const scopeStore = yield* deps.artifactStore | 259 | const scopeStore = yield* deps.artifactStore.forScope(scopeAddress(tenant, input.scope)).pipe( |
| 256 | .forScope(scopeAddress(tenant, input.scope)) | 260 | Effect.mapError((storeFailure) => { |
| 257 | .pipe( | 261 | const message = storeFailure.message; |
| 258 | Effect.mapError( | 262 | return new PublishError({ message, stage: "project", diagnostics: [] }); |
| 259 | (cause) => | 263 | }), |
| 260 | new PublishError({ message: cause.message, stage: "project", diagnostics: [] }), | 264 | ); |
| 261 | ), | ||
| 262 | ); | ||
| 263 | 265 | ||
| 264 | const filesWithDescriptor = new Map(input.files); | 266 | const filesWithDescriptor = new Map(input.files); |
| 265 | filesWithDescriptor.set(DESCRIPTOR_SNAPSHOT_PATH, stableStringify(body)); | 267 | filesWithDescriptor.set(DESCRIPTOR_SNAPSHOT_PATH, stableStringify(body)); |
| @@ -267,10 +269,10 @@ export const publish = ( · 1 unmodified line | |||
| 267 | const meta = yield* scopeStore | 269 | const meta = yield* scopeStore |
| 268 | .commit(filesWithDescriptor, input.commitMessage ?? `publish ${new Date().toISOString()}`) | 270 | .commit(filesWithDescriptor, input.commitMessage ?? `publish ${new Date().toISOString()}`) |
| 269 | .pipe( | 271 | .pipe( |
| 270 | Effect.mapError( | 272 | Effect.mapError((storeFailure) => { |
| 271 | (cause) => | 273 | const message = storeFailure.message; |
| 272 | new PublishError({ message: cause.message, stage: "project", diagnostics: [] }), | 274 | return new PublishError({ message, stage: "project", diagnostics: [] }); |
| 273 | ), | 275 | }), |
| 274 | ); | 276 | ); |
| 275 | 277 | ||
| 276 | const descriptor: AppDescriptor = { ...body, snapshotId: meta.id }; | 278 | const descriptor: AppDescriptor = { ...body, snapshotId: meta.id }; |
| @@ -285,21 +287,28 @@ export const loadDescriptorFromSnapshot = ( · 8 unmodified lines | |||
| 285 | snapshotId: SnapshotId, | 287 | snapshotId: SnapshotId, |
| 286 | ): Effect.Effect<AppDescriptor | null, PublishError> => | 288 | ): Effect.Effect<AppDescriptor | null, PublishError> => |
| 287 | Effect.gen(function* () { | 289 | Effect.gen(function* () { |
| 288 | const scopeStore = yield* store | 290 | const scopeStore = yield* store.forScope(scopeAddress(tenant, scope)).pipe( |
| 289 | .forScope(scopeAddress(tenant, scope)) | 291 | Effect.mapError((storeFailure) => { |
| 290 | .pipe( | 292 | const message = storeFailure.message; |
| 291 | Effect.mapError( | 293 | return new PublishError({ message, stage: "project", diagnostics: [] }); |
| 292 | (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), | 294 | }), |
| 293 | ), | 295 | ); |
| 294 | ); | 296 | const raw = yield* scopeStore.readFile(snapshotId, DESCRIPTOR_SNAPSHOT_PATH).pipe( |
| 295 | const raw = yield* scopeStore | 297 | Effect.mapError((storeFailure) => { |
| 296 | .readFile(snapshotId, DESCRIPTOR_SNAPSHOT_PATH) | 298 | const message = storeFailure.message; |
| 297 | .pipe( | 299 | return new PublishError({ message, stage: "project", diagnostics: [] }); |
| 298 | Effect.mapError( | 300 | }), |
| 299 | (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), | 301 | ); |
| 300 | ), | ||
| 301 | ); | ||
| 302 | if (raw == null) return null; | 302 | if (raw == null) return null; |
| 303 | const body = JSON.parse(raw) as Omit<AppDescriptor, "snapshotId">; | 303 | const body = yield* decodeDescriptorJson(raw).pipe( |
| 304 | return { ...body, snapshotId }; | 304 | Effect.mapError( |
| 305 | (_cause) => | ||
| 306 | new PublishError({ | ||
| 307 | message: "descriptor snapshot is not valid JSON", | ||
| 308 | stage: "project", | ||
| 309 | diagnostics: [], | ||
| 310 | }), | ||
| 311 | ), | ||
| 312 | ); | ||
| 313 | return { ...(body as Omit<AppDescriptor, "snapshotId">), snapshotId }; | ||
| 305 | }); | 314 | }); |
| @@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync } from "node:fs"; · 1 unmodified line | |||
| 2 | import { tmpdir } from "node:os"; | 2 | import { tmpdir } from "node:os"; |
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| 5 | import { describe, expect, it } from "vitest"; | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | import { Effect } from "effect"; | 6 | import { Effect } from "effect"; |
| 7 | 7 | ||
| 8 | import { appsPlugin, connectionNameForScope } from "./apps-plugin"; | 8 | import { appsPlugin, connectionNameForScope } from "./apps-plugin"; |
| @@ -20,6 +20,7 @@ import type { AppsRuntime } from "./runtime"; · 19 unmodified lines | |||
| 20 | import { makeAppsStore } from "./store"; | 20 | import { makeAppsStore } from "./store"; |
| 21 | import type { ClientResolver, ConnectionCandidate } from "./bindings"; | 21 | import type { ClientResolver, ConnectionCandidate } from "./bindings"; |
| 22 | import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor"; | 22 | import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor"; |
| 23 | import { PublishError } from "../pipeline/discover"; | ||
| 23 | import { syncGitHubSource, type GitHubSyncResult } from "../source/github-source"; | 24 | import { syncGitHubSource, type GitHubSyncResult } from "../source/github-source"; |
| 24 | 25 | ||
| 25 | export const APPS_INTEGRATION_SLUG = "apps"; | 26 | export const APPS_INTEGRATION_SLUG = "apps"; |
| @@ -119,11 +120,8 @@ const projectInputSchema = ( · 93 unmodified lines | |||
| 119 | }; | 120 | }; |
| 120 | 121 | ||
| 121 | export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { | 122 | export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { |
| 122 | if (!options?.runtime) { | 123 | const runtime = options?.runtime as AppsRuntime; |
| 123 | throw new Error("appsPlugin requires a runtime"); | 124 | const makeResolver = options?.makeResolver; |
| 124 | } | ||
| 125 | const runtime = options.runtime; | ||
| 126 | const makeResolver = options.makeResolver; | ||
| 127 | 125 | ||
| 128 | const tenantFor = (ctx: Pick<PluginCtx, "owner"> | undefined): string => { | 126 | const tenantFor = (ctx: Pick<PluginCtx, "owner"> | undefined): string => { |
| 129 | const tenant = ctx?.owner?.tenant; | 127 | const tenant = ctx?.owner?.tenant; |
| @@ -321,33 +319,31 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 191 unmodified lines | |||
| 321 | const scope = yield* scopeFor(tenant, String(toolRow.connection)); | 319 | const scope = yield* scopeFor(tenant, String(toolRow.connection)); |
| 322 | const descriptor = yield* runtime.getDescriptor(tenant, scope); | 320 | const descriptor = yield* runtime.getDescriptor(tenant, scope); |
| 323 | if (!descriptor) { | 321 | if (!descriptor) { |
| 324 | return yield* Effect.fail( | 322 | return yield* new PublishError({ |
| 325 | new Error( | 323 | message: `apps scope "${scope}" has no published app (connection "${toolRow.connection}")`, |
| 326 | `apps scope "${scope}" has no published app (connection "${toolRow.connection}")`, | 324 | stage: "project", |
| 327 | ), | 325 | diagnostics: [], |
| 328 | ); | 326 | }); |
| 329 | } | 327 | } |
| 330 | const toolDesc = descriptor.tools.find((t) => t.name === toolRow.name); | 328 | const toolDesc = descriptor.tools.find((t) => t.name === toolRow.name); |
| 331 | if (!toolDesc) { | 329 | if (!toolDesc) { |
| 332 | return yield* Effect.fail( | 330 | return yield* new PublishError({ |
| 333 | new Error(`apps tool "${toolRow.name}" is not published in scope "${scope}"`), | 331 | message: `apps tool "${toolRow.name}" is not published in scope "${scope}"`, |
| 334 | ); | 332 | stage: "project", |
| 333 | diagnostics: [], | ||
| 334 | }); | ||
| 335 | } | 335 | } |
| 336 | const resolver = makeResolver | 336 | const resolver = makeResolver |
| 337 | ? makeResolver({ ctx, scope, tool: toolRow.name }) | 337 | ? makeResolver({ ctx, scope, tool: toolRow.name }) |
| 338 | : undefined; | 338 | : undefined; |
| 339 | return yield* runtime | 339 | return yield* runtime.invokeTool({ |
| 340 | .invokeTool({ tenant, scope, tool: toolRow.name, args, resolver, invokeOptions }) | 340 | tenant, |
| 341 | .pipe( | 341 | scope, |
| 342 | Effect.mapError( | 342 | tool: toolRow.name, |
| 343 | (cause) => | 343 | args, |
| 344 | new Error( | 344 | resolver, |
| 345 | "message" in cause && typeof cause.message === "string" | 345 | invokeOptions, |
| 346 | ? cause.message | 346 | }); |
| 347 | : "apps tool invocation failed", | ||
| 348 | ), | ||
| 349 | ), | ||
| 350 | ); | ||
| 351 | }), | 347 | }), |
| 352 | }; | 348 | }; |
| 353 | }); | 349 | }); |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect } from "effect"; | 2 | import { Effect, Exit } from "effect"; |
| 3 | 3 | ||
| 4 | import { buildBridge, resolveIntegrationBindings, type ClientResolver } from "./bindings"; | 4 | import { buildBridge, resolveIntegrationBindings, type ClientResolver } from "./bindings"; |
| 5 | import type { IntegrationDecl } from "../pipeline/descriptor"; | 5 | import type { IntegrationDecl } from "../pipeline/descriptor"; |
| @@ -43,7 +43,7 @@ const okResolver: ClientResolver = { · 37 unmodified lines | |||
| 43 | }; | 43 | }; |
| 44 | 44 | ||
| 45 | const fails = <A, E>(effect: Effect.Effect<A, E>): Promise<boolean> => | 45 | const fails = <A, E>(effect: Effect.Effect<A, E>): Promise<boolean> => |
| 46 | Effect.runPromiseExit(effect).then((exit) => exit._tag === "Failure"); | 46 | Effect.runPromiseExit(effect).then((exit) => Exit.isFailure(exit)); |
| 47 | 47 | ||
| 48 | describe("HandleBridge strict dispatch", () => { | 48 | describe("HandleBridge strict dispatch", () => { |
| 49 | const declared: Record<string, IntegrationDecl> = { | 49 | const declared: Record<string, IntegrationDecl> = { |
| @@ -71,19 +71,6 @@ export interface BindingContext { · 70 unmodified lines | |||
| 71 | const isRecord = (value: unknown): value is Record<string, unknown> => | 71 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| 72 | value !== null && typeof value === "object" && !Array.isArray(value); | 72 | value !== null && typeof value === "object" && !Array.isArray(value); |
| 73 | 73 | ||
| 74 | const bindingError = ( | ||
| 75 | role: string, | ||
| 76 | integration: string, | ||
| 77 | message: string, | ||
| 78 | requestedConnection?: string, | ||
| 79 | ): BindingError => | ||
| 80 | new BindingError({ | ||
| 81 | message, | ||
| 82 | role, | ||
| 83 | integration, | ||
| 84 | requestedConnection, | ||
| 85 | }); | ||
| 86 | |||
| 87 | const findDefaultConnection = ( | 74 | const findDefaultConnection = ( |
| 88 | role: string, | 75 | role: string, |
| 89 | decl: IntegrationDecl, | 76 | decl: IntegrationDecl, |
| @@ -93,15 +80,16 @@ const findDefaultConnection = ( · 3 unmodified lines | |||
| 93 | Effect.flatMap((connections) => { | 80 | Effect.flatMap((connections) => { |
| 94 | if (connections.length === 1) return Effect.succeed(connections[0]!.address); | 81 | if (connections.length === 1) return Effect.succeed(connections[0]!.address); |
| 95 | return Effect.fail( | 82 | return Effect.fail( |
| 96 | bindingError( | 83 | new BindingError({ |
| 97 | role, | 84 | role, |
| 98 | decl.integration, | 85 | integration: decl.integration, |
| 99 | connections.length === 0 | 86 | message: |
| 100 | ? `missing required connection for role "${role}" (${decl.integration}); no connections are available` | 87 | connections.length === 0 |
| 101 | : `missing required connection for role "${role}" (${decl.integration}); choose one of ${connections | 88 | ? `missing required connection for role "${role}" (${decl.integration}); no connections are available` |
| 102 | .map((c) => c.address) | 89 | : `missing required connection for role "${role}" (${decl.integration}); choose one of ${connections |
| 103 | .join(", ")}`, | 90 | .map((c) => c.address) |
| 104 | ), | 91 | .join(", ")}`, |
| 92 | }), | ||
| 105 | ); | 93 | ); |
| 106 | }), | 94 | }), |
| 107 | ); | 95 | ); |
| @@ -116,22 +104,22 @@ const resolveRequestedConnection = ( · 8 unmodified lines | |||
| 116 | Effect.flatMap((connection) => { | 104 | Effect.flatMap((connection) => { |
| 117 | if (!connection) { | 105 | if (!connection) { |
| 118 | return Effect.fail( | 106 | return Effect.fail( |
| 119 | bindingError( | 107 | new BindingError({ |
| 120 | role, | 108 | role, |
| 121 | decl.integration, | 109 | integration: decl.integration, |
| 122 | `unknown connection "${requested}" for role "${role}" (${decl.integration})`, | 110 | message: `unknown connection "${requested}" for role "${role}" (${decl.integration})`, |
| 123 | requested, | 111 | requestedConnection: requested, |
| 124 | ), | 112 | }), |
| 125 | ); | 113 | ); |
| 126 | } | 114 | } |
| 127 | if (connection.integration !== decl.integration) { | 115 | if (connection.integration !== decl.integration) { |
| 128 | return Effect.fail( | 116 | return Effect.fail( |
| 129 | bindingError( | 117 | new BindingError({ |
| 130 | role, | 118 | role, |
| 131 | decl.integration, | 119 | integration: decl.integration, |
| 132 | `connection "${requested}" belongs to integration "${connection.integration}", not "${decl.integration}" for role "${role}"`, | 120 | message: `connection "${requested}" belongs to integration "${connection.integration}", not "${decl.integration}" for role "${role}"`, |
| 133 | requested, | 121 | requestedConnection: requested, |
| 134 | ), | 122 | }), |
| 135 | ); | 123 | ); |
| 136 | } | 124 | } |
| 137 | return Effect.succeed(connection.address); | 125 | return Effect.succeed(connection.address); |
| @@ -168,14 +156,12 @@ export const resolveIntegrationBindings = ( · 30 unmodified lines | |||
| 168 | continue; | 156 | continue; |
| 169 | } | 157 | } |
| 170 | if (typeof raw !== "string" || raw.length === 0) { | 158 | if (typeof raw !== "string" || raw.length === 0) { |
| 171 | return yield* Effect.fail( | 159 | return yield* new BindingError({ |
| 172 | bindingError( | 160 | role, |
| 173 | role, | 161 | integration: decl.integration, |
| 174 | decl.integration, | 162 | message: `connection for role "${role}" (${decl.integration}) must be a non-empty string`, |
| 175 | `connection for role "${role}" (${decl.integration}) must be a non-empty string`, | 163 | ...(typeof raw === "string" ? { requestedConnection: raw } : {}), |
| 176 | typeof raw === "string" ? raw : undefined, | 164 | }); |
| 177 | ), | ||
| 178 | ); | ||
| 179 | } | 165 | } |
| 180 | bindings[role] = yield* resolveRequestedConnection(role, decl, raw, resolver); | 166 | bindings[role] = yield* resolveRequestedConnection(role, decl, raw, resolver); |
| 181 | } | 167 | } |
| @@ -231,13 +217,16 @@ export const buildBridge = (context: BindingContext): HandleBridge => ({ · 49 unmodified lines | |||
| 231 | if (root === "db") { | 217 | if (root === "db") { |
| 232 | if (path.length === 1 && path[0] === "sql") { | 218 | if (path.length === 1 && path[0] === "sql") { |
| 233 | const [strings, ...values] = args as [TemplateStringsArray, ...unknown[]]; | 219 | const [strings, ...values] = args as [TemplateStringsArray, ...unknown[]]; |
| 234 | return context.db | 220 | return context.db.sql(strings, ...values).pipe( |
| 235 | .sql(strings, ...values) | 221 | Effect.mapError( |
| 236 | .pipe( | 222 | (cause) => |
| 237 | Effect.mapError( | 223 | new ToolSandboxError({ |
| 238 | (cause) => new ToolSandboxError({ kind: "invoke", message: cause.message, cause }), | 224 | kind: "invoke", |
| 239 | ), | 225 | message: "scope database call failed", |
| 240 | ); | 226 | cause, |
| 227 | }), | ||
| 228 | ), | ||
| 229 | ); | ||
| 241 | } | 230 | } |
| 242 | return Effect.fail(invokeErr(`unsupported db call: ${path.join(".")}`)); | 231 | return Effect.fail(invokeErr(`unsupported db call: ${path.join(".")}`)); |
| 243 | } | 232 | } |
| @@ -274,7 +263,12 @@ export const buildBridge = (context: BindingContext): HandleBridge => ({ · 30 unmodified lines | |||
| 274 | }) | 263 | }) |
| 275 | .pipe( | 264 | .pipe( |
| 276 | Effect.mapError( | 265 | Effect.mapError( |
| 277 | (cause) => new ToolSandboxError({ kind: "invoke", message: cause.message, cause }), | 266 | (cause) => |
| 267 | new ToolSandboxError({ | ||
| 268 | kind: "invoke", | ||
| 269 | message: "integration call failed", | ||
| 270 | cause, | ||
| 271 | }), | ||
| 278 | ), | 272 | ), |
| 279 | ); | 273 | ); |
| 280 | }, | 274 | }, |
| @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; · 1 unmodified line | |||
| 2 | import { tmpdir } from "node:os"; | 2 | import { tmpdir } from "node:os"; |
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| 5 | import { describe, expect, it } from "vitest"; | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | import { Effect } from "effect"; | 6 | import { Effect } from "effect"; |
| 7 | 7 | ||
| 8 | import { makeSelfHostAppsRuntime } from "./self-host-runtime"; | 8 | import { makeSelfHostAppsRuntime } from "./self-host-runtime"; |
| @@ -60,13 +60,12 @@ describe("concurrent publishes to one scope (Fix 6)", () => { · 51 unmodified lines | |||
| 60 | // Every publish either succeeded or failed with a typed conflict; none | 60 | // Every publish either succeeded or failed with a typed conflict; none |
| 61 | // clobbered another silently. | 61 | // clobbered another silently. |
| 62 | const succeeded = results.filter((r) => r.status === "fulfilled"); | 62 | const succeeded = results.filter((r) => r.status === "fulfilled"); |
| 63 | const rejectedReasons = results.flatMap((r) => | ||
| 64 | r.status === "rejected" ? [JSON.stringify(r.reason)] : [], | ||
| 65 | ); | ||
| 63 | expect(succeeded.length).toBeGreaterThan(0); | 66 | expect(succeeded.length).toBeGreaterThan(0); |
| 64 | for (const r of results) { | 67 | // A conflict is the only acceptable failure mode. |
| 65 | if (r.status === "rejected") { | 68 | expect(rejectedReasons.every((reason) => /conflict/i.test(reason))).toBe(true); |
| 66 | // A conflict is the only acceptable failure mode. | ||
| 67 | expect(String(r.reason)).toMatch(/conflict/i); | ||
| 68 | } | ||
| 69 | } | ||
| 70 | 69 | ||
| 71 | // HEAD and the descriptor pointer AGREE: the store's current descriptor's | 70 | // HEAD and the descriptor pointer AGREE: the store's current descriptor's |
| 72 | // snapshotId equals the artifact store's latest committed snapshot. | 71 | // snapshotId equals the artifact store's latest committed snapshot. |
| @@ -2,8 +2,8 @@ import { mkdtempSync } from "node:fs"; · 1 unmodified line | |||
| 2 | import { tmpdir } from "node:os"; | 2 | import { tmpdir } from "node:os"; |
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| 5 | import { describe, expect, it } from "vitest"; | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | import { Effect } from "effect"; | 6 | import { Effect, Exit } from "effect"; |
| 7 | 7 | ||
| 8 | import { makeSelfHostAppsRuntime } from "./self-host-runtime"; | 8 | import { makeSelfHostAppsRuntime } from "./self-host-runtime"; |
| 9 | import { makeInMemoryAppsStore, makeTestResolver, dailyBriefFileSet } from "../testing"; | 9 | import { makeInMemoryAppsStore, makeTestResolver, dailyBriefFileSet } from "../testing"; |
| @@ -157,11 +157,10 @@ export default defineTool({ · 147 unmodified lines | |||
| 157 | host.runtime.invokeTool({ scope: "s", tool: "sync", args: {} }), | 157 | host.runtime.invokeTool({ scope: "s", tool: "sync", args: {} }), |
| 158 | ); | 158 | ); |
| 159 | 159 | ||
| 160 | expect(exit._tag).toBe("Failure"); | 160 | expect(Exit.isFailure(exit)).toBe(true); |
| 161 | if (exit._tag !== "Failure") throw new Error("expected missing connection failure"); | 161 | expect(JSON.stringify(exit)).toContain("BindingError"); |
| 162 | expect(JSON.stringify(exit.cause)).toContain("BindingError"); | 162 | expect(JSON.stringify(exit)).toContain("crm"); |
| 163 | expect(JSON.stringify(exit.cause)).toContain("crm"); | 163 | expect(JSON.stringify(exit)).toContain("dealcloud"); |
| 164 | expect(JSON.stringify(exit.cause)).toContain("dealcloud"); | ||
| 165 | await host.close(); | 164 | await host.close(); |
| 166 | }); | 165 | }); |
| 167 | 166 | ||
| @@ -204,12 +203,11 @@ export default defineTool({ · 36 unmodified lines | |||
| 204 | }), | 203 | }), |
| 205 | ); | 204 | ); |
| 206 | 205 | ||
| 207 | expect(exit._tag).toBe("Failure"); | 206 | expect(Exit.isFailure(exit)).toBe(true); |
| 208 | if (exit._tag !== "Failure") throw new Error("expected wrong integration failure"); | 207 | expect(JSON.stringify(exit)).toContain("BindingError"); |
| 209 | expect(JSON.stringify(exit.cause)).toContain("BindingError"); | 208 | expect(JSON.stringify(exit)).toContain("github"); |
| 210 | expect(JSON.stringify(exit.cause)).toContain("github"); | 209 | expect(JSON.stringify(exit)).toContain("dealcloud"); |
| 211 | expect(JSON.stringify(exit.cause)).toContain("dealcloud"); | 210 | expect(JSON.stringify(exit)).toContain("tools.github.user.main"); |
| 212 | expect(JSON.stringify(exit.cause)).toContain("tools.github.user.main"); | ||
| 213 | await host.close(); | 211 | await host.close(); |
| 214 | }); | 212 | }); |
| 215 | 213 | ||
| @@ -1,4 +1,4 @@ | |||
| 1 | import { Effect } from "effect"; | 1 | import { Effect, Predicate } from "effect"; |
| 2 | import type { InvokeOptions } from "@executor-js/sdk"; | 2 | import type { InvokeOptions } from "@executor-js/sdk"; |
| 3 | 3 | ||
| 4 | import type { ArtifactStore } from "../seams/artifact-store"; | 4 | import type { ArtifactStore } from "../seams/artifact-store"; |
| @@ -100,6 +100,13 @@ const failNoDescriptor = (scope: string): PublishError => · 95 unmodified lines | |||
| 100 | diagnostics: [], | 100 | diagnostics: [], |
| 101 | }); | 101 | }); |
| 102 | 102 | ||
| 103 | const isInvokePassthroughError = ( | ||
| 104 | cause: unknown, | ||
| 105 | ): cause is BindingError | InputValidationError | OutputValidationError => | ||
| 106 | Predicate.isTagged("BindingError")(cause) || | ||
| 107 | Predicate.isTagged("InputValidationError")(cause) || | ||
| 108 | Predicate.isTagged("OutputValidationError")(cause); | ||
| 109 | |||
| 103 | export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { | 110 | export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { |
| 104 | const defaultTenant = deps.defaultTenant ?? "org"; | 111 | const defaultTenant = deps.defaultTenant ?? "org"; |
| 105 | const bundleCache = new Map<string, string>(); | 112 | const bundleCache = new Map<string, string>(); |
| @@ -120,10 +127,17 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 14 unmodified lines | |||
| 120 | ): Promise<A> => { | 127 | ): Promise<A> => { |
| 121 | const key = `${tenant}:${scope}`; | 128 | const key = `${tenant}:${scope}`; |
| 122 | const prior = publishChains.get(key) ?? Promise.resolve(); | 129 | const prior = publishChains.get(key) ?? Promise.resolve(); |
| 123 | const next = prior.catch(() => undefined).then(run); | 130 | const afterPrior = prior.then( |
| 131 | () => undefined, | ||
| 132 | () => undefined, | ||
| 133 | ); | ||
| 134 | const next = afterPrior.then(run); | ||
| 124 | publishChains.set( | 135 | publishChains.set( |
| 125 | key, | 136 | key, |
| 126 | next.catch(() => undefined), | 137 | next.then( |
| 138 | () => undefined, | ||
| 139 | () => undefined, | ||
| 140 | ), | ||
| 127 | ); | 141 | ); |
| 128 | return next; | 142 | return next; |
| 129 | }; | 143 | }; |
| @@ -293,11 +307,7 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 163 unmodified lines | |||
| 293 | ) | 307 | ) |
| 294 | .pipe( | 308 | .pipe( |
| 295 | Effect.mapError((c) => { | 309 | Effect.mapError((c) => { |
| 296 | if ( | 310 | if (isInvokePassthroughError(c)) { |
| 297 | c instanceof BindingError || | ||
| 298 | c instanceof InputValidationError || | ||
| 299 | c instanceof OutputValidationError | ||
| 300 | ) { | ||
| 301 | return c; | 311 | return c; |
| 302 | } | 312 | } |
| 303 | return new PublishError({ | 313 | return new PublishError({ |
| @@ -340,9 +350,9 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 36 unmodified lines | |||
| 340 | ), | 350 | ), |
| 341 | ); | 351 | ); |
| 342 | }, | 352 | }, |
| 343 | catch: (cause) => | 353 | catch: (_cause) => |
| 344 | new PublishError({ | 354 | new PublishError({ |
| 345 | message: cause instanceof Error ? cause.message : String(cause), | 355 | message: "publish failed before pipeline completed", |
| 346 | stage: "project", | 356 | stage: "project", |
| 347 | diagnostics: [], | 357 | diagnostics: [], |
| 348 | }), | 358 | }), |
| @@ -404,13 +414,11 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 55 unmodified lines | |||
| 404 | const descriptor = yield* requireDescriptor(tenant, input.scope); | 414 | const descriptor = yield* requireDescriptor(tenant, input.scope); |
| 405 | const toolDesc = descriptor.tools.find((t) => t.name === input.tool); | 415 | const toolDesc = descriptor.tools.find((t) => t.name === input.tool); |
| 406 | if (!toolDesc) { | 416 | if (!toolDesc) { |
| 407 | return yield* Effect.fail( | 417 | return yield* new PublishError({ |
| 408 | new PublishError({ | 418 | message: `tool "${input.tool}" is not published in scope "${input.scope}"`, |
| 409 | message: `tool "${input.tool}" is not published in scope "${input.scope}"`, | 419 | stage: "project", |
| 410 | stage: "project", | 420 | diagnostics: [], |
| 411 | diagnostics: [], | 421 | }); |
| 412 | }), | ||
| 413 | ); | ||
| 414 | } | 422 | } |
| 415 | return yield* invokeToolInternal( | 423 | return yield* invokeToolInternal( |
| 416 | input.scope, | 424 | input.scope, |
| @@ -2,7 +2,7 @@ import { mkdtempSync, readdirSync } from "node:fs"; · 1 unmodified line | |||
| 2 | import { tmpdir } from "node:os"; | 2 | import { tmpdir } from "node:os"; |
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| 5 | import { describe, expect, it } from "vitest"; | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | import { Effect } from "effect"; | 6 | import { Effect } from "effect"; |
| 7 | 7 | ||
| 8 | import { makeSqliteAppsStore } from "../backing/sqlite-apps-store"; | 8 | import { makeSqliteAppsStore } from "../backing/sqlite-apps-store"; |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { useMemo, useState } from "react"; | 1 | import { useMemo, useState } from "react"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import { Effect, Exit } from "effect"; | ||
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | 5 | ||
| 5 | import { connectionsAllAtom } from "@executor-js/react/api/atoms"; | 6 | import { connectionsAllAtom } from "@executor-js/react/api/atoms"; |
| @@ -20,7 +21,7 @@ import { · 14 unmodified lines | |||
| 20 | consoleIntegrationHref, | 21 | consoleIntegrationHref, |
| 21 | formatSyncErrors, | 22 | formatSyncErrors, |
| 22 | githubConnections, | 23 | githubConnections, |
| 23 | syncCustomToolSource, | 24 | syncCustomToolSourceEffect, |
| 24 | syncStatusLabel, | 25 | syncStatusLabel, |
| 25 | validateGitHubRepo, | 26 | validateGitHubRepo, |
| 26 | } from "./custom-tools-client"; | 27 | } from "./custom-tools-client"; |
| @@ -54,23 +55,26 @@ export default function AddCustomToolsSource(props: { · 27 unmodified lines | |||
| 54 | if (validation || !selectedConnection) return; | 55 | if (validation || !selectedConnection) return; |
| 55 | 56 | ||
| 56 | setSyncing(true); | 57 | setSyncing(true); |
| 57 | try { | 58 | const exit = await Effect.runPromiseExit( |
| 58 | const result = await syncCustomToolSource({ | 59 | syncCustomToolSourceEffect({ |
| 59 | repo, | 60 | repo, |
| 60 | ref, | 61 | ref, |
| 61 | connection: selectedConnection, | 62 | connection: selectedConnection, |
| 62 | }); | 63 | }), |
| 63 | if (result.status === "failed") { | 64 | ); |
| 64 | setSyncError(formatSyncErrors(result).join("\n") || "Sync failed."); | 65 | if (Exit.isFailure(exit)) { |
| 65 | setSyncing(false); | 66 | setSyncError("Failed to sync custom tools."); |
| 66 | return; | ||
| 67 | } | ||
| 68 | toast.success(syncStatusLabel(result)); | ||
| 69 | props.onComplete("apps"); | ||
| 70 | } catch (error) { | ||
| 71 | setSyncError(error instanceof Error ? error.message : "Failed to sync custom tools."); | ||
| 72 | setSyncing(false); | 67 | setSyncing(false); |
| 68 | return; | ||
| 73 | } | 69 | } |
| 70 | const result = exit.value; | ||
| 71 | if (result.status === "failed") { | ||
| 72 | setSyncError(formatSyncErrors(result).join("\n") || "Sync failed."); | ||
| 73 | setSyncing(false); | ||
| 74 | return; | ||
| 75 | } | ||
| 76 | toast.success(syncStatusLabel(result)); | ||
| 77 | props.onComplete("apps"); | ||
| 74 | }; | 78 | }; |
| 75 | 79 | ||
| 76 | return ( | 80 | return ( |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { useEffect, useMemo, useState, type ReactNode } from "react"; | 1 | import { useEffect, useState, type ReactNode } from "react"; |
| 2 | import { Effect, Exit } from "effect"; | ||
| 2 | 3 | ||
| 3 | import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert"; | 4 | import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert"; |
| 4 | import { Badge } from "@executor-js/react/components/badge"; | 5 | import { Badge } from "@executor-js/react/components/badge"; |
| @@ -17,8 +18,8 @@ import type { GitHubCustomToolsSourceSummary, GitHubSyncResult } from "../api"; · 12 unmodified lines | |||
| 17 | import { | 18 | import { |
| 18 | consoleIntegrationHref, | 19 | consoleIntegrationHref, |
| 19 | formatSyncErrors, | 20 | formatSyncErrors, |
| 20 | listCustomToolSources, | 21 | listCustomToolSourcesEffect, |
| 21 | syncCustomToolSource, | 22 | syncCustomToolSourceEffect, |
| 22 | syncStatusLabel, | 23 | syncStatusLabel, |
| 23 | toolDiff, | 24 | toolDiff, |
| 24 | } from "./custom-tools-client"; | 25 | } from "./custom-tools-client"; |
| @@ -49,31 +50,33 @@ export default function CustomToolsAccountsPanel() { · 24 unmodified lines | |||
| 49 | 50 | ||
| 50 | const loadSources = async () => { | 51 | const loadSources = async () => { |
| 51 | setLoadState({ status: "loading" }); | 52 | setLoadState({ status: "loading" }); |
| 52 | try { | 53 | const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect()); |
| 53 | const result = await listCustomToolSources(); | 54 | if (Exit.isFailure(exit)) { |
| 54 | setLoadState({ status: "ready", sources: result.sources }); | ||
| 55 | setSelectedScope((current) => current ?? result.sources[0]?.scope ?? null); | ||
| 56 | } catch (error) { | ||
| 57 | setLoadState({ | 55 | setLoadState({ |
| 58 | status: "error", | 56 | status: "error", |
| 59 | message: error instanceof Error ? error.message : "Failed to load custom tool sources.", | 57 | message: "Failed to load custom tool sources.", |
| 60 | }); | 58 | }); |
| 59 | return; | ||
| 61 | } | 60 | } |
| 61 | const result = exit.value; | ||
| 62 | setLoadState({ status: "ready", sources: result.sources }); | ||
| 63 | setSelectedScope((current) => current ?? result.sources[0]?.scope ?? null); | ||
| 62 | }; | 64 | }; |
| 63 | 65 | ||
| 64 | useEffect(() => { | 66 | useEffect(() => { |
| 65 | let active = true; | 67 | let active = true; |
| 66 | void (async () => { | 68 | void (async () => { |
| 67 | try { | 69 | const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect()); |
| 68 | const result = await listCustomToolSources(); | 70 | if (Exit.isSuccess(exit)) { |
| 69 | if (!active) return; | 71 | if (!active) return; |
| 72 | const result = exit.value; | ||
| 70 | setLoadState({ status: "ready", sources: result.sources }); | 73 | setLoadState({ status: "ready", sources: result.sources }); |
| 71 | setSelectedScope(result.sources[0]?.scope ?? null); | 74 | setSelectedScope(result.sources[0]?.scope ?? null); |
| 72 | } catch (error) { | 75 | } else { |
| 73 | if (!active) return; | 76 | if (!active) return; |
| 74 | setLoadState({ | 77 | setLoadState({ |
| 75 | status: "error", | 78 | status: "error", |
| 76 | message: error instanceof Error ? error.message : "Failed to load custom tool sources.", | 79 | message: "Failed to load custom tool sources.", |
| 77 | }); | 80 | }); |
| 78 | } | 81 | } |
| 79 | })(); | 82 | })(); |
| @@ -83,10 +86,7 @@ export default function CustomToolsAccountsPanel() { · 3 unmodified lines | |||
| 83 | }, []); | 86 | }, []); |
| 84 | 87 | ||
| 85 | const sources = loadState.status === "ready" ? loadState.sources : []; | 88 | const sources = loadState.status === "ready" ? loadState.sources : []; |
| 86 | const selected = useMemo( | 89 | const selected = sources.find((source) => source.scope === selectedScope) ?? sources[0] ?? null; |
| 87 | () => sources.find((source) => source.scope === selectedScope) ?? sources[0] ?? null, | ||
| 88 | [sources, selectedScope], | ||
| 89 | ); | ||
| 90 | 90 | ||
| 91 | const syncSelected = async (source: GitHubCustomToolsSourceSummary) => { | 91 | const syncSelected = async (source: GitHubCustomToolsSourceSummary) => { |
| 92 | if (!source.connection) { | 92 | if (!source.connection) { |
| @@ -102,35 +102,37 @@ export default function CustomToolsAccountsPanel() { · 9 unmodified lines | |||
| 102 | setSyncingScope(source.scope); | 102 | setSyncingScope(source.scope); |
| 103 | setNotice(null); | 103 | setNotice(null); |
| 104 | const beforeTools = source.tools; | 104 | const beforeTools = source.tools; |
| 105 | try { | 105 | const exit = await Effect.runPromiseExit( |
| 106 | const result = await syncCustomToolSource({ | 106 | syncCustomToolSourceEffect({ |
| 107 | repo: source.repo, | 107 | repo: source.repo, |
| 108 | ref: source.ref, | 108 | ref: source.ref, |
| 109 | connection: source.connection, | 109 | connection: source.connection, |
| 110 | }); | 110 | }), |
| 111 | const diff = | 111 | ); |
| 112 | result.status === "failed" | 112 | if (Exit.isFailure(exit)) { |
| 113 | ? { added: [], removed: [] } | ||
| 114 | : toolDiff(beforeTools, result.tools); | ||
| 115 | setNotice({ | ||
| 116 | status: result.status, | ||
| 117 | message: syncStatusLabel(result), | ||
| 118 | added: diff.added, | ||
| 119 | removed: diff.removed, | ||
| 120 | errors: formatSyncErrors(result), | ||
| 121 | }); | ||
| 122 | if (result.status !== "failed") { | ||
| 123 | await loadSources(); | ||
| 124 | setSelectedScope(source.scope); | ||
| 125 | } | ||
| 126 | } catch (error) { | ||
| 127 | setNotice({ | 113 | setNotice({ |
| 128 | status: "failed", | 114 | status: "failed", |
| 129 | message: "Sync failed.", | 115 | message: "Sync failed.", |
| 130 | added: [], | 116 | added: [], |
| 131 | removed: [], | 117 | removed: [], |
| 132 | errors: [error instanceof Error ? error.message : "Failed to sync custom tools."], | 118 | errors: ["Failed to sync custom tools."], |
| 133 | }); | 119 | }); |
| 120 | setSyncingScope(null); | ||
| 121 | return; | ||
| 122 | } | ||
| 123 | const result = exit.value; | ||
| 124 | const diff = | ||
| 125 | result.status === "failed" ? { added: [], removed: [] } : toolDiff(beforeTools, result.tools); | ||
| 126 | setNotice({ | ||
| 127 | status: result.status, | ||
| 128 | message: syncStatusLabel(result), | ||
| 129 | added: diff.added, | ||
| 130 | removed: diff.removed, | ||
| 131 | errors: formatSyncErrors(result), | ||
| 132 | }); | ||
| 133 | if (result.status !== "failed") { | ||
| 134 | await loadSources(); | ||
| 135 | setSelectedScope(source.scope); | ||
| 134 | } | 136 | } |
| 135 | setSyncingScope(null); | 137 | setSyncingScope(null); |
| 136 | }; | 138 | }; |
| @@ -181,16 +183,17 @@ export default function CustomToolsAccountsPanel() { · 44 unmodified lines | |||
| 181 | asChild | 183 | asChild |
| 182 | searchText={`${source.repo} ${source.ref}`} | 184 | searchText={`${source.repo} ${source.ref}`} |
| 183 | > | 185 | > |
| 184 | <button | 186 | <Button |
| 185 | type="button" | 187 | type="button" |
| 188 | variant="ghost" | ||
| 186 | onClick={() => { | 189 | onClick={() => { |
| 187 | setSelectedScope(source.scope); | 190 | setSelectedScope(source.scope); |
| 188 | setNotice(null); | 191 | setNotice(null); |
| 189 | }} | 192 | }} |
| 190 | className={ | 193 | className={ |
| 191 | selected?.scope === source.scope | 194 | selected?.scope === source.scope |
| 192 | ? "bg-muted/70 text-left" | 195 | ? "h-auto w-full justify-start bg-muted/70 p-0 text-left" |
| 193 | : "bg-transparent text-left" | 196 | : "h-auto w-full justify-start bg-transparent p-0 text-left" |
| 194 | } | 197 | } |
| 195 | > | 198 | > |
| 196 | <CardStackEntryContent> | 199 | <CardStackEntryContent> |
| @@ -200,7 +203,7 @@ export default function CustomToolsAccountsPanel() { · 3 unmodified lines | |||
| 200 | <CardStackEntryActions> | 203 | <CardStackEntryActions> |
| 201 | <Badge variant="secondary">{source.tools.length}</Badge> | 204 | <Badge variant="secondary">{source.tools.length}</Badge> |
| 202 | </CardStackEntryActions> | 205 | </CardStackEntryActions> |
| 203 | </button> | 206 | </Button> |
| 204 | </CardStackEntry> | 207 | </CardStackEntry> |
| 205 | ))} | 208 | ))} |
| 206 | </CardStackContent> | 209 | </CardStackContent> |
| @@ -1,4 +1,5 @@ | |||
| 1 | import type { Connection } from "@executor-js/sdk/shared"; | 1 | import type { Connection } from "@executor-js/sdk/shared"; |
| 2 | import { Data, Effect, Schema } from "effect"; | ||
| 2 | 3 | ||
| 3 | import type { GitHubCustomToolsSourceSummary, GitHubSyncResult } from "../api"; | 4 | import type { GitHubCustomToolsSourceSummary, GitHubSyncResult } from "../api"; |
| 4 | 5 | ||
| @@ -15,12 +16,11 @@ export interface SyncGitHubSourceRequest { · 10 unmodified lines | |||
| 15 | readonly connection: string; | 16 | readonly connection: string; |
| 16 | } | 17 | } |
| 17 | 18 | ||
| 18 | export class CustomToolsClientError extends Error { | 19 | export type CustomToolsFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>; |
| 19 | constructor(message: string) { | 20 | |
| 20 | super(message); | 21 | export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{ |
| 21 | this.name = "CustomToolsClientError"; | 22 | readonly message: string; |
| 22 | } | 23 | }> {} |
| 23 | } | ||
| 24 | 24 | ||
| 25 | const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; | 25 | const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; |
| 26 | 26 | ||
| @@ -34,47 +34,83 @@ export const validateGitHubRepo = (repo: string): string | null => { · 7 unmodified lines | |||
| 34 | export const githubConnections = (connections: readonly Connection[]): readonly Connection[] => | 34 | export const githubConnections = (connections: readonly Connection[]): readonly Connection[] => |
| 35 | connections.filter((connection) => String(connection.integration) === "github"); | 35 | connections.filter((connection) => String(connection.integration) === "github"); |
| 36 | 36 | ||
| 37 | const parseJsonResponse = async <A>(response: Response, fallback: string): Promise<A> => { | 37 | const decodeJsonText = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); |
| 38 | const text = await response.text(); | ||
| 39 | const body = text.length > 0 ? (JSON.parse(text) as unknown) : null; | ||
| 40 | if (!response.ok) { | ||
| 41 | const message = | ||
| 42 | body && | ||
| 43 | typeof body === "object" && | ||
| 44 | "error" in body && | ||
| 45 | typeof (body as { error?: unknown }).error === "string" | ||
| 46 | ? (body as { error: string }).error | ||
| 47 | : fallback; | ||
| 48 | throw new CustomToolsClientError(message); | ||
| 49 | } | ||
| 50 | return body as A; | ||
| 51 | }; | ||
| 52 | 38 | ||
| 53 | export const listCustomToolSources = async ( | 39 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| 54 | fetchImpl: typeof fetch = fetch, | 40 | value !== null && typeof value === "object" && !Array.isArray(value); |
| 55 | ): Promise<GitHubSourcesListResponse> => { | 41 | |
| 56 | const response = await fetchImpl("/api/apps/sources/github", { | 42 | const responseErrorMessage = (body: unknown, fallback: string): string => |
| 57 | credentials: "same-origin", | 43 | isRecord(body) && typeof body.error === "string" ? body.error : fallback; |
| 44 | |||
| 45 | const parseJsonResponseEffect = <A>( | ||
| 46 | response: Response, | ||
| 47 | fallback: string, | ||
| 48 | ): Effect.Effect<A, CustomToolsClientError> => | ||
| 49 | Effect.gen(function* () { | ||
| 50 | const text = yield* Effect.tryPromise({ | ||
| 51 | try: () => response.text(), | ||
| 52 | catch: () => new CustomToolsClientError({ message: fallback }), | ||
| 53 | }); | ||
| 54 | const body = | ||
| 55 | text.length > 0 | ||
| 56 | ? yield* decodeJsonText(text).pipe( | ||
| 57 | Effect.mapError(() => new CustomToolsClientError({ message: fallback })), | ||
| 58 | ) | ||
| 59 | : null; | ||
| 60 | if (!response.ok) { | ||
| 61 | return yield* new CustomToolsClientError({ | ||
| 62 | message: responseErrorMessage(body, fallback), | ||
| 63 | }); | ||
| 64 | } | ||
| 65 | return body as A; | ||
| 58 | }); | 66 | }); |
| 59 | return parseJsonResponse<GitHubSourcesListResponse>(response, "Failed to load custom tools."); | ||
| 60 | }; | ||
| 61 | 67 | ||
| 62 | export const syncCustomToolSource = async ( | 68 | export const listCustomToolSourcesEffect = ( |
| 69 | fetchImpl: CustomToolsFetch = fetch, | ||
| 70 | ): Effect.Effect<GitHubSourcesListResponse, CustomToolsClientError> => | ||
| 71 | Effect.tryPromise({ | ||
| 72 | try: () => | ||
| 73 | fetchImpl("/api/apps/sources/github", { | ||
| 74 | credentials: "same-origin", | ||
| 75 | }), | ||
| 76 | catch: () => new CustomToolsClientError({ message: "Failed to load custom tools." }), | ||
| 77 | }).pipe( | ||
| 78 | Effect.flatMap((response) => | ||
| 79 | parseJsonResponseEffect<GitHubSourcesListResponse>(response, "Failed to load custom tools."), | ||
| 80 | ), | ||
| 81 | ); | ||
| 82 | |||
| 83 | export const listCustomToolSources = ( | ||
| 84 | fetchImpl: CustomToolsFetch = fetch, | ||
| 85 | ): Promise<GitHubSourcesListResponse> => Effect.runPromise(listCustomToolSourcesEffect(fetchImpl)); | ||
| 86 | |||
| 87 | export const syncCustomToolSourceEffect = ( | ||
| 63 | input: SyncGitHubSourceRequest, | 88 | input: SyncGitHubSourceRequest, |
| 64 | fetchImpl: typeof fetch = fetch, | 89 | fetchImpl: CustomToolsFetch = fetch, |
| 65 | ): Promise<GitHubSyncResult> => { | 90 | ): Effect.Effect<GitHubSyncResult, CustomToolsClientError> => |
| 66 | const response = await fetchImpl("/api/apps/sources/github/sync", { | 91 | Effect.tryPromise({ |
| 67 | method: "POST", | 92 | try: () => |
| 68 | headers: { "content-type": "application/json" }, | 93 | fetchImpl("/api/apps/sources/github/sync", { |
| 69 | credentials: "same-origin", | 94 | method: "POST", |
| 70 | body: JSON.stringify({ | 95 | headers: { "content-type": "application/json" }, |
| 71 | repo: input.repo.trim(), | 96 | credentials: "same-origin", |
| 72 | ...(input.ref?.trim() ? { ref: input.ref.trim() } : {}), | 97 | body: JSON.stringify({ |
| 73 | connection: input.connection, | 98 | repo: input.repo.trim(), |
| 74 | }), | 99 | ...(input.ref?.trim() ? { ref: input.ref.trim() } : {}), |
| 75 | }); | 100 | connection: input.connection, |
| 76 | return parseJsonResponse<GitHubSyncResult>(response, "Failed to sync custom tools."); | 101 | }), |
| 77 | }; | 102 | }), |
| 103 | catch: () => new CustomToolsClientError({ message: "Failed to sync custom tools." }), | ||
| 104 | }).pipe( | ||
| 105 | Effect.flatMap((response) => | ||
| 106 | parseJsonResponseEffect<GitHubSyncResult>(response, "Failed to sync custom tools."), | ||
| 107 | ), | ||
| 108 | ); | ||
| 109 | |||
| 110 | export const syncCustomToolSource = ( | ||
| 111 | input: SyncGitHubSourceRequest, | ||
| 112 | fetchImpl: CustomToolsFetch = fetch, | ||
| 113 | ): Promise<GitHubSyncResult> => Effect.runPromise(syncCustomToolSourceEffect(input, fetchImpl)); | ||
| 78 | 114 | ||
| 79 | export const syncStatusLabel = (result: GitHubSyncResult): string => { | 115 | export const syncStatusLabel = (result: GitHubSyncResult): string => { |
| 80 | if (result.status === "published") return `Published ${result.tools.length} tools.`; | 116 | if (result.status === "published") return `Published ${result.tools.length} tools.`; |
| @@ -84,11 +120,11 @@ export const syncStatusLabel = (result: GitHubSyncResult): string => { · 3 unmodified lines | |||
| 84 | 120 | ||
| 85 | export const formatSyncErrors = (result: GitHubSyncResult): readonly string[] => { | 121 | export const formatSyncErrors = (result: GitHubSyncResult): readonly string[] => { |
| 86 | if (result.status !== "failed") return []; | 122 | if (result.status !== "failed") return []; |
| 87 | return result.errors.map((error) => { | 123 | return result.errors.map((entry) => { |
| 88 | const details = error.diagnostics?.map((d) => `${d.path}: ${d.message}`).join("; "); | 124 | const message = entry.message; |
| 89 | return details | 125 | const stage = entry.stage; |
| 90 | ? `${error.stage}: ${error.message} (${details})` | 126 | const details = entry.diagnostics?.map((d) => `${d.path}: ${d.message}`).join("; "); |
| 91 | : `${error.stage}: ${error.message}`; | 127 | return details ? `${stage}: ${message} (${details})` : `${stage}: ${message}`; |
| 92 | }); | 128 | }); |
| 93 | }; | 129 | }; |
| 94 | 130 | ||
| @@ -1,4 +1,4 @@ | |||
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect } from "effect"; | 2 | import { Effect } from "effect"; |
| 3 | 3 | ||
| 4 | import type { ArtifactStore, FileSet } from "./artifact-store"; | 4 | import type { ArtifactStore, FileSet } from "./artifact-store"; |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect } from "effect"; | 2 | import { Effect } from "effect"; |
| 3 | 3 | ||
| 4 | import type { ScopeDb } from "./scope-db"; | 4 | import type { ScopeDb } from "./scope-db"; |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect } from "effect"; | 2 | import { Effect, Exit } from "effect"; |
| 3 | 3 | ||
| 4 | import type { HandleBridge, ToolSandbox } from "./tool-sandbox"; | 4 | import type { HandleBridge, ToolSandbox } from "./tool-sandbox"; |
| 5 | import { bundleEntry } from "../pipeline/bundle"; | 5 | import { bundleEntry } from "../pipeline/bundle"; |
| @@ -34,7 +34,7 @@ import { z } from "zod"; · 28 unmodified lines | |||
| 34 | export default defineTool({ description: "x" + Math.random(), input: z.object({}), async handler(){ return {}; } });`; | 34 | export default defineTool({ description: "x" + Math.random(), input: z.object({}), async handler(){ return {}; } });`; |
| 35 | const rngBundle = await bundle("tools/rng.ts", rng); | 35 | const rngBundle = await bundle("tools/rng.ts", rng); |
| 36 | const exit = await Effect.runPromiseExit(sandbox.collect(rngBundle)); | 36 | const exit = await Effect.runPromiseExit(sandbox.collect(rngBundle)); |
| 37 | expect(exit._tag).toBe("Failure"); | 37 | expect(Exit.isFailure(exit)).toBe(true); |
| 38 | }); | 38 | }); |
| 39 | 39 | ||
| 40 | it("denies network access", async () => { | 40 | it("denies network access", async () => { |
| @@ -47,7 +47,7 @@ export default defineTool({ description: "net", input: z.object({}), async handl · 6 unmodified lines | |||
| 47 | const exit = await Effect.runPromiseExit( | 47 | const exit = await Effect.runPromiseExit( |
| 48 | sandbox.invoke(b, { artifact: "net", kind: "tool", input: {}, roots: {} }, bridge), | 48 | sandbox.invoke(b, { artifact: "net", kind: "tool", input: {}, roots: {} }, bridge), |
| 49 | ); | 49 | ); |
| 50 | expect(exit._tag).toBe("Failure"); | 50 | expect(Exit.isFailure(exit)).toBe(true); |
| 51 | }); | 51 | }); |
| 52 | 52 | ||
| 53 | it("kills a runaway handler on timeout", async () => { | 53 | it("kills a runaway handler on timeout", async () => { |
| @@ -60,7 +60,7 @@ export default defineTool({ description: "loop", input: z.object({}), async hand · 6 unmodified lines | |||
| 60 | const exit = await Effect.runPromiseExit( | 60 | const exit = await Effect.runPromiseExit( |
| 61 | sandbox.invoke(b, { artifact: "loop", kind: "tool", input: {}, roots: {} }, bridge), | 61 | sandbox.invoke(b, { artifact: "loop", kind: "tool", input: {}, roots: {} }, bridge), |
| 62 | ); | 62 | ); |
| 63 | expect(exit._tag).toBe("Failure"); | 63 | expect(Exit.isFailure(exit)).toBe(true); |
| 64 | }); | 64 | }); |
| 65 | 65 | ||
| 66 | it("round-trips the handle bridge for a declared integration role", async () => { | 66 | it("round-trips the handle bridge for a declared integration role", async () => { |
| @@ -2,8 +2,8 @@ import { mkdtempSync } from "node:fs"; · 1 unmodified line | |||
| 2 | import { tmpdir } from "node:os"; | 2 | import { tmpdir } from "node:os"; |
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| 5 | import { describe, expect, it } from "vitest"; | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | import { Effect } from "effect"; | 6 | import { Effect, Exit } from "effect"; |
| 7 | 7 | ||
| 8 | import { makeSelfHostAppsRuntime } from "../plugin/self-host-runtime"; | 8 | import { makeSelfHostAppsRuntime } from "../plugin/self-host-runtime"; |
| 9 | import { makeInMemoryAppsStore, makeTestResolver } from "../testing"; | 9 | import { makeInMemoryAppsStore, makeTestResolver } from "../testing"; |
| @@ -208,7 +208,7 @@ describe("GitHub custom-tools source", () => { · 198 unmodified lines | |||
| 208 | const exit = await Effect.runPromiseExit( | 208 | const exit = await Effect.runPromiseExit( |
| 209 | fetchGitHubSource({ repo: "acme/tools", fetch: github.fetch }), | 209 | fetchGitHubSource({ repo: "acme/tools", fetch: github.fetch }), |
| 210 | ); | 210 | ); |
| 211 | expect(exit._tag).toBe("Failure"); | 211 | expect(Exit.isFailure(exit)).toBe(true); |
| 212 | expect(JSON.stringify(exit)).toContain("exceeding the limit"); | 212 | expect(JSON.stringify(exit)).toContain("exceeding the limit"); |
| 213 | expect(github.blobCalls()).toBe(0); | 213 | expect(github.blobCalls()).toBe(0); |
| 214 | }); | 214 | }); |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { Data, Effect, Result } from "effect"; | 1 | import { Data, Effect, Predicate, Result, Schema } from "effect"; |
| 2 | 2 | ||
| 3 | import { PublishError, type FileDiagnostic } from "../pipeline/discover"; | 3 | import { PublishError, type FileDiagnostic } from "../pipeline/discover"; |
| 4 | import { PUBLISH_LIMITS, enforcePublishLimits } from "../pipeline/publish"; | 4 | import { PUBLISH_LIMITS, enforcePublishLimits } from "../pipeline/publish"; |
| @@ -106,37 +106,42 @@ const requestJson = <A>( · 101 unmodified lines | |||
| 106 | input: GitHubSourceInput, | 106 | input: GitHubSourceInput, |
| 107 | path: string, | 107 | path: string, |
| 108 | ): Effect.Effect<A, GitHubSourceError> => | 108 | ): Effect.Effect<A, GitHubSourceError> => |
| 109 | Effect.tryPromise({ | 109 | Effect.gen(function* () { |
| 110 | try: async () => { | 110 | const response = yield* Effect.tryPromise({ |
| 111 | const fetchImpl = input.fetch ?? globalThis.fetch; | 111 | try: async () => { |
| 112 | const headers: Record<string, string> = { | 112 | const fetchImpl = input.fetch ?? globalThis.fetch; |
| 113 | accept: "application/vnd.github+json", | 113 | const headers: Record<string, string> = { |
| 114 | "user-agent": "executor-apps-github-source", | 114 | accept: "application/vnd.github+json", |
| 115 | }; | 115 | "user-agent": "executor-apps-github-source", |
| 116 | if (input.token) headers.authorization = `Bearer ${input.token}`; | 116 | }; |
| 117 | const response = await fetchImpl( | 117 | if (input.token) headers.authorization = `Bearer ${input.token}`; |
| 118 | `${trimBaseUrl(input.baseUrl ?? "https://api.github.com")}${path}`, | 118 | return fetchImpl(`${trimBaseUrl(input.baseUrl ?? "https://api.github.com")}${path}`, { |
| 119 | { | ||
| 120 | headers, | 119 | headers, |
| 121 | }, | ||
| 122 | ); | ||
| 123 | if (!response.ok) { | ||
| 124 | throw new GitHubSourceError({ | ||
| 125 | message: `GitHub request failed: GET ${path} -> ${response.status}`, | ||
| 126 | status: response.status, | ||
| 127 | path, | ||
| 128 | }); | 120 | }); |
| 129 | } | 121 | }, |
| 130 | return (await response.json()) as A; | 122 | catch: (cause) => |
| 131 | }, | 123 | new GitHubSourceError({ |
| 132 | catch: (cause) => | 124 | message: `GitHub request failed: GET ${path}`, |
| 133 | cause instanceof GitHubSourceError | 125 | path, |
| 134 | ? cause | 126 | cause, |
| 135 | : new GitHubSourceError({ | 127 | }), |
| 136 | message: `GitHub request failed: GET ${path}`, | 128 | }); |
| 137 | path, | 129 | if (!response.ok) { |
| 138 | cause, | 130 | return yield* new GitHubSourceError({ |
| 139 | }), | 131 | message: `GitHub request failed: GET ${path} -> ${response.status}`, |
| 132 | status: response.status, | ||
| 133 | path, | ||
| 134 | }); | ||
| 135 | } | ||
| 136 | return yield* Effect.tryPromise({ | ||
| 137 | try: () => response.json() as Promise<A>, | ||
| 138 | catch: (cause) => | ||
| 139 | new GitHubSourceError({ | ||
| 140 | message: `GitHub response was not valid JSON: GET ${path}`, | ||
| 141 | path, | ||
| 142 | cause, | ||
| 143 | }), | ||
| 144 | }); | ||
| 140 | }); | 145 | }); |
| 141 | 146 | ||
| 142 | interface RepoResponse { | 147 | interface RepoResponse { |
| @@ -254,42 +259,39 @@ const classifyTreeEntry = ( · 111 unmodified lines | |||
| 254 | }; | 259 | }; |
| 255 | 260 | ||
| 256 | const decodeBlob = (path: string, blob: BlobResponse): Effect.Effect<string, GitHubSourceError> => | 261 | const decodeBlob = (path: string, blob: BlobResponse): Effect.Effect<string, GitHubSourceError> => |
| 257 | Effect.try({ | 262 | Effect.gen(function* () { |
| 258 | try: () => { | 263 | const encoding = asString(blob.encoding); |
| 259 | const encoding = asString(blob.encoding); | 264 | const content = asString(blob.content); |
| 260 | const content = asString(blob.content); | 265 | if (!content || encoding !== "base64") { |
| 261 | if (!content || encoding !== "base64") { | 266 | return yield* new GitHubSourceError({ |
| 262 | throw new GitHubSourceError({ | 267 | message: `GitHub blob ${path} did not return base64 content`, |
| 263 | message: `GitHub blob ${path} did not return base64 content`, | 268 | path, |
| 264 | path, | 269 | }); |
| 265 | }); | 270 | } |
| 266 | } | 271 | return Buffer.from(content.replace(/\s/g, ""), "base64").toString("utf8"); |
| 267 | return Buffer.from(content.replace(/\s/g, ""), "base64").toString("utf8"); | ||
| 268 | }, | ||
| 269 | catch: (cause) => | ||
| 270 | cause instanceof GitHubSourceError | ||
| 271 | ? cause | ||
| 272 | : new GitHubSourceError({ message: `Failed to decode GitHub blob ${path}`, path, cause }), | ||
| 273 | }); | 272 | }); |
| 274 | 273 | ||
| 274 | const decodeExecutorJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); | ||
| 275 | |||
| 275 | const executorDescription = ( | 276 | const executorDescription = ( |
| 276 | files: FileSet, | 277 | files: FileSet, |
| 277 | ): Effect.Effect<string | undefined, GitHubSourceError> => | 278 | ): Effect.Effect<string | undefined, GitHubSourceError> => |
| 278 | Effect.try({ | 279 | Effect.gen(function* () { |
| 279 | try: () => { | 280 | const raw = files.get("executor.json"); |
| 280 | const raw = files.get("executor.json"); | 281 | if (!raw) return undefined; |
| 281 | if (!raw) return undefined; | 282 | const parsed = yield* decodeExecutorJson(raw).pipe( |
| 282 | const parsed = JSON.parse(raw) as unknown; | 283 | Effect.mapError( |
| 283 | if (!isRecord(parsed)) return undefined; | 284 | (cause) => |
| 284 | const description = parsed.description; | 285 | new GitHubSourceError({ |
| 285 | return typeof description === "string" ? description : undefined; | 286 | message: "executor.json is not valid JSON", |
| 286 | }, | 287 | path: "executor.json", |
| 287 | catch: (cause) => | 288 | cause, |
| 288 | new GitHubSourceError({ | 289 | }), |
| 289 | message: "executor.json is not valid JSON", | 290 | ), |
| 290 | path: "executor.json", | 291 | ); |
| 291 | cause, | 292 | if (!isRecord(parsed)) return undefined; |
| 292 | }), | 293 | const description = parsed.description; |
| 294 | return typeof description === "string" ? description : undefined; | ||
| 293 | }); | 295 | }); |
| 294 | 296 | ||
| 295 | export const fetchGitHubSource = ( | 297 | export const fetchGitHubSource = ( |
| @@ -336,7 +338,7 @@ export const fetchGitHubSource = ( · 40 unmodified lines | |||
| 336 | else skipped.push(classified.skipped); | 338 | else skipped.push(classified.skipped); |
| 337 | } | 339 | } |
| 338 | const treeLimitError = checkTreeLimits(entries); | 340 | const treeLimitError = checkTreeLimits(entries); |
| 339 | if (treeLimitError) return yield* Effect.fail(treeLimitError); | 341 | if (treeLimitError) return yield* treeLimitError; |
| 340 | 342 | ||
| 341 | const files = new Map<string, string>(); | 343 | const files = new Map<string, string>(); |
| 342 | for (const entry of entries) { | 344 | for (const entry of entries) { |
| @@ -348,7 +350,7 @@ export const fetchGitHubSource = ( · 5 unmodified lines | |||
| 348 | files.set(path, yield* decodeBlob(path, blob)); | 350 | files.set(path, yield* decodeBlob(path, blob)); |
| 349 | } | 351 | } |
| 350 | const payloadLimitError = enforcePublishLimits(files); | 352 | const payloadLimitError = enforcePublishLimits(files); |
| 351 | if (payloadLimitError) return yield* Effect.fail(payloadLimitError); | 353 | if (payloadLimitError) return yield* payloadLimitError; |
| 352 | 354 | ||
| 353 | return { | 355 | return { |
| 354 | files, | 356 | files, |
| @@ -360,17 +362,22 @@ export const fetchGitHubSource = ( · 5 unmodified lines | |||
| 360 | }; | 362 | }; |
| 361 | }); | 363 | }); |
| 362 | 364 | ||
| 363 | const publishErrorToSyncError = (error: PublishError): SyncErrorData => ({ | 365 | const publishErrorToSyncError = (publishFailure: PublishError): SyncErrorData => { |
| 364 | stage: error.stage, | 366 | const stage = publishFailure.stage; |
| 365 | message: error.message, | 367 | const message = publishFailure.message; |
| 366 | diagnostics: error.diagnostics, | 368 | const diagnostics = publishFailure.diagnostics; |
| 367 | }); | 369 | return { stage, message, diagnostics }; |
| 370 | }; | ||
| 368 | 371 | ||
| 369 | const sourceErrorToSyncError = (error: GitHubSourceError): SyncErrorData => ({ | 372 | const sourceErrorToSyncError = (sourceFailure: GitHubSourceError): SyncErrorData => { |
| 370 | stage: "source", | 373 | const message = sourceFailure.message; |
| 371 | message: error.message, | 374 | const path = sourceFailure.path; |
| 372 | diagnostics: error.path ? [{ path: error.path, message: error.message }] : [], | 375 | return { |
| 373 | }); | 376 | stage: "source", |
| 377 | message, | ||
| 378 | diagnostics: path ? [{ path, message }] : [], | ||
| 379 | }; | ||
| 380 | }; | ||
| 374 | 381 | ||
| 375 | const sourceRef = (snapshot: GitHubSourceSnapshot, connection?: string): AppSourceRef => ({ | 382 | const sourceRef = (snapshot: GitHubSourceSnapshot, connection?: string): AppSourceRef => ({ |
| 376 | kind: "github", | 383 | kind: "github", |
| @@ -391,7 +398,7 @@ export const syncGitHubSource = (input: SyncGitHubSourceInput): Effect.Effect<Gi · 14 unmodified lines | |||
| 391 | tools: [], | 398 | tools: [], |
| 392 | skipped: [], | 399 | skipped: [], |
| 393 | errors: [ | 400 | errors: [ |
| 394 | error instanceof PublishError | 401 | Predicate.isTagged("PublishError")(error) |
| 395 | ? publishErrorToSyncError(error) | 402 | ? publishErrorToSyncError(error) |
| 396 | : sourceErrorToSyncError(error), | 403 | : sourceErrorToSyncError(error), |
| 397 | ], | 404 | ], |
| @@ -2,7 +2,7 @@ import { Effect } from "effect"; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import type { AppDescriptor } from "../pipeline/descriptor"; | 3 | import type { AppDescriptor } from "../pipeline/descriptor"; |
| 4 | import type { AppsStore } from "../plugin/store"; | 4 | import type { AppsStore } from "../plugin/store"; |
| 5 | import type { BindingError, ClientResolver, ConnectionCandidate } from "../plugin/bindings"; | 5 | import { BindingError, type ClientResolver, type ConnectionCandidate } from "../plugin/bindings"; |
| 6 | import { | 6 | import { |
| 7 | ArtifactStoreError, | 7 | ArtifactStoreError, |
| 8 | asSnapshotId, | 8 | asSnapshotId, |
| @@ -123,12 +123,13 @@ export const makeTestResolver = ( · 114 unmodified lines | |||
| 123 | calls.push({ integration, connection, method }); | 123 | calls.push({ integration, connection, method }); |
| 124 | const handler = handlers[integration]?.[method]; | 124 | const handler = handlers[integration]?.[method]; |
| 125 | if (!handler) { | 125 | if (!handler) { |
| 126 | return Effect.fail({ | 126 | return Effect.fail( |
| 127 | _tag: "BindingError", | 127 | new BindingError({ |
| 128 | message: `no test handler for ${integration}.${method}`, | 128 | message: `no test handler for ${integration}.${method}`, |
| 129 | role: integration, | 129 | role: integration, |
| 130 | integration, | 130 | integration, |
| 131 | } as unknown as BindingError); | 131 | }), |
| 132 | ); | ||
| 132 | } | 133 | } |
| 133 | return Effect.sync(() => handler(args)); | 134 | return Effect.sync(() => handler(args)); |
| 134 | }, | 135 | }, |