Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/plugins/google-discovery/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"@effect-atom/atom-react": "^0.5.0",
"@effect/vitest": "catalog:",
"@executor/react": "workspace:*",
"@executor/storage-core": "workspace:*",
"@types/node": "catalog:",
"@types/react": "catalog:",
"bun-types": "catalog:",
Expand Down
19 changes: 19 additions & 0 deletions packages/plugins/google-discovery/src/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
GoogleDiscoverySourceError,
} from "../sdk/errors";
import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source";
import { GoogleDiscoveryAnnotationPolicy } from "../sdk/types";

export { HttpApiSchema };

Expand Down Expand Up @@ -58,13 +59,24 @@ const AddSourcePayload = Schema.Struct({
discoveryUrl: Schema.String,
namespace: Schema.optional(Schema.String),
auth: AuthPayload,
annotationPolicy: Schema.optional(GoogleDiscoveryAnnotationPolicy),
});

const AddSourceResponse = Schema.Struct({
toolCount: Schema.Number,
namespace: Schema.String,
});

const UpdateSourcePayload = Schema.Struct({
name: Schema.optional(Schema.String),
// `null` clears a previously-set override; `undefined` leaves as-is.
annotationPolicy: Schema.optional(Schema.NullOr(GoogleDiscoveryAnnotationPolicy)),
});

const UpdateSourceResponse = Schema.Struct({
updated: Schema.Boolean,
});

const StartOAuthPayload = Schema.Struct({
name: Schema.String,
discoveryUrl: Schema.String,
Expand Down Expand Up @@ -159,6 +171,13 @@ export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery")
)`/scopes/${scopeIdParam}/google-discovery/sources/${namespaceParam}`
.addSuccess(Schema.NullOr(GoogleDiscoveryStoredSourceSchema)),
)
.add(
HttpApiEndpoint.patch(
"updateSource",
)`/scopes/${scopeIdParam}/google-discovery/sources/${namespaceParam}`
.setPayload(UpdateSourcePayload)
.addSuccess(UpdateSourceResponse),
)
// Errors declared once at the group level — every endpoint inherits.
// `InternalError` is the shared opaque 500 translated at the HTTP edge
// by `withCapture`. The others are 4xx domain errors carrying their
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/google-discovery/src/api/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const failingExtension: GoogleDiscoveryPluginExtension = {
startOAuth: () => unused,
completeOAuth: () => Effect.die(new Error("Not implemented")),
getSource: (_namespace: string, _scope: string) => Effect.succeed(null),
updateSource: () => unused,
};

const Api = addGroup(GoogleDiscoveryGroup);
Expand Down
11 changes: 11 additions & 0 deletions packages/plugins/google-discovery/src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
GoogleDiscoveryAddSourceInput,
GoogleDiscoveryOAuthAuthResult,
GoogleDiscoveryPluginExtension,
GoogleDiscoveryUpdateSourceInput,
} from "../sdk/plugin";
import { GoogleDiscoveryOAuthError } from "../sdk/errors";
import { GoogleDiscoveryGroup } from "./group";
Expand Down Expand Up @@ -103,6 +104,16 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group(
return yield* ext.getSource(path.namespace, path.scopeId);
})),
)
.handle("updateSource", ({ path, payload }) =>
capture(Effect.gen(function* () {
const ext = yield* GoogleDiscoveryExtensionService;
yield* ext.updateSource(path.namespace, {
name: payload.name,
annotationPolicy: payload.annotationPolicy,
} satisfies GoogleDiscoveryUpdateSourceInput);
return { updated: true };
})),
)
.handle("oauthCallback", ({ urlParams }) =>
capture(Effect.gen(function* () {
const ext = yield* GoogleDiscoveryExtensionService;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ import { Input } from "@executor/react/components/input";
import { Label } from "@executor/react/components/label";
import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group";
import { IOSSpinner, Spinner } from "@executor/react/components/spinner";
import {
ApprovalPolicyToggles,
HTTP_METHOD_TOKENS,
} from "@executor/react/plugins/approval-policy-field";
import { addGoogleDiscoverySource, probeGoogleDiscovery, startGoogleDiscoveryOAuth } from "./atoms";

type GoogleAuthKind = "none" | "oauth2";
Expand Down Expand Up @@ -412,6 +416,9 @@ export default function AddGoogleDiscoverySource(props: {
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showScopes, setShowScopes] = useState(false);
const [annotationPolicy, setAnnotationPolicy] = useState<readonly string[] | undefined>(
undefined,
);

const scopeId = useScope();
const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promise" });
Expand Down Expand Up @@ -576,6 +583,21 @@ export default function AddGoogleDiscoverySource(props: {
authKind === "oauth2"
? (oauthAuth ?? { kind: "none" as const })
: { kind: "none" as const },
...(annotationPolicy !== undefined
? {
annotationPolicy: {
requireApprovalFor: annotationPolicy as readonly (
| "get"
| "put"
| "post"
| "delete"
| "patch"
| "head"
| "options"
)[],
},
}
: {}),
},
reactivityKeys: [...sourceWriteKeys],
});
Expand All @@ -586,7 +608,18 @@ export default function AddGoogleDiscoverySource(props: {
} finally {
placeholder.done();
}
}, [probe, doAdd, identity, discoveryUrl, authKind, oauthAuth, props, scopeId, beginAdd]);
}, [
probe,
doAdd,
identity,
discoveryUrl,
authKind,
oauthAuth,
props,
scopeId,
beginAdd,
annotationPolicy,
]);

const addDisabled =
!probe || adding || (authKind === "oauth2" && (!canUseOAuth || oauthAuth === null));
Expand Down Expand Up @@ -797,6 +830,15 @@ export default function AddGoogleDiscoverySource(props: {
)}
</section>

{probe && (
<ApprovalPolicyToggles
tokens={HTTP_METHOD_TOKENS}
value={annotationPolicy}
onChange={setAnnotationPolicy}
description="Choose which HTTP methods require approval before a tool call from this source runs. Defaults: write methods (POST / PUT / PATCH / DELETE) require approval."
/>
)}

{error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{error}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,40 +1,86 @@
import { useAtomValue, Result } from "@effect-atom/atom-react";
import { useState } from "react";
import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react";
import { useScope } from "@executor/react/api/scope-context";
import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
import { Badge } from "@executor/react/components/badge";
import { Button } from "@executor/react/components/button";
import {
ApprovalPolicyToggles,
HTTP_METHOD_TOKENS,
} from "@executor/react/plugins/approval-policy-field";
import type { GoogleDiscoveryStoredSourceSchemaType } from "../sdk/stored-source";

import { googleDiscoverySourceAtom } from "./atoms";
import { googleDiscoverySourceAtom, updateGoogleDiscoverySource } from "./atoms";

export default function EditGoogleDiscoverySource({
sourceId,
onSave,
}: {
readonly sourceId: string;
readonly onSave: () => void;
function EditForm(props: {
sourceId: string;
initial: GoogleDiscoveryStoredSourceSchemaType;
onSave: () => void;
}) {
const scopeId = useScope();
const sourceResult = useAtomValue(googleDiscoverySourceAtom(scopeId, sourceId));
const doUpdate = useAtomSet(updateGoogleDiscoverySource, { mode: "promise" });

const config = props.initial.config;
const authKind = config.auth.kind;

const [annotationPolicy, setAnnotationPolicy] = useState<readonly string[] | undefined>(
props.initial.annotationPolicy?.requireApprovalFor
? [...props.initial.annotationPolicy.requireApprovalFor]
: undefined,
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);

const source = Result.isSuccess(sourceResult) ? sourceResult.value : null;
const config = source?.config;
const authKind = config?.auth.kind ?? "none";
const handleSave = async () => {
setSaving(true);
setError(null);
try {
await doUpdate({
path: { scopeId, namespace: props.sourceId },
payload: {
annotationPolicy:
annotationPolicy === undefined
? null
: {
requireApprovalFor: annotationPolicy as readonly (
| "get"
| "put"
| "post"
| "delete"
| "patch"
| "head"
| "options"
)[],
},
},
reactivityKeys: sourceWriteKeys,
});
setDirty(false);
props.onSave();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update source");
} finally {
setSaving(false);
}
};

return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Edit Google Discovery Source</h1>
<p className="mt-1 text-sm text-muted-foreground">
View configuration for this Google API source. To change authentication, remove and re-add
the source with updated OAuth credentials.
Adjust the approval policy for this Google API source. To change authentication, remove
and re-add the source with updated OAuth credentials.
</p>
</div>

<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-card-foreground">
{source?.name ?? sourceId}
{props.initial.name}
</p>
{config?.discoveryUrl && (
{config.discoveryUrl && (
<p className="mt-0.5 text-xs text-muted-foreground font-mono truncate">
{config.discoveryUrl}
</p>
Expand All @@ -45,37 +91,80 @@ export default function EditGoogleDiscoverySource({
</Badge>
</div>

{config && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-lg border border-border bg-card/50 p-3">
<p className="text-xs uppercase tracking-wider text-muted-foreground mb-1">
Service
</p>
<p className="text-sm font-medium text-foreground">{config.service}</p>
</div>
<div className="rounded-lg border border-border bg-card/50 p-3">
<p className="text-xs uppercase tracking-wider text-muted-foreground mb-1">
Version
</p>
<p className="text-sm font-medium text-foreground">{config.version}</p>
</div>
</div>

<div className="space-y-3">
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-lg border border-border bg-card/50 p-3">
<p className="text-xs uppercase tracking-wider text-muted-foreground mb-1">
Authentication
Service
</p>
<p className="text-sm font-medium text-foreground capitalize">
{authKind === "oauth2" ? "OAuth 2.0" : authKind}
<p className="text-sm font-medium text-foreground">{config.service}</p>
</div>
<div className="rounded-lg border border-border bg-card/50 p-3">
<p className="text-xs uppercase tracking-wider text-muted-foreground mb-1">
Version
</p>
<p className="text-sm font-medium text-foreground">{config.version}</p>
</div>
</div>

<div className="rounded-lg border border-border bg-card/50 p-3">
<p className="text-xs uppercase tracking-wider text-muted-foreground mb-1">
Authentication
</p>
<p className="text-sm font-medium text-foreground capitalize">
{authKind === "oauth2" ? "OAuth 2.0" : authKind}
</p>
</div>
</div>

<ApprovalPolicyToggles
tokens={HTTP_METHOD_TOKENS}
value={annotationPolicy}
onChange={(next) => {
setAnnotationPolicy(next);
setDirty(true);
}}
description="Choose which HTTP methods require approval before a tool call from this source runs. Defaults: write methods (POST / PUT / PATCH / DELETE) require approval."
/>

{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
<p className="text-sm text-destructive">{error}</p>
</div>
)}

<div className="flex items-center justify-end border-t border-border pt-4">
<Button onClick={onSave}>Done</Button>
<div className="flex items-center justify-between border-t border-border pt-4">
<Button variant="ghost" onClick={props.onSave}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!dirty || saving}>
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</div>
);
}

export default function EditGoogleDiscoverySource({
sourceId,
onSave,
}: {
readonly sourceId: string;
readonly onSave: () => void;
}) {
const scopeId = useScope();
const sourceResult = useAtomValue(googleDiscoverySourceAtom(scopeId, sourceId));

if (!Result.isSuccess(sourceResult) || !sourceResult.value) {
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Edit Google Discovery Source</h1>
<p className="mt-1 text-sm text-muted-foreground">Loading configuration…</p>
</div>
</div>
);
}

return <EditForm sourceId={sourceId} initial={sourceResult.value} onSave={onSave} />;
}
4 changes: 4 additions & 0 deletions packages/plugins/google-discovery/src/react/atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,7 @@ export const completeGoogleDiscoveryOAuth = GoogleDiscoveryClient.mutation(
"googleDiscovery",
"completeOAuth",
);
export const updateGoogleDiscoverySource = GoogleDiscoveryClient.mutation(
"googleDiscovery",
"updateSource",
);
Loading
Loading