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