427 lines · 16.4 KB · TypeScript
RawDownload
import { useEffect, useMemo, useRef, useState } from "react";import { useAtomValue, useAtomSet } from "@effect/atom-react";import * as Exit from "effect/Exit";import * as Option from "effect/Option";import * as Schema from "effect/Schema";import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";import { toast } from "sonner";import {  ToolAddress,  type Connection,  type ConnectionName,  type IntegrationSlug,} from "@executor-js/sdk/shared"; import { executeCode, toolSchemaAtom } from "../api/atoms";import { trackEvent } from "../api/analytics";import { Badge } from "./badge";import { Button } from "./button";import { Label } from "./label";import { Textarea } from "./textarea";import { CodeBlock } from "./code-block";import { CardStack, CardStackHeader, CardStackContent } from "./card-stack";import { NativeSelect, NativeSelectOption } from "./native-select";import { Tabs, TabsList, TabsTrigger, TabsContent } from "./tabs";import {  JsonSchemaForm,  isRenderableObjectSchema,  missingRequiredFields,} from "./json-schema-form";import { cn } from "../lib/utils"; // ---------------------------------------------------------------------------// ToolRunPanel — invoke a single tool against a chosen connection (account) to// verify the credentials actually work and see the result/error.//// Backend: `POST /executions` runs codemode source. We generate a one-liner// that invokes the exact per-connection tool through the sandbox `tools` proxy:////   return await tools["<int>.<owner>.<conn>.<tool>"](<args>);//// The `<owner>` segment is the SELECTED CONNECTION's own owner — never an// ambient value — because the connection list merges both owners (the global// owner toggle is retired). The tool name segment may itself contain dots (e.g.// `aliases.deleteAlias`), so the address is built by string-joining the segments// and passed to the proxy via bracket access — never split on dots.// --------------------------------------------------------------------------- /** Build the proxy address (no `tools.` prefix) for a tool against a specific *  connection, using the CONNECTION's own owner. Pure — unit-testable. */export const toolProxyAddress = (input: {  readonly integration: IntegrationSlug;  readonly connection: Connection;  readonly toolName: string;}): string =>  `${input.integration}.${input.connection.owner}.${input.connection.name}.${input.toolName}`; export const shouldRenderConnectionPicker = (connections: readonly Connection[]): boolean =>  connections.length > 1; type RunResult =  | {      readonly kind: "completed";      readonly text: string;      readonly structured: unknown;      readonly isError: boolean;    }  | { readonly kind: "paused"; readonly text: string }; /** Detect the `{ ok: false }` invocation-failure envelope — a failed-credential *  signal even when the HTTP execution itself "completed" without isError. */const isFailedEnvelope = (structured: unknown): boolean =>  typeof structured === "object" &&  structured !== null &&  "ok" in structured &&  (structured as { ok: unknown }).ok === false; /** Decode the args Textarea as JSON. Effect Schema is used at the JSON boundary *  (the codebase forbids raw `JSON.parse`); a malformed string yields `None`, *  which we surface as an inline validation error. */const decodeArgsJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); const prettyJson = (value: unknown): string => {  try {    return JSON.stringify(value, null, 2);  } catch {    return String(value);  }}; export function ToolRunPanel(props: {  /** Integration slug — the `<integration>` address segment. */  readonly integration: IntegrationSlug;  /** Bare tool name — the `<tool>` address segment (may contain dots). */  readonly toolName: string;  /** This integration's connections (accounts) across BOTH owners. Each is   *  addressed under its OWN owner — there is no ambient owner. */  readonly connections: readonly Connection[];  /** Pre-select this connection (the account the selected tool belongs to in the   *  grouped Tools tab). Falls back to the first connection when null/unmatched. */  readonly initialConnectionName?: ConnectionName | string | null;}) {  const { integration, toolName, connections, initialConnectionName } = props;   const [selectedConnection, setSelectedConnection] = useState<ConnectionName | null>(    (initialConnectionName as ConnectionName | null) ?? connections[0]?.name ?? null,  );  const [argsJson, setArgsJson] = useState("{}");  const [argsError, setArgsError] = useState<string | null>(null);  const [argsMode, setArgsMode] = useState<"form" | "json">("json");  const [running, setRunning] = useState(false);  const [result, setResult] = useState<RunResult | null>(null);   const doExecute = useAtomSet(executeCode, { mode: "promiseExit" });   // Reset the picker + result when the connection set or the tool changes, so a  // result never lingers against a stale tool/account. Prefer the tool's own  // account (`initialConnectionName`) when it is present in the list.  useEffect(() => {    setSelectedConnection((current) => {      if (        initialConnectionName &&        connections.some((c: Connection) => c.name === initialConnectionName)      ) {        return initialConnectionName as ConnectionName;      }      if (current && connections.some((c: Connection) => c.name === current)) return current;      return connections[0]?.name ?? null;    });  }, [connections, initialConnectionName]);   useEffect(() => {    setResult(null);    setArgsError(null);  }, [toolName]);   // The full `Connection` for the selected name — carries its OWN owner, which  // is the `<owner>` address segment (never an ambient owner).  const selectedConnectionObj = useMemo<Connection | null>(    () => connections.find((c: Connection) => c.name === selectedConnection) ?? null,    [connections, selectedConnection],  );   // Build the per-connection address `tools.<int>.<owner>.<conn>.<tool>` and the  // proxy form (the same string minus the `tools.` root), using the SELECTED  // CONNECTION's own owner.  const addressNoPrefix = useMemo(    () =>      selectedConnectionObj        ? toolProxyAddress({ integration, connection: selectedConnectionObj, toolName })        : null,    [integration, selectedConnectionObj, toolName],  );   const fullAddress = addressNoPrefix ? ToolAddress.make(`tools.${addressNoPrefix}`) : null;   // The tool's schema view drives both the args-shape hint (TypeScript) and the  // dynamic form (raw input JSON Schema + shared $defs).  const schemaResult = useAtomValue(toolSchemaAtom(fullAddress ?? ToolAddress.make("")));  const schemaView = AsyncResult.isSuccess(schemaResult) ? schemaResult.value : null;  const inputSchema = schemaView?.inputSchema ?? null;  const schemaDefinitions = schemaView?.schemaDefinitions;  const schemaAvailable = isRenderableObjectSchema(inputSchema, schemaDefinitions);   // The form is a controlled view over the single `argsJson` string. Decode it  // (same Option-returning decode the run path uses); only a plain JSON object  // can drive the form.  const parsedArgs = useMemo<Record<string, unknown> | null>(() => {    const decoded = decodeArgsJson(argsJson);    if (Option.isNone(decoded)) return null;    const v = decoded.value;    return typeof v === "object" && v !== null && !Array.isArray(v)      ? (v as Record<string, unknown>)      : null;  }, [argsJson]);   // Required-arg gate: Run is only available once every `required` top-level  // field has a value. With no renderable schema we can't know what's required,  // so we don't block.  const requiredMissing = useMemo<readonly string[]>(    () =>      schemaAvailable        ? missingRequiredFields(inputSchema, schemaDefinitions, parsedArgs ?? {})        : [],    [schemaAvailable, inputSchema, schemaDefinitions, parsedArgs],  );   // Default to the form the first time a renderable schema arrives with a JSON  // object in the args. A ref guard makes this fire exactly once, so a later  // manual tab choice is respected even as `parsedArgs` keeps changing.  const autoSelectedFormRef = useRef(false);  useEffect(() => {    if (autoSelectedFormRef.current) return;    if (schemaAvailable && parsedArgs !== null) {      autoSelectedFormRef.current = true;      setArgsMode("form");    }  }, [schemaAvailable, parsedArgs]);   // Form edits stringify straight back into the same `argsJson` state — no  // second copy of state, so the JSON tab always shows the live form value.  const handleFormChange = (next: Record<string, unknown>): void => {    setArgsJson(JSON.stringify(next, null, 2));    setArgsError(null);  };   const handleRun = async () => {    if (!addressNoPrefix) return;     const decoded = decodeArgsJson(argsJson);    if (Option.isNone(decoded)) {      setArgsError("Invalid JSON — enter a valid JSON object of arguments.");      return;    }    const parsed = decoded.value;    setArgsError(null);    setRunning(true);    setResult(null);     // Read-only invoke: pass the validated args through as JSON. Empty    // `reactivityKeys` — invoking a tool doesn't mutate `tools.list`.    // `autoApprove` because the operator clicked Run: that IS the approval, so    // an approval-gated tool should run here instead of dead-ending on a pause.    const code = `return await tools[${JSON.stringify(addressNoPrefix)}](${JSON.stringify(parsed)});`;    const exit = await doExecute({ payload: { code, autoApprove: true }, reactivityKeys: [] });    setRunning(false);     if (Exit.isFailure(exit)) {      trackEvent("tool_run_submitted", {        integration_slug: integration,        tool_name: toolName,        args_mode: argsMode,        result: "failed",      });      toast.error("Failed to run tool");      return;    }     const value = exit.value;    if (value.status === "paused") {      trackEvent("tool_run_submitted", {        integration_slug: integration,        tool_name: toolName,        args_mode: argsMode,        result: "paused",      });      setResult({ kind: "paused", text: value.text });      return;    }    trackEvent("tool_run_submitted", {      integration_slug: integration,      tool_name: toolName,      args_mode: argsMode,      result: "completed",      ...(value.isError ? { is_error: true } : {}),    });    setResult({      kind: "completed",      text: value.text,      structured: value.structured,      isError: value.isError,    });  };   if (connections.length === 0) {    return <p className="text-sm text-muted-foreground">Add a connection to test tools.</p>;  }  const renderConnectionPicker = shouldRenderConnectionPicker(connections);   return (    <div className="space-y-4">      <CardStack>        <CardStackContent className="space-y-4 px-4 py-4">          {renderConnectionPicker && (            <div className="space-y-1.5">              <Label htmlFor="tool-run-connection">Connection</Label>              <NativeSelect                id="tool-run-connection"                className="w-full"                value={selectedConnection ?? ""}                onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>                  setSelectedConnection(e.target.value as ConnectionName)                }                disabled={running}              >                {connections.map((connection: Connection) => (                  <NativeSelectOption key={connection.name} value={connection.name}>                    {connection.identityLabel || connection.name}                  </NativeSelectOption>                ))}              </NativeSelect>              <p className="text-xs text-muted-foreground">                Pick the account whose credentials you want to verify.              </p>            </div>          )}           {/* Arguments editor — dynamic form built from the input JSON Schema,              with a Raw JSON escape hatch. Both views share the `argsJson`              string as the single source of truth. */}          <div className="space-y-1.5">            <Tabs              value={argsMode}              onValueChange={(v: string) => setArgsMode(v === "form" ? "form" : "json")}            >              <TabsList variant="line" className="w-full justify-start border-b">                <TabsTrigger value="form" disabled={!schemaAvailable || parsedArgs === null}>                  Form                </TabsTrigger>                <TabsTrigger value="json">Raw JSON</TabsTrigger>              </TabsList>               <TabsContent value="form" className="space-y-3 pt-3">                {schemaAvailable && parsedArgs !== null ? (                  <JsonSchemaForm                    schema={inputSchema}                    definitions={schemaDefinitions}                    value={parsedArgs}                    onChange={handleFormChange}                    disabled={running}                  />                ) : (                  <p className="text-xs text-muted-foreground">                    No schema available — use Raw JSON to enter arguments.                  </p>                )}              </TabsContent>               <TabsContent value="json" className="space-y-1.5 pt-3">                <Textarea                  id="tool-run-args"                  className="font-mono text-xs"                  rows={5}                  spellCheck={false}                  value={argsJson}                  onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>                    setArgsJson(e.target.value)                  }                  disabled={running}                  aria-invalid={argsError != null}                />                {argsError && <p className="text-xs text-destructive">{argsError}</p>}              </TabsContent>            </Tabs>          </div>           <div className="flex items-center justify-between gap-3">            {requiredMissing.length > 0 ? (              <p className="text-xs text-muted-foreground">                Fill required:{" "}                <span className="font-mono text-foreground/80">{requiredMissing.join(", ")}</span>              </p>            ) : (              <span aria-hidden />            )}            <Button              size="sm"              onClick={() => void handleRun()}              disabled={running || requiredMissing.length > 0}            >              {running ? "Running..." : "Run"}            </Button>          </div>        </CardStackContent>      </CardStack>       {result && <ToolRunResult result={result} />}    </div>  );} function ToolRunResult(props: { readonly result: RunResult }) {  const { result } = props;   if (result.kind === "paused") {    return (      <CardStack>        <CardStackHeader>Result</CardStackHeader>        <CardStackContent>          <p className="px-4 py-3 text-sm text-muted-foreground">            This tool requires approval (a policy gates it) — adjust the policy to run it directly.          </p>        </CardStackContent>      </CardStack>    );  }   const failed = result.isError || isFailedEnvelope(result.structured);  const hasStructured = result.structured !== undefined;   return (    <CardStack className={cn(failed ? "border-destructive/40" : undefined)}>      <CardStackHeader>        <div className="flex items-center gap-2">          <span>Result</span>          <Badge variant={failed ? "destructive" : "secondary"}>            {failed ? "Error" : "Success"}          </Badge>        </div>      </CardStackHeader>      <CardStackContent className="space-y-3 px-4 py-4">        {failed && (          <p className="text-xs text-destructive">            The tool returned an error — the credentials may be invalid or lack access.          </p>        )}        {hasStructured ? (          <CodeBlock            code={prettyJson(result.structured)}            lang="json"            title="Structured"            className={cn(failed ? "border-destructive/40" : undefined)}          />        ) : null}        {result.text && result.text.length > 0 && (          <div className="space-y-1">            <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">              Text            </p>            <pre className="max-h-48 overflow-auto rounded-md border border-border bg-card/60 p-3 font-mono text-xs whitespace-pre-wrap break-words text-foreground/80">              {result.text}            </pre>          </div>        )}      </CardStackContent>    </CardStack>  );}