History

Merge remote-tracking branch 'origin/main' into codex/effect-lint-baseline

# Conflicts: # apps/cloud/src/api/slack.ts # apps/cloud/src/services/slack.ts # apps/local/src/web/shell.tsx # packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx # packages/plugins/graphql/src/react/AddGraphqlSource.tsx # packages/plugins/mcp/src/react/AddMcpSource.tsx # packages/plugins/openapi/src/react/AddOpenApiSource.tsx # packages/react/src/api/optimistic.tsx # packages/react/src/pages/source-detail.tsx

Rhys Sullivan committed 6 months ago

Commit 54c05452 parents 8e18c36 73a448222 files · +517 −862Browse files

Unified · Split

22 files
  1. apps/cloud/src/api/slack.ts +3 −52
  2. apps/cloud/src/env-augment.d.ts +0 −5
  3. apps/cloud/src/routes/billing_.plans.tsx +3 −129
  4. apps/cloud/src/services/slack.ts +53 −97
  5. apps/cloud/src/web/shell.tsx +2 −2
  6. apps/cloud/wrangler.jsonc +4 −7
  7. apps/local/src/web/shell.tsx +16 −22
  8. packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx +34 −53
  9. packages/plugins/google-discovery/src/react/atoms.ts +29 −0
  10. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +18 −16
  11. packages/plugins/graphql/src/react/atoms.ts +30 −0
  12. packages/plugins/mcp/src/react/AddMcpSource.tsx +19 −39
  13. packages/plugins/mcp/src/react/atoms.ts +29 −0
  14. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +176 −166
  15. packages/plugins/openapi/src/react/atoms.ts +30 −0
  16. packages/react/src/api/atoms.tsx +47 −15
  17. packages/react/src/api/optimistic.tsx +0 −213
  18. packages/react/src/components/command-palette.tsx +2 −2
  19. packages/react/src/pages/connections.tsx +9 −14
  20. packages/react/src/pages/source-detail.tsx +5 −5
  21. packages/react/src/pages/sources.tsx +7 −20
  22. warden.toml +1 −5
apps/cloud/src/api/slack.ts+3−52
@@ -11,29 +11,6 @@ import { · 10 unmodified lines
1111
1212 const isValidEmail = (s: string): boolean => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s);
1313
14−const verifyTurnstile = (token: string, remoteIp: string | null) =>
15− Effect.tryPromise({
16− try: async () => {
17− const secret = env.TURNSTILE_SECRET_KEY;
18− if (!secret) {
19− // Turnstile is unconfigured — fail closed in prod, but allow dev to
20− // boot without it. We treat empty secret as "skip" so the form works
21− // before secrets are wired; remove this branch once you've set the
22− // secret in every environment.
23− return { success: true, skipped: true };
24− }
25− const form = new URLSearchParams({ secret, response: token });
26− if (remoteIp) form.set("remoteip", remoteIp);
27− const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
28− method: "POST",
29− body: form,
30− });
31− const json = (await res.json()) as { success: boolean; "error-codes"?: string[] };
32− return { success: json.success, errorCodes: json["error-codes"] ?? [] };
33− },
34− catch: (cause) => ({ success: false as const, fetchError: cause }),
35− });
36−
3714 const handler = Effect.gen(function* () {
3815 const request = yield* HttpServerRequest.HttpServerRequest;
3916
@@ -55,20 +32,14 @@ const handler = Effect.gen(function* () { · 15 unmodified lines
5532 }),
5633 )) as {
5734 email?: unknown;
58− name?: unknown;
59− note?: unknown;
6035 organization?: unknown;
61− turnstileToken?: unknown;
6236 };
6337
6438 const trimmed = (v: unknown, max: number): string | undefined =>
6539 typeof v === "string" && v.trim().length > 0 ? v.trim().slice(0, max) : undefined;
6640
6741 const email = typeof body.email === "string" ? body.email.trim() : "";
68− const name = trimmed(body.name, 200);
69− const note = trimmed(body.note, 2000);
7042 const organization = trimmed(body.organization, 200);
71− const turnstileToken = typeof body.turnstileToken === "string" ? body.turnstileToken : "";
7243
7344 if (!isValidEmail(email)) {
7445 return yield* new HttpResponseError({
@@ -78,28 +49,8 @@ const handler = Effect.gen(function* () { · 3 unmodified lines
7849 });
7950 }
8051
81− if (!turnstileToken) {
82− return yield* new HttpResponseError({
83− status: 400,
84− code: "captcha_required",
85− message: "Captcha verification is required.",
86− });
87− }
88−
89− const remoteIp = request.headers["cf-connecting-ip"] ?? null;
90− const verification = yield* verifyTurnstile(turnstileToken, remoteIp);
91− if (!verification.success) {
92− console.error("[slack] turnstile verification failed:", verification);
93− return yield* new HttpResponseError({
94− status: 403,
95− code: "captcha_failed",
96− message: "Captcha verification failed. Please try again.",
97− });
98− }
99−
100− // Global daily channel-creation cap — bounds the worst case if Turnstile is
101− // bypassed somehow. Per-IP gating belongs at the edge (Cloudflare Rules);
102− // this binding is a single shared bucket keyed at "global".
52+ // Global rate limit — single shared bucket keyed at "global". Per-IP gating
53+ // belongs at the edge (Cloudflare Rules).
10354 const limit = yield* Effect.tryPromise({
10455 try: () => env.SLACK_INVITE_LIMITER.limit({ key: "global" }),
10556 catch: (cause) => ({ success: false as const, fetchError: cause }),
@@ -114,7 +65,7 @@ const handler = Effect.gen(function* () { · 8 unmodified lines
11465 }
11566
11667 const slack = yield* SlackService;
117− const { invite } = yield* slack.createConnectInvite({ email, name, note, organization }).pipe(
68+ const { invite } = yield* slack.createConnectInvite({ email, organization }).pipe(
11869 Effect.tapError((err) =>
11970 Effect.sync(() => {
12071 console.error(`[slack] ${err.method} failed:`, err.error);
apps/cloud/src/env-augment.d.ts+0−5
@@ -25,11 +25,6 @@ declare global { · 24 unmodified lines
2525
2626 // Contact / Slack Connect
2727 SLACK_BOT_TOKEN?: string;
28− // Turnstile (Cloudflare CAPTCHA) — used to gate the public Slack-contact
29− // endpoint. Sitekey is public and ships in `vars`; secret is set via
30− // `wrangler secret put TURNSTILE_SECRET_KEY`.
31− TURNSTILE_SECRET_KEY?: string;
32− VITE_PUBLIC_TURNSTILE_SITEKEY?: string;
3328 // Cloudflare ratelimit binding declared in wrangler.jsonc — caps total
3429 // Slack-contact channel creations across all callers.
3530 SLACK_INVITE_LIMITER: { limit: (input: { key: string }) => Promise<{ success: boolean }> };
apps/cloud/src/routes/billing_.plans.tsx+3−129
@@ -1,4 +1,4 @@
1−import { useEffect, useRef, useState } from "react";
1+import { useEffect, useState } from "react";
22 import { createFileRoute, Link } from "@tanstack/react-router";
33 import { useAuth } from "../web/auth";
44 import { useCustomer, useListPlans } from "autumn-js/react";
@@ -16,7 +16,6 @@ import { · 11 unmodified lines
1616 } from "@executor-js/react/components/dialog";
1717 import { Input } from "@executor-js/react/components/input";
1818 import { Label } from "@executor-js/react/components/label";
19−import { Textarea } from "@executor-js/react/components/textarea";
2019
2120 type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number];
2221
@@ -310,40 +309,27 @@ function SlackContactCta() { · 287 unmodified lines
310309 const auth = useAuth();
311310 const signedIn = auth.status === "authenticated" ? auth : null;
312311 const prefillEmail = signedIn?.user.email ?? "";
313− const prefillName = signedIn?.user.name ?? "";
314312 const orgName = signedIn?.organization?.name ?? "";
315313
316− const turnstileSiteKey = import.meta.env.VITE_PUBLIC_TURNSTILE_SITEKEY ?? "";
317314 const [open, setOpen] = useState(false);
318315 const [email, setEmail] = useState(prefillEmail);
319− const [name, setName] = useState(prefillName);
320− const [note, setNote] = useState("");
321316 const [submitting, setSubmitting] = useState(false);
322317 const [error, setError] = useState<string | null>(null);
323318 const [inviteUrl, setInviteUrl] = useState<string | null>(null);
324− const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
325319
326320 // Hydrate prefill once auth resolves (it starts as `loading` on first render).
327321 useEffect(() => {
328322 if (prefillEmail && !email) setEmail(prefillEmail);
329− if (prefillName && !name) setName(prefillName);
330− }, [prefillEmail, prefillName]); // eslint-disable-line react-hooks/exhaustive-deps
323+ }, [prefillEmail]); // eslint-disable-line react-hooks/exhaustive-deps
331324
332325 const reset = () => {
333326 setEmail(prefillEmail);
334− setName(prefillName);
335− setNote("");
336327 setError(null);
337328 setInviteUrl(null);
338− setTurnstileToken(null);
339329 };
340330
341331 const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
342332 e.preventDefault();
343− if (!turnstileToken) {
344− setError("Please complete the captcha first.");
345− return;
346− }
347333 setSubmitting(true);
348334 setError(null);
349335 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: browser fetch submit path maps network failures to public UI copy
@@ -353,10 +339,7 @@ function SlackContactCta() { · 3 unmodified lines
353339 headers: { "content-type": "application/json" },
354340 body: JSON.stringify({
355341 email,
356− name: name || undefined,
357− note: note || undefined,
358342 organization: orgName || undefined,
359− turnstileToken,
360343 }),
361344 });
362345 const data = (await res.json().then(
@@ -372,9 +355,6 @@ function SlackContactCta() { · 9 unmodified lines
372355 setError("Network error. Please try again.");
373356 } finally {
374357 setSubmitting(false);
375− // Turnstile tokens are single-use server-side — clear so the widget
376− // re-issues a fresh one on retry.
377− setTurnstileToken(null);
378358 }
379359 };
380360
@@ -449,29 +429,6 @@ function SlackContactCta() { · 68 unmodified lines
449429 placeholder="you@company.com"
450430 />
451431 </div>
452− <div className="grid gap-2">
453− <Label htmlFor="slack-contact-name">Name (optional)</Label>
454− <Input
455− id="slack-contact-name"
456− autoComplete="name"
457− value={name}
458− onChange={(e) => setName(e.currentTarget.value)}
459− placeholder="Ada Lovelace"
460− />
461− </div>
462− <div className="grid gap-2">
463− <Label htmlFor="slack-contact-note">What's on your mind? (optional)</Label>
464− <Textarea
465− id="slack-contact-note"
466− rows={3}
467− value={note}
468− onChange={(e) => setNote(e.currentTarget.value)}
469− placeholder="Pricing question, integration help, anything…"
470− />
471− </div>
472− {turnstileSiteKey && (
473− <TurnstileWidget siteKey={turnstileSiteKey} onToken={setTurnstileToken} />
474− )}
475432 {error && <p className="text-sm text-destructive">{error}</p>}
476433 </div>
477434 <DialogFooter>
@@ -480,10 +437,7 @@ function SlackContactCta() { · 2 unmodified lines
480437 Cancel
481438 </Button>
482439 </DialogClose>
483− <Button
484− type="submit"
485− disabled={submitting || !email || (!!turnstileSiteKey && !turnstileToken)}
486− >
440+ <Button type="submit" disabled={submitting || !email}>
487441 {submitting ? "Sending…" : "Send invite"}
488442 </Button>
489443 </DialogFooter>
@@ -515,86 +469,6 @@ function MailIcon({ className }: { className?: string }) { · 25 unmodified lines
515469 );
516470 }
517471
518−type TurnstileApi = {
519− render: (
520− el: HTMLElement,
521− opts: {
522− sitekey: string;
523− callback?: (token: string) => void;
524− "error-callback"?: () => void;
525− "expired-callback"?: () => void;
526− theme?: "light" | "dark" | "auto";
527− },
528− ) => string;
529− remove: (id: string) => void;
530−};
531−
532−declare global {
533− interface Window {
534− turnstile?: TurnstileApi;
535− }
536−}
537−
538−function TurnstileWidget({
539− siteKey,
540− onToken,
541−}: {
542− siteKey: string;
543− onToken: (token: string | null) => void;
544−}) {
545− const containerRef = useRef<HTMLDivElement>(null);
546− const widgetIdRef = useRef<string | null>(null);
547− const onTokenRef = useRef(onToken);
548− onTokenRef.current = onToken;
549−
550− useEffect(() => {
551− let cancelled = false;
552− const render = () => {
553− if (cancelled || !containerRef.current || !window.turnstile) return;
554− if (widgetIdRef.current) {
555− window.turnstile.remove(widgetIdRef.current);
556− widgetIdRef.current = null;
557− }
558− widgetIdRef.current = window.turnstile.render(containerRef.current, {
559− sitekey: siteKey,
560− callback: (token) => onTokenRef.current(token),
561− "error-callback": () => onTokenRef.current(null),
562− "expired-callback": () => onTokenRef.current(null),
563− });
564− };
565−
566− if (window.turnstile) {
567− render();
568− } else {
569− const existing = document.querySelector<HTMLScriptElement>("script[data-turnstile]");
570− if (existing) {
571− existing.addEventListener("load", render);
572− return () => {
573− cancelled = true;
574− existing.removeEventListener("load", render);
575− };
576− }
577− const script = document.createElement("script");
578− script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
579− script.async = true;
580− script.defer = true;
581− script.dataset.turnstile = "1";
582− script.addEventListener("load", render);
583− document.head.appendChild(script);
584− }
585−
586− return () => {
587− cancelled = true;
588− if (widgetIdRef.current && window.turnstile) {
589− window.turnstile.remove(widgetIdRef.current);
590− widgetIdRef.current = null;
591− }
592− };
593− }, [siteKey]);
594−
595− return <div ref={containerRef} className="flex justify-center" />;
596−}
597−
598472 function SlackMark({ className }: { className?: string }) {
599473 return (
600474 <svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
apps/cloud/src/services/slack.ts+53−97
@@ -5,18 +5,17 @@ · 4 unmodified lines
55 // ---------------------------------------------------------------------------
66
77 import { env } from "cloudflare:workers";
8−import { Context, Data, Effect, Layer, Schema } from "effect";
8+import { Context, Data, Effect, Layer } from "effect";
99
1010 export class SlackError extends Data.TaggedError("SlackError")<{
1111 method: string;
1212 error: string;
13+ cause?: unknown;
1314 }> {}
1415
1516 export type ISlackService = Readonly<{
1617 createConnectInvite: (input: {
1718 email: string;
18− name?: string;
19− note?: string;
2019 organization?: string;
2120 }) => Effect.Effect<
2221 {
@@ -27,8 +26,8 @@ export type ISlackService = Readonly<{ · 4 unmodified lines
2726 >;
2827 }>;
2928
30−const slugifyEmail = (email: string): string =>
31− email
29+const slugify = (s: string): string =>
30+ s
3231 .toLowerCase()
3332 .replace(/[^a-z0-9]+/g, "-")
3433 .replace(/^-+|-+$/g, "")
@@ -40,95 +39,64 @@ const randomSuffix = (): string => { · 5 unmodified lines
4039 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
4140 };
4241
43−const SlackErrorResponse = Schema.Struct({
44− ok: Schema.Literal(false),
45− error: Schema.optional(Schema.String),
46−});
47−
48−const SlackChannel = Schema.Struct({ id: Schema.String, name: Schema.String });
49−
50−const SlackPostMessageResponse = Schema.Struct({ ok: Schema.Literal(true) });
51−
52−const SlackCreateChannelResponse = Schema.Struct({
53− ok: Schema.Literal(true),
54− channel: SlackChannel,
55−});
56−
57−const SlackSharedInviteResponse = Schema.Struct({
58− ok: Schema.Literal(true),
59− invite_id: Schema.String,
60− url: Schema.String,
61−});
42+type SlackResponse = { ok: boolean; error?: string } & Record<string, unknown>;
6243
6344 const make = Effect.sync(() => {
6445 const token = env.SLACK_BOT_TOKEN;
6546
6647 if (!token) {
6748 const notConfigured = (method: string) =>
68− Effect.fail(new SlackError({ method, error: "SLACK_BOT_TOKEN is not configured" }));
49+ Effect.fail(
50+ new SlackError({ method, error: "SLACK_BOT_TOKEN is not configured" }),
51+ );
6952 return {
7053 createConnectInvite: () => notConfigured("createConnectInvite"),
7154 } satisfies ISlackService;
7255 }
7356
74− const call = <A extends { ok: true }>(
75− method: string,
76− body: Record<string, unknown>,
77− successSchema: Schema.Decoder<A>,
78− ) =>
79− Effect.gen(function* () {
80− const json = yield* Effect.tryPromise({
81− try: async (): Promise<unknown> => {
82− const res = await fetch(`https://slack.com/api/${method}`, {
83− method: "POST",
84− headers: {
85− "content-type": "application/json; charset=utf-8",
86− authorization: `Bearer ${token}`,
87− },
88− body: JSON.stringify(body),
89− });
90− return res.json();
91− },
92− catch: () =>
93− new SlackError({
94− method,
95− error: "Failed to read Slack API response",
96− }),
97− });
98−
99− const response = yield* Schema.decodeUnknownEffect(
100− Schema.Union([successSchema, SlackErrorResponse]),
101− )(json).pipe(
102− Effect.mapError(
103− () =>
104− new SlackError({
105− method,
106− error: "Unexpected Slack API response",
107− }),
108− ),
109− );
110−
111− if (!response.ok) {
112− return yield* new SlackError({
113− method,
114− error: response.error ?? "unknown_slack_error",
57+ const call = <A extends SlackResponse>(method: string, body: Record<string, unknown>) =>
58+ Effect.tryPromise({
59+ try: async (): Promise<A> => {
60+ const res = await fetch(`https://slack.com/api/${method}`, {
61+ method: "POST",
62+ headers: {
63+ "content-type": "application/json; charset=utf-8",
64+ authorization: `Bearer ${token}`,
65+ },
66+ body: JSON.stringify(body),
11567 });
116− }
117−
118− return response;
119− }).pipe(Effect.withSpan(`slack.${method}`));
68+ const json = (await res.json()) as A;
69+ return json;
70+ },
71+ catch: (cause) =>
72+ new SlackError({
73+ method,
74+ error: "Slack request failed",
75+ cause,
76+ }),
77+ }).pipe(
78+ Effect.flatMap((json) =>
79+ json.ok
80+ ? Effect.succeed(json)
81+ : Effect.fail(new SlackError({ method, error: json.error ?? "unknown_slack_error" })),
82+ ),
83+ Effect.withSpan(`slack.${method}`),
84+ );
12085
12186 const createConnectInvite: ISlackService["createConnectInvite"] = ({
12287 email,
123− name,
124− note,
12588 organization,
12689 }) =>
12790 Effect.gen(function* () {
128− // Slack channel names: lowercase, no spaces, max 80 chars, unique per workspace.
129− const baseName = `shared-${slugifyEmail(email)}`.slice(0, 80);
91+ // Slack channel names: lowercase, no spaces, max 80 chars, unique per
92+ // workspace. Use the org name when available; fall back to the email
93+ // local-part for unauthenticated submissions.
94+ const baseName = slugify(organization ?? email).slice(0, 80) || "contact";
13095 const tryCreate = (n: string) =>
131− call("conversations.create", { name: n, is_private: false }, SlackCreateChannelResponse);
96+ call<SlackResponse & { channel: { id: string; name: string } }>(
97+ "conversations.create",
98+ { name: n, is_private: false },
99+ );
132100
133101 const created = yield* tryCreate(baseName).pipe(
134102 Effect.catchTag("SlackError", (err) =>
@@ -139,39 +107,27 @@ const make = Effect.sync(() => { · 4 unmodified lines
139107 );
140108 const channel = created.channel;
141109
142− const helloLines = [
143− `:wave: New contact from pricing page: ${email}`,
144− name ? `Name: ${name}` : null,
145− organization ? `Org: ${organization}` : null,
146− note ? `Note: ${note}` : null,
147− ].filter(Boolean) as string[];
148−
149− yield* call(
150− "chat.postMessage",
151− {
152− channel: channel.id,
153− text: helloLines.join("\n"),
154− },
155− SlackPostMessageResponse,
156− );
157−
158− const invite = yield* call(
110+ const invite = yield* call<SlackResponse & { invite_id: string; url: string }>(
159111 "conversations.inviteShared",
160112 {
161113 channel: channel.id,
162114 emails: [email],
163− // `external_limited: true` scopes the guest to just this channel,
164− // which is what we want for a focused 1:1 support conversation.
165− external_limited: true,
166115 },
167− SlackSharedInviteResponse,
116+ );
117+
118+ // Best-effort: leave the channel so the bot doesn't sit in every
119+ // contact channel forever. Don't fail the request if leave errors.
120+ yield* call<SlackResponse>("conversations.leave", { channel: channel.id }).pipe(
121+ Effect.ignore,
168122 );
169123
170124 return {
171125 channel: { id: channel.id, name: channel.name },
172126 invite: { invite_id: invite.invite_id, url: invite.url },
173127 };
174− }).pipe(Effect.withSpan("slack.createConnectInvite", { attributes: { "slack.email": email } }));
128+ }).pipe(
129+ Effect.withSpan("slack.createConnectInvite", { attributes: { "slack.email": email } }),
130+ );
175131
176132 return { createConnectInvite } satisfies ISlackService;
177133 });
apps/cloud/src/web/shell.tsx+2−2
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react"; · 2 unmodified lines
33 import { useAtomValue, useAtomSet } from "@effect/atom-react";
44 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
55 import * as Exit from "effect/Exit";
6−import { useSourcesWithPending } from "@executor-js/react/api/optimistic";
6+import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms";
77 import { useScope } from "@executor-js/react/api/scope-context";
88 import { Button } from "@executor-js/react/components/button";
99 import { Skeleton } from "@executor-js/react/components/skeleton";
@@ -60,7 +60,7 @@ function NavItem(props: { to: string; label: string; active: boolean; onNavigate · 50 unmodified lines
6060
6161 function SourceList(props: { pathname: string; onNavigate?: () => void }) {
6262 const scopeId = useScope();
63− const sources = useSourcesWithPending(scopeId);
63+ const sources = useAtomValue(sourcesOptimisticAtom(scopeId));
6464
6565 return AsyncResult.match(sources, {
6666 onInitial: () => (
apps/cloud/wrangler.jsonc+4−7
@@ -54,15 +54,12 @@ · 53 unmodified lines
5454 "vars": {
5555 "VITE_PUBLIC_SITE_URL": "https://executor.sh",
5656 "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL",
57− // Replace with your Turnstile production sitekey before going live. The
58− // test key always passes — fine for local dev, useless in prod.
59− "VITE_PUBLIC_TURNSTILE_SITEKEY": "1x00000000000000000000AA",
6057 },
6158 // Cloudflare's `simple` ratelimit only supports 10s or 60s windows — it's
62− // burst protection, not a true daily cap. With Turnstile gating the form,
63− // 5/min keeps the worst-case sustained throughput to ~7k channels/day,
64− // which is bounded enough as a backstop. For an actual daily ceiling,
65− // layer a Durable Object counter on top.
59+ // burst protection, not a true daily cap. 5/min keeps the worst-case
60+ // sustained throughput to ~7k channels/day, which is bounded enough as a
61+ // backstop. For an actual daily ceiling, layer a Durable Object counter
62+ // on top.
6663 "ratelimits": [
6764 {
6865 "name": "SLACK_INVITE_LIMITER",
apps/local/src/web/shell.tsx+16−22
@@ -1,10 +1,10 @@
11 import { Link, Outlet, useLocation } from "@tanstack/react-router";
22 import { useCallback, useEffect, useRef, useState } from "react";
3−import { useAtomRefresh } from "@effect/atom-react";
4−import { Data, Effect, Exit } from "effect";
3+import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
4+import * as Effect from "effect/Effect";
5+import * as Exit from "effect/Exit";
56 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
6−import { sourcesAtom, toolsAtom } from "@executor-js/react/api/atoms";
7−import { useSourcesWithPending } from "@executor-js/react/api/optimistic";
7+import { sourcesAtom, sourcesOptimisticAtom, toolsAtom } from "@executor-js/react/api/atoms";
88 import { useScope, useScopeInfo } from "@executor-js/react/api/scope-context";
99 import { Button } from "@executor-js/react/components/button";
1010 import { SourceFavicon } from "@executor-js/react/components/source-favicon";
@@ -30,10 +30,6 @@ type UpdateChannel = "latest" | "beta"; · 19 unmodified lines
3030
3131 const EXECUTOR_DIST_TAGS_PATH = "/v1/app/npm/dist-tags";
3232
33−class LatestVersionCheckError extends Data.TaggedError("LatestVersionCheckError")<{
34− readonly cause: unknown;
35−}> {}
36−
3733 type ParsedVersion = {
3834 readonly major: number;
3935 readonly minor: number;
@@ -100,22 +96,20 @@ function useLatestVersion(currentVersion: string) { · 60 unmodified lines
10096
10197 useEffect(() => {
10298 let cancelled = false;
103−
104− const latestVersionEffect = Effect.tryPromise({
105− try: async () => {
106− const res = await fetch(EXECUTOR_DIST_TAGS_PATH);
107− if (!res.ok) return {};
108− return (await res.json()) as Partial<Record<UpdateChannel, string>>;
109− },
110− catch: (cause) => new LatestVersionCheckError({ cause }),
111− });
112−
113− void Effect.runPromiseExit(latestVersionEffect).then((exit) => {
99+ void Effect.runPromiseExit(
100+ Effect.tryPromise({
101+ try: async () => {
102+ const res = await fetch(EXECUTOR_DIST_TAGS_PATH);
103+ if (!res.ok) return null;
104+ return (await res.json()) as Partial<Record<UpdateChannel, string>>;
105+ },
106+ catch: (cause) => cause,
107+ }),
108+ ).then((exit) => {
114109 if (!cancelled && Exit.isSuccess(exit)) {
115− setLatestVersion(exit.value[channel] ?? null);
110+ setLatestVersion(exit.value?.[channel] ?? null);
116111 }
117112 });
118−
119113 return () => {
120114 cancelled = true;
121115 };
@@ -273,7 +267,7 @@ function PluginNav(props: { pathname: string; onNavigate?: () => void }) { · 151 unmodified lines
273267
274268 function SourceList(props: { pathname: string; onNavigate?: () => void }) {
275269 const scopeId = useScope();
276− const sources = useSourcesWithPending(scopeId);
270+ const sources = useAtomValue(sourcesOptimisticAtom(scopeId));
277271
278272 return AsyncResult.match(sources, {
279273 onInitial: () => <div className="px-2.5 py-2 text-xs text-muted-foreground">Loading…</div>,
packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx+34−53
@@ -1,8 +1,9 @@
11 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22 import { useAtomSet } from "@effect/atom-react";
3−import { Exit, Option, Schema } from "effect";
3+import * as Exit from "effect/Exit";
4+import * as Option from "effect/Option";
5+import * as Schema from "effect/Schema";
46
5−import { usePendingSources } from "@executor-js/react/api/optimistic";
67 import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
78 import { useScope } from "@executor-js/react/api/scope-context";
89 import type { SecretPickerSecret } from "@executor-js/react/plugins/secret-picker";
@@ -45,10 +46,21 @@ import { FloatActions } from "@executor-js/react/components/float-actions"; · 36 unmodified lines
4546 import { Input } from "@executor-js/react/components/input";
4647 import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group";
4748 import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";
48−import { addGoogleDiscoverySource, probeGoogleDiscovery } from "./atoms";
49+import { addGoogleDiscoverySourceOptimistic, probeGoogleDiscovery } from "./atoms";
4950 import { GOOGLE_DISCOVERY_OAUTH_POPUP_NAME, googleDiscoveryOAuthStrategy } from "./oauth";
5051 import { googleDiscoveryPresets, type GoogleDiscoveryPreset } from "../sdk/presets";
5152
53+const ErrorMessage = Schema.Struct({ message: Schema.String });
54+
55+const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
56+ Option.match(
57+ Option.flatMap(Exit.findErrorOption(exit), Schema.decodeUnknownOption(ErrorMessage)),
58+ {
59+ onNone: () => fallback,
60+ onSome: ({ message }) => message,
61+ },
62+ );
63+
5264 type GoogleAuthKind = "none" | "oauth2";
5365
5466 // ---------------------------------------------------------------------------
@@ -96,50 +108,25 @@ type GoogleDiscoveryTemplate = GoogleDiscoveryPreset & { · 41 unmodified lines
96108 };
97109
98110 const GOOGLE_G_ICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";
99−const PublicErrorMessage = Schema.Struct({
100− _tag: Schema.Literals([
101− "GoogleDiscoveryParseError",
102− "GoogleDiscoveryOAuthError",
103− "GoogleDiscoverySourceError",
104− ]),
105− message: Schema.String,
106−});
107−
108−const messageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => {
109− const error = Exit.findErrorOption(exit);
110− if (Option.isNone(error)) return fallback;
111− const errorMessage = Schema.decodeUnknownOption(PublicErrorMessage)(error.value);
112− return Option.match(errorMessage, {
113− onNone: () => fallback,
114− onSome: (value) => value.message,
115− });
116−};
117111
118−const parseUrlOption = (value: string): URL | null => {
119− // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor is the platform URL parser
112+function parseGoogleDiscoveryPreset(preset: GoogleDiscoveryPreset): GoogleDiscoveryTemplate {
113+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes user-provided preset URLs
120114 try {
121− return new URL(value);
115+ const url = new URL(preset.url);
116+ const parts = url.pathname.split("/").filter(Boolean);
117+ const apisIndex = parts.indexOf("apis");
118+ const service = apisIndex >= 0 ? parts[apisIndex + 1] : undefined;
119+ const version =
120+ apisIndex >= 0 ? parts[apisIndex + 2] : (url.searchParams.get("version") ?? undefined);
121+ return {
122+ ...preset,
123+ discoveryUrl: preset.url,
124+ service: service ?? url.hostname.replace(/\.googleapis\.com$/, ""),
125+ version: version ?? "",
126+ };
122127 } catch {
123− return null;
124− }
125−};
126−
127−function parseGoogleDiscoveryPreset(preset: GoogleDiscoveryPreset): GoogleDiscoveryTemplate {
128− const url = parseUrlOption(preset.url);
129− if (!url) {
130128 return { ...preset, discoveryUrl: preset.url, service: preset.id, version: "" };
131129 }
132− const parts = url.pathname.split("/").filter(Boolean);
133− const apisIndex = parts.indexOf("apis");
134− const service = apisIndex >= 0 ? parts[apisIndex + 1] : undefined;
135− const version =
136− apisIndex >= 0 ? parts[apisIndex + 2] : (url.searchParams.get("version") ?? undefined);
137− return {
138− ...preset,
139− discoveryUrl: preset.url,
140− service: service ?? url.hostname.replace(/\.googleapis\.com$/, ""),
141− version: version ?? "",
142− };
143130 }
144131
145132 const GOOGLE_DISCOVERY_TEMPLATES = googleDiscoveryPresets.map(parseGoogleDiscoveryPreset);
@@ -230,8 +217,9 @@ export default function AddGoogleDiscoverySource(props: { · 84 unmodified lines
230217
231218 const scopeId = useScope();
232219 const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promiseExit" });
233− const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promiseExit" });
234− const { beginAdd } = usePendingSources();
220+ const doAdd = useAtomSet(addGoogleDiscoverySourceOptimistic(scopeId), {
221+ mode: "promiseExit",
222+ });
235223 const secretList = useSecretPickerSecrets();
236224 const oauth = useOAuthPopupFlow({
237225 popupName: GOOGLE_DISCOVERY_OAUTH_POPUP_NAME,
@@ -269,7 +257,7 @@ export default function AddGoogleDiscoverySource(props: { · 31 unmodified lines
269257 if (Exit.isFailure(exit)) {
270258 setProbe(null);
271259 setLoadingProbe(false);
272− setError(messageFromExit(exit, "Failed to inspect discovery document"));
260+ setError(errorMessageFromExit(exit, "Failed to inspect discovery document"));
273261 return;
274262 }
275263 const result = exit.value;
@@ -354,11 +342,6 @@ export default function AddGoogleDiscoverySource(props: { · 78 unmodified lines
354342 setError(null);
355343 const displayName = identity.name.trim() || probe.name;
356344 const namespace = resolvedNamespace;
357− const placeholder = beginAdd({
358− id: namespace,
359− name: displayName,
360− kind: "google-discovery",
361− });
362345 const exit = await doAdd({
363346 params: { scopeId },
364347 payload: {
@@ -378,9 +361,8 @@ export default function AddGoogleDiscoverySource(props: { · 13 unmodified lines
378361 },
379362 reactivityKeys: [...sourceWriteKeys],
380363 });
381− placeholder.done();
382364 if (Exit.isFailure(exit)) {
383− setError(messageFromExit(exit, "Failed to add source"));
365+ setError(errorMessageFromExit(exit, "Failed to add source"));
384366 setAdding(false);
385367 return;
386368 }
@@ -394,7 +376,6 @@ export default function AddGoogleDiscoverySource(props: { · 7 unmodified lines
394376 oauthAuth,
395377 props,
396378 scopeId,
397− beginAdd,
398379 resolvedNamespace,
399380 ]);
400381
packages/plugins/google-discovery/src/react/atoms.ts+29−0
@@ -1,4 +1,7 @@
11 import type { ScopeId } from "@executor-js/sdk/core";
2+import * as Atom from "effect/unstable/reactivity/Atom";
3+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4+import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms";
25 import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
36 import { GoogleDiscoveryClient } from "./client";
47
@@ -25,6 +28,32 @@ export const addGoogleDiscoverySource = GoogleDiscoveryClient.mutation( · 20 unmodified lines
2528 "googleDiscovery",
2629 "addSource",
2730 );
31+export const addGoogleDiscoverySourceOptimistic = Atom.family((scopeId: ScopeId) =>
32+ sourcesOptimisticAtom(scopeId).pipe(
33+ Atom.optimisticFn({
34+ reducer: (current, arg) =>
35+ AsyncResult.map(current, (rows) => {
36+ const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`;
37+ const source = {
38+ id,
39+ scopeId,
40+ kind: "googleDiscovery",
41+ pluginId: "google-discovery",
42+ name: arg.payload.name,
43+ url: arg.payload.discoveryUrl,
44+ canRemove: false,
45+ canRefresh: false,
46+ canEdit: false,
47+ runtime: false,
48+ };
49+ return [source, ...rows.filter((row) => row.id !== id)].sort((a, b) =>
50+ a.name.localeCompare(b.name),
51+ );
52+ }),
53+ fn: addGoogleDiscoverySource,
54+ }),
55+ ),
56+);
2857 export const updateGoogleDiscoverySource = GoogleDiscoveryClient.mutation(
2958 "googleDiscovery",
3059 "updateSource",
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+18−16
@@ -1,10 +1,11 @@
11 import { useCallback, useState } from "react";
22 import { useAtomSet } from "@effect/atom-react";
33 import * as Exit from "effect/Exit";
4+import * as Option from "effect/Option";
5+import * as Schema from "effect/Schema";
46
57 import { useScope } from "@executor-js/react/api/scope-context";
68 import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
7−import { usePendingSources } from "@executor-js/react/api/optimistic";
89 import {
910 HttpCredentialsEditor,
1011 httpCredentialsValid,
@@ -34,10 +35,21 @@ import { · 23 unmodified lines
3435 import { FloatActions } from "@executor-js/react/components/float-actions";
3536 import { Input } from "@executor-js/react/components/input";
3637 import { Spinner } from "@executor-js/react/components/spinner";
37−import { addGraphqlSource } from "./atoms";
38+import { addGraphqlSourceOptimistic } from "./atoms";
3839 import { initialGraphqlCredentials } from "./defaults";
3940 import type { HeaderValue } from "../sdk/types";
4041
42+const ErrorMessage = Schema.Struct({ message: Schema.String });
43+
44+const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
45+ Option.match(
46+ Option.flatMap(Exit.findErrorOption(exit), Schema.decodeUnknownOption(ErrorMessage)),
47+ {
48+ onNone: () => fallback,
49+ onSome: ({ message }) => message,
50+ },
51+ );
52+
4153 type AuthMode = "none" | "oauth2";
4254
4355 export default function AddGraphqlSource(props: {
@@ -56,8 +68,7 @@ export default function AddGraphqlSource(props: { · 12 unmodified lines
5668 const [tokens, setTokens] = useState<OAuthCompletionPayload | null>(null);
5769
5870 const scopeId = useScope();
59− const doAdd = useAtomSet(addGraphqlSource, { mode: "promiseExit" });
60− const { beginAdd } = usePendingSources();
71+ const doAdd = useAtomSet(addGraphqlSourceOptimistic(scopeId), { mode: "promiseExit" });
6172 const secretList = useSecretPickerSecrets();
6273 const oauth = useOAuthPopupFlow({
6374 popupName: "graphql-oauth",
@@ -113,18 +124,12 @@ export default function AddGraphqlSource(props: { · 49 unmodified lines
113124 const { headers: headerMap, queryParams } = serializeHttpCredentials(credentials);
114125
115126 const { trimmedEndpoint, namespace, displayName } = sourceIdentity();
116− const placeholder = beginAdd({
117− id: namespace,
118− name: displayName,
119− kind: "graphql",
120− url: trimmedEndpoint || undefined,
121− });
122127 const exit = await doAdd({
123128 params: { scopeId },
124129 payload: {
125130 endpoint: trimmedEndpoint,
126− name: identity.name.trim() || undefined,
127− namespace: slugifyNamespace(identity.namespace) || undefined,
131+ name: displayName,
132+ namespace,
128133 ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}),
129134 ...(Object.keys(queryParams).length > 0
130135 ? { queryParams: queryParams as Record<string, HeaderValue> }
@@ -140,14 +145,11 @@ export default function AddGraphqlSource(props: { · 9 unmodified lines
140145 },
141146 reactivityKeys: sourceWriteKeys,
142147 });
143− placeholder.done();
144−
145148 if (Exit.isFailure(exit)) {
146− setAddError("Failed to add source");
149+ setAddError(errorMessageFromExit(exit, "Failed to add source"));
147150 setAdding(false);
148151 return;
149152 }
150−
151153 props.onComplete();
152154 };
153155
packages/plugins/graphql/src/react/atoms.ts+30−0
@@ -1,4 +1,7 @@
11 import type { ScopeId } from "@executor-js/sdk/core";
2+import * as Atom from "effect/unstable/reactivity/Atom";
3+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4+import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms";
25 import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
36 import { GraphqlClient } from "./client";
47
@@ -19,4 +22,31 @@ export const graphqlSourceAtom = (scopeId: ScopeId, namespace: string) => · 14 unmodified lines
1922
2023 export const addGraphqlSource = GraphqlClient.mutation("graphql", "addSource");
2124
25+export const addGraphqlSourceOptimistic = Atom.family((scopeId: ScopeId) =>
26+ sourcesOptimisticAtom(scopeId).pipe(
27+ Atom.optimisticFn({
28+ reducer: (current, arg) =>
29+ AsyncResult.map(current, (rows) => {
30+ const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`;
31+ const source = {
32+ id,
33+ scopeId,
34+ kind: "graphql",
35+ pluginId: "graphql",
36+ name: arg.payload.name ?? id,
37+ url: arg.payload.endpoint,
38+ canRemove: false,
39+ canRefresh: false,
40+ canEdit: false,
41+ runtime: false,
42+ };
43+ return [source, ...rows.filter((row) => row.id !== id)].sort((a, b) =>
44+ a.name.localeCompare(b.name),
45+ );
46+ }),
47+ fn: addGraphqlSource,
48+ }),
49+ ),
50+);
51+
2252 export const updateGraphqlSource = GraphqlClient.mutation("graphql", "updateSource");
packages/plugins/mcp/src/react/AddMcpSource.tsx+19−39
@@ -1,6 +1,8 @@
11 import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
22 import { useAtomSet } from "@effect/atom-react";
3−import { Exit, Option, Schema } from "effect";
3+import * as Exit from "effect/Exit";
4+import * as Option from "effect/Option";
5+import * as Schema from "effect/Schema";
46
57 import { useScope } from "@executor-js/react/api/scope-context";
68 import { Button } from "@executor-js/react/components/button";
@@ -50,24 +52,19 @@ import { · 43 unmodified lines
5052
5153 type RemoteAuthMode = "none" | "header" | "oauth2";
5254 import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
53−import { usePendingSources } from "@executor-js/react/api/optimistic";
54−import { probeMcpEndpoint, addMcpSource } from "./atoms";
55+import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms";
5556 import { mcpPresets, type McpPreset } from "../sdk/presets";
5657
57−const PublicErrorMessage = Schema.Struct({
58− _tag: Schema.Literals(["McpConnectionError", "McpToolDiscoveryError", "McpOAuthError"]),
59− message: Schema.String,
60−});
61−
62−const messageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => {
63− const error = Exit.findErrorOption(exit);
64− if (Option.isNone(error)) return fallback;
65− const errorMessage = Schema.decodeUnknownOption(PublicErrorMessage)(error.value);
66− return Option.match(errorMessage, {
67− onNone: () => fallback,
68− onSome: (value) => value.message,
69− });
70−};
58+const ErrorMessage = Schema.Struct({ message: Schema.String });
59+
60+const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
61+ Option.match(
62+ Option.flatMap(Exit.findErrorOption(exit), Schema.decodeUnknownOption(ErrorMessage)),
63+ {
64+ onNone: () => fallback,
65+ onSome: ({ message }) => message,
66+ },
67+ );
7168
7269 // ---------------------------------------------------------------------------
7370 // Preset lookup
@@ -285,8 +282,7 @@ export default function AddMcpSource(props: { · 211 unmodified lines
285282
286283 const scopeId = useScope();
287284 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });
288− const doAdd = useAtomSet(addMcpSource, { mode: "promiseExit" });
289− const { beginAdd } = usePendingSources();
285+ const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), { mode: "promiseExit" });
290286 const secretList = useSecretPickerSecrets();
291287 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
292288 popupName: "mcp-oauth",
@@ -361,7 +357,7 @@ export default function AddMcpSource(props: { · 68 unmodified lines
361357 if (Exit.isFailure(exit)) {
362358 dispatch({
363359 type: "probe-fail",
364− error: messageFromExit(exit, "Failed to connect"),
360+ error: errorMessageFromExit(exit, "Failed to connect"),
365361 });
366362 return;
367363 }
@@ -482,13 +478,6 @@ export default function AddMcpSource(props: { · 114 unmodified lines
482478 };
483479 const displayName = remoteIdentity.name.trim() || probe.serverName || probe.name;
484480 const slugNamespace = slugifyNamespace(remoteIdentity.namespace);
485− const placeholderId = slugNamespace || `pending:${crypto.randomUUID()}`;
486− const placeholder = beginAdd({
487− id: placeholderId,
488− name: displayName,
489− kind: "mcp",
490− url: state.url.trim(),
491− });
492481 const exit = await doAdd({
493482 params: { scopeId },
494483 payload: {
@@ -504,11 +493,10 @@ export default function AddMcpSource(props: { · 9 unmodified lines
504493 },
505494 reactivityKeys: sourceWriteKeys,
506495 });
507− placeholder.done();
508496 if (Exit.isFailure(exit)) {
509497 dispatch({
510498 type: "add-fail",
511− error: messageFromExit(exit, "Failed to add source"),
499+ error: errorMessageFromExit(exit, "Failed to add source"),
512500 });
513501 return;
514502 }
@@ -525,7 +513,6 @@ export default function AddMcpSource(props: { · 10 unmodified lines
525513 doAdd,
526514 props,
527515 scopeId,
528− beginAdd,
529516 ]);
530517
531518 // ---- Stdio actions ----
@@ -560,12 +547,6 @@ export default function AddMcpSource(props: { · 28 unmodified lines
560547 setStdioError(null);
561548 const displayName = stdioIdentity.name.trim() || cmd;
562549 const slugNamespace = slugifyNamespace(stdioIdentity.namespace);
563− const placeholderId = slugNamespace || `pending:${crypto.randomUUID()}`;
564− const placeholder = beginAdd({
565− id: placeholderId,
566− name: displayName,
567− kind: "mcp",
568− });
569550 const exit = await doAdd({
570551 params: { scopeId },
571552 payload: {
@@ -578,14 +559,13 @@ export default function AddMcpSource(props: { · 6 unmodified lines
578559 },
579560 reactivityKeys: sourceWriteKeys,
580561 });
581− placeholder.done();
582562 if (Exit.isFailure(exit)) {
583− setStdioError(messageFromExit(exit, "Failed to add source"));
563+ setStdioError(errorMessageFromExit(exit, "Failed to add source"));
584564 setStdioAdding(false);
585565 return;
586566 }
587567 props.onComplete();
588− }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props, beginAdd]);
568+ }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props]);
589569
590570 // ---- Render ----
591571
packages/plugins/mcp/src/react/atoms.ts+29−0
@@ -1,4 +1,7 @@
11 import type { ScopeId } from "@executor-js/sdk/core";
2+import * as Atom from "effect/unstable/reactivity/Atom";
3+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4+import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms";
25 import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
36 import { McpClient } from "./client";
47
@@ -19,6 +22,32 @@ export const mcpSourceAtom = (scopeId: ScopeId, namespace: string) => · 14 unmodified lines
1922
2023 export const probeMcpEndpoint = McpClient.mutation("mcp", "probeEndpoint");
2124 export const addMcpSource = McpClient.mutation("mcp", "addSource");
25+export const addMcpSourceOptimistic = Atom.family((scopeId: ScopeId) =>
26+ sourcesOptimisticAtom(scopeId).pipe(
27+ Atom.optimisticFn({
28+ reducer: (current, arg) =>
29+ AsyncResult.map(current, (rows) => {
30+ const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`;
31+ const source = {
32+ id,
33+ scopeId,
34+ kind: "mcp",
35+ pluginId: "mcp",
36+ name: arg.payload.name ?? id,
37+ ...(arg.payload.transport === "remote" ? { url: arg.payload.endpoint } : {}),
38+ canRemove: false,
39+ canRefresh: false,
40+ canEdit: false,
41+ runtime: false,
42+ };
43+ return [source, ...rows.filter((row) => row.id !== id)].sort((a, b) =>
44+ a.name.localeCompare(b.name),
45+ );
46+ }),
47+ fn: addMcpSource,
48+ }),
49+ ),
50+);
2251 export const removeMcpSource = McpClient.mutation("mcp", "removeSource");
2352 export const refreshMcpSource = McpClient.mutation("mcp", "refreshSource");
2453 export const updateMcpSource = McpClient.mutation("mcp", "updateSource");
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+176−166
@@ -16,7 +16,6 @@ import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/rea · 15 unmodified lines
1616 // state without a refresh.
1717 const addSpecWriteKeys = [...sourceWriteKeys, ...connectionWriteKeys] as const;
1818 const bindingWriteKeys = [...sourceWriteKeys, ...connectionWriteKeys] as const;
19−import { usePendingSources } from "@executor-js/react/api/optimistic";
2019 import { HeadersList } from "@executor-js/react/plugins/headers-list";
2120 import {
2221 HttpCredentialsEditor,
@@ -67,7 +66,7 @@ import { SourceFavicon } from "@executor-js/react/components/source-favicon"; · 44 unmodified lines
6766 import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group";
6867 import { Skeleton } from "@executor-js/react/components/skeleton";
6968 import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";
70−import { addOpenApiSpec, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms";
69+import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms";
7170 import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";
7271 import {
7372 headerBindingSlot,
@@ -83,6 +82,9 @@ import { · 9 unmodified lines
8382 type ServerVariable,
8483 } from "../sdk/types";
8584
85+export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth";
86+export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback";
87+
8688 const ErrorMessage = Schema.Struct({ message: Schema.String });
8789
8890 const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
@@ -94,9 +96,6 @@ const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: strin · 5 unmodified lines
9496 },
9597 );
9698
97−export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth";
98−export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback";
99−
10099 const substituteUrlVariables = (url: string, values: Record<string, string>): string => {
101100 let out = url;
102101 for (const [name, value] of Object.entries(values)) {
@@ -122,15 +121,28 @@ export const openApiOAuthConnectionId = ( · 19 unmodified lines
122121 */
123122 export function resolveOAuthUrl(url: string, baseUrl: string): string {
124123 if (!url) return url;
125− if (URL.canParse(url)) {
124+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs
125+ try {
126+ new URL(url);
126127 return url;
128+ } catch {
129+ if (!baseUrl) return url;
130+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor resolves relative provider metadata URLs
131+ try {
132+ return new URL(url, baseUrl).toString();
133+ } catch {
134+ return url;
135+ }
127136 }
128− if (!baseUrl || !URL.canParse(url, baseUrl)) return url;
129− return new URL(url, baseUrl).toString();
130137 }
131138
132139 export function inferOAuthIssuerUrl(authorizationUrl: string): string | null {
133− return URL.canParse(authorizationUrl) ? new URL(authorizationUrl).origin : null;
140+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs
141+ try {
142+ return new URL(authorizationUrl).origin;
143+ } catch {
144+ return null;
145+ }
134146 }
135147
136148 type StrategySelection =
@@ -246,10 +258,9 @@ export default function AddOpenApiSource(props: { · 109 unmodified lines
246258 const scopeId = useScope();
247259 const userScope = useUserScope();
248260 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" });
249− const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" });
261+ const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), { mode: "promiseExit" });
250262 const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" });
251263 const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promiseExit" });
252− const { beginAdd } = usePendingSources();
253264 const secretList = useSecretPickerSecrets();
254265 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
255266 popupName: OPENAPI_OAUTH_POPUP_NAME,
@@ -281,7 +292,7 @@ export default function AddOpenApiSource(props: { · 25 unmodified lines
281292 selectedServerIndex >= 0 ? (servers[selectedServerIndex] ?? null) : null;
282293
283294 const serverVariables: Record<string, ServerVariable> = selectedServer
284− ? Option.getOrElse(selectedServer.variables, () => ({}))
295+ ? Option.getOrElse(selectedServer.variables, () => ({}) as Record<string, ServerVariable>)
285296 : {};
286297 const serverVariableEntries: Array<[string, ServerVariable]> = Object.entries(serverVariables);
287298
@@ -293,7 +304,10 @@ export default function AddOpenApiSource(props: { · 5 unmodified lines
293304 // Helper used by analyze + server selection: build a default selection map
294305 // from a server's variable defaults.
295306 const defaultSelectionsFor = (server: ServerInfo): Record<string, string> => {
296− const vars: Record<string, ServerVariable> = Option.getOrElse(server.variables, () => ({}));
307+ const vars: Record<string, ServerVariable> = Option.getOrElse(
308+ server.variables,
309+ () => ({}) as Record<string, ServerVariable>,
310+ );
297311 const out: Record<string, string> = {};
298312 for (const [name, v] of Object.entries(vars)) out[name] = v.default;
299313 return out;
@@ -384,33 +398,32 @@ export default function AddOpenApiSource(props: { · 84 unmodified lines
384398 setAnalyzing(false);
385399 return;
386400 }
387−
388401 const result = exit.value;
389402 setPreview(result);
390403
391− const firstServer = result.servers[0];
392− if (firstServer) {
393− setSelectedServerIndex(0);
394− setVariableSelections(defaultSelectionsFor(firstServer));
395− setCustomBaseUrl("");
396− } else {
397− setSelectedServerIndex(-1);
398− setVariableSelections({});
399− setCustomBaseUrl("");
400− }
404+ const firstServer = result.servers[0];
405+ if (firstServer) {
406+ setSelectedServerIndex(0);
407+ setVariableSelections(defaultSelectionsFor(firstServer));
408+ setCustomBaseUrl("");
409+ } else {
410+ setSelectedServerIndex(-1);
411+ setVariableSelections({});
412+ setCustomBaseUrl("");
413+ }
401414
402− const firstPreset = result.headerPresets[0];
403− if (firstPreset) {
404− setStrategy({ kind: "header", presetIndex: 0 });
405− setCustomHeaders(entriesFromSpecPreset(firstPreset));
406− } else {
407− // No header presets — default to "custom" so the headers editor is
408− // visible immediately. Specs with no `security` block (e.g. Microsoft
409− // Graph) would otherwise leave the user staring at just the
410− // Authentication heading with no way to add headers.
411− setStrategy({ kind: "custom" });
412− setCustomHeaders([]);
413− }
415+ const firstPreset = result.headerPresets[0];
416+ if (firstPreset) {
417+ setStrategy({ kind: "header", presetIndex: 0 });
418+ setCustomHeaders(entriesFromSpecPreset(firstPreset));
419+ } else {
420+ // No header presets — default to "custom" so the headers editor is
421+ // visible immediately. Specs with no `security` block (e.g. Microsoft
422+ // Graph) would otherwise leave the user staring at just the
423+ // Authentication heading with no way to add headers.
424+ setStrategy({ kind: "custom" });
425+ setCustomHeaders([]);
426+ }
414427 setAnalyzing(false);
415428 };
416429
@@ -477,59 +490,59 @@ export default function AddOpenApiSource(props: { · 60 unmodified lines
477490 const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl);
478491
479492 if (selectedOAuth2Preset.flow === "clientCredentials") {
480− // RFC 6749 §4.4: no user-interactive consent step. The client_secret
481− // is mandatory; the backend exchanges tokens inline and returns a
482− // completed OAuth2Auth we can attach to the source directly.
483− if (!oauth2ClientSecretSecretId) {
484− setOauth2Error("client_credentials requires a client secret");
485− return;
486− }
487− setStartingOAuth(true);
488− const connectionId = openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow);
489− const startOAuthExit = await doStartOAuth({
490− params: { scopeId },
491− payload: {
492− endpoint: tokenUrl,
493− redirectUrl: tokenUrl,
494− connectionId,
495− tokenScope: scopeId,
496− strategy: {
497− kind: "client-credentials",
498− tokenEndpoint: tokenUrl,
493+ // RFC 6749 §4.4: no user-interactive consent step. The client_secret
494+ // is mandatory; the backend exchanges tokens inline and returns a
495+ // completed OAuth2Auth we can attach to the source directly.
496+ if (!oauth2ClientSecretSecretId) {
497+ setOauth2Error("client_credentials requires a client secret");
498+ return;
499+ }
500+ setStartingOAuth(true);
501+ const connectionId = openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow);
502+ const exit = await doStartOAuth({
503+ params: { scopeId },
504+ payload: {
505+ endpoint: tokenUrl,
506+ redirectUrl: tokenUrl,
507+ connectionId,
508+ tokenScope: scopeId,
509+ strategy: {
510+ kind: "client-credentials",
511+ tokenEndpoint: tokenUrl,
512+ clientIdSecretId: oauth2ClientIdSecretId,
513+ clientSecretSecretId: oauth2ClientSecretSecretId,
514+ scopes: [...oauth2SelectedScopes],
515+ },
516+ pluginId: "openapi",
517+ identityLabel: `${displayName} OAuth`,
518+ },
519+ });
520+ setStartingOAuth(false);
521+ if (Exit.isFailure(exit)) {
522+ setOauth2Error(errorMessageFromExit(exit, "Failed to start OAuth"));
523+ return;
524+ }
525+ const response = exit.value;
526+ if (!response.completedConnection) {
527+ setOauth2Error("client_credentials flow did not mint a connection");
528+ return;
529+ }
530+ setOauth2AuthState({
531+ fingerprint: selectedOAuth2Fingerprint,
532+ auth: new OAuth2Auth({
533+ kind: "oauth2",
534+ connectionId: response.completedConnection.connectionId,
535+ securitySchemeName: selectedOAuth2Preset.securitySchemeName,
536+ flow: "clientCredentials",
537+ tokenUrl,
538+ authorizationUrl: null,
499539 clientIdSecretId: oauth2ClientIdSecretId,
500540 clientSecretSecretId: oauth2ClientSecretSecretId,
501541 scopes: [...oauth2SelectedScopes],
502− },
503− pluginId: "openapi",
504− identityLabel: `${displayName} OAuth`,
505− },
506− });
507− setStartingOAuth(false);
508− if (Exit.isFailure(startOAuthExit)) {
509− setOauth2Error(errorMessageFromExit(startOAuthExit, "Failed to start OAuth"));
510− return;
511− }
512− const response = startOAuthExit.value;
513− if (!response.completedConnection) {
514− setOauth2Error("client_credentials flow did not mint a connection");
542+ }),
543+ });
544+ setOauth2Error(null);
515545 return;
516− }
517− setOauth2AuthState({
518− fingerprint: selectedOAuth2Fingerprint,
519− auth: new OAuth2Auth({
520− kind: "oauth2",
521− connectionId: response.completedConnection.connectionId,
522− securitySchemeName: selectedOAuth2Preset.securitySchemeName,
523− flow: "clientCredentials",
524− tokenUrl,
525− authorizationUrl: null,
526− clientIdSecretId: oauth2ClientIdSecretId,
527− clientSecretSecretId: oauth2ClientSecretSecretId,
528− scopes: [...oauth2SelectedScopes],
529− }),
530− });
531− setOauth2Error(null);
532− return;
533546 }
534547
535548 const authorizationUrl = resolveOAuthUrl(
@@ -537,61 +550,66 @@ export default function AddOpenApiSource(props: { · 1 unmodified line
537550 resolvedBaseUrl,
538551 );
539552 const issuerUrl = inferOAuthIssuerUrl(authorizationUrl);
540− const startOAuthExit = await doStartOAuth({
541− params: { scopeId },
542− payload: {
543− endpoint: authorizationUrl,
544− connectionId: openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow),
545− tokenScope: scopeId,
546− redirectUrl: oauth2RedirectUrl,
547− strategy: {
548− kind: "authorization-code",
549− authorizationEndpoint: authorizationUrl,
550− tokenEndpoint: tokenUrl,
551− issuerUrl,
552− clientIdSecretId: oauth2ClientIdSecretId,
553− clientSecretSecretId: oauth2ClientSecretSecretId ?? null,
554− scopes: [...oauth2SelectedScopes],
555− },
556− pluginId: "openapi",
557− identityLabel: `${displayName} OAuth`,
558− },
559− });
560− if (Exit.isFailure(startOAuthExit)) {
561− setOauth2Error(errorMessageFromExit(startOAuthExit, "Failed to start OAuth"));
562− return;
563− }
564− const response = startOAuthExit.value;
565− if (response.authorizationUrl === null) {
566− setOauth2Error("Unexpected response flow from server");
567− return;
568− }
569553
570554 await oauth.openAuthorization({
571− run: async () => ({
572− sessionId: response.sessionId,
573− authorizationUrl: response.authorizationUrl,
574− }),
575− onSuccess: (result) => {
576− setOauth2AuthState({
577− fingerprint: selectedOAuth2Fingerprint,
578− auth: new OAuth2Auth({
579− kind: "oauth2",
580− connectionId: result.connectionId,
581− securitySchemeName: selectedOAuth2Preset.securitySchemeName,
582− flow: "authorizationCode",
583− tokenUrl,
584− authorizationUrl,
585− issuerUrl,
586− clientIdSecretId: oauth2ClientIdSecretId,
587− clientSecretSecretId: oauth2ClientSecretSecretId,
588− scopes: [...oauth2SelectedScopes],
589− }),
555+ run: async () => {
556+ const exit = await doStartOAuth({
557+ params: { scopeId },
558+ payload: {
559+ endpoint: authorizationUrl,
560+ connectionId: openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow),
561+ tokenScope: scopeId,
562+ redirectUrl: oauth2RedirectUrl,
563+ strategy: {
564+ kind: "authorization-code",
565+ authorizationEndpoint: authorizationUrl,
566+ tokenEndpoint: tokenUrl,
567+ issuerUrl,
568+ clientIdSecretId: oauth2ClientIdSecretId,
569+ clientSecretSecretId: oauth2ClientSecretSecretId ?? null,
570+ scopes: [...oauth2SelectedScopes],
571+ },
572+ pluginId: "openapi",
573+ identityLabel: `${displayName} OAuth`,
574+ },
590575 });
591− setOauth2Error(null);
592− },
593− onError: setOauth2Error,
594− });
576+ if (Exit.isFailure(exit)) {
577+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: OAuth popup API represents start failure by rejecting run()
578+ throw new Error(errorMessageFromExit(exit, "Failed to start OAuth"));
579+ }
580+ const response = exit.value;
581+ if (response.authorizationUrl === null) {
582+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: OAuth popup API represents start failure by rejecting run()
583+ throw new Error("Unexpected response flow from server");
584+ }
585+ return {
586+ sessionId: response.sessionId,
587+ authorizationUrl: response.authorizationUrl,
588+ };
589+ },
590+ onSuccess: (result) => {
591+ setOauth2AuthState({
592+ fingerprint: selectedOAuth2Fingerprint,
593+ auth: new OAuth2Auth({
594+ kind: "oauth2",
595+ connectionId: result.connectionId,
596+ securitySchemeName: selectedOAuth2Preset.securitySchemeName,
597+ flow: "authorizationCode",
598+ tokenUrl,
599+ authorizationUrl,
600+ issuerUrl,
601+ clientIdSecretId: oauth2ClientIdSecretId,
602+ clientSecretSecretId: oauth2ClientSecretSecretId,
603+ scopes: [...oauth2SelectedScopes],
604+ }),
605+ });
606+ setOauth2Error(null);
607+ },
608+ onError: (message) => {
609+ setStartingOAuth(false);
610+ setOauth2Error(message);
611+ },
612+ });
595613 }, [
596614 selectedOAuth2Preset,
597615 oauth2ClientIdSecretId,
@@ -621,24 +639,13 @@ export default function AddOpenApiSource(props: { · 23 unmodified lines
621639 const displayName =
622640 identity.name.trim() ||
623641 (preview ? Option.getOrElse(preview.title, () => namespace) : namespace);
624− const placeholder = beginAdd({
625− id: namespace,
626− name: displayName,
627− kind: "openapi",
628− url: resolvedBaseUrl || undefined,
629− });
630− const failAdd = (message: string) => {
631− setAddError(message);
632− setAdding(false);
633− placeholder.done();
634− };
635− const resultExit = await doAdd({
642+ const exit = await doAdd({
636643 params: { scopeId },
637644 payload: {
638645 spec: specUrl,
639646 specFetchCredentials: serializeHttpCredentials(specFetchCredentials),
640− name: identity.name.trim() || undefined,
641− namespace: slugifyNamespace(identity.namespace) || undefined,
647+ name: displayName,
648+ namespace,
642649 baseUrl: resolvedBaseUrl || undefined,
643650 ...(hasHeaders ? { headers: configuredHeaders } : {}),
644651 ...(Object.keys(serializeHttpCredentials(runtimeCredentials).queryParams).length > 0
@@ -648,13 +655,13 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines
648655 },
649656 reactivityKeys: addSpecWriteKeys,
650657 });
651−
652− if (Exit.isFailure(resultExit)) {
653− failAdd(errorMessageFromExit(resultExit, "Failed to add source"));
658+ if (Exit.isFailure(exit)) {
659+ setAddError(errorMessageFromExit(exit, "Failed to add source"));
660+ setAdding(false);
654661 return;
655662 }
656663
657− const sourceId = resultExit.value.namespace;
664+ const sourceId = exit.value.namespace;
658665 const sourceScope = ScopeId.make(scopeId);
659666 const bindingScope = ScopeId.make(userScope);
660667
@@ -674,13 +681,14 @@ export default function AddOpenApiSource(props: { · 13 unmodified lines
674681 reactivityKeys: bindingWriteKeys,
675682 });
676683 if (Exit.isFailure(bindingExit)) {
677− failAdd(errorMessageFromExit(bindingExit, "Failed to add source"));
684+ setAddError(errorMessageFromExit(bindingExit, "Failed to add source"));
685+ setAdding(false);
678686 return;
679687 }
680688 }
681689
682690 if (configuredOAuth2 && oauth2ClientIdSecretId) {
683− const clientIdBindingExit = await doSetBinding({
691+ const bindingExit = await doSetBinding({
684692 params: { scopeId },
685693 payload: {
686694 sourceId,
@@ -694,14 +702,15 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines
694702 },
695703 reactivityKeys: bindingWriteKeys,
696704 });
697− if (Exit.isFailure(clientIdBindingExit)) {
698− failAdd(errorMessageFromExit(clientIdBindingExit, "Failed to add source"));
705+ if (Exit.isFailure(bindingExit)) {
706+ setAddError(errorMessageFromExit(bindingExit, "Failed to add source"));
707+ setAdding(false);
699708 return;
700709 }
701710 }
702711
703712 if (configuredOAuth2?.clientSecretSlot && oauth2ClientSecretSecretId) {
704− const clientSecretBindingExit = await doSetBinding({
713+ const bindingExit = await doSetBinding({
705714 params: { scopeId },
706715 payload: {
707716 sourceId,
@@ -715,14 +724,15 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines
715724 },
716725 reactivityKeys: bindingWriteKeys,
717726 });
718− if (Exit.isFailure(clientSecretBindingExit)) {
719− failAdd(errorMessageFromExit(clientSecretBindingExit, "Failed to add source"));
727+ if (Exit.isFailure(bindingExit)) {
728+ setAddError(errorMessageFromExit(bindingExit, "Failed to add source"));
729+ setAdding(false);
720730 return;
721731 }
722732 }
723733
724734 if (configuredOAuth2 && oauth2Auth) {
725− const connectionBindingExit = await doSetBinding({
735+ const bindingExit = await doSetBinding({
726736 params: { scopeId },
727737 payload: {
728738 sourceId,
@@ -736,13 +746,13 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines
736746 },
737747 reactivityKeys: bindingWriteKeys,
738748 });
739− if (Exit.isFailure(connectionBindingExit)) {
740− failAdd(errorMessageFromExit(connectionBindingExit, "Failed to add source"));
749+ if (Exit.isFailure(bindingExit)) {
750+ setAddError(errorMessageFromExit(bindingExit, "Failed to add source"));
751+ setAdding(false);
741752 return;
742753 }
743754 }
744755
745− placeholder.done();
746756 props.onComplete();
747757 };
748758
packages/plugins/openapi/src/react/atoms.ts+30−0
@@ -1,4 +1,7 @@
11 import type { ScopeId } from "@executor-js/sdk/core";
2+import * as Atom from "effect/unstable/reactivity/Atom";
3+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4+import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms";
25 import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
36 import { OpenApiClient } from "./client";
47
@@ -32,6 +35,33 @@ export const previewOpenApiSpec = OpenApiClient.mutation("openapi", "previewSpec · 27 unmodified lines
3235
3336 export const addOpenApiSpec = OpenApiClient.mutation("openapi", "addSpec");
3437
38+export const addOpenApiSpecOptimistic = Atom.family((scopeId: ScopeId) =>
39+ sourcesOptimisticAtom(scopeId).pipe(
40+ Atom.optimisticFn({
41+ reducer: (current, arg) =>
42+ AsyncResult.map(current, (rows) => {
43+ const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`;
44+ const source = {
45+ id,
46+ scopeId,
47+ kind: "openapi",
48+ pluginId: "openapi",
49+ name: arg.payload.name ?? id,
50+ ...(arg.payload.baseUrl ? { url: arg.payload.baseUrl } : {}),
51+ canRemove: false,
52+ canRefresh: false,
53+ canEdit: false,
54+ runtime: false,
55+ };
56+ return [source, ...rows.filter((row) => row.id !== id)].sort((a, b) =>
57+ a.name.localeCompare(b.name),
58+ );
59+ }),
60+ fn: addOpenApiSpec,
61+ }),
62+ ),
63+);
64+
3565 export const updateOpenApiSource = OpenApiClient.mutation("openapi", "updateSource");
3666
3767 export const setOpenApiSourceBinding = OpenApiClient.mutation("openapi", "setSourceBinding");
packages/react/src/api/atoms.tsx+47−15
@@ -55,7 +55,10 @@ export const sourcesAtom = (scopeId: ScopeId) => · 54 unmodified lines
5555
5656 /** Single source by id — derived from the sources list */
5757 export const sourceAtom = (sourceId: string, scopeId: ScopeId) =>
58− Atom.mapResult(sourcesAtom(scopeId), (sources) => sources.find((s) => s.id === sourceId) ?? null);
58+ Atom.mapResult(
59+ sourcesOptimisticAtom(scopeId),
60+ (sources) => sources.find((s) => s.id === sourceId) ?? null,
61+ );
5962
6063 export const secretsAtom = (scopeId: ScopeId) =>
6164 ExecutorApiClient.query("secrets", "list", {
@@ -85,25 +88,14 @@ export const secretUsagesAtom = (scopeId: ScopeId, secretId: SecretId) => · 23 unmodified lines
8588 // Refresh whenever any source / connection / secret changes — adding
8689 // an oauth source pulls in a new connection-secret link and we want
8790 // the usage list to reflect it.
88− reactivityKeys: [
89− ReactivityKey.secrets,
90− ReactivityKey.sources,
91− ReactivityKey.connections,
92− ],
91+ reactivityKeys: [ReactivityKey.secrets, ReactivityKey.sources, ReactivityKey.connections],
9392 });
9493
95−export const connectionUsagesAtom = (
96− scopeId: ScopeId,
97− connectionId: ConnectionId,
98−) =>
94+export const connectionUsagesAtom = (scopeId: ScopeId, connectionId: ConnectionId) =>
9995 ExecutorApiClient.query("connections", "usages", {
10096 params: { scopeId, connectionId },
10197 timeToLive: "30 seconds",
102− reactivityKeys: [
103− ReactivityKey.connections,
104− ReactivityKey.sources,
105− ReactivityKey.secrets,
106− ],
98+ reactivityKeys: [ReactivityKey.connections, ReactivityKey.sources, ReactivityKey.secrets],
10799 });
108100
109101 export const policiesAtom = (scopeId: ScopeId) =>
@@ -153,6 +145,46 @@ export const updatePolicy = ExecutorApiClient.mutation("policies", "update"); · 43 unmodified lines
153145
154146 export const removePolicy = ExecutorApiClient.mutation("policies", "remove");
155147
148+// ---------------------------------------------------------------------------
149+// Sources — optimistic surface.
150+// ---------------------------------------------------------------------------
151+
152+export const sourcesOptimisticAtom = Atom.family((scopeId: ScopeId) =>
153+ Atom.optimistic(sourcesAtom(scopeId)),
154+);
155+
156+export const removeSourceOptimistic = Atom.family((scopeId: ScopeId) =>
157+ sourcesOptimisticAtom(scopeId).pipe(
158+ Atom.optimisticFn({
159+ reducer: (current, arg) =>
160+ AsyncResult.map(current, (rows) =>
161+ rows.filter((source) => source.id !== arg.params.sourceId),
162+ ),
163+ fn: removeSource,
164+ }),
165+ ),
166+);
167+
168+// ---------------------------------------------------------------------------
169+// Connections — optimistic removals.
170+// ---------------------------------------------------------------------------
171+
172+export const connectionsOptimisticAtom = Atom.family((scopeId: ScopeId) =>
173+ Atom.optimistic(connectionsAtom(scopeId)),
174+);
175+
176+export const removeConnectionOptimistic = Atom.family((scopeId: ScopeId) =>
177+ connectionsOptimisticAtom(scopeId).pipe(
178+ Atom.optimisticFn({
179+ reducer: (current, arg) =>
180+ AsyncResult.map(current, (rows) =>
181+ rows.filter((connection) => connection.id !== arg.params.connectionId),
182+ ),
183+ fn: removeConnection,
184+ }),
185+ ),
186+);
187+
156188 // ---------------------------------------------------------------------------
157189 // Secrets — optimistic removals.
158190 // ---------------------------------------------------------------------------
packages/react/src/api/optimistic.tsx+0−213
@@ -1,213 +0,0 @@
1−import * as React from "react";
2−import { useAtomSet, useAtomValue } from "@effect/atom-react";
3−import * as Atom from "effect/unstable/reactivity/Atom";
4−import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5−import type { ScopeId } from "@executor-js/sdk";
6−
7−import { connectionsAtom, sourcesAtom } from "./atoms";
8−
9−/**
10− * Client-only optimistic-update layer.
11− *
12− * Mutations that add a row return only after the server roundtrip. The
13− * `pendingResourceAtom` family lets a UI insert a placeholder *before* the
14− * mutation resolves so the sidebar/list updates instantly. Once the server
15− * returns and reactivity refetches the canonical list, callers should remove
16− * the placeholder (the dedupe in `mergePending` filters it anyway, but
17− * explicit removal keeps the pending set bounded on errors).
18− *
19− * Resource keys (`"sources"`, `"secrets"`, …) match the strings in
20− * `ReactivityKey` but are intentionally not coupled — pending state is purely
21− * client-side.
22− */
23−
24−export const PendingResource = {
25− sources: "sources",
26− connectionRemovals: "connection-removals",
27−} as const;
28−
29−export interface PendingEntry<T> {
30− readonly id: string;
31− readonly value: T;
32−}
33−
34−const pendingFamily = Atom.family((_resource: string) =>
35− Atom.make<ReadonlyArray<PendingEntry<unknown>>>([]),
36−);
37−
38−const atomFor = <T,>(resource: string) =>
39− pendingFamily(resource) as ReturnType<typeof pendingFamily> & {
40− readonly __t?: T;
41− };
42−
43−export const usePendingResource = <T,>(resource: string) => {
44− const atom = atomFor<T>(resource);
45− const pending = useAtomValue(atom) as ReadonlyArray<PendingEntry<T>>;
46− const setPending = useAtomSet(atom);
47−
48− const add = React.useCallback(
49− (entry: PendingEntry<T>) =>
50− setPending((prev) => [
51− ...(prev as ReadonlyArray<PendingEntry<T>>).filter((p) => p.id !== entry.id),
52− entry,
53− ]),
54− [setPending],
55− );
56−
57− const remove = React.useCallback(
58− (id: string) =>
59− setPending((prev) => (prev as ReadonlyArray<PendingEntry<T>>).filter((p) => p.id !== id)),
60− [setPending],
61− );
62−
63− const clear = React.useCallback(() => setPending([]), [setPending]);
64−
65− return { pending, add, remove, clear };
66−};
67−
68−/**
69− * Merges pending entries with a server-loaded list. Pending entries whose id
70− * already exists on the server are dropped so we never double-render a row
71− * once the canonical version arrives.
72− */
73−export const mergePending = <T, R>(
74− pending: ReadonlyArray<PendingEntry<T>>,
75− server: ReadonlyArray<R>,
76− serverId: (row: R) => string,
77− fromPending: (entry: PendingEntry<T>) => R,
78−): ReadonlyArray<R> => {
79− if (pending.length === 0) return server;
80− const seen = new Set(server.map(serverId));
81− const extras = pending.filter((p) => !seen.has(p.id)).map(fromPending);
82− if (extras.length === 0) return server;
83− return [...extras, ...server];
84−};
85−
86−// ---------------------------------------------------------------------------
87−// Sources — convenience wrappers used by sidebar/list views and add forms.
88−// Pending entries are global (not per-scope) because the user only sees one
89−// scope at a time; this keeps the add-form code free of scope plumbing.
90−// ---------------------------------------------------------------------------
91−
92−export interface PendingSource {
93− readonly name: string;
94− readonly kind: string;
95− readonly url?: string;
96−}
97−
98−/**
99− * Sidebar/list helper. Reads `sourcesAtom(scopeId)` and merges any pending
100− * placeholder rows in, sorting the combined list by name so the placeholder
101− * lands in the same position the canonical row will occupy — no visual jump
102− * when the server confirms.
103− */
104−export const useSourcesWithPending = (scopeId: ScopeId) => {
105− const result = useAtomValue(sourcesAtom(scopeId));
106− const { pending } = usePendingResource<PendingSource>(PendingResource.sources);
107− return React.useMemo(
108− () =>
109− AsyncResult.map(
110− result,
111− (
112− sources: ReadonlyArray<{
113− readonly id: string;
114− readonly name: string;
115− readonly kind: string;
116− readonly url?: string;
117− }>,
118− ) => {
119− const merged = mergePending(
120− pending,
121− sources,
122− (s: { readonly id: string }) => s.id,
123− (p) => ({
124− id: p.id,
125− name: p.value.name,
126− kind: p.value.kind,
127− url: p.value.url,
128− // The placeholder cannot be removed/refreshed/edited until the
129− // server confirms it, so disable those affordances.
130− canRemove: false,
131− canRefresh: false,
132− canEdit: false,
133− }),
134− );
135− return [...merged].sort((a, b) => a.name.localeCompare(b.name));
136− },
137− ),
138− [result, pending],
139− );
140−};
141−
142−/**
143− * Hook for an add form. Returns helpers to push and clear a pending source
144− * placeholder. On submit:
145− * const placeholder = beginAdd({ id: namespace, name, kind });
146− * try { await doAdd(...); } finally { placeholder.done(); }
147− */
148−export const usePendingSources = () => {
149− const { add, remove } = usePendingResource<PendingSource>(PendingResource.sources);
150− return React.useMemo(
151− () => ({
152− beginAdd: (entry: { id: string } & PendingSource) => {
153− add({ id: entry.id, value: { name: entry.name, kind: entry.kind, url: entry.url } });
154− return { done: () => remove(entry.id) };
155− },
156− }),
157− [add, remove],
158− );
159−};
160−
161−// ---------------------------------------------------------------------------
162−// Connections — optimistic removals.
163−// ---------------------------------------------------------------------------
164−
165−interface PendingConnectionRemoval {
166− readonly id: string;
167−}
168−
169−export const useConnectionsWithPendingRemovals = (scopeId: ScopeId) => {
170− const result = useAtomValue(connectionsAtom(scopeId));
171− const { pending, remove } = usePendingResource<PendingConnectionRemoval>(
172− PendingResource.connectionRemovals,
173− );
174−
175− React.useEffect(() => {
176− if (!AsyncResult.isSuccess(result) || pending.length === 0) return;
177−
178− const serverIds = new Set(
179− result.value.map((connection: { readonly id: string }) => connection.id),
180− );
181− for (const entry of pending) {
182− if (!serverIds.has(entry.id)) remove(entry.id);
183− }
184− }, [result, pending, remove]);
185−
186− return React.useMemo(
187− () =>
188− AsyncResult.map(result, (connections) => {
189− if (pending.length === 0) return connections;
190− const hiddenIds = new Set(pending.map((entry) => entry.id));
191− return connections.filter(
192− (connection: { readonly id: string }) => !hiddenIds.has(connection.id),
193− );
194− }),
195− [result, pending],
196− );
197−};
198−
199−export const usePendingConnectionRemovals = () => {
200− const { add, remove } = usePendingResource<PendingConnectionRemoval>(
201− PendingResource.connectionRemovals,
202− );
203−
204− return React.useMemo(
205− () => ({
206− beginRemove: (id: string) => {
207− add({ id, value: { id } });
208− return { undo: () => remove(id) };
209− },
210− }),
211− [add, remove],
212− );
213−};
packages/react/src/components/command-palette.tsx+2−2
@@ -4,7 +4,7 @@ import { useAtomValue } from "@effect/atom-react"; · 3 unmodified lines
44 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
55 import { PlusIcon } from "lucide-react";
66 import { SourceFavicon } from "./source-favicon";
7−import { sourcesAtom } from "../api/atoms";
7+import { sourcesOptimisticAtom } from "../api/atoms";
88 import { useScope } from "../hooks/use-scope";
99 import { useSourcePlugins } from "@executor-js/sdk/client";
1010 import {
@@ -32,7 +32,7 @@ export function CommandPalette() { · 21 unmodified lines
3232 const [open, setOpen] = useState(false);
3333 const navigate = useNavigate();
3434 const scopeId = useScope();
35− const sourcesResult = useAtomValue(sourcesAtom(scopeId));
35+ const sourcesResult = useAtomValue(sourcesOptimisticAtom(scopeId));
3636
3737 // Toggle with ⌘K / Ctrl+K
3838 useEffect(() => {
packages/react/src/pages/connections.tsx+9−14
@@ -7,8 +7,11 @@ import * as Schema from "effect/Schema"; · 6 unmodified lines
77 import { ConnectionId, ConnectionInUseError, type ScopeId } from "@executor-js/sdk";
88 import { toast } from "sonner";
99
10−import { connectionUsagesAtom, removeConnection } from "../api/atoms";
11−import { useConnectionsWithPendingRemovals, usePendingConnectionRemovals } from "../api/optimistic";
10+import {
11+ connectionUsagesAtom,
12+ connectionsOptimisticAtom,
13+ removeConnectionOptimistic,
14+} from "../api/atoms";
1215 import { connectionWriteKeys } from "../api/reactivity-keys";
1316 import { useScope, useScopeStack } from "../hooks/use-scope";
1417 import { Badge } from "../components/badge";
@@ -62,13 +65,8 @@ const connectionScopeLabel = ( · 47 unmodified lines
6265 // don't get a stray "Used by 0" line before any source binds to them.
6366 // ---------------------------------------------------------------------------
6467
65−function ConnectionUsageFooter(props: {
66− scopeId: ScopeId;
67− connectionId: ConnectionId;
68−}) {
69− const usages = useAtomValue(
70− connectionUsagesAtom(props.scopeId, props.connectionId),
71− );
68+function ConnectionUsageFooter(props: { scopeId: ScopeId; connectionId: ConnectionId }) {
69+ const usages = useAtomValue(connectionUsagesAtom(props.scopeId, props.connectionId));
7270 return AsyncResult.match(usages, {
7371 onInitial: () => null,
7472 onFailure: () => null,
@@ -164,18 +162,15 @@ function ConnectionRow(props: { · 89 unmodified lines
164162 export function ConnectionsPage() {
165163 const scopeId = useScope();
166164 const scopeStack = useScopeStack();
167− const connections = useConnectionsWithPendingRemovals(scopeId);
168− const { beginRemove } = usePendingConnectionRemovals();
169− const doRemove = useAtomSet(removeConnection, { mode: "promiseExit" });
165+ const connections = useAtomValue(connectionsOptimisticAtom(scopeId));
166+ const doRemove = useAtomSet(removeConnectionOptimistic(scopeId), { mode: "promiseExit" });
170167
171168 const handleRemove = async (connectionId: string) => {
172− const pending = beginRemove(connectionId);
173169 const exit = await doRemove({
174170 params: { scopeId, connectionId: ConnectionId.make(connectionId) },
175171 reactivityKeys: connectionWriteKeys,
176172 });
177173 if (Exit.isFailure(exit)) {
178− pending.undo();
179174 const error = Exit.findErrorOption(exit);
180175 if (Option.isSome(error) && isConnectionInUseError(error.value)) {
181176 const count = error.value.usageCount;
packages/react/src/pages/source-detail.tsx+5−5
@@ -1,15 +1,15 @@
11 import { Suspense, useEffect, useMemo, useState } from "react";
22 import { useNavigate } from "@tanstack/react-router";
33 import { useAtomValue, useAtomSet, useAtomRefresh } from "@effect/atom-react";
4−import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
54 import * as Exit from "effect/Exit";
5+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
66 import { effectivePolicyFromSorted } from "@executor-js/sdk";
77 import {
88 policiesOptimisticAtom,
99 sourceToolsAtom,
10− sourcesAtom,
10+ sourcesOptimisticAtom,
1111 sourceAtom,
12− removeSource,
12+ removeSourceOptimistic,
1313 refreshSource,
1414 } from "../api/atoms";
1515 import { sourceWriteKeys } from "../api/reactivity-keys";
@@ -30,9 +30,9 @@ export function SourceDetailPage(props: { namespace: string }) { · 14 unmodified lines
3030 const source = useAtomValue(sourceAtom(namespace, scopeId));
3131 const tools = useAtomValue(sourceToolsAtom(namespace, scopeId));
3232 const policies = useAtomValue(policiesOptimisticAtom(scopeId));
33− const refreshSources = useAtomRefresh(sourcesAtom(scopeId));
33+ const refreshSources = useAtomRefresh(sourcesOptimisticAtom(scopeId));
3434 const refreshTools = useAtomRefresh(sourceToolsAtom(namespace, scopeId));
35− const doRemove = useAtomSet(removeSource, { mode: "promiseExit" });
35+ const doRemove = useAtomSet(removeSourceOptimistic(scopeId), { mode: "promiseExit" });
3636 const doRefresh = useAtomSet(refreshSource, { mode: "promiseExit" });
3737 const policyActions = usePolicyActions(scopeId);
3838 const navigate = useNavigate();
packages/react/src/pages/sources.tsx+7−20
@@ -1,17 +1,12 @@
11 import { Suspense, useCallback, useMemo, useState } from "react";
22 import { Link, useNavigate } from "@tanstack/react-router";
3−import { useAtomSet } from "@effect/atom-react";
3+import { useAtomSet, useAtomValue } from "@effect/atom-react";
44 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
55 import * as Exit from "effect/Exit";
66 import { PlusIcon } from "lucide-react";
77 import type { SourceDetectionResult } from "@executor-js/sdk";
8−import {
9− useSourcePlugins,
10− type SourcePlugin,
11− type SourcePreset,
12−} from "@executor-js/sdk/client";
13−import { detectSource } from "../api/atoms";
14−import { useSourcesWithPending } from "../api/optimistic";
8+import { useSourcePlugins, type SourcePlugin, type SourcePreset } from "@executor-js/sdk/client";
9+import { detectSource, sourcesOptimisticAtom } from "../api/atoms";
1510 import { useScope } from "../hooks/use-scope";
1611 import { McpInstallCard } from "../components/mcp-install-card";
1712 import { Button } from "../components/button";
@@ -61,7 +56,7 @@ const bestDetection = ( · 43 unmodified lines
6156
6257 export function SourcesPage() {
6358 const scopeId = useScope();
64− const sources = useSourcesWithPending(scopeId);
59+ const sources = useAtomValue(sourcesOptimisticAtom(scopeId));
6560 const [connectOpen, setConnectOpen] = useState(false);
6661
6762 return (
@@ -230,10 +225,7 @@ function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => · 162 unmodified lines
230225 className="flex-1"
231226 />
232227 {isUrl && (
233− <Button
234− onClick={() => void handleDetect()}
235− disabled={detecting || !query.trim()}
236− >
228+ <Button onClick={() => void handleDetect()} disabled={detecting || !query.trim()}>
237229 {detecting ? "Detecting..." : "Detect"}
238230 </Button>
239231 )}
@@ -258,11 +250,7 @@ function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => · 18 unmodified lines
258250 </div>
259251 </div>
260252
261− <PresetGrid
262− plugins={sourcePlugins}
263− onPick={closeAndReset}
264− searchQuery={presetSearch}
265− />
253+ <PresetGrid plugins={sourcePlugins} onPick={closeAndReset} searchQuery={presetSearch} />
266254 </div>
267255 </DialogContent>
268256 </Dialog>
@@ -326,8 +314,7 @@ function PresetGrid(props: { · 57 unmodified lines
326314 const q = (props.searchQuery ?? "").trim().toLowerCase();
327315 if (q.length === 0) return allPresets;
328316 return allPresets.filter(({ preset, pluginLabel }) => {
329− const corpus =
330− `${preset.name} ${preset.summary ?? ""} ${pluginLabel}`.toLowerCase();
317+ const corpus = `${preset.name} ${preset.summary ?? ""} ${pluginLabel}`.toLowerCase();
331318 return corpus.includes(q);
332319 });
333320 }, [allPresets, props.searchQuery]);
warden.toml+1−5
@@ -61,17 +61,13 @@ paths = [ · 60 unmodified lines
6161 [[skills]]
6262 name = "wrdn-gha-workflows"
6363 remote = "getsentry/warden-skills"
64−paths = [
65− ".github/workflows/**/*.yml",
66− ".github/workflows/**/*.yaml",
67−]
64+paths = [".github/workflows/**/*.yml", ".github/workflows/**/*.yaml"]
6865
6966 # Local skill — no `remote =`, resolved from `.agents/skills/<name>/SKILL.md`.
7067 [[skills]]
7168 name = "wrdn-effect-atom-optimistic"
7269 paths = [
7370 "packages/react/src/api/atoms.tsx",
74− "packages/react/src/api/optimistic.tsx",
7571 "packages/react/src/pages/**/*.tsx",
7672 "packages/react/src/components/**/*.tsx",
7773 ]

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t