History

Refresh custom tool connection schemas at read time

Rhys Sullivan committed 4 months ago

Commit 6e2010f1 parent 18fafe47 files · +184 −25Browse files

Unified · Split

7 files
  1. apps/host-selfhost/src/apps-wire.node.test.ts +69 −2
  2. packages/core/sdk/src/executor.ts +29 −8
  3. packages/core/sdk/src/plugin.ts +19 −0
  4. packages/plugins/apps/src/plugin/apps-plugin.test.ts +17 −1
  5. packages/plugins/apps/src/plugin/apps-plugin.ts +25 −14
  6. packages/react/src/components/json-schema-form.test.ts +8 −0
  7. packages/react/src/components/json-schema-form.tsx +17 −0
apps/host-selfhost/src/apps-wire.node.test.ts+69−2
@@ -426,7 +426,7 @@ const approvalFiles = (): Record<string, string> => ({ · 425 unmodified lines
426 "tools/approval-bridge.ts": approvalBridgeSource,426 "tools/approval-bridge.ts": approvalBridgeSource,
427});427});
428 428
429const registerGitHubIntegration = async (emulator: Emulator, token: string): Promise<void> => {429const registerGitHubSpec = async (emulator: Emulator): Promise<void> => {
430 const specResponse = await fetch(`${server.baseUrl}/api/openapi/specs`, {430 const specResponse = await fetch(`${server.baseUrl}/api/openapi/specs`, {
431 method: "POST",431 method: "POST",
432 headers: jsonHeaders,432 headers: jsonHeaders,
@@ -439,13 +439,18 @@ const registerGitHubIntegration = async (emulator: Emulator, token: string): Pro · 6 unmodified lines
439 });439 });
440 expect([200, 409]).toContain(specResponse.status);440 expect([200, 409]).toContain(specResponse.status);
441 await specResponse.text();441 await specResponse.text();
442};
443
444const createGitHubConnection = async (token: string): Promise<string> => {
442 const existing = await requestJson<readonly ConnectionRow[]>(445 const existing = await requestJson<readonly ConnectionRow[]>(
443 "/api/connections?integration=github",446 "/api/connections?integration=github",
444 {447 {
445 headers: jsonHeaders,448 headers: jsonHeaders,
446 },449 },
447 );450 );
448 if (existing.some((connection) => connection.address === GITHUB_CONNECTION)) return;451 if (existing.some((connection) => connection.address === GITHUB_CONNECTION)) {
452 return GITHUB_CONNECTION;
453 }
449 const created = await postJson<{ address: string }>("/api/connections", {454 const created = await postJson<{ address: string }>("/api/connections", {
450 owner: "user",455 owner: "user",
451 name: "main",456 name: "main",
@@ -454,6 +459,12 @@ const registerGitHubIntegration = async (emulator: Emulator, token: string): Pro · 2 unmodified lines
454 value: token,459 value: token,
455 });460 });
456 expect(created.address).toBe(GITHUB_CONNECTION);461 expect(created.address).toBe(GITHUB_CONNECTION);
462 return created.address;
463};
464
465const registerGitHubIntegration = async (emulator: Emulator, token: string): Promise<void> => {
466 await registerGitHubSpec(emulator);
467 await createGitHubConnection(token);
457};468};
458 469
459const sourceUrl = (repo = REPO_FULL_NAME): string => `https://github.com/${repo}`;470const sourceUrl = (repo = REPO_FULL_NAME): string => `https://github.com/${repo}`;
@@ -522,6 +533,62 @@ afterAll(async () => { · 62 unmodified lines
522 await github?.close();533 await github?.close();
523});534});
524 535
536test("custom tool connection schema updates when connections change after sync", async () => {
537 const credential = await github.credentials.mint({
538 type: "api-key",
539 login: OWNER,
540 scopes: ["repo", "user"],
541 });
542 const token = credential.token;
543 expect(token).toBeTruthy();
544 await registerGitHubSpec(github);
545 const initialConnections = await requestJson<readonly ConnectionRow[]>(
546 "/api/connections?integration=github",
547 { headers: jsonHeaders },
548 );
549 expect(initialConnections).toEqual([]);
550 await putRepoFiles(github, token!, initialFiles());
551
552 const published = await syncSource();
553 expect(published.status).toBe("published");
554
555 const listed = await listAppTools();
556 const dealSync = listed.find((tool) => tool.name === "deal-pipeline-sync");
557 expect(dealSync).toBeTruthy();
558 const beforeConnection = await requestJson<{
559 inputSchema: { properties?: Record<string, unknown>; required?: readonly string[] };
560 }>(`/api/tools/schema?address=${encodeURIComponent(dealSync!.address)}`, {
561 headers: jsonHeaders,
562 });
563 expect(beforeConnection.inputSchema.properties?.github).toMatchObject({
564 type: "string",
565 enum: [],
566 });
567 expect(beforeConnection.inputSchema.required ?? []).toContain("github");
568
569 await createGitHubConnection(token!);
570 const afterConnection = await requestJson<{
571 inputSchema: { properties?: Record<string, unknown>; required?: readonly string[] };
572 }>(`/api/tools/schema?address=${encodeURIComponent(dealSync!.address)}`, {
573 headers: jsonHeaders,
574 });
575 expect(afterConnection.inputSchema.properties?.github).toMatchObject({
576 type: "string",
577 enum: [GITHUB_CONNECTION],
578 default: GITHUB_CONNECTION,
579 });
580 expect(afterConnection.inputSchema.required ?? []).not.toContain("github");
581
582 const defaultInvoke = (await executeResult(
583 callAppToolCode("deal-pipeline-sync", {
584 owner: OWNER,
585 repo: REPO,
586 }),
587 )) as { result?: { ok?: boolean; data?: { synced?: number }; error?: unknown } };
588 expect(defaultInvoke.result?.ok).toBe(true);
589 expect(defaultInvoke.result?.data?.synced).toBe(0);
590});
591
525test("GitHub source sync publishes and invokes custom tools through self-host HTTP", async () => {592test("GitHub source sync publishes and invokes custom tools through self-host HTTP", async () => {
526 const credential = await github.credentials.mint({593 const credential = await github.credentials.mint({
527 type: "api-key",594 type: "api-key",
packages/core/sdk/src/executor.ts+29−8
@@ -3089,6 +3089,30 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3088 unmodified lines
3089 );3089 );
3090 if (effective.action === "block") return null;3090 if (effective.action === "block") return null;
3091 3091
3092 const runtime = runtimes.get(row.plugin_id);
3093 const projected = runtime?.plugin.projectToolSchema
3094 ? yield* runtime.plugin
3095 .projectToolSchema({
3096 ctx: runtime.ctx,
3097 toolRow: row,
3098 inputSchema: tool.inputSchema,
3099 outputSchema: tool.outputSchema,
3100 })
3101 .pipe(
3102 Effect.mapError((cause) =>
3103 pluginStorageFailure(row.plugin_id, "projectToolSchema", cause),
3104 ),
3105 )
3106 : null;
3107 const inputSchema =
3108 projected && Object.prototype.hasOwnProperty.call(projected, "inputSchema")
3109 ? projected.inputSchema
3110 : tool.inputSchema;
3111 const outputSchema =
3112 projected && Object.prototype.hasOwnProperty.call(projected, "outputSchema")
3113 ? projected.outputSchema
3114 : tool.outputSchema;
3115
3092 const definitionRows = yield* core.findMany("definition", {3116 const definitionRows = yield* core.findMany("definition", {
3093 where: (b: AnyCb) =>3117 where: (b: AnyCb) =>
3094 b.and(3118 b.and(
@@ -3100,15 +3124,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
3100 const defs = new Map<string, unknown>();3124 const defs = new Map<string, unknown>();
3101 for (const def of definitionRows) defs.set(def.name, decodeJsonColumn(def.schema));3125 for (const def of definitionRows) defs.set(def.name, decodeJsonColumn(def.schema));
3102 3126
3103 const referenced = collectReferencedDefinitions(3127 const referenced = collectReferencedDefinitions([inputSchema, outputSchema], defs);
3104 [tool.inputSchema, tool.outputSchema],
3105 defs,
3106 );
3107 const preview = yield* Effect.tryPromise({3128 const preview = yield* Effect.tryPromise({
3108 try: () =>3129 try: () =>
3109 buildToolTypeScriptPreview({3130 buildToolTypeScriptPreview({
3110 inputSchema: tool.inputSchema,3131 inputSchema,
3111 outputSchema: tool.outputSchema,3132 outputSchema,
3112 defs,3133 defs,
3113 }),3134 }),
3114 catch: (cause) =>3135 catch: (cause) =>
@@ -3120,8 +3141,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
3120 address,3141 address,
3121 name: tool.name,3142 name: tool.name,
3122 description: tool.description,3143 description: tool.description,
3123 inputSchema: tool.inputSchema,3144 inputSchema,
3124 outputSchema: tool.outputSchema,3145 outputSchema,
3125 schemaDefinitions:3146 schemaDefinitions:
3126 Object.keys(referenced).length > 03147 Object.keys(referenced).length > 0
3127 ? (referenced as Record<string, unknown>)3148 ? (referenced as Record<string, unknown>)
packages/core/sdk/src/plugin.ts+19−0
@@ -334,6 +334,18 @@ export interface ResolveToolsResult { · 333 unmodified lines
334 readonly incomplete?: boolean;334 readonly incomplete?: boolean;
335}335}
336 336
337export interface ProjectToolSchemaInput<TStore = unknown> {
338 readonly ctx: PluginCtx<TStore>;
339 readonly toolRow: ToolInvocationRow;
340 readonly inputSchema?: unknown;
341 readonly outputSchema?: unknown;
342}
343
344export interface ProjectToolSchemaResult {
345 readonly inputSchema?: unknown;
346 readonly outputSchema?: unknown;
347}
348
337// ---------------------------------------------------------------------------349// ---------------------------------------------------------------------------
338// Resolved credential handed to `invokeTool` so the plugin renders auth onto350// Resolved credential handed to `invokeTool` so the plugin renders auth onto
339// the request (D11: "auth state derived into the auth-template format").351// the request (D11: "auth state derived into the auth-template format").
@@ -622,6 +634,13 @@ export interface PluginSpec< · 282 unmodified lines
622 * for catalogs derived purely from stored state (specs, static config). */634 * for catalogs derived purely from stored state (specs, static config). */
623 readonly remoteToolCatalog?: boolean;635 readonly remoteToolCatalog?: boolean;
624 636
637 /** Project a persisted tool row's stable schemas into the request-visible
638 * schema view. Use this only for volatile presentation data that should be
639 * current at read time, not persisted at catalog-refresh time. */
640 readonly projectToolSchema?: (
641 input: ProjectToolSchemaInput<TStore>,
642 ) => Effect.Effect<ProjectToolSchemaResult, unknown>;
643
625 /** Invoke a dynamic tool. Called when the static-handler map doesn't have the644 /** Invoke a dynamic tool. Called when the static-handler map doesn't have the
626 * address. The plugin applies `input.credential` to the outbound request. */645 * address. The plugin applies `input.credential` to the outbound request. */
627 readonly invokeTool?: (input: InvokeToolInput<TStore>) => Effect.Effect<unknown, unknown>;646 readonly invokeTool?: (input: InvokeToolInput<TStore>) => Effect.Effect<unknown, unknown>;
packages/plugins/apps/src/plugin/apps-plugin.test.ts+17−1
@@ -73,7 +73,23 @@ describe("appsPlugin custom-tools contract", () => { · 72 unmodified lines
73 );73 );
74 const syncTool = resolved.tools.find((tool) => String(tool.name) === "deal-pipeline-sync");74 const syncTool = resolved.tools.find((tool) => String(tool.name) === "deal-pipeline-sync");
75 expect(syncTool).toBeTruthy();75 expect(syncTool).toBeTruthy();
76 const inputSchema = syncTool!.inputSchema as {76 const persistedInputSchema = syncTool!.inputSchema as {
77 properties: Record<string, unknown>;
78 };
79 expect(persistedInputSchema.properties.crm).toBeUndefined();
80
81 const projected = await run(
82 plugin.projectToolSchema!({
83 ctx: { owner: { tenant: "org" } },
84 toolRow: {
85 name: "deal-pipeline-sync",
86 connection: appConnection,
87 },
88 inputSchema: syncTool!.inputSchema,
89 outputSchema: syncTool!.outputSchema,
90 } as never),
91 );
92 const inputSchema = projected.inputSchema as {
77 properties: Record<string, { enum?: string[]; default?: string; description?: string }>;93 properties: Record<string, { enum?: string[]; default?: string; description?: string }>;
78 required?: string[];94 required?: string[];
79 };95 };
packages/plugins/apps/src/plugin/apps-plugin.ts+25−14
@@ -332,21 +332,35 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 331 unmodified lines
332 const scope = yield* scopeFor(tenant, String(connection.name));332 const scope = yield* scopeFor(tenant, String(connection.name));
333 const descriptor = yield* runtime.getDescriptor(tenant, scope);333 const descriptor = yield* runtime.getDescriptor(tenant, scope);
334 if (!descriptor) return { tools: [] } satisfies ResolveToolsResult;334 if (!descriptor) return { tools: [] } satisfies ResolveToolsResult;
335 const resolver =
336 makeResolver && ctx ? makeResolver({ ctx, scope, tool: "*" }) : runtime.deps.resolver;
337 const tools: ToolDef[] = [];335 const tools: ToolDef[] = [];
338 for (const t of descriptor.tools) {336 for (const t of descriptor.tools) {
339 const byRole: Record<string, readonly ConnectionCandidate[]> = {};337 tools.push(projectTool(t));
340 for (const [role, decl] of Object.entries(t.integrations)) {
341 byRole[role] = yield* resolver
342 .listConnections({ integration: decl.integration })
343 .pipe(Effect.orElseSucceed(() => []));
344 }
345 tools.push(projectTool(t, byRole));
346 }338 }
347 return { tools } satisfies ResolveToolsResult;339 return { tools } satisfies ResolveToolsResult;
348 }),340 }),
349 341
342 projectToolSchema: ({ ctx, toolRow, inputSchema, outputSchema }) =>
343 Effect.gen(function* () {
344 const tenant = tenantFor(ctx);
345 const scope = yield* scopeFor(tenant, String(toolRow.connection));
346 const descriptor = yield* runtime.getDescriptor(tenant, scope);
347 const toolDesc = descriptor?.tools.find((t) => t.name === toolRow.name);
348 if (!toolDesc) return { inputSchema, outputSchema };
349 const resolver = makeResolver
350 ? makeResolver({ ctx, scope, tool: toolRow.name })
351 : runtime.deps.resolver;
352 const byRole: Record<string, readonly ConnectionCandidate[]> = {};
353 for (const [role, decl] of Object.entries(toolDesc.integrations)) {
354 byRole[role] = yield* resolver
355 .listConnections({ integration: decl.integration })
356 .pipe(Effect.orElseSucceed(() => []));
357 }
358 return {
359 inputSchema: projectInputSchema(toolDesc.inputSchema, toolDesc.integrations, byRole),
360 outputSchema: toolDesc.outputSchema,
361 };
362 }),
363
350 invokeTool: ({ ctx, toolRow, args, invokeOptions }: InvokeToolInput<AppsStoreShape>) =>364 invokeTool: ({ ctx, toolRow, args, invokeOptions }: InvokeToolInput<AppsStoreShape>) =>
351 Effect.gen(function* () {365 Effect.gen(function* () {
352 const tenant = tenantFor(ctx);366 const tenant = tenantFor(ctx);
@@ -382,13 +396,10 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 29 unmodified lines
382 };396 };
383});397});
384 398
385const projectTool = (399const projectTool = (descriptor: ToolDescriptor): ToolDef => ({
386 descriptor: ToolDescriptor,
387 byRole: Readonly<Record<string, readonly ConnectionCandidate[]>>,
388): ToolDef => ({
389 name: ToolName.make(descriptor.name),400 name: ToolName.make(descriptor.name),
390 description: descriptor.description,401 description: descriptor.description,
391 inputSchema: projectInputSchema(descriptor.inputSchema, descriptor.integrations, byRole),402 inputSchema: descriptor.inputSchema,
392 outputSchema: descriptor.outputSchema,403 outputSchema: descriptor.outputSchema,
393 annotations: {404 annotations: {
394 requiresApproval: descriptor.annotations?.destructive === true,405 requiresApproval: descriptor.annotations?.destructive === true,
packages/react/src/components/json-schema-form.test.ts+8−0
@@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; · 4 unmodified lines
5import {5import {
6 coerceNumber,6 coerceNumber,
7 defaultForSchema,7 defaultForSchema,
8 emptyEnumGuidance,
8 isRenderableObjectSchema,9 isRenderableObjectSchema,
9 missingRequiredFields,10 missingRequiredFields,
10 removeProperty,11 removeProperty,
@@ -108,6 +109,13 @@ describe("json-schema-form value transforms", () => { · 97 unmodified lines
108 expect(defaultForSchema({ const: 7 }, root)).toBe(7);109 expect(defaultForSchema({ const: 7 }, root)).toBe(7);
109 });110 });
110 111
112 it("empty enum guidance names connection integrations", () => {
113 expect(emptyEnumGuidance("Connection to use for github (github)")).toBe(
114 "Connect github first.",
115 );
116 expect(emptyEnumGuidance("Choose a value")).toBe("No options available.");
117 });
118
111 it("isRenderableObjectSchema gates form-mode availability", () => {119 it("isRenderableObjectSchema gates form-mode availability", () => {
112 expect(isRenderableObjectSchema(SCHEMA)).toBe(true);120 expect(isRenderableObjectSchema(SCHEMA)).toBe(true);
113 121
packages/react/src/components/json-schema-form.tsx+17−0
@@ -95,6 +95,11 @@ const schemaDescription = (schema: JsonSchema): string | undefined => { · 94 unmodified lines
95 return undefined;95 return undefined;
96};96};
97 97
98export const emptyEnumGuidance = (description: string | undefined): string => {
99 const integration = description?.match(/^Connection to use for .+ \(([^)]+)\)$/)?.[1];
100 return integration ? `Connect ${integration} first.` : "No options available.";
101};
102
98const DEFAULT_BY_TYPE: Record<string, () => unknown> = {103const DEFAULT_BY_TYPE: Record<string, () => unknown> = {
99 string: () => "",104 string: () => "",
100 number: () => 0,105 number: () => 0,
@@ -413,6 +418,18 @@ function FieldControl(props: { · 312 unmodified lines
413 );418 );
414 }419 }
415 420
421 if (Array.isArray(schema.enum) && schema.enum.length === 0) {
422 const guidance = emptyEnumGuidance(schemaDescription(schema));
423 return (
424 <div className="space-y-1">
425 <NativeSelect id={fieldId} className="w-full text-xs" value="" disabled={true}>
426 <NativeSelectOption value="">{guidance}</NativeSelectOption>
427 </NativeSelect>
428 <p className="text-[11px] leading-4 text-muted-foreground">{guidance}</p>
429 </div>
430 );
431 }
432
416 // enum — native select. Optional/unset gets a leading empty option.433 // enum — native select. Optional/unset gets a leading empty option.
417 if (Array.isArray(schema.enum) && schema.enum.length > 0) {434 if (Array.isArray(schema.enum) && schema.enum.length > 0) {
418 const options = schema.enum;435 const options = schema.enum;