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