History

Make custom tools lint clean

Rhys Sullivan committed 4 months ago

Commit f754da51 parent 2a35fef33 files · +681 −549Browse files

Unified · Split

33 files
  1. apps/host-selfhost/src/apps-resolver.ts +34 −44
  2. apps/host-selfhost/src/apps-route.ts +18 −16
  3. apps/host-selfhost/src/apps-wire.node.test.ts +58 −30
  4. apps/host-selfhost/src/custom-tools-console.web.test.ts +8 −9
  5. packages/plugins/apps/CHANGELOG.md +1 −0
  6. packages/plugins/apps/src/backing/git-artifact-store.cas.test.ts +1 −1
  7. packages/plugins/apps/src/backing/git-artifact-store.ts +12 −8
  8. packages/plugins/apps/src/backing/libsql-scope-db.ts +1 −1
  9. packages/plugins/apps/src/backing/quickjs-tool-sandbox.test.ts +7 −8
  10. packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts +41 −37
  11. packages/plugins/apps/src/backing/scope-collision.test.ts +1 −1
  12. packages/plugins/apps/src/backing/sqlite-apps-store.ts +35 −10
  13. packages/plugins/apps/src/pipeline/bundle.ts +37 −10
  14. packages/plugins/apps/src/pipeline/descriptor.ts +1 −1
  15. packages/plugins/apps/src/pipeline/publish.test.ts +9 −11
  16. packages/plugins/apps/src/pipeline/publish.ts +64 −55
  17. packages/plugins/apps/src/plugin/apps-plugin.test.ts +1 −1
  18. packages/plugins/apps/src/plugin/apps-plugin.ts +21 −25
  19. packages/plugins/apps/src/plugin/bindings.strict.test.ts +3 −3
  20. packages/plugins/apps/src/plugin/bindings.ts +41 −47
  21. packages/plugins/apps/src/plugin/publish-concurrency.test.ts +6 −7
  22. packages/plugins/apps/src/plugin/runtime.test.ts +11 −13
  23. packages/plugins/apps/src/plugin/runtime.ts +25 −17
  24. packages/plugins/apps/src/plugin/tenant-isolation.test.ts +1 −1
  25. packages/plugins/apps/src/react/AddCustomToolsSource.tsx +17 −13
  26. packages/plugins/apps/src/react/CustomToolsAccountsPanel.tsx +44 −41
  27. packages/plugins/apps/src/react/custom-tools-client.ts +84 −48
  28. packages/plugins/apps/src/seams/artifact-store.conformance.ts +1 −1
  29. packages/plugins/apps/src/seams/scope-db.conformance.ts +1 −1
  30. packages/plugins/apps/src/seams/tool-sandbox.conformance.ts +5 −5
  31. packages/plugins/apps/src/source/github-source.test.ts +3 −3
  32. packages/plugins/apps/src/source/github-source.ts +81 −74
  33. packages/plugins/apps/src/testing/index.ts +8 −7
apps/host-selfhost/src/apps-resolver.ts+34−44
@@ -6,7 +6,6 @@ import { · 5 unmodified lines
66 ToolAddress,
77 isToolResult,
88 type ConnectionRef,
9− type ExecuteError,
109 type Owner,
1110 type PluginCtx,
1211 } from "@executor-js/sdk";
@@ -29,22 +28,6 @@ const parseConnectionAddress = (address: string): ConnectionRef | null => { · 16 unmodified lines
2928 };
3029 };
3130
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−
4831 const toCandidate = (connection: {
4932 readonly address: unknown;
5033 readonly integration: unknown;
@@ -61,12 +44,13 @@ export const makeSelfHostAppsResolver = (input: { readonly ctx: PluginCtx }): Cl · 10 unmodified lines
6144 listConnections: ({ integration }) =>
6245 input.ctx.connections.list({ integration: IntegrationSlug.make(integration) }).pipe(
6346 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+ }),
7054 ),
7155 ),
7256
@@ -75,13 +59,14 @@ export const makeSelfHostAppsResolver = (input: { readonly ctx: PluginCtx }): Cl · 2 unmodified lines
7559 const ref = parseConnectionAddress(connection);
7660 if (!ref) return null;
7761 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+ }),
8570 ),
8671 );
8772 return row ? toCandidate(row) : null;
@@ -91,16 +76,18 @@ export const makeSelfHostAppsResolver = (input: { readonly ctx: PluginCtx }): Cl · 3 unmodified lines
9176 Effect.gen(function* () {
9277 const ref = parseConnectionAddress(connection);
9378 if (!ref) {
94− return yield* bindingError({
79+ return yield* new BindingError({
80+ role: integration,
9581 integration,
96− connection,
82+ requestedConnection: connection,
9783 message: `invalid connection address ${connection}`,
9884 });
9985 }
10086 if (String(ref.integration) !== integration) {
101− return yield* bindingError({
87+ return yield* new BindingError({
88+ role: integration,
10289 integration,
103− connection,
90+ requestedConnection: connection,
10491 message: `connection "${connection}" belongs to integration "${ref.integration}", not "${integration}"`,
10592 });
10693 }
@@ -108,21 +95,24 @@ export const makeSelfHostAppsResolver = (input: { readonly ctx: PluginCtx }): Cl · 1 unmodified line
10895 const address = ToolAddress.make(`${connection}.${tool}`);
10996 const payload = args[0] ?? {};
11097 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+ }),
118106 ),
119107 );
120108 if (isToolResult(result)) {
121109 if (result.ok) return result.data;
122− return yield* bindingError({
110+ const { message } = result.error;
111+ return yield* new BindingError({
112+ role: integration,
123113 integration,
124− connection,
125− message: result.error.message,
114+ requestedConnection: connection,
115+ message,
126116 });
127117 }
128118 return result;
apps/host-selfhost/src/apps-route.ts+18−16
@@ -1,5 +1,5 @@
11 import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
2−import { Data, Effect, Layer } from "effect";
2+import { Data, Effect, Layer, Predicate } from "effect";
33
44 import {
55 IdentityProvider,
@@ -19,6 +19,9 @@ class AppsSyncRouteError extends Data.TaggedError("AppsSyncRouteError")<{ · 13 unmodified lines
1919 readonly message: string;
2020 }> {}
2121
22+const isAppsSyncRouteError = (failure: unknown): failure is AppsSyncRouteError =>
23+ Predicate.isTagged("AppsSyncRouteError")(failure);
24+
2225 const parseJson = (request: Request): Effect.Effect<unknown, AppsSyncRouteError> =>
2326 Effect.tryPromise({
2427 try: () => request.json() as Promise<unknown>,
@@ -50,29 +53,28 @@ const routeHandler = ( · 25 unmodified lines
5053 ): Effect.Effect<HttpServerResponse.HttpServerResponse, never, unknown> =>
5154 Effect.gen(function* () {
5255 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+ }
5462 return yield* run(request).pipe(
5563 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)) {
6066 return Effect.succeed(HttpServerResponse.text("Unauthorized", { status: 401 }));
6167 }
62− if (tag === "NoOrganization") {
68+ if (Predicate.isTagged("NoOrganization")(failure)) {
6369 return Effect.succeed(HttpServerResponse.text("Forbidden", { status: 403 }));
6470 }
65− if (tag === "Unavailable") {
71+ if (Predicate.isTagged("Unavailable")(failure)) {
6672 return Effect.succeed(HttpServerResponse.text("Unavailable", { status: 503 }));
6773 }
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 }));
7678 }
7779 return Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 }));
7880 }),
apps/host-selfhost/src/apps-wire.node.test.ts+58−30
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; · 5 unmodified lines
66 import { join } from "node:path";
77
88 import { afterAll, beforeAll, expect, test } from "@effect/vitest";
9+import { Effect, Schema } from "effect";
910 import { createEmulator, type Emulator } from "@executor-js/emulate";
1011
1112 const dataDir = mkdtempSync(join(tmpdir(), "eh-apps-wire-"));
@@ -69,16 +70,30 @@ const jsonHeaders = { · 57 unmodified lines
6970 "x-test-org": TEST_ORG,
7071 };
7172
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+
7281 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+ );
8297
8398 const readRequestBody = async (request: IncomingMessage): Promise<Buffer | undefined> => {
8499 if (request.method === "GET" || request.method === "HEAD") return undefined;
@@ -118,29 +133,42 @@ const startHttpServer = async ( · 33 unmodified lines
118133 ): Promise<TestHttpServer> => {
119134 let port = 0;
120135 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+ );
137151 });
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+ );
138162 return {
139163 baseUrl: `http://127.0.0.1:${port}`,
140164 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+ ),
144172 };
145173 };
146174
@@ -152,7 +180,7 @@ const requestJson = async <T>( · 5 unmodified lines
152180 const response = await fetch(`${server.baseUrl}${path}`, init);
153181 const text = await response.text();
154182 expect(response.status).toBe(expectedStatus);
155− return (text.length > 0 ? JSON.parse(text) : null) as T;
183+ return parseJsonText<T>(text);
156184 };
157185
158186 const postJson = <T>(path: string, body: unknown, expectedStatus = 200): Promise<T> =>
@@ -179,7 +207,7 @@ const githubFetch = async <T>( · 20 unmodified lines
179207 const response = await fetch(`${emulator.url}${path}`, { ...init, headers });
180208 const text = await response.text();
181209 expect(response.ok).toBe(true);
182− return (text.length > 0 ? JSON.parse(text) : null) as T;
210+ return parseJsonText<T>(text);
183211 };
184212
185213 const createIssue = (
apps/host-selfhost/src/custom-tools-console.web.test.ts+8−9
@@ -1,8 +1,9 @@
1−import { describe, expect, it, vi } from "@effect/vitest";
1+import { describe, expect, it } from "@effect/vitest";
22
33 import appsClientPlugin, {
44 CUSTOM_TOOLS_LABEL,
55 CUSTOM_TOOLS_PLUGIN_KEY,
6+ type CustomToolsFetch,
67 appsIntegrationPlugin,
78 formatSyncErrors,
89 listCustomToolSources,
@@ -42,7 +43,7 @@ describe("custom tools console client", () => { · 33 unmodified lines
4243 });
4344
4445 it("surfaces successful sync and source detail data", async () => {
45− const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
46+ const fetchImpl: CustomToolsFetch = async (input) => {
4647 const url = String(input);
4748 if (url.endsWith("/sync")) {
4849 return jsonResponse({
@@ -68,7 +69,7 @@ describe("custom tools console client", () => { · 19 unmodified lines
6869 },
6970 ],
7071 });
71− }) as unknown as typeof fetch;
72+ };
7273
7374 const syncResult = await syncCustomToolSource(
7475 {
@@ -85,7 +86,7 @@ describe("custom tools console client", () => { · 10 unmodified lines
8586 });
8687
8788 it("renders failed sync errors readably", async () => {
88− const fetchImpl = vi.fn(async () =>
89+ const fetchImpl: CustomToolsFetch = async () =>
8990 jsonResponse({
9091 status: "failed",
9192 tools: [],
@@ -97,8 +98,7 @@ describe("custom tools console client", () => { · 5 unmodified lines
9798 diagnostics: [{ path: "tools/x.ts", message: "vendor: nope" }],
9899 },
99100 ],
100− }),
101− ) as unknown as typeof fetch;
101+ });
102102
103103 const result = await syncCustomToolSource(
104104 {
@@ -115,14 +115,13 @@ describe("custom tools console client", () => { · 10 unmodified lines
115115 });
116116
117117 it("shows the up-to-date sync state", async () => {
118− const fetchImpl = vi.fn(async () =>
118+ const fetchImpl: CustomToolsFetch = async () =>
119119 jsonResponse({
120120 status: "up-to-date",
121121 upstreamSha: "abc123",
122122 tools: ["repo-summary"],
123123 skipped: [],
124− }),
125− ) as unknown as typeof fetch;
124+ });
126125
127126 const result = await syncCustomToolSource(
128127 {
packages/plugins/apps/CHANGELOG.md+1−0
@@ -0,0 +1 @@
1+# @executor-js/plugin-apps
packages/plugins/apps/src/backing/git-artifact-store.cas.test.ts+1−1
@@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; · 1 unmodified line
22 import { tmpdir } from "node:os";
33 import { join } from "node:path";
44
5−import { describe, expect, it } from "vitest";
5+import { describe, expect, it } from "@effect/vitest";
66 import { Effect } from "effect";
77
88 import { makeGitArtifactStore } from "./git-artifact-store";
packages/plugins/apps/src/backing/git-artifact-store.ts+12−8
@@ -30,6 +30,14 @@ const BRANCH = "refs/heads/main"; · 29 unmodified lines
3030 // scope repo). git treats the empty string as "the ref must not already exist".
3131 const EMPTY_OID = "";
3232
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+
3341 const run = (
3442 cwd: string,
3543 args: readonly string[],
@@ -45,7 +53,7 @@ const run = ( · 9 unmodified lines
4553 resume(
4654 Effect.fail(
4755 new ArtifactStoreError({
48− message: `git ${args[0]} failed: ${(stderr as Buffer)?.toString() || error.message}`,
56+ message: gitFailureMessage(args[0] ?? "command", stderr),
4957 cause: error,
5058 }),
5159 ),
@@ -114,7 +122,7 @@ const makeScopeStore = (repoDir: string): ScopeArtifactStore => { · 62 unmodified lines
114122 resume(
115123 Effect.fail(
116124 new ArtifactStoreError({
117− message: `git ${args[0]} failed: ${(stderr as Buffer)?.toString() || error.message}`,
125+ message: gitFailureMessage(args[0] ?? "command", stderr),
118126 cause: error,
119127 }),
120128 ),
@@ -159,7 +167,7 @@ const makeScopeStore = (repoDir: string): ScopeArtifactStore => { · 38 unmodified lines
159167 resume(
160168 Effect.fail(
161169 new ArtifactStoreError({
162− message: `git commit-tree failed: ${(stderr as Buffer)?.toString() || error.message}`,
170+ message: gitFailureMessage("commit-tree", stderr),
163171 cause: error,
164172 }),
165173 ),
@@ -271,11 +279,7 @@ export const makeGitArtifactStore = (options: GitArtifactStoreOptions): Artifact · 105 unmodified lines
271279 const init = async (key: string): Promise<ScopeArtifactStore> => {
272280 const repoDir = join(options.root, `${key}.git`);
273281 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"]));
279283 return makeScopeStore(repoDir);
280284 };
281285
packages/plugins/apps/src/backing/libsql-scope-db.ts+1−1
@@ -86,7 +86,7 @@ const makeHandle = (address: ScopeAddress, client: Client): ScopeDbHandle => { · 85 unmodified lines
8686 await bump(targets);
8787 }
8888 }
89− return result.rows as unknown as readonly Row[];
89+ return result.rows.map((row) => row as Row);
9090 },
9191 catch: (cause) =>
9292 new ScopeDbError({ message: `scope-db statement failed for scope ${label}`, cause }),
packages/plugins/apps/src/backing/quickjs-tool-sandbox.test.ts+7−8
@@ -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";
33
44 import { bundleEntry } from "../pipeline/bundle";
55 import { makeQuickjsToolSandbox } from "./quickjs-tool-sandbox";
@@ -100,10 +100,9 @@ export default defineTool({ · 94 unmodified lines
100100 ),
101101 );
102102
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");
107106 });
108107
109108 it("invokes raw JSON Schema tools without sandbox Standard Schema validation", async () => {
@@ -139,7 +138,7 @@ export default defineTool({ · 29 unmodified lines
139138 const files = new Map([["tools/rng.ts", nondeterministic]]);
140139 const { code } = await run(bundleEntry({ files, entry: "tools/rng.ts" }));
141140 const exit = await Effect.runPromiseExit(sandbox.collect(code));
142− expect(exit._tag).toBe("Failure");
141+ expect(Exit.isFailure(exit)).toBe(true);
143142 });
144143
145144 it("denies network (fetch throws in the sandbox)", async () => {
@@ -156,7 +155,7 @@ export default defineTool({ · 10 unmodified lines
156155 const exit = await Effect.runPromiseExit(
157156 sandbox.invoke(code, { artifact: "net", kind: "tool", input: {}, roots: {} }, bridge),
158157 );
159− expect(exit._tag).toBe("Failure");
158+ expect(Exit.isFailure(exit)).toBe(true);
160159 });
161160
162161 it("returns typed InputValidationError from promise rejection", async () => {
packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts+41−37
@@ -1,4 +1,4 @@
1−import { Effect } from "effect";
1+import { Effect, Option, Predicate, Schema } from "effect";
22 import type { SandboxToolInvoker } from "@executor-js/codemode-core";
33 import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
44
@@ -85,14 +85,13 @@ ${bundle} · 80 unmodified lines
8585 })(module, exports, require);
8686 `;
8787
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+
8893 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));
9695 const direct = parse(message);
9796 if (direct) return direct;
9897 const firstBrace = message.indexOf("{");
@@ -104,15 +103,20 @@ const parseMarker = (message: string): unknown => { · 5 unmodified lines
104103 };
105104
106105 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);
109108 };
110109
111110 const markerMessage = (marker: Record<string, unknown>, fallback: string): string =>
112111 typeof marker.message === "string" ? marker.message : fallback;
113112
114113 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));
116120
117121 // Collect driver: describe the artifact's integrations + input/output schema.
118122 // Deterministic JSON only — no handler execution.
@@ -236,7 +240,10 @@ const buildInvokeCode = (bundle: string, request: InvokeRequest): string => · 117 unmodified lines
236240 // A no-op invoker for collect: no handle calls should happen; if they do
237241 // (misbehaving describe path), fail loudly.
238242 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+ ),
240247 };
241248
242249 export interface QuickjsToolSandboxOptions {
@@ -286,13 +293,11 @@ export const makeQuickjsToolSandbox = (options: QuickjsToolSandboxOptions = {}): · 43 unmodified lines
286293 const a = stableStringify(first);
287294 const b = stableStringify(second);
288295 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+ });
296301 }
297302 const descriptor = first as { artifact?: string };
298303 const result: CollectResult = {
@@ -318,8 +323,11 @@ export const makeQuickjsToolSandbox = (options: QuickjsToolSandboxOptions = {}): · 19 unmodified lines
318323 // through an unexpected channel.
319324 if (input.path !== "__handle__") {
320325 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+ );
323331 }
324332 const call = input.args as {
325333 root: string;
@@ -327,7 +335,9 @@ export const makeQuickjsToolSandbox = (options: QuickjsToolSandboxOptions = {}): · 1 unmodified line
327335 args: readonly unknown[];
328336 };
329337 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+ );
331341 }
332342 return bridge.call({ root: call.root, path: call.path, args: call.args }) as never;
333343 },
@@ -343,24 +353,18 @@ export const makeQuickjsToolSandbox = (options: QuickjsToolSandboxOptions = {}): · 9 unmodified lines
343353 if (result.error) {
344354 const marker = parseMarker(result.error);
345355 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+ });
352360 }
353361 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+ });
360366 }
361− return yield* Effect.fail(
362− new ToolSandboxError({ kind: "invoke", message: result.error }),
363− );
367+ return yield* new ToolSandboxError({ kind: "invoke", message: result.error });
364368 }
365369 return { output: result.result, logs: result.logs ?? [] } satisfies InvokeResult;
366370 }),
packages/plugins/apps/src/backing/scope-collision.test.ts+1−1
@@ -2,7 +2,7 @@ import { mkdtempSync, readdirSync } from "node:fs"; · 1 unmodified line
22 import { tmpdir } from "node:os";
33 import { join } from "node:path";
44
5−import { describe, expect, it } from "vitest";
5+import { describe, expect, it } from "@effect/vitest";
66 import { Effect } from "effect";
77
88 import { makeLibsqlScopeDb } from "./libsql-scope-db";
packages/plugins/apps/src/backing/sqlite-apps-store.ts+35−10
@@ -2,9 +2,9 @@ import { mkdirSync } from "node:fs"; · 1 unmodified line
22 import { dirname, resolve } from "node:path";
33
44 import { createClient, type Client } from "@libsql/client";
5−import { Effect } from "effect";
5+import { Effect, Schema } from "effect";
66
7−import type { StorageFailure } from "@executor-js/sdk";
7+import { StorageError, type StorageFailure } from "@executor-js/sdk";
88
99 import type { AppDescriptor } from "../pipeline/descriptor";
1010 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
2222 `;
2323
2424 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+ );
2637
2738 export interface SqliteAppsStoreOptions {
2839 readonly path: string;
@@ -73,10 +84,16 @@ export const makeSqliteAppsStore = (options: SqliteAppsStoreOptions): AppsStore · 44 unmodified lines
7384 args: [tenant, scope],
7485 });
7586 const row = res.rows[0];
76− return row ? (JSON.parse(String(row.descriptor)) as AppDescriptor) : null;
87+ return row?.descriptor ?? null;
7788 },
7889 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+ ),
8097 listDescriptors: (tenant) =>
8198 Effect.tryPromise({
8299 try: async () => {
@@ -85,13 +102,21 @@ export const makeSqliteAppsStore = (options: SqliteAppsStoreOptions): AppsStore · 2 unmodified lines
85102 sql: "SELECT descriptor, published_at FROM descriptors WHERE tenant = ? ORDER BY published_at DESC",
86103 args: [tenant],
87104 });
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;
92106 },
93107 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+ ),
95120 putScopeForConnection: (tenant, connectionName, scope) =>
96121 Effect.tryPromise({
97122 try: async () => {
packages/plugins/apps/src/pipeline/bundle.ts+37−10
@@ -1,6 +1,6 @@
11 import { build, version as esbuildVersion } from "esbuild";
22
3−import { Effect } from "effect";
3+import { Effect, Option, Schema } from "effect";
44
55 import { ToolSandboxError } from "../seams/tool-sandbox";
66 import type { ToolchainRef } from "./descriptor";
@@ -52,6 +52,25 @@ export interface BundleOutput { · 45 unmodified lines
5252 readonly code: string;
5353 }
5454
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+
5574 const resolveRelative = (base: string, rel: string): string => {
5675 const parts = (base ? base.split("/") : []).concat(rel.split("/"));
5776 const out: string[] = [];
@@ -166,8 +185,8 @@ const fileSetPlugin = (files: ReadonlyMap<string, string>, authorEntry: string) · 108 unmodified lines
166185 /** Bundle one entry from the file set to a single CJS string. */
167186 export const bundleEntry = (input: BundleInput): Effect.Effect<BundleOutput, ToolSandboxError> =>
168187 Effect.tryPromise({
169− try: async () => {
170− const result = await build({
188+ try: () =>
189+ build({
171190 entryPoints: [VIRTUAL_ENTRY],
172191 bundle: true,
173192 write: false,
@@ -180,15 +199,23 @@ export const bundleEntry = (input: BundleInput): Effect.Effect<BundleOutput, Too · 6 unmodified lines
180199 logLevel: "silent",
181200 external: [...PLATFORM_MODULES],
182201 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+ }),
188203 catch: (cause) =>
189204 new ToolSandboxError({
190205 kind: "bundle",
191− message: `bundle failed for ${input.entry}: ${cause instanceof Error ? cause.message : String(cause)}`,
206+ message: bundleFailureMessage(input.entry, cause),
192207 cause,
193208 }),
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+ );
packages/plugins/apps/src/pipeline/descriptor.ts+1−1
@@ -108,7 +108,7 @@ export const stableStringify = (value: unknown): string => { · 107 unmodified lines
108108 const seen = new WeakSet<object>();
109109 const walk = (v: unknown): unknown => {
110110 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]";
112112 seen.add(v);
113113 if (Array.isArray(v)) return v.map(walk);
114114 const out: Record<string, unknown> = {};
packages/plugins/apps/src/pipeline/publish.test.ts+9−11
@@ -2,8 +2,8 @@ import { mkdtempSync } from "node:fs"; · 1 unmodified line
22 import { tmpdir } from "node:os";
33 import { join } from "node:path";
44
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";
77
88 import { makeGitArtifactStore } from "../backing/git-artifact-store";
99 import { makeQuickjsToolSandbox } from "../backing/quickjs-tool-sandbox";
@@ -89,7 +89,7 @@ export default defineTool({ · 79 unmodified lines
8989
9090 const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files }));
9191
92− expect(exit._tag).toBe("Failure");
92+ expect(Exit.isFailure(exit)).toBe(true);
9393 expect(JSON.stringify(exit)).toContain("collides");
9494 expect(JSON.stringify(exit)).toContain("crm");
9595 });
@@ -117,7 +117,7 @@ export default defineTool({ · 21 unmodified lines
117117
118118 const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files }));
119119
120− expect(exit._tag).toBe("Failure");
120+ expect(Exit.isFailure(exit)).toBe(true);
121121 expect(JSON.stringify(exit)).toContain("vendor-without-json-schema");
122122 expect(JSON.stringify(exit)).toContain("vendor");
123123 expect(JSON.stringify(exit)).toContain("input");
@@ -155,10 +155,8 @@ export default defineTool({ · 31 unmodified lines
155155 ],
156156 ]);
157157 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");
162160 });
163161
164162 it("rejects an oversized publish set (too many files) and persists nothing", async () => {
@@ -168,7 +166,7 @@ export default defineTool({ · 3 unmodified lines
168166 files.set(`tools/t${i}.ts`, "// noop");
169167 }
170168 const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files }));
171− expect(exit._tag).toBe("Failure");
169+ expect(Exit.isFailure(exit)).toBe(true);
172170 expect(JSON.stringify(exit)).toContain("exceeding the limit");
173171 const latest = await run(
174172 Effect.flatMap(deps.artifactStore.forScope(scopeAddress("org", "s")), (s) => s.latest()),
@@ -181,7 +179,7 @@ export default defineTool({ · 6 unmodified lines
181179 const big = "x".repeat(PUBLISH_LIMITS.maxFileBytes + 1);
182180 const files = new Map([["tools/big.ts", big]]);
183181 const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files }));
184− expect(exit._tag).toBe("Failure");
182+ expect(Exit.isFailure(exit)).toBe(true);
185183 expect(JSON.stringify(exit)).toContain("per-file limit");
186184 });
187185
@@ -192,7 +190,7 @@ export default defineTool({ · 4 unmodified lines
192190 const count = Math.ceil(PUBLISH_LIMITS.maxTotalBytes / PUBLISH_LIMITS.maxFileBytes) + 1;
193191 for (let i = 0; i < count; i++) files.set(`tools/t${i}.ts`, half);
194192 const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files }));
195− expect(exit._tag).toBe("Failure");
193+ expect(Exit.isFailure(exit)).toBe(true);
196194 expect(JSON.stringify(exit)).toContain("total limit");
197195 });
198196 });
packages/plugins/apps/src/pipeline/publish.ts+64−55
@@ -1,4 +1,4 @@
1−import { Effect } from "effect";
1+import { Effect, Predicate, Schema } from "effect";
22
33 import type { ArtifactStore, FileSet, SnapshotId } from "../seams/artifact-store";
44 import { scopeAddress } from "../seams/scope-address";
@@ -155,33 +155,39 @@ export interface PublishDeps { · 150 unmodified lines
155155 readonly sandbox: ToolSandbox;
156156 }
157157
158+const isPublishError = Predicate.isTagged("PublishError") as (
159+ value: unknown,
160+) => value is PublishError;
161+
162+const decodeDescriptorJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString);
163+
158164 const assemble = (deps: PublishDeps, files: FileSet): Effect.Effect<AssembledApp, PublishError> =>
159165 Effect.gen(function* () {
160166 const discovered = discover(files);
161− if (discovered instanceof PublishError) return yield* Effect.fail(discovered);
167+ if (isPublishError(discovered)) return yield* discovered;
162168
163169 const tools: ToolDescriptor[] = [];
164170
165171 for (const artifact of discovered.artifacts) {
166172 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+ }),
175181 );
176182 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+ }),
185191 );
186192 const raw = collected.artifacts.default?.descriptor as CollectedDescriptor | undefined;
187193 const keyed = collected.artifacts[artifact.name]?.descriptor as
@@ -189,13 +195,11 @@ const assemble = (deps: PublishDeps, files: FileSet): Effect.Effect<AssembledApp · 1 unmodified line
189195 | undefined;
190196 const descriptor = keyed ?? raw;
191197 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+ });
199203 }
200204 const integrations = toIntegrationDecls(descriptor.integrations);
201205 const collision = rejectRoleInputCollisions(
@@ -204,7 +208,7 @@ const assemble = (deps: PublishDeps, files: FileSet): Effect.Effect<AssembledApp · 2 unmodified lines
204208 descriptor.inputJsonSchema,
205209 integrations,
206210 );
207− if (collision) return yield* Effect.fail(collision);
211+ if (collision) return yield* collision;
208212 const source = yield* sourceRef(artifact.entry, files.get(artifact.entry) ?? "");
209213 tools.push({
210214 name: artifact.name,
@@ -247,19 +251,17 @@ export const publish = ( · 36 unmodified lines
247251 Effect.gen(function* () {
248252 const tenant = input.tenant ?? "org";
249253 const overLimit = enforcePublishLimits(input.files);
250− if (overLimit) return yield* Effect.fail(overLimit);
254+ if (overLimit) return yield* overLimit;
251255
252256 const assembled = yield* assemble(deps, input.files);
253257 const body = descriptorBody(tenant, input.scope, assembled, input);
254258
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+ );
263265
264266 const filesWithDescriptor = new Map(input.files);
265267 filesWithDescriptor.set(DESCRIPTOR_SNAPSHOT_PATH, stableStringify(body));
@@ -267,10 +269,10 @@ export const publish = ( · 1 unmodified line
267269 const meta = yield* scopeStore
268270 .commit(filesWithDescriptor, input.commitMessage ?? `publish ${new Date().toISOString()}`)
269271 .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+ }),
274276 );
275277
276278 const descriptor: AppDescriptor = { ...body, snapshotId: meta.id };
@@ -285,21 +287,28 @@ export const loadDescriptorFromSnapshot = ( · 8 unmodified lines
285287 snapshotId: SnapshotId,
286288 ): Effect.Effect<AppDescriptor | null, PublishError> =>
287289 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+ );
302302 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 };
305314 });
packages/plugins/apps/src/plugin/apps-plugin.test.ts+1−1
@@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync } from "node:fs"; · 1 unmodified line
22 import { tmpdir } from "node:os";
33 import { join } from "node:path";
44
5−import { describe, expect, it } from "vitest";
5+import { describe, expect, it } from "@effect/vitest";
66 import { Effect } from "effect";
77
88 import { appsPlugin, connectionNameForScope } from "./apps-plugin";
packages/plugins/apps/src/plugin/apps-plugin.ts+21−25
@@ -20,6 +20,7 @@ import type { AppsRuntime } from "./runtime"; · 19 unmodified lines
2020 import { makeAppsStore } from "./store";
2121 import type { ClientResolver, ConnectionCandidate } from "./bindings";
2222 import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor";
23+import { PublishError } from "../pipeline/discover";
2324 import { syncGitHubSource, type GitHubSyncResult } from "../source/github-source";
2425
2526 export const APPS_INTEGRATION_SLUG = "apps";
@@ -119,11 +120,8 @@ const projectInputSchema = ( · 93 unmodified lines
119120 };
120121
121122 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;
127125
128126 const tenantFor = (ctx: Pick<PluginCtx, "owner"> | undefined): string => {
129127 const tenant = ctx?.owner?.tenant;
@@ -321,33 +319,31 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 191 unmodified lines
321319 const scope = yield* scopeFor(tenant, String(toolRow.connection));
322320 const descriptor = yield* runtime.getDescriptor(tenant, scope);
323321 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+ });
329327 }
330328 const toolDesc = descriptor.tools.find((t) => t.name === toolRow.name);
331329 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+ });
335335 }
336336 const resolver = makeResolver
337337 ? makeResolver({ ctx, scope, tool: toolRow.name })
338338 : 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+ });
351347 }),
352348 };
353349 });
packages/plugins/apps/src/plugin/bindings.strict.test.ts+3−3
@@ -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";
33
44 import { buildBridge, resolveIntegrationBindings, type ClientResolver } from "./bindings";
55 import type { IntegrationDecl } from "../pipeline/descriptor";
@@ -43,7 +43,7 @@ const okResolver: ClientResolver = { · 37 unmodified lines
4343 };
4444
4545 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));
4747
4848 describe("HandleBridge strict dispatch", () => {
4949 const declared: Record<string, IntegrationDecl> = {
packages/plugins/apps/src/plugin/bindings.ts+41−47
@@ -71,19 +71,6 @@ export interface BindingContext { · 70 unmodified lines
7171 const isRecord = (value: unknown): value is Record<string, unknown> =>
7272 value !== null && typeof value === "object" && !Array.isArray(value);
7373
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−
8774 const findDefaultConnection = (
8875 role: string,
8976 decl: IntegrationDecl,
@@ -93,15 +80,16 @@ const findDefaultConnection = ( · 3 unmodified lines
9380 Effect.flatMap((connections) => {
9481 if (connections.length === 1) return Effect.succeed(connections[0]!.address);
9582 return Effect.fail(
96− bindingError(
83+ new BindingError({
9784 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+ }),
10593 );
10694 }),
10795 );
@@ -116,22 +104,22 @@ const resolveRequestedConnection = ( · 8 unmodified lines
116104 Effect.flatMap((connection) => {
117105 if (!connection) {
118106 return Effect.fail(
119− bindingError(
107+ new BindingError({
120108 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+ }),
125113 );
126114 }
127115 if (connection.integration !== decl.integration) {
128116 return Effect.fail(
129− bindingError(
117+ new BindingError({
130118 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+ }),
135123 );
136124 }
137125 return Effect.succeed(connection.address);
@@ -168,14 +156,12 @@ export const resolveIntegrationBindings = ( · 30 unmodified lines
168156 continue;
169157 }
170158 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+ });
179165 }
180166 bindings[role] = yield* resolveRequestedConnection(role, decl, raw, resolver);
181167 }
@@ -231,13 +217,16 @@ export const buildBridge = (context: BindingContext): HandleBridge => ({ · 49 unmodified lines
231217 if (root === "db") {
232218 if (path.length === 1 && path[0] === "sql") {
233219 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+ );
241230 }
242231 return Effect.fail(invokeErr(`unsupported db call: ${path.join(".")}`));
243232 }
@@ -274,7 +263,12 @@ export const buildBridge = (context: BindingContext): HandleBridge => ({ · 30 unmodified lines
274263 })
275264 .pipe(
276265 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+ }),
278272 ),
279273 );
280274 },
packages/plugins/apps/src/plugin/publish-concurrency.test.ts+6−7
@@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; · 1 unmodified line
22 import { tmpdir } from "node:os";
33 import { join } from "node:path";
44
5−import { describe, expect, it } from "vitest";
5+import { describe, expect, it } from "@effect/vitest";
66 import { Effect } from "effect";
77
88 import { makeSelfHostAppsRuntime } from "./self-host-runtime";
@@ -60,13 +60,12 @@ describe("concurrent publishes to one scope (Fix 6)", () => { · 51 unmodified lines
6060 // Every publish either succeeded or failed with a typed conflict; none
6161 // clobbered another silently.
6262 const succeeded = results.filter((r) => r.status === "fulfilled");
63+ const rejectedReasons = results.flatMap((r) =>
64+ r.status === "rejected" ? [JSON.stringify(r.reason)] : [],
65+ );
6366 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);
7069
7170 // HEAD and the descriptor pointer AGREE: the store's current descriptor's
7271 // snapshotId equals the artifact store's latest committed snapshot.
packages/plugins/apps/src/plugin/runtime.test.ts+11−13
@@ -2,8 +2,8 @@ import { mkdtempSync } from "node:fs"; · 1 unmodified line
22 import { tmpdir } from "node:os";
33 import { join } from "node:path";
44
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";
77
88 import { makeSelfHostAppsRuntime } from "./self-host-runtime";
99 import { makeInMemoryAppsStore, makeTestResolver, dailyBriefFileSet } from "../testing";
@@ -157,11 +157,10 @@ export default defineTool({ · 147 unmodified lines
157157 host.runtime.invokeTool({ scope: "s", tool: "sync", args: {} }),
158158 );
159159
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");
165164 await host.close();
166165 });
167166
@@ -204,12 +203,11 @@ export default defineTool({ · 36 unmodified lines
204203 }),
205204 );
206205
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");
213211 await host.close();
214212 });
215213
packages/plugins/apps/src/plugin/runtime.ts+25−17
@@ -1,4 +1,4 @@
1−import { Effect } from "effect";
1+import { Effect, Predicate } from "effect";
22 import type { InvokeOptions } from "@executor-js/sdk";
33
44 import type { ArtifactStore } from "../seams/artifact-store";
@@ -100,6 +100,13 @@ const failNoDescriptor = (scope: string): PublishError => · 95 unmodified lines
100100 diagnostics: [],
101101 });
102102
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+
103110 export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => {
104111 const defaultTenant = deps.defaultTenant ?? "org";
105112 const bundleCache = new Map<string, string>();
@@ -120,10 +127,17 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 14 unmodified lines
120127 ): Promise<A> => {
121128 const key = `${tenant}:${scope}`;
122129 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);
124135 publishChains.set(
125136 key,
126− next.catch(() => undefined),
137+ next.then(
138+ () => undefined,
139+ () => undefined,
140+ ),
127141 );
128142 return next;
129143 };
@@ -293,11 +307,7 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 163 unmodified lines
293307 )
294308 .pipe(
295309 Effect.mapError((c) => {
296− if (
297− c instanceof BindingError ||
298− c instanceof InputValidationError ||
299− c instanceof OutputValidationError
300− ) {
310+ if (isInvokePassthroughError(c)) {
301311 return c;
302312 }
303313 return new PublishError({
@@ -340,9 +350,9 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 36 unmodified lines
340350 ),
341351 );
342352 },
343− catch: (cause) =>
353+ catch: (_cause) =>
344354 new PublishError({
345− message: cause instanceof Error ? cause.message : String(cause),
355+ message: "publish failed before pipeline completed",
346356 stage: "project",
347357 diagnostics: [],
348358 }),
@@ -404,13 +414,11 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 55 unmodified lines
404414 const descriptor = yield* requireDescriptor(tenant, input.scope);
405415 const toolDesc = descriptor.tools.find((t) => t.name === input.tool);
406416 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+ });
414422 }
415423 return yield* invokeToolInternal(
416424 input.scope,
packages/plugins/apps/src/plugin/tenant-isolation.test.ts+1−1
@@ -2,7 +2,7 @@ import { mkdtempSync, readdirSync } from "node:fs"; · 1 unmodified line
22 import { tmpdir } from "node:os";
33 import { join } from "node:path";
44
5−import { describe, expect, it } from "vitest";
5+import { describe, expect, it } from "@effect/vitest";
66 import { Effect } from "effect";
77
88 import { makeSqliteAppsStore } from "../backing/sqlite-apps-store";
packages/plugins/apps/src/react/AddCustomToolsSource.tsx+17−13
@@ -1,5 +1,6 @@
11 import { useMemo, useState } from "react";
22 import { useAtomValue } from "@effect/atom-react";
3+import { Effect, Exit } from "effect";
34 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
45
56 import { connectionsAllAtom } from "@executor-js/react/api/atoms";
@@ -20,7 +21,7 @@ import { · 14 unmodified lines
2021 consoleIntegrationHref,
2122 formatSyncErrors,
2223 githubConnections,
23− syncCustomToolSource,
24+ syncCustomToolSourceEffect,
2425 syncStatusLabel,
2526 validateGitHubRepo,
2627 } from "./custom-tools-client";
@@ -54,23 +55,26 @@ export default function AddCustomToolsSource(props: { · 27 unmodified lines
5455 if (validation || !selectedConnection) return;
5556
5657 setSyncing(true);
57− try {
58− const result = await syncCustomToolSource({
58+ const exit = await Effect.runPromiseExit(
59+ syncCustomToolSourceEffect({
5960 repo,
6061 ref,
6162 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.");
7267 setSyncing(false);
68+ return;
7369 }
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");
7478 };
7579
7680 return (
packages/plugins/apps/src/react/CustomToolsAccountsPanel.tsx+44−41
@@ -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";
23
34 import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert";
45 import { Badge } from "@executor-js/react/components/badge";
@@ -17,8 +18,8 @@ import type { GitHubCustomToolsSourceSummary, GitHubSyncResult } from "../api"; · 12 unmodified lines
1718 import {
1819 consoleIntegrationHref,
1920 formatSyncErrors,
20− listCustomToolSources,
21− syncCustomToolSource,
21+ listCustomToolSourcesEffect,
22+ syncCustomToolSourceEffect,
2223 syncStatusLabel,
2324 toolDiff,
2425 } from "./custom-tools-client";
@@ -49,31 +50,33 @@ export default function CustomToolsAccountsPanel() { · 24 unmodified lines
4950
5051 const loadSources = async () => {
5152 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)) {
5755 setLoadState({
5856 status: "error",
59− message: error instanceof Error ? error.message : "Failed to load custom tool sources.",
57+ message: "Failed to load custom tool sources.",
6058 });
59+ return;
6160 }
61+ const result = exit.value;
62+ setLoadState({ status: "ready", sources: result.sources });
63+ setSelectedScope((current) => current ?? result.sources[0]?.scope ?? null);
6264 };
6365
6466 useEffect(() => {
6567 let active = true;
6668 void (async () => {
67− try {
68− const result = await listCustomToolSources();
69+ const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect());
70+ if (Exit.isSuccess(exit)) {
6971 if (!active) return;
72+ const result = exit.value;
7073 setLoadState({ status: "ready", sources: result.sources });
7174 setSelectedScope(result.sources[0]?.scope ?? null);
72− } catch (error) {
75+ } else {
7376 if (!active) return;
7477 setLoadState({
7578 status: "error",
76− message: error instanceof Error ? error.message : "Failed to load custom tool sources.",
79+ message: "Failed to load custom tool sources.",
7780 });
7881 }
7982 })();
@@ -83,10 +86,7 @@ export default function CustomToolsAccountsPanel() { · 3 unmodified lines
8386 }, []);
8487
8588 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;
9090
9191 const syncSelected = async (source: GitHubCustomToolsSourceSummary) => {
9292 if (!source.connection) {
@@ -102,35 +102,37 @@ export default function CustomToolsAccountsPanel() { · 9 unmodified lines
102102 setSyncingScope(source.scope);
103103 setNotice(null);
104104 const beforeTools = source.tools;
105− try {
106− const result = await syncCustomToolSource({
105+ const exit = await Effect.runPromiseExit(
106+ syncCustomToolSourceEffect({
107107 repo: source.repo,
108108 ref: source.ref,
109109 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)) {
127113 setNotice({
128114 status: "failed",
129115 message: "Sync failed.",
130116 added: [],
131117 removed: [],
132− errors: [error instanceof Error ? error.message : "Failed to sync custom tools."],
118+ errors: ["Failed to sync custom tools."],
133119 });
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);
134136 }
135137 setSyncingScope(null);
136138 };
@@ -181,16 +183,17 @@ export default function CustomToolsAccountsPanel() { · 44 unmodified lines
181183 asChild
182184 searchText={`${source.repo} ${source.ref}`}
183185 >
184− <button
186+ <Button
185187 type="button"
188+ variant="ghost"
186189 onClick={() => {
187190 setSelectedScope(source.scope);
188191 setNotice(null);
189192 }}
190193 className={
191194 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"
194197 }
195198 >
196199 <CardStackEntryContent>
@@ -200,7 +203,7 @@ export default function CustomToolsAccountsPanel() { · 3 unmodified lines
200203 <CardStackEntryActions>
201204 <Badge variant="secondary">{source.tools.length}</Badge>
202205 </CardStackEntryActions>
203− </button>
206+ </Button>
204207 </CardStackEntry>
205208 ))}
206209 </CardStackContent>
packages/plugins/apps/src/react/custom-tools-client.ts+84−48
@@ -1,4 +1,5 @@
11 import type { Connection } from "@executor-js/sdk/shared";
2+import { Data, Effect, Schema } from "effect";
23
34 import type { GitHubCustomToolsSourceSummary, GitHubSyncResult } from "../api";
45
@@ -15,12 +16,11 @@ export interface SyncGitHubSourceRequest { · 10 unmodified lines
1516 readonly connection: string;
1617 }
1718
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+}> {}
2424
2525 const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
2626
@@ -34,47 +34,83 @@ export const validateGitHubRepo = (repo: string): string | null => { · 7 unmodified lines
3434 export const githubConnections = (connections: readonly Connection[]): readonly Connection[] =>
3535 connections.filter((connection) => String(connection.integration) === "github");
3636
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);
5238
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;
5866 });
59− return parseJsonResponse<GitHubSourcesListResponse>(response, "Failed to load custom tools.");
60−};
6167
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 = (
6388 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));
78114
79115 export const syncStatusLabel = (result: GitHubSyncResult): string => {
80116 if (result.status === "published") return `Published ${result.tools.length} tools.`;
@@ -84,11 +120,11 @@ export const syncStatusLabel = (result: GitHubSyncResult): string => { · 3 unmodified lines
84120
85121 export const formatSyncErrors = (result: GitHubSyncResult): readonly string[] => {
86122 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}`;
92128 });
93129 };
94130
packages/plugins/apps/src/seams/artifact-store.conformance.ts+1−1
@@ -1,4 +1,4 @@
1−import { describe, expect, it } from "vitest";
1+import { describe, expect, it } from "@effect/vitest";
22 import { Effect } from "effect";
33
44 import type { ArtifactStore, FileSet } from "./artifact-store";
packages/plugins/apps/src/seams/scope-db.conformance.ts+1−1
@@ -1,4 +1,4 @@
1−import { describe, expect, it } from "vitest";
1+import { describe, expect, it } from "@effect/vitest";
22 import { Effect } from "effect";
33
44 import type { ScopeDb } from "./scope-db";
packages/plugins/apps/src/seams/tool-sandbox.conformance.ts+5−5
@@ -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";
33
44 import type { HandleBridge, ToolSandbox } from "./tool-sandbox";
55 import { bundleEntry } from "../pipeline/bundle";
@@ -34,7 +34,7 @@ import { z } from "zod"; · 28 unmodified lines
3434 export default defineTool({ description: "x" + Math.random(), input: z.object({}), async handler(){ return {}; } });`;
3535 const rngBundle = await bundle("tools/rng.ts", rng);
3636 const exit = await Effect.runPromiseExit(sandbox.collect(rngBundle));
37− expect(exit._tag).toBe("Failure");
37+ expect(Exit.isFailure(exit)).toBe(true);
3838 });
3939
4040 it("denies network access", async () => {
@@ -47,7 +47,7 @@ export default defineTool({ description: "net", input: z.object({}), async handl · 6 unmodified lines
4747 const exit = await Effect.runPromiseExit(
4848 sandbox.invoke(b, { artifact: "net", kind: "tool", input: {}, roots: {} }, bridge),
4949 );
50− expect(exit._tag).toBe("Failure");
50+ expect(Exit.isFailure(exit)).toBe(true);
5151 });
5252
5353 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
6060 const exit = await Effect.runPromiseExit(
6161 sandbox.invoke(b, { artifact: "loop", kind: "tool", input: {}, roots: {} }, bridge),
6262 );
63− expect(exit._tag).toBe("Failure");
63+ expect(Exit.isFailure(exit)).toBe(true);
6464 });
6565
6666 it("round-trips the handle bridge for a declared integration role", async () => {
packages/plugins/apps/src/source/github-source.test.ts+3−3
@@ -2,8 +2,8 @@ import { mkdtempSync } from "node:fs"; · 1 unmodified line
22 import { tmpdir } from "node:os";
33 import { join } from "node:path";
44
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";
77
88 import { makeSelfHostAppsRuntime } from "../plugin/self-host-runtime";
99 import { makeInMemoryAppsStore, makeTestResolver } from "../testing";
@@ -208,7 +208,7 @@ describe("GitHub custom-tools source", () => { · 198 unmodified lines
208208 const exit = await Effect.runPromiseExit(
209209 fetchGitHubSource({ repo: "acme/tools", fetch: github.fetch }),
210210 );
211− expect(exit._tag).toBe("Failure");
211+ expect(Exit.isFailure(exit)).toBe(true);
212212 expect(JSON.stringify(exit)).toContain("exceeding the limit");
213213 expect(github.blobCalls()).toBe(0);
214214 });
packages/plugins/apps/src/source/github-source.ts+81−74
@@ -1,4 +1,4 @@
1−import { Data, Effect, Result } from "effect";
1+import { Data, Effect, Predicate, Result, Schema } from "effect";
22
33 import { PublishError, type FileDiagnostic } from "../pipeline/discover";
44 import { PUBLISH_LIMITS, enforcePublishLimits } from "../pipeline/publish";
@@ -106,37 +106,42 @@ const requestJson = <A>( · 101 unmodified lines
106106 input: GitHubSourceInput,
107107 path: string,
108108 ): 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}`, {
120119 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,
128120 });
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+ });
140145 });
141146
142147 interface RepoResponse {
@@ -254,42 +259,39 @@ const classifyTreeEntry = ( · 111 unmodified lines
254259 };
255260
256261 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");
273272 });
274273
274+const decodeExecutorJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString);
275+
275276 const executorDescription = (
276277 files: FileSet,
277278 ): 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;
293295 });
294296
295297 export const fetchGitHubSource = (
@@ -336,7 +338,7 @@ export const fetchGitHubSource = ( · 40 unmodified lines
336338 else skipped.push(classified.skipped);
337339 }
338340 const treeLimitError = checkTreeLimits(entries);
339− if (treeLimitError) return yield* Effect.fail(treeLimitError);
341+ if (treeLimitError) return yield* treeLimitError;
340342
341343 const files = new Map<string, string>();
342344 for (const entry of entries) {
@@ -348,7 +350,7 @@ export const fetchGitHubSource = ( · 5 unmodified lines
348350 files.set(path, yield* decodeBlob(path, blob));
349351 }
350352 const payloadLimitError = enforcePublishLimits(files);
351− if (payloadLimitError) return yield* Effect.fail(payloadLimitError);
353+ if (payloadLimitError) return yield* payloadLimitError;
352354
353355 return {
354356 files,
@@ -360,17 +362,22 @@ export const fetchGitHubSource = ( · 5 unmodified lines
360362 };
361363 });
362364
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+};
368371
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+};
374381
375382 const sourceRef = (snapshot: GitHubSourceSnapshot, connection?: string): AppSourceRef => ({
376383 kind: "github",
@@ -391,7 +398,7 @@ export const syncGitHubSource = (input: SyncGitHubSourceInput): Effect.Effect<Gi · 14 unmodified lines
391398 tools: [],
392399 skipped: [],
393400 errors: [
394− error instanceof PublishError
401+ Predicate.isTagged("PublishError")(error)
395402 ? publishErrorToSyncError(error)
396403 : sourceErrorToSyncError(error),
397404 ],
packages/plugins/apps/src/testing/index.ts+8−7
@@ -2,7 +2,7 @@ import { Effect } from "effect"; · 1 unmodified line
22
33 import type { AppDescriptor } from "../pipeline/descriptor";
44 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";
66 import {
77 ArtifactStoreError,
88 asSnapshotId,
@@ -123,12 +123,13 @@ export const makeTestResolver = ( · 114 unmodified lines
123123 calls.push({ integration, connection, method });
124124 const handler = handlers[integration]?.[method];
125125 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+ );
132133 }
133134 return Effect.sync(() => handler(args));
134135 },