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
11 11
12const isValidEmail = (s: string): boolean => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s);12const isValidEmail = (s: string): boolean => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s);
13 13
14const 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
37const handler = Effect.gen(function* () {14const handler = Effect.gen(function* () {
38 const request = yield* HttpServerRequest.HttpServerRequest;15 const request = yield* HttpServerRequest.HttpServerRequest;
39 16
@@ -55,20 +32,14 @@ const handler = Effect.gen(function* () { · 15 unmodified lines
55 }),32 }),
56 )) as {33 )) as {
57 email?: unknown;34 email?: unknown;
58 name?: unknown;
59 note?: unknown;
60 organization?: unknown;35 organization?: unknown;
61 turnstileToken?: unknown;
62 };36 };
63 37
64 const trimmed = (v: unknown, max: number): string | undefined =>38 const trimmed = (v: unknown, max: number): string | undefined =>
65 typeof v === "string" && v.trim().length > 0 ? v.trim().slice(0, max) : undefined;39 typeof v === "string" && v.trim().length > 0 ? v.trim().slice(0, max) : undefined;
66 40
67 const email = typeof body.email === "string" ? body.email.trim() : "";41 const email = typeof body.email === "string" ? body.email.trim() : "";
68 const name = trimmed(body.name, 200);
69 const note = trimmed(body.note, 2000);
70 const organization = trimmed(body.organization, 200);42 const organization = trimmed(body.organization, 200);
71 const turnstileToken = typeof body.turnstileToken === "string" ? body.turnstileToken : "";
72 43
73 if (!isValidEmail(email)) {44 if (!isValidEmail(email)) {
74 return yield* new HttpResponseError({45 return yield* new HttpResponseError({
@@ -78,28 +49,8 @@ const handler = Effect.gen(function* () { · 3 unmodified lines
78 });49 });
79 }50 }
80 51
81 if (!turnstileToken) {52 // Global rate limit — single shared bucket keyed at "global". Per-IP gating
82 return yield* new HttpResponseError({53 // belongs at the edge (Cloudflare Rules).
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".
103 const limit = yield* Effect.tryPromise({54 const limit = yield* Effect.tryPromise({
104 try: () => env.SLACK_INVITE_LIMITER.limit({ key: "global" }),55 try: () => env.SLACK_INVITE_LIMITER.limit({ key: "global" }),
105 catch: (cause) => ({ success: false as const, fetchError: cause }),56 catch: (cause) => ({ success: false as const, fetchError: cause }),
@@ -114,7 +65,7 @@ const handler = Effect.gen(function* () { · 8 unmodified lines
114 }65 }
115 66
116 const slack = yield* SlackService;67 const slack = yield* SlackService;
117 const { invite } = yield* slack.createConnectInvite({ email, name, note, organization }).pipe(68 const { invite } = yield* slack.createConnectInvite({ email, organization }).pipe(
118 Effect.tapError((err) =>69 Effect.tapError((err) =>
119 Effect.sync(() => {70 Effect.sync(() => {
120 console.error(`[slack] ${err.method} failed:`, err.error);71 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
25 25
26 // Contact / Slack Connect26 // Contact / Slack Connect
27 SLACK_BOT_TOKEN?: string;27 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;
33 // Cloudflare ratelimit binding declared in wrangler.jsonc — caps total28 // Cloudflare ratelimit binding declared in wrangler.jsonc — caps total
34 // Slack-contact channel creations across all callers.29 // Slack-contact channel creations across all callers.
35 SLACK_INVITE_LIMITER: { limit: (input: { key: string }) => Promise<{ success: boolean }> };30 SLACK_INVITE_LIMITER: { limit: (input: { key: string }) => Promise<{ success: boolean }> };
apps/cloud/src/routes/billing_.plans.tsx+3−129
@@ -1,4 +1,4 @@
1import { useEffect, useRef, useState } from "react";1import { useEffect, useState } from "react";
2import { createFileRoute, Link } from "@tanstack/react-router";2import { createFileRoute, Link } from "@tanstack/react-router";
3import { useAuth } from "../web/auth";3import { useAuth } from "../web/auth";
4import { useCustomer, useListPlans } from "autumn-js/react";4import { useCustomer, useListPlans } from "autumn-js/react";
@@ -16,7 +16,6 @@ import { · 11 unmodified lines
16} from "@executor-js/react/components/dialog";16} from "@executor-js/react/components/dialog";
17import { Input } from "@executor-js/react/components/input";17import { Input } from "@executor-js/react/components/input";
18import { Label } from "@executor-js/react/components/label";18import { Label } from "@executor-js/react/components/label";
19import { Textarea } from "@executor-js/react/components/textarea";
20 19
21type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number];20type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number];
22 21
@@ -310,40 +309,27 @@ function SlackContactCta() { · 287 unmodified lines
310 const auth = useAuth();309 const auth = useAuth();
311 const signedIn = auth.status === "authenticated" ? auth : null;310 const signedIn = auth.status === "authenticated" ? auth : null;
312 const prefillEmail = signedIn?.user.email ?? "";311 const prefillEmail = signedIn?.user.email ?? "";
313 const prefillName = signedIn?.user.name ?? "";
314 const orgName = signedIn?.organization?.name ?? "";312 const orgName = signedIn?.organization?.name ?? "";
315 313
316 const turnstileSiteKey = import.meta.env.VITE_PUBLIC_TURNSTILE_SITEKEY ?? "";
317 const [open, setOpen] = useState(false);314 const [open, setOpen] = useState(false);
318 const [email, setEmail] = useState(prefillEmail);315 const [email, setEmail] = useState(prefillEmail);
319 const [name, setName] = useState(prefillName);
320 const [note, setNote] = useState("");
321 const [submitting, setSubmitting] = useState(false);316 const [submitting, setSubmitting] = useState(false);
322 const [error, setError] = useState<string | null>(null);317 const [error, setError] = useState<string | null>(null);
323 const [inviteUrl, setInviteUrl] = useState<string | null>(null);318 const [inviteUrl, setInviteUrl] = useState<string | null>(null);
324 const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
325 319
326 // Hydrate prefill once auth resolves (it starts as `loading` on first render).320 // Hydrate prefill once auth resolves (it starts as `loading` on first render).
327 useEffect(() => {321 useEffect(() => {
328 if (prefillEmail && !email) setEmail(prefillEmail);322 if (prefillEmail && !email) setEmail(prefillEmail);
329 if (prefillName && !name) setName(prefillName);323 }, [prefillEmail]); // eslint-disable-line react-hooks/exhaustive-deps
330 }, [prefillEmail, prefillName]); // eslint-disable-line react-hooks/exhaustive-deps
331 324
332 const reset = () => {325 const reset = () => {
333 setEmail(prefillEmail);326 setEmail(prefillEmail);
334 setName(prefillName);
335 setNote("");
336 setError(null);327 setError(null);
337 setInviteUrl(null);328 setInviteUrl(null);
338 setTurnstileToken(null);
339 };329 };
340 330
341 const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {331 const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
342 e.preventDefault();332 e.preventDefault();
343 if (!turnstileToken) {
344 setError("Please complete the captcha first.");
345 return;
346 }
347 setSubmitting(true);333 setSubmitting(true);
348 setError(null);334 setError(null);
349 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: browser fetch submit path maps network failures to public UI copy335 // 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
353 headers: { "content-type": "application/json" },339 headers: { "content-type": "application/json" },
354 body: JSON.stringify({340 body: JSON.stringify({
355 email,341 email,
356 name: name || undefined,
357 note: note || undefined,
358 organization: orgName || undefined,342 organization: orgName || undefined,
359 turnstileToken,
360 }),343 }),
361 });344 });
362 const data = (await res.json().then(345 const data = (await res.json().then(
@@ -372,9 +355,6 @@ function SlackContactCta() { · 9 unmodified lines
372 setError("Network error. Please try again.");355 setError("Network error. Please try again.");
373 } finally {356 } finally {
374 setSubmitting(false);357 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);
378 }358 }
379 };359 };
380 360
@@ -449,29 +429,6 @@ function SlackContactCta() { · 68 unmodified lines
449 placeholder="you@company.com"429 placeholder="you@company.com"
450 />430 />
451 </div>431 </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 )}
475 {error && <p className="text-sm text-destructive">{error}</p>}432 {error && <p className="text-sm text-destructive">{error}</p>}
476 </div>433 </div>
477 <DialogFooter>434 <DialogFooter>
@@ -480,10 +437,7 @@ function SlackContactCta() { · 2 unmodified lines
480 Cancel437 Cancel
481 </Button>438 </Button>
482 </DialogClose>439 </DialogClose>
483 <Button440 <Button type="submit" disabled={submitting || !email}>
484 type="submit"
485 disabled={submitting || !email || (!!turnstileSiteKey && !turnstileToken)}
486 >
487 {submitting ? "Sending…" : "Send invite"}441 {submitting ? "Sending…" : "Send invite"}
488 </Button>442 </Button>
489 </DialogFooter>443 </DialogFooter>
@@ -515,86 +469,6 @@ function MailIcon({ className }: { className?: string }) { · 25 unmodified lines
515 );469 );
516}470}
517 471
518type 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
532declare global {
533 interface Window {
534 turnstile?: TurnstileApi;
535 }
536}
537
538function 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
598function SlackMark({ className }: { className?: string }) {472function SlackMark({ className }: { className?: string }) {
599 return (473 return (
600 <svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>474 <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
5// ---------------------------------------------------------------------------5// ---------------------------------------------------------------------------
6 6
7import { env } from "cloudflare:workers";7import { env } from "cloudflare:workers";
8import { Context, Data, Effect, Layer, Schema } from "effect";8import { Context, Data, Effect, Layer } from "effect";
9 9
10export class SlackError extends Data.TaggedError("SlackError")<{10export class SlackError extends Data.TaggedError("SlackError")<{
11 method: string;11 method: string;
12 error: string;12 error: string;
13 cause?: unknown;
13}> {}14}> {}
14 15
15export type ISlackService = Readonly<{16export type ISlackService = Readonly<{
16 createConnectInvite: (input: {17 createConnectInvite: (input: {
17 email: string;18 email: string;
18 name?: string;
19 note?: string;
20 organization?: string;19 organization?: string;
21 }) => Effect.Effect<20 }) => Effect.Effect<
22 {21 {
@@ -27,8 +26,8 @@ export type ISlackService = Readonly<{ · 4 unmodified lines
27 >;26 >;
28}>;27}>;
29 28
30const slugifyEmail = (email: string): string =>29const slugify = (s: string): string =>
31 email30 s
32 .toLowerCase()31 .toLowerCase()
33 .replace(/[^a-z0-9]+/g, "-")32 .replace(/[^a-z0-9]+/g, "-")
34 .replace(/^-+|-+$/g, "")33 .replace(/^-+|-+$/g, "")
@@ -40,95 +39,64 @@ const randomSuffix = (): string => { · 5 unmodified lines
40 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");39 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
41};40};
42 41
43const SlackErrorResponse = Schema.Struct({42type SlackResponse = { ok: boolean; error?: string } & Record<string, unknown>;
44 ok: Schema.Literal(false),
45 error: Schema.optional(Schema.String),
46});
47
48const SlackChannel = Schema.Struct({ id: Schema.String, name: Schema.String });
49
50const SlackPostMessageResponse = Schema.Struct({ ok: Schema.Literal(true) });
51
52const SlackCreateChannelResponse = Schema.Struct({
53 ok: Schema.Literal(true),
54 channel: SlackChannel,
55});
56
57const SlackSharedInviteResponse = Schema.Struct({
58 ok: Schema.Literal(true),
59 invite_id: Schema.String,
60 url: Schema.String,
61});
62 43
63const make = Effect.sync(() => {44const make = Effect.sync(() => {
64 const token = env.SLACK_BOT_TOKEN;45 const token = env.SLACK_BOT_TOKEN;
65 46
66 if (!token) {47 if (!token) {
67 const notConfigured = (method: string) =>48 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 );
69 return {52 return {
70 createConnectInvite: () => notConfigured("createConnectInvite"),53 createConnectInvite: () => notConfigured("createConnectInvite"),
71 } satisfies ISlackService;54 } satisfies ISlackService;
72 }55 }
73 56
74 const call = <A extends { ok: true }>(57 const call = <A extends SlackResponse>(method: string, body: Record<string, unknown>) =>
75 method: string,58 Effect.tryPromise({
76 body: Record<string, unknown>,59 try: async (): Promise<A> => {
77 successSchema: Schema.Decoder<A>,60 const res = await fetch(`https://slack.com/api/${method}`, {
78 ) =>61 method: "POST",
79 Effect.gen(function* () {62 headers: {
80 const json = yield* Effect.tryPromise({63 "content-type": "application/json; charset=utf-8",
81 try: async (): Promise<unknown> => {64 authorization: `Bearer ${token}`,
82 const res = await fetch(`https://slack.com/api/${method}`, {65 },
83 method: "POST",66 body: JSON.stringify(body),
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",
115 });67 });
116 }68 const json = (await res.json()) as A;
117 69 return json;
118 return response;70 },
119 }).pipe(Effect.withSpan(`slack.${method}`));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 );
120 85
121 const createConnectInvite: ISlackService["createConnectInvite"] = ({86 const createConnectInvite: ISlackService["createConnectInvite"] = ({
122 email,87 email,
123 name,
124 note,
125 organization,88 organization,
126 }) =>89 }) =>
127 Effect.gen(function* () {90 Effect.gen(function* () {
128 // Slack channel names: lowercase, no spaces, max 80 chars, unique per workspace.91 // Slack channel names: lowercase, no spaces, max 80 chars, unique per
129 const baseName = `shared-${slugifyEmail(email)}`.slice(0, 80);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";
130 const tryCreate = (n: string) =>95 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 );
132 100
133 const created = yield* tryCreate(baseName).pipe(101 const created = yield* tryCreate(baseName).pipe(
134 Effect.catchTag("SlackError", (err) =>102 Effect.catchTag("SlackError", (err) =>
@@ -139,39 +107,27 @@ const make = Effect.sync(() => { · 4 unmodified lines
139 );107 );
140 const channel = created.channel;108 const channel = created.channel;
141 109
142 const helloLines = [110 const invite = yield* call<SlackResponse & { invite_id: string; url: string }>(
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(
159 "conversations.inviteShared",111 "conversations.inviteShared",
160 {112 {
161 channel: channel.id,113 channel: channel.id,
162 emails: [email],114 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,
166 },115 },
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,
168 );122 );
169 123
170 return {124 return {
171 channel: { id: channel.id, name: channel.name },125 channel: { id: channel.id, name: channel.name },
172 invite: { invite_id: invite.invite_id, url: invite.url },126 invite: { invite_id: invite.invite_id, url: invite.url },
173 };127 };
174 }).pipe(Effect.withSpan("slack.createConnectInvite", { attributes: { "slack.email": email } }));128 }).pipe(
129 Effect.withSpan("slack.createConnectInvite", { attributes: { "slack.email": email } }),
130 );
175 131
176 return { createConnectInvite } satisfies ISlackService;132 return { createConnectInvite } satisfies ISlackService;
177});133});
apps/cloud/src/web/shell.tsx+2−2
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react"; · 2 unmodified lines
3import { useAtomValue, useAtomSet } from "@effect/atom-react";3import { useAtomValue, useAtomSet } from "@effect/atom-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import * as Exit from "effect/Exit";5import * as Exit from "effect/Exit";
6import { useSourcesWithPending } from "@executor-js/react/api/optimistic";6import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms";
7import { useScope } from "@executor-js/react/api/scope-context";7import { useScope } from "@executor-js/react/api/scope-context";
8import { Button } from "@executor-js/react/components/button";8import { Button } from "@executor-js/react/components/button";
9import { Skeleton } from "@executor-js/react/components/skeleton";9import { Skeleton } from "@executor-js/react/components/skeleton";
@@ -60,7 +60,7 @@ function NavItem(props: { to: string; label: string; active: boolean; onNavigate · 50 unmodified lines
60 60
61function SourceList(props: { pathname: string; onNavigate?: () => void }) {61function SourceList(props: { pathname: string; onNavigate?: () => void }) {
62 const scopeId = useScope();62 const scopeId = useScope();
63 const sources = useSourcesWithPending(scopeId);63 const sources = useAtomValue(sourcesOptimisticAtom(scopeId));
64 64
65 return AsyncResult.match(sources, {65 return AsyncResult.match(sources, {
66 onInitial: () => (66 onInitial: () => (
apps/cloud/wrangler.jsonc+4−7
@@ -54,15 +54,12 @@ · 53 unmodified lines
54 "vars": {54 "vars": {
55 "VITE_PUBLIC_SITE_URL": "https://executor.sh",55 "VITE_PUBLIC_SITE_URL": "https://executor.sh",
56 "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL",56 "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",
60 },57 },
61 // Cloudflare's `simple` ratelimit only supports 10s or 60s windows — it's58 // 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,59 // burst protection, not a true daily cap. 5/min keeps the worst-case
63 // 5/min keeps the worst-case sustained throughput to ~7k channels/day,60 // sustained throughput to ~7k channels/day, which is bounded enough as a
64 // which is bounded enough as a backstop. For an actual daily ceiling,61 // backstop. For an actual daily ceiling, layer a Durable Object counter
65 // layer a Durable Object counter on top.62 // on top.
66 "ratelimits": [63 "ratelimits": [
67 {64 {
68 "name": "SLACK_INVITE_LIMITER",65 "name": "SLACK_INVITE_LIMITER",
apps/local/src/web/shell.tsx+16−22
@@ -1,10 +1,10 @@
1import { Link, Outlet, useLocation } from "@tanstack/react-router";1import { Link, Outlet, useLocation } from "@tanstack/react-router";
2import { useCallback, useEffect, useRef, useState } from "react";2import { useCallback, useEffect, useRef, useState } from "react";
3import { useAtomRefresh } from "@effect/atom-react";3import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
4import { Data, Effect, Exit } from "effect";4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
5import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";6import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
6import { sourcesAtom, toolsAtom } from "@executor-js/react/api/atoms";7import { sourcesAtom, sourcesOptimisticAtom, toolsAtom } from "@executor-js/react/api/atoms";
7import { useSourcesWithPending } from "@executor-js/react/api/optimistic";
8import { useScope, useScopeInfo } from "@executor-js/react/api/scope-context";8import { useScope, useScopeInfo } from "@executor-js/react/api/scope-context";
9import { Button } from "@executor-js/react/components/button";9import { Button } from "@executor-js/react/components/button";
10import { SourceFavicon } from "@executor-js/react/components/source-favicon";10import { SourceFavicon } from "@executor-js/react/components/source-favicon";
@@ -30,10 +30,6 @@ type UpdateChannel = "latest" | "beta"; · 19 unmodified lines
30 30
31const EXECUTOR_DIST_TAGS_PATH = "/v1/app/npm/dist-tags";31const EXECUTOR_DIST_TAGS_PATH = "/v1/app/npm/dist-tags";
32 32
33class LatestVersionCheckError extends Data.TaggedError("LatestVersionCheckError")<{
34 readonly cause: unknown;
35}> {}
36
37type ParsedVersion = {33type ParsedVersion = {
38 readonly major: number;34 readonly major: number;
39 readonly minor: number;35 readonly minor: number;
@@ -100,22 +96,20 @@ function useLatestVersion(currentVersion: string) { · 60 unmodified lines
100 96
101 useEffect(() => {97 useEffect(() => {
102 let cancelled = false;98 let cancelled = false;
103 99 void Effect.runPromiseExit(
104 const latestVersionEffect = Effect.tryPromise({100 Effect.tryPromise({
105 try: async () => {101 try: async () => {
106 const res = await fetch(EXECUTOR_DIST_TAGS_PATH);102 const res = await fetch(EXECUTOR_DIST_TAGS_PATH);
107 if (!res.ok) return {};103 if (!res.ok) return null;
108 return (await res.json()) as Partial<Record<UpdateChannel, string>>;104 return (await res.json()) as Partial<Record<UpdateChannel, string>>;
109 },105 },
110 catch: (cause) => new LatestVersionCheckError({ cause }),106 catch: (cause) => cause,
111 });107 }),
112 108 ).then((exit) => {
113 void Effect.runPromiseExit(latestVersionEffect).then((exit) => {
114 if (!cancelled && Exit.isSuccess(exit)) {109 if (!cancelled && Exit.isSuccess(exit)) {
115 setLatestVersion(exit.value[channel] ?? null);110 setLatestVersion(exit.value?.[channel] ?? null);
116 }111 }
117 });112 });
118
119 return () => {113 return () => {
120 cancelled = true;114 cancelled = true;
121 };115 };
@@ -273,7 +267,7 @@ function PluginNav(props: { pathname: string; onNavigate?: () => void }) { · 151 unmodified lines
273 267
274function SourceList(props: { pathname: string; onNavigate?: () => void }) {268function SourceList(props: { pathname: string; onNavigate?: () => void }) {
275 const scopeId = useScope();269 const scopeId = useScope();
276 const sources = useSourcesWithPending(scopeId);270 const sources = useAtomValue(sourcesOptimisticAtom(scopeId));
277 271
278 return AsyncResult.match(sources, {272 return AsyncResult.match(sources, {
279 onInitial: () => <div className="px-2.5 py-2 text-xs text-muted-foreground">Loading…</div>,273 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 @@
1import { useCallback, useEffect, useMemo, useRef, useState } from "react";1import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import { useAtomSet } from "@effect/atom-react";2import { useAtomSet } from "@effect/atom-react";
3import { Exit, Option, Schema } from "effect";3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
4 6
5import { usePendingSources } from "@executor-js/react/api/optimistic";
6import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";7import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
7import { useScope } from "@executor-js/react/api/scope-context";8import { useScope } from "@executor-js/react/api/scope-context";
8import type { SecretPickerSecret } from "@executor-js/react/plugins/secret-picker";9import 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
45import { Input } from "@executor-js/react/components/input";46import { Input } from "@executor-js/react/components/input";
46import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group";47import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group";
47import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";48import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";
48import { addGoogleDiscoverySource, probeGoogleDiscovery } from "./atoms";49import { addGoogleDiscoverySourceOptimistic, probeGoogleDiscovery } from "./atoms";
49import { GOOGLE_DISCOVERY_OAUTH_POPUP_NAME, googleDiscoveryOAuthStrategy } from "./oauth";50import { GOOGLE_DISCOVERY_OAUTH_POPUP_NAME, googleDiscoveryOAuthStrategy } from "./oauth";
50import { googleDiscoveryPresets, type GoogleDiscoveryPreset } from "../sdk/presets";51import { googleDiscoveryPresets, type GoogleDiscoveryPreset } from "../sdk/presets";
51 52
53const ErrorMessage = Schema.Struct({ message: Schema.String });
54
55const 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
52type GoogleAuthKind = "none" | "oauth2";64type GoogleAuthKind = "none" | "oauth2";
53 65
54// ---------------------------------------------------------------------------66// ---------------------------------------------------------------------------
@@ -96,50 +108,25 @@ type GoogleDiscoveryTemplate = GoogleDiscoveryPreset & { · 41 unmodified lines
96};108};
97 109
98const GOOGLE_G_ICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";110const GOOGLE_G_ICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";
99const PublicErrorMessage = Schema.Struct({
100 _tag: Schema.Literals([
101 "GoogleDiscoveryParseError",
102 "GoogleDiscoveryOAuthError",
103 "GoogleDiscoverySourceError",
104 ]),
105 message: Schema.String,
106});
107
108const 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};
117 111
118const parseUrlOption = (value: string): URL | null => {112function parseGoogleDiscoveryPreset(preset: GoogleDiscoveryPreset): GoogleDiscoveryTemplate {
119 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor is the platform URL parser113 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes user-provided preset URLs
120 try {114 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 };
122 } catch {127 } catch {
123 return null;
124 }
125};
126
127function parseGoogleDiscoveryPreset(preset: GoogleDiscoveryPreset): GoogleDiscoveryTemplate {
128 const url = parseUrlOption(preset.url);
129 if (!url) {
130 return { ...preset, discoveryUrl: preset.url, service: preset.id, version: "" };128 return { ...preset, discoveryUrl: preset.url, service: preset.id, version: "" };
131 }129 }
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 };
143}130}
144 131
145const GOOGLE_DISCOVERY_TEMPLATES = googleDiscoveryPresets.map(parseGoogleDiscoveryPreset);132const GOOGLE_DISCOVERY_TEMPLATES = googleDiscoveryPresets.map(parseGoogleDiscoveryPreset);
@@ -230,8 +217,9 @@ export default function AddGoogleDiscoverySource(props: { · 84 unmodified lines
230 217
231 const scopeId = useScope();218 const scopeId = useScope();
232 const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promiseExit" });219 const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promiseExit" });
233 const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promiseExit" });220 const doAdd = useAtomSet(addGoogleDiscoverySourceOptimistic(scopeId), {
234 const { beginAdd } = usePendingSources();221 mode: "promiseExit",
222 });
235 const secretList = useSecretPickerSecrets();223 const secretList = useSecretPickerSecrets();
236 const oauth = useOAuthPopupFlow({224 const oauth = useOAuthPopupFlow({
237 popupName: GOOGLE_DISCOVERY_OAUTH_POPUP_NAME,225 popupName: GOOGLE_DISCOVERY_OAUTH_POPUP_NAME,
@@ -269,7 +257,7 @@ export default function AddGoogleDiscoverySource(props: { · 31 unmodified lines
269 if (Exit.isFailure(exit)) {257 if (Exit.isFailure(exit)) {
270 setProbe(null);258 setProbe(null);
271 setLoadingProbe(false);259 setLoadingProbe(false);
272 setError(messageFromExit(exit, "Failed to inspect discovery document"));260 setError(errorMessageFromExit(exit, "Failed to inspect discovery document"));
273 return;261 return;
274 }262 }
275 const result = exit.value;263 const result = exit.value;
@@ -354,11 +342,6 @@ export default function AddGoogleDiscoverySource(props: { · 78 unmodified lines
354 setError(null);342 setError(null);
355 const displayName = identity.name.trim() || probe.name;343 const displayName = identity.name.trim() || probe.name;
356 const namespace = resolvedNamespace;344 const namespace = resolvedNamespace;
357 const placeholder = beginAdd({
358 id: namespace,
359 name: displayName,
360 kind: "google-discovery",
361 });
362 const exit = await doAdd({345 const exit = await doAdd({
363 params: { scopeId },346 params: { scopeId },
364 payload: {347 payload: {
@@ -378,9 +361,8 @@ export default function AddGoogleDiscoverySource(props: { · 13 unmodified lines
378 },361 },
379 reactivityKeys: [...sourceWriteKeys],362 reactivityKeys: [...sourceWriteKeys],
380 });363 });
381 placeholder.done();
382 if (Exit.isFailure(exit)) {364 if (Exit.isFailure(exit)) {
383 setError(messageFromExit(exit, "Failed to add source"));365 setError(errorMessageFromExit(exit, "Failed to add source"));
384 setAdding(false);366 setAdding(false);
385 return;367 return;
386 }368 }
@@ -394,7 +376,6 @@ export default function AddGoogleDiscoverySource(props: { · 7 unmodified lines
394 oauthAuth,376 oauthAuth,
395 props,377 props,
396 scopeId,378 scopeId,
397 beginAdd,
398 resolvedNamespace,379 resolvedNamespace,
399 ]);380 ]);
400 381
packages/plugins/google-discovery/src/react/atoms.ts+29−0
@@ -1,4 +1,7 @@
1import type { ScopeId } from "@executor-js/sdk/core";1import type { ScopeId } from "@executor-js/sdk/core";
2import * as Atom from "effect/unstable/reactivity/Atom";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms";
2import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";5import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
3import { GoogleDiscoveryClient } from "./client";6import { GoogleDiscoveryClient } from "./client";
4 7
@@ -25,6 +28,32 @@ export const addGoogleDiscoverySource = GoogleDiscoveryClient.mutation( · 20 unmodified lines
25 "googleDiscovery",28 "googleDiscovery",
26 "addSource",29 "addSource",
27);30);
31export 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);
28export const updateGoogleDiscoverySource = GoogleDiscoveryClient.mutation(57export const updateGoogleDiscoverySource = GoogleDiscoveryClient.mutation(
29 "googleDiscovery",58 "googleDiscovery",
30 "updateSource",59 "updateSource",
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+18−16
@@ -1,10 +1,11 @@
1import { useCallback, useState } from "react";1import { useCallback, useState } from "react";
2import { useAtomSet } from "@effect/atom-react";2import { useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
4 6
5import { useScope } from "@executor-js/react/api/scope-context";7import { useScope } from "@executor-js/react/api/scope-context";
6import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";8import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
7import { usePendingSources } from "@executor-js/react/api/optimistic";
8import {9import {
9 HttpCredentialsEditor,10 HttpCredentialsEditor,
10 httpCredentialsValid,11 httpCredentialsValid,
@@ -34,10 +35,21 @@ import { · 23 unmodified lines
34import { FloatActions } from "@executor-js/react/components/float-actions";35import { FloatActions } from "@executor-js/react/components/float-actions";
35import { Input } from "@executor-js/react/components/input";36import { Input } from "@executor-js/react/components/input";
36import { Spinner } from "@executor-js/react/components/spinner";37import { Spinner } from "@executor-js/react/components/spinner";
37import { addGraphqlSource } from "./atoms";38import { addGraphqlSourceOptimistic } from "./atoms";
38import { initialGraphqlCredentials } from "./defaults";39import { initialGraphqlCredentials } from "./defaults";
39import type { HeaderValue } from "../sdk/types";40import type { HeaderValue } from "../sdk/types";
40 41
42const ErrorMessage = Schema.Struct({ message: Schema.String });
43
44const 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
41type AuthMode = "none" | "oauth2";53type AuthMode = "none" | "oauth2";
42 54
43export default function AddGraphqlSource(props: {55export default function AddGraphqlSource(props: {
@@ -56,8 +68,7 @@ export default function AddGraphqlSource(props: { · 12 unmodified lines
56 const [tokens, setTokens] = useState<OAuthCompletionPayload | null>(null);68 const [tokens, setTokens] = useState<OAuthCompletionPayload | null>(null);
57 69
58 const scopeId = useScope();70 const scopeId = useScope();
59 const doAdd = useAtomSet(addGraphqlSource, { mode: "promiseExit" });71 const doAdd = useAtomSet(addGraphqlSourceOptimistic(scopeId), { mode: "promiseExit" });
60 const { beginAdd } = usePendingSources();
61 const secretList = useSecretPickerSecrets();72 const secretList = useSecretPickerSecrets();
62 const oauth = useOAuthPopupFlow({73 const oauth = useOAuthPopupFlow({
63 popupName: "graphql-oauth",74 popupName: "graphql-oauth",
@@ -113,18 +124,12 @@ export default function AddGraphqlSource(props: { · 49 unmodified lines
113 const { headers: headerMap, queryParams } = serializeHttpCredentials(credentials);124 const { headers: headerMap, queryParams } = serializeHttpCredentials(credentials);
114 125
115 const { trimmedEndpoint, namespace, displayName } = sourceIdentity();126 const { trimmedEndpoint, namespace, displayName } = sourceIdentity();
116 const placeholder = beginAdd({
117 id: namespace,
118 name: displayName,
119 kind: "graphql",
120 url: trimmedEndpoint || undefined,
121 });
122 const exit = await doAdd({127 const exit = await doAdd({
123 params: { scopeId },128 params: { scopeId },
124 payload: {129 payload: {
125 endpoint: trimmedEndpoint,130 endpoint: trimmedEndpoint,
126 name: identity.name.trim() || undefined,131 name: displayName,
127 namespace: slugifyNamespace(identity.namespace) || undefined,132 namespace,
128 ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}),133 ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}),
129 ...(Object.keys(queryParams).length > 0134 ...(Object.keys(queryParams).length > 0
130 ? { queryParams: queryParams as Record<string, HeaderValue> }135 ? { queryParams: queryParams as Record<string, HeaderValue> }
@@ -140,14 +145,11 @@ export default function AddGraphqlSource(props: { · 9 unmodified lines
140 },145 },
141 reactivityKeys: sourceWriteKeys,146 reactivityKeys: sourceWriteKeys,
142 });147 });
143 placeholder.done();
144
145 if (Exit.isFailure(exit)) {148 if (Exit.isFailure(exit)) {
146 setAddError("Failed to add source");149 setAddError(errorMessageFromExit(exit, "Failed to add source"));
147 setAdding(false);150 setAdding(false);
148 return;151 return;
149 }152 }
150
151 props.onComplete();153 props.onComplete();
152 };154 };
153 155
packages/plugins/graphql/src/react/atoms.ts+30−0
@@ -1,4 +1,7 @@
1import type { ScopeId } from "@executor-js/sdk/core";1import type { ScopeId } from "@executor-js/sdk/core";
2import * as Atom from "effect/unstable/reactivity/Atom";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms";
2import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";5import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
3import { GraphqlClient } from "./client";6import { GraphqlClient } from "./client";
4 7
@@ -19,4 +22,31 @@ export const graphqlSourceAtom = (scopeId: ScopeId, namespace: string) => · 14 unmodified lines
19 22
20export const addGraphqlSource = GraphqlClient.mutation("graphql", "addSource");23export const addGraphqlSource = GraphqlClient.mutation("graphql", "addSource");
21 24
25export 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
22export const updateGraphqlSource = GraphqlClient.mutation("graphql", "updateSource");52export const updateGraphqlSource = GraphqlClient.mutation("graphql", "updateSource");
packages/plugins/mcp/src/react/AddMcpSource.tsx+19−39
@@ -1,6 +1,8 @@
1import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react";1import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
2import { useAtomSet } from "@effect/atom-react";2import { useAtomSet } from "@effect/atom-react";
3import { Exit, Option, Schema } from "effect";3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
4 6
5import { useScope } from "@executor-js/react/api/scope-context";7import { useScope } from "@executor-js/react/api/scope-context";
6import { Button } from "@executor-js/react/components/button";8import { Button } from "@executor-js/react/components/button";
@@ -50,24 +52,19 @@ import { · 43 unmodified lines
50 52
51type RemoteAuthMode = "none" | "header" | "oauth2";53type RemoteAuthMode = "none" | "header" | "oauth2";
52import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";54import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
53import { usePendingSources } from "@executor-js/react/api/optimistic";55import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms";
54import { probeMcpEndpoint, addMcpSource } from "./atoms";
55import { mcpPresets, type McpPreset } from "../sdk/presets";56import { mcpPresets, type McpPreset } from "../sdk/presets";
56 57
57const PublicErrorMessage = Schema.Struct({58const ErrorMessage = Schema.Struct({ message: Schema.String });
58 _tag: Schema.Literals(["McpConnectionError", "McpToolDiscoveryError", "McpOAuthError"]),59
59 message: Schema.String,60const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
60});61 Option.match(
61 62 Option.flatMap(Exit.findErrorOption(exit), Schema.decodeUnknownOption(ErrorMessage)),
62const messageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => {63 {
63 const error = Exit.findErrorOption(exit);64 onNone: () => fallback,
64 if (Option.isNone(error)) return fallback;65 onSome: ({ message }) => message,
65 const errorMessage = Schema.decodeUnknownOption(PublicErrorMessage)(error.value);66 },
66 return Option.match(errorMessage, {67 );
67 onNone: () => fallback,
68 onSome: (value) => value.message,
69 });
70};
71 68
72// ---------------------------------------------------------------------------69// ---------------------------------------------------------------------------
73// Preset lookup70// Preset lookup
@@ -285,8 +282,7 @@ export default function AddMcpSource(props: { · 211 unmodified lines
285 282
286 const scopeId = useScope();283 const scopeId = useScope();
287 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });284 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });
288 const doAdd = useAtomSet(addMcpSource, { mode: "promiseExit" });285 const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), { mode: "promiseExit" });
289 const { beginAdd } = usePendingSources();
290 const secretList = useSecretPickerSecrets();286 const secretList = useSecretPickerSecrets();
291 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({287 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
292 popupName: "mcp-oauth",288 popupName: "mcp-oauth",
@@ -361,7 +357,7 @@ export default function AddMcpSource(props: { · 68 unmodified lines
361 if (Exit.isFailure(exit)) {357 if (Exit.isFailure(exit)) {
362 dispatch({358 dispatch({
363 type: "probe-fail",359 type: "probe-fail",
364 error: messageFromExit(exit, "Failed to connect"),360 error: errorMessageFromExit(exit, "Failed to connect"),
365 });361 });
366 return;362 return;
367 }363 }
@@ -482,13 +478,6 @@ export default function AddMcpSource(props: { · 114 unmodified lines
482 };478 };
483 const displayName = remoteIdentity.name.trim() || probe.serverName || probe.name;479 const displayName = remoteIdentity.name.trim() || probe.serverName || probe.name;
484 const slugNamespace = slugifyNamespace(remoteIdentity.namespace);480 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 });
492 const exit = await doAdd({481 const exit = await doAdd({
493 params: { scopeId },482 params: { scopeId },
494 payload: {483 payload: {
@@ -504,11 +493,10 @@ export default function AddMcpSource(props: { · 9 unmodified lines
504 },493 },
505 reactivityKeys: sourceWriteKeys,494 reactivityKeys: sourceWriteKeys,
506 });495 });
507 placeholder.done();
508 if (Exit.isFailure(exit)) {496 if (Exit.isFailure(exit)) {
509 dispatch({497 dispatch({
510 type: "add-fail",498 type: "add-fail",
511 error: messageFromExit(exit, "Failed to add source"),499 error: errorMessageFromExit(exit, "Failed to add source"),
512 });500 });
513 return;501 return;
514 }502 }
@@ -525,7 +513,6 @@ export default function AddMcpSource(props: { · 10 unmodified lines
525 doAdd,513 doAdd,
526 props,514 props,
527 scopeId,515 scopeId,
528 beginAdd,
529 ]);516 ]);
530 517
531 // ---- Stdio actions ----518 // ---- Stdio actions ----
@@ -560,12 +547,6 @@ export default function AddMcpSource(props: { · 28 unmodified lines
560 setStdioError(null);547 setStdioError(null);
561 const displayName = stdioIdentity.name.trim() || cmd;548 const displayName = stdioIdentity.name.trim() || cmd;
562 const slugNamespace = slugifyNamespace(stdioIdentity.namespace);549 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 });
569 const exit = await doAdd({550 const exit = await doAdd({
570 params: { scopeId },551 params: { scopeId },
571 payload: {552 payload: {
@@ -578,14 +559,13 @@ export default function AddMcpSource(props: { · 6 unmodified lines
578 },559 },
579 reactivityKeys: sourceWriteKeys,560 reactivityKeys: sourceWriteKeys,
580 });561 });
581 placeholder.done();
582 if (Exit.isFailure(exit)) {562 if (Exit.isFailure(exit)) {
583 setStdioError(messageFromExit(exit, "Failed to add source"));563 setStdioError(errorMessageFromExit(exit, "Failed to add source"));
584 setStdioAdding(false);564 setStdioAdding(false);
585 return;565 return;
586 }566 }
587 props.onComplete();567 props.onComplete();
588 }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props, beginAdd]);568 }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props]);
589 569
590 // ---- Render ----570 // ---- Render ----
591 571
packages/plugins/mcp/src/react/atoms.ts+29−0
@@ -1,4 +1,7 @@
1import type { ScopeId } from "@executor-js/sdk/core";1import type { ScopeId } from "@executor-js/sdk/core";
2import * as Atom from "effect/unstable/reactivity/Atom";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms";
2import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";5import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
3import { McpClient } from "./client";6import { McpClient } from "./client";
4 7
@@ -19,6 +22,32 @@ export const mcpSourceAtom = (scopeId: ScopeId, namespace: string) => · 14 unmodified lines
19 22
20export const probeMcpEndpoint = McpClient.mutation("mcp", "probeEndpoint");23export const probeMcpEndpoint = McpClient.mutation("mcp", "probeEndpoint");
21export const addMcpSource = McpClient.mutation("mcp", "addSource");24export const addMcpSource = McpClient.mutation("mcp", "addSource");
25export 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);
22export const removeMcpSource = McpClient.mutation("mcp", "removeSource");51export const removeMcpSource = McpClient.mutation("mcp", "removeSource");
23export const refreshMcpSource = McpClient.mutation("mcp", "refreshSource");52export const refreshMcpSource = McpClient.mutation("mcp", "refreshSource");
24export const updateMcpSource = McpClient.mutation("mcp", "updateSource");53export 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
16// state without a refresh.16// state without a refresh.
17const addSpecWriteKeys = [...sourceWriteKeys, ...connectionWriteKeys] as const;17const addSpecWriteKeys = [...sourceWriteKeys, ...connectionWriteKeys] as const;
18const bindingWriteKeys = [...sourceWriteKeys, ...connectionWriteKeys] as const;18const bindingWriteKeys = [...sourceWriteKeys, ...connectionWriteKeys] as const;
19import { usePendingSources } from "@executor-js/react/api/optimistic";
20import { HeadersList } from "@executor-js/react/plugins/headers-list";19import { HeadersList } from "@executor-js/react/plugins/headers-list";
21import {20import {
22 HttpCredentialsEditor,21 HttpCredentialsEditor,
@@ -67,7 +66,7 @@ import { SourceFavicon } from "@executor-js/react/components/source-favicon"; · 44 unmodified lines
67import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group";66import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group";
68import { Skeleton } from "@executor-js/react/components/skeleton";67import { Skeleton } from "@executor-js/react/components/skeleton";
69import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";68import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";
70import { addOpenApiSpec, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms";69import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms";
71import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";70import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";
72import {71import {
73 headerBindingSlot,72 headerBindingSlot,
@@ -83,6 +82,9 @@ import { · 9 unmodified lines
83 type ServerVariable,82 type ServerVariable,
84} from "../sdk/types";83} from "../sdk/types";
85 84
85export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth";
86export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback";
87
86const ErrorMessage = Schema.Struct({ message: Schema.String });88const ErrorMessage = Schema.Struct({ message: Schema.String });
87 89
88const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>90const 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
94 },96 },
95 );97 );
96 98
97export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth";
98export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback";
99
100const substituteUrlVariables = (url: string, values: Record<string, string>): string => {99const substituteUrlVariables = (url: string, values: Record<string, string>): string => {
101 let out = url;100 let out = url;
102 for (const [name, value] of Object.entries(values)) {101 for (const [name, value] of Object.entries(values)) {
@@ -122,15 +121,28 @@ export const openApiOAuthConnectionId = ( · 19 unmodified lines
122 */121 */
123export function resolveOAuthUrl(url: string, baseUrl: string): string {122export function resolveOAuthUrl(url: string, baseUrl: string): string {
124 if (!url) return url;123 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);
126 return url;127 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 }
127 }136 }
128 if (!baseUrl || !URL.canParse(url, baseUrl)) return url;
129 return new URL(url, baseUrl).toString();
130}137}
131 138
132export function inferOAuthIssuerUrl(authorizationUrl: string): string | null {139export 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 }
134}146}
135 147
136type StrategySelection =148type StrategySelection =
@@ -246,10 +258,9 @@ export default function AddOpenApiSource(props: { · 109 unmodified lines
246 const scopeId = useScope();258 const scopeId = useScope();
247 const userScope = useUserScope();259 const userScope = useUserScope();
248 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" });260 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" });
249 const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" });261 const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), { mode: "promiseExit" });
250 const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" });262 const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" });
251 const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promiseExit" });263 const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promiseExit" });
252 const { beginAdd } = usePendingSources();
253 const secretList = useSecretPickerSecrets();264 const secretList = useSecretPickerSecrets();
254 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({265 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
255 popupName: OPENAPI_OAUTH_POPUP_NAME,266 popupName: OPENAPI_OAUTH_POPUP_NAME,
@@ -281,7 +292,7 @@ export default function AddOpenApiSource(props: { · 25 unmodified lines
281 selectedServerIndex >= 0 ? (servers[selectedServerIndex] ?? null) : null;292 selectedServerIndex >= 0 ? (servers[selectedServerIndex] ?? null) : null;
282 293
283 const serverVariables: Record<string, ServerVariable> = selectedServer294 const serverVariables: Record<string, ServerVariable> = selectedServer
284 ? Option.getOrElse(selectedServer.variables, () => ({}))295 ? Option.getOrElse(selectedServer.variables, () => ({}) as Record<string, ServerVariable>)
285 : {};296 : {};
286 const serverVariableEntries: Array<[string, ServerVariable]> = Object.entries(serverVariables);297 const serverVariableEntries: Array<[string, ServerVariable]> = Object.entries(serverVariables);
287 298
@@ -293,7 +304,10 @@ export default function AddOpenApiSource(props: { · 5 unmodified lines
293 // Helper used by analyze + server selection: build a default selection map304 // Helper used by analyze + server selection: build a default selection map
294 // from a server's variable defaults.305 // from a server's variable defaults.
295 const defaultSelectionsFor = (server: ServerInfo): Record<string, string> => {306 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 );
297 const out: Record<string, string> = {};311 const out: Record<string, string> = {};
298 for (const [name, v] of Object.entries(vars)) out[name] = v.default;312 for (const [name, v] of Object.entries(vars)) out[name] = v.default;
299 return out;313 return out;
@@ -384,33 +398,32 @@ export default function AddOpenApiSource(props: { · 84 unmodified lines
384 setAnalyzing(false);398 setAnalyzing(false);
385 return;399 return;
386 }400 }
387
388 const result = exit.value;401 const result = exit.value;
389 setPreview(result);402 setPreview(result);
390 403
391 const firstServer = result.servers[0];404 const firstServer = result.servers[0];
392 if (firstServer) {405 if (firstServer) {
393 setSelectedServerIndex(0);406 setSelectedServerIndex(0);
394 setVariableSelections(defaultSelectionsFor(firstServer));407 setVariableSelections(defaultSelectionsFor(firstServer));
395 setCustomBaseUrl("");408 setCustomBaseUrl("");
396 } else {409 } else {
397 setSelectedServerIndex(-1);410 setSelectedServerIndex(-1);
398 setVariableSelections({});411 setVariableSelections({});
399 setCustomBaseUrl("");412 setCustomBaseUrl("");
400 }413 }
401 414
402 const firstPreset = result.headerPresets[0];415 const firstPreset = result.headerPresets[0];
403 if (firstPreset) {416 if (firstPreset) {
404 setStrategy({ kind: "header", presetIndex: 0 });417 setStrategy({ kind: "header", presetIndex: 0 });
405 setCustomHeaders(entriesFromSpecPreset(firstPreset));418 setCustomHeaders(entriesFromSpecPreset(firstPreset));
406 } else {419 } else {
407 // No header presets — default to "custom" so the headers editor is420 // No header presets — default to "custom" so the headers editor is
408 // visible immediately. Specs with no `security` block (e.g. Microsoft421 // visible immediately. Specs with no `security` block (e.g. Microsoft
409 // Graph) would otherwise leave the user staring at just the422 // Graph) would otherwise leave the user staring at just the
410 // Authentication heading with no way to add headers.423 // Authentication heading with no way to add headers.
411 setStrategy({ kind: "custom" });424 setStrategy({ kind: "custom" });
412 setCustomHeaders([]);425 setCustomHeaders([]);
413 }426 }
414 setAnalyzing(false);427 setAnalyzing(false);
415 };428 };
416 429
@@ -477,59 +490,59 @@ export default function AddOpenApiSource(props: { · 60 unmodified lines
477 const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl);490 const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl);
478 491
479 if (selectedOAuth2Preset.flow === "clientCredentials") {492 if (selectedOAuth2Preset.flow === "clientCredentials") {
480 // RFC 6749 §4.4: no user-interactive consent step. The client_secret493 // RFC 6749 §4.4: no user-interactive consent step. The client_secret
481 // is mandatory; the backend exchanges tokens inline and returns a494 // is mandatory; the backend exchanges tokens inline and returns a
482 // completed OAuth2Auth we can attach to the source directly.495 // completed OAuth2Auth we can attach to the source directly.
483 if (!oauth2ClientSecretSecretId) {496 if (!oauth2ClientSecretSecretId) {
484 setOauth2Error("client_credentials requires a client secret");497 setOauth2Error("client_credentials requires a client secret");
485 return;498 return;
486 }499 }
487 setStartingOAuth(true);500 setStartingOAuth(true);
488 const connectionId = openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow);501 const connectionId = openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow);
489 const startOAuthExit = await doStartOAuth({502 const exit = await doStartOAuth({
490 params: { scopeId },503 params: { scopeId },
491 payload: {504 payload: {
492 endpoint: tokenUrl,505 endpoint: tokenUrl,
493 redirectUrl: tokenUrl,506 redirectUrl: tokenUrl,
494 connectionId,507 connectionId,
495 tokenScope: scopeId,508 tokenScope: scopeId,
496 strategy: {509 strategy: {
497 kind: "client-credentials",510 kind: "client-credentials",
498 tokenEndpoint: tokenUrl,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,
499 clientIdSecretId: oauth2ClientIdSecretId,539 clientIdSecretId: oauth2ClientIdSecretId,
500 clientSecretSecretId: oauth2ClientSecretSecretId,540 clientSecretSecretId: oauth2ClientSecretSecretId,
501 scopes: [...oauth2SelectedScopes],541 scopes: [...oauth2SelectedScopes],
502 },542 }),
503 pluginId: "openapi",543 });
504 identityLabel: `${displayName} OAuth`,544 setOauth2Error(null);
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");
515 return;545 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;
533 }546 }
534 547
535 const authorizationUrl = resolveOAuthUrl(548 const authorizationUrl = resolveOAuthUrl(
@@ -537,61 +550,66 @@ export default function AddOpenApiSource(props: { · 1 unmodified line
537 resolvedBaseUrl,550 resolvedBaseUrl,
538 );551 );
539 const issuerUrl = inferOAuthIssuerUrl(authorizationUrl);552 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 }
569 553
570 await oauth.openAuthorization({554 await oauth.openAuthorization({
571 run: async () => ({555 run: async () => {
572 sessionId: response.sessionId,556 const exit = await doStartOAuth({
573 authorizationUrl: response.authorizationUrl,557 params: { scopeId },
574 }),558 payload: {
575 onSuccess: (result) => {559 endpoint: authorizationUrl,
576 setOauth2AuthState({560 connectionId: openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow),
577 fingerprint: selectedOAuth2Fingerprint,561 tokenScope: scopeId,
578 auth: new OAuth2Auth({562 redirectUrl: oauth2RedirectUrl,
579 kind: "oauth2",563 strategy: {
580 connectionId: result.connectionId,564 kind: "authorization-code",
581 securitySchemeName: selectedOAuth2Preset.securitySchemeName,565 authorizationEndpoint: authorizationUrl,
582 flow: "authorizationCode",566 tokenEndpoint: tokenUrl,
583 tokenUrl,567 issuerUrl,
584 authorizationUrl,568 clientIdSecretId: oauth2ClientIdSecretId,
585 issuerUrl,569 clientSecretSecretId: oauth2ClientSecretSecretId ?? null,
586 clientIdSecretId: oauth2ClientIdSecretId,570 scopes: [...oauth2SelectedScopes],
587 clientSecretSecretId: oauth2ClientSecretSecretId,571 },
588 scopes: [...oauth2SelectedScopes],572 pluginId: "openapi",
589 }),573 identityLabel: `${displayName} OAuth`,
574 },
590 });575 });
591 setOauth2Error(null);576 if (Exit.isFailure(exit)) {
592 },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()
593 onError: setOauth2Error,578 throw new Error(errorMessageFromExit(exit, "Failed to start OAuth"));
594 });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 });
595 }, [613 }, [
596 selectedOAuth2Preset,614 selectedOAuth2Preset,
597 oauth2ClientIdSecretId,615 oauth2ClientIdSecretId,
@@ -621,24 +639,13 @@ export default function AddOpenApiSource(props: { · 23 unmodified lines
621 const displayName =639 const displayName =
622 identity.name.trim() ||640 identity.name.trim() ||
623 (preview ? Option.getOrElse(preview.title, () => namespace) : namespace);641 (preview ? Option.getOrElse(preview.title, () => namespace) : namespace);
624 const placeholder = beginAdd({642 const exit = await doAdd({
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({
636 params: { scopeId },643 params: { scopeId },
637 payload: {644 payload: {
638 spec: specUrl,645 spec: specUrl,
639 specFetchCredentials: serializeHttpCredentials(specFetchCredentials),646 specFetchCredentials: serializeHttpCredentials(specFetchCredentials),
640 name: identity.name.trim() || undefined,647 name: displayName,
641 namespace: slugifyNamespace(identity.namespace) || undefined,648 namespace,
642 baseUrl: resolvedBaseUrl || undefined,649 baseUrl: resolvedBaseUrl || undefined,
643 ...(hasHeaders ? { headers: configuredHeaders } : {}),650 ...(hasHeaders ? { headers: configuredHeaders } : {}),
644 ...(Object.keys(serializeHttpCredentials(runtimeCredentials).queryParams).length > 0651 ...(Object.keys(serializeHttpCredentials(runtimeCredentials).queryParams).length > 0
@@ -648,13 +655,13 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines
648 },655 },
649 reactivityKeys: addSpecWriteKeys,656 reactivityKeys: addSpecWriteKeys,
650 });657 });
651 658 if (Exit.isFailure(exit)) {
652 if (Exit.isFailure(resultExit)) {659 setAddError(errorMessageFromExit(exit, "Failed to add source"));
653 failAdd(errorMessageFromExit(resultExit, "Failed to add source"));660 setAdding(false);
654 return;661 return;
655 }662 }
656 663
657 const sourceId = resultExit.value.namespace;664 const sourceId = exit.value.namespace;
658 const sourceScope = ScopeId.make(scopeId);665 const sourceScope = ScopeId.make(scopeId);
659 const bindingScope = ScopeId.make(userScope);666 const bindingScope = ScopeId.make(userScope);
660 667
@@ -674,13 +681,14 @@ export default function AddOpenApiSource(props: { · 13 unmodified lines
674 reactivityKeys: bindingWriteKeys,681 reactivityKeys: bindingWriteKeys,
675 });682 });
676 if (Exit.isFailure(bindingExit)) {683 if (Exit.isFailure(bindingExit)) {
677 failAdd(errorMessageFromExit(bindingExit, "Failed to add source"));684 setAddError(errorMessageFromExit(bindingExit, "Failed to add source"));
685 setAdding(false);
678 return;686 return;
679 }687 }
680 }688 }
681 689
682 if (configuredOAuth2 && oauth2ClientIdSecretId) {690 if (configuredOAuth2 && oauth2ClientIdSecretId) {
683 const clientIdBindingExit = await doSetBinding({691 const bindingExit = await doSetBinding({
684 params: { scopeId },692 params: { scopeId },
685 payload: {693 payload: {
686 sourceId,694 sourceId,
@@ -694,14 +702,15 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines
694 },702 },
695 reactivityKeys: bindingWriteKeys,703 reactivityKeys: bindingWriteKeys,
696 });704 });
697 if (Exit.isFailure(clientIdBindingExit)) {705 if (Exit.isFailure(bindingExit)) {
698 failAdd(errorMessageFromExit(clientIdBindingExit, "Failed to add source"));706 setAddError(errorMessageFromExit(bindingExit, "Failed to add source"));
707 setAdding(false);
699 return;708 return;
700 }709 }
701 }710 }
702 711
703 if (configuredOAuth2?.clientSecretSlot && oauth2ClientSecretSecretId) {712 if (configuredOAuth2?.clientSecretSlot && oauth2ClientSecretSecretId) {
704 const clientSecretBindingExit = await doSetBinding({713 const bindingExit = await doSetBinding({
705 params: { scopeId },714 params: { scopeId },
706 payload: {715 payload: {
707 sourceId,716 sourceId,
@@ -715,14 +724,15 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines
715 },724 },
716 reactivityKeys: bindingWriteKeys,725 reactivityKeys: bindingWriteKeys,
717 });726 });
718 if (Exit.isFailure(clientSecretBindingExit)) {727 if (Exit.isFailure(bindingExit)) {
719 failAdd(errorMessageFromExit(clientSecretBindingExit, "Failed to add source"));728 setAddError(errorMessageFromExit(bindingExit, "Failed to add source"));
729 setAdding(false);
720 return;730 return;
721 }731 }
722 }732 }
723 733
724 if (configuredOAuth2 && oauth2Auth) {734 if (configuredOAuth2 && oauth2Auth) {
725 const connectionBindingExit = await doSetBinding({735 const bindingExit = await doSetBinding({
726 params: { scopeId },736 params: { scopeId },
727 payload: {737 payload: {
728 sourceId,738 sourceId,
@@ -736,13 +746,13 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines
736 },746 },
737 reactivityKeys: bindingWriteKeys,747 reactivityKeys: bindingWriteKeys,
738 });748 });
739 if (Exit.isFailure(connectionBindingExit)) {749 if (Exit.isFailure(bindingExit)) {
740 failAdd(errorMessageFromExit(connectionBindingExit, "Failed to add source"));750 setAddError(errorMessageFromExit(bindingExit, "Failed to add source"));
751 setAdding(false);
741 return;752 return;
742 }753 }
743 }754 }
744 755
745 placeholder.done();
746 props.onComplete();756 props.onComplete();
747 };757 };
748 758
packages/plugins/openapi/src/react/atoms.ts+30−0
@@ -1,4 +1,7 @@
1import type { ScopeId } from "@executor-js/sdk/core";1import type { ScopeId } from "@executor-js/sdk/core";
2import * as Atom from "effect/unstable/reactivity/Atom";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms";
2import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";5import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
3import { OpenApiClient } from "./client";6import { OpenApiClient } from "./client";
4 7
@@ -32,6 +35,33 @@ export const previewOpenApiSpec = OpenApiClient.mutation("openapi", "previewSpec · 27 unmodified lines
32 35
33export const addOpenApiSpec = OpenApiClient.mutation("openapi", "addSpec");36export const addOpenApiSpec = OpenApiClient.mutation("openapi", "addSpec");
34 37
38export 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
35export const updateOpenApiSource = OpenApiClient.mutation("openapi", "updateSource");65export const updateOpenApiSource = OpenApiClient.mutation("openapi", "updateSource");
36 66
37export const setOpenApiSourceBinding = OpenApiClient.mutation("openapi", "setSourceBinding");67export 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
55 55
56/** Single source by id — derived from the sources list */56/** Single source by id — derived from the sources list */
57export const sourceAtom = (sourceId: string, scopeId: ScopeId) =>57export 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 );
59 62
60export const secretsAtom = (scopeId: ScopeId) =>63export const secretsAtom = (scopeId: ScopeId) =>
61 ExecutorApiClient.query("secrets", "list", {64 ExecutorApiClient.query("secrets", "list", {
@@ -85,25 +88,14 @@ export const secretUsagesAtom = (scopeId: ScopeId, secretId: SecretId) => · 23 unmodified lines
85 // Refresh whenever any source / connection / secret changes — adding88 // Refresh whenever any source / connection / secret changes — adding
86 // an oauth source pulls in a new connection-secret link and we want89 // an oauth source pulls in a new connection-secret link and we want
87 // the usage list to reflect it.90 // the usage list to reflect it.
88 reactivityKeys: [91 reactivityKeys: [ReactivityKey.secrets, ReactivityKey.sources, ReactivityKey.connections],
89 ReactivityKey.secrets,
90 ReactivityKey.sources,
91 ReactivityKey.connections,
92 ],
93 });92 });
94 93
95export const connectionUsagesAtom = (94export const connectionUsagesAtom = (scopeId: ScopeId, connectionId: ConnectionId) =>
96 scopeId: ScopeId,
97 connectionId: ConnectionId,
98) =>
99 ExecutorApiClient.query("connections", "usages", {95 ExecutorApiClient.query("connections", "usages", {
100 params: { scopeId, connectionId },96 params: { scopeId, connectionId },
101 timeToLive: "30 seconds",97 timeToLive: "30 seconds",
102 reactivityKeys: [98 reactivityKeys: [ReactivityKey.connections, ReactivityKey.sources, ReactivityKey.secrets],
103 ReactivityKey.connections,
104 ReactivityKey.sources,
105 ReactivityKey.secrets,
106 ],
107 });99 });
108 100
109export const policiesAtom = (scopeId: ScopeId) =>101export const policiesAtom = (scopeId: ScopeId) =>
@@ -153,6 +145,46 @@ export const updatePolicy = ExecutorApiClient.mutation("policies", "update"); · 43 unmodified lines
153 145
154export const removePolicy = ExecutorApiClient.mutation("policies", "remove");146export const removePolicy = ExecutorApiClient.mutation("policies", "remove");
155 147
148// ---------------------------------------------------------------------------
149// Sources — optimistic surface.
150// ---------------------------------------------------------------------------
151
152export const sourcesOptimisticAtom = Atom.family((scopeId: ScopeId) =>
153 Atom.optimistic(sourcesAtom(scopeId)),
154);
155
156export 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
172export const connectionsOptimisticAtom = Atom.family((scopeId: ScopeId) =>
173 Atom.optimistic(connectionsAtom(scopeId)),
174);
175
176export 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
156// ---------------------------------------------------------------------------188// ---------------------------------------------------------------------------
157// Secrets — optimistic removals.189// Secrets — optimistic removals.
158// ---------------------------------------------------------------------------190// ---------------------------------------------------------------------------
packages/react/src/api/optimistic.tsx+0−213
@@ -1,213 +0,0 @@
1import * as React from "react";
2import { useAtomSet, useAtomValue } from "@effect/atom-react";
3import * as Atom from "effect/unstable/reactivity/Atom";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import type { ScopeId } from "@executor-js/sdk";
6
7import { 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
24export const PendingResource = {
25 sources: "sources",
26 connectionRemovals: "connection-removals",
27} as const;
28
29export interface PendingEntry<T> {
30 readonly id: string;
31 readonly value: T;
32}
33
34const pendingFamily = Atom.family((_resource: string) =>
35 Atom.make<ReadonlyArray<PendingEntry<unknown>>>([]),
36);
37
38const atomFor = <T,>(resource: string) =>
39 pendingFamily(resource) as ReturnType<typeof pendingFamily> & {
40 readonly __t?: T;
41 };
42
43export 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 */
73export 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
92export 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 */
104export 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 */
148export 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
165interface PendingConnectionRemoval {
166 readonly id: string;
167}
168
169export 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
199export 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
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import { PlusIcon } from "lucide-react";5import { PlusIcon } from "lucide-react";
6import { SourceFavicon } from "./source-favicon";6import { SourceFavicon } from "./source-favicon";
7import { sourcesAtom } from "../api/atoms";7import { sourcesOptimisticAtom } from "../api/atoms";
8import { useScope } from "../hooks/use-scope";8import { useScope } from "../hooks/use-scope";
9import { useSourcePlugins } from "@executor-js/sdk/client";9import { useSourcePlugins } from "@executor-js/sdk/client";
10import {10import {
@@ -32,7 +32,7 @@ export function CommandPalette() { · 21 unmodified lines
32 const [open, setOpen] = useState(false);32 const [open, setOpen] = useState(false);
33 const navigate = useNavigate();33 const navigate = useNavigate();
34 const scopeId = useScope();34 const scopeId = useScope();
35 const sourcesResult = useAtomValue(sourcesAtom(scopeId));35 const sourcesResult = useAtomValue(sourcesOptimisticAtom(scopeId));
36 36
37 // Toggle with ⌘K / Ctrl+K37 // Toggle with ⌘K / Ctrl+K
38 useEffect(() => {38 useEffect(() => {
packages/react/src/pages/connections.tsx+9−14
@@ -7,8 +7,11 @@ import * as Schema from "effect/Schema"; · 6 unmodified lines
7import { ConnectionId, ConnectionInUseError, type ScopeId } from "@executor-js/sdk";7import { ConnectionId, ConnectionInUseError, type ScopeId } from "@executor-js/sdk";
8import { toast } from "sonner";8import { toast } from "sonner";
9 9
10import { connectionUsagesAtom, removeConnection } from "../api/atoms";10import {
11import { useConnectionsWithPendingRemovals, usePendingConnectionRemovals } from "../api/optimistic";11 connectionUsagesAtom,
12 connectionsOptimisticAtom,
13 removeConnectionOptimistic,
14} from "../api/atoms";
12import { connectionWriteKeys } from "../api/reactivity-keys";15import { connectionWriteKeys } from "../api/reactivity-keys";
13import { useScope, useScopeStack } from "../hooks/use-scope";16import { useScope, useScopeStack } from "../hooks/use-scope";
14import { Badge } from "../components/badge";17import { Badge } from "../components/badge";
@@ -62,13 +65,8 @@ const connectionScopeLabel = ( · 47 unmodified lines
62// don't get a stray "Used by 0" line before any source binds to them.65// don't get a stray "Used by 0" line before any source binds to them.
63// ---------------------------------------------------------------------------66// ---------------------------------------------------------------------------
64 67
65function ConnectionUsageFooter(props: {68function ConnectionUsageFooter(props: { scopeId: ScopeId; connectionId: ConnectionId }) {
66 scopeId: ScopeId;69 const usages = useAtomValue(connectionUsagesAtom(props.scopeId, props.connectionId));
67 connectionId: ConnectionId;
68}) {
69 const usages = useAtomValue(
70 connectionUsagesAtom(props.scopeId, props.connectionId),
71 );
72 return AsyncResult.match(usages, {70 return AsyncResult.match(usages, {
73 onInitial: () => null,71 onInitial: () => null,
74 onFailure: () => null,72 onFailure: () => null,
@@ -164,18 +162,15 @@ function ConnectionRow(props: { · 89 unmodified lines
164export function ConnectionsPage() {162export function ConnectionsPage() {
165 const scopeId = useScope();163 const scopeId = useScope();
166 const scopeStack = useScopeStack();164 const scopeStack = useScopeStack();
167 const connections = useConnectionsWithPendingRemovals(scopeId);165 const connections = useAtomValue(connectionsOptimisticAtom(scopeId));
168 const { beginRemove } = usePendingConnectionRemovals();166 const doRemove = useAtomSet(removeConnectionOptimistic(scopeId), { mode: "promiseExit" });
169 const doRemove = useAtomSet(removeConnection, { mode: "promiseExit" });
170 167
171 const handleRemove = async (connectionId: string) => {168 const handleRemove = async (connectionId: string) => {
172 const pending = beginRemove(connectionId);
173 const exit = await doRemove({169 const exit = await doRemove({
174 params: { scopeId, connectionId: ConnectionId.make(connectionId) },170 params: { scopeId, connectionId: ConnectionId.make(connectionId) },
175 reactivityKeys: connectionWriteKeys,171 reactivityKeys: connectionWriteKeys,
176 });172 });
177 if (Exit.isFailure(exit)) {173 if (Exit.isFailure(exit)) {
178 pending.undo();
179 const error = Exit.findErrorOption(exit);174 const error = Exit.findErrorOption(exit);
180 if (Option.isSome(error) && isConnectionInUseError(error.value)) {175 if (Option.isSome(error) && isConnectionInUseError(error.value)) {
181 const count = error.value.usageCount;176 const count = error.value.usageCount;
packages/react/src/pages/source-detail.tsx+5−5
@@ -1,15 +1,15 @@
1import { Suspense, useEffect, useMemo, useState } from "react";1import { Suspense, useEffect, useMemo, useState } from "react";
2import { useNavigate } from "@tanstack/react-router";2import { useNavigate } from "@tanstack/react-router";
3import { useAtomValue, useAtomSet, useAtomRefresh } from "@effect/atom-react";3import { useAtomValue, useAtomSet, useAtomRefresh } from "@effect/atom-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import * as Exit from "effect/Exit";4import * as Exit from "effect/Exit";
5import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
6import { effectivePolicyFromSorted } from "@executor-js/sdk";6import { effectivePolicyFromSorted } from "@executor-js/sdk";
7import {7import {
8 policiesOptimisticAtom,8 policiesOptimisticAtom,
9 sourceToolsAtom,9 sourceToolsAtom,
10 sourcesAtom,10 sourcesOptimisticAtom,
11 sourceAtom,11 sourceAtom,
12 removeSource,12 removeSourceOptimistic,
13 refreshSource,13 refreshSource,
14} from "../api/atoms";14} from "../api/atoms";
15import { sourceWriteKeys } from "../api/reactivity-keys";15import { sourceWriteKeys } from "../api/reactivity-keys";
@@ -30,9 +30,9 @@ export function SourceDetailPage(props: { namespace: string }) { · 14 unmodified lines
30 const source = useAtomValue(sourceAtom(namespace, scopeId));30 const source = useAtomValue(sourceAtom(namespace, scopeId));
31 const tools = useAtomValue(sourceToolsAtom(namespace, scopeId));31 const tools = useAtomValue(sourceToolsAtom(namespace, scopeId));
32 const policies = useAtomValue(policiesOptimisticAtom(scopeId));32 const policies = useAtomValue(policiesOptimisticAtom(scopeId));
33 const refreshSources = useAtomRefresh(sourcesAtom(scopeId));33 const refreshSources = useAtomRefresh(sourcesOptimisticAtom(scopeId));
34 const refreshTools = useAtomRefresh(sourceToolsAtom(namespace, scopeId));34 const refreshTools = useAtomRefresh(sourceToolsAtom(namespace, scopeId));
35 const doRemove = useAtomSet(removeSource, { mode: "promiseExit" });35 const doRemove = useAtomSet(removeSourceOptimistic(scopeId), { mode: "promiseExit" });
36 const doRefresh = useAtomSet(refreshSource, { mode: "promiseExit" });36 const doRefresh = useAtomSet(refreshSource, { mode: "promiseExit" });
37 const policyActions = usePolicyActions(scopeId);37 const policyActions = usePolicyActions(scopeId);
38 const navigate = useNavigate();38 const navigate = useNavigate();
packages/react/src/pages/sources.tsx+7−20
@@ -1,17 +1,12 @@
1import { Suspense, useCallback, useMemo, useState } from "react";1import { Suspense, useCallback, useMemo, useState } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";2import { Link, useNavigate } from "@tanstack/react-router";
3import { useAtomSet } from "@effect/atom-react";3import { useAtomSet, useAtomValue } from "@effect/atom-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import * as Exit from "effect/Exit";5import * as Exit from "effect/Exit";
6import { PlusIcon } from "lucide-react";6import { PlusIcon } from "lucide-react";
7import type { SourceDetectionResult } from "@executor-js/sdk";7import type { SourceDetectionResult } from "@executor-js/sdk";
8import {8import { useSourcePlugins, type SourcePlugin, type SourcePreset } from "@executor-js/sdk/client";
9 useSourcePlugins,9import { detectSource, sourcesOptimisticAtom } from "../api/atoms";
10 type SourcePlugin,
11 type SourcePreset,
12} from "@executor-js/sdk/client";
13import { detectSource } from "../api/atoms";
14import { useSourcesWithPending } from "../api/optimistic";
15import { useScope } from "../hooks/use-scope";10import { useScope } from "../hooks/use-scope";
16import { McpInstallCard } from "../components/mcp-install-card";11import { McpInstallCard } from "../components/mcp-install-card";
17import { Button } from "../components/button";12import { Button } from "../components/button";
@@ -61,7 +56,7 @@ const bestDetection = ( · 43 unmodified lines
61 56
62export function SourcesPage() {57export function SourcesPage() {
63 const scopeId = useScope();58 const scopeId = useScope();
64 const sources = useSourcesWithPending(scopeId);59 const sources = useAtomValue(sourcesOptimisticAtom(scopeId));
65 const [connectOpen, setConnectOpen] = useState(false);60 const [connectOpen, setConnectOpen] = useState(false);
66 61
67 return (62 return (
@@ -230,10 +225,7 @@ function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => · 162 unmodified lines
230 className="flex-1"225 className="flex-1"
231 />226 />
232 {isUrl && (227 {isUrl && (
233 <Button228 <Button onClick={() => void handleDetect()} disabled={detecting || !query.trim()}>
234 onClick={() => void handleDetect()}
235 disabled={detecting || !query.trim()}
236 >
237 {detecting ? "Detecting..." : "Detect"}229 {detecting ? "Detecting..." : "Detect"}
238 </Button>230 </Button>
239 )}231 )}
@@ -258,11 +250,7 @@ function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => · 18 unmodified lines
258 </div>250 </div>
259 </div>251 </div>
260 252
261 <PresetGrid253 <PresetGrid plugins={sourcePlugins} onPick={closeAndReset} searchQuery={presetSearch} />
262 plugins={sourcePlugins}
263 onPick={closeAndReset}
264 searchQuery={presetSearch}
265 />
266 </div>254 </div>
267 </DialogContent>255 </DialogContent>
268 </Dialog>256 </Dialog>
@@ -326,8 +314,7 @@ function PresetGrid(props: { · 57 unmodified lines
326 const q = (props.searchQuery ?? "").trim().toLowerCase();314 const q = (props.searchQuery ?? "").trim().toLowerCase();
327 if (q.length === 0) return allPresets;315 if (q.length === 0) return allPresets;
328 return allPresets.filter(({ preset, pluginLabel }) => {316 return allPresets.filter(({ preset, pluginLabel }) => {
329 const corpus =317 const corpus = `${preset.name} ${preset.summary ?? ""} ${pluginLabel}`.toLowerCase();
330 `${preset.name} ${preset.summary ?? ""} ${pluginLabel}`.toLowerCase();
331 return corpus.includes(q);318 return corpus.includes(q);
332 });319 });
333 }, [allPresets, props.searchQuery]);320 }, [allPresets, props.searchQuery]);
warden.toml+1−5
@@ -61,17 +61,13 @@ paths = [ · 60 unmodified lines
61[[skills]]61[[skills]]
62name = "wrdn-gha-workflows"62name = "wrdn-gha-workflows"
63remote = "getsentry/warden-skills"63remote = "getsentry/warden-skills"
64paths = [64paths = [".github/workflows/**/*.yml", ".github/workflows/**/*.yaml"]
65 ".github/workflows/**/*.yml",
66 ".github/workflows/**/*.yaml",
67]
68 65
69# Local skill — no `remote =`, resolved from `.agents/skills/<name>/SKILL.md`.66# Local skill — no `remote =`, resolved from `.agents/skills/<name>/SKILL.md`.
70[[skills]]67[[skills]]
71name = "wrdn-effect-atom-optimistic"68name = "wrdn-effect-atom-optimistic"
72paths = [69paths = [
73 "packages/react/src/api/atoms.tsx",70 "packages/react/src/api/atoms.tsx",
74 "packages/react/src/api/optimistic.tsx",
75 "packages/react/src/pages/**/*.tsx",71 "packages/react/src/pages/**/*.tsx",
76 "packages/react/src/components/**/*.tsx",72 "packages/react/src/components/**/*.tsx",
77]73]

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